From 3da10ce58c411ecfe6443324f6bb35976817ea21 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Wed, 27 May 2026 13:14:59 +0300 Subject: [PATCH] feat(runtime/knowledge): SQLite knowledge base with starter intel + lessons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-server state//knowledge.db. Schema: - recipes (seeded from docs/minecraft-recipes.json, 38 rows) - mob_intel (15 mobs incl. creeper/zombie/skeleton with verdict_no_weapon) - block_intel (30 blocks with required_tool / drops) - lessons (12 starter rules — "don't attack creepers with fists", etc.) - deaths / postmortems / poi / wiki_pages / chat_log / code_changes Public API in runtime/knowledge/index.js: initKnowledge, recall, record, markApplied, topAdvice, lookupRecipe/Mob/Block, insertDeath, insertPostmortem, recordPOI, poiNearby, logChat. All operations gracefully no-op when better-sqlite3 is unavailable. 10 tests; covers init, seeding, recall filters, lesson lifecycle, death/PM round-trip, spatial POI queries, chat log. Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 31 ++- package.json | 4 +- runtime/knowledge/index.js | 233 ++++++++++++++++++++++ runtime/knowledge/knowledge.test.js | 224 +++++++++++++++++++++ runtime/knowledge/lessons.js | 178 +++++++++++++++++ runtime/knowledge/schema.sql | 181 +++++++++++++++++ runtime/knowledge/seed.js | 296 ++++++++++++++++++++++++++++ runtime/knowledge/store.js | 134 +++++++++++++ 8 files changed, 1277 insertions(+), 4 deletions(-) create mode 100644 runtime/knowledge/index.js create mode 100644 runtime/knowledge/knowledge.test.js create mode 100644 runtime/knowledge/lessons.js create mode 100644 runtime/knowledge/schema.sql create mode 100644 runtime/knowledge/seed.js create mode 100644 runtime/knowledge/store.js diff --git a/package-lock.json b/package-lock.json index 21b8448..8e38e3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "pepa-pi-bot", - "version": "0.1.0", + "version": "0.2.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pepa-pi-bot", - "version": "0.1.0", + "version": "0.2.0-rc.1", "license": "MIT", "dependencies": { + "better-sqlite3": "^11.10.0", "canvas": "^3.2.3", "dotenv": "^16.4.5", "ink": "^7.0.4", @@ -742,6 +743,26 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -1560,6 +1581,12 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", diff --git a/package.json b/package.json index 412442a..ce8075c 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,10 @@ "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/skills/recovery-tunnel-out.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.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 runtime/critic.test.js runtime/skill-library.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" + "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.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 runtime/critic.test.js runtime/skill-library.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/knowledge/knowledge.test.js runtime/coach/postmortem.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" }, "dependencies": { + "better-sqlite3": "^11.10.0", "canvas": "^3.2.3", "dotenv": "^16.4.5", "ink": "^7.0.4", @@ -31,7 +32,6 @@ "mineflayer-pathfinder": "^2.4.5", "mineflayer-pvp": "^1.3.2", "mineflayer-tool": "^1.2.0", - "better-sqlite3": "^11.3.0", "prismarine-item": "^1.18.0", "prismarine-viewer": "^1.33.0", "react": "^19.2.6", diff --git a/runtime/knowledge/index.js b/runtime/knowledge/index.js new file mode 100644 index 0000000..94518b2 --- /dev/null +++ b/runtime/knowledge/index.js @@ -0,0 +1,233 @@ +// Public surface of the knowledge subsystem. Other runtime modules should +// import from here, not from store/seed/lessons directly. +// +// Wire-up: +// await initKnowledge({ stateDir }) +// - opens the SQLite DB at state//knowledge.db +// - applies schema +// - seeds starter recipes/mobs/blocks/lessons (idempotent) +// isAvailable() — true once init succeeded +// +// All other helpers degrade gracefully when the store is unavailable +// (e.g. fresh checkout without `npm install`). + +export { isAvailable, disabledReason, getStore, closeStore, runMaintenance } from "./store.js"; +export { recall, record, markApplied, topAdvice } from "./lessons.js"; + +import { ensureStore, isAvailable as _isAvailable } from "./store.js"; +import { seed } from "./seed.js"; +import { warn, info } from "../log.js"; + +let _initialised = false; + +export async function initKnowledge({ stateDir } = {}) { + if (_initialised) return _isAvailable(); + _initialised = true; + const db = await ensureStore({ stateDir }); + if (!db) { + warn("knowledge", "init: store not available; knowledge layer will be a no-op"); + return false; + } + const seedResult = seed(); + if (!seedResult.ok) { + warn("knowledge", `init: seed step failed (${seedResult.reason})`); + } + return true; +} + +// Death/postmortem helpers — separate file would be overkill; they share +// the store and are only called from coach/postmortem.js. +import { getStore as _getStore } from "./store.js"; + +export function insertDeath({ ts, x, y, z, cause, hostile, lastSkill, lastSkillCode, + hp, food, inventoryLost, contextBlob } = {}) { + if (!_isAvailable()) return null; + try { + const stmt = _getStore().prepare(` + INSERT INTO deaths (ts, x, y, z, cause, hostile, last_skill, last_skill_code, + hp_at_death, food_at_death, inventory_lost, context_blob, analysed) + VALUES (@ts, @x, @y, @z, @cause, @hostile, @lastSkill, @lastSkillCode, + @hp, @food, @inventoryLost, @contextBlob, 0) + `); + const res = stmt.run({ + ts: ts ?? Date.now(), + x: x ?? null, y: y ?? null, z: z ?? null, + cause: cause ?? "unknown", + hostile: hostile ?? null, + lastSkill: lastSkill ?? null, + lastSkillCode: lastSkillCode ?? null, + hp: hp ?? null, + food: food ?? null, + inventoryLost: inventoryLost ? JSON.stringify(inventoryLost) : null, + contextBlob: contextBlob ? JSON.stringify(contextBlob) : null, + }); + return res.lastInsertRowid; + } catch (e) { + warn("knowledge", `insertDeath failed: ${e?.message ?? e}`); + return null; + } +} + +export function unanalysedDeaths({ limit = 5 } = {}) { + if (!_isAvailable()) return []; + try { + return _getStore().prepare(` + SELECT * FROM deaths WHERE analysed = 0 ORDER BY ts ASC LIMIT @limit + `).all({ limit }); + } catch (e) { + warn("knowledge", `unanalysedDeaths failed: ${e?.message ?? e}`); + return []; + } +} + +export function markDeathAnalysed(deathId) { + if (!_isAvailable()) return; + try { + _getStore().prepare("UPDATE deaths SET analysed = 1 WHERE id = ?").run(deathId); + } catch (e) { + warn("knowledge", `markDeathAnalysed failed: ${e?.message ?? e}`); + } +} + +export function insertPostmortem({ deathId, cause, lesson, nextAction, rawResponse, source = "pi" } = {}) { + if (!_isAvailable() || !deathId) return null; + try { + const res = _getStore().prepare(` + INSERT INTO postmortems (death_id, ts, cause, lesson, next_action, raw_response, source) + VALUES (@deathId, @ts, @cause, @lesson, @nextAction, @rawResponse, @source) + `).run({ + deathId, + ts: Date.now(), + cause: cause ?? null, + lesson: lesson ?? null, + nextAction: nextAction ?? null, + rawResponse: rawResponse ?? null, + source, + }); + return res.lastInsertRowid; + } catch (e) { + warn("knowledge", `insertPostmortem failed: ${e?.message ?? e}`); + return null; + } +} + +// Recipe / mob / block lookups +export function lookupRecipe(name) { + if (!_isAvailable()) return null; + try { + const row = _getStore().prepare(`SELECT * FROM recipes WHERE name = ?`).get(name); + if (!row) return null; + return { ...row, shape: safeParse(row.shape) }; + } catch (e) { + warn("knowledge", `lookupRecipe failed: ${e?.message ?? e}`); + return null; + } +} + +export function lookupMob(name) { + if (!_isAvailable() || !name) return null; + try { + const row = _getStore().prepare(`SELECT * FROM mob_intel WHERE name = ?`).get(name); + if (!row) return null; + return { ...row, drops: safeParse(row.drops) }; + } catch (e) { + warn("knowledge", `lookupMob failed: ${e?.message ?? e}`); + return null; + } +} + +export function lookupBlock(name) { + if (!_isAvailable() || !name) return null; + try { + const row = _getStore().prepare(`SELECT * FROM block_intel WHERE name = ?`).get(name); + if (!row) return null; + return { ...row, drops: safeParse(row.drops) }; + } catch (e) { + warn("knowledge", `lookupBlock failed: ${e?.message ?? e}`); + return null; + } +} + +// POI helpers — spatially-keyed long-term memory. +const CELL = 16; + +export function recordPOI({ kind, name, x, y, z, expiresAt, notes } = {}) { + if (!_isAvailable() || typeof x !== "number" || typeof z !== "number") return null; + try { + const stmt = _getStore().prepare(` + INSERT INTO poi (kind, name, x, y, z, cell_x, cell_z, ts, expires_at, notes) + VALUES (@kind, @name, @x, @y, @z, @cellX, @cellZ, @ts, @expiresAt, @notes) + `); + const cellX = Math.floor(x / CELL); + const cellZ = Math.floor(z / CELL); + const res = stmt.run({ + kind, name: name ?? null, + x, y: y ?? 0, z, + cellX, cellZ, + ts: Date.now(), + expiresAt: expiresAt ?? null, + notes: notes ?? null, + }); + return res.lastInsertRowid; + } catch (e) { + warn("knowledge", `recordPOI failed: ${e?.message ?? e}`); + return null; + } +} + +export function poiNearby({ x, z, kind, radius = 64, limit = 8 } = {}) { + if (!_isAvailable() || typeof x !== "number" || typeof z !== "number") return []; + try { + const cellX = Math.floor(x / CELL); + const cellZ = Math.floor(z / CELL); + const cellRadius = Math.ceil(radius / CELL); + const sql = ` + SELECT *, ((x - @x) * (x - @x) + (z - @z) * (z - @z)) AS dist2 + FROM poi + WHERE cell_x BETWEEN @cxLo AND @cxHi + AND cell_z BETWEEN @czLo AND @czHi + ${kind ? "AND kind = @kind" : ""} + AND (expires_at IS NULL OR expires_at > @now) + ORDER BY dist2 ASC + LIMIT @limit + `; + return _getStore().prepare(sql).all({ + x, z, + cxLo: cellX - cellRadius, cxHi: cellX + cellRadius, + czLo: cellZ - cellRadius, czHi: cellZ + cellRadius, + kind: kind ?? null, + now: Date.now(), + limit, + }).filter((r) => r.dist2 <= radius * radius); + } catch (e) { + warn("knowledge", `poiNearby failed: ${e?.message ?? e}`); + return []; + } +} + +// Chat log +export function logChat({ direction, speaker, text, intent, repliedWith } = {}) { + if (!_isAvailable() || !text) return null; + try { + const res = _getStore().prepare(` + INSERT INTO chat_log (ts, direction, speaker, text, intent, replied_with) + VALUES (@ts, @direction, @speaker, @text, @intent, @repliedWith) + `).run({ + ts: Date.now(), + direction: direction ?? "in", + speaker: speaker ?? null, + text, + intent: intent ?? null, + repliedWith: repliedWith ?? null, + }); + return res.lastInsertRowid; + } catch (e) { + warn("knowledge", `logChat failed: ${e?.message ?? e}`); + return null; + } +} + +function safeParse(s) { + if (!s) return null; + try { return JSON.parse(s); } catch { return null; } +} diff --git a/runtime/knowledge/knowledge.test.js b/runtime/knowledge/knowledge.test.js new file mode 100644 index 0000000..43aa731 --- /dev/null +++ b/runtime/knowledge/knowledge.test.js @@ -0,0 +1,224 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + initKnowledge, + isAvailable, + disabledReason, + lookupRecipe, + lookupMob, + lookupBlock, + recall, + record, + markApplied, + topAdvice, + insertDeath, + unanalysedDeaths, + markDeathAnalysed, + insertPostmortem, + recordPOI, + poiNearby, + logChat, +} from "./index.js"; +import { __resetForTests, closeStore } from "./store.js"; + +// All tests share one DB in a tmp dir per run. The first test bootstraps, +// later tests assume init has happened. When `better-sqlite3` is not +// installed, `isAvailable()` stays false and every test asserts the +// graceful no-op contract instead. + +const tmp = mkdtempSync(join(tmpdir(), "pepa-knowledge-test-")); +let bootstrapped = false; + +async function bootstrap() { + if (bootstrapped) return; + __resetForTests(); + await initKnowledge({ stateDir: tmp }); + bootstrapped = true; +} + +test("init: opens store or stays disabled gracefully", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.match(disabledReason() ?? "", /better-sqlite3|store/i, + "when unavailable, disabledReason should explain why"); + return; // rest of suite covered by no-op assertions below + } + assert.equal(typeof isAvailable(), "boolean"); +}); + +test("seed: recipes, mobs, blocks, lessons present", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.equal(lookupRecipe("planks"), null); + assert.equal(lookupMob("creeper"), null); + assert.equal(lookupBlock("oak_log"), null); + assert.deepEqual(recall(), []); + return; + } + const planks = lookupRecipe("planks"); + assert.ok(planks, "planks recipe seeded"); + assert.equal(planks.yields, 4); + + const creeper = lookupMob("creeper"); + assert.ok(creeper, "creeper intel seeded"); + assert.equal(creeper.threat_level, 5); + assert.equal(creeper.verdict_no_weapon, "flee"); + + const oak = lookupBlock("oak_log"); + assert.ok(oak, "oak_log intel seeded"); + assert.equal(oak.required_tool, "axe"); + + const lessons = recall(); + assert.ok(lessons.length >= 5, `expected ≥5 starter lessons, got ${lessons.length}`); +}); + +test("recall: filter by hostile narrows results", async () => { + await bootstrap(); + if (!isAvailable()) return; + const all = recall(); + const creeperLessons = recall({ hostile: "creeper" }); + assert.ok(creeperLessons.length > 0, "creeper-specific lessons exist"); + assert.ok(creeperLessons.every( + (l) => l.trigger_hostile === null || l.trigger_hostile === "creeper", + ), "filter excludes other hostiles"); + assert.ok(creeperLessons.length <= all.length); +}); + +test("record: insert custom lesson, retrievable by category", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.equal(record({ text: "noop", category: "combat" }).ok, false); + return; + } + const { ok, id } = record({ + text: "Stop attacking creepers with fists — confirmed 30 deaths in spawn area.", + category: "combat", + triggerHostile: "creeper", + avoidSkill: "attack creeper", + preferSkill: "survive.flee", + confidence: 0.8, + source: "test", + }); + assert.equal(ok, true); + assert.ok(typeof id === "number" || typeof id === "bigint"); + + const lessons = recall({ hostile: "creeper", category: "combat" }); + assert.ok(lessons.some((l) => l.id === Number(id))); +}); + +test("markApplied: increments counters, adjusts confidence", async () => { + await bootstrap(); + if (!isAvailable()) return; + const { id } = record({ + text: "test-applied-lesson", category: "pathing", confidence: 0.5, source: "test", + }); + markApplied(id, { succeeded: true }); + markApplied(id, { succeeded: true }); + markApplied(id, { succeeded: false }); + const found = recall({ category: "pathing" }).find((l) => l.id === Number(id)); + assert.ok(found, "lesson retrievable after marks"); + assert.equal(found.applied_count, 3); + assert.equal(found.succeeded_count, 2); + assert.ok(found.confidence > 0.5, "two successes outweighed one failure"); +}); + +test("topAdvice: returns null when no high-confidence lesson matches", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.deepEqual(topAdvice({ hostile: "creeper" }), { avoid: null, prefer: null, lessonId: null, lesson: null }); + return; + } + // Starter rule for creeper is confidence 0.95, with avoid + prefer set. + const advice = topAdvice({ hostile: "creeper" }); + assert.equal(advice.avoid, "attack creeper"); + assert.equal(advice.prefer, "survive.flee"); + assert.ok(advice.lesson); + + // Unrelated mob → no specific advice usually. + const noneAdvice = topAdvice({ hostile: "rabbit" }); + // Either no advice OR a generic lesson without avoid/prefer set. Both fine. + if (noneAdvice.avoid || noneAdvice.prefer) { + assert.ok(typeof noneAdvice.lesson === "string"); + } +}); + +test("death + postmortem round-trip", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.equal(insertDeath({ ts: 1, x: 0, y: 0, z: 0 }), null); + return; + } + const deathId = insertDeath({ + ts: Date.now(), + x: 100, y: 64, z: 200, + cause: "hostile", + hostile: "creeper", + lastSkill: "gather.logs", + lastSkillCode: "timeout", + hp: 0, + food: 14, + inventoryLost: [{ name: "oak_log", count: 4 }], + contextBlob: { lastTicks: ["wandered E", "noticed creeper at 6m", "boom"] }, + }); + assert.ok(deathId); + + const pending = unanalysedDeaths({ limit: 10 }); + assert.ok(pending.some((d) => d.id === Number(deathId))); + + const pmId = insertPostmortem({ + deathId, + cause: "creeper_explosion_in_open", + lesson: "Don't gather logs at night without armor.", + nextAction: "shelter, then gather at dawn", + rawResponse: '{"cause":"creeper"}', + }); + assert.ok(pmId); + + markDeathAnalysed(deathId); + const stillPending = unanalysedDeaths({ limit: 10 }); + assert.ok(!stillPending.some((d) => d.id === Number(deathId))); +}); + +test("poi: insert + nearby query", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.equal(recordPOI({ kind: "tree", x: 0, y: 64, z: 0 }), null); + assert.deepEqual(poiNearby({ x: 0, z: 0 }), []); + return; + } + recordPOI({ kind: "tree", x: 100, y: 64, z: 100, notes: "oak cluster" }); + recordPOI({ kind: "tree", x: 110, y: 64, z: 102 }); + recordPOI({ kind: "tree", x: 500, y: 64, z: 500 }); + recordPOI({ kind: "danger", x: 100, y: 64, z: 100, notes: "creeper spawned here" }); + + const near = poiNearby({ x: 100, z: 100, kind: "tree", radius: 32 }); + assert.equal(near.length, 2); + assert.ok(near[0].dist2 < 200, "nearest first"); + + const far = poiNearby({ x: 100, z: 100, kind: "tree", radius: 8 }); + assert.equal(far.length, 1, "radius 8 excludes the second tree at (110,102)"); + + const danger = poiNearby({ x: 100, z: 100, kind: "danger", radius: 32 }); + assert.equal(danger.length, 1); +}); + +test("chat log: append + select", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.equal(logChat({ text: "hi", speaker: "alice" }), null); + return; + } + const id1 = logChat({ direction: "in", speaker: "alice", text: "привет", intent: "GREETING" }); + const id2 = logChat({ direction: "out", text: "yo", repliedWith: "template" }); + assert.ok(id1 && id2); +}); + +// Cleanup: close DB and remove tmp dir. +test("teardown", () => { + closeStore(); + try { rmSync(tmp, { recursive: true, force: true }); } catch {} +}); diff --git a/runtime/knowledge/lessons.js b/runtime/knowledge/lessons.js new file mode 100644 index 0000000..0c12afd --- /dev/null +++ b/runtime/knowledge/lessons.js @@ -0,0 +1,178 @@ +// Lesson recall, recording, and outcome tracking. +// +// A "lesson" is a generalised rule the bot has learned: "don't attack +// creepers with fists", "gather.logs timeouts here, move on", "fight +// skeletons under cover only". Recall is best-effort: returns the top-K +// lessons matching the situation, sorted by confidence × recency. +// +// The dispatch path uses recall() to ALTER its planned action — see +// runtime/coach/advice.js. Lessons are immutable rows once written; the +// applied / succeeded counters and confidence are updated separately. + +import { isAvailable, getStore } from "./store.js"; +import { warn } from "../log.js"; + +const RECALL_DEFAULT_LIMIT = 8; +const RECENCY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // a week — older lessons score lower + +/** + * recall({ skill?, hostile?, situation?, category?, limit? }) + * → Lesson[] + * + * Best-match lessons in confidence order with light recency boost. + * Any missing filter widens the search; passing none returns the most + * confident recent lessons. + */ +export function recall({ skill, hostile, situation, category, limit = RECALL_DEFAULT_LIMIT } = {}) { + if (!isAvailable()) return []; + const db = getStore(); + const conds = []; + const params = {}; + if (skill) { conds.push("(trigger_skill IS NULL OR trigger_skill = @skill)"); params.skill = skill; } + if (hostile) { conds.push("(trigger_hostile IS NULL OR trigger_hostile = @hostile)"); params.hostile = hostile; } + if (situation) { conds.push("(trigger_situation IS NULL OR trigger_situation = @situation)"); params.situation = situation; } + if (category) { conds.push("category = @category"); params.category = category; } + const where = conds.length ? "WHERE " + conds.join(" AND ") : ""; + try { + const rows = db.prepare(` + SELECT id, text, category, trigger_skill, trigger_hostile, trigger_situation, + avoid_skill, prefer_skill, confidence, applied_count, succeeded_count, + source, source_ref, ts + FROM lessons + ${where} + ORDER BY confidence DESC, ts DESC + LIMIT @limit + `).all({ ...params, limit }); + return rows.map(scoreLesson).sort((a, b) => b._score - a._score); + } catch (e) { + warn("knowledge", `recall failed: ${e?.message ?? e}`); + return []; + } +} + +function scoreLesson(row) { + const ageMs = Math.max(0, Date.now() - (row.ts ?? 0)); + const recency = ageMs < RECENCY_WINDOW_MS + ? 1 - ageMs / RECENCY_WINDOW_MS + : 0; + const applied = row.applied_count ?? 0; + const succeeded = row.succeeded_count ?? 0; + // Reward lessons that have been applied successfully. + const validation = applied > 0 ? succeeded / applied : 0; + row._score = row.confidence * 0.6 + recency * 0.2 + validation * 0.2; + return row; +} + +/** + * record({ text, category, ... }) + * → { ok, id } + * + * Insert a new lesson. Does NOT dedupe; callers should check recall() + * first if dedupe matters. (For Pi-extracted lessons, near-duplicates + * are fine — variety helps recall.) + */ +export function record({ + text, + category = "survival", + triggerSkill = null, + triggerHostile = null, + triggerSituation = null, + avoidSkill = null, + preferSkill = null, + confidence = 0.5, + source = "pi-coach", + sourceRef = null, +} = {}) { + if (!isAvailable()) return { ok: false, reason: "store unavailable" }; + if (!text || typeof text !== "string") return { ok: false, reason: "text required" }; + try { + const stmt = getStore().prepare(` + INSERT INTO lessons (ts, text, category, trigger_skill, trigger_hostile, trigger_situation, + avoid_skill, prefer_skill, confidence, applied_count, succeeded_count, + source, source_ref) + VALUES (@ts, @text, @category, @triggerSkill, @triggerHostile, @triggerSituation, + @avoidSkill, @preferSkill, @confidence, 0, 0, @source, @sourceRef) + `); + const info = stmt.run({ + ts: Date.now(), + text, + category, + triggerSkill, + triggerHostile, + triggerSituation, + avoidSkill, + preferSkill, + confidence: Math.max(0, Math.min(1, confidence)), + source, + sourceRef, + }); + return { ok: true, id: info.lastInsertRowid }; + } catch (e) { + warn("knowledge", `record failed: ${e?.message ?? e}`); + return { ok: false, reason: e?.message ?? String(e) }; + } +} + +/** + * markApplied(id, { succeeded }) + * Bumps applied_count, and if succeeded=true, succeeded_count too. + * Adjusts confidence: success increases it slightly, failure decreases. + */ +export function markApplied(id, { succeeded = false } = {}) { + if (!isAvailable()) return false; + try { + const stmt = getStore().prepare(` + UPDATE lessons SET + applied_count = applied_count + 1, + succeeded_count = succeeded_count + @suc, + confidence = MIN(1.0, MAX(0.05, confidence + @delta)) + WHERE id = @id + `); + stmt.run({ + id, + suc: succeeded ? 1 : 0, + delta: succeeded ? 0.05 : -0.03, + }); + return true; + } catch (e) { + warn("knowledge", `markApplied failed: ${e?.message ?? e}`); + return false; + } +} + +/** + * topAdvice({ skill, hostile, situation, hp, food, hasWeapon }) + * → { avoid: string | null, prefer: string | null, lessonId: number | null, lesson: string | null } + * + * Reduce recalled lessons to one actionable directive. The dispatch + * layer reads this and adjusts its plan; if no high-confidence lesson + * applies, returns empty advice and the caller proceeds as normal. + */ +export function topAdvice(ctx = {}) { + const lessons = recall({ + skill: ctx.skill, + hostile: ctx.hostile, + situation: ctx.situation, + limit: 6, + }); + for (const l of lessons) { + if (l.confidence < 0.6) break; + if (l.avoid_skill || l.prefer_skill) { + return { + avoid: l.avoid_skill ?? null, + prefer: l.prefer_skill ?? null, + lessonId: l.id, + lesson: l.text, + }; + } + } + return { avoid: null, prefer: null, lessonId: null, lesson: null }; +} + +/** Test-only helpers. Not exported through index.js. */ +export function __wipeForTests() { + if (!isAvailable()) return; + try { + getStore().exec("DELETE FROM lessons"); + } catch {} +} diff --git a/runtime/knowledge/schema.sql b/runtime/knowledge/schema.sql new file mode 100644 index 0000000..93b8c36 --- /dev/null +++ b/runtime/knowledge/schema.sql @@ -0,0 +1,181 @@ +-- pepa-pi-bot knowledge schema v1 +-- Single-process SQLite store at state//knowledge.db. +-- Idempotent: applied on every boot. Migrations go below the CREATE TABLE +-- block, gated by schema_version. + +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER NOT NULL PRIMARY KEY, + applied_at INTEGER NOT NULL +); + +---------------------------------------------------------------------- +-- Recipes (seeded from docs/minecraft-recipes.json + augmented by wiki) +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS recipes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + shape TEXT NOT NULL, -- JSON array of rows + shapeless INTEGER NOT NULL DEFAULT 0, + yields INTEGER NOT NULL DEFAULT 1, + requires_table INTEGER NOT NULL DEFAULT 1, -- 0=hand,1=table,2=furnace,3=smithing + source TEXT, + source_url TEXT, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_recipes_name ON recipes(name); + +---------------------------------------------------------------------- +-- Mob intel — what to do when you see a mob +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mob_intel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + hostility TEXT NOT NULL, -- 'hostile' | 'neutral' | 'passive' | 'tamable' + threat_level INTEGER NOT NULL, -- 1..5 + approach_range REAL, -- blocks at which it engages + burns_in_sun INTEGER NOT NULL DEFAULT 0, + ranged INTEGER NOT NULL DEFAULT 0, + weakness TEXT, + drops TEXT, -- JSON array of names + verdict_no_weapon TEXT, -- 'flee' | 'shelter' | 'avoid' | 'pillar' + verdict_with_sword TEXT, -- 'kite' | 'attack' | 'avoid' + notes TEXT, + source TEXT, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_mob_name ON mob_intel(name); + +---------------------------------------------------------------------- +-- Block intel — what tool, what drops, lighting +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS block_intel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + required_tool TEXT, -- 'any'|'wood_pickaxe'|'stone_pickaxe'|'iron_pickaxe'|'shovel'|'axe' + drops TEXT, -- JSON array + light_emit INTEGER DEFAULT 0, + walkable INTEGER DEFAULT 1, + notes TEXT, + source TEXT, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_block_name ON block_intel(name); + +---------------------------------------------------------------------- +-- Lessons — generalised "what to do / what to avoid" learned over time +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS lessons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + text TEXT NOT NULL, + category TEXT NOT NULL, -- 'combat'|'pathing'|'crafting'|'survival'|'social'|'self-improve' + trigger_skill TEXT, + trigger_hostile TEXT, + trigger_situation TEXT, -- coarse hash key from scenario-memory + avoid_skill TEXT, + prefer_skill TEXT, + confidence REAL NOT NULL DEFAULT 0.5, + applied_count INTEGER NOT NULL DEFAULT 0, + succeeded_count INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL, -- 'postmortem'|'pi-coach'|'wiki'|'operator'|'rule' + source_ref TEXT +); +CREATE INDEX IF NOT EXISTS idx_lessons_category ON lessons(category); +CREATE INDEX IF NOT EXISTS idx_lessons_skill ON lessons(trigger_skill); +CREATE INDEX IF NOT EXISTS idx_lessons_hostile ON lessons(trigger_hostile); +CREATE INDEX IF NOT EXISTS idx_lessons_ts ON lessons(ts); + +---------------------------------------------------------------------- +-- Death events — captured by coach/postmortem.js on every death +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS deaths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + x REAL, y REAL, z REAL, + cause TEXT, -- 'hostile'|'fall'|'lava'|'drowning'|'starvation'|'suffocation'|'other'|'unknown' + hostile TEXT, + last_skill TEXT, + last_skill_code TEXT, + hp_at_death REAL, + food_at_death REAL, + inventory_lost TEXT, -- JSON + context_blob TEXT, -- JSON: last 30s of events + analysed INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_deaths_ts ON deaths(ts); +CREATE INDEX IF NOT EXISTS idx_deaths_analysed ON deaths(analysed); +CREATE INDEX IF NOT EXISTS idx_deaths_cause ON deaths(cause); + +---------------------------------------------------------------------- +-- Pi-extracted post-mortems linking back to deaths +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS postmortems ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + death_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + cause TEXT, + lesson TEXT, + next_action TEXT, + raw_response TEXT, + source TEXT, -- 'pi' | 'rule' + FOREIGN KEY (death_id) REFERENCES deaths(id) +); +CREATE INDEX IF NOT EXISTS idx_postmortems_death ON postmortems(death_id); + +---------------------------------------------------------------------- +-- Points of interest — queryable spatial memory +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS poi ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, -- 'tree'|'ore'|'water'|'mob_spawner'|'danger'|'foreign_build'|'base'|'chest' + name TEXT, + x REAL NOT NULL, y REAL NOT NULL, z REAL NOT NULL, + cell_x INTEGER NOT NULL, + cell_z INTEGER NOT NULL, + ts INTEGER NOT NULL, + expires_at INTEGER, + notes TEXT +); +CREATE INDEX IF NOT EXISTS idx_poi_cell ON poi(cell_x, cell_z); +CREATE INDEX IF NOT EXISTS idx_poi_kind ON poi(kind); + +---------------------------------------------------------------------- +-- Cached wiki pages (rc.2) +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS wiki_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + url TEXT NOT NULL, + body TEXT, + etag TEXT, + fetched_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +---------------------------------------------------------------------- +-- Full chat log (durable; per-speaker LRU in memory is unchanged) +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS chat_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + direction TEXT NOT NULL, -- 'in' | 'out' + speaker TEXT, + text TEXT NOT NULL, + intent TEXT, + replied_with TEXT +); +CREATE INDEX IF NOT EXISTS idx_chat_speaker ON chat_log(speaker); +CREATE INDEX IF NOT EXISTS idx_chat_ts ON chat_log(ts); + +---------------------------------------------------------------------- +-- Self-rewrite audit +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS code_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + proposal_slug TEXT, + files TEXT, -- JSON array + diff_hash TEXT, + outcome TEXT, -- 'applied'|'rolled_back'|'rejected' + notes TEXT +); diff --git a/runtime/knowledge/seed.js b/runtime/knowledge/seed.js new file mode 100644 index 0000000..6137492 --- /dev/null +++ b/runtime/knowledge/seed.js @@ -0,0 +1,296 @@ +// Seed the knowledge DB with starter content shipped in the repo. +// Idempotent: only inserts rows that aren't already present (UPSERT +// keyed by `name`). +// +// Sources: +// docs/minecraft-recipes.json — recipes table +// inline MOB_INTEL / BLOCK_INTEL / STARTER_LESSONS arrays — bootstrap +// knowledge so the bot has something to consult before any wiki/ +// post-mortem run has populated the DB. +// +// Call seed() once after ensureStore(). Cheap (single transaction, +// ~50 rows). No network. No Pi. + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { isAvailable, getStore } from "./store.js"; +import { info, warn } from "../log.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const RECIPES_JSON = resolve(HERE, "..", "..", "docs", "minecraft-recipes.json"); + +// Compact intel for the most common hostiles + a few passives. +// `verdict_no_weapon`: what the bot should do when caught without a sword. +// `verdict_with_sword`: what to do with at least a wooden sword. +// Sources: docs/minecraft-knowledge.md (already synthesised from wiki). +const MOB_INTEL = [ + { name: "zombie", hostility: "hostile", threat_level: 2, approach_range: 16, burns_in_sun: 1, ranged: 0, + weakness: "sunlight", drops: ["rotten_flesh","iron_ingot","carrot","potato"], + verdict_no_weapon: "flee", verdict_with_sword: "kite", + notes: "Babies move fast — flee on sight even with a sword if your hp<14." }, + { name: "husk", hostility: "hostile", threat_level: 3, approach_range: 16, burns_in_sun: 0, ranged: 0, + weakness: "water", drops: ["rotten_flesh"], + verdict_no_weapon: "shelter", verdict_with_sword: "kite", + notes: "Desert zombie variant; doesn't burn in daylight; inflicts hunger." }, + { name: "skeleton", hostility: "hostile", threat_level: 3, approach_range: 16, burns_in_sun: 1, ranged: 1, + weakness: "melee_in_cover", drops: ["bone","arrow","bow"], + verdict_no_weapon: "shelter", verdict_with_sword: "kite", + notes: "Ranged — never approach in open. Close distance only if you have a shield or terrain cover." }, + { name: "creeper", hostility: "hostile", threat_level: 5, approach_range: 16, burns_in_sun: 0, ranged: 0, + weakness: "knockback", drops: ["gunpowder"], + verdict_no_weapon: "flee", verdict_with_sword: "kite", + notes: "Silent, explodes within 3 blocks. Keep > 5 blocks distance ALWAYS. Never fight near base." }, + { name: "spider", hostility: "neutral", threat_level: 2, approach_range: 16, burns_in_sun: 0, ranged: 0, + weakness: "high_ground", drops: ["string","spider_eye"], + verdict_no_weapon: "pillar", verdict_with_sword: "attack", + notes: "Climbs walls. Pillar up 2 blocks for safety. Daytime spider is neutral unless hit." }, + { name: "enderman", hostility: "neutral", threat_level: 4, approach_range: 64, burns_in_sun: 0, ranged: 0, + weakness: "water", drops: ["ender_pearl"], + verdict_no_weapon: "avoid", verdict_with_sword: "avoid", + notes: "Don't look at the head. Hostile only if provoked. Teleports — fights are unpredictable." }, + { name: "drowned", hostility: "hostile", threat_level: 3, approach_range: 16, burns_in_sun: 0, ranged: 1, + weakness: "above_water", drops: ["rotten_flesh","copper_ingot","trident","nautilus_shell"], + verdict_no_weapon: "flee", verdict_with_sword: "kite", + notes: "Trident variants ranged & deadly. Don't fight in water." }, + { name: "witch", hostility: "hostile", threat_level: 4, approach_range: 16, burns_in_sun: 0, ranged: 1, + weakness: "burst_damage", drops: ["redstone","glowstone_dust","gunpowder","sugar","stick","glass_bottle","spider_eye"], + verdict_no_weapon: "flee", verdict_with_sword: "avoid", + notes: "Throws poison/weakness potions. Avoid until iron sword + apples." }, + { name: "slime", hostility: "hostile", threat_level: 1, approach_range: 16, burns_in_sun: 0, ranged: 0, + weakness: "split_into_smaller", drops: ["slime_ball"], + verdict_no_weapon: "pillar", verdict_with_sword: "attack", + notes: "Splits when killed. Common in swamp at night." }, + { name: "phantom", hostility: "hostile", threat_level: 3, approach_range: 64, burns_in_sun: 1, ranged: 0, + weakness: "burns_in_sun", drops: ["phantom_membrane"], + verdict_no_weapon: "shelter", verdict_with_sword: "kite", + notes: "Triggered by not sleeping 3+ days. Sleep when possible." }, + { name: "cow", hostility: "passive", threat_level: 1, drops: ["beef","leather"], verdict_no_weapon: "attack", verdict_with_sword: "attack", notes: "Hit until dead for food/leather. Breed with wheat." }, + { name: "sheep", hostility: "passive", threat_level: 1, drops: ["wool","mutton"], verdict_no_weapon: "attack", verdict_with_sword: "attack", notes: "Shear for wool (sheep lives) or kill for mutton+wool. Breed with wheat." }, + { name: "chicken", hostility: "passive", threat_level: 1, drops: ["chicken","feather","egg"], verdict_no_weapon: "attack", verdict_with_sword: "attack", notes: "Lays eggs every 5-10 min. Breed with seeds." }, + { name: "pig", hostility: "passive", threat_level: 1, drops: ["porkchop"], verdict_no_weapon: "attack", verdict_with_sword: "attack", notes: "Breed with carrot/potato/beetroot." }, + { name: "wolf", hostility: "neutral", threat_level: 2, drops: [], verdict_no_weapon: "avoid", verdict_with_sword: "avoid", notes: "Don't hit. Tame with bones later." }, +]; + +const BLOCK_INTEL = [ + { name: "oak_log", required_tool: "axe", drops: ["oak_log"], light_emit: 0, notes: "Any axe; fists work but slow." }, + { name: "birch_log", required_tool: "axe", drops: ["birch_log"], light_emit: 0 }, + { name: "spruce_log", required_tool: "axe", drops: ["spruce_log"], light_emit: 0 }, + { name: "dark_oak_log", required_tool: "axe", drops: ["dark_oak_log"], light_emit: 0 }, + { name: "jungle_log", required_tool: "axe", drops: ["jungle_log"], light_emit: 0 }, + { name: "acacia_log", required_tool: "axe", drops: ["acacia_log"], light_emit: 0 }, + { name: "mangrove_log", required_tool: "axe", drops: ["mangrove_log"], light_emit: 0 }, + { name: "cherry_log", required_tool: "axe", drops: ["cherry_log"], light_emit: 0 }, + { name: "stone", required_tool: "wood_pickaxe", drops: ["cobblestone"], light_emit: 0, notes: "Needs wood pickaxe minimum; otherwise drops nothing." }, + { name: "cobblestone", required_tool: "wood_pickaxe", drops: ["cobblestone"], light_emit: 0 }, + { name: "deepslate", required_tool: "wood_pickaxe", drops: ["cobbled_deepslate"], light_emit: 0 }, + { name: "coal_ore", required_tool: "wood_pickaxe", drops: ["coal"], light_emit: 0 }, + { name: "iron_ore", required_tool: "stone_pickaxe", drops: ["raw_iron"], light_emit: 0, notes: "Needs stone pickaxe; wood pickaxe drops nothing." }, + { name: "copper_ore", required_tool: "stone_pickaxe", drops: ["raw_copper"], light_emit: 0 }, + { name: "gold_ore", required_tool: "iron_pickaxe", drops: ["raw_gold"], light_emit: 0 }, + { name: "diamond_ore", required_tool: "iron_pickaxe", drops: ["diamond"], light_emit: 0 }, + { name: "redstone_ore", required_tool: "iron_pickaxe", drops: ["redstone"], light_emit: 9 }, + { name: "lapis_ore", required_tool: "stone_pickaxe", drops: ["lapis_lazuli"], light_emit: 0 }, + { name: "obsidian", required_tool: "diamond_pickaxe", drops: ["obsidian"], light_emit: 0, notes: "Diamond+ only; takes 10s+ to mine." }, + { name: "dirt", required_tool: "shovel", drops: ["dirt"], light_emit: 0, walkable: 1 }, + { name: "grass_block", required_tool: "shovel", drops: ["dirt"], light_emit: 0 }, + { name: "sand", required_tool: "shovel", drops: ["sand"], light_emit: 0, notes: "Falls with gravity — never stand under it while mining." }, + { name: "gravel", required_tool: "shovel", drops: ["gravel"], light_emit: 0, notes: "Falls with gravity." }, + { name: "torch", required_tool: "any", drops: ["torch"], light_emit: 14, walkable: 0 }, + { name: "lantern", required_tool: "wood_pickaxe", drops: ["lantern"], light_emit: 15 }, + { name: "campfire", required_tool: "axe", drops: ["charcoal"], light_emit: 15, notes: "Damages anyone walking through." }, + { name: "water", required_tool: "bucket", drops: [], light_emit: 0, walkable: 0, notes: "Use to escape mobs / hydrate farmland." }, + { name: "lava", required_tool: "bucket", drops: [], light_emit: 15, walkable: 0, notes: "Instant death. Never walk near without water bucket." }, + { name: "crafting_table",required_tool: "axe", drops: ["crafting_table"], light_emit: 0, notes: "Essential — first crafting target." }, + { name: "furnace", required_tool: "wood_pickaxe", drops: ["furnace"], light_emit: 13, notes: "Light value 13 when lit." }, +]; + +// Starter lessons — hard-coded survival rules that shouldn't have to be +// re-learned every server. Confidence is high (0.9) for rules taken from +// the wiki-derived knowledge in docs/. +const STARTER_LESSONS = [ + { text: "Don't attack hostiles with fists at night. Flee, shelter, or pillar up instead.", + category: "combat", trigger_hostile: null, avoid_skill: "attack", prefer_skill: "survive.flee", + confidence: 0.9, source: "rule", source_ref: "docs/minecraft-knowledge.md#mobs" }, + { text: "Creeper within 5 blocks = critical danger. Never engage near base or chests.", + category: "combat", trigger_hostile: "creeper", avoid_skill: "attack creeper", prefer_skill: "survive.flee", + confidence: 0.95, source: "rule", source_ref: "docs/minecraft-knowledge.md#mobs" }, + { text: "Skeleton in open ground = retreat to cover. Bow knockback kills you in a few hits.", + category: "combat", trigger_hostile: "skeleton", avoid_skill: "attack skeleton", + confidence: 0.85, source: "rule", source_ref: "docs/minecraft-knowledge.md#mobs" }, + { text: "Spider — pillar up 2 blocks with dirt. Spiders can't climb a 2-block overhang.", + category: "combat", trigger_hostile: "spider", prefer_skill: "recovery.tunnel-out", + confidence: 0.85, source: "rule", source_ref: "docs/minecraft-knowledge.md#mobs" }, + { text: "Enderman — don't look at the head, don't hit. Just walk away.", + category: "combat", trigger_hostile: "enderman", avoid_skill: "attack enderman", + confidence: 0.9, source: "rule", source_ref: "docs/minecraft-knowledge.md#mobs" }, + { text: "At night without shelter — dig 2 blocks into ground and cap with dirt. Survive until day.", + category: "survival", trigger_situation: "night-no-shelter", + confidence: 0.8, source: "rule", source_ref: "docs/minecraft-knowledge.md" }, + { text: "Before any cave/deep mining: have a wood pickaxe, food, torches, and a return path.", + category: "survival", confidence: 0.85, source: "rule", source_ref: "docs/minecraft-knowledge.md" }, + { text: "First crafting target — 4 logs → 16 planks → crafting table → wooden axe + sword. Always.", + category: "crafting", confidence: 0.95, source: "rule", source_ref: "docs/minecraft-knowledge.md" }, + { text: "Cobblestone needs at least a wood pickaxe — mining stone with fists drops nothing.", + category: "crafting", trigger_skill: "gather.stone", confidence: 0.95, source: "rule" }, + { text: "Sleep in a bed at night to skip phantoms and reset spawn. Bed needs 3 wool + 3 planks.", + category: "survival", confidence: 0.85, source: "rule" }, + { text: "Pathfinder stuck for 6s usually means terrain is unfavourable — back off and try a different direction rather than retry.", + category: "pathing", confidence: 0.7, source: "rule", source_ref: "v0.2.0 observations" }, + { text: "If gather.logs times out repeatedly in one area, move ≥ 32 blocks before trying again.", + category: "pathing", trigger_skill: "gather.logs", confidence: 0.8, source: "rule" }, +]; + +function loadRecipesJson() { + try { + const raw = readFileSync(RECIPES_JSON, "utf8"); + return JSON.parse(raw); + } catch (e) { + warn("knowledge", `recipes seed: ${e?.message ?? e}; skipping`); + return null; + } +} + +export function seed() { + if (!isAvailable()) return { ok: false, reason: "store unavailable" }; + const db = getStore(); + const now = Date.now(); + + const tx = db.transaction(() => { + const recipesData = loadRecipesJson(); + const recipeRows = recipesData?.recipes ?? []; + const insertRecipe = db.prepare(` + INSERT INTO recipes (name, shape, shapeless, yields, requires_table, source, source_url, updated_at) + VALUES (@name, @shape, @shapeless, @yields, @requires_table, @source, @source_url, @updated_at) + ON CONFLICT(name) DO UPDATE SET + shape = excluded.shape, + shapeless = excluded.shapeless, + yields = excluded.yields, + requires_table = excluded.requires_table, + updated_at = excluded.updated_at + `); + for (const r of recipeRows) { + insertRecipe.run({ + name: r.name, + shape: JSON.stringify(r.shape ?? []), + shapeless: r.shapeless ? 1 : 0, + yields: r.yields ?? 1, + requires_table: r.requires_table ?? 1, + source: "seed:docs", + source_url: recipesData?.sources?.[0] ?? null, + updated_at: now, + }); + } + + const insertMob = db.prepare(` + INSERT INTO mob_intel (name, hostility, threat_level, approach_range, burns_in_sun, ranged, + weakness, drops, verdict_no_weapon, verdict_with_sword, notes, source, updated_at) + VALUES (@name, @hostility, @threat_level, @approach_range, @burns_in_sun, @ranged, + @weakness, @drops, @verdict_no_weapon, @verdict_with_sword, @notes, @source, @updated_at) + ON CONFLICT(name) DO UPDATE SET + hostility = excluded.hostility, + threat_level = excluded.threat_level, + approach_range = excluded.approach_range, + burns_in_sun = excluded.burns_in_sun, + ranged = excluded.ranged, + weakness = excluded.weakness, + drops = excluded.drops, + verdict_no_weapon = excluded.verdict_no_weapon, + verdict_with_sword = excluded.verdict_with_sword, + notes = excluded.notes, + updated_at = excluded.updated_at + `); + for (const m of MOB_INTEL) { + insertMob.run({ + name: m.name, + hostility: m.hostility, + threat_level: m.threat_level, + approach_range: m.approach_range ?? null, + burns_in_sun: m.burns_in_sun ? 1 : 0, + ranged: m.ranged ? 1 : 0, + weakness: m.weakness ?? null, + drops: JSON.stringify(m.drops ?? []), + verdict_no_weapon: m.verdict_no_weapon ?? null, + verdict_with_sword: m.verdict_with_sword ?? null, + notes: m.notes ?? null, + source: "seed:docs", + updated_at: now, + }); + } + + const insertBlock = db.prepare(` + INSERT INTO block_intel (name, required_tool, drops, light_emit, walkable, notes, source, updated_at) + VALUES (@name, @required_tool, @drops, @light_emit, @walkable, @notes, @source, @updated_at) + ON CONFLICT(name) DO UPDATE SET + required_tool = excluded.required_tool, + drops = excluded.drops, + light_emit = excluded.light_emit, + walkable = excluded.walkable, + notes = excluded.notes, + updated_at = excluded.updated_at + `); + for (const b of BLOCK_INTEL) { + insertBlock.run({ + name: b.name, + required_tool: b.required_tool ?? null, + drops: JSON.stringify(b.drops ?? []), + light_emit: b.light_emit ?? 0, + walkable: b.walkable ?? 1, + notes: b.notes ?? null, + source: "seed:docs", + updated_at: now, + }); + } + + // Starter lessons — only insert if no row with the same text exists. + // Lessons don't have a UNIQUE constraint on text (Pi-extracted ones + // can rephrase), so dedupe explicitly. + const findLesson = db.prepare("SELECT id FROM lessons WHERE text = ? LIMIT 1"); + const insertLesson = db.prepare(` + INSERT INTO lessons (ts, text, category, trigger_skill, trigger_hostile, trigger_situation, + avoid_skill, prefer_skill, confidence, applied_count, succeeded_count, + source, source_ref) + VALUES (@ts, @text, @category, @trigger_skill, @trigger_hostile, @trigger_situation, + @avoid_skill, @prefer_skill, @confidence, 0, 0, @source, @source_ref) + `); + for (const l of STARTER_LESSONS) { + if (findLesson.get(l.text)) continue; + insertLesson.run({ + ts: now, + text: l.text, + category: l.category, + trigger_skill: l.trigger_skill ?? null, + trigger_hostile: l.trigger_hostile ?? null, + trigger_situation: l.trigger_situation ?? null, + avoid_skill: l.avoid_skill ?? null, + prefer_skill: l.prefer_skill ?? null, + confidence: l.confidence ?? 0.5, + source: l.source ?? "rule", + source_ref: l.source_ref ?? null, + }); + } + }); + + try { + tx(); + const counts = countRows(); + info("knowledge", `seed complete: ${counts.recipes} recipes, ${counts.mobs} mobs, ${counts.blocks} blocks, ${counts.lessons} lessons`); + return { ok: true, counts }; + } catch (e) { + warn("knowledge", `seed failed: ${e?.message ?? e}`); + return { ok: false, reason: e?.message ?? String(e) }; + } +} + +function countRows() { + const db = getStore(); + const q = (sql) => db.prepare(sql).get().n; + return { + recipes: q("SELECT COUNT(*) AS n FROM recipes"), + mobs: q("SELECT COUNT(*) AS n FROM mob_intel"), + blocks: q("SELECT COUNT(*) AS n FROM block_intel"), + lessons: q("SELECT COUNT(*) AS n FROM lessons"), + }; +} + +export { countRows as __countRowsForTests }; diff --git a/runtime/knowledge/store.js b/runtime/knowledge/store.js new file mode 100644 index 0000000..6067990 --- /dev/null +++ b/runtime/knowledge/store.js @@ -0,0 +1,134 @@ +// SQLite-backed knowledge store. +// +// Lazy-loads better-sqlite3 on first use so a fresh checkout without +// `npm install` still boots — the knowledge subsystem just goes into +// disabled mode and every public API becomes a safe no-op. +// +// Public API: +// await ensureStore({ stateDir }) → opens (or reopens) the DB +// getStore() → underlying Database handle or null +// isAvailable() → boolean +// closeStore() +// runMaintenance() → idempotent vacuum/analyze +// +// All schema is in schema.sql alongside this file. Apply happens once on +// first open; subsequent opens are no-ops. + +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { readFileSync, mkdirSync, existsSync } from "node:fs"; +import { info, warn, error as logError } from "../log.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SCHEMA_PATH = resolve(HERE, "schema.sql"); +const CURRENT_SCHEMA_VERSION = 1; + +let _db = null; +let _disabled = false; +let _disabledReason = null; +let _Database = null; +let _loadAttempted = false; + +async function loadDriver() { + if (_Database) return _Database; + if (_loadAttempted) return null; + _loadAttempted = true; + try { + const mod = await import("better-sqlite3"); + _Database = mod.default ?? mod; + return _Database; + } catch (e) { + _disabled = true; + _disabledReason = e?.code === "ERR_MODULE_NOT_FOUND" + ? "better-sqlite3 not installed (run npm install)" + : `better-sqlite3 load failed: ${e?.message ?? e}`; + warn("knowledge", `${_disabledReason}; knowledge subsystem disabled`); + return null; + } +} + +export function isAvailable() { + return !_disabled && _db !== null; +} + +export function disabledReason() { + return _disabledReason; +} + +export function getStore() { + return _db; +} + +export async function ensureStore({ stateDir } = {}) { + if (_db) return _db; + if (_disabled) return null; + if (!stateDir) { + warn("knowledge", "ensureStore called without stateDir; ignoring"); + return null; + } + const Database = await loadDriver(); + if (!Database) return null; + try { + mkdirSync(stateDir, { recursive: true }); + const dbPath = resolve(stateDir, "knowledge.db"); + const isNew = !existsSync(dbPath); + _db = new Database(dbPath); + _db.pragma("journal_mode = WAL"); + _db.pragma("synchronous = NORMAL"); + _db.pragma("foreign_keys = ON"); + const ddl = readFileSync(SCHEMA_PATH, "utf8"); + _db.exec(ddl); + applyMigrations(_db); + info("knowledge", `store opened at ${dbPath}${isNew ? " (new)" : ""}`); + return _db; + } catch (e) { + _disabled = true; + _disabledReason = `store open failed: ${e?.message ?? e}`; + logError("knowledge", _disabledReason); + try { _db?.close(); } catch {} + _db = null; + return null; + } +} + +function applyMigrations(db) { + const row = db + .prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1") + .get(); + const current = row?.version ?? 0; + if (current >= CURRENT_SCHEMA_VERSION) return; + // Migrations stack here when we cross schema versions in the future. + // For v1 the schema.sql already defines everything; just record the version. + db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (?, ?)").run( + CURRENT_SCHEMA_VERSION, + Date.now(), + ); +} + +export function closeStore() { + if (!_db) return; + try { + _db.close(); + } catch (e) { + warn("knowledge", `closeStore: ${e?.message ?? e}`); + } + _db = null; +} + +export function runMaintenance() { + if (!isAvailable()) return; + try { + _db.exec("ANALYZE"); + } catch (e) { + warn("knowledge", `maintenance failed: ${e?.message ?? e}`); + } +} + +// Reset for tests. Not exported for runtime callers. +export function __resetForTests() { + closeStore(); + _disabled = false; + _disabledReason = null; + _loadAttempted = false; + _Database = null; +}