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:
2026-05-27 13:14:59 +03:00
co-authored by Claude Opus 4.7
parent b5954f193d
commit 3da10ce58c
8 changed files with 1277 additions and 4 deletions
+29 -2
View File
@@ -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
View File
@@ -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",
+233
View File
@@ -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; }
}
+224
View File
@@ -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 {}
});
+178
View File
@@ -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 {}
}
+181
View File
@@ -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
);
+296
View File
@@ -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 };
+134
View File
@@ -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;
}