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/README.md +351 -0
- package/botSupervisor.js +237 -0
- package/mc-server/bot/bot.js +1415 -0
- package/mc-server/bot/damagePipeline.js +402 -0
- package/mc-server/bot/engine.js +212 -0
- package/mc-server/bot/entityLiveness.js +121 -0
- package/mc-server/bot/env.js +38 -0
- package/mc-server/bot/environmentPerception.js +384 -0
- package/mc-server/bot/fatalDesyncRecovery.js +42 -0
- package/mc-server/bot/goalRegistry.js +49 -0
- package/mc-server/bot/healthIntegrityWatchdog.js +59 -0
- package/mc-server/bot/llm.js +232 -0
- package/mc-server/bot/locomotionRecovery.js +190 -0
- package/mc-server/bot/logger.js +63 -0
- package/mc-server/bot/memory.js +59 -0
- package/mc-server/bot/movementController.js +110 -0
- package/mc-server/bot/package.json +14 -0
- package/mc-server/bot/positionGuard.js +75 -0
- package/mc-server/bot/recoveryEngine.js +315 -0
- package/mc-server/bot/safeMineflayer.js +129 -0
- package/mc-server/bot/state.js +105 -0
- package/mc-server/bot/tasks.js +939 -0
- package/mc-server/server.properties +74 -0
- package/package.json +44 -0
- package/tui.js +1099 -0
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}{/}`))
|