Files
mayatnikovandClaude Opus 4.7 3da10ce58c 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>
2026-05-27 13:14:59 +03:00

179 lines
5.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 {}
}