Files
pepa-pi-bot/runtime/knowledge/index.js
T
15b6c11002 v0.3.1: survival behaviour overhaul — storyline, biome-aware scout, wedge-relocate, food/perf fixes, monitor TUI (#28)
* docs(v0.3.1): PRD — LLM prompt cost optimization

Design-only commit; no runtime changes. Spec for the next patch iteration.

Goal: cut per-advise() input tokens from ~800 to ≤300, preserving the
LLM's ability to produce valid registered skill ids and useful rationale.

Five proposed changes ranked by impact:

  P1  Compact registry format (saves ~350t/call) — group by namespace,
      comma-list ids, drop human titles. Default mode for advisor;
      verbose mode kept for postmortem/reflect.
  P2  Need-scoped registry (~50t additional) — show LLM only skills
      relevant to the active Maslow need + always-available safety
      skills (survive.flee, pillar-up, recovery.tunnel-out, explore.*).
  P3  Snapshot pruning (~50t) — drop weather/experience/dimension/biome/
      players from the user prompt; the LLM doesn't consult them.
  P4  Prompt caching probe — check if TimeWeb passes through
      prompt_tokens_details.cached_tokens. If yes, restructure prefix
      to maximize cache hits (cached input is ~10x cheaper at OpenAI).
  P5  Per-trigger cost telemetry in scripts/list-improvements.js --stats:
      avg_in / avg_out / cost_₽ / share% per trigger_reason, using
      TIMEWEB_PRICE_IN_RUB_PER_M and TIMEWEB_PRICE_OUT_RUB_PER_M env.

Trigger: TimeWeb admin panel after first day of v0.3.0 live showed
34K tokens / day at low activity. At cap budget that projects to
~480₽/month (101₽/M in, 608₽/M out for gpt-5.4-mini). Manageable
but the savings are mostly free — repeated infra tokens, not signal.

All changes are additive; runtime behaviour stays the same. If the
LLM produces worse advice with the compact registry, flip back via a
single constant in fast-advisor.js.

Acceptance: re-run scripts/check-timeweb.js probe 3 — expect
tokens_in ≤ 300 (was ~800). Live for 1h, check --stats: avg_in ≤ 300
per trigger group. Existing 360 tests still green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v0.3.1): storyline — canonical Minecraft survival quest

The bot has been stuck in a loop for two days:
  acquire-food (fail: no nearby food) → explore.far → pillar-up (fail) → repeat

Diagnosis: manifesto + LLM advisor both correctly identify "you need
food" but neither expresses *what concretely to do next*. Manifesto is
a priority ladder (need-detection), not a narrative arc.

This commit adds the missing narrative layer — an ordered list of
operational steps that mirror the vanilla Minecraft survival path:

  1. orient_self      — Понять где я
  2. first_wood       — Собрать 8 поленьев
  3. crafting_basics  — Сделать верстак и палки
  4. first_tools      — Деревянные орудия
  5. first_food       — Найти первую еду
  6. shelter_minimal  — Простой шелтер с кроватью
  7. stone_tier       — Каменные орудия
  8. food_security    — Запас еды на 16+
  9. iron_age         — Железо и печь
  10. settle_base     — Постоянная база
  11. village_grow    — Развивать деревню (ongoing)

Each step has:
  - completed(snapshot) → bool — detects achievement from snapshot
  - suggestSkill(snapshot) → { skillId, args? } — concrete next dispatch
  - emergencyPause(snapshot) → bool — defers to manifesto L0 alive
    emergencies (low HP near hostile, lava under foot, food = 0)
  - narration_ru — chat-friendly Russian one-liner spoken on entry

Components:

- runtime/goal/storyline.js — 11-step canonical quest catalogue
- runtime/goal/state.js — pickCurrentStep(snapshot) walks the list,
  returns first non-completed step + its suggestion. 3s cache.
  Validates suggestSkill's skillId against the live registry.
- runtime/reflex.js — curriculumReflex dispatch priority is now:
    1. manifesto (L0 alive emergencies always win)
    2. storyline (concrete operational subgoal)
    3. curriculum plan (legacy fallback)
  Tests pass ctx.disableStoryline=true for isolation.
- runtime/bot.js — snapshot.storyStep populated each tick so
  chatter/advisor/reflect observers see the same view.
- runtime/coach/fast-advisor.js — buildUserPrompt now embeds the
  current step + its suggested skill, so LLM advice is anchored
  ("step 5 first_food, storyline wants survive.acquire-food, but
  recent dispatches show it's failing — try explore.far + scout").
- runtime/coach/advisor-trigger.js — forwards ctx.storyStep into
  advise() and logs step id at trigger time.
- runtime/coach/reflect.js — reflection prompt includes storyline
  progress so 30-min self-assessment is anchored.
- runtime/persona/chatter.js — narrates step.narration_ru on
  transition. Rate-limited via existing maybeNarrateRaw().

New operator CLI:

- scripts/show-story.js — fetches the live snapshot via IPC sock and
  prints step progress with ✓/→/ markers, current skill, inventory.
  Falls back to --plain catalogue view when bot offline.

Token cost impact: ~+30 input tokens per advise() call (one extra
line in user prompt). Trivial vs the value of grounding LLM advice
in a concrete narrative.

Operator usage:

  node scripts/show-story.js          # live progress + which step + why
  node scripts/show-story.js --plain  # static catalogue of all 11 steps

Tests: 376 green (was 360, +16 storyline tests).

Also in this branch (already committed): dev/v0.3.1/PRD.md —
LLM prompt cost optimization design doc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(v0.3.1): storyline beats manifesto L1+ (only L0 alive emergencies override)

Found in live logs after the previous commit deployed:
  storyline: step 1/11: orient_self → explore.wander
  advisor-trigger: firing because wedged (planned=survive.acquire-food, ...)

Manifesto was still picking survive.acquire-food (L1 food) over the
storyline's orient_self → explore.wander. That's the wrong precedence —
storyline expresses a *concrete operational subgoal* and L1+ manifesto
needs are just "you'd benefit from food" priorities, not emergencies.

New dispatch precedence in curriculumReflex:
  1. manifesto L0 (alive emergencies: lava, low-HP+hostile, food=0)
  2. storyline (concrete narrative subgoal — beats L1+ manifesto)
  3. manifesto L1+ (fallback when storyline has no concrete suggestion)
  4. curriculum plan (legacy fallback)

This way the bot starts following the narrative arc even while
manifesto's L1 food is technically unsatisfied — orient_self runs to
completion before pursuing food explicitly. Storyline already handles
food as step 5 (first_food), so we're not skipping it.

Tests: 378 green (+2 priority-ordering tests):
- L0 manifesto emergency: upstream reflex (defend/modes) catches before
  curriculum dispatch
- storyline beats manifesto when both have suggestions: well-fed bot
  with logs → craft.planks (storyline crafting_basics), not gather.logs
  (manifesto L2)
- updated "manifesto fallback" test to require disableStoryline=true

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(tui): fullscreen monitor-only TUI (opencode-style)

Replaces the old tui/tui.tsx hotkey-heavy dashboard with a read-only
observability screen. Operator actions live in scripts/* now —
TUI is for watching, not driving.

Layout (top to bottom, all auto-resizing to terminal):
  1. Header     — MC/IPC status, pos, HP, food, day/night, hostiles
  2. Storyline  — current step + 11-step quest map (✓/→/○)
  3. Activity   — last N skill dispatches (colour by outcome)
  4. MC Chat    — last N chat lines (cyan for bot, yellow for players)
  5. Advisor    — last N LLM recommendations (trigger + outcome + tokens)
  6. Improvements — open requests from knowledge.improvement_requests
  7. Footer     — 24h token usage + cost in ₽ + q-to-quit

Data sources:
  - IPC sock: snapshot frames, log frames, chat frames (push)
  - SQLite knowledge.db: advisor_recommendations + improvement_requests
    polled every 5s (pull)

Token cost displayed live using TIMEWEB_PRICE_IN_RUB_PER_M /
TIMEWEB_PRICE_OUT_RUB_PER_M env vars (defaults: 101 / 608 for
gpt-5.4-mini).

Switches:
  - npm run tui          → new monitor (this file)
  - npm run tui:legacy   → old action-driven tui/tui.tsx (kept for now)

Implementation notes:
  - Uses ink + alternate-screen-buffer ANSI for proper "opencode-feel"
    fullscreen behaviour; restores prior terminal contents on quit.
  - Skips alt-screen and useInput when stdin/stdout isn't a TTY
    (smoke tests, piped output) — both gracefully degrade.
  - Stable React keys via per-event uid counter, avoids reconciler
    duplicate-key warnings as logs/chat/dispatches stream in.
  - Resize handled via 1s stdout-dimension poll, NOT direct
    'resize' listener (which conflicts with ink's own listener and
    triggers MaxListenersExceededWarning).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ui(tui): compact 4-section monitor (was 6) — fits 1080p without zoom

Operator reported the TUI overflowed the screen unless terminal was
zoomed way out. The 11-step storyline list alone was eating ~13
rows, and each advisor/improvement entry took 2-3 rows. Now:

- Header + storyline collapsed into one panel (2 lines):
    line 1: pepa · ●MC ●IPC · 1m50s · pepa_bot · (697,61,702) · HP 20 · food 5 · ☀ · ⚔60(creeper@58b)
    line 2: story ▓▒░░░░░░░░░ 1/11 orient_self · Понять где я → explore.wander
  The 11-step ladder is now a unicode progress bar (▓ done, ▒ current,
  ░ pending) — same info, fits in one row.

- Advisor entries: one line each instead of two.
    ✓ wedged_60s → survive.flee  802t 1900ms
    (outcome mark / trigger / target skill / tokens / latency)

- Improvements entries: one line each instead of two.
    #1 P2 ×3  Add craft.iron-pickaxe skill
  Description dropped from the row — use `node scripts/list-improvements.js`
  for full text.

- Sections: 4 (was 6).
    [header+story] · [activity | chat] · [advisor | improvements] · [footer]

Tested on a typical 1080p terminal — fits comfortably without zoom.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v0.3.1): real survival patterns — biome-aware scout, wedge-relocate, escape-pit-safe

Operator reported the bot wandered the same 50×50 patch for 2 hours
without making any progress toward food. Diagnosis showed three root
causes; this commit addresses all five open improvement_requests
the LLM (postmortem + tuner) flagged automatically.

Research basis (`Voyager`, `Plan4MC`, `GITM`, `Mindcraft`):
  - Coverage / commit-to-cardinal exploration when local scan fails
  - Biome-aware strategy switching using a static affordance table
  - Wedge detector above the skill layer that triggers RELOCATE not
    RETRY (per-skill stuck checks reset on re-entry — useless)
  - Time-in-region bbox heuristic + need-duration AND skill-cycle gate

Concrete changes:

1. `runtime/goal/storyline.js`
   - orient_self.completed: added timeout fallback (HP=full + session
     >120s → done) so barren biomes don't block the bot on step 1.
     Closes improvement #2 'Нет навыка оценки когда сменить район'.
   - first_food.suggestSkill: now picks survive.scout-food (new) when
     no passive mob is nearby; falls back to survive.acquire-food only
     when something is in immediate range.

2. `runtime/biome-affordances.js` (new)
   - Static table: 40+ biomes → {has_passive_mobs, has_trees,
     has_water, has_crops, livable}.
   - Unknown biomes return optimistic defaults to avoid regressions.
   - Closes improvement #1 'Нет навыка целевого поиска еды по биому'.

3. `runtime/skills/scout-food.js` (new — survive.scout-food)
   - Tiered strategy: biome check → scan 32 → scan 64 → commit a
     cardinal for 200 blocks rescanning every 16. On cardinal
     exhaustion, returns code:"exhausted" so the curriculum can
     escalate to village.relocate.
   - In barren biomes (desert/ocean/snowy_plains) the scan is
     SKIPPED — bot walks straight toward the nearest neighbour
     biome that affords passive mobs (8-direction biome probe at
     radius 64).

4. `runtime/awareness/wedge-detector.js` (new)
   - Rolling 10-min position bbox tracker. observe() called every
     tick; isWedged() returns true when bbox<50 AND active need
     unmet >5min AND skill cycles ≥3.
   - markRelocationStarted() suppresses further wedge firings
     until the bot has displaced ≥200b — prevents stack overflow
     of relocate calls.
   - Lives ABOVE the skill layer (in runtime/reflex.js), because
     any per-skill stuck check resets on re-entry.

5. `runtime/skills/relocate.js` (new — village.relocate)
   - 300-block walk in least-recently-used cardinal (per-incident
     memory in ctx.recentRelocations).
   - Re-paths every 32 blocks, soft-tolerates pathfinder failures
     (3 consecutive throws → exit with code:"stuck_in_place").
   - Closes improvement #2 + #4 ('low success rate trigger').

6. `runtime/skills/escape-pit-safe.js` (new — recovery.escape-pit-safe)
   - Surveys 4 cardinals AND ceiling height before committing.
     Picks the direction with most open blocks (≥3, no lava).
     Falls through to pillar-up only if ceiling clear ≥4b. Returns
     code:"no_strategy" if both blocked so curriculum can escalate
     to relocate.
   - Closes improvement #3 'Нет навыка для безопасного выхода'.

7. `runtime/reflex.js`
   - Wedge detector wired before manifesto/storyline. If wedge.wedged
     is true, dispatches village.relocate directly and returns —
     bypasses every other branch.
   - ctx.disableWedge flag for tests.

8. `runtime/coach/advisor-trigger.js`
   - LLM provider outage backoff: 3 consecutive http_400 / timeout /
     network_error → suppress advisor for 10 min. Today's TimeWeb
     gpt-5.4-mini was 400'ing for an hour straight; we were spending
     trigger budget on dead calls. Closes improvement implicit gap
     in #5.

9. `runtime/bot.js`
   - Tracks botSpawnedAt; snapshot._sessionMs exposed for storyline
     orient_self timeout fallback.

Tests: 396 green (was 378, +18):
  - runtime/biome-affordances.test.js — 8 tests
  - runtime/awareness/wedge-detector.test.js — 9 tests
  - runtime/goal/storyline.test.js — 1 new test (orient_self timeout)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(coach/trigger-tuner): crash after ~1h — runOnce is sync, not a Promise

The live bot died overnight with:
  TypeError: runOnce(...).catch is not a function
  at trigger-tuner.js:42  →  [supervisor] child exited code=1

attach() wrapped the timer body as `runOnce().catch(...)` but
runOnce() returns a plain {ok, flagged, ...} object (pure SQL, no
await). The first tuner tick (60min after spawn) threw → killed the
whole bot process. Never surfaced before because the bot rarely ran
uninterrupted for a full hour during development.

Fix: guard the synchronous call with try/catch, matching how
persona/chatter.js already does its sync tick. (postmortem.drainOnce
and reflect.runOnce ARE async, so their .catch is correct — audited.)

Regression test added: captures the setInterval callback and invokes
it synchronously, asserting it does not throw.

Tests: 397 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(v0.3.1): mechanical food/stuck fixes — bot reaches the chicken now

The wedge wasn't only in the manifesto layer; several mechanical bugs
kept the bot in a dead random-walk:

- storyline / manifesto / curriculum: "local food" now means an edible
  passive mob within <=32 blocks. A distant chicken or a cod no longer
  fools the bot into dispatching acquire-food (which then fails on
  no_path). Long-range food goes through scout-food instead.

- scout-food: partial approach to a target now counts as progress
  (approached_target, e.g. moved:14); a blocked heading is NOT counted
  as movement; added blind/tunnel fallback so it doesn't die when the
  pathfinder can't route cleanly.

- acquire-food: on no_path it now also tries a blind/tunnel approach to
  the animal; no_drop routes back into food scouting instead of giving
  up.

- explore.far / relocate / flee: fewer false "done" results (micro-steps
  no longer counted as success), more genuine escapes from stuck.

- scripts/show-story.js: live IPC now actually renders the current
  storyline step.

Verification: scripts/lint-patch.js clean; npm test 404/404 green; bot
relaunched in tmux `pepa`. Live logs show real progress — bot switched
to survive.scout-food, approached the chicken (approached_target
moved:14), then reached survive.acquire-food: hunting chicken. Food
isn't fully closed yet but the remaining issue is concrete pickup/drop,
not dead random-walk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(v0.3.1): sated bot stops chasing food + perf-leak + fuzzy improvement dedup

Third day of "bot just walks back and forth burning tokens". Root
causes were mechanical, not the manifesto:

1. SATED BOT CHASING FOOD (the big one)
   Bot had food=17 (nearly full) but storyline first_food + manifesto
   L1 required 2+ food ITEMS in inventory, so it looped scout-food /
   acquire-food for hours instead of working. Now both treat a hunger
   bar >= 14 (SATED_FOOD) as satisfied even with empty food inventory —
   a full bot chops wood / makes tools and grabs food opportunistically,
   only hard-pursuing food when actually hungry (< 14).
   manifesto/needs.js foodDetect + goal/storyline.js first_food.completed.

2. perf_hooks MEMORY LEAK (overnight OOM suspect)
   "MaxPerformanceEntryBufferExceededWarning: 1,000,001 measure entries".
   mineflayer/pathfinder emit perf marks we never consume. Added a
   60s reaper in bot.js (performance.clearMeasures/clearMarks). unref'd.

3. IMPROVEMENT QUEUE SELF-DUPLICATING
   The LLM re-filed closed gaps with reworded titles (#5/#8/#9 were
   dupes of implemented #1/#2/#3). Exact-title dedup missed them.
   Replaced with token-set fuzzy match (isDuplicateTitle): jaccard>=0.75
   OR >=3 shared meaningful tokens with jaccard>=0.5. Also: a re-filed
   gap that's already implemented/rejected is NOT resurrected as a new
   open row. Cleared all 5 open requests (now genuinely implemented).

Also confirmed (no change needed):
- canDig=true is a DELIBERATE codebase-wide choice ("without it the bot
  gets permanently stuck", actions.js). The stale memory recommending
  canDig=false is updated. ViaBackwards dig works partially (dug:1
  moved:1.8 observed); false would trap the bot in every pit.
- scout-food already has blind/tunnel fallback + 12s step timeout
  (operator's earlier edits) so trapped-pathfinder degrades instead of
  hanging 30s.

Tests: 407 green (was 404). Updated needs/state/storyline tests for the
SATED_FOOD threshold; added fuzzy-dedup + tokenize/jaccard tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:43:57 +03:00

472 lines
16 KiB
JavaScript
Raw 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.
// 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;
}
}
// ---- v0.3.0 advisor recommendations ----------------------------------------
//
// Every fast-advisor call that produced a usable answer is logged here.
// Rows are mutated post-hoc when reflex applies and when the dispatch
// finishes — this is the ground truth for "is the LLM advice actually
// helping" and the input to trigger-tuner.js.
export function insertRecommendation({
triggerReason, plannedSkill, recommendedSkill, action, rationale,
activeNeed, tokensIn, tokensOut, latencyMs,
} = {}) {
if (!_isAvailable()) return null;
try {
const res = _getStore().prepare(`
INSERT INTO advisor_recommendations
(ts, trigger_reason, planned_skill, recommended_skill, action, rationale,
active_need, tokens_in, tokens_out, latency_ms, applied)
VALUES
(@ts, @triggerReason, @plannedSkill, @recommendedSkill, @action, @rationale,
@activeNeed, @tokensIn, @tokensOut, @latencyMs, 0)
`).run({
ts: Date.now(),
triggerReason,
plannedSkill: plannedSkill ?? null,
recommendedSkill: recommendedSkill ?? null,
action,
rationale: rationale ?? null,
activeNeed: activeNeed ?? null,
tokensIn: tokensIn ?? null,
tokensOut: tokensOut ?? null,
latencyMs: latencyMs ?? null,
});
return res.lastInsertRowid;
} catch (e) {
warn("knowledge", `insertRecommendation failed: ${e?.message ?? e}`);
return null;
}
}
export function markRecommendationApplied(id) {
if (!_isAvailable() || !id) return;
try {
_getStore().prepare(`UPDATE advisor_recommendations SET applied = 1 WHERE id = ?`).run(id);
} catch (e) {
warn("knowledge", `markRecommendationApplied failed: ${e?.message ?? e}`);
}
}
export function markRecommendationOutcome(id, { ok, code } = {}) {
if (!_isAvailable() || !id) return;
try {
_getStore().prepare(`
UPDATE advisor_recommendations
SET outcome_ok = @ok, outcome_code = @code, outcome_at = @at
WHERE id = @id
`).run({ id, ok: ok ? 1 : 0, code: code ?? null, at: Date.now() });
} catch (e) {
warn("knowledge", `markRecommendationOutcome failed: ${e?.message ?? e}`);
}
}
export function recommendationStats({ sinceHours = 24 } = {}) {
if (!_isAvailable()) return [];
try {
const since = Date.now() - sinceHours * 3600_000;
return _getStore().prepare(`
SELECT trigger_reason,
COUNT(*) AS total,
SUM(applied) AS applied,
SUM(CASE WHEN outcome_ok = 1 THEN 1 ELSE 0 END) AS succeeded,
SUM(CASE WHEN outcome_ok = 0 THEN 1 ELSE 0 END) AS failed,
AVG(tokens_in) AS avg_in,
AVG(tokens_out) AS avg_out,
AVG(latency_ms) AS avg_latency_ms
FROM advisor_recommendations
WHERE ts >= @since
GROUP BY trigger_reason
ORDER BY total DESC
`).all({ since });
} catch (e) {
warn("knowledge", `recommendationStats failed: ${e?.message ?? e}`);
return [];
}
}
export function recentRecommendations({ limit = 20 } = {}) {
if (!_isAvailable()) return [];
try {
return _getStore().prepare(`
SELECT * FROM advisor_recommendations ORDER BY ts DESC LIMIT @limit
`).all({ limit });
} catch (e) {
warn("knowledge", `recentRecommendations failed: ${e?.message ?? e}`);
return [];
}
}
// ---- v0.3.0 improvement requests -------------------------------------------
//
// The LLM (postmortem / reflect / advisor) writes here when it sees the bot
// lack a needed skill or feature. Operator-readable via scripts/list-improvements.js.
export function createImprovementRequest({
source, category, title, description, context, priority = 3,
} = {}) {
if (!_isAvailable() || !title) return null;
try {
// Fuzzy dedup: the LLM re-files the same gap with reworded titles
// ("целевого поиска еды" vs "целевого дальнего поиска еды по биому").
// Exact-title match misses these, so we compare normalized token
// sets against ALL recent requests (any status — a closed/
// implemented one shouldn't reappear as a fresh open row). On a
// strong overlap we bump votes (if still open) and return, instead
// of inserting a near-duplicate.
const incoming = tokenize(title);
const candidates = _getStore().prepare(`
SELECT id, title, status, votes FROM improvement_requests
ORDER BY ts DESC LIMIT 60
`).all();
for (const c of candidates) {
if (isDuplicateTitle(incoming, tokenize(c.title))) {
// Re-flagged gap. If it's still open, count the vote. If it
// was implemented/rejected, do NOT resurrect it — just
// return its id so the caller treats it as "already known".
if (c.status === "open") {
_getStore().prepare(`UPDATE improvement_requests SET votes = votes + 1 WHERE id = ?`).run(c.id);
}
return c.id;
}
}
const res = _getStore().prepare(`
INSERT INTO improvement_requests
(ts, source, category, title, description, context, priority, status, votes)
VALUES
(@ts, @source, @category, @title, @description, @context, @priority, 'open', 1)
`).run({
ts: Date.now(),
source: source ?? "manual",
category: category ?? "other",
title,
description: description ?? null,
context: context ? JSON.stringify(context) : null,
priority: clamp(priority, 1, 5),
});
return res.lastInsertRowid;
} catch (e) {
warn("knowledge", `createImprovementRequest failed: ${e?.message ?? e}`);
return null;
}
}
export function listImprovements({ status, source, category, limit = 50 } = {}) {
if (!_isAvailable()) return [];
try {
const where = [];
const params = { limit };
if (status) { where.push("status = @status"); params.status = status; }
if (source) { where.push("source = @source"); params.source = source; }
if (category) { where.push("category = @category"); params.category = category; }
const sql = `
SELECT * FROM improvement_requests
${where.length ? "WHERE " + where.join(" AND ") : ""}
ORDER BY (status = 'open') DESC, priority ASC, votes DESC, ts DESC
LIMIT @limit
`;
return _getStore().prepare(sql).all(params).map((r) => ({
...r,
context: safeParse(r.context),
}));
} catch (e) {
warn("knowledge", `listImprovements failed: ${e?.message ?? e}`);
return [];
}
}
export function markImprovementStatus(id, { status, notes } = {}) {
if (!_isAvailable() || !id) return;
const validStatuses = ["open", "in_progress", "implemented", "rejected", "duplicate"];
if (!validStatuses.includes(status)) {
warn("knowledge", `markImprovementStatus: invalid status "${status}"`);
return;
}
try {
const fields = ["status = @status", "notes = @notes"];
const params = { id, status, notes: notes ?? null };
if (status === "implemented") {
fields.push("implemented_at = @implementedAt");
params.implementedAt = Date.now();
}
_getStore().prepare(`UPDATE improvement_requests SET ${fields.join(", ")} WHERE id = @id`).run(params);
} catch (e) {
warn("knowledge", `markImprovementStatus failed: ${e?.message ?? e}`);
}
}
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, Number(n) || lo)); }
// Normalize a title into a set of meaningful tokens for fuzzy dedup.
// Lowercase, strip punctuation, drop short / stop words (RU + EN) that
// carry no signal ("нет", "навыка", "для", "the", "a", …).
const _STOP = new Set([
"нет", "навык", "навыка", "для", "из", "по", "в", "на", "и", "с", "к",
"когда", "нужно", "это", "что", "the", "a", "an", "to", "of", "for",
"no", "skill", "has", "is", "low", "rate",
]);
function tokenize(s) {
if (!s || typeof s !== "string") return new Set();
const words = s.toLowerCase()
.replace(/["'`(),.:;!?\/\\-]+/g, " ")
.split(/\s+/)
.filter((w) => w.length >= 3 && !_STOP.has(w));
return new Set(words);
}
function jaccard(a, b) {
if (a.size === 0 || b.size === 0) return 0;
let inter = 0;
for (const w of a) if (b.has(w)) inter++;
return inter / (a.size + b.size - inter);
}
function intersectionSize(a, b) {
let n = 0;
for (const w of a) if (b.has(w)) n++;
return n;
}
// Two titles are "the same gap" when they either overlap very strongly
// (jaccard ≥ 0.75) OR share at least 3 meaningful tokens with moderate
// overlap (≥ 0.5). The 3-token floor stops short titles with one or two
// generic words in common ("low-prio thing" vs "high-prio thing") from
// false-matching, while still catching reworded long titles.
function isDuplicateTitle(a, b) {
const j = jaccard(a, b);
if (j >= 0.75) return true;
return intersectionSize(a, b) >= 3 && j >= 0.5;
}
function safeParse(s) {
if (!s) return null;
try { return JSON.parse(s); } catch { return null; }
}
// Test exports
export const __testing = { tokenize, jaccard, isDuplicateTitle };