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.
package/tui.js ADDED
@@ -0,0 +1,1099 @@
1
+ #!/usr/bin/env node
2
+ // tui.js — embark-ai Live Control Dashboard (sole entry point)
3
+ //
4
+ // Layout:
5
+ // ┌─ Header ─────────────────────────────────────────┐
6
+ // │ Server status, bot list, uptime │
7
+ // ├─ Bot Status ─────────┬─ Actions ─────────────────┤
8
+ // │ HP, food, goal, pos, │ [1] Start server │
9
+ // │ inventory, anger │ [2] Stop server │
10
+ // ├─ Live Log ───────────┤ [3] Spawn bot │
11
+ // │ JSONL events, │ [4] Stop bot │
12
+ // │ auto-scrolling │ [5] Restart bot │
13
+ // │ │ [6] World settings │
14
+ // │ │ [7] List models │
15
+ // │ │ [8] Reconfigure │
16
+ // │ │ [q] Quit │
17
+ // └──────────────────────┴───────────────────────────┘
18
+ //
19
+ // Keys: 1-8 = actions · q/Ctrl+C = quit · Tab = focus cycle
20
+ // ↑↓/PgUp/Dn = scroll log · r = refresh · s = switch bot
21
+ //
22
+ // First run: onboarding wizard prompts for Ollama or Featherless API setup.
23
+ // Config saved to .ember-config.json (not .env).
24
+
25
+ // Load .env from project root if present (optional — config.json is primary)
26
+ function loadDotEnv() {
27
+ const envPath = require('path').join(__dirname, '.env')
28
+ try {
29
+ for (const rawLine of require('fs').readFileSync(envPath, 'utf8').split('\n')) {
30
+ const line = rawLine.trim()
31
+ if (!line || line.startsWith('#')) continue
32
+ const eq = line.indexOf('=')
33
+ if (eq === -1) continue
34
+ const key = line.slice(0, eq).trim()
35
+ let value = line.slice(eq + 1).trim()
36
+ if ((value.startsWith('"') && value.endsWith('"')) ||
37
+ (value.startsWith("'") && value.endsWith("'"))) {
38
+ value = value.slice(1, -1)
39
+ }
40
+ if (process.env[key] === undefined) process.env[key] = value
41
+ }
42
+ } catch {}
43
+ }
44
+ loadDotEnv()
45
+
46
+ const blessed = require('blessed')
47
+ const { spawn, execSync } = require('child_process')
48
+ const fs = require('fs')
49
+ const os = require('os')
50
+ const path = require('path')
51
+ const https = require('https')
52
+ const createBotSupervisor = require('./botSupervisor')
53
+
54
+ // ── Paths & constants ─────────────────────────────────────────────────────────
55
+ const ROOT = __dirname
56
+ const SERVER_DIR = path.join(ROOT, 'mc-server')
57
+ const BOT_DIR = path.join(ROOT, 'mc-server', 'bot')
58
+ const LOG_DIR = path.join(ROOT, '.cli-logs')
59
+ const CONFIG_FILE = path.join(ROOT, '.ember-config.json')
60
+ const SERVER_JAR = 'server.jar'
61
+ const MC_VERSION = '1.21.4'
62
+ const EVENTS_FILE = path.join(BOT_DIR, 'events.jsonl')
63
+
64
+ if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true })
65
+
66
+ // ── Process state ─────────────────────────────────────────────────────────────
67
+ const state = {
68
+ serverProc: null,
69
+ serverLogPath: path.join(LOG_DIR, 'server.log'),
70
+ selectedBot: null,
71
+ botStates: new Map(),
72
+ eventsFollowOffset: 0,
73
+ }
74
+
75
+ let supervisor = null
76
+ let appConfig = null // loaded by startup()
77
+
78
+ // ── Config management ─────────────────────────────────────────────────────────
79
+ function loadConfig() {
80
+ try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) }
81
+ catch { return null }
82
+ }
83
+
84
+ function saveConfig(cfg) {
85
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2))
86
+ }
87
+
88
+ // Sets process.env so the supervisor's spawned bot processes inherit the right vars.
89
+ // Ollama reuses the Featherless-compatible URL env vars — llm.js talks to Ollama's
90
+ // OpenAI-compatible endpoint without any code change.
91
+ function applyConfig(cfg) {
92
+ if (!cfg) return
93
+ if (cfg.llmMode === 'ollama') {
94
+ process.env.FEATHERLESS_URL = 'http://localhost:11434/v1/chat/completions'
95
+ process.env.FEATHERLESS_API_KEY = 'ollama'
96
+ process.env.FEATHERLESS_MODEL = cfg.ollamaModel || 'llama3.2'
97
+ } else {
98
+ delete process.env.FEATHERLESS_URL
99
+ process.env.FEATHERLESS_API_KEY = cfg.featherlessApiKey || ''
100
+ process.env.FEATHERLESS_MODEL = cfg.featherlessModel || ''
101
+ }
102
+ }
103
+
104
+ function configSummary(cfg) {
105
+ if (!cfg) return '{red-fg}not configured — press [8]{/}'
106
+ if (cfg.llmMode === 'ollama')
107
+ return `{green-fg}Ollama{/} {gray-fg}(${cfg.ollamaModel || 'no model'}){/}`
108
+ return `{cyan-fg}Featherless API{/} {gray-fg}(${cfg.featherlessModel || 'no model'}){/}`
109
+ }
110
+
111
+ // ── Hardware / Ollama helpers ─────────────────────────────────────────────────
112
+ function detectRAMGB() {
113
+ return Math.round(os.totalmem() / (1024 ** 3))
114
+ }
115
+
116
+ function recommendOllamaModel(ramGB) {
117
+ if (ramGB >= 32) return 'qwen2.5:32b'
118
+ if (ramGB >= 16) return 'qwen2.5-coder:14b'
119
+ if (ramGB >= 8) return 'llama3.2'
120
+ return 'llama3.2:1b'
121
+ }
122
+
123
+ function isOllamaInstalled() {
124
+ try { execSync('ollama --version', { stdio: 'pipe' }); return true }
125
+ catch { return false }
126
+ }
127
+
128
+ function getOllamaModels() {
129
+ try {
130
+ const out = execSync('ollama list', { stdio: ['pipe', 'pipe', 'pipe'] }).toString()
131
+ return out.trim().split('\n')
132
+ .slice(1)
133
+ .map(l => l.trim().split(/\s+/)[0])
134
+ .filter(m => m && m !== 'NAME')
135
+ } catch { return [] }
136
+ }
137
+
138
+ // ── Featherless model helpers ─────────────────────────────────────────────────
139
+ const FEATHERLESS_DEFAULT_MODELS = [
140
+ 'meta-llama/Meta-Llama-3.1-8B-Instruct',
141
+ 'meta-llama/Meta-Llama-3.1-70B-Instruct',
142
+ 'mistralai/Mistral-7B-Instruct-v0.3',
143
+ 'mistralai/Mixtral-8x7B-Instruct-v0.1',
144
+ 'Qwen/Qwen2.5-7B-Instruct',
145
+ 'Qwen/Qwen2.5-14B-Instruct',
146
+ 'Qwen/Qwen2.5-72B-Instruct',
147
+ 'google/gemma-2-9b-it',
148
+ 'google/gemma-2-27b-it',
149
+ ]
150
+
151
+ function fetchFeatherlessModels() {
152
+ return new Promise((resolve) => {
153
+ const apiKey = process.env.FEATHERLESS_API_KEY
154
+ if (!apiKey || apiKey === 'ollama') { resolve(FEATHERLESS_DEFAULT_MODELS); return }
155
+ const req = https.request({
156
+ hostname: 'api.featherless.ai',
157
+ path: '/v1/models',
158
+ method: 'GET',
159
+ headers: {
160
+ 'Authorization': `Bearer ${apiKey}`,
161
+ 'User-Agent': 'embark-ai/1.0',
162
+ },
163
+ timeout: 3000,
164
+ }, (res) => {
165
+ let data = ''
166
+ res.on('data', d => data += d)
167
+ res.on('end', () => {
168
+ try {
169
+ const parsed = JSON.parse(data)
170
+ const live = Array.isArray(parsed.data) ? parsed.data.map(m => m.id).filter(Boolean) : []
171
+ const seen = new Set(FEATHERLESS_DEFAULT_MODELS)
172
+ const extras = live.filter(id => !seen.has(id)).slice(0, 20)
173
+ resolve([...FEATHERLESS_DEFAULT_MODELS, ...extras])
174
+ } catch { resolve(FEATHERLESS_DEFAULT_MODELS) }
175
+ })
176
+ })
177
+ req.on('error', () => resolve(FEATHERLESS_DEFAULT_MODELS))
178
+ req.on('timeout', () => { req.destroy(); resolve(FEATHERLESS_DEFAULT_MODELS) })
179
+ req.end()
180
+ })
181
+ }
182
+
183
+ // ── Server helpers ────────────────────────────────────────────────────────────
184
+ function getServerPort() {
185
+ try {
186
+ const props = fs.readFileSync(path.join(SERVER_DIR, 'server.properties'), 'utf8')
187
+ const m = props.match(/^server-port=(\d+)/m)
188
+ return m ? parseInt(m[1]) : 25565
189
+ } catch { return 25565 }
190
+ }
191
+
192
+ function getServerPids(port) {
193
+ try {
194
+ const out = execSync(`lsof -ti tcp:${port} 2>/dev/null`).toString().trim()
195
+ return out ? out.split('\n').map(Number).filter(Boolean) : []
196
+ } catch { return [] }
197
+ }
198
+
199
+ const isPortInUse = (port) => getServerPids(port).length > 0
200
+
201
+ function findJava() {
202
+ const javaVersion = (bin) => {
203
+ try {
204
+ const out = execSync(`"${bin}" -version 2>&1`).toString()
205
+ const m = out.match(/version "(\d+)/)
206
+ return m ? parseInt(m[1]) : 0
207
+ } catch { return 0 }
208
+ }
209
+ if (process.env.JAVA_HOME) {
210
+ const bin = path.join(process.env.JAVA_HOME, 'bin', 'java')
211
+ if (javaVersion(bin) >= 21) return bin
212
+ }
213
+ const brew = '/opt/homebrew/opt/openjdk/bin/java'
214
+ if (javaVersion(brew) >= 21) return brew
215
+ for (const v of [21, 22, 23, 24, 25]) {
216
+ try {
217
+ const home = execSync(`/usr/libexec/java_home -v ${v} 2>/dev/null`).toString().trim()
218
+ if (home) {
219
+ const bin = path.join(home, 'bin', 'java')
220
+ if (javaVersion(bin) >= 21) return bin
221
+ }
222
+ } catch {}
223
+ }
224
+ return 'java'
225
+ }
226
+
227
+ // ── Blessed screen setup ──────────────────────────────────────────────────────
228
+ const screen = blessed.screen({ smartCSR: true, title: 'embark-ai', fullUnicode: true })
229
+
230
+ const header = blessed.box({
231
+ parent: screen,
232
+ top: 0, left: 0, right: 0, height: 3,
233
+ border: { type: 'line' },
234
+ style: { border: { fg: 'cyan' } },
235
+ tags: true,
236
+ padding: { left: 1, right: 1 },
237
+ })
238
+
239
+ const statusPanel = blessed.box({
240
+ parent: screen,
241
+ label: ' Bot Status ',
242
+ top: 3, left: 0, width: '60%', height: '40%',
243
+ border: { type: 'line' },
244
+ style: { border: { fg: 'green' }, label: { fg: 'green' } },
245
+ tags: true,
246
+ padding: { left: 1, right: 1 },
247
+ })
248
+
249
+ const logPanel = blessed.log({
250
+ parent: screen,
251
+ label: ' Live Log ',
252
+ top: '43%', left: 0, width: '60%', bottom: 1,
253
+ border: { type: 'line' },
254
+ style: { border: { fg: 'blue' }, label: { fg: 'blue' } },
255
+ tags: true,
256
+ scrollable: true,
257
+ alwaysScroll: true,
258
+ scrollbar: { ch: '│', track: { bg: 'gray' }, style: { bg: 'cyan' } },
259
+ keys: true,
260
+ mouse: true,
261
+ padding: { left: 1, right: 1 },
262
+ })
263
+
264
+ const actionsPanel = blessed.box({
265
+ parent: screen,
266
+ label: ' Actions ',
267
+ top: 3, left: '60%', right: 0, bottom: 1,
268
+ border: { type: 'line' },
269
+ style: { border: { fg: 'magenta' }, label: { fg: 'magenta' } },
270
+ tags: true,
271
+ padding: { left: 1, right: 1 },
272
+ })
273
+
274
+ const statusBar = blessed.box({
275
+ parent: screen,
276
+ bottom: 0, left: 0, right: 0, height: 1,
277
+ style: { fg: 'white', bg: 'blue' },
278
+ tags: true,
279
+ padding: { left: 1, right: 1 },
280
+ })
281
+
282
+ // ── Supervisor ────────────────────────────────────────────────────────────────
283
+ supervisor = createBotSupervisor({
284
+ botDir: BOT_DIR,
285
+ logDir: LOG_DIR,
286
+ onLog: (entry) => {
287
+ const clr = entry.level === 'error' ? '{red-fg}' : entry.level === 'warn' ? '{yellow-fg}' : '{gray-fg}'
288
+ logPanel.log(`${clr}[supervisor] ${entry.msg}{/}`)
289
+ },
290
+ onRespawn: ({ name, chainId, restartCount }) => {
291
+ const port = getServerPort()
292
+ logPanel.log(`{yellow-fg}[supervisor] Bot "${name}" respawning (chain ${chainId}, attempt #${restartCount}) — localhost:${port}{/}`)
293
+ refresh()
294
+ },
295
+ onStorm: ({ name, chainId, restartCount }) => {
296
+ logPanel.log(`{red-fg}[supervisor] RESTART STORM: bot "${name}" restarted ${restartCount}x in 10 min (chain ${chainId}). Halted.{/}`)
297
+ state.botStates.delete(name)
298
+ if (state.selectedBot === name) state.selectedBot = null
299
+ refresh()
300
+ },
301
+ })
302
+
303
+ // ── Rendering ─────────────────────────────────────────────────────────────────
304
+ function renderHeader() {
305
+ const port = getServerPort()
306
+ let serverLine
307
+ if (state.serverProc) {
308
+ serverLine = `{green-fg}●{/} Server {bold}localhost:${port}{/bold} (managed)`
309
+ } else if (isPortInUse(port)) {
310
+ serverLine = `{yellow-fg}●{/} Server {bold}localhost:${port}{/bold} (external)`
311
+ } else {
312
+ serverLine = `{gray-fg}○{/} Server {gray-fg}stopped{/}`
313
+ }
314
+
315
+ const sv = supervisor ? supervisor.getState() : {}
316
+ const svNames = Object.keys(sv)
317
+ let botsLine
318
+ if (!svNames.length) {
319
+ botsLine = '{gray-fg}no bots running{/}'
320
+ } else {
321
+ const items = []
322
+ for (const [name, b] of Object.entries(sv)) {
323
+ const up = Math.floor((b.uptimeMs || 0) / 1000)
324
+ const upStr = up >= 60 ? `${Math.floor(up / 60)}m${up % 60}s` : `${up}s`
325
+ const dot = b.running ? (name === state.selectedBot ? `{green-fg}●{/}` : `{cyan-fg}●{/}`)
326
+ : b.awaitingRespawn ? `{yellow-fg}○{/}` : `{gray-fg}○{/}`
327
+ const restarts = b.restartCount > 0 ? ` {yellow-fg}[${b.restartCount}↺]{/}` : ''
328
+ items.push(`${dot} {bold}${name}{/bold} {gray-fg}(${b.model}, ${upStr}){/}${restarts}`)
329
+ }
330
+ botsLine = items.join(' ')
331
+ }
332
+
333
+ header.setContent(`{bold}{cyan-fg}embark-ai{/}{/bold} ${serverLine} | Bots: ${botsLine}`)
334
+ }
335
+
336
+ function renderStatus() {
337
+ const name = state.selectedBot
338
+ if (!name || !state.botStates.has(name)) {
339
+ statusPanel.setContent(
340
+ `{gray-fg}No active bot.{/}\n\n` +
341
+ `Press {cyan-fg}[3]{/} to spawn one.\n\n` +
342
+ `{gray-fg}Once a bot is online, this panel will show its{/}\n` +
343
+ `{gray-fg}HP, food, goal, position, and inventory in real-time.{/}`
344
+ )
345
+ return
346
+ }
347
+
348
+ const s = state.botStates.get(name)
349
+ const hp = s.hp ?? 0
350
+ const food = s.food ?? 0
351
+ const hpBar = renderBar(hp, 20, 'red', 'green')
352
+ const foodBar = renderBar(food, 20, 'yellow', 'yellow')
353
+ const pos = s.pos ? `(${s.pos.x}, ${s.pos.y}, ${s.pos.z})` : 'unknown'
354
+ const inv = s.inventory && s.inventory.length > 0 ? s.inventory.join(', ') : '{gray-fg}empty{/}'
355
+
356
+ let goalColor = 'white'
357
+ if (s.goal === 'idle') goalColor = 'gray'
358
+ if (s.goal === 'attacking') goalColor = 'red'
359
+ if (s.goal === 'following') goalColor = 'cyan'
360
+ if (s.goal === 'resting') goalColor = 'yellow'
361
+
362
+ const lines = [
363
+ `{bold}{green-fg}${name}{/}{/bold}`,
364
+ ``,
365
+ `{bold}HP:{/bold} ${hpBar} ${hp.toFixed(1)}/20`,
366
+ `{bold}Food:{/bold} ${foodBar} ${food}/20`,
367
+ `{bold}Goal:{/bold} {${goalColor}-fg}${s.goal}{/}${s.busy ? ' {yellow-fg}(busy){/}' : ''}`,
368
+ `{bold}Pos:{/bold} ${pos}`,
369
+ s.followTarget ? `{bold}Following:{/bold} ${s.followTarget}` : '',
370
+ `{bold}Anger:{/bold} ${s.anger > 0 ? '{red-fg}' + s.anger + ' player(s){/}' : '{gray-fg}none{/}'}`,
371
+ ``,
372
+ `{bold}Inventory:{/bold}`,
373
+ ` ${inv}`,
374
+ ].filter(Boolean)
375
+
376
+ statusPanel.setContent(lines.join('\n'))
377
+ }
378
+
379
+ function renderBar(value, max, lowColor, highColor) {
380
+ const width = 14
381
+ const filled = Math.max(0, Math.min(width, Math.round((value / max) * width)))
382
+ const empty = width - filled
383
+ const color = value < max * 0.3 ? lowColor : highColor
384
+ return `{${color}-fg}${'█'.repeat(filled)}{/}{gray-fg}${'░'.repeat(empty)}{/}`
385
+ }
386
+
387
+ function renderActions() {
388
+ const modeStr = configSummary(appConfig)
389
+ const lines = [
390
+ `{bold}AI:{/bold} ${modeStr}`,
391
+ ``,
392
+ `{bold}{cyan-fg}[1]{/} Start server`,
393
+ `{bold}{cyan-fg}[2]{/} Stop server`,
394
+ `{bold}{cyan-fg}[3]{/} Spawn bot`,
395
+ `{bold}{cyan-fg}[4]{/} Stop bot`,
396
+ `{bold}{cyan-fg}[5]{/} Restart bot`,
397
+ `{bold}{cyan-fg}[6]{/} World settings`,
398
+ `{bold}{cyan-fg}[7]{/} List models`,
399
+ `{bold}{cyan-fg}[8]{/} Reconfigure`,
400
+ `{bold}{cyan-fg}[s]{/} Switch active bot`,
401
+ ``,
402
+ `{gray-fg}─── navigation ───{/}`,
403
+ `{cyan-fg}↑↓{/} scroll log`,
404
+ `{cyan-fg}Tab{/} switch focus`,
405
+ `{cyan-fg}r{/} refresh status`,
406
+ `{cyan-fg}q{/} quit`,
407
+ ``,
408
+ `{gray-fg}Logs in:{/}`,
409
+ `{gray-fg}${LOG_DIR.replace(process.env.HOME || '', '~')}{/}`,
410
+ ]
411
+ actionsPanel.setContent(lines.join('\n'))
412
+ }
413
+
414
+ function renderStatusBar(message) {
415
+ if (message) {
416
+ statusBar.setContent(`{bold}${message}{/}`)
417
+ } else {
418
+ const port = getServerPort()
419
+ const hint = (!supervisor || !Object.keys(supervisor.getState()).length) && !state.serverProc && !isPortInUse(port)
420
+ ? '{yellow-fg}Press [1] to start the server, then [3] to spawn a bot.{/}'
421
+ : 'Press a key to act, or [q] to quit.'
422
+ statusBar.setContent(hint)
423
+ }
424
+ }
425
+
426
+ function refresh(message) {
427
+ renderHeader()
428
+ renderStatus()
429
+ renderActions()
430
+ renderStatusBar(message)
431
+ screen.render()
432
+ }
433
+
434
+ // ── Live log streaming from events.jsonl ──────────────────────────────────────
435
+ function appendLogLine(record) {
436
+ const t = record.ts ? record.ts.slice(11, 19) : ''
437
+ const lvl = record.level
438
+ const colors = { trace: 'gray', debug: 'gray', info: 'cyan', warn: 'yellow', error: 'red', fatal: 'red' }
439
+ const c = colors[lvl] || 'white'
440
+
441
+ if (record.event === 'state') {
442
+ if (record.bot) state.botStates.set(record.bot, record)
443
+ if (!state.selectedBot && record.bot) state.selectedBot = record.bot
444
+ renderStatus()
445
+ renderHeader()
446
+ screen.render()
447
+ return
448
+ }
449
+
450
+ const data = Object.entries(record).filter(([k]) =>
451
+ !['ts', 'level', 'event', 'bot'].includes(k)
452
+ ).map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`).join(' ')
453
+
454
+ const botTag = record.bot ? `{magenta-fg}${record.bot}{/} ` : ''
455
+ logPanel.log(`{gray-fg}${t}{/} {${c}-fg}[${lvl}]{/} ${botTag}{bold}${record.event}{/bold} {gray-fg}${data}{/}`)
456
+ }
457
+
458
+ function followEvents() {
459
+ let lastSize = 0
460
+ try { lastSize = fs.statSync(EVENTS_FILE).size } catch {}
461
+ state.eventsFollowOffset = lastSize
462
+
463
+ const tail = setInterval(() => {
464
+ let stat
465
+ try { stat = fs.statSync(EVENTS_FILE) } catch { return }
466
+ if (stat.size <= state.eventsFollowOffset) {
467
+ if (stat.size < state.eventsFollowOffset) state.eventsFollowOffset = 0
468
+ return
469
+ }
470
+ const fd = fs.openSync(EVENTS_FILE, 'r')
471
+ const buf = Buffer.alloc(stat.size - state.eventsFollowOffset)
472
+ fs.readSync(fd, buf, 0, buf.length, state.eventsFollowOffset)
473
+ fs.closeSync(fd)
474
+ state.eventsFollowOffset = stat.size
475
+
476
+ const lines = buf.toString('utf8').split('\n').filter(Boolean)
477
+ for (const line of lines) {
478
+ try {
479
+ const rec = JSON.parse(line)
480
+ appendLogLine(rec)
481
+ } catch {}
482
+ }
483
+ }, 500)
484
+
485
+ return () => clearInterval(tail)
486
+ }
487
+
488
+ // ── Modal helpers ─────────────────────────────────────────────────────────────
489
+ function modalPrompt(question, defaultValue = '') {
490
+ return new Promise((resolve) => {
491
+ const box = blessed.form({
492
+ parent: screen,
493
+ top: 'center', left: 'center', width: '60%', height: 7,
494
+ border: { type: 'line' },
495
+ style: { border: { fg: 'yellow' } },
496
+ label: ' Input ',
497
+ keys: true,
498
+ tags: true,
499
+ })
500
+ blessed.text({ parent: box, top: 0, left: 1, content: question })
501
+ const input = blessed.textbox({
502
+ parent: box, top: 2, left: 1, right: 1, height: 1,
503
+ inputOnFocus: true,
504
+ style: { fg: 'white', bg: 'black' },
505
+ value: defaultValue,
506
+ })
507
+ blessed.text({
508
+ parent: box, bottom: 0, left: 1,
509
+ content: '{gray-fg}Enter to confirm, Esc to cancel{/}',
510
+ tags: true,
511
+ })
512
+
513
+ input.focus()
514
+ input.readInput()
515
+ input.key(['escape'], () => { box.destroy(); screen.render(); resolve(null) })
516
+ input.on('submit', (val) => { box.destroy(); screen.render(); resolve(val) })
517
+ screen.render()
518
+ })
519
+ }
520
+
521
+ function modalSelect(title, items) {
522
+ return new Promise((resolve) => {
523
+ if (items.length === 0) { resolve(null); return }
524
+ const box = blessed.list({
525
+ parent: screen,
526
+ top: 'center', left: 'center', width: '65%', height: Math.min(items.length + 4, 18),
527
+ border: { type: 'line' },
528
+ style: { border: { fg: 'yellow' }, selected: { bg: 'blue', bold: true } },
529
+ label: ` ${title} `,
530
+ keys: true, mouse: true,
531
+ items: items.map(it => typeof it === 'string' ? it : it.label),
532
+ tags: true,
533
+ })
534
+ box.focus()
535
+ box.on('select', (_, idx) => {
536
+ const chosen = items[idx]
537
+ box.destroy(); screen.render()
538
+ resolve(typeof chosen === 'string' ? chosen : chosen.value)
539
+ })
540
+ box.key(['escape'], () => { box.destroy(); screen.render(); resolve(null) })
541
+ screen.render()
542
+ })
543
+ }
544
+
545
+ function modalMessage(text, color = 'cyan') {
546
+ const box = blessed.box({
547
+ parent: screen,
548
+ top: 'center', left: 'center', width: '55%', height: 'shrink',
549
+ border: { type: 'line' },
550
+ style: { border: { fg: color } },
551
+ label: ' Info ',
552
+ tags: true,
553
+ content: text + '\n\n{gray-fg}Press any key{/}',
554
+ padding: 1,
555
+ })
556
+ screen.render()
557
+ return new Promise((resolve) => {
558
+ screen.once('keypress', () => { box.destroy(); screen.render(); resolve() })
559
+ })
560
+ }
561
+
562
+ // Shows a command to run in a terminal. Enter = done, Esc = cancel.
563
+ function modalCommand(title, instruction, command, note) {
564
+ return new Promise((resolve) => {
565
+ const noteBlock = note ? `\n\n{gray-fg}${note}{/}` : ''
566
+ const content =
567
+ `{bold}${instruction}{/bold}\n\n` +
568
+ `{green-fg}{bold} ${command}{/bold}{/}` +
569
+ noteBlock +
570
+ `\n\n{gray-fg}Enter = done · Esc = cancel{/}`
571
+
572
+ const noteLines = note ? note.split('\n').length : 0
573
+ const height = Math.min(8 + noteLines, 18)
574
+
575
+ const box = blessed.box({
576
+ parent: screen,
577
+ top: 'center', left: 'center', width: '70%', height,
578
+ border: { type: 'line' },
579
+ style: { border: { fg: 'yellow' } },
580
+ label: ` ${title} `,
581
+ tags: true,
582
+ content,
583
+ padding: { left: 2, right: 2, top: 1, bottom: 1 },
584
+ })
585
+ screen.render()
586
+
587
+ const onKey = (ch, key) => {
588
+ if (key.name === 'return' || key.name === 'enter') {
589
+ screen.removeListener('keypress', onKey)
590
+ box.destroy(); screen.render(); resolve(true)
591
+ } else if (key.name === 'escape') {
592
+ screen.removeListener('keypress', onKey)
593
+ box.destroy(); screen.render(); resolve(false)
594
+ }
595
+ }
596
+ screen.on('keypress', onKey)
597
+ })
598
+ }
599
+
600
+ // ── Onboarding wizard ─────────────────────────────────────────────────────────
601
+ async function runOnboarding() {
602
+ await modalMessage(
603
+ `{bold}{cyan-fg}Welcome to embark-ai{/}{/bold}\n\n` +
604
+ `Ember is an autonomous Minecraft agent powered by AI.\n` +
605
+ `Let\'s set up your AI backend and verify your server.\n\n` +
606
+ `{gray-fg}This takes about 2 minutes on first run.{/}`,
607
+ 'cyan'
608
+ )
609
+
610
+ // Step 1: choose backend
611
+ const mode = await modalSelect('Step 1 of 3 — AI Backend', [
612
+ { label: 'Ollama local AI, runs on your machine (free, private)', value: 'ollama' },
613
+ { label: 'Featherless API cloud AI, no GPU needed (requires API key)', value: 'featherless' },
614
+ ])
615
+ if (!mode) return null
616
+
617
+ const cfg = { llmMode: mode }
618
+
619
+ // ── Ollama path ──────────────────────────────────────────────────────────────
620
+ if (mode === 'ollama') {
621
+
622
+ // Check installation
623
+ while (!isOllamaInstalled()) {
624
+ const ok = await modalCommand(
625
+ 'Install Ollama',
626
+ 'Ollama is not installed. Run this in a separate terminal:',
627
+ 'curl -fsSL https://ollama.com/install.sh | sh',
628
+ 'On macOS with Homebrew: brew install ollama\nPress Enter once installation finishes.',
629
+ )
630
+ if (!ok) return null
631
+ if (!isOllamaInstalled()) {
632
+ await modalMessage(
633
+ '{yellow-fg}Ollama still not detected.{/}\nMake sure the installation completed and try again.',
634
+ 'yellow'
635
+ )
636
+ }
637
+ }
638
+
639
+ await modalMessage('{green-fg}Ollama detected!{/}', 'green')
640
+
641
+ // Check for installed models
642
+ let models = getOllamaModels()
643
+
644
+ if (!models.length) {
645
+ const ramGB = detectRAMGB()
646
+ const rec = recommendOllamaModel(ramGB)
647
+
648
+ const MODEL_OPTIONS = [
649
+ { value: 'llama3.2:1b', label: 'llama3.2:1b 1.3 GB (< 4 GB RAM)' },
650
+ { value: 'llama3.2', label: 'llama3.2 2.0 GB (4–8 GB RAM)' },
651
+ { value: 'qwen2.5-coder:14b', label: 'qwen2.5-coder:14b 9.0 GB (16 GB RAM)' },
652
+ { value: 'qwen2.5:32b', label: 'qwen2.5:32b 20 GB (32 GB RAM)' },
653
+ ].map(o => o.value === rec
654
+ ? { ...o, label: o.label + ' ← recommended' }
655
+ : o
656
+ )
657
+
658
+ while (!models.length) {
659
+ const chosen = await modalSelect(
660
+ `Step 2 of 3 — Choose Model to Install (${ramGB} GB RAM detected)`,
661
+ MODEL_OPTIONS
662
+ )
663
+ if (!chosen) return null
664
+
665
+ const ok = await modalCommand(
666
+ 'Download Model',
667
+ `Run in a separate terminal (may take several minutes):`,
668
+ `ollama pull ${chosen}`,
669
+ `Wait for "success" to appear, then press Enter.`,
670
+ )
671
+ if (!ok) return null
672
+
673
+ models = getOllamaModels()
674
+ if (!models.length) {
675
+ await modalMessage(
676
+ '{yellow-fg}No models found yet.{/}\nPlease wait for the download to complete.',
677
+ 'yellow'
678
+ )
679
+ }
680
+ }
681
+ }
682
+
683
+ // Pick from installed models
684
+ const model = models.length === 1
685
+ ? models[0]
686
+ : await modalSelect('Step 2 of 3 — Select Ollama Model', models)
687
+ if (!model) return null
688
+
689
+ cfg.ollamaModel = model
690
+
691
+ // ── Featherless path ─────────────────────────────────────────────────────────
692
+ } else {
693
+ let apiKey = ''
694
+ while (!apiKey) {
695
+ const input = await modalPrompt('Step 2 of 3 — Enter Featherless API key (starts with rc_...):', '')
696
+ if (input === null) return null
697
+ apiKey = input.trim()
698
+ if (!apiKey) await modalMessage('{red-fg}API key cannot be empty.{/}', 'red')
699
+ }
700
+ cfg.featherlessApiKey = apiKey
701
+
702
+ process.env.FEATHERLESS_API_KEY = apiKey
703
+ const models = await fetchFeatherlessModels()
704
+ const model = await modalSelect('Step 2 of 3 — Select Featherless Model', models)
705
+ if (!model) return null
706
+ cfg.featherlessModel = model
707
+ }
708
+
709
+ // ── Step 3: Minecraft server check ──────────────────────────────────────────
710
+ const jarPath = path.join(SERVER_DIR, SERVER_JAR)
711
+ if (!fs.existsSync(jarPath)) {
712
+ await modalCommand(
713
+ `Step 3 of 3 — Minecraft Server (Java Edition ${MC_VERSION})`,
714
+ `server.jar not found at mc-server/server.jar`,
715
+ `mc-server/server.jar`,
716
+ `Download Minecraft Java Edition ${MC_VERSION} server jar\n` +
717
+ `from minecraft.net > Download > Minecraft Server\n` +
718
+ `and place it at the path above.\n\n` +
719
+ `Press Enter once the file is in place, or Esc to set up later.`,
720
+ )
721
+ } else {
722
+ await modalMessage(
723
+ `{green-fg}Minecraft server jar found.{/} {gray-fg}(Java Edition ${MC_VERSION}){/}`,
724
+ 'green'
725
+ )
726
+ }
727
+
728
+ // Save and confirm
729
+ saveConfig(cfg)
730
+ const modeLine = cfg.llmMode === 'ollama'
731
+ ? `Ollama — ${cfg.ollamaModel}`
732
+ : `Featherless API — ${cfg.featherlessModel}`
733
+
734
+ await modalMessage(
735
+ `{green-fg}{bold}Setup complete!{/}{/bold}\n\n` +
736
+ `Mode: {bold}${modeLine}{/bold}\n\n` +
737
+ `Press {cyan-fg}[1]{/} to start the server, then {cyan-fg}[3]{/} to spawn Ember.`,
738
+ 'green'
739
+ )
740
+ return cfg
741
+ }
742
+
743
+ // ── Action handlers ───────────────────────────────────────────────────────────
744
+ async function actStartServer() {
745
+ if (state.serverProc) return modalMessage('{yellow-fg}Server is already running.{/}')
746
+ const jarPath = path.join(SERVER_DIR, SERVER_JAR)
747
+ if (!fs.existsSync(jarPath)) {
748
+ return modalMessage(
749
+ `{red-fg}server.jar not found at mc-server/${SERVER_JAR}{/}\n` +
750
+ `Run setup again with {cyan-fg}[8]{/} for download instructions.`,
751
+ 'red'
752
+ )
753
+ }
754
+ const port = getServerPort()
755
+ if (isPortInUse(port)) {
756
+ return modalMessage(`{red-fg}Port ${port} already in use.{/}\nStop the running server first.`, 'red')
757
+ }
758
+ const javaBin = findJava()
759
+ let javaVer = 0
760
+ try {
761
+ const m = execSync(`"${javaBin}" -version 2>&1`).toString().match(/version "(\d+)/)
762
+ javaVer = m ? parseInt(m[1]) : 0
763
+ } catch {}
764
+ if (javaVer < 21) {
765
+ return modalMessage(
766
+ `{red-fg}Java ${javaVer || '?'} found — need Java 21+.{/}\nInstall: brew install openjdk@21`,
767
+ 'red'
768
+ )
769
+ }
770
+
771
+ refresh(`{cyan-fg}Starting server with Java ${javaVer}...{/}`)
772
+ const logFd = fs.openSync(state.serverLogPath, 'w')
773
+ const proc = spawn(javaBin, ['-Xmx2G', '-jar', SERVER_JAR, 'nogui'], {
774
+ cwd: SERVER_DIR,
775
+ stdio: ['pipe', logFd, logFd],
776
+ })
777
+ proc.stdin.end()
778
+ proc.on('error', (err) => {
779
+ logPanel.log(`{red-fg}server error: ${err.message}{/}`)
780
+ state.serverProc = null
781
+ refresh()
782
+ })
783
+
784
+ let fullyStarted = false
785
+ const startTimer = setTimeout(() => { fullyStarted = true }, 20000)
786
+ proc.on('exit', (code) => {
787
+ clearTimeout(startTimer)
788
+ if (!fullyStarted) {
789
+ logPanel.log(`{red-fg}Server exited early (code ${code}){/}`)
790
+ try {
791
+ const tail = fs.readFileSync(state.serverLogPath, 'utf8').trim().split('\n').slice(-10).join('\n')
792
+ for (const line of tail.split('\n')) logPanel.log(`{gray-fg} ${line}{/}`)
793
+ } catch {}
794
+ } else {
795
+ logPanel.log(`{gray-fg}Server stopped (code ${code}){/}`)
796
+ }
797
+ state.serverProc = null
798
+ refresh()
799
+ })
800
+
801
+ state.serverProc = proc
802
+ logPanel.log(`{green-fg}Server starting — connect at localhost:${port}{/}`)
803
+ refresh()
804
+ }
805
+
806
+ function actStopServer() {
807
+ const port = getServerPort()
808
+ if (!state.serverProc) {
809
+ const pids = getServerPids(port)
810
+ if (!pids.length) return modalMessage(`{yellow-fg}No server running on port ${port}.{/}`)
811
+ try {
812
+ for (const pid of pids) process.kill(pid, 'SIGTERM')
813
+ logPanel.log(`{green-fg}Server on port ${port} stopped (pids ${pids.join(', ')}){/}`)
814
+ refresh()
815
+ } catch (e) {
816
+ modalMessage(`{red-fg}Stop failed: ${e.message}{/}`, 'red')
817
+ }
818
+ return
819
+ }
820
+ logPanel.log('{cyan-fg}Stopping server...{/}')
821
+ try { state.serverProc.kill('SIGTERM') }
822
+ catch (e) { modalMessage(`{red-fg}Kill failed: ${e.message}{/}`, 'red') }
823
+ }
824
+
825
+ async function actSpawnBot() {
826
+ if (!appConfig) {
827
+ return modalMessage('{red-fg}Not configured yet.{/}\nPress {cyan-fg}[8]{/} to run the setup wizard.', 'red')
828
+ }
829
+
830
+ const name = await modalPrompt('Bot name (default Ember):', 'Ember')
831
+ if (name === null) return
832
+ const botName = name.trim() || 'Ember'
833
+
834
+ if (supervisor.has(botName)) {
835
+ return modalMessage(`{red-fg}Bot "${botName}" is already running.{/}`, 'red')
836
+ }
837
+
838
+ let model
839
+ if (appConfig.llmMode === 'ollama') {
840
+ const models = getOllamaModels()
841
+ if (!models.length) {
842
+ return modalMessage(
843
+ '{red-fg}No Ollama models installed.{/}\nPress {cyan-fg}[8]{/} to run setup and install a model.',
844
+ 'red'
845
+ )
846
+ }
847
+ model = models.length === 1
848
+ ? models[0]
849
+ : await modalSelect('Select Ollama Model', models)
850
+ } else {
851
+ const models = await fetchFeatherlessModels()
852
+ model = await modalSelect('Select Featherless Model', models)
853
+ }
854
+ if (!model) return
855
+
856
+ // Persist last used model and re-apply env so the supervisor spawn gets it
857
+ if (appConfig.llmMode === 'ollama') appConfig.ollamaModel = model
858
+ else appConfig.featherlessModel = model
859
+ saveConfig(appConfig)
860
+ applyConfig(appConfig)
861
+
862
+ const logPath = path.join(LOG_DIR, `bot_${botName}.log`)
863
+ supervisor.launch(botName, model, logPath, { fresh: true })
864
+
865
+ state.selectedBot = botName
866
+ logPanel.log(`{green-fg}Bot "${botName}" starting with ${model} (supervised){/}`)
867
+ refresh()
868
+ }
869
+
870
+ async function actStopBot() {
871
+ const sv = supervisor.getState()
872
+ const names = Object.keys(sv)
873
+ if (!names.length) return modalMessage('{yellow-fg}No bots running.{/}')
874
+ const items = names.map(n => ({ label: `${n} (${sv[n].model})`, value: n }))
875
+ const name = await modalSelect('Stop which bot?', items)
876
+ if (!name) return
877
+ supervisor.stop(name)
878
+ state.botStates.delete(name)
879
+ if (state.selectedBot === name)
880
+ state.selectedBot = Object.keys(supervisor.getState()).find(n => n !== name) || null
881
+ logPanel.log(`{cyan-fg}Stopping ${name} (supervisor disabled){/}`)
882
+ refresh()
883
+ }
884
+
885
+ async function actRestartBot() {
886
+ const sv = supervisor.getState()
887
+ const names = Object.keys(sv)
888
+ if (!names.length) return modalMessage('{yellow-fg}No bots running.{/}')
889
+ const items = names.map(n => ({ label: `${n} (${sv[n].model})`, value: n }))
890
+ const name = await modalSelect('Restart which bot?', items)
891
+ if (!name) return
892
+ const { model } = sv[name]
893
+ supervisor.stop(name)
894
+ await new Promise(r => setTimeout(r, 1500))
895
+ const logPath = path.join(LOG_DIR, `bot_${name}.log`)
896
+ supervisor.launch(name, model, logPath, { fresh: true })
897
+ state.selectedBot = name
898
+ logPanel.log(`{green-fg}Bot "${name}" restarted (clean chain){/}`)
899
+ refresh()
900
+ }
901
+
902
+ async function actSwitchBot() {
903
+ const sv = supervisor.getState()
904
+ const names = Object.keys(sv)
905
+ if (!names.length) return modalMessage('{yellow-fg}No bots running.{/}')
906
+ const items = names.map(n => ({
907
+ label: n + (n === state.selectedBot ? ' (active)' : ''),
908
+ value: n,
909
+ }))
910
+ const name = await modalSelect('Show which bot in status panel?', items)
911
+ if (!name) return
912
+ state.selectedBot = name
913
+ refresh()
914
+ }
915
+
916
+ async function actListModels() {
917
+ if (!appConfig) {
918
+ return modalMessage('{red-fg}Not configured. Press [8] to run setup.{/}', 'red')
919
+ }
920
+ if (appConfig.llmMode === 'ollama') {
921
+ const models = getOllamaModels()
922
+ if (!models.length) {
923
+ return modalMessage('{yellow-fg}No Ollama models installed.{/}\nPress [8] to run setup.', 'yellow')
924
+ }
925
+ const list = models.map(m => ` {green-fg}●{/} ${m}`).join('\n')
926
+ await modalMessage(`{bold}Installed Ollama models:{/}\n\n${list}`, 'green')
927
+ } else {
928
+ const models = await fetchFeatherlessModels()
929
+ if (!models.length) return modalMessage('{red-fg}Could not reach Featherless API.{/}', 'red')
930
+ const list = models.slice(0, 15).map(m => ` {green-fg}●{/} ${m}`).join('\n')
931
+ const more = models.length > 15 ? `\n{gray-fg}…and ${models.length - 15} more{/}` : ''
932
+ await modalMessage(`{bold}Featherless models:{/}\n\n${list}${more}`, 'green')
933
+ }
934
+ }
935
+
936
+ async function actReconfigure() {
937
+ const cfg = await runOnboarding()
938
+ if (cfg) {
939
+ appConfig = cfg
940
+ applyConfig(cfg)
941
+ logPanel.log(`{green-fg}Reconfigured:{/} ${configSummary(cfg).replace(/\{[^}]+\}/g, '')}`)
942
+ refresh()
943
+ }
944
+ }
945
+
946
+ // ── World Settings ────────────────────────────────────────────────────────────
947
+ const WORLD_DEFS = [
948
+ { key: 'gamemode', label: 'Game Mode', type: 'cycle',
949
+ options: ['survival','creative','adventure','spectator'] },
950
+ { key: 'difficulty', label: 'Difficulty', type: 'cycle',
951
+ options: ['peaceful','easy','normal','hard'] },
952
+ { key: 'pvp', label: 'PVP', type: 'cycle', options: ['true','false'] },
953
+ { key: 'hardcore', label: 'Hardcore', type: 'cycle', options: ['false','true'] },
954
+ { key: 'level-type', label: 'World Type', type: 'cycle',
955
+ options: ['minecraft:normal','minecraft:flat','minecraft:large_biomes','minecraft:amplified'] },
956
+ { key: 'level-seed', label: 'Seed', type: 'text' },
957
+ { key: 'generate-structures', label: 'Structures', type: 'cycle', options: ['true','false'] },
958
+ { key: 'max-players', label: 'Max Players', type: 'num', min: 1, max: 200 },
959
+ { key: 'view-distance', label: 'View Distance', type: 'num', min: 3, max: 32 },
960
+ { key: 'allow-flight', label: 'Allow Flight', type: 'cycle', options: ['false','true'] },
961
+ { key: 'spawn-monsters', label: 'Spawn Monsters', type: 'cycle', options: ['true','false'] },
962
+ { key: 'enable-command-block', label: 'Command Blocks', type: 'cycle', options: ['true','false'] },
963
+ ]
964
+
965
+ function readServerProps() {
966
+ try {
967
+ const raw = fs.readFileSync(path.join(SERVER_DIR, 'server.properties'), 'utf8')
968
+ const props = {}
969
+ for (const line of raw.split('\n')) {
970
+ if (line.startsWith('#') || !line.includes('=')) continue
971
+ const eq = line.indexOf('=')
972
+ props[line.slice(0, eq).trim()] = line.slice(eq + 1).trim()
973
+ }
974
+ return props
975
+ } catch { return {} }
976
+ }
977
+
978
+ function writeServerProps(changes) {
979
+ const propsPath = path.join(SERVER_DIR, 'server.properties')
980
+ let raw = ''
981
+ try { raw = fs.readFileSync(propsPath, 'utf8') } catch {}
982
+ for (const [key, value] of Object.entries(changes)) {
983
+ const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
984
+ const re = new RegExp(`^${esc}=.*$`, 'm')
985
+ raw = re.test(raw) ? raw.replace(re, `${key}=${value}`) : raw + `\n${key}=${value}`
986
+ }
987
+ fs.writeFileSync(propsPath, raw)
988
+ }
989
+
990
+ async function actWorldSettings() {
991
+ const props = readServerProps()
992
+ const draft = {}
993
+ for (const def of WORLD_DEFS) {
994
+ draft[def.key] = props[def.key] ?? (def.type === 'cycle' ? def.options[0] : def.type === 'num' ? '10' : '')
995
+ }
996
+
997
+ while (true) {
998
+ const items = WORLD_DEFS.map(def => ({
999
+ label: `${def.label.padEnd(20)} ${draft[def.key] || '(blank)'}`,
1000
+ value: def.key,
1001
+ }))
1002
+ items.push({ label: '── Save & close ──', value: '__save__' })
1003
+ items.push({ label: '── Cancel ──', value: '__cancel__' })
1004
+
1005
+ const choice = await modalSelect('World Settings', items)
1006
+ if (!choice || choice === '__cancel__') return
1007
+ if (choice === '__save__') {
1008
+ writeServerProps(draft)
1009
+ await modalMessage('{green-fg}Saved to server.properties.{/}\nRestart server to apply.', 'green')
1010
+ return
1011
+ }
1012
+
1013
+ const def = WORLD_DEFS.find(d => d.key === choice)
1014
+ if (def.type === 'cycle') {
1015
+ const idx = def.options.indexOf(draft[def.key])
1016
+ draft[def.key] = def.options[(idx + 1) % def.options.length]
1017
+ } else if (def.type === 'text') {
1018
+ const val = await modalPrompt(`${def.label} (current: "${draft[def.key] || 'blank'}")`, draft[def.key] || '')
1019
+ if (val !== null) draft[def.key] = val.trim()
1020
+ } else if (def.type === 'num') {
1021
+ const val = await modalPrompt(
1022
+ `${def.label} (${def.min}-${def.max}, current: ${draft[def.key]})`, draft[def.key]
1023
+ )
1024
+ if (val !== null) {
1025
+ const n = parseInt(val.trim())
1026
+ if (!isNaN(n) && n >= def.min && n <= def.max) draft[def.key] = String(n)
1027
+ else await modalMessage(`{red-fg}Invalid — must be ${def.min}-${def.max}.{/}`, 'red')
1028
+ }
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ // ── Cleanup & quit ────────────────────────────────────────────────────────────
1034
+ async function quit() {
1035
+ refresh('{yellow-fg}Shutting down...{/}')
1036
+ for (const name of Object.keys(supervisor.getState())) {
1037
+ supervisor.stop(name)
1038
+ }
1039
+ if (state.serverProc) {
1040
+ try { state.serverProc.kill('SIGTERM') } catch {}
1041
+ }
1042
+ await new Promise(r => setTimeout(r, 800))
1043
+ screen.destroy()
1044
+ process.exit(0)
1045
+ }
1046
+
1047
+ // ── Key bindings ──────────────────────────────────────────────────────────────
1048
+ screen.key(['1'], actStartServer)
1049
+ screen.key(['2'], actStopServer)
1050
+ screen.key(['3'], actSpawnBot)
1051
+ screen.key(['4'], actStopBot)
1052
+ screen.key(['5'], actRestartBot)
1053
+ screen.key(['6'], actWorldSettings)
1054
+ screen.key(['7'], actListModels)
1055
+ screen.key(['8'], actReconfigure)
1056
+ screen.key(['s'], actSwitchBot)
1057
+ screen.key(['r'], () => refresh())
1058
+ screen.key(['q', 'C-c'], quit)
1059
+ screen.key(['tab'], () => screen.focusNext())
1060
+
1061
+ logPanel.key(['up','down','pageup','pagedown'], () => {})
1062
+ logPanel.focus()
1063
+
1064
+ // ── Startup ───────────────────────────────────────────────────────────────────
1065
+ refresh()
1066
+ followEvents()
1067
+ setInterval(() => renderHeader() || screen.render(), 1000)
1068
+
1069
+ logPanel.log(`{green-fg}embark-ai started.{/} Minecraft ${MC_VERSION}`)
1070
+ logPanel.log(`{gray-fg}Watching: ${EVENTS_FILE}{/}`)
1071
+
1072
+ async function startup() {
1073
+ appConfig = loadConfig()
1074
+ if (!appConfig) {
1075
+ logPanel.log('{yellow-fg}No configuration found — starting setup wizard...{/}')
1076
+ screen.render()
1077
+ await new Promise(r => setTimeout(r, 400))
1078
+ const cfg = await runOnboarding()
1079
+ if (cfg) {
1080
+ appConfig = cfg
1081
+ applyConfig(cfg)
1082
+ logPanel.log(`{green-fg}Configuration saved.{/}`)
1083
+ } else {
1084
+ logPanel.log('{yellow-fg}Setup cancelled. Press [8] to configure when ready.{/}')
1085
+ }
1086
+ } else {
1087
+ applyConfig(appConfig)
1088
+ const summary = appConfig.llmMode === 'ollama'
1089
+ ? `Ollama (${appConfig.ollamaModel})`
1090
+ : `Featherless API (${appConfig.featherlessModel || 'no model'})`
1091
+ logPanel.log(`{green-fg}Config loaded:{/} {bold}${summary}{/bold}`)
1092
+ }
1093
+ refresh()
1094
+ }
1095
+
1096
+ process.on('SIGINT', quit)
1097
+ process.on('SIGTERM', quit)
1098
+
1099
+ startup().catch(err => logPanel.log(`{red-fg}Startup error: ${err.message}{/}`))