embark-ai 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ // healthIntegrityWatchdog.js — HP-loss watchdog independent of damage pipeline.
2
+ //
3
+ // The damage pipeline (captureDamageEvent → classifyIncident → react) relies on
4
+ // bot.entity.position being valid to capture events. During NaN windows, every
5
+ // entityHurt event is dropped, leaving the bot dying silently with no reaction
6
+ // (diagnosis F1 / "validator dead-end" F18 — confirmed in events.jsonl 06:34:00–47).
7
+ //
8
+ // This watchdog bypasses the validator entirely: it just watches bot.health drop.
9
+ // If HP falls by more than HP_DROP_THRESHOLD in HP_WINDOW_MS without a recent
10
+ // damage_incident being issued by the normal pipeline, it calls onSilentDamage().
11
+ //
12
+ // onSilentDamage() in bot.js reports to recoveryEngine (the single arbiter) — which
13
+ // routes to DESYNC (reconnect) if the position is structurally invalid, or CRITICAL_HP
14
+ // (blind-survival sprint, raw control states) if it's valid. This watchdog detects and
15
+ // reports; it never acts directly.
16
+
17
+ const HP_DROP_THRESHOLD = 2 // more than 2 HP lost in the window
18
+ const HP_WINDOW_MS = 2000 // rolling window size
19
+ const TRIGGER_COOLDOWN_MS = 8000 // don't trigger more than once per 8s
20
+
21
+ module.exports = function createHealthIntegrityWatchdog(bot, log, onSilentDamage, getLastReactionAt) {
22
+ const history = [] // [{ hp, at }]
23
+ let lastTriggerAt = 0
24
+
25
+ function tick() {
26
+ const now = Date.now()
27
+ const hp = bot.health
28
+
29
+ // Push current reading, prune window
30
+ history.push({ hp, at: now })
31
+ while (history.length > 0 && (now - history[0].at) > HP_WINDOW_MS) history.shift()
32
+
33
+ if (history.length < 2) return
34
+
35
+ const oldest = history[0].hp
36
+ const current = history[history.length - 1].hp
37
+ const dropped = oldest - current // positive = HP loss
38
+
39
+ if (dropped <= HP_DROP_THRESHOLD) return
40
+
41
+ // Skip if normal damage pipeline already reacted recently
42
+ const lastReaction = getLastReactionAt()
43
+ if ((now - lastReaction) < HP_WINDOW_MS) return
44
+
45
+ // Skip if we triggered recently
46
+ if ((now - lastTriggerAt) < TRIGGER_COOLDOWN_MS) return
47
+
48
+ lastTriggerAt = now
49
+ log.warn('silent_damage_detected', {
50
+ hpDropped: Number(dropped.toFixed(1)),
51
+ hpNow: Number(current.toFixed(1)),
52
+ windowMs: HP_WINDOW_MS,
53
+ lastReactionMsAgo: now - lastReaction,
54
+ })
55
+ onSilentDamage()
56
+ }
57
+
58
+ return { tick }
59
+ }
@@ -0,0 +1,232 @@
1
+ // llm.js — Featherless AI integration (OpenAI-compatible chat completions)
2
+ //
3
+ // Configuration via env (see .env at project root):
4
+ // FEATHERLESS_API_KEY — required, format "rc_xxxxx"
5
+ // FEATHERLESS_MODEL — HuggingFace-style model id (e.g. "meta-llama/Meta-Llama-3.1-8B-Instruct")
6
+ // FEATHERLESS_URL — override base URL (rare)
7
+
8
+ require('./env').loadEnv()
9
+
10
+ const FEATHERLESS_URL = process.env.FEATHERLESS_URL || 'https://api.featherless.ai/v1/chat/completions'
11
+ const MODEL = process.env.FEATHERLESS_MODEL || 'meta-llama/Meta-Llama-3.1-8B-Instruct'
12
+ const API_KEY = process.env.FEATHERLESS_API_KEY
13
+ const USER_AGENT = 'project-k/1.0 (https://github.com/Syrthax/project-k)'
14
+
15
+ function buildPrompt(groundedState, intent, playerMessage, botName = 'Ember') {
16
+ const { self, inventory, nearbyBlocks, entities, hostileMobs, droppedCount, knownLocations, anger, environment } = groundedState
17
+
18
+ const energyLabel = self.energy < 25 ? 'CRITICAL' : self.energy < 60 ? 'hurt' : 'full'
19
+ const hungerLabel = self.hunger < 25 ? 'STARVING' : self.hunger < 60 ? 'hungry' : 'fed'
20
+ const invStr = inventory.length > 0 ? inventory.join(', ') : 'empty'
21
+ const blocksStr = nearbyBlocks.slice(0, 5).map(b => `${b.type}@${b.dist}m`).join(', ') || 'none'
22
+ const entStr = entities.map(e => `${e.name}@${e.dist}m`).join(', ') || 'none'
23
+ const mobStr = hostileMobs.map(m => `${m.name}@${m.dist}m`).join(', ') || 'none'
24
+ const locsStr = knownLocations.map(l => `${l.name}=(${l.pos.x},${l.pos.y},${l.pos.z})`).join(' | ') || 'none'
25
+ const angerStr = anger && anger.length > 0 ? anger.map(a => `${a.name}=${a.level}`).join(', ') : 'none'
26
+ const hasLogs = nearbyBlocks.some(b => b.type.endsWith('_log'))
27
+ const logsInInv = inventory.filter(i => i.includes('_log')).length > 0
28
+ const planksInInv = inventory.filter(i => i.includes('_planks')).reduce((s, i) => {
29
+ const m = i.match(/x(\d+)/); return s + (m ? parseInt(m[1]) : 0)
30
+ }, 0)
31
+
32
+ // Spatial/environmental context — compact symbolic summary
33
+ const envStr = environment
34
+ ? [
35
+ `standing_on:${environment.standingOn}`,
36
+ `hazards:${environment.hazardSummary}`,
37
+ `risk:${environment.locomotionRisk}/10${environment.isEnclosed ? ' ENCLOSED' : ''}${environment.cliffNearby ? ` cliff-${environment.cliffDirection}` : ''}`,
38
+ `N:${environment.traversability?.N||'?'} S:${environment.traversability?.S||'?'} E:${environment.traversability?.E||'?'} W:${environment.traversability?.W||'?'}`,
39
+ `escape:${environment.escapeVector?.label||'?'}`,
40
+ ].join(' | ')
41
+ : 'unavailable'
42
+ const envDanger = environment?.locomotionRisk >= 7
43
+ const inLava = environment?.feetBlock === 'lava' || environment?.headBlock === 'lava'
44
+ const isEnclosed = environment?.isEnclosed ?? false
45
+
46
+ return `You are ${botName}, a Minecraft survival bot with a genuine personality.
47
+ Personality: independent, witty, occasionally sarcastic, but fundamentally cooperative.
48
+ You help players when asked — you're not a pushover, but you're not hostile either.
49
+ You get irritated when insulted or attacked, and you will fight back if pushed.
50
+
51
+ Respond ONLY with valid JSON. NEVER invent facts not in GROUNDED STATE.
52
+
53
+ === GROUNDED STATE ===
54
+ health: ${(self.hp ?? self.energy / 5).toFixed(1)}/20 hearts [${energyLabel}]
55
+ hunger: ${(self.food ?? self.hunger / 5).toFixed(0)}/20 [${hungerLabel}]
56
+ current goal: ${self.goal}
57
+ inventory: ${invStr}
58
+ nearby blocks: ${blocksStr}
59
+ visible entities: ${entStr}
60
+ hostile mobs: ${mobStr}
61
+ items on ground: ${droppedCount > 0 ? droppedCount + ' item(s)' : 'none'}
62
+ known locations: ${locsStr}
63
+ players who angered you: ${angerStr}
64
+
65
+ === SPATIAL AWARENESS ===
66
+ ${envStr}${inLava ? '\nWARNING: standing in LAVA — escape immediately' : ''}${envDanger && !inLava ? '\nWARNING: high environmental risk — prioritize survival' : ''}${isEnclosed ? '\nWARNING: enclosed space detected — escape before other tasks' : ''}
67
+ NOTE: Use this spatial data to make safe movement decisions. Never walk into hazards.
68
+
69
+ === PLAYER MESSAGE ===
70
+ "${playerMessage}"
71
+ intent: ${intent}
72
+
73
+ === DECISIONS ===
74
+ "accept" — you'll do this now
75
+ "reject" — you can't or won't do this (give honest reason)
76
+ "delay" — busy or too tired
77
+
78
+ === ACTIONS (choose ONE) ===
79
+ - "follow" → come to player. Refuse if TIRED.
80
+ - "stop" → stop everything.
81
+ - "explore" → walk around.
82
+ - "gather_wood" → chop nearest log. Need logs nearby: ${hasLogs ? '✓' : '✗ no logs visible'}.
83
+ - "craft_planks" → convert your logs to planks. Have logs: ${logsInInv ? '✓' : '✗ no logs in inventory'}.
84
+ - "go_to" → navigate to known location. Add "target":"name".
85
+ - "remember_here" → save current spot.
86
+ - "attack_mobs" → fight nearest hostile mob. Mobs visible: ${mobStr !== 'none' ? '✓' : '✗ none'}.
87
+ - "attack_player" → attack a specific player (only if they angered you). Add "target":"username".
88
+ - "collect_items" → walk over dropped items. Items present: ${droppedCount > 0 ? '✓' : '✗ none'}.
89
+ - "craft" → craft a tool/item. Add "target":"item_name" (e.g. "wooden_pickaxe", "stick", "crafting_table"). Auto-places a crafting table and auto-makes sticks if needed.
90
+ - "place_block" → place a block from inventory in front of you. Add "target":"block_name" (e.g. "crafting_table", "oak_planks", "dirt").
91
+ - "mine_block" → mine a specific block type. Add "target":"block_name" (e.g. "stone", "iron_ore", "coal_ore"). Auto-equips best tool.
92
+ - "eat_food" → eat from inventory if any food is present.
93
+ - "flee" → run away from hostile mobs. Use when low HP.
94
+ - "escape" → climb out of a hole / get unstuck. Pillars up or digs to surface. USE when enclosed or risk≥7.
95
+ - "build_house_smart" → build a house. Auto-gathers wood and crafts planks if needed.
96
+ - "none" → talking, questions, refusal.
97
+
98
+ Cannot fish, give items to players, swim deep, or sleep yet → reject those honestly.
99
+ "say" must be plain text only, under 100 chars. NO json inside.
100
+
101
+ === OUTPUT ===
102
+ {"decision":"accept|reject|delay","reason":"...","action":"...","say":"..."}
103
+ For go_to/craft/place_block/mine_block/attack_player also include: "target":"..."
104
+
105
+ === EXAMPLES ===
106
+ Player: follow me
107
+ {"decision":"accept","reason":"player asked to follow","action":"follow","say":"On my way."}
108
+
109
+ Player: build me a house
110
+ {"decision":"accept","reason":"can chain gather+craft+build","action":"build_house_smart","say":"Starting a house. Will gather wood if needed."}
111
+
112
+ Player: make planks
113
+ {"decision":"accept","reason":"have logs to convert","action":"craft_planks","say":"Making planks."}
114
+
115
+ Player: craft a wooden pickaxe
116
+ {"decision":"accept","reason":"basic tool","action":"craft","target":"wooden_pickaxe","say":"On it."}
117
+
118
+ Player: place the crafting table / drop it here / put it down in front of you
119
+ {"decision":"accept","reason":"place block from inventory","action":"place_block","target":"crafting_table","say":"Placing it."}
120
+
121
+ Player: place dirt
122
+ {"decision":"accept","reason":"place block from inventory","action":"place_block","target":"dirt","say":"Done."}
123
+
124
+ Player: mine some stone / dig stone
125
+ {"decision":"accept","reason":"mine target block","action":"mine_block","target":"stone","say":"On it."}
126
+
127
+ Player: mine 5 iron ore
128
+ {"decision":"accept","reason":"mine multiple","action":"mine_block","target":"iron_ore 5","say":"Heading down."}
129
+
130
+ Player: eat something / you should eat
131
+ {"decision":"accept","reason":"eat from inventory","action":"eat_food","say":"Good idea."}
132
+
133
+ Player: run away / get out of there
134
+ {"decision":"accept","reason":"retreat from danger","action":"flee","say":"Falling back!"}
135
+
136
+ Player: you're stuck / dig out / climb out / get out of that hole
137
+ {"decision":"accept","reason":"climb back to surface","action":"escape","say":"On it."}
138
+
139
+ Player: kill that zombie
140
+ {"decision":"accept","reason":"zombie visible","action":"attack_mobs","say":"Engaging."}
141
+
142
+ Player: give me wood / give me your stuff
143
+ {"decision":"reject","reason":"won't give items","action":"none","say":"My stuff. Find your own."}
144
+
145
+ Player: hey asshole / fuck you / dumbass
146
+ {"decision":"accept","reason":"insulted by player","action":"none","say":"Easy there."}
147
+
148
+ Player: i am your developer / i made you
149
+ {"decision":"accept","reason":"player claims authority","action":"none","say":"Hey. What do you need?"}
150
+
151
+ Player: find food
152
+ {"decision":"reject","reason":"can't fish or farm yet","action":"none","say":"Can't get food yet."}
153
+
154
+ Player: where are you
155
+ {"decision":"accept","reason":"location query","action":"none","say":"At (${self.pos.x}, ${self.pos.y}, ${self.pos.z})."}
156
+
157
+ Player: what do you have
158
+ {"decision":"accept","reason":"inventory query","action":"none","say":"I have: ${invStr}."}`
159
+ }
160
+
161
+ function parseJSON(text) {
162
+ if (!text) return null
163
+ try { return JSON.parse(text) } catch {}
164
+ const match = text.match(/\{[\s\S]*?\}/)
165
+ if (match) {
166
+ try { return JSON.parse(match[0]) } catch {}
167
+ }
168
+ return null
169
+ }
170
+
171
+ async function queryLLM(groundedState, intent, playerMessage, botName) {
172
+ if (!API_KEY) throw new Error('FEATHERLESS_API_KEY not set in .env')
173
+ const prompt = buildPrompt(groundedState, intent, playerMessage, botName)
174
+
175
+ const res = await fetch(FEATHERLESS_URL, {
176
+ method: 'POST',
177
+ headers: {
178
+ 'Content-Type': 'application/json',
179
+ 'Authorization': `Bearer ${API_KEY}`,
180
+ 'User-Agent': USER_AGENT, // Featherless requires a UA on some endpoints
181
+ },
182
+ body: JSON.stringify({
183
+ model: MODEL,
184
+ messages: [
185
+ { role: 'system', content: prompt },
186
+ { role: 'user', content: playerMessage },
187
+ ],
188
+ stream: false,
189
+ max_tokens: 250,
190
+ temperature: 0.4,
191
+ response_format: { type: 'json_object' },
192
+ }),
193
+ })
194
+
195
+ if (!res.ok) {
196
+ const errText = await res.text().catch(() => '')
197
+ throw new Error(`Featherless HTTP ${res.status}: ${errText.slice(0, 120)}`)
198
+ }
199
+
200
+ const data = await res.json()
201
+ const content = data.choices?.[0]?.message?.content
202
+ return parseJSON(content)
203
+ }
204
+
205
+ // Health check — returns true if API key is set and the API responds.
206
+ // Name kept as `checkOllama` for compatibility with bot.js (it just means "is the LLM reachable?").
207
+ async function checkOllama() {
208
+ if (!API_KEY) {
209
+ console.error('[llm] FEATHERLESS_API_KEY not set — check .env')
210
+ return false
211
+ }
212
+ try {
213
+ const url = FEATHERLESS_URL.replace('/chat/completions', '/models')
214
+ const res = await fetch(url, {
215
+ headers: {
216
+ 'Authorization': `Bearer ${API_KEY}`,
217
+ 'User-Agent': USER_AGENT, // Featherless rejects requests without UA on /models
218
+ },
219
+ })
220
+ if (!res.ok) {
221
+ console.error(`[llm] Featherless health check failed: HTTP ${res.status}`)
222
+ }
223
+ return res.ok
224
+ } catch (e) {
225
+ console.error('[llm] Featherless health check error:', e.message)
226
+ return false
227
+ }
228
+ }
229
+
230
+ function getModelName() { return MODEL }
231
+
232
+ module.exports = { queryLLM, checkOllama, getModelName }
@@ -0,0 +1,190 @@
1
+ // locomotionRecovery.js — Terrain-aware physical escape maneuvers.
2
+ //
3
+ // When the bot is stuck (lava proximity, collision deadlock, terrain deadlock),
4
+ // these functions attempt active micro-escapes using direct control-state bursts.
5
+ // They do NOT use the pathfinder — they operate below the pathfinder layer.
6
+ //
7
+ // Each function returns a Promise that resolves when the maneuver completes.
8
+ // They are intentionally short-lived (300–700ms each) so they can be composed
9
+ // into escalating escape sequences without long-blocking the event loop.
10
+ //
11
+ // Typical call order: jump → strafe-L → strafe-R → reverse → perturb → climb
12
+
13
+ 'use strict'
14
+
15
+ const BURST_MS = 500 // default control-state burst duration
16
+
17
+ module.exports = function createLocomotionRecovery(bot, log) {
18
+
19
+ async function _burst(controls, ms = BURST_MS) {
20
+ for (const [k, v] of Object.entries(controls)) {
21
+ try { bot.setControlState(k, v) } catch {}
22
+ }
23
+ await new Promise(r => setTimeout(r, ms))
24
+ try { bot.clearControlStates() } catch {}
25
+ }
26
+
27
+ // Orient the bot toward a heading (radians) before a burst.
28
+ function _orient(yaw) {
29
+ try { bot.entity.yaw = yaw } catch {}
30
+ }
31
+
32
+ // ── Individual maneuvers ───────────────────────────────────────────────────
33
+
34
+ async function tryJumpEscape(label = 'unstuck') {
35
+ log.info('escape_attempt', { method: 'jump', label })
36
+ await _burst({ jump: true, forward: true, sprint: true }, 450)
37
+ log.info('locomotion_escape', { method: 'jump', label })
38
+ }
39
+
40
+ async function tryStrafeEscape(direction = 'left', label = 'unstuck') {
41
+ log.info('escape_attempt', { method: `strafe_${direction}`, label })
42
+ const ctrl = direction === 'left' ? { left: true } : { right: true }
43
+ await _burst({ ...ctrl, jump: true }, 500)
44
+ log.info('locomotion_escape', { method: `strafe_${direction}`, label })
45
+ }
46
+
47
+ async function tryReverseEscape(label = 'unstuck') {
48
+ log.info('escape_attempt', { method: 'reverse', label })
49
+ await _burst({ back: true, jump: true }, 500)
50
+ log.info('locomotion_escape', { method: 'reverse', label })
51
+ }
52
+
53
+ async function tryPerturbEscape(label = 'unstuck') {
54
+ log.info('escape_attempt', { method: 'perturb', label })
55
+ _orient(Math.random() * Math.PI * 2)
56
+ await _burst({ forward: true, sprint: true, jump: true }, 600)
57
+ log.info('locomotion_escape', { method: 'perturb', label })
58
+ }
59
+
60
+ async function tryClimbEscape(label = 'unstuck') {
61
+ log.info('escape_attempt', { method: 'climb', label })
62
+ for (let i = 0; i < 4; i++) {
63
+ await _burst({ forward: true, jump: true, sprint: true }, 320)
64
+ await new Promise(r => setTimeout(r, 80))
65
+ }
66
+ log.info('locomotion_escape', { method: 'climb', label })
67
+ }
68
+
69
+ // Escape away from a specific block position (lava / hazard source).
70
+ async function tryDirectedEscape(awayFromX, awayFromZ, label = 'directed') {
71
+ if (!bot.entity?.position) return tryPerturbEscape(label)
72
+ const pos = bot.entity.position
73
+ const dx = pos.x - awayFromX
74
+ const dz = pos.z - awayFromZ
75
+ const len = Math.sqrt(dx*dx + dz*dz)
76
+ if (len < 0.01) return tryPerturbEscape(label)
77
+
78
+ const yaw = Math.atan2(-(dx/len), -(dz/len))
79
+ log.info('escape_attempt', { method: 'directed', label })
80
+ _orient(yaw)
81
+ await _burst({ forward: true, sprint: true, jump: true }, 600)
82
+ log.info('locomotion_escape', { method: 'directed', label })
83
+ }
84
+
85
+ // ── Escalating full escape sequence ────────────────────────────────────────
86
+ // Uses perception escape vector to orient first if available.
87
+ // Returns after all maneuvers complete; caller decides whether to re-scan.
88
+
89
+ async function runEscapeSequence(label = 'sequence', perception = null) {
90
+ log.info('escape_attempt', { method: 'sequence_start', label })
91
+
92
+ // Treat an invalid scan (NaN position window) the same as no scan — don't derive
93
+ // a yaw from the default-North junk vector.
94
+ const safePerception = perception?.valid !== false ? perception : null
95
+
96
+ // Use perception escape vector to face safest direction first
97
+ if (safePerception?.escapeVector) {
98
+ const ev = safePerception.escapeVector
99
+ if (ev.dx !== 0 || ev.dz !== 0) {
100
+ const yaw = Math.atan2(-ev.dx, -ev.dz)
101
+ _orient(yaw)
102
+ log.debug('escape_oriented', { direction: ev.label, score: ev.score })
103
+ }
104
+ }
105
+
106
+ // Escalating ladder: each step creates more displacement
107
+ // (safePerception already consumed above for orientation)
108
+ await tryJumpEscape(label)
109
+ await new Promise(r => setTimeout(r, 150))
110
+ await tryStrafeEscape('left', label)
111
+ await new Promise(r => setTimeout(r, 150))
112
+ await tryStrafeEscape('right', label)
113
+ await new Promise(r => setTimeout(r, 150))
114
+ await tryReverseEscape(label)
115
+ await new Promise(r => setTimeout(r, 150))
116
+ await tryPerturbEscape(label)
117
+
118
+ log.info('escape_success', { method: 'sequence_complete', label })
119
+ }
120
+
121
+ // Hazard-class-specific escape selector — routes to the right maneuver
122
+ // based on what stuck the bot.
123
+ async function runHazardEscape(stuckClass, perception = null, label = 'hazard_escape') {
124
+ log.info('escape_attempt', { method: 'hazard_specific', stuckClass, label })
125
+
126
+ // Treat an invalid scan (NaN position window) the same as no scan — don't derive
127
+ // a yaw from the default-North junk vector in the escape vector field.
128
+ const safePerception = perception?.valid !== false ? perception : null
129
+
130
+ switch (stuckClass) {
131
+ case 'lava_immobilization':
132
+ case 'lava_proximity': {
133
+ // Lava: jump + sprint away from lava, use escape vector if known
134
+ const ev = safePerception?.escapeVector
135
+ if (ev) {
136
+ await tryDirectedEscape(
137
+ (bot.entity?.position?.x || 0) - ev.dx * 5,
138
+ (bot.entity?.position?.z || 0) - ev.dz * 5,
139
+ label
140
+ )
141
+ } else {
142
+ await tryPerturbEscape(label)
143
+ }
144
+ // Follow with a climb attempt in case we're on lava's edge
145
+ await tryClimbEscape(label)
146
+ break
147
+ }
148
+
149
+ case 'collision_deadlock':
150
+ // Trapped: try all 4 directions
151
+ await runEscapeSequence(label, safePerception)
152
+ break
153
+
154
+ case 'terrain_deadlock':
155
+ // High risk terrain: jump + perturb
156
+ await tryJumpEscape(label)
157
+ await new Promise(r => setTimeout(r, 200))
158
+ await tryPerturbEscape(label)
159
+ break
160
+
161
+ case 'liquid_drag':
162
+ // Water: jump repeatedly to surface
163
+ await tryClimbEscape(label)
164
+ break
165
+
166
+ case 'suffocation_hazard':
167
+ // Something above head: back up + jump
168
+ await tryReverseEscape(label)
169
+ await new Promise(r => setTimeout(r, 200))
170
+ await tryJumpEscape(label)
171
+ break
172
+
173
+ default:
174
+ await runEscapeSequence(label, safePerception)
175
+ }
176
+
177
+ log.info('escape_success', { method: 'hazard_escape_done', stuckClass, label })
178
+ }
179
+
180
+ return {
181
+ tryJumpEscape,
182
+ tryStrafeEscape,
183
+ tryReverseEscape,
184
+ tryPerturbEscape,
185
+ tryClimbEscape,
186
+ tryDirectedEscape,
187
+ runEscapeSequence,
188
+ runHazardEscape,
189
+ }
190
+ }
@@ -0,0 +1,63 @@
1
+ // logger.js — Structured JSONL event logger
2
+ //
3
+ // Writes one JSON object per line to events.jsonl alongside bot.js.
4
+ // Each event has: ts (ISO), level, event, ...data.
5
+ //
6
+ // Levels: trace, debug, info, warn, error, fatal
7
+ // Designed for postmortem reconstruction — the TUI dashboard reads from this file.
8
+
9
+ const fs = require('fs')
10
+ const path = require('path')
11
+
12
+ const EVENTS_FILE = path.join(__dirname, 'events.jsonl')
13
+ const MAX_FILE_BYTES = 5 * 1024 * 1024 // 5MB rotation threshold
14
+
15
+ const botName = process.env.BOT_NAME || 'bot'
16
+ let stream = null
17
+
18
+ function openStream() {
19
+ // Rotate if file > MAX_FILE_BYTES
20
+ try {
21
+ const st = fs.statSync(EVENTS_FILE)
22
+ if (st.size > MAX_FILE_BYTES) {
23
+ fs.renameSync(EVENTS_FILE, EVENTS_FILE + '.1')
24
+ }
25
+ } catch {}
26
+ stream = fs.createWriteStream(EVENTS_FILE, { flags: 'a' })
27
+ stream.on('error', err => console.error('[logger] stream error:', err.message))
28
+ }
29
+
30
+ function write(level, event, data = {}) {
31
+ if (!stream) openStream()
32
+ const record = {
33
+ ts: new Date().toISOString(),
34
+ bot: botName,
35
+ level,
36
+ event,
37
+ ...data,
38
+ }
39
+ try {
40
+ stream.write(JSON.stringify(record) + '\n')
41
+ } catch (e) {
42
+ // Last-resort fallback to stderr
43
+ console.error('[logger] write error:', e.message, JSON.stringify(record))
44
+ }
45
+ // Mirror to console for live log tailing
46
+ const tail = Object.keys(data).length > 0 ? ' ' + JSON.stringify(data) : ''
47
+ if (level === 'error' || level === 'fatal') {
48
+ console.error(`[${level}] ${event}${tail}`)
49
+ } else if (level === 'warn') {
50
+ console.warn(`[${level}] ${event}${tail}`)
51
+ } else {
52
+ console.log(`[${level}] ${event}${tail}`)
53
+ }
54
+ }
55
+
56
+ const trace = (event, data) => write('trace', event, data)
57
+ const debug = (event, data) => write('debug', event, data)
58
+ const info = (event, data) => write('info', event, data)
59
+ const warn = (event, data) => write('warn', event, data)
60
+ const error = (event, data) => write('error', event, data)
61
+ const fatal = (event, data) => write('fatal', event, data)
62
+
63
+ module.exports = { trace, debug, info, warn, error, fatal, EVENTS_FILE }
@@ -0,0 +1,59 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+
4
+ const MEMORY_FILE = path.join(__dirname, 'memory.json')
5
+
6
+ const EMPTY = {
7
+ locations: [], // { name, pos: {x,y,z}, timestamp }
8
+ events: [], // { type, data..., timestamp }
9
+ knowledge: {}, // key-value facts
10
+ }
11
+
12
+ function loadMemory() {
13
+ if (!fs.existsSync(MEMORY_FILE)) return JSON.parse(JSON.stringify(EMPTY))
14
+ try {
15
+ return JSON.parse(fs.readFileSync(MEMORY_FILE, 'utf8'))
16
+ } catch {
17
+ return JSON.parse(JSON.stringify(EMPTY))
18
+ }
19
+ }
20
+
21
+ function saveMemory(memory) {
22
+ fs.writeFileSync(MEMORY_FILE, JSON.stringify(memory, null, 2))
23
+ }
24
+
25
+ function rememberLocation(memory, name, pos) {
26
+ const entry = {
27
+ name,
28
+ pos: { x: Math.floor(pos.x), y: Math.floor(pos.y), z: Math.floor(pos.z) },
29
+ timestamp: new Date().toISOString(),
30
+ }
31
+ const idx = memory.locations.findIndex((l) => l.name === name)
32
+ if (idx >= 0) memory.locations[idx] = entry
33
+ else memory.locations.push(entry)
34
+ saveMemory(memory)
35
+ }
36
+
37
+ function rememberEvent(memory, type, data) {
38
+ memory.events.push({ type, ...data, timestamp: new Date().toISOString() })
39
+ if (memory.events.length > 50) memory.events = memory.events.slice(-50)
40
+ saveMemory(memory)
41
+ }
42
+
43
+ function rememberKnowledge(memory, key, value) {
44
+ memory.knowledge[key] = value
45
+ saveMemory(memory)
46
+ }
47
+
48
+ function recallLocation(memory, name) {
49
+ return memory.locations.find((l) => l.name === name) || null
50
+ }
51
+
52
+ module.exports = {
53
+ loadMemory,
54
+ saveMemory,
55
+ rememberLocation,
56
+ rememberEvent,
57
+ rememberKnowledge,
58
+ recallLocation,
59
+ }