feat(runtime/knowledge): SQLite knowledge base with starter intel + lessons
Per-server state/<host>/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 <noreply@anthropic.com>
This commit is contained in:
Generated
+29
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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/<host>/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; }
|
||||
}
|
||||
@@ -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 {}
|
||||
});
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
-- pepa-pi-bot knowledge schema v1
|
||||
-- Single-process SQLite store at state/<host>/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
|
||||
);
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user