From cce492f6854ac2fbd066b6fc04623ff0e7832d89 Mon Sep 17 00:00:00 2001 From: Rin Date: Wed, 4 Feb 2026 06:24:25 +0800 Subject: [PATCH] feat(minecraft): add REPL dashboard tab with live execution --- .../src/cognitive/conscious/brain.ts | 158 ++++++++++++-- .../cognitive/conscious/js-planner.test.ts | 21 +- .../src/cognitive/conscious/js-planner.ts | 107 ++++++++- .../src/cognitive/conscious/llmlogic.ts | 203 +++++++++--------- services/minecraft/src/cognitive/index.ts | 27 +++ services/minecraft/src/debug/debug-service.ts | 29 ++- services/minecraft/src/debug/types.ts | 79 +++++-- services/minecraft/src/debug/web/app.js | 201 ++++++++++++++++- services/minecraft/src/debug/web/index.html | 34 ++- services/minecraft/src/debug/web/styles.css | 120 ++++++++++- 10 files changed, 834 insertions(+), 145 deletions(-) diff --git a/services/minecraft/src/cognitive/conscious/brain.ts b/services/minecraft/src/cognitive/conscious/brain.ts index c10f868d2..583e15a40 100644 --- a/services/minecraft/src/cognitive/conscious/brain.ts +++ b/services/minecraft/src/cognitive/conscious/brain.ts @@ -2,17 +2,19 @@ import type { Logg } from '@guiiai/logg' import type { Message } from '@xsai/shared-chat' import type { TaskExecutor } from '../action/task-executor' +import type { ActionInstruction } from '../action/types' import type { EventBus, TracedEvent } from '../os' import type { PerceptionSignal } from '../perception/types/signals' import type { ReflexManager } from '../reflex/reflex-manager' import type { BotEvent, MineflayerWithAgents } from '../types' -import type { ActionInstruction } from '../action/types' +import type { PlannerGlobalDescriptor } from './js-planner' +import type { LLMAgent } from './llm-agent' +import type { CancellationToken } from './task-state' import { config } from '../../composables/config' import { DebugService } from '../../debug' import { buildConsciousContextView } from './context-view' import { JavaScriptPlanner } from './js-planner' -import { LLMAgent } from './llm-agent' import { isLikelyAuthOrBadArgError, isRateLimitError, @@ -20,7 +22,7 @@ import { toErrorMessage, } from './llmlogic' import { generateBrainSystemPrompt } from './prompts/brain-prompt' -import { createCancellationToken, type CancellationToken } from './task-state' +import { createCancellationToken } from './task-state' interface BrainDeps { eventBus: EventBus @@ -45,6 +47,22 @@ interface PlannerOutcomeSummary { updatedAt: number } +interface DebugReplResult { + code: string + logs: string[] + actions: Array<{ + tool: string + params: Record + ok: boolean + result?: string + error?: string + }> + returnValue?: string + error?: string + durationMs: number + timestamp: number +} + function truncateForPrompt(value: string, maxLength = 220): string { return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}...` } @@ -56,6 +74,7 @@ export class Brain { // State private queue: QueuedEvent[] = [] private isProcessing = false + private isReplEvaluating = false private currentCancellationToken: CancellationToken | undefined private giveUpUntil = 0 private giveUpReason: string | undefined @@ -123,6 +142,108 @@ export class Brain { this.currentCancellationToken?.cancel() } + public getReplState(): { variables: PlannerGlobalDescriptor[], updatedAt: number } { + const snapshot = this.deps.reflexManager.getContextSnapshot() + const variables = this.planner.describeGlobals( + this.deps.taskExecutor.getAvailableActions(), + { + event: { + type: 'system_alert', + payload: { source: 'debug-repl-state' }, + source: { type: 'system', id: 'debug-repl' }, + timestamp: Date.now(), + }, + snapshot: snapshot as unknown as Record, + }, + ) + + return { + variables, + updatedAt: Date.now(), + } + } + + public async executeDebugRepl(code: string): Promise { + const startedAt = Date.now() + if (this.isProcessing || this.isReplEvaluating) { + return { + code, + logs: [], + actions: [], + error: 'Brain is currently processing an event. Try again in a moment.', + durationMs: Date.now() - startedAt, + timestamp: Date.now(), + } + } + + const snapshot = this.deps.reflexManager.getContextSnapshot() + const actionDefs = new Map(this.deps.taskExecutor.getAvailableActions().map(action => [action.name, action])) + const normalizedReplCode = this.normalizeReplCode(code) + const codeToEvaluate = this.planner.canEvaluateAsExpression(normalizedReplCode) + ? `return (\n${normalizedReplCode}\n)` + : normalizedReplCode + + this.isReplEvaluating = true + try { + const runResult = await this.planner.evaluate( + codeToEvaluate, + this.deps.taskExecutor.getAvailableActions(), + { + event: { + type: 'system_alert', + payload: { source: 'debug-repl' }, + source: { type: 'system', id: 'debug-repl' }, + timestamp: Date.now(), + }, + snapshot: snapshot as unknown as Record, + }, + async (action: ActionInstruction) => { + const actionDef = actionDefs.get(action.tool) + if (actionDef?.followControl === 'detach') + this.deps.reflexManager.clearFollowTarget() + return this.deps.taskExecutor.executeActionWithResult(action) + }, + ) + + return { + code, + logs: runResult.logs, + actions: runResult.actions.map(item => ({ + tool: item.action.tool, + params: item.action.params, + ok: item.ok, + result: item.result === undefined ? undefined : (typeof item.result === 'string' ? item.result : JSON.stringify(item.result)), + error: item.error, + })), + returnValue: runResult.returnValue, + durationMs: Date.now() - startedAt, + timestamp: Date.now(), + } + } + catch (err) { + return { + code, + logs: [], + actions: [], + error: toErrorMessage(err), + durationMs: Date.now() - startedAt, + timestamp: Date.now(), + } + } + finally { + this.isReplEvaluating = false + } + } + + private normalizeReplCode(code: string): string { + // Simple top-level var persistence for REPL UX: + // const/let/var foo = ... -> globalThis.foo = ... + return code.replace( + /^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^\n]+?)\s*(?:;\s*)?$/gm, + (_full: string, name: string, valueExpr: string) => `globalThis.${name} = ${valueExpr}`, + ) + } + // --- Event Queue Logic --- private async enqueueEvent(bot: MineflayerWithAgents, event: BotEvent): Promise { @@ -133,7 +254,8 @@ export class Brain { } private async processQueue(bot: MineflayerWithAgents): Promise { - if (this.isProcessing || this.queue.length === 0) return + if (this.isProcessing || this.queue.length === 0) + return try { this.isProcessing = true @@ -148,11 +270,13 @@ export class Brain { try { await this.processEvent(bot, item.event) item.resolve() - } catch (err) { + } + catch (err) { this.deps.logger.withError(err).error('Brain: Error processing event') item.reject(err as Error) } - } finally { + } + finally { this.isProcessing = false this.debugService.emitBrainState({ status: 'idle', @@ -211,7 +335,8 @@ export class Brain { const content = llmResult.text const reasoning = llmResult.reasoning - if (!content) throw new Error('No content from LLM') + if (!content) + throw new Error('No content from LLM') // Capture reasoning for later use capturedReasoning = reasoning @@ -234,7 +359,8 @@ export class Brain { }) break // Success, exit retry loop - } catch (err) { + } + catch (err) { const remaining = maxAttempts - attempt const isRateLimit = isRateLimitError(err) const shouldRetry = remaining > 0 && !isLikelyAuthOrBadArgError(err) @@ -323,8 +449,8 @@ export class Brain { logs: runResult.logs, returnValue: runResult.returnValue, }) - - } catch (err) { + } + catch (err) { this.deps.logger.withError(err).error('Brain: Failed to execute decision') void this.enqueueEvent(bot, { type: 'feedback', @@ -343,18 +469,22 @@ export class Brain { const signal = event.payload as PerceptionSignal if (signal.type === 'chat_message') { parts.push(`[EVENT] ${signal.description}`) - } else { + } + else { parts.push(`[EVENT] Perception Signal: ${signal.description}`) } - } else if (event.type === 'feedback') { + } + else if (event.type === 'feedback') { const p = event.payload as any const tool = p.action?.tool || 'unknown' if (p.status === 'success') { parts.push(`[FEEDBACK] ${tool}: Success. ${typeof p.result === 'string' ? p.result : JSON.stringify(p.result)}`) - } else { + } + else { parts.push(`[FEEDBACK] ${tool}: Failed. ${p.error}`) } - } else { + } + else { parts.push(`[EVENT] ${event.type}: ${JSON.stringify(event.payload)}`) } diff --git a/services/minecraft/src/cognitive/conscious/js-planner.test.ts b/services/minecraft/src/cognitive/conscious/js-planner.test.ts index a8850ffc6..edeaa62aa 100644 --- a/services/minecraft/src/cognitive/conscious/js-planner.test.ts +++ b/services/minecraft/src/cognitive/conscious/js-planner.test.ts @@ -23,7 +23,7 @@ const actions: Action[] = [ })), ] -describe('JavaScriptPlanner', () => { +describe('javaScriptPlanner', () => { const globals = { event: { type: 'perception', @@ -146,4 +146,23 @@ describe('JavaScriptPlanner', () => { expectMoved(1, "did not move enough") `, actions, globals, executeAction)).rejects.toThrow(/Expectation failed: did not move enough/i) }) + + it('describes registered globals for debug REPL', () => { + const planner = new JavaScriptPlanner() + const descriptors = planner.describeGlobals(actions, globals) + const names = descriptors.map(d => d.name) + + expect(names).toContain('mem') + expect(names).toContain('chat') + expect(names).toContain('goToPlayer') + + const mem = descriptors.find(d => d.name === 'mem') + expect(mem?.readonly).toBe(false) + }) + + it('detects expression-friendly REPL inputs', () => { + const planner = new JavaScriptPlanner() + expect(planner.canEvaluateAsExpression('2 + 3')).toBe(true) + expect(planner.canEvaluateAsExpression('const a = 1; a + 1')).toBe(false) + }) }) diff --git a/services/minecraft/src/cognitive/conscious/js-planner.ts b/services/minecraft/src/cognitive/conscious/js-planner.ts index a62b47749..558a4975a 100644 --- a/services/minecraft/src/cognitive/conscious/js-planner.ts +++ b/services/minecraft/src/cognitive/conscious/js-planner.ts @@ -2,9 +2,10 @@ import type { Action } from '../../libs/mineflayer/action' import type { ActionInstruction } from '../action/types' import type { BotEvent } from '../types' -import { inspect } from 'node:util' import vm from 'node:vm' +import { inspect } from 'node:util' + interface JavaScriptPlannerOptions { timeoutMs?: number maxActionsPerTurn?: number @@ -69,6 +70,13 @@ export interface JavaScriptRunResult { returnValue?: string } +export interface PlannerGlobalDescriptor { + name: string + kind: 'tool' | 'function' | 'object' | 'number' | 'string' | 'boolean' | 'undefined' | 'null' | 'unknown' + readonly: boolean + preview: string +} + export function extractJavaScriptCandidate(input: string): string { const trimmed = input.trim() const fenced = trimmed.match(/^```(?:js|javascript|ts|typescript)?\s*([\s\S]*?)\s*```$/i) @@ -136,6 +144,87 @@ export class JavaScriptPlanner { } } + public canEvaluateAsExpression(content: string): boolean { + const script = extractJavaScriptCandidate(content) + if (!script.trim()) + return false + + try { + void new vm.Script(`(async () => (\n${script}\n))()`) + return true + } + catch { + return false + } + } + + public describeGlobals(availableActions: Action[], globals: RuntimeGlobals): PlannerGlobalDescriptor[] { + const descriptors: PlannerGlobalDescriptor[] = [] + + const staticGlobals: Array> = [ + { name: 'skip', kind: 'tool', readonly: true }, + { name: 'use', kind: 'function', readonly: true }, + { name: 'log', kind: 'function', readonly: true }, + { name: 'expect', kind: 'function', readonly: true }, + { name: 'expectMoved', kind: 'function', readonly: true }, + { name: 'expectNear', kind: 'function', readonly: true }, + { name: 'snapshot', kind: 'object', readonly: true }, + { name: 'event', kind: 'object', readonly: true }, + { name: 'now', kind: 'number', readonly: true }, + { name: 'self', kind: 'object', readonly: true }, + { name: 'environment', kind: 'object', readonly: true }, + { name: 'social', kind: 'object', readonly: true }, + { name: 'threat', kind: 'object', readonly: true }, + { name: 'attention', kind: 'object', readonly: true }, + { name: 'autonomy', kind: 'object', readonly: true }, + { name: 'mem', kind: 'object', readonly: false }, + { name: 'lastRun', kind: 'object', readonly: true }, + { name: 'prevRun', kind: 'object', readonly: true }, + { name: 'lastAction', kind: 'object', readonly: true }, + ] + + const valueByName: Record = { + snapshot: globals.snapshot, + event: globals.event, + now: Date.now(), + self: (globals.snapshot as Record)?.self, + environment: (globals.snapshot as Record)?.environment, + social: (globals.snapshot as Record)?.social, + threat: (globals.snapshot as Record)?.threat, + attention: (globals.snapshot as Record)?.attention, + autonomy: (globals.snapshot as Record)?.autonomy, + mem: this.sandbox.mem, + lastRun: this.sandbox.lastRun, + prevRun: this.sandbox.prevRun, + lastAction: this.sandbox.lastAction, + skip: this.sandbox.skip, + use: this.sandbox.use, + log: this.sandbox.log, + expect: this.sandbox.expect, + expectMoved: this.sandbox.expectMoved, + expectNear: this.sandbox.expectNear, + } + + for (const item of staticGlobals) { + descriptors.push({ + ...item, + preview: this.previewValue(valueByName[item.name]), + }) + } + + for (const action of availableActions) { + descriptors.push({ + name: action.name, + kind: 'tool', + readonly: true, + preview: action.description || '(tool)', + }) + } + + descriptors.sort((a, b) => a.name.localeCompare(b.name)) + return descriptors + } + private installBuiltins(): void { this.defineGlobalTool('skip', async () => this.runAction('skip', {})) this.defineGlobalTool('use', (toolName: unknown, params?: unknown) => { @@ -373,7 +462,9 @@ export class JavaScriptPlanner { const parsed = action.schema.safeParse(params) if (!parsed.success) { - const details = parsed.error.issues.map(issue => `${issue.path.join('.') || 'root'}: ${issue.message}`).join('; ') + const details = parsed.error.issues + .map((issue: { path: Array, message: string }) => `${issue.path.join('.') || 'root'}: ${issue.message}`) + .join('; ') return { error: `Invalid tool parameters for ${tool}: ${details}`, } @@ -397,4 +488,16 @@ export class JavaScriptPlanner { writable: false, }) } + + private previewValue(value: unknown): string { + if (value === null) + return 'null' + if (typeof value === 'undefined') + return 'undefined' + if (typeof value === 'string') + return value.length > 120 ? `${value.slice(0, 117)}...` : value + + const rendered = inspect(value, { depth: 1, breakLength: 120 }) + return rendered.length > 120 ? `${rendered.slice(0, 117)}...` : rendered + } } diff --git a/services/minecraft/src/cognitive/conscious/llmlogic.ts b/services/minecraft/src/cognitive/conscious/llmlogic.ts index 69ab821c4..5de7890a2 100644 --- a/services/minecraft/src/cognitive/conscious/llmlogic.ts +++ b/services/minecraft/src/cognitive/conscious/llmlogic.ts @@ -1,186 +1,189 @@ import type { Message } from '@xsai/shared-chat' export interface LLMTraceData { - route: string - messages: Message[] - content: string - reasoning?: string - usage: any - model: string - duration: number + route: string + messages: Message[] + content: string + reasoning?: string + usage: any + model: string + duration: number } export interface RetryDecision { - shouldRetry: boolean - remainingAttempts: number + shouldRetry: boolean + remainingAttempts: number } /** * Pure function to build messages for LLM */ export function buildMessages(sysPrompt: string, userMsg: string): Message[] { - return [ - { role: 'system', content: sysPrompt }, - { role: 'user', content: userMsg }, - ] + return [ + { role: 'system', content: sysPrompt }, + { role: 'user', content: userMsg }, + ] } /** * Pure function to convert error to message string */ export function toErrorMessage(err: unknown): string { - if (err instanceof Error) - return err.message - if (typeof err === 'string') - return err - try { - return JSON.stringify(err) - } - catch { - return String(err) - } + if (err instanceof Error) + return err.message + if (typeof err === 'string') + return err + if (typeof err === 'object' && err !== null && 'message' in err && typeof (err as { message: unknown }).message === 'string') + return (err as { message: string }).message + try { + return JSON.stringify(err) + } + catch { + return String(err) + } } /** * Pure function to get HTTP status from error */ export function getErrorStatus(err: unknown): number | undefined { - const anyErr = err as any - const status = anyErr?.status ?? anyErr?.response?.status ?? anyErr?.cause?.status - return typeof status === 'number' ? status : undefined + const anyErr = err as any + const status = anyErr?.status ?? anyErr?.response?.status ?? anyErr?.cause?.status + return typeof status === 'number' ? status : undefined } /** * Pure function to get error code */ export function getErrorCode(err: unknown): string | undefined { - const anyErr = err as any - const code = anyErr?.code ?? anyErr?.cause?.code - return typeof code === 'string' ? code : undefined + const anyErr = err as any + const code = anyErr?.code ?? anyErr?.cause?.code + return typeof code === 'string' ? code : undefined } /** * Pure function to check if error is likely auth or bad argument error */ export function isLikelyAuthOrBadArgError(err: unknown): boolean { - const msg = toErrorMessage(err).toLowerCase() - const status = getErrorStatus(err) - if (status === 401 || status === 403) - return true + const msg = toErrorMessage(err).toLowerCase() + const status = getErrorStatus(err) + if (status === 401 || status === 403) + return true - return ( - msg.includes('unauthorized') - || msg.includes('invalid api key') - || msg.includes('authentication') - || msg.includes('forbidden') - || msg.includes('badarg') - || msg.includes('bad arg') - || msg.includes('invalid argument') - || msg.includes('invalid_request_error') - ) + return ( + msg.includes('unauthorized') + || msg.includes('invalid api key') + || msg.includes('authentication') + || msg.includes('forbidden') + || msg.includes('badarg') + || msg.includes('bad arg') + || msg.includes('invalid argument') + || msg.includes('invalid_request_error') + ) } /** * Pure function to check if error is rate limit */ export function isRateLimitError(err: unknown): boolean { - const status = getErrorStatus(err) - if (status === 429) return true - const msg = toErrorMessage(err).toLowerCase() - return msg.includes('rate limit') || msg.includes('too many requests') + const status = getErrorStatus(err) + if (status === 429) + return true + const msg = toErrorMessage(err).toLowerCase() + return msg.includes('rate limit') || msg.includes('too many requests') } /** * Pure function to check if error is likely recoverable */ export function isLikelyRecoverableError(err: unknown): boolean { - if (err instanceof SyntaxError) - return true + if (err instanceof SyntaxError) + return true - const status = getErrorStatus(err) - if (status === 429) - return true - if (typeof status === 'number' && status >= 500) - return true + const status = getErrorStatus(err) + if (status === 429) + return true + if (typeof status === 'number' && status >= 500) + return true - const code = getErrorCode(err) - if (code && ['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED'].includes(code)) - return true + const code = getErrorCode(err) + if (code && ['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED'].includes(code)) + return true - const msg = toErrorMessage(err).toLowerCase() - return ( - msg.includes('timeout') - || msg.includes('timed out') - || msg.includes('rate limit') - || msg.includes('overloaded') - || msg.includes('temporarily') - || msg.includes('try again') - || (msg.includes('in json') && msg.includes('position')) - ) + const msg = toErrorMessage(err).toLowerCase() + return ( + msg.includes('timeout') + || msg.includes('timed out') + || msg.includes('rate limit') + || msg.includes('overloaded') + || msg.includes('temporarily') + || msg.includes('try again') + || (msg.includes('in json') && msg.includes('position')) + ) } /** * Pure function to decide whether to retry */ export function shouldRetryError(err: unknown, remainingAttempts: number): RetryDecision { - const shouldRetry = remainingAttempts > 0 && !isLikelyAuthOrBadArgError(err) && isLikelyRecoverableError(err) - return { - shouldRetry, - remainingAttempts, - } + const shouldRetry = remainingAttempts > 0 && !isLikelyAuthOrBadArgError(err) && isLikelyRecoverableError(err) + return { + shouldRetry, + remainingAttempts, + } } /** * Pure function to extract JSON from LLM response */ export function extractJsonCandidate(input: string): string { - const trimmed = input.trim() - const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i) - if (fenced?.[1]) - return fenced[1].trim() + const trimmed = input.trim() + const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i) + if (fenced?.[1]) + return fenced[1].trim() - const start = trimmed.indexOf('{') - const end = trimmed.lastIndexOf('}') - if (start >= 0 && end > start) - return trimmed.slice(start, end + 1) + const start = trimmed.indexOf('{') + const end = trimmed.lastIndexOf('}') + if (start >= 0 && end > start) + return trimmed.slice(start, end + 1) - return trimmed + return trimmed } /** * Get JSON error position from error message */ function getJsonErrorPosition(err: unknown): number | null { - const msg = toErrorMessage(err) - const match = msg.match(/position\s+(\d+)/i) - if (!match) - return null + const msg = toErrorMessage(err) + const match = msg.match(/position\s+(\d+)/i) + if (!match) + return null - const pos = Number.parseInt(match[1], 10) - return Number.isFinite(pos) ? pos : null + const pos = Number.parseInt(match[1], 10) + return Number.isFinite(pos) ? pos : null } /** * Pure function to parse LLM JSON response */ export function parseLLMResponseJson(response: string): T { - const candidate = extractJsonCandidate(response) - try { - return JSON.parse(candidate) as T - } - catch (err) { - const pos = getJsonErrorPosition(err) - const window = 120 - const snippet = (typeof pos === 'number') - ? candidate.slice(Math.max(0, pos - window), Math.min(candidate.length, pos + window)) - : candidate.slice(0, Math.min(candidate.length, 240)) - throw new Error(`Failed to parse LLM JSON response: ${toErrorMessage(err)}; snippet=${JSON.stringify(snippet)}`) - } + const candidate = extractJsonCandidate(response) + try { + return JSON.parse(candidate) as T + } + catch (err) { + const pos = getJsonErrorPosition(err) + const window = 120 + const snippet = (typeof pos === 'number') + ? candidate.slice(Math.max(0, pos - window), Math.min(candidate.length, pos + window)) + : candidate.slice(0, Math.min(candidate.length, 240)) + throw new Error(`Failed to parse LLM JSON response: ${toErrorMessage(err)}; snippet=${JSON.stringify(snippet)}`) + } } /** * Sleep utility */ export function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) + return new Promise(resolve => setTimeout(resolve, ms)) } diff --git a/services/minecraft/src/cognitive/index.ts b/services/minecraft/src/cognitive/index.ts index 2f6fb9902..26ea45e78 100644 --- a/services/minecraft/src/cognitive/index.ts +++ b/services/minecraft/src/cognitive/index.ts @@ -1,6 +1,7 @@ import type { MineflayerPlugin } from '../libs/mineflayer' import type { CognitiveEngineOptions, MineflayerWithAgents } from './types' +import { DebugService } from '../debug' import { ChatMessageHandler } from '../libs/mineflayer' import { createAgentContainer } from './container' import { computeNearbyPlayerGaze } from './reflex/gaze' @@ -22,6 +23,32 @@ export function CognitiveEngine(options: CognitiveEngineOptions): MineflayerPlug const brain = container.resolve('brain') const reflexManager = container.resolve('reflexManager') const taskExecutor = container.resolve('taskExecutor') + const debugService = DebugService.getInstance() + + debugService.onCommand('request_repl_state', () => { + debugService.emit('debug:repl_state', brain.getReplState()) + }) + + debugService.onCommand('execute_repl', async (command) => { + if (command.type !== 'execute_repl') + return + + const code = command.payload?.code + if (typeof code !== 'string') { + debugService.emit('debug:repl_result', { + code: '', + logs: [], + actions: [], + error: 'Invalid REPL request: code must be a string', + durationMs: 0, + timestamp: Date.now(), + }) + return + } + + const result = await brain.executeDebugRepl(code) + debugService.emit('debug:repl_result', result) + }) // Initialize task executor with mineflayer instance taskExecutor.setMineflayer(bot) diff --git a/services/minecraft/src/debug/debug-service.ts b/services/minecraft/src/debug/debug-service.ts index 0d96cb9e2..ec63d170b 100644 --- a/services/minecraft/src/debug/debug-service.ts +++ b/services/minecraft/src/debug/debug-service.ts @@ -1,4 +1,17 @@ -import type { BlackboardEvent, BrainStateEvent, ClientCommand, LLMTraceEvent, LogEvent, QueueEvent, ReflexStateEvent, SaliencyEvent, ServerEvent, TraceEvent } from './types' +import type { + BlackboardEvent, + BrainStateEvent, + ClientCommand, + LLMTraceEvent, + LogEvent, + QueueEvent, + ReflexStateEvent, + ReplExecutionResultEvent, + ReplStateEvent, + SaliencyEvent, + ServerEvent, + TraceEvent, +} from './types' import { DebugServer } from './server' @@ -73,8 +86,6 @@ export class DebugService { this.server.broadcast(event) } - - /** * Emit a brain state update */ @@ -214,6 +225,18 @@ export class DebugService { payload: payload as any, }) break + case 'debug:repl_state': + this.server.broadcast({ + type: 'debug:repl_state', + payload: payload as ReplStateEvent, + }) + break + case 'debug:repl_result': + this.server.broadcast({ + type: 'debug:repl_result', + payload: payload as ReplExecutionResultEvent, + }) + break default: // For unknown types, emit as log this.log('DEBUG', `Unknown event type: ${type}`, { payload }) diff --git a/services/minecraft/src/debug/types.ts b/services/minecraft/src/debug/types.ts index e10c2036f..7d5b676eb 100644 --- a/services/minecraft/src/debug/types.ts +++ b/services/minecraft/src/debug/types.ts @@ -139,6 +139,34 @@ export interface ToolExecutionResultEvent { timestamp: number } +export interface ReplVariableDescriptor { + name: string + kind: 'tool' | 'function' | 'object' | 'number' | 'string' | 'boolean' | 'undefined' | 'null' | 'unknown' + readonly: boolean + preview: string +} + +export interface ReplStateEvent { + variables: ReplVariableDescriptor[] + updatedAt: number +} + +export interface ReplExecutionResultEvent { + code: string + logs: string[] + actions: Array<{ + tool: string + params: Record + ok: boolean + result?: string + error?: string + }> + returnValue?: string + error?: string + durationMs: number + timestamp: number +} + // ============================================================ // Server Events Extension // ============================================================ @@ -147,18 +175,20 @@ export interface ToolExecutionResultEvent { export type ServerEvent = | { type: 'log', payload: LogEvent } - | { type: 'llm', payload: LLMTraceEvent } - | { type: 'blackboard', payload: BlackboardEvent } - | { type: 'queue', payload: QueueEvent } - | { type: 'saliency', payload: SaliencyEvent } - | { type: 'reflex', payload: ReflexStateEvent } - | { type: 'trace', payload: TraceEvent } - | { type: 'trace_batch', payload: TraceBatchEvent } - | { type: 'history', payload: ServerEvent[] } - | { type: 'pong', payload: { timestamp: number } } - | { type: 'debug:tools_list', payload: { tools: ToolDefinition[] } } - | { type: 'debug:tool_result', payload: ToolExecutionResultEvent } - | { type: 'brain_state', payload: BrainStateEvent } + | { type: 'llm', payload: LLMTraceEvent } + | { type: 'blackboard', payload: BlackboardEvent } + | { type: 'queue', payload: QueueEvent } + | { type: 'saliency', payload: SaliencyEvent } + | { type: 'reflex', payload: ReflexStateEvent } + | { type: 'trace', payload: TraceEvent } + | { type: 'trace_batch', payload: TraceBatchEvent } + | { type: 'history', payload: ServerEvent[] } + | { type: 'pong', payload: { timestamp: number } } + | { type: 'debug:tools_list', payload: { tools: ToolDefinition[] } } + | { type: 'debug:tool_result', payload: ToolExecutionResultEvent } + | { type: 'debug:repl_state', payload: ReplStateEvent } + | { type: 'debug:repl_result', payload: ReplExecutionResultEvent } + | { type: 'brain_state', payload: BrainStateEvent } // ============================================================ // Client -> Server commands @@ -209,14 +239,27 @@ export interface RequestToolsCommand { type: 'request_tools' } +export interface RequestReplStateCommand { + type: 'request_repl_state' +} + +export interface ExecuteReplCommand { + type: 'execute_repl' + payload: { + code: string + } +} + export type ClientCommand = | ClearLogsCommand - | SetFilterCommand - | InjectEventCommand - | PingCommand - | RequestHistoryCommand - | ExecuteToolCommand - | RequestToolsCommand + | SetFilterCommand + | InjectEventCommand + | PingCommand + | RequestHistoryCommand + | ExecuteToolCommand + | RequestToolsCommand + | RequestReplStateCommand + | ExecuteReplCommand // ============================================================ // Wire format diff --git a/services/minecraft/src/debug/web/app.js b/services/minecraft/src/debug/web/app.js index 79ee0055e..a4c9cfc4e 100644 --- a/services/minecraft/src/debug/web/app.js +++ b/services/minecraft/src/debug/web/app.js @@ -10,6 +10,7 @@ const CONFIG = { MAX_LOGS: 500, MAX_LLM_TRACES: 50, + MAX_REPL_RESULTS: 20, RECONNECT_MAX_ATTEMPTS: 10, RECONNECT_DELAY: 1000, PING_INTERVAL: 25000, @@ -488,7 +489,7 @@ class LogsPanel { if (!select) return const current = select.value - select.innerHTML = '' + files.map(f => ``).join('') + select.innerHTML = `${files.map(f => ``).join('')}` if (files.includes(current)) select.value = current } @@ -614,8 +615,8 @@ class LLMPanel {
${msg.role || 'unknown'}
${escapeHtml(msg.role === 'system' - ? formatSystemMessageContent(msg.content || '') - : (msg.content || ''))}
+ ? formatSystemMessageContent(msg.content || '') + : (msg.content || ''))}
`).join('') } @@ -632,10 +633,12 @@ class LLMPanel {
- ${trace.reasoning ? ` + ${trace.reasoning + ? `
Reasoning
${escapeHtml(trace.reasoning)}
- ` : ''} + ` + : ''}
Result
${escapeHtml(trace.content || '')}
@@ -995,6 +998,192 @@ class ToolsPanel { } } +class ReplPanel { + constructor(client) { + this.client = client + this.variables = [] + this.variableFilter = '' + this.results = [] + this.isRunning = false + this.elements = { + varsList: document.getElementById('repl-vars-list'), + varsSearch: document.getElementById('repl-vars-search'), + refreshBtn: document.getElementById('repl-refresh-state'), + runBtn: document.getElementById('repl-run-btn'), + codeInput: document.getElementById('repl-code-input'), + resultList: document.getElementById('repl-result-list'), + } + } + + init() { + this.client.on('debug:repl_state', data => this.updateState(data)) + this.client.on('debug:repl_result', data => this.handleResult(data)) + this.client.on('connected', () => this.requestState()) + + this.elements.refreshBtn?.addEventListener('click', () => this.requestState()) + this.elements.varsSearch?.addEventListener('input', (event) => { + this.variableFilter = (event.target?.value || '').toLowerCase() + this.renderVariables() + }) + this.elements.runBtn?.addEventListener('click', () => this.execute()) + this.elements.codeInput?.addEventListener('keydown', (event) => { + const isEnter = event.key === 'Enter' + const hasModifier = event.metaKey || event.ctrlKey + if (!isEnter || !hasModifier) + return + + event.preventDefault() + this.execute() + }) + + this.renderVariables() + this.renderResults() + } + + requestState() { + this.client.send({ type: 'request_repl_state' }) + } + + execute() { + const code = this.elements.codeInput?.value ?? '' + if (!code.trim()) { + this.results.unshift({ + code: '', + logs: [], + actions: [], + error: 'Code is empty', + durationMs: 0, + timestamp: Date.now(), + }) + this.results = this.results.slice(0, CONFIG.MAX_REPL_RESULTS) + this.renderResults() + return + } + + this.isRunning = true + this.renderRunState() + + this.client.send({ + type: 'execute_repl', + payload: { code }, + }) + } + + updateState(data) { + if (!data || !Array.isArray(data.variables)) + return + this.variables = data.variables + this.renderVariables() + } + + handleResult(data) { + this.isRunning = false + this.renderRunState() + + this.results.unshift(data) + if (this.results.length > CONFIG.MAX_REPL_RESULTS) { + this.results = this.results.slice(0, CONFIG.MAX_REPL_RESULTS) + } + this.renderResults() + } + + renderRunState() { + if (!this.elements.runBtn) + return + + this.elements.runBtn.disabled = this.isRunning + this.elements.runBtn.textContent = this.isRunning ? 'Running...' : 'Run (Ctrl/Cmd+Enter)' + } + + renderVariables() { + if (!this.elements.varsList) + return + + if (this.variables.length === 0) { + this.elements.varsList.innerHTML = '
No variables loaded
' + return + } + + const filtered = this.variables.filter((variable) => { + if (!this.variableFilter) + return true + const searchSpace = `${variable.name} ${variable.kind} ${variable.preview} ${variable.readonly ? 'readonly' : 'writable'}`.toLowerCase() + return searchSpace.includes(this.variableFilter) + }) + + if (filtered.length === 0) { + this.elements.varsList.innerHTML = '
No variables match filter
' + return + } + + this.elements.varsList.innerHTML = filtered.map(v => ` +
+
${escapeHtml(v.name)}
+
${escapeHtml(v.kind)} · ${v.readonly ? 'readonly' : 'writable'}
+
${escapeHtml(v.preview || '')}
+
+ `).join('') + } + + renderResults() { + if (!this.elements.resultList) + return + + if (this.results.length === 0) { + this.elements.resultList.innerHTML = '
No REPL executions yet
' + return + } + + this.elements.resultList.innerHTML = this.results.map((result) => { + const isError = !!result.error + const actionSummary = Array.isArray(result.actions) && result.actions.length > 0 + ? result.actions.map((action) => { + const status = action.ok ? 'ok' : 'error' + return `${status} ${action.tool}(${JSON.stringify(action.params || {})})${action.error ? ` -> ${action.error}` : ''}${action.result ? ` -> ${action.result}` : ''}` + }).join('\n') + : '(none)' + const logsSummary = Array.isArray(result.logs) && result.logs.length > 0 + ? result.logs.join('\n') + : '(none)' + const returnValue = typeof result.returnValue === 'string' ? result.returnValue : '(undefined)' + const time = new Date(result.timestamp || Date.now()).toLocaleTimeString() + + return ` +
+
+ ${time} + ${Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : '-'} +
+
+
Code
+
${escapeHtml(result.code || '')}
+
+
+
Return
+
${escapeHtml(returnValue)}
+
+
+
Actions
+
${escapeHtml(actionSummary)}
+
+
+
Logs
+
${escapeHtml(logsSummary)}
+
+ ${isError + ? ` +
+
Error
+
${escapeHtml(result.error)}
+
+ ` + : ''} +
+ ` + }).join('') + } +} + class TimelinePanel { constructor(client) { this.client = client @@ -1320,6 +1509,7 @@ class DebugApp { this.saliencyPanel = new SaliencyPanel(this.client) this.timelinePanel = new TimelinePanel(this.client) this.toolsPanel = new ToolsPanel(this.client) + this.replPanel = new ReplPanel(this.client) this.panels = { queue: this.queuePanel, @@ -1330,6 +1520,7 @@ class DebugApp { saliency: this.saliencyPanel, timeline: this.timelinePanel, tools: this.toolsPanel, + repl: this.replPanel, } this.paused = false } diff --git a/services/minecraft/src/debug/web/index.html b/services/minecraft/src/debug/web/index.html index d3c6b6b4f..0d3aeb045 100644 --- a/services/minecraft/src/debug/web/index.html +++ b/services/minecraft/src/debug/web/index.html @@ -48,6 +48,7 @@
+
@@ -246,7 +247,38 @@
+ +
+
+
+
+

Registered Variables

+
+ + +
+
+
+
+
+
+
+
+

REPL Console

+
+ +
+
+
+ +
+
+
+
+
+ - \ No newline at end of file + diff --git a/services/minecraft/src/debug/web/styles.css b/services/minecraft/src/debug/web/styles.css index cfb149df8..fef3f3b24 100644 --- a/services/minecraft/src/debug/web/styles.css +++ b/services/minecraft/src/debug/web/styles.css @@ -225,6 +225,117 @@ button:hover, height: 100%; } +.repl-view-shell { + height: 100%; + display: grid; + grid-template-columns: minmax(300px, 36%) 1fr; + gap: 8px; +} + +.repl-vars-list { + height: 100%; + overflow: auto; + font-family: var(--font-mono); + font-size: 12px; +} + +#repl-vars-search { + min-width: 180px; +} + +.repl-var-row { + border-bottom: 1px solid var(--border-color); + padding: 8px 10px; +} + +.repl-var-row:last-child { + border-bottom: none; +} + +.repl-var-name { + color: var(--accent-info); + font-weight: 600; +} + +.repl-var-meta { + color: var(--text-muted); + font-size: 11px; +} + +.repl-var-preview { + color: var(--text-secondary); + margin-top: 3px; + white-space: pre-wrap; + word-break: break-word; +} + +.repl-console-content { + display: flex; + flex-direction: column; + gap: 8px; +} + +.repl-code-input { + width: 100%; + min-height: 180px; + resize: vertical; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; +} + +.repl-code-input:focus { + outline: none; + border-color: var(--accent-info); +} + +.repl-result-list { + flex: 1 1 auto; + overflow: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.repl-result-card { + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-primary); + padding: 8px; + font-family: var(--font-mono); + font-size: 11px; +} + +.repl-result-card.error { + border-color: var(--accent-error); +} + +.repl-result-meta { + display: flex; + justify-content: space-between; + color: var(--text-muted); + margin-bottom: 6px; +} + +.repl-result-section { + margin-top: 6px; +} + +.repl-result-label { + color: var(--text-secondary); + margin-bottom: 2px; +} + +.repl-result-content { + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; +} + .panel.full-height { height: 100%; } @@ -238,6 +349,13 @@ button:hover, box-sizing: border-box; } +@media (max-width: 960px) { + .repl-view-shell { + grid-template-columns: 1fr; + grid-template-rows: minmax(220px, 35%) 1fr; + } +} + #main-grid { flex: 1 1 auto; min-height: 0; @@ -910,4 +1028,4 @@ button:hover, word-wrap: break-word; max-height: 100px; overflow-y: auto; -} \ No newline at end of file +}