feat(minecraft) debug server redesigned

This commit is contained in:
Rin
2026-02-18 11:11:02 +08:00
committed by Neko Ayaka
parent bc5e1f42cb
commit c46b3fa3d2
20 changed files with 2280 additions and 1174 deletions
+16 -3
View File
@@ -69,6 +69,9 @@ catalogs:
'@types/unist':
specifier: ^3.0.3
version: 3.0.3
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@vueuse/core':
specifier: 14.1.0
version: 14.1.0
@@ -201,6 +204,9 @@ catalogs:
vue-sonner:
specifier: 2.0.9
version: 2.0.9
ws:
specifier: ^8.18.3
version: 8.19.0
xsschema:
specifier: 0.4.0-beta.13
version: 0.4.0-beta.13
@@ -3048,7 +3054,7 @@ importers:
version: 14.1.0(vue@3.5.26(typescript@5.9.3))
'@wxt-dev/module-vue':
specifier: ^1.0.3
version: 1.0.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 1.0.3(vite@8.0.0-beta.9(@types/node@24.10.9)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
nanoid:
specifier: ^5.1.6
version: 5.1.6
@@ -3187,12 +3193,19 @@ importers:
vec3:
specifier: ^0.1.10
version: 0.1.10
ws:
specifier: 'catalog:'
version: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)
zod:
specifier: ^4.3.5
version: 4.3.5
zod-to-json-schema:
specifier: ^3.25.1
version: 3.25.1(zod@4.3.5)
devDependencies:
'@types/ws':
specifier: 'catalog:'
version: 8.18.1
services/satori-bot:
dependencies:
@@ -23300,9 +23313,9 @@ snapshots:
'@types/filesystem': 0.0.36
'@types/har-format': 1.2.16
'@wxt-dev/module-vue@1.0.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.9(@types/node@24.10.9)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
'@vitejs/plugin-vue': 6.0.3(vite@8.0.0-beta.9(@types/node@24.10.9)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
wxt: 0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- vite
+2
View File
@@ -46,6 +46,7 @@ catalog:
'@types/hast': ^3.0.4
'@types/splitpanes': ^2.2.6
'@types/unist': ^3.0.3
'@types/ws': ^8.18.1
'@vueuse/core': 14.1.0
'@xsai-ext/providers': ^0.4.2
'@xsai/embed': ^0.4.2
@@ -90,6 +91,7 @@ catalog:
vite-plugin-mkcert: ^1.17.9
vue: ^3.5.25
vue-sonner: 2.0.9
ws: ^8.18.3
xsschema: 0.4.0-beta.13
zod: ^4.3.5
+4
View File
@@ -36,6 +36,10 @@
"valibot": "^1.2.0",
"vec3": "^0.1.10",
"zod": "^4.3.5",
"ws": "catalog:",
"zod-to-json-schema": "^3.25.1"
},
"devDependencies": {
"@types/ws": "catalog:"
}
}
@@ -11,7 +11,7 @@ import type { BotEvent, MineflayerWithAgents } from '../types'
import { system, user } from 'neuri/openai'
import { config } from '../../composables/config'
import { DebugService } from '../../debug-server'
import { DebugService } from '../../debug'
import { Blackboard } from './blackboard'
import { buildConsciousContextView } from './context-view'
import { generateBrainSystemPrompt } from './prompts/brain-prompt'
@@ -7,7 +7,7 @@ import type { MineflayerWithAgents } from '../types'
import { assistant } from 'neuri/openai'
import { config } from '../../composables/config'
import { DebugService } from '../../debug-server'
import { DebugService } from '../../debug'
export async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: Logger): Promise<string> {
logger.log('rerouting...')
@@ -7,7 +7,7 @@ import type { LLMConfig, LLMResponse } from '../types'
import { withRetry } from '@moeru/std'
import { config } from '../../composables/config'
import { DebugService } from '../../debug-server'
import { DebugService } from '../../debug'
import { useLogger } from '../../utils/logger'
export abstract class BaseLLMHandler {
@@ -6,10 +6,10 @@ import type { PerceptionFrame } from './frame'
import type { PerceptionSignal } from './types/signals'
import type { PerceptionStage } from './types/stage'
import { DebugService } from '../../debug-server'
import { SaliencyDetector } from './saliency-detector'
import { DebugService } from '../../debug'
import { createPerceptionFrameFromRawEvent } from './frame'
import { MineflayerPerceptionCollector } from './mineflayer-perception-collector'
import { SaliencyDetector } from './saliency-detector'
export class PerceptionPipeline {
private readonly detector: SaliencyDetector
@@ -119,7 +119,7 @@ export class PerceptionPipeline {
this.saliencyEmitTimer = setInterval(() => {
if (!this.initialized)
return
DebugService.getInstance().emit('saliency', this.detector.getDebugSnapshot({ maxKeys: 30 }))
DebugService.getInstance().emit('saliency', this.detector.getDebugSnapshot())
}, 100)
this.collector = new MineflayerPerceptionCollector({
@@ -1,41 +1,76 @@
import type { Logg } from '@guiiai/logg'
import { DEFAULT_THRESHOLD, DEFAULT_WINDOW_TICKS, SALIENCY_RULES, type SaliencyRuleBook } from './saliency-rules'
import type { SaliencyRuleBook } from './saliency-rules'
import type { RawPerceptionEvent } from './types/raw-events'
import type { PerceptionSignal } from './types/signals'
type WindowCounter = {
windowTicks: number
import { EVENT_KEYS, SALIENCY_RULES, WINDOW_SIZE } from './saliency-rules'
/**
* Circular buffer counter for a single event type
*/
interface WindowCounter {
/** Current head position in circular buffer */
head: number
/** Event counts per slot */
counts: number[]
/** Trigger markers per slot (1 = fired in this slot) */
triggers: number[]
/** Running total of counts in window */
total: number
/** Slot when last event was received */
lastEventSlot: number
/** Slot when threshold was last triggered */
lastFireSlot: number | null
/** Total count when last fired */
lastFireTotal: number
}
/**
* Debug snapshot data for a single counter
*/
export interface CounterSnapshot {
key: string
total: number
window: number[]
triggers: number[]
lastFireSlot: number | null
lastFireTotal: number
}
export class SaliencyDetector {
private readonly counters = new Map<string, WindowCounter>()
/**
* Debug snapshot for the entire saliency system
*/
export interface SaliencySnapshot {
slot: number
counters: CounterSnapshot[]
}
export class SaliencyDetector {
/** Fixed set of counters - one per event key, never deleted */
private readonly counters: Map<string, WindowCounter> = new Map()
/** Current slot (advances every slotMs) */
private currentSlot = 0
/** Timer for advancing slots */
private timer: ReturnType<typeof setInterval> | null = null
/** Milliseconds per slot */
private readonly slotMs = 20
private lastStatsAt = 0
private emittedSinceStats: Record<string, number> = {}
constructor(
private readonly deps: {
logger: Logg
onAttention: (signal: PerceptionSignal) => void
rules?: SaliencyRuleBook
windowTicks?: number
threshold?: number
},
) { }
) {
// Initialize all counters at construction (fixed set)
for (const key of EVENT_KEYS) {
this.counters.set(key, this.createCounter())
}
}
public start(): void {
if (this.timer)
@@ -44,12 +79,6 @@ export class SaliencyDetector {
this.timer = setInterval(() => {
this.currentSlot += 1
this.advanceWindows()
const now = Date.now()
if (now - this.lastStatsAt >= 2000) {
this.lastStatsAt = now
this.emittedSinceStats = {}
}
}, this.slotMs)
}
@@ -60,6 +89,9 @@ export class SaliencyDetector {
this.timer = null
}
/**
* Process an incoming perception event
*/
public ingest(event: RawPerceptionEvent): void {
const rule = this.lookupRule(event)
if (!rule)
@@ -68,159 +100,135 @@ export class SaliencyDetector {
if (rule.predicate && !rule.predicate(event))
return
const tick = this.currentSlot
const windowTicks = rule.windowTicks ?? this.deps.windowTicks ?? DEFAULT_WINDOW_TICKS
const threshold = rule.threshold ?? this.deps.threshold ?? DEFAULT_THRESHOLD
const key = rule.key(event)
const counter = this.getOrCreateCounter(key, windowTicks)
const counter = this.counters.get(rule.key)
if (!counter) {
// Unknown key - should not happen with fixed key set
return
}
// Increment count in current slot
counter.counts[counter.head] = (counter.counts[counter.head] ?? 0) + 1
counter.total += 1
counter.lastEventSlot = tick
counter.lastEventSlot = this.currentSlot
if (counter.total >= threshold) {
counter.lastFireSlot = tick
// Check threshold
if (counter.total >= rule.threshold) {
counter.lastFireSlot = this.currentSlot
counter.lastFireTotal = counter.total
counter.triggers[counter.head] = 1
this.resetCounter(counter)
const signal = rule.buildSignal(event)
this.emitSignal(signal)
this.emitSignal(rule.buildSignal(event))
}
}
public getDebugSnapshot(options?: { maxKeys?: number }): {
slot: number
keys: Array<{
key: string
total: number
windowTicks: number
window: number[]
triggers: number[]
lastFireSlot: number | null
lastFireTotal: number
}>
} {
const maxKeys = options?.maxKeys ?? 30
/**
* Get debug snapshot for visualization
* Returns all counters in fixed order (always same keys, same order)
*/
public getDebugSnapshot(): SaliencySnapshot {
const counters: CounterSnapshot[] = []
const rows = Array.from(this.counters.entries()).map(([key, counter]) => {
const window = this.exportWindow(counter)
const triggers = this.exportTriggers(counter)
const triggerSum = triggers.reduce((acc, v) => acc + (v ? 1 : 0), 0)
const firedRecently = counter.lastFireSlot !== null && (this.currentSlot - counter.lastFireSlot) <= counter.windowTicks
for (const key of EVENT_KEYS) {
const counter = this.counters.get(key)
if (!counter)
continue
return {
counters.push({
key,
total: counter.total,
windowTicks: counter.windowTicks,
window,
triggers,
window: this.exportWindow(counter),
triggers: this.exportTriggers(counter),
lastFireSlot: counter.lastFireSlot,
lastFireTotal: counter.lastFireTotal,
_triggerSum: triggerSum,
_firedRecently: firedRecently,
}
})
// Ensure keys with triggers/recent fires stay visible even if total was reset.
rows.sort((a, b) => {
if (a._triggerSum !== b._triggerSum)
return b._triggerSum - a._triggerSum
const af = a._firedRecently ? 1 : 0
const bf = b._firedRecently ? 1 : 0
if (af !== bf)
return bf - af
const at = a.lastFireSlot ?? -1
const bt = b.lastFireSlot ?? -1
if (at !== bt)
return bt - at
return b.total - a.total
})
})
}
return {
slot: this.currentSlot,
keys: rows.slice(0, maxKeys).map(({ _triggerSum: _ts, _firedRecently: _fr, ...row }) => row),
counters,
}
}
/**
* Advance all windows by one slot
* Never deletes counters - they persist with zeroed values
*/
private advanceWindows(): void {
for (const [key, counter] of this.counters.entries()) {
counter.head = (counter.head + 1) % counter.windowTicks
for (const counter of this.counters.values()) {
// Move head forward in circular buffer
counter.head = (counter.head + 1) % WINDOW_SIZE
// Subtract expired slot from total
const expired = counter.counts[counter.head] ?? 0
if (expired > 0) {
counter.total = Math.max(0, counter.total - expired)
counter.counts[counter.head] = 0
}
else {
counter.counts[counter.head] = 0
}
// Clear the slot for new data
counter.counts[counter.head] = 0
counter.triggers[counter.head] = 0
if (counter.total === 0 && this.currentSlot - counter.lastEventSlot >= counter.windowTicks) {
this.counters.delete(key)
}
}
}
private getOrCreateCounter(key: string, windowTicks: number): WindowCounter {
const existing = this.counters.get(key)
if (existing && existing.windowTicks === windowTicks)
return existing
const created: WindowCounter = {
windowTicks,
/**
* Create a new counter with zeroed circular buffer
*/
private createCounter(): WindowCounter {
return {
head: 0,
counts: Array.from({ length: windowTicks }, () => 0),
triggers: Array.from({ length: windowTicks }, () => 0),
counts: new Array(WINDOW_SIZE).fill(0),
triggers: new Array(WINDOW_SIZE).fill(0),
total: 0,
lastEventSlot: this.currentSlot,
lastEventSlot: 0,
lastFireSlot: null,
lastFireTotal: 0,
}
this.counters.set(key, created)
return created
}
/**
* Reset counter counts (after threshold triggered)
*/
private resetCounter(counter: WindowCounter): void {
counter.total = 0
counter.counts.fill(0)
}
/**
* Export window data in chronological order (oldest -> newest)
*/
private exportWindow(counter: WindowCounter): number[] {
const w = counter.windowTicks
const out = new Array<number>(w)
// Oldest -> newest. The newest bucket is at `head`.
for (let i = 0; i < w; i++) {
const idx = (counter.head + 1 + i) % w
const out = new Array<number>(WINDOW_SIZE)
for (let i = 0; i < WINDOW_SIZE; i++) {
const idx = (counter.head + 1 + i) % WINDOW_SIZE
out[i] = counter.counts[idx] ?? 0
}
return out
}
/**
* Export trigger markers in chronological order (oldest -> newest)
*/
private exportTriggers(counter: WindowCounter): number[] {
const w = counter.windowTicks
const out = new Array<number>(w)
// Oldest -> newest. The newest bucket is at `head`.
for (let i = 0; i < w; i++) {
const idx = (counter.head + 1 + i) % w
const out = new Array<number>(WINDOW_SIZE)
for (let i = 0; i < WINDOW_SIZE; i++) {
const idx = (counter.head + 1 + i) % WINDOW_SIZE
out[i] = counter.triggers[idx] ?? 0
}
return out
}
/**
* Find matching rule for an event
*/
private lookupRule(event: RawPerceptionEvent) {
const rules = this.deps.rules ?? SALIENCY_RULES
return rules[event.modality]?.[event.kind]
}
/**
* Emit a perception signal via callback
*/
private emitSignal(signal: PerceptionSignal): void {
const key = `emit.${signal.type}.${signal.metadata.action || 'unknown'}`
this.emittedSinceStats[key] = (this.emittedSinceStats[key] ?? 0) + 1
this.deps.logger.withFields({
type: signal.type,
desc: signal.description,
@@ -2,148 +2,171 @@ import type { RawPerceptionEvent } from './types/raw-events'
import type { PerceptionSignal } from './types/signals'
export interface SaliencyRule<E extends RawPerceptionEvent = RawPerceptionEvent> {
/**
* How many occurrences within the window are required before emitting.
* Defaults to 5.
*/
threshold?: number
/**
* Window size in ticks. Defaults to 100 ticks (~5s).
*/
windowTicks?: number
/**
* Optional predicate to gate the rule.
*/
predicate?: (event: E) => boolean
/**
* Counter key used to bucket occurrences.
*/
key: (event: E) => string
/**
* Builds the PerceptionSignal when the rule fires.
*/
buildSignal: (event: E) => PerceptionSignal
/**
* How many occurrences within the window are required before emitting.
*/
threshold: number
/**
* Optional predicate to gate the rule.
*/
predicate?: (event: E) => boolean
/**
* Counter key - should be one of the predefined EVENT_KEYS
*/
key: string
/**
* Builds the PerceptionSignal when the rule fires.
*/
buildSignal: (event: E) => PerceptionSignal
}
export type SaliencyRuleBook = Partial<Record<
RawPerceptionEvent['modality'],
Record<string, SaliencyRule>
RawPerceptionEvent['modality'],
Record<string, SaliencyRule>
>>
export const DEFAULT_WINDOW_TICKS = 100
/**
* Fixed event keys for stable heatmap visualization.
* All counters are initialized with these keys at startup.
*/
export const EVENT_KEYS = [
'punch:player',
'teabag:player',
'move:player',
'sound:ambient',
'felt:damage',
'felt:pickup',
] as const
export type EventKey = typeof EVENT_KEYS[number]
/**
* Unified window size for all event types (100 slots = ~2 seconds at 20ms/slot)
*/
export const WINDOW_SIZE = 100
/**
* Default threshold for attention triggering
*/
export const DEFAULT_THRESHOLD = 5
export const SALIENCY_RULES: SaliencyRuleBook = {
sighted: {
arm_swing: {
key: event => `punch:${(event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'arm_swing' }>).entityId}`,
buildSignal: (event) => {
const e = event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'arm_swing' }>
return {
type: 'entity_attention',
description: `Player ${e.displayName || 'unknown'} is punching nearby`,
sourceId: e.entityId,
confidence: 1.0,
timestamp: Date.now(),
metadata: {
kind: 'player',
action: 'punch',
distance: e.distance,
hasLineOfSight: e.hasLineOfSight,
displayName: e.displayName,
},
}
},
},
sneak_toggle: {
key: event => `teabag:${(event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'sneak_toggle' }>).entityId}`,
buildSignal: (event) => {
const e = event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'sneak_toggle' }>
return {
type: 'entity_attention',
description: `Player ${e.displayName || 'unknown'} is teabagging (rapid sneaking)`,
sourceId: e.entityId,
confidence: 1.0,
timestamp: Date.now(),
metadata: {
kind: 'player',
action: 'teabag',
distance: e.distance,
hasLineOfSight: e.hasLineOfSight,
displayName: e.displayName,
},
}
},
},
entity_moved: {
predicate: event => (event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'entity_moved' }>).entityType === 'player',
key: event => `move:${(event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'entity_moved' }>).entityId}`,
buildSignal: (event) => {
const e = event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'entity_moved' }>
return {
type: 'entity_attention',
description: `Player ${e.displayName || 'unknown'} is moving nearby`,
sourceId: e.entityId,
confidence: 0.8,
timestamp: Date.now(),
metadata: {
kind: 'player',
action: 'move',
distance: e.distance,
hasLineOfSight: e.hasLineOfSight,
displayName: e.displayName,
},
}
},
},
sighted: {
arm_swing: {
threshold: 5,
key: 'punch:player',
buildSignal: (event) => {
const e = event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'arm_swing' }>
return {
type: 'entity_attention',
description: `Player ${e.displayName || 'unknown'} is punching nearby`,
sourceId: e.entityId,
confidence: 1.0,
timestamp: Date.now(),
metadata: {
kind: 'player',
action: 'punch',
distance: e.distance,
hasLineOfSight: e.hasLineOfSight,
displayName: e.displayName,
},
}
},
},
heard: {
sound: {
key: event => `sound:${(event as Extract<RawPerceptionEvent, { modality: 'heard', kind: 'sound' }>).soundId}`,
buildSignal: (event) => {
const e = event as Extract<RawPerceptionEvent, { modality: 'heard', kind: 'sound' }>
return {
type: 'environmental_anomaly',
description: `Heard sound: ${e.soundId}`,
sourceId: e.soundId,
confidence: 1.0,
timestamp: Date.now(),
metadata: {
kind: 'sound',
action: 'sound',
soundId: e.soundId,
distance: e.distance,
},
}
},
},
sneak_toggle: {
threshold: 5,
key: 'teabag:player',
buildSignal: (event) => {
const e = event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'sneak_toggle' }>
return {
type: 'entity_attention',
description: `Player ${e.displayName || 'unknown'} is teabagging (rapid sneaking)`,
sourceId: e.entityId,
confidence: 1.0,
timestamp: Date.now(),
metadata: {
kind: 'player',
action: 'teabag',
distance: e.distance,
hasLineOfSight: e.hasLineOfSight,
displayName: e.displayName,
},
}
},
},
felt: {
damage_taken: {
key: () => 'felt:damage',
buildSignal: (_event) => ({
type: 'saliency_high',
description: 'Taken damage!',
confidence: 1.0,
timestamp: Date.now(),
metadata: {
kind: 'felt',
action: 'damage',
},
}),
},
item_collected: {
key: () => 'felt:pickup',
buildSignal: (_event) => ({
type: 'entity_attention',
description: 'Picked up an item',
confidence: 1.0,
timestamp: Date.now(),
metadata: {
kind: 'felt',
action: 'pickup',
},
}),
},
entity_moved: {
threshold: 5,
predicate: event => (event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'entity_moved' }>).entityType === 'player',
key: 'move:player',
buildSignal: (event) => {
const e = event as Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'entity_moved' }>
return {
type: 'entity_attention',
description: `Player ${e.displayName || 'unknown'} is moving nearby`,
sourceId: e.entityId,
confidence: 0.8,
timestamp: Date.now(),
metadata: {
kind: 'player',
action: 'move',
distance: e.distance,
hasLineOfSight: e.hasLineOfSight,
displayName: e.displayName,
},
}
},
},
},
heard: {
sound: {
threshold: 5,
key: 'sound:ambient',
buildSignal: (event) => {
const e = event as Extract<RawPerceptionEvent, { modality: 'heard', kind: 'sound' }>
return {
type: 'environmental_anomaly',
description: `Heard sound: ${e.soundId}`,
sourceId: e.soundId,
confidence: 1.0,
timestamp: Date.now(),
metadata: {
kind: 'sound',
action: 'sound',
soundId: e.soundId,
distance: e.distance,
},
}
},
},
},
felt: {
damage_taken: {
threshold: 1, // Damage is immediately salient
key: 'felt:damage',
buildSignal: () => ({
type: 'saliency_high',
description: 'Taken damage!',
confidence: 1.0,
timestamp: Date.now(),
metadata: {
kind: 'felt',
action: 'damage',
},
}),
},
item_collected: {
threshold: 3,
key: 'felt:pickup',
buildSignal: () => ({
type: 'entity_attention',
description: 'Picked up an item',
confidence: 1.0,
timestamp: Date.now(),
metadata: {
kind: 'felt',
action: 'pickup',
},
}),
},
},
}
-211
View File
@@ -1,211 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { useLogger } from './utils/logger'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
interface DebugEvent {
type: string
payload: any
timestamp: number
}
interface LlmTrace {
route: string
messages: any
content: any
usage?: any
model?: string
duration?: number // ms
}
export class DebugService {
private static instance: DebugService
private clients: Set<ServerResponse> = new Set()
private server: http.Server | null = null
// History buffer (Ring buffer)
private history: DebugEvent[] = []
private readonly MAX_HISTORY = 1000
// File Logging
private logStream: fs.WriteStream | null = null
private constructor() {
this.initLogFile()
}
public static getInstance(): DebugService {
if (!DebugService.instance) {
DebugService.instance = new DebugService()
}
return DebugService.instance
}
private initLogFile() {
const logsDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true })
}
const filename = `session-${new Date().toISOString().replace(/:/g, '-')}.jsonl`
this.logStream = fs.createWriteStream(path.join(logsDir, filename), { flags: 'a' })
}
public start(port = 3000): void {
if (this.server)
return
this.server = http.createServer((req: IncomingMessage, res: ServerResponse) => {
// Enable CORS
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
if (req.method === 'OPTIONS') {
res.writeHead(200)
res.end()
return
}
if (req.url === '/') {
// Serve dashboard
const htmlPath = path.join(__dirname, 'web', 'dashboard.html')
try {
const html = fs.readFileSync(htmlPath, 'utf-8')
res.writeHead(200, {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache',
})
res.end(html)
}
catch {
res.writeHead(500)
res.end('Dashboard not found')
}
}
else if (req.url === '/events') {
// Serve SSE stream
this.handleSSE(req, res)
}
else {
res.writeHead(404)
res.end('Not Found')
}
})
this.server.listen(port, () => {
useLogger().log(`Debug server running at http://localhost:${port}`)
})
}
// --- Public API for Brain/Agents ---
public log(level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG', message: string, fields?: any) {
this.emit('log', { level, message, fields, timestamp: Date.now() })
}
public traceLLM(trace: LlmTrace) {
this.emit('llm', { ...trace, timestamp: Date.now() })
}
public updateBlackboard(state: any) {
this.emit('blackboard', { state, timestamp: Date.now() })
}
public updateQueue(queue: any[], processing?: any) {
this.emit('queue', { queue, processing, timestamp: Date.now() })
}
// Generic emit
public emit(type: string, payload: any): void {
const event: DebugEvent = {
type,
payload,
timestamp: Date.now(),
}
// 1. Add to Histroy
this.addToHistory(event)
// 2. Persist to Disk
this.persistEvent(event)
// 3. Broadcast to Clients
this.broadcast(event)
}
// --- Internal Logic ---
private addToHistory(event: DebugEvent) {
this.history.push(event)
if (this.history.length > this.MAX_HISTORY) {
this.history.shift()
}
}
private persistEvent(event: DebugEvent) {
if (this.logStream) {
try {
this.logStream.write(`${JSON.stringify(event)}\n`)
}
catch (err) {
console.error('Failed to write to log file', err)
}
}
}
private broadcast(event: DebugEvent): void {
// Safe stringify to handle circular refs if any (basic protection)
let data = ''
try {
data = JSON.stringify(event.payload)
}
catch {
data = JSON.stringify({ error: 'Circular Reference or Serialization Error' })
}
const message = `event: ${event.type}\ndata: ${data}\n\n`
for (const client of this.clients) {
client.write(message)
}
}
private handleSSE(req: IncomingMessage, res: ServerResponse): void {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
// Send history immediately on connection
for (const event of this.history) {
let data = ''
try {
data = JSON.stringify(event.payload)
}
catch {
continue
}
res.write(`event: ${event.type}\ndata: ${data}\n\n`)
}
this.clients.add(res)
const keepAlive = setInterval(() => {
res.write(': keep-alive\n\n')
}, 15000)
req.on('close', () => {
clearInterval(keepAlive)
this.clients.delete(res)
})
}
}
@@ -0,0 +1,175 @@
import type { BlackboardEvent, ClientCommand, LLMTraceEvent, LogEvent, QueueEvent, SaliencyEvent, ServerEvent } from './types'
import { DebugServer } from './server'
type CommandHandler = (command: ClientCommand) => void
/**
* DebugService - Public API for application code to emit debug events
*
* This is a singleton that wraps the DebugServer and provides
* a convenient API for emitting events and handling commands.
*/
export class DebugService {
private static instance: DebugService
private server: DebugServer
private constructor() {
this.server = new DebugServer()
}
public static getInstance(): DebugService {
if (!DebugService.instance) {
DebugService.instance = new DebugService()
}
return DebugService.instance
}
/**
* Start the debug server
*/
public start(port = 3000): void {
this.server.start(port)
}
/**
* Stop the debug server
*/
public stop(): void {
this.server.stop()
}
// ============================================================
// Convenience methods for common event types
// ============================================================
/**
* Emit a log event
*/
public log(level: LogEvent['level'], message: string, fields?: Record<string, unknown>): void {
const event: ServerEvent = {
type: 'log',
payload: {
level,
message,
fields,
timestamp: Date.now(),
},
}
this.server.broadcast(event)
}
/**
* Emit an LLM trace event
*/
public traceLLM(trace: Omit<LLMTraceEvent, 'timestamp'>): void {
const event: ServerEvent = {
type: 'llm',
payload: {
...trace,
timestamp: Date.now(),
},
}
this.server.broadcast(event)
}
/**
* Emit a blackboard state update
*/
public updateBlackboard(state: BlackboardEvent['state'] | { goal?: string, thought?: string, strategy?: string }): void {
const event: ServerEvent = {
type: 'blackboard',
payload: {
state,
timestamp: Date.now(),
},
}
this.server.broadcast(event)
}
/**
* Emit a queue state update
*/
public updateQueue(queue: QueueEvent['queue'], processing?: QueueEvent['processing']): void {
const event: ServerEvent = {
type: 'queue',
payload: {
queue,
processing,
timestamp: Date.now(),
},
}
this.server.broadcast(event)
}
/**
* Emit a saliency snapshot
*/
public emitSaliency(
slot: number,
counters: SaliencyEvent['counters'],
): void {
const event: ServerEvent = {
type: 'saliency',
payload: {
slot,
counters,
timestamp: Date.now(),
},
}
this.server.broadcast(event)
}
// ============================================================
// Generic emit for custom events
// ============================================================
/**
* Emit a raw event (for backwards compatibility and custom events)
*/
public emit(type: string, payload: unknown): void {
// Map to strongly-typed events where possible
switch (type) {
case 'log':
this.server.broadcast({ type: 'log', payload: payload as LogEvent })
break
case 'llm':
this.server.broadcast({ type: 'llm', payload: payload as LLMTraceEvent })
break
case 'blackboard':
this.server.broadcast({ type: 'blackboard', payload: payload as BlackboardEvent })
break
case 'queue':
this.server.broadcast({ type: 'queue', payload: payload as QueueEvent })
break
case 'saliency': {
// Transform the saliency data to match expected format
const data = payload as { slot?: number, counters?: SaliencyEvent['counters'] }
this.server.broadcast({
type: 'saliency',
payload: {
slot: data.slot || 0,
counters: data.counters || [],
timestamp: Date.now(),
},
})
break
}
default:
// For unknown types, emit as log
this.log('DEBUG', `Unknown event type: ${type}`, { payload })
}
}
// ============================================================
// Command handling
// ============================================================
/**
* Register a handler for client commands
* Returns an unsubscribe function
*/
public onCommand(type: string, handler: CommandHandler): () => void {
return this.server.onCommand(type, handler)
}
}
+13
View File
@@ -0,0 +1,13 @@
// Re-export public API
export { DebugService } from './debug-service'
export { DebugServer } from './server'
export type {
BlackboardEvent,
ClientCommand,
DebugMessage,
LLMTraceEvent,
LogEvent,
QueueEvent,
SaliencyEvent,
ServerEvent,
} from './types'
+334
View File
@@ -0,0 +1,334 @@
import type { IncomingMessage } from 'node:http'
import type { WebSocket } from 'ws'
import type { ClientCommand, DebugMessage, ServerEvent } from './types'
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { WebSocketServer } from 'ws'
import { useLogger } from '../utils/logger'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
interface ClientInfo {
ws: WebSocket
id: string
connectedAt: number
lastPing: number
}
type CommandHandler = (command: ClientCommand, client: ClientInfo) => void
export class DebugServer {
private httpServer: http.Server | null = null
private wss: WebSocketServer | null = null
private clients: Map<string, ClientInfo> = new Map()
private commandHandlers: Map<string, CommandHandler[]> = new Map()
// History buffer (Ring buffer)
private history: ServerEvent[] = []
private readonly MAX_HISTORY = 1000
// File Logging
private logStream: fs.WriteStream | null = null
// Heartbeat
private heartbeatInterval: ReturnType<typeof setInterval> | null = null
private readonly HEARTBEAT_INTERVAL = 30000
private messageIdCounter = 0
constructor() {
this.initLogFile()
}
private initLogFile(): void {
const logsDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true })
}
const filename = `session-${new Date().toISOString().replace(/:/g, '-')}.jsonl`
this.logStream = fs.createWriteStream(path.join(logsDir, filename), { flags: 'a' })
}
public start(port = 3000): void {
if (this.httpServer) {
return
}
// Create HTTP server for static files
this.httpServer = http.createServer((req, res) => {
this.handleHttpRequest(req, res)
})
// Create WebSocket server
this.wss = new WebSocketServer({ server: this.httpServer })
this.wss.on('connection', (ws, req) => {
this.handleConnection(ws, req)
})
// Start heartbeat
this.heartbeatInterval = setInterval(() => {
this.sendHeartbeat()
}, this.HEARTBEAT_INTERVAL)
this.httpServer.listen(port, () => {
useLogger().log(`Debug server running at http://localhost:${port}`)
})
}
public stop(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
this.heartbeatInterval = null
}
for (const client of this.clients.values()) {
client.ws.close()
}
this.clients.clear()
this.wss?.close()
this.wss = null
this.httpServer?.close()
this.httpServer = null
this.logStream?.end()
this.logStream = null
}
// ============================================================
// Public API for emitting events
// ============================================================
public broadcast(event: ServerEvent): void {
// Add to history
this.addToHistory(event)
// Persist to disk
this.persistEvent(event)
// Send to all connected clients
const message = this.createMessage(event)
const data = JSON.stringify(message)
for (const client of this.clients.values()) {
if (client.ws.readyState === 1) { // WebSocket.OPEN
client.ws.send(data)
}
}
}
// ============================================================
// Command handling
// ============================================================
public onCommand(type: string, handler: CommandHandler): () => void {
const handlers = this.commandHandlers.get(type) || []
handlers.push(handler)
this.commandHandlers.set(type, handlers)
// Return unsubscribe function
return () => {
const h = this.commandHandlers.get(type)
if (h) {
const idx = h.indexOf(handler)
if (idx !== -1) {
h.splice(idx, 1)
}
}
}
}
// ============================================================
// Private methods
// ============================================================
private handleHttpRequest(req: IncomingMessage, res: http.ServerResponse): void {
// Enable CORS
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
if (req.method === 'OPTIONS') {
res.writeHead(200)
res.end()
return
}
// Serve static files from web directory
let filePath = req.url === '/' ? '/index.html' : req.url || '/index.html'
// Security: prevent directory traversal
filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, '')
const fullPath = path.join(__dirname, 'web', filePath)
const extname = path.extname(fullPath).toLowerCase()
const mimeTypes: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
}
const contentType = mimeTypes[extname] || 'application/octet-stream'
fs.readFile(fullPath, (err, content) => {
if (err) {
if (err.code === 'ENOENT') {
res.writeHead(404)
res.end('Not Found')
}
else {
res.writeHead(500)
res.end('Server Error')
}
return
}
res.writeHead(200, {
'Content-Type': contentType,
'Cache-Control': 'no-cache',
})
res.end(content)
})
}
private handleConnection(ws: WebSocket, _req: IncomingMessage): void {
const clientId = this.generateClientId()
const client: ClientInfo = {
ws,
id: clientId,
connectedAt: Date.now(),
lastPing: Date.now(),
}
this.clients.set(clientId, client)
// Send history on connect
this.sendHistory(client)
ws.on('message', (data) => {
this.handleMessage(client, data.toString())
})
ws.on('close', () => {
this.clients.delete(clientId)
})
ws.on('error', (err) => {
console.error(`WebSocket error for client ${clientId}:`, err)
this.clients.delete(clientId)
})
}
private handleMessage(client: ClientInfo, data: string): void {
try {
const message = JSON.parse(data) as DebugMessage<ClientCommand>
const command = message.data
// Handle ping internally
if (command.type === 'ping') {
client.lastPing = Date.now()
const pong: ServerEvent = { type: 'pong', payload: { timestamp: Date.now() } }
client.ws.send(JSON.stringify(this.createMessage(pong)))
return
}
// Handle history request
if (command.type === 'request_history') {
this.sendHistory(client)
return
}
// Dispatch to registered handlers
const handlers = this.commandHandlers.get(command.type)
if (handlers) {
for (const handler of handlers) {
try {
handler(command, client)
}
catch (err) {
console.error(`Command handler error for ${command.type}:`, err)
}
}
}
}
catch (err) {
console.error('Failed to parse WebSocket message:', err)
}
}
private sendHistory(client: ClientInfo): void {
if (this.history.length === 0) {
return
}
const historyEvent: ServerEvent = {
type: 'history',
payload: this.history,
}
client.ws.send(JSON.stringify(this.createMessage(historyEvent)))
}
private sendHeartbeat(): void {
const now = Date.now()
for (const [clientId, client] of this.clients) {
// Check if client is still alive (responded to ping within 2x heartbeat interval)
if (now - client.lastPing > this.HEARTBEAT_INTERVAL * 2) {
client.ws.close()
this.clients.delete(clientId)
continue
}
// Send ping request
if (client.ws.readyState === 1) {
const ping: ServerEvent = { type: 'pong', payload: { timestamp: now } }
client.ws.send(JSON.stringify(this.createMessage(ping)))
}
}
}
private addToHistory(event: ServerEvent): void {
this.history.push(event)
if (this.history.length > this.MAX_HISTORY) {
this.history.shift()
}
}
private persistEvent(event: ServerEvent): void {
if (this.logStream) {
try {
this.logStream.write(`${JSON.stringify(event)}\n`)
}
catch (err) {
console.error('Failed to write to log file', err)
}
}
}
private createMessage(event: ServerEvent): DebugMessage<ServerEvent> {
return {
id: `${++this.messageIdCounter}`,
data: event,
timestamp: Date.now(),
}
}
private generateClientId(): string {
return `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
}
+119
View File
@@ -0,0 +1,119 @@
// Debug server message types for bidirectional WebSocket communication
// ============================================================
// Server -> Client events
// ============================================================
export interface LogEvent {
level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG'
message: string
fields?: Record<string, unknown>
timestamp: number
}
export interface LLMTraceEvent {
route: string
messages: unknown[]
content: string
usage?: {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
}
model?: string
duration?: number // ms
timestamp: number
}
export interface BlackboardEvent {
state: Record<string, unknown>
timestamp: number
}
export interface QueueEvent {
queue: Array<{
type: string
payload: unknown
source?: { type: string, id: string }
}>
processing?: {
type: string
payload: unknown
source?: { type: string, id: string }
}
timestamp: number
}
export interface SaliencyEvent {
slot: number
counters: Array<{
key: string
total: number
window: number[]
triggers: number[]
lastFireSlot: number | null
lastFireTotal: number
}>
timestamp: number
}
// Union type for all server events
export type ServerEvent
= | { type: 'log', payload: LogEvent }
| { type: 'llm', payload: LLMTraceEvent }
| { type: 'blackboard', payload: BlackboardEvent }
| { type: 'queue', payload: QueueEvent }
| { type: 'saliency', payload: SaliencyEvent }
| { type: 'history', payload: ServerEvent[] }
| { type: 'pong', payload: { timestamp: number } }
// ============================================================
// Client -> Server commands
// ============================================================
export interface ClearLogsCommand {
type: 'clear_logs'
}
export interface SetFilterCommand {
type: 'set_filter'
payload: {
panel: string
filter: string
}
}
export interface InjectEventCommand {
type: 'inject_event'
payload: {
eventType: string
data: unknown
}
}
export interface PingCommand {
type: 'ping'
payload: { timestamp: number }
}
export interface RequestHistoryCommand {
type: 'request_history'
}
// Union type for all client commands
export type ClientCommand
= | ClearLogsCommand
| SetFilterCommand
| InjectEventCommand
| PingCommand
| RequestHistoryCommand
// ============================================================
// Wire format
// ============================================================
export interface DebugMessage<T = ServerEvent | ClientCommand> {
id: string
data: T
timestamp: number
}
+654
View File
@@ -0,0 +1,654 @@
/**
* Debug Dashboard - Multi-View Application
* All panels visible simultaneously, focus on robustness and utility
*/
// =============================================================================
// Configuration & Constants
// =============================================================================
const CONFIG = {
MAX_LOGS: 500,
MAX_LLM_TRACES: 50,
RECONNECT_MAX_ATTEMPTS: 10,
RECONNECT_DELAY: 1000,
PING_INTERVAL: 25000,
UPDATE_THROTTLE: 100, // ms
}
// =============================================================================
// Utility Functions
// =============================================================================
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
function throttle(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
// =============================================================================
// WebSocket Client
// =============================================================================
class DebugClient {
constructor() {
this.ws = null
this.reconnectAttempts = 0
this.pingInterval = null
this.messageIdCounter = 0
this.eventHandlers = new Map()
}
on(eventType, handler) {
if (!this.eventHandlers.has(eventType)) {
this.eventHandlers.set(eventType, [])
}
this.eventHandlers.get(eventType).push(handler)
}
emit(eventType, data) {
const handlers = this.eventHandlers.get(eventType)
if (handlers) {
handlers.forEach((handler) => {
try {
handler(data)
}
catch (err) {
console.error(`Error in ${eventType} handler:`, err)
}
})
}
}
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}`
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
this.reconnectAttempts = 0
this.updateConnectionStatus(true)
this.startPing()
this.emit('connected')
}
this.ws.onclose = () => {
this.updateConnectionStatus(false)
this.stopPing()
this.scheduleReconnect()
}
this.ws.onerror = (err) => {
console.error('WebSocket error:', err)
}
this.ws.onmessage = (event) => {
this.handleMessage(event.data)
}
}
disconnect() {
if (this.ws) {
this.ws.close()
this.ws = null
}
this.stopPing()
}
reconnect() {
this.disconnect()
this.reconnectAttempts = 0
this.connect()
}
send(command) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const message = {
id: `${++this.messageIdCounter}`,
data: command,
timestamp: Date.now(),
}
this.ws.send(JSON.stringify(message))
}
}
handleMessage(data) {
try {
const message = JSON.parse(data)
const event = message.data
if (event.type === 'history') {
for (const historyEvent of event.payload) {
this.routeEvent(historyEvent)
}
return
}
if (event.type === 'pong') {
return
}
this.routeEvent(event)
}
catch (err) {
console.error('Failed to parse message:', err)
}
}
routeEvent(event) {
this.emit(event.type, event.payload)
}
updateConnectionStatus(connected) {
const dot = document.getElementById('connection-status')
const text = document.getElementById('status-text')
if (connected) {
dot.classList.add('connected')
text.textContent = 'Connected'
}
else {
dot.classList.remove('connected')
text.textContent = 'Disconnected'
}
}
scheduleReconnect() {
if (this.reconnectAttempts >= CONFIG.RECONNECT_MAX_ATTEMPTS) {
console.log('Max reconnect attempts reached')
return
}
this.reconnectAttempts++
const delay = CONFIG.RECONNECT_DELAY * Math.min(this.reconnectAttempts, 5)
const text = document.getElementById('status-text')
text.textContent = `Reconnecting (${this.reconnectAttempts})...`
setTimeout(() => this.connect(), delay)
}
startPing() {
this.pingInterval = setInterval(() => {
this.send({ type: 'ping', payload: { timestamp: Date.now() } })
}, CONFIG.PING_INTERVAL)
}
stopPing() {
if (this.pingInterval) {
clearInterval(this.pingInterval)
this.pingInterval = null
}
}
}
// =============================================================================
// Queue Panel
// =============================================================================
class QueuePanel {
constructor(client) {
this.client = client
this.data = { queue: [], processing: null }
this.elements = {
queueList: document.getElementById('queue-list'),
queueCount: document.getElementById('queue-count'),
processingContent: document.getElementById('processing-content'),
statQueue: document.getElementById('stat-queue'),
}
}
init() {
this.client.on('queue', data => this.update(data))
this.client.on('connected', () => this.reset())
this.render()
}
update(data) {
this.data = data
this.render()
}
reset() {
this.data = { queue: [], processing: null }
this.render()
}
render() {
const { queue, processing } = this.data
// Queue count
const queueSize = queue?.length || 0
this.elements.queueCount.textContent = queueSize
this.elements.statQueue.textContent = queueSize
// Queue list
if (queue && queue.length > 0) {
this.elements.queueList.innerHTML = queue.map((item, idx) => `
<div class="queue-item">
<span class="queue-index">#${idx + 1}</span>
<span class="queue-type">${escapeHtml(item.type)}</span>
<span class="queue-source">${escapeHtml(item.source?.id || 'unknown')}</span>
</div>
`).join('')
}
else {
this.elements.queueList.innerHTML = '<div class="empty-state">Queue empty</div>'
}
// Processing
if (processing) {
this.elements.processingContent.innerHTML = `
<div class="processing-active">
<strong>${escapeHtml(processing.type)}</strong>
<div style="color: var(--text-secondary); font-size: 11px; margin-top: 4px;">
Source: ${escapeHtml(processing.source?.type)}/${escapeHtml(processing.source?.id)}
</div>
</div>
`
}
else {
this.elements.processingContent.innerHTML = '<span style="color: var(--text-muted);">Idle</span>'
}
}
}
// =============================================================================
// Blackboard Panel
// =============================================================================
class BlackboardPanel {
constructor(client) {
this.client = client
this.state = {}
this.elements = {
json: document.getElementById('blackboard-json'),
copyBtn: document.getElementById('blackboard-copy-btn'),
}
}
init() {
this.client.on('blackboard', data => this.update(data))
this.client.on('connected', () => this.reset())
this.elements.copyBtn.addEventListener('click', () => this.copy())
this.render()
}
update(data) {
this.state = data.state || {}
this.render()
}
reset() {
this.state = {}
this.render()
}
render() {
this.elements.json.textContent = JSON.stringify(this.state, null, 2)
}
copy() {
navigator.clipboard.writeText(JSON.stringify(this.state, null, 2))
.then(() => {
this.elements.copyBtn.textContent = '✓'
setTimeout(() => { this.elements.copyBtn.textContent = '📋' }, 1000)
})
.catch(err => console.error('Copy failed:', err))
}
}
// =============================================================================
// Logs Panel
// =============================================================================
class LogsPanel {
constructor(client) {
this.client = client
this.logs = []
this.autoScroll = true
this.paused = false
this.filter = { level: 'all', search: '' }
this.elements = {
container: document.getElementById('logs-container'),
search: document.getElementById('log-search'),
levelFilter: document.getElementById('log-level-filter'),
autoScroll: document.getElementById('auto-scroll'),
statEvents: document.getElementById('stat-events'),
}
}
init() {
this.client.on('log', data => this.addLog(data))
this.client.on('connected', () => this.reset())
this.elements.search.addEventListener('input', (e) => {
this.filter.search = e.target.value.toLowerCase()
this.renderThrottled()
})
this.elements.levelFilter.addEventListener('change', (e) => {
this.filter.level = e.target.value
this.renderThrottled()
})
this.elements.autoScroll.addEventListener('change', (e) => {
this.autoScroll = e.target.checked
})
this.renderThrottled = throttle(() => this.render(), CONFIG.UPDATE_THROTTLE)
this.render()
}
addLog(entry) {
if (this.paused)
return
this.logs.push(entry)
if (this.logs.length > CONFIG.MAX_LOGS) {
this.logs.shift()
}
this.elements.statEvents.textContent = this.logs.length
this.renderThrottled()
}
reset() {
this.logs = []
this.elements.statEvents.textContent = '0'
this.render()
}
clear() {
this.logs = []
this.elements.statEvents.textContent = '0'
this.render()
}
setPaused(paused) {
this.paused = paused
}
render() {
const filtered = this.logs.filter((log) => {
if (this.filter.level !== 'all' && log.level !== this.filter.level) {
return false
}
if (this.filter.search && !log.message.toLowerCase().includes(this.filter.search)) {
return false
}
return true
})
this.elements.container.innerHTML = filtered.map((log) => {
const time = new Date(log.timestamp).toLocaleTimeString()
const fieldsStr = log.fields ? JSON.stringify(log.fields) : ''
return `
<div class="log-entry">
<span class="log-time">${time}</span>
<span class="log-level level-${log.level}">[${log.level}]</span>
<span class="log-message">${escapeHtml(log.message)}</span>
${fieldsStr ? `<div class="log-fields">${escapeHtml(fieldsStr)}</div>` : ''}
</div>
`
}).join('')
if (this.autoScroll) {
this.elements.container.scrollTop = this.elements.container.scrollHeight
}
}
}
// =============================================================================
// LLM Panel
// =============================================================================
class LLMPanel {
constructor(client) {
this.client = client
this.traces = []
this.elements = {
list: document.getElementById('llm-list'),
count: document.getElementById('llm-count'),
statLlm: document.getElementById('stat-llm'),
}
}
init() {
this.client.on('llm', data => this.addTrace(data))
this.client.on('connected', () => this.reset())
}
addTrace(trace) {
this.traces.unshift(trace)
if (this.traces.length > CONFIG.MAX_LLM_TRACES) {
this.traces.pop()
}
this.elements.count.textContent = this.traces.length
this.elements.statLlm.textContent = this.traces.length
this.renderTrace(trace)
}
reset() {
this.traces = []
this.elements.list.innerHTML = ''
this.elements.count.textContent = '0'
this.elements.statLlm.textContent = '0'
}
renderTrace(trace) {
const time = new Date(trace.timestamp).toLocaleTimeString()
const tokens = trace.usage?.total_tokens || '?'
const duration = Number.isFinite(trace.duration) ? `${trace.duration}ms` : 'n/a'
const card = document.createElement('div')
card.className = 'llm-card'
// Messages HTML
let messagesHtml = ''
if (trace.messages && Array.isArray(trace.messages)) {
messagesHtml = trace.messages.map(msg => `
<div class="llm-message role-${msg.role || 'unknown'}">
<div class="llm-message-role">${msg.role || 'unknown'}</div>
<div class="llm-message-content">${escapeHtml(msg.content || '')}</div>
</div>
`).join('')
}
card.innerHTML = `
<div class="llm-header">
<div class="llm-title">
<span class="llm-route">${escapeHtml(trace.route || 'unknown')}</span>
<span style="color: var(--text-muted);">${time}</span>
</div>
<div class="llm-meta">
<span>${tokens} tokens</span>
<span>${duration}</span>
</div>
</div>
<div class="llm-body">
<div class="llm-section-title">Result</div>
<div class="llm-content">${escapeHtml(trace.content || '')}</div>
<div class="llm-section-title">Messages</div>
<div class="llm-messages">
${messagesHtml || '<div class="empty-state">No messages</div>'}
</div>
<div class="llm-section-title">Usage</div>
<div class="llm-content">${JSON.stringify(trace.usage || {}, null, 2)}</div>
</div>
`
const header = card.querySelector('.llm-header')
const body = card.querySelector('.llm-body')
header.addEventListener('click', () => {
body.classList.toggle('open')
})
this.elements.list.insertBefore(card, this.elements.list.firstChild)
}
}
// =============================================================================
// Saliency Panel
// =============================================================================
class SaliencyPanel {
constructor(client) {
this.client = client
this.data = null
this.elements = {
canvas: document.getElementById('saliency-canvas'),
labels: document.getElementById('saliency-labels'),
slot: document.getElementById('saliency-slot'),
}
this.ctx = this.elements.canvas.getContext('2d')
}
init() {
this.client.on('saliency', data => this.update(data))
this.client.on('connected', () => this.reset())
}
update(data) {
this.data = data
this.render()
}
reset() {
this.data = null
this.render()
}
render() {
if (!this.data || !this.data.counters) {
this.elements.labels.innerHTML = '<div class="empty-state">No data</div>'
return
}
this.elements.slot.textContent = this.data.slot || 0
// Use all counters directly from server (fixed order)
const counters = this.data.counters
// Render labels
this.elements.labels.innerHTML = counters.map(c => `
<div class="saliency-label">
<span class="saliency-key">${escapeHtml(c.key || '')}</span>
<span class="saliency-total">(${c.total || 0})</span>
</div>
`).join('')
// Render canvas
const cols = 100
const rows = counters.length
const cellW = 6
const cellH = 16
this.elements.canvas.width = cols * cellW
this.elements.canvas.height = Math.max(1, rows) * cellH
this.ctx.fillStyle = '#0d1117'
this.ctx.fillRect(0, 0, this.elements.canvas.width, this.elements.canvas.height)
let maxCell = 0
for (const counter of counters) {
for (let i = 0; i < Math.min(cols, counter.window?.length || 0); i++) {
maxCell = Math.max(maxCell, counter.window[i] || 0)
}
}
for (let y = 0; y < rows; y++) {
const counter = counters[y]
const w = counter.window || []
const t = counter.triggers || []
for (let x = 0; x < cols; x++) {
const v = x < w.length ? (w[x] || 0) : 0
const fired = x < t.length ? (t[x] || 0) : 0
this.ctx.fillStyle = fired
? 'rgba(248, 81, 73, 0.9)'
: this.colorFor(v, maxCell)
this.ctx.fillRect(x * cellW, y * cellH, cellW - 1, cellH - 1)
}
}
}
colorFor(value, maxValue) {
if (!maxValue)
return 'rgba(0,0,0,0)'
const t = Math.min(1, value / maxValue)
const r = Math.round(88 + 80 * t)
const g = Math.round(166 + 80 * t)
const b = Math.round(255 * t)
return `rgba(${r},${g},${b},${0.2 + 0.8 * t})`
}
}
// =============================================================================
// Application
// =============================================================================
class DebugApp {
constructor() {
this.client = new DebugClient()
this.panels = {
queue: new QueuePanel(this.client),
blackboard: new BlackboardPanel(this.client),
logs: new LogsPanel(this.client),
llm: new LLMPanel(this.client),
saliency: new SaliencyPanel(this.client),
}
this.paused = false
}
init() {
// Initialize all panels
Object.values(this.panels).forEach(panel => panel.init())
// Setup controls
document.getElementById('clear-logs-btn').addEventListener('click', () => {
this.panels.logs.clear()
})
document.getElementById('pause-btn').addEventListener('click', (e) => {
this.paused = !this.paused
this.panels.logs.setPaused(this.paused)
e.target.textContent = this.paused ? 'Resume' : 'Pause'
})
document.getElementById('reconnect-btn').addEventListener('click', () => {
this.client.reconnect()
})
// Connect
this.client.connect()
}
}
// =============================================================================
// Bootstrap
// =============================================================================
document.addEventListener('DOMContentLoaded', () => {
const app = new DebugApp()
app.init()
window.debugApp = app // For debugging
})
+129
View File
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debug Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Header Bar -->
<header id="header">
<div class="header-left">
<h1>Debug Dashboard</h1>
<div class="status-indicator">
<span id="connection-status" class="status-dot"></span>
<span id="status-text">Disconnected</span>
</div>
</div>
<div class="header-stats">
<div class="stat">
<span class="stat-label">Events</span>
<span id="stat-events" class="stat-value">0</span>
</div>
<div class="stat">
<span class="stat-label">Queue</span>
<span id="stat-queue" class="stat-value">0</span>
</div>
<div class="stat">
<span class="stat-label">LLM Calls</span>
<span id="stat-llm" class="stat-value">0</span>
</div>
</div>
<div class="header-controls">
<button id="clear-logs-btn" title="Clear logs">Clear Logs</button>
<button id="pause-btn" title="Pause updates">Pause</button>
<button id="reconnect-btn" title="Reconnect WebSocket">Reconnect</button>
</div>
</header>
<!-- Main Grid Layout -->
<main id="main-grid">
<!-- Left Column -->
<div id="left-column">
<!-- Queue & Processing -->
<section id="queue-section" class="panel">
<div class="panel-header">
<h2>Queue & Processing</h2>
<span class="panel-badge" id="queue-count">0</span>
</div>
<div class="panel-content">
<div id="queue-list" class="queue-list"></div>
<div class="divider"></div>
<div id="processing-info" class="processing-info">
<div class="processing-label">Processing:</div>
<div id="processing-content">Idle</div>
</div>
</div>
</section>
<!-- Blackboard -->
<section id="blackboard-section" class="panel">
<div class="panel-header">
<h2>Blackboard</h2>
<button id="blackboard-copy-btn" class="icon-btn" title="Copy JSON">📋</button>
</div>
<div class="panel-content">
<pre id="blackboard-json" class="json-display">{}</pre>
</div>
</section>
</div>
<!-- Right Column: Logs -->
<section id="logs-section" class="panel">
<div class="panel-header">
<h2>Logs</h2>
<div class="panel-controls">
<input type="search" id="log-search" placeholder="Search logs..." />
<select id="log-level-filter">
<option value="all">All Levels</option>
<option value="ERROR">ERROR</option>
<option value="WARN">WARN</option>
<option value="INFO">INFO</option>
<option value="DEBUG">DEBUG</option>
</select>
<label class="checkbox-label">
<input type="checkbox" id="auto-scroll" checked />
Auto-scroll
</label>
</div>
</div>
<div class="panel-content">
<div id="logs-container" class="logs-container"></div>
</div>
</section>
<!-- Bottom Row: LLM Traces -->
<section id="llm-section" class="panel panel-full-width">
<div class="panel-header">
<h2>LLM Traces</h2>
<span class="panel-badge" id="llm-count">0</span>
</div>
<div class="panel-content">
<div id="llm-list" class="llm-list"></div>
</div>
</section>
<!-- Bottom Row: Saliency -->
<section id="saliency-section" class="panel panel-full-width">
<div class="panel-header">
<h2>Saliency Heatmap</h2>
<div class="panel-controls">
<span>Slot: <span id="saliency-slot" class="panel-badge">0</span></span>
</div>
</div>
<div class="panel-content">
<div class="saliency-container">
<canvas id="saliency-canvas"></canvas>
<div id="saliency-labels" class="saliency-labels"></div>
</div>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+554
View File
@@ -0,0 +1,554 @@
/* ==========================================================================
Debug Dashboard - Utility-Focused Styles
========================================================================== */
/* CSS Variables */
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--border-color: #30363d;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent-info: #58a6ff;
--accent-success: #3fb950;
--accent-warning: #d29922;
--accent-error: #f85149;
--font-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
overflow: hidden;
}
body {
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
background-color: var(--bg-primary);
}
/* Header */
#header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
height: 48px;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header-left h1 {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 12px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--accent-error);
}
.status-dot.connected {
background-color: var(--accent-success);
}
.header-stats {
display: flex;
gap: 1.5rem;
margin-left: auto;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
}
.stat-value {
font-size: 16px;
font-weight: 600;
font-family: var(--font-mono);
color: var(--accent-info);
}
.header-controls {
display: flex;
gap: 0.5rem;
}
/* Buttons */
button,
.icon-btn {
padding: 0.25rem 0.75rem;
font-size: 12px;
font-family: inherit;
background-color: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 3px;
cursor: pointer;
transition: background-color 0.1s;
}
button:hover,
.icon-btn:hover {
background-color: var(--border-color);
}
.icon-btn {
padding: 0.25rem 0.5rem;
}
/* Main Grid Layout */
#main-grid {
display: grid;
grid-template-columns: 350px 1fr;
grid-template-rows: minmax(300px, 1fr) minmax(200px, 0.6fr) minmax(200px, 0.4fr);
gap: 8px;
padding: 8px;
height: calc(100vh - 48px);
overflow: hidden;
}
#left-column {
display: flex;
flex-direction: column;
gap: 8px;
grid-row: 1 / 2;
}
#queue-section {
flex: 0 0 auto;
max-height: 40%;
min-height: 180px;
}
#blackboard-section {
flex: 1;
min-height: 0;
}
#logs-section {
grid-row: 1 / 2;
grid-column: 2 / 3;
}
#llm-section {
grid-row: 2 / 3;
grid-column: 1 / 3;
}
#saliency-section {
grid-row: 3 / 4;
grid-column: 1 / 3;
}
/* Panel Styles */
.panel {
display: flex;
flex-direction: column;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background-color: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.panel-header h2 {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.panel-badge {
padding: 2px 6px;
font-size: 11px;
font-family: var(--font-mono);
background-color: var(--bg-primary);
color: var(--accent-info);
border-radius: 3px;
}
.panel-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.panel-controls input[type="search"],
.panel-controls input[type="number"],
.panel-controls select {
padding: 0.25rem 0.5rem;
font-size: 12px;
font-family: inherit;
background-color: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 3px;
}
.panel-controls input[type="search"] {
width: 200px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
}
.panel-content {
flex: 1;
overflow: auto;
padding: 0.5rem;
min-height: 0;
}
/* Queue & Processing */
.queue-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 0.5rem;
}
.queue-item {
padding: 0.25rem 0.5rem;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 3px;
font-size: 12px;
font-family: var(--font-mono);
}
.queue-item .queue-index {
color: var(--text-muted);
margin-right: 0.5rem;
}
.queue-item .queue-type {
color: var(--accent-info);
}
.divider {
height: 1px;
background-color: var(--border-color);
margin: 0.5rem 0;
}
.processing-info {
padding: 0.5rem;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 3px;
}
.processing-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 0.25rem;
}
#processing-content {
font-size: 12px;
font-family: var(--font-mono);
white-space: pre-wrap;
}
.processing-active {
color: var(--accent-success);
}
/* Blackboard */
.json-display {
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.4;
color: var(--text-primary);
white-space: pre-wrap;
word-wrap: break-word;
}
/* Logs */
.logs-container {
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.4;
}
.log-entry {
padding: 2px 0;
border-bottom: 1px solid var(--bg-tertiary);
}
.log-time {
color: var(--text-muted);
margin-right: 0.5rem;
}
.log-level {
margin-right: 0.5rem;
font-weight: 600;
}
.level-ERROR {
color: var(--accent-error);
}
.level-WARN {
color: var(--accent-warning);
}
.level-INFO {
color: var(--accent-info);
}
.level-DEBUG {
color: var(--text-muted);
}
.level-SYSTEM {
color: var(--accent-success);
}
.log-message {
color: var(--text-primary);
}
.log-fields {
color: var(--text-secondary);
font-size: 11px;
margin-top: 2px;
}
/* LLM Traces */
.llm-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.llm-card {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 3px;
overflow: hidden;
}
.llm-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background-color: var(--bg-tertiary);
cursor: pointer;
font-size: 12px;
}
.llm-header:hover {
background-color: var(--border-color);
}
.llm-title {
display: flex;
gap: 0.5rem;
align-items: center;
}
.llm-route {
color: var(--accent-info);
font-family: var(--font-mono);
}
.llm-meta {
display: flex;
gap: 0.75rem;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.llm-body {
display: none;
padding: 0.75rem;
border-top: 1px solid var(--border-color);
}
.llm-body.open {
display: block;
}
.llm-section-title {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
}
.llm-section-title:first-child {
margin-top: 0;
}
.llm-content {
padding: 0.5rem;
background-color: var(--bg-secondary);
border-radius: 3px;
font-family: var(--font-mono);
font-size: 12px;
white-space: pre-wrap;
word-wrap: break-word;
}
.llm-messages {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.llm-message {
padding: 0.5rem;
background-color: var(--bg-secondary);
border-left: 3px solid transparent;
border-radius: 3px;
}
.llm-message.role-system {
border-left-color: #a371f7;
}
.llm-message.role-user {
border-left-color: var(--accent-success);
}
.llm-message.role-assistant {
border-left-color: var(--accent-info);
}
.llm-message-role {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.llm-message-content {
font-family: var(--font-mono);
font-size: 12px;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Saliency */
.saliency-container {
display: flex;
gap: 1rem;
}
#saliency-canvas {
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
}
.saliency-labels {
flex: 0 0 300px;
font-family: var(--font-mono);
font-size: 11px;
line-height: 16px;
overflow-y: auto;
}
.saliency-label {
height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--bg-tertiary);
color: var(--text-secondary);
}
.saliency-key {
color: var(--text-primary);
}
.saliency-total {
color: var(--text-muted);
}
/* Utilities */
.empty-state {
padding: 1rem;
text-align: center;
color: var(--text-muted);
font-size: 12px;
}
/* Scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
+1 -1
View File
@@ -13,7 +13,7 @@ import { CognitiveEngine } from './cognitive'
import { initBot } from './composables/bot'
import { config, initEnv } from './composables/config'
import { createNeuriAgent } from './composables/neuri'
import { DebugService } from './debug-server'
import { DebugService } from './debug'
import { wrapPlugin } from './libs/mineflayer'
import { initLogger, useLogger } from './utils/logger'
+1 -1
View File
@@ -1,6 +1,6 @@
import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg'
import { DebugService } from '../debug-server'
import { DebugService } from '../debug'
export type Logger = ReturnType<typeof useLogg>
-711
View File
@@ -1,711 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Minecraft Service Debugger</title>
<style>
:root {
--bg-color: #1a1a1a;
--text-color: #e0e0e0;
--sidebar-width: 250px;
--accent-color: #4a9eff;
--border-color: #333;
--log-bg: #111;
--log-info: #4a9eff;
--log-warn: #ffb74d;
--log-error: #ef5350;
--log-debug: #888;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background-color: var(--bg-color);
color: var(--text-color);
height: 100vh;
display: flex;
flex-direction: column;
}
header {
background-color: #252525;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #f44336;
display: inline-block;
margin-right: 8px;
}
.status-dot.connected {
background-color: #4caf50;
}
main {
display: flex;
flex: 1;
overflow: hidden;
}
nav {
width: var(--sidebar-width);
background-color: #252525;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.heatmap-wrap {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.heatmap-legend {
color: #888;
font-size: 0.85em;
margin-top: 0.5rem;
}
.heatmap-labels {
width: 260px;
max-width: 260px;
overflow: hidden;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 16px;
color: #aaa;
}
.heatmap-label {
height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid #222;
}
.nav-item {
padding: 1rem;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s;
}
.nav-item:hover {
background-color: #333;
}
.nav-item.active {
background-color: #333;
border-left: 3px solid var(--accent-color);
}
.content-panel {
flex: 1;
display: none;
flex-direction: column;
overflow: hidden;
}
.content-panel.active {
display: flex;
}
.toolbar {
padding: 0.5rem;
border-bottom: 1px solid var(--border-color);
display: flex;
gap: 1rem;
}
.log-container {
flex: 1;
overflow-y: auto;
padding: 1rem;
background-color: var(--log-bg);
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
}
.log-entry {
margin-bottom: 4px;
line-height: 1.4;
white-space: pre-wrap;
border-bottom: 1px solid #222;
}
.log-time {
color: #666;
margin-right: 8px;
}
.level-info {
color: var(--log-info);
}
.level-warn {
color: var(--log-warn);
}
.level-error {
color: var(--log-error);
}
.level-debug {
color: var(--log-debug);
}
.llm-card {
background-color: #252525;
margin-bottom: 1rem;
border-radius: 4px;
overflow: hidden;
}
.llm-header {
padding: 0.8rem;
background-color: #333;
cursor: pointer;
display: flex;
justify-content: space-between;
}
.llm-body {
padding: 1rem;
display: none;
border-top: 1px solid var(--border-color);
}
.llm-body.open {
display: block;
}
.json-block {
background-color: #111;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
}
.badge {
padding: 2px 6px;
border-radius: 3px;
font-size: 0.8em;
background-color: #555;
}
.section-title {
color: #888;
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 1px;
margin: 1rem 0 0.5rem 0;
font-weight: 600;
}
.content-block {
background-color: #1e1e1e;
padding: 1rem;
border-radius: 4px;
white-space: pre-wrap;
margin: 0;
border-left: 3px solid var(--accent-color);
}
.messages-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.message {
padding: 0.8rem;
border-radius: 4px;
background-color: #1e1e1e;
border-left: 3px solid transparent;
}
.message-role {
font-size: 0.75em;
text-transform: uppercase;
margin-bottom: 0.5rem;
opacity: 0.7;
font-weight: bold;
}
.message-content {
white-space: pre-wrap;
line-height: 1.4;
}
.role-system {
border-left-color: #e91e63;
background-color: #2a1a20;
}
.role-user {
border-left-color: #4caf50;
background-color: #1a2a1a;
}
.role-assistant {
border-left-color: #2196f3;
background-color: #1a202a;
}
</style>
</head>
<body>
<header>
<div>Minecraft Service Debugger</div>
<div>
<span id="connection-status" class="status-dot"></span>
<span id="status-text">Disconnected</span>
<button onclick="reconnect()"
style="margin-left: 1rem; padding: 0.25rem 0.75rem; cursor: pointer;">Reconnect</button>
</div>
</header>
<main>
<nav>
<div class="nav-item active" onclick="switchTab('overview')">Overview</div>
<div class="nav-item" onclick="switchTab('logs')">Logs</div>
<div class="nav-item" onclick="switchTab('llm')">LLM Traces</div>
<div class="nav-item" onclick="switchTab('blackboard')">Blackboard</div>
<div class="nav-item" onclick="switchTab('saliency')">Saliency</div>
</nav>
<div id="overview-panel" class="content-panel active">
<div class="toolbar">
<h3 style="margin: 0;">System Overview</h3>
</div>
<div class="log-container" style="padding: 2rem;">
<!-- Event Queue -->
<div id="queue-section" style="margin-bottom: 2rem;">
<div class="section-title">Event Queue (<span id="queue-size">0</span>)</div>
<div id="queue-list"
style="display: flex; flex-direction: column; gap: 0.5rem; min-height: 50px; background: #111; padding: 1rem; border-radius: 4px;">
<div style="color: #666;">Queue is empty</div>
</div>
</div>
<!-- Current Processing -->
<div id="processing-section">
<div class="section-title">Currently Processing</div>
<div id="current-event" class="llm-card" style="background-color: #1e1e1e;">
<div style="padding: 1rem; color: #666;">Idle</div>
</div>
</div>
</div>
</div>
<div id="blackboard-panel" class="content-panel">
<div class="toolbar">
<h3 style="margin: 0;">Blackboard State</h3>
</div>
<div class="log-container">
<pre id="blackboard-content" class="json-block" style="height: 100%; border: none;">{}</pre>
</div>
</div>
<div id="logs-panel" class="content-panel">
<div class="toolbar">
<button onclick="clearLogs()">Clear</button>
<label><input type="checkbox" id="auto-scroll" checked> Auto-scroll</label>
</div>
<div id="logs-list" class="log-container"></div>
</div>
<div id="llm-panel" class="content-panel">
<div class="toolbar">
<button onclick="clearLLM()">Clear</button>
</div>
<div id="llm-list" class="log-container"></div>
</div>
<div id="saliency-panel" class="content-panel">
<div class="toolbar" style="align-items: center;">
<h3 style="margin: 0;">Saliency Window</h3>
<div style="margin-left: auto; display: flex; gap: 0.75rem; align-items: center; color:#aaa; font-size: 0.9em;">
<span>Top</span>
<input id="saliency-topn" type="number" min="1" max="100" value="30" style="width: 64px;" />
<span>Slot</span>
<span id="saliency-slot" class="badge">0</span>
</div>
</div>
<div class="log-container" style="padding: 1rem;">
<div class="heatmap-wrap">
<div>
<canvas id="saliency-canvas" width="800" height="480"
style="border: 1px solid #222; background:#0f0f0f;"></canvas>
<div class="heatmap-legend">Columns: oldest → newest (100 slots). Rows: counter keys (top-N by total).</div>
</div>
<div>
<div class="section-title" style="margin-top:0;">Keys</div>
<div id="saliency-labels" class="heatmap-labels"></div>
</div>
</div>
</div>
</div>
</main>
<script>
var autoScroll = true;
var sseClient = null;
var latestSaliency = null;
var saliencyKeyOrder = [];
var saliencyKeyMap = new Map();
const statusDot = document.getElementById('connection-status');
const statusText = document.getElementById('status-text');
document.getElementById('auto-scroll').addEventListener('change', (e) => {
autoScroll = e.target.checked;
});
function switchTab(tabId) {
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.content-panel').forEach(el => el.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tabId + '-panel').classList.add('active');
}
function clearLogs() {
document.getElementById('logs-list').innerHTML = '';
}
function clearLLM() {
document.getElementById('llm-list').innerHTML = '';
}
function addLog(entry) {
const container = document.getElementById('logs-list');
const div = document.createElement('div');
div.className = 'log-entry level-' + (entry.level || 'info').toLowerCase();
const time = new Date(entry.timestamp || Date.now()).toLocaleTimeString();
const fieldsJson = entry.fields ? JSON.stringify(entry.fields) : '';
div.innerHTML = `<span class="log-time">[${time}]</span> <span class="log-level">[${entry.level}]</span> ${entry.message} ${fieldsJson ? `<br><span style="color:#666;font-size:0.9em">${fieldsJson}</span>` : ''}`;
container.appendChild(div);
if (autoScroll) {
container.scrollTop = container.scrollHeight;
}
}
function addLLMTrace(trace) {
if (!trace) return;
const container = document.getElementById('llm-list');
const div = document.createElement('div');
div.className = 'llm-card';
const time = new Date(trace.timestamp || Date.now()).toLocaleTimeString();
const usage = trace.usage || {};
const tokens = usage.total_tokens || '?';
const durationMs = trace.duration;
const durationText = Number.isFinite(durationMs) ? `${durationMs} ms` : 'n/a';
// Format messages
let messagesHtml = '';
if (trace.messages && Array.isArray(trace.messages)) {
messagesHtml = trace.messages.map(msg => {
const roleClass = 'role-' + (msg.role || 'unknown');
const content = (msg.content || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `
<div class="message ${roleClass}">
<div class="message-role">${msg.role || 'unknown'}</div>
<div class="message-content">${content}</div>
</div>
`;
}).join('');
}
// Format extra details (excluding messages/content/usage which are shown separately)
const { messages, content, usage: _u, timestamp, ...other } = trace;
const otherJson = Object.keys(other).length > 0 ? JSON.stringify(other, null, 2) : '';
div.innerHTML = `
<div class="llm-header" onclick="this.nextElementSibling.classList.toggle('open')">
<div>
<span class="badge">${time}</span>
<span>LLM Completion (${trace.route || 'unknown'})</span>
</div>
<div>
<span class="badge">${tokens} tokens</span>
<span class="badge">${durationText}</span>
</div>
</div>
<div class="llm-body">
<div class="section-title">Result</div>
<pre class="content-block">${(trace.content || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>
<div class="section-title">Messages</div>
<div class="messages-list">
${messagesHtml || '<div style="color:#666">No messages</div>'}
</div>
${otherJson ? `
<div class="section-title">Metadata</div>
<pre class="json-block">${otherJson}</pre>
` : ''}
<div class="section-title">Usage</div>
<pre class="json-block">${JSON.stringify(usage, null, 2)}</pre>
</div>
`;
container.insertBefore(div, container.firstChild);
}
function updateQueue(data) {
const queueList = document.getElementById('queue-list');
const queueSize = document.getElementById('queue-size');
const currentEventEl = document.getElementById('current-event');
if (!data) return;
// Update Header
queueSize.textContent = data.queue ? data.queue.length : 0;
// Render Queue
if (data.queue && data.queue.length > 0) {
queueList.innerHTML = data.queue.map((item, idx) => {
return `
<div class="llm-card" style="margin-bottom: 0.5rem; background-color: #1e1e1e;">
<div style="padding: 0.5rem; display: flex; justify-content: space-between;">
<span><span style="color: #666; margin-right: 0.5rem;">#${idx + 1}</span> ${item.type}</span>
<span class="badge">${item.source?.id || 'unknown'}</span>
</div>
<div style="padding: 0 0.5rem 0.5rem 0.5rem; color: #aaa; font-size: 0.9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
${item.payload?.content || JSON.stringify(item.payload)}
</div>
</div>
`
}).join('');
} else {
queueList.innerHTML = '<div style="color: #666;">Queue is empty</div>';
}
// Render Current Processing
if (data.processing) {
const item = data.processing;
currentEventEl.innerHTML = `
<div style="padding: 1rem;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="color: var(--accent-color); font-weight: bold;">${item.type}</span>
<span class="badge" style="background-color: var(--accent-color);">PROCESSING</span>
</div>
<div style="background-color: #111; padding: 0.5rem; border-radius: 4px; margin-top: 0.5rem;">
<pre style="margin: 0; white-space: pre-wrap;">${JSON.stringify(item.payload, null, 2)}</pre>
</div>
<div style="margin-top: 0.5rem; font-size: 0.9em; color: #888;">
Source: ${item.source?.type} / ${item.source?.id}
</div>
</div>
`;
} else {
currentEventEl.innerHTML = '<div style="padding: 1rem; color: #666;">Idle</div>';
}
}
function updateBlackboard(data) {
const el = document.getElementById('blackboard-content');
if (data && data.state) {
el.textContent = JSON.stringify(data.state, null, 2);
}
}
function reconnect() {
if (sseClient) {
sseClient.close();
sseClient = null;
}
statusDot.classList.remove('connected');
statusText.textContent = 'Reconnecting...';
setTimeout(connectSSE, 100);
}
function connectSSE() {
if (sseClient) {
sseClient.close();
}
sseClient = new EventSource('/events');
sseClient.onopen = () => {
statusDot.classList.add('connected');
statusText.textContent = 'Connected';
addLog({ level: 'SYSTEM', message: 'Connected to debug server' });
};
sseClient.onerror = (err) => {
console.error('SSE error:', err);
statusDot.classList.remove('connected');
statusText.textContent = 'Disconnected';
};
sseClient.addEventListener('log', (e) => {
try {
addLog(JSON.parse(e.data));
} catch (e) { console.error(e); }
});
sseClient.addEventListener('llm', (e) => {
try {
addLLMTrace(JSON.parse(e.data));
} catch (e) { console.error(e); }
});
sseClient.addEventListener('queue', (e) => {
try {
updateQueue(JSON.parse(e.data));
} catch (e) { console.error(e); }
});
sseClient.addEventListener('blackboard', (e) => {
try {
updateBlackboard(JSON.parse(e.data));
} catch (e) { console.error(e); }
});
sseClient.addEventListener('saliency', (e) => {
try {
latestSaliency = JSON.parse(e.data);
ingestSaliency(latestSaliency);
renderSaliency();
} catch (e) { console.error(e); }
});
}
function ingestSaliency(snapshot) {
if (!snapshot || !snapshot.keys) return;
const slot = snapshot.slot || 0;
const TTL_SLOTS = 300;
for (const row of snapshot.keys) {
const key = row.key;
if (!key) continue;
if (!saliencyKeyMap.has(key)) {
saliencyKeyOrder.push(key);
}
saliencyKeyMap.set(key, {
key,
total: row.total || 0,
windowTicks: row.windowTicks || 100,
window: Array.isArray(row.window) ? row.window : [],
triggers: Array.isArray(row.triggers) ? row.triggers : [],
lastFireSlot: (row.lastFireSlot === null || row.lastFireSlot === undefined) ? null : row.lastFireSlot,
lastFireTotal: row.lastFireTotal || 0,
lastSeenSlot: slot,
});
}
// Cleanup stale keys to avoid unbounded growth
saliencyKeyOrder = saliencyKeyOrder.filter((key) => {
const item = saliencyKeyMap.get(key);
if (!item) return false;
if (slot - item.lastSeenSlot > TTL_SLOTS) {
saliencyKeyMap.delete(key);
return false;
}
return true;
});
}
function colorFor(value, maxValue) {
if (!maxValue || maxValue <= 0) return 'rgba(0,0,0,0)';
const t = Math.max(0, Math.min(1, value / maxValue));
// dark -> bright blue
const r = Math.round(20 + 30 * t);
const g = Math.round(40 + 80 * t);
const b = Math.round(80 + 170 * t);
const a = 0.15 + 0.85 * t;
return `rgba(${r},${g},${b},${a})`;
}
function renderSaliency() {
if (!latestSaliency) return;
const topNInput = document.getElementById('saliency-topn');
const topN = Math.max(1, Math.min(100, parseInt(topNInput.value || '30', 10)));
const tickBadge = document.getElementById('saliency-slot');
tickBadge.textContent = String(latestSaliency.slot || 0);
const order = saliencyKeyOrder.slice(Math.max(0, saliencyKeyOrder.length - topN));
const keys = order.map(k => saliencyKeyMap.get(k)).filter(Boolean);
const labels = document.getElementById('saliency-labels');
labels.innerHTML = keys.map(k => {
const fire = (k.lastFireSlot !== null && k.lastFireSlot !== undefined)
? ` <span style="color:#666">fire:${k.lastFireTotal || 0}@${k.lastFireSlot}</span>`
: '';
return `<div class="heatmap-label">${(k.key || '')} <span style="color:#666">(${k.total || 0})</span>${fire}</div>`;
}).join('') || '<div style="color:#666">No data</div>';
const canvas = document.getElementById('saliency-canvas');
const ctx = canvas.getContext('2d');
const cols = 100;
const rows = keys.length;
const cellW = 6;
const cellH = 16;
canvas.width = cols * cellW;
canvas.height = Math.max(1, rows) * cellH;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#0f0f0f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
let maxCell = 0;
for (const row of keys) {
const w = row.window || [];
for (let i = 0; i < Math.min(cols, w.length); i++) {
const v = w[i] || 0;
if (v > maxCell) maxCell = v;
}
}
for (let y = 0; y < rows; y++) {
const row = keys[y];
const w = row.window || [];
const t = row.triggers || [];
for (let x = 0; x < cols; x++) {
const v = (x < w.length) ? (w[x] || 0) : 0;
const fired = (x < t.length) ? (t[x] || 0) : 0;
ctx.fillStyle = fired ? 'rgba(255, 80, 80, 0.95)' : colorFor(v, maxCell);
ctx.fillRect(x * cellW, y * cellH, cellW - 1, cellH - 1);
}
}
}
document.getElementById('saliency-topn').addEventListener('change', () => {
renderSaliency();
});
connectSSE();
</script>
</body>
</html>