feat(minecraft): add REPL dashboard tab with live execution

This commit is contained in:
Rin
2026-02-18 11:14:38 +08:00
committed by Neko Ayaka
parent 6d7db23f6c
commit cce492f685
10 changed files with 834 additions and 145 deletions
@@ -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<string, unknown>
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<string, unknown>,
},
)
return {
variables,
updatedAt: Date.now(),
}
}
public async executeDebugRepl(code: string): Promise<DebugReplResult> {
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<string, unknown>,
},
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<void> {
@@ -133,7 +254,8 @@ export class Brain {
}
private async processQueue(bot: MineflayerWithAgents): Promise<void> {
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)}`)
}
@@ -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)
})
})
@@ -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<Omit<PlannerGlobalDescriptor, 'preview'>> = [
{ 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<string, unknown> = {
snapshot: globals.snapshot,
event: globals.event,
now: Date.now(),
self: (globals.snapshot as Record<string, unknown>)?.self,
environment: (globals.snapshot as Record<string, unknown>)?.environment,
social: (globals.snapshot as Record<string, unknown>)?.social,
threat: (globals.snapshot as Record<string, unknown>)?.threat,
attention: (globals.snapshot as Record<string, unknown>)?.attention,
autonomy: (globals.snapshot as Record<string, unknown>)?.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<string | number>, 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
}
}
@@ -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<T>(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<void> {
return new Promise(resolve => setTimeout(resolve, ms))
return new Promise(resolve => setTimeout(resolve, ms))
}
+27
View File
@@ -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)
+26 -3
View File
@@ -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 })
+61 -18
View File
@@ -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<string, unknown>
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
+196 -5
View File
@@ -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 = '<option value="">Live (current session)</option>' + files.map(f => `<option value="${f}">${f}</option>`).join('')
select.innerHTML = `<option value="">Live (current session)</option>${files.map(f => `<option value="${f}">${f}</option>`).join('')}`
if (files.includes(current))
select.value = current
}
@@ -614,8 +615,8 @@ class LLMPanel {
<div class="llm-message role-${msg.role || 'unknown'}">
<div class="llm-message-role">${msg.role || 'unknown'}</div>
<div class="llm-message-content">${escapeHtml(msg.role === 'system'
? formatSystemMessageContent(msg.content || '')
: (msg.content || ''))}</div>
? formatSystemMessageContent(msg.content || '')
: (msg.content || ''))}</div>
</div>
`).join('')
}
@@ -632,10 +633,12 @@ class LLMPanel {
</div>
</div>
<div class="llm-body">
${trace.reasoning ? `
${trace.reasoning
? `
<div class="llm-section-title">Reasoning</div>
<div class="llm-content reasoning">${escapeHtml(trace.reasoning)}</div>
` : ''}
`
: ''}
<div class="llm-section-title">Result</div>
<div class="llm-content">${escapeHtml(trace.content || '')}</div>
@@ -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 = '<div class="empty-state">No variables loaded</div>'
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 = '<div class="empty-state">No variables match filter</div>'
return
}
this.elements.varsList.innerHTML = filtered.map(v => `
<div class="repl-var-row">
<div class="repl-var-name">${escapeHtml(v.name)}</div>
<div class="repl-var-meta">${escapeHtml(v.kind)} · ${v.readonly ? 'readonly' : 'writable'}</div>
<div class="repl-var-preview">${escapeHtml(v.preview || '')}</div>
</div>
`).join('')
}
renderResults() {
if (!this.elements.resultList)
return
if (this.results.length === 0) {
this.elements.resultList.innerHTML = '<div class="empty-state">No REPL executions yet</div>'
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 `
<div class="repl-result-card ${isError ? 'error' : ''}">
<div class="repl-result-meta">
<span>${time}</span>
<span>${Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : '-'}</span>
</div>
<div class="repl-result-section">
<div class="repl-result-label">Code</div>
<div class="repl-result-content">${escapeHtml(result.code || '')}</div>
</div>
<div class="repl-result-section">
<div class="repl-result-label">Return</div>
<div class="repl-result-content">${escapeHtml(returnValue)}</div>
</div>
<div class="repl-result-section">
<div class="repl-result-label">Actions</div>
<div class="repl-result-content">${escapeHtml(actionSummary)}</div>
</div>
<div class="repl-result-section">
<div class="repl-result-label">Logs</div>
<div class="repl-result-content">${escapeHtml(logsSummary)}</div>
</div>
${isError
? `
<div class="repl-result-section">
<div class="repl-result-label">Error</div>
<div class="repl-result-content">${escapeHtml(result.error)}</div>
</div>
`
: ''}
</div>
`
}).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
}
+33 -1
View File
@@ -48,6 +48,7 @@
<div class="tab-bar">
<button class="tab-button active" data-target="main-view">Dashboard</button>
<button class="tab-button" data-target="tools-view">Tools</button>
<button class="tab-button" data-target="repl-view">REPL</button>
</div>
<!-- Main Layout -->
@@ -246,7 +247,38 @@
</div>
</section>
<!-- REPL View -->
<section id="repl-view" class="view">
<div class="repl-view-shell">
<section class="panel repl-vars-panel">
<div class="panel-header">
<h2>Registered Variables</h2>
<div class="panel-controls">
<input type="search" id="repl-vars-search" placeholder="Filter variables..." />
<button id="repl-refresh-state" class="icon-btn" title="Refresh variable list"></button>
</div>
</div>
<div class="panel-content">
<div id="repl-vars-list" class="repl-vars-list"></div>
</div>
</section>
<section class="panel repl-console-panel">
<div class="panel-header">
<h2>REPL Console</h2>
<div class="panel-controls">
<button id="repl-run-btn">Run (Ctrl/Cmd+Enter)</button>
</div>
</div>
<div class="panel-content repl-console-content">
<textarea id="repl-code-input" class="repl-code-input" spellcheck="false"
placeholder="Example: await chat(&quot;hello from repl&quot;)"></textarea>
<div id="repl-result-list" class="repl-result-list"></div>
</div>
</section>
</div>
</section>
<script src="app.js"></script>
</body>
</html>
</html>
+119 -1
View File
@@ -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;
}
}