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