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
+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 };