From 7158c55e9a8f842941f7067134fd77efb77eb52c Mon Sep 17 00:00:00 2001 From: Rin Date: Mon, 12 Jan 2026 01:53:00 +0800 Subject: [PATCH] feat(minecraft): yet another overhaul... i honestly don't know what to feel about this --- pnpm-lock.yaml | 9 + pnpm-workspace.yaml | 1 + services/minecraft/package.json | 2 + services/minecraft/src/cognitive/container.ts | 27 ++ services/minecraft/src/cognitive/index.ts | 19 ++ .../src/cognitive/os/event-bus.test.ts | 242 +++++++++++++++ .../minecraft/src/cognitive/os/event-bus.ts | 273 +++++++++++++++++ services/minecraft/src/cognitive/os/index.ts | 43 +++ .../src/cognitive/os/rules/accumulator.ts | 186 +++++++++++ .../src/cognitive/os/rules/engine.ts | 288 ++++++++++++++++++ .../minecraft/src/cognitive/os/rules/index.ts | 54 ++++ .../src/cognitive/os/rules/loader.ts | 111 +++++++ .../src/cognitive/os/rules/matcher.ts | 168 ++++++++++ .../src/cognitive/os/rules/rules.test.ts | 163 ++++++++++ .../minecraft/src/cognitive/os/rules/types.ts | 173 +++++++++++ services/minecraft/src/cognitive/os/tracer.ts | 98 ++++++ services/minecraft/src/cognitive/os/types.ts | 139 +++++++++ .../src/cognitive/perception/pipeline.ts | 25 +- .../cognitive/perception/saliency-detector.ts | 3 + .../cognitive/rules/attention/movement.yaml | 22 ++ .../src/cognitive/rules/attention/punch.yaml | 25 ++ .../src/cognitive/rules/attention/teabag.yaml | 22 ++ .../src/cognitive/rules/danger/damage.yaml | 17 ++ services/minecraft/src/debug/debug-service.ts | 30 +- services/minecraft/src/debug/types.ts | 33 ++ services/minecraft/src/debug/web/app.js | 226 +++++++++++++- services/minecraft/src/debug/web/index.html | 28 ++ services/minecraft/src/debug/web/styles.css | 186 +++++++++++ 28 files changed, 2607 insertions(+), 6 deletions(-) create mode 100644 services/minecraft/src/cognitive/os/event-bus.test.ts create mode 100644 services/minecraft/src/cognitive/os/event-bus.ts create mode 100644 services/minecraft/src/cognitive/os/index.ts create mode 100644 services/minecraft/src/cognitive/os/rules/accumulator.ts create mode 100644 services/minecraft/src/cognitive/os/rules/engine.ts create mode 100644 services/minecraft/src/cognitive/os/rules/index.ts create mode 100644 services/minecraft/src/cognitive/os/rules/loader.ts create mode 100644 services/minecraft/src/cognitive/os/rules/matcher.ts create mode 100644 services/minecraft/src/cognitive/os/rules/rules.test.ts create mode 100644 services/minecraft/src/cognitive/os/rules/types.ts create mode 100644 services/minecraft/src/cognitive/os/tracer.ts create mode 100644 services/minecraft/src/cognitive/os/types.ts create mode 100644 services/minecraft/src/cognitive/rules/attention/movement.yaml create mode 100644 services/minecraft/src/cognitive/rules/attention/punch.yaml create mode 100644 services/minecraft/src/cognitive/rules/attention/teabag.yaml create mode 100644 services/minecraft/src/cognitive/rules/danger/damage.yaml diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c1558282..baff86f6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ catalogs: xsschema: specifier: 0.4.0-beta.13 version: 0.4.0-beta.13 + yaml: + specifier: ^2.8.2 + version: 2.8.2 zod: specifier: ^4.3.5 version: 4.3.5 @@ -3166,6 +3169,9 @@ importers: mineflayer-tool: specifier: ^1.2.0 version: 1.2.0(encoding@0.1.13) + nanoid: + specifier: 'catalog:' + version: 5.1.6 neuri: specifier: ^0.2.1 version: 0.2.1(zod-to-json-schema@3.25.1(zod@4.3.5))(zod@4.3.5) @@ -3196,6 +3202,9 @@ importers: ws: specifier: 'catalog:' version: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + yaml: + specifier: 'catalog:' + version: 2.8.2 zod: specifier: ^4.3.5 version: 4.3.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2dd8c4de1..a574cadc9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -93,6 +93,7 @@ catalog: vue-sonner: 2.0.9 ws: ^8.18.3 xsschema: 0.4.0-beta.13 + yaml: ^2.8.2 zod: ^4.3.5 catalogs: diff --git a/services/minecraft/package.json b/services/minecraft/package.json index 1816de62a..00c599f23 100644 --- a/services/minecraft/package.json +++ b/services/minecraft/package.json @@ -26,6 +26,7 @@ "mineflayer-pathfinder": "^2.4.5", "mineflayer-pvp": "^1.3.2", "mineflayer-tool": "^1.2.0", + "nanoid": "catalog:", "neuri": "^0.2.1", "prismarine-block": "^1.22.0", "prismarine-entity": "^2.5.0", @@ -37,6 +38,7 @@ "vec3": "^0.1.10", "zod": "^4.3.5", "ws": "catalog:", + "yaml": "catalog:", "zod-to-json-schema": "^3.25.1" }, "devDependencies": { diff --git a/services/minecraft/src/cognitive/container.ts b/services/minecraft/src/cognitive/container.ts index 719c501c1..dbf87cd79 100644 --- a/services/minecraft/src/cognitive/container.ts +++ b/services/minecraft/src/cognitive/container.ts @@ -1,6 +1,8 @@ import type { Logg } from '@guiiai/logg' import type { Neuri } from 'neuri' +import type { EventBus, RuleEngine } from './os' + import { useLogg } from '@guiiai/logg' import { asClass, asFunction, createContainer, InjectionMode } from 'awilix' @@ -9,12 +11,15 @@ import { ChatAgentImpl } from '../agents/chat' import { PlanningAgentImpl } from '../agents/planning' import { TaskExecutor } from './action/task-executor' import { Brain } from './conscious/brain' +import { createEventBus, createRuleEngine } from './os' import { EventManager } from './perception/event-manager' import { PerceptionPipeline } from './perception/pipeline' import { ReflexManager } from './reflex/reflex-manager' export interface ContainerServices { logger: Logg + eventBus: EventBus + ruleEngine: RuleEngine actionAgent: ActionAgentImpl planningAgent: PlanningAgentImpl chatAgent: ChatAgentImpl @@ -43,6 +48,28 @@ export function createAgentContainer(options: { // Register neuri client neuri: asFunction(() => options.neuri).singleton(), + // Register EventBus (Cognitive OS core) + eventBus: asFunction(() => + createEventBus({ + logger: useLogg('eventBus').useGlobalConfig(), + config: { historySize: 10000 }, + }), + ).singleton(), + + // Register RuleEngine (YAML rules processing) + ruleEngine: asFunction(({ eventBus }) => { + const engine = createRuleEngine({ + eventBus, + logger: useLogg('ruleEngine').useGlobalConfig(), + config: { + rulesDir: new URL('../rules', import.meta.url).pathname, + slotMs: 20, + }, + }) + engine.init() + return engine + }).singleton(), + // Register agents actionAgent: asClass(ActionAgentImpl) .singleton() diff --git a/services/minecraft/src/cognitive/index.ts b/services/minecraft/src/cognitive/index.ts index 40ba8751f..a3e58fb8f 100644 --- a/services/minecraft/src/cognitive/index.ts +++ b/services/minecraft/src/cognitive/index.ts @@ -2,6 +2,7 @@ import type { MineflayerPlugin } from '../libs/mineflayer' import type { CognitiveEngineOptions, MineflayerWithAgents } from './types' import { config } from '../composables/config' +import { DebugService } from '../debug' import { ChatMessageHandler } from '../libs/mineflayer' import { createAgentContainer } from './container' import { createPerceptionFrameFromChat } from './perception/frame' @@ -48,6 +49,21 @@ export function CognitiveEngine(options: CognitiveEngineOptions): MineflayerPlug // Initialize perception pipeline (raw events + detectors) perceptionPipeline.init(botWithAgents) + // Resolve EventBus and subscribe to forward events to debug timeline + const eventBus = container.resolve('eventBus') + eventBus.subscribe('*', (event) => { + // Forward to debug service for timeline visualization + DebugService.getInstance().emitTrace({ + id: event.id, + traceId: event.traceId, + parentId: event.parentId, + type: event.type, + payload: event.payload, + timestamp: event.timestamp, + source: event.source, + }) + }) + // Set message handling via EventManager const chatHandler = new ChatMessageHandler(bot.username) bot.bot.on('chat', (username, message) => { @@ -79,6 +95,9 @@ export function CognitiveEngine(options: CognitiveEngineOptions): MineflayerPlug const perceptionPipeline = container.resolve('perceptionPipeline') perceptionPipeline.destroy() + const ruleEngine = container.resolve('ruleEngine') + ruleEngine.destroy() + const reflexManager = container.resolve('reflexManager') reflexManager.destroy() } diff --git a/services/minecraft/src/cognitive/os/event-bus.test.ts b/services/minecraft/src/cognitive/os/event-bus.test.ts new file mode 100644 index 000000000..0615d6e79 --- /dev/null +++ b/services/minecraft/src/cognitive/os/event-bus.test.ts @@ -0,0 +1,242 @@ +import type { TracedEvent } from './types' + +import { useLogg } from '@guiiai/logg' +import { describe, expect, it, vi } from 'vitest' + +import { createEventBus } from './index' + +describe('eventBus', () => { + const createTestBus = () => + createEventBus({ + logger: useLogg('test'), + config: { historySize: 100 }, + }) + + describe('emit', () => { + it('should create an event with auto-generated id and timestamp', () => { + const bus = createTestBus() + + const event = bus.emit({ + type: 'test:event', + payload: { foo: 'bar' }, + traceId: 'trace-1', + source: { component: 'test' }, + }) + + expect(event.id).toBeDefined() + expect(event.id.length).toBe(12) + expect(event.traceId).toBe('trace-1') + expect(event.type).toBe('test:event') + expect(event.payload).toEqual({ foo: 'bar' }) + expect(event.timestamp).toBeGreaterThan(0) + }) + + it('should generate traceId if not provided', () => { + const bus = createTestBus() + + const event = bus.emit({ + type: 'test:event', + payload: {}, + source: { component: 'test' }, + }) + + expect(event.traceId).toBeDefined() + expect(event.traceId.length).toBe(16) + }) + + it('should freeze the event (immutable)', () => { + const bus = createTestBus() + + const event = bus.emit({ + type: 'test:event', + payload: { mutable: 'data' }, + source: { component: 'test' }, + }) + + expect(Object.isFrozen(event)).toBe(true) + expect(Object.isFrozen(event.payload)).toBe(true) + expect(Object.isFrozen(event.source)).toBe(true) + }) + + it('should deep freeze nested objects in payload', () => { + const bus = createTestBus() + + const event = bus.emit({ + type: 'test:event', + payload: { + level1: { + level2: { + value: 42, + }, + }, + array: [{ item: 1 }, { item: 2 }], + }, + source: { component: 'test' }, + }) + + expect(Object.isFrozen(event.payload)).toBe(true) + expect(Object.isFrozen((event.payload as any).level1)).toBe(true) + expect(Object.isFrozen((event.payload as any).level1.level2)).toBe(true) + expect(Object.isFrozen((event.payload as any).array)).toBe(true) + expect(Object.isFrozen((event.payload as any).array[0])).toBe(true) + }) + }) + + describe('emitChild', () => { + it('should inherit traceId and set parentId', () => { + const bus = createTestBus() + + const parent = bus.emit({ + type: 'parent:event', + payload: {}, + source: { component: 'test' }, + }) + + const child = bus.emitChild(parent, { + type: 'child:event', + payload: { derived: true }, + source: { component: 'test' }, + }) + + expect(child.traceId).toBe(parent.traceId) + expect(child.parentId).toBe(parent.id) + }) + }) + + describe('subscribe', () => { + it('should call handler for matching events', () => { + const bus = createTestBus() + const handler = vi.fn() + + bus.subscribe('test:event', handler) + bus.emit({ + type: 'test:event', + payload: { data: 123 }, + source: { component: 'test' }, + }) + + expect(handler).toHaveBeenCalledTimes(1) + expect(handler.mock.calls[0][0].payload).toEqual({ data: 123 }) + }) + + it('should support wildcard patterns', () => { + const bus = createTestBus() + const handler = vi.fn() + + bus.subscribe('raw:*', handler) + + bus.emit({ + type: 'raw:sighted:punch', + payload: {}, + source: { component: 'test' }, + }) + bus.emit({ + type: 'raw:heard:sound', + payload: {}, + source: { component: 'test' }, + }) + bus.emit({ + type: 'signal:attention', + payload: {}, + source: { component: 'test' }, + }) + + expect(handler).toHaveBeenCalledTimes(2) + }) + + it('should return unsubscribe function', () => { + const bus = createTestBus() + const handler = vi.fn() + + const unsub = bus.subscribe('test:*', handler) + + bus.emit({ + type: 'test:one', + payload: {}, + source: { component: 'test' }, + }) + expect(handler).toHaveBeenCalledTimes(1) + + unsub() + + bus.emit({ + type: 'test:two', + payload: {}, + source: { component: 'test' }, + }) + expect(handler).toHaveBeenCalledTimes(1) // Still 1 + }) + }) + + describe('trace context propagation', () => { + it('should propagate trace context in handlers', () => { + const bus = createTestBus() + let childEvent: TracedEvent | undefined + + bus.subscribe('parent:event', () => { + // Emit within handler - should inherit context + childEvent = bus.emit({ + type: 'child:event', + payload: {}, + source: { component: 'handler' }, + }) + }) + + const parent = bus.emit({ + type: 'parent:event', + payload: {}, + source: { component: 'test' }, + }) + + expect(childEvent).toBeDefined() + expect(childEvent!.traceId).toBe(parent.traceId) + expect(childEvent!.parentId).toBe(parent.id) + }) + }) + + describe('history', () => { + it('should store events in history', () => { + const bus = createTestBus() + + bus.emit({ type: 'e1', payload: {}, source: { component: 'test' } }) + bus.emit({ type: 'e2', payload: {}, source: { component: 'test' } }) + bus.emit({ type: 'e3', payload: {}, source: { component: 'test' } }) + + const history = bus.getHistory() + expect(history.length).toBe(3) + expect(history[0].type).toBe('e1') + expect(history[2].type).toBe('e3') + }) + + it('should respect historySize limit (ring buffer)', () => { + const bus = createEventBus({ + logger: useLogg('test'), + config: { historySize: 3 }, + }) + + bus.emit({ type: 'e1', payload: {}, source: { component: 'test' } }) + bus.emit({ type: 'e2', payload: {}, source: { component: 'test' } }) + bus.emit({ type: 'e3', payload: {}, source: { component: 'test' } }) + bus.emit({ type: 'e4', payload: {}, source: { component: 'test' } }) + + const history = bus.getHistory() + expect(history.length).toBe(3) + // Oldest event (e1) should be evicted + expect(history.map(e => e.type)).toEqual(['e2', 'e3', 'e4']) + }) + }) + + describe('getEventsByTrace', () => { + it('should filter events by traceId', () => { + const bus = createTestBus() + + const e1 = bus.emit({ type: 'a', payload: {}, source: { component: 'test' } }) + bus.emitChild(e1, { type: 'b', payload: {}, source: { component: 'test' } }) + bus.emit({ type: 'c', payload: {}, source: { component: 'test' } }) // Different trace + + const trace = bus.getEventsByTrace(e1.traceId) + expect(trace.length).toBe(2) + expect(trace.map(e => e.type)).toEqual(['a', 'b']) + }) + }) +}) diff --git a/services/minecraft/src/cognitive/os/event-bus.ts b/services/minecraft/src/cognitive/os/event-bus.ts new file mode 100644 index 000000000..c44a05f66 --- /dev/null +++ b/services/minecraft/src/cognitive/os/event-bus.ts @@ -0,0 +1,273 @@ +import type { Logg } from '@guiiai/logg' + +import type { + EventBusConfig, + EventBusSnapshot, + EventHandler, + EventInput, + EventPattern, + Subscription, + TraceContext, + TracedEvent, + Unsubscribe, +} from './types' + +import { + deriveTraceContext, + generateEventId, + resolveTraceContext, + runWithTraceContext, +} from './tracer' +import { freezeEvent } from './types' + +/** + * Default EventBus configuration + */ +const DEFAULT_CONFIG: EventBusConfig = Object.freeze({ + historySize: 10000, +}) + +/** + * Check if an event type matches a pattern + * Supports wildcards: 'raw:*' matches 'raw:sighted:punch' + */ +function matchesPattern(pattern: EventPattern, eventType: string): boolean { + if (pattern === '*') + return true + + if (pattern.endsWith(':*')) { + const prefix = pattern.slice(0, -1) // Remove the '*' + return eventType.startsWith(prefix) + } + + return pattern === eventType +} + +/** + * EventBus - The heart of the Cognitive OS + * + * This is the ONLY component with mutable internal state. + * All other components should be pure functions that interact + * through the EventBus. + * + * Design principles: + * - Events are immutable once created + * - Trace context automatically propagates through handlers + * - Ring buffer prevents memory leaks + * - Pattern-based subscriptions for flexible routing + */ +export class EventBus { + // Internal mutable state - isolated from the outside world + private readonly buffer: (TracedEvent | null)[] = [] + private readonly subscriptions = new Map() + private nextSubId = 0 + private writeIndex = 0 // Next position to write + private count = 0 // Number of events stored + + constructor( + private readonly deps: { + logger: Logg + config?: Partial + }, + ) { + // Pre-allocate buffer + const size = this.config.historySize + this.buffer = new Array(size).fill(null) + } + + private get config(): EventBusConfig { + return { ...DEFAULT_CONFIG, ...this.deps.config } + } + + /** + * Emit an event to the bus + * + * This is the ONLY side effect entry point. + * Returns the created event (immutable). + */ + public emit(input: EventInput): TracedEvent { + // Resolve trace context (from explicit, async context, or new) + const trace = resolveTraceContext({ + traceId: input.traceId, + parentId: input.parentId, + }) + + // Create the full event + const event = freezeEvent({ + id: generateEventId(), + traceId: trace.traceId, + parentId: trace.parentId, + type: input.type, + payload: input.payload, + timestamp: Date.now(), + source: input.source, + }) + + // Store in ring buffer + this.storeEvent(event) + + // Dispatch to subscribers + this.dispatch(event) + + return event + } + + /** + * Emit an event as a child of another event + * Automatically sets up trace context + */ + public emitChild( + parent: TracedEvent, + input: Omit, 'traceId' | 'parentId'>, + ): TracedEvent { + return this.emit({ + ...input, + traceId: parent.traceId, + parentId: parent.id, + }) + } + + /** + * Subscribe to events matching a pattern + * Returns an unsubscribe function + */ + public subscribe( + pattern: EventPattern, + handler: EventHandler, + ): Unsubscribe { + const id = this.nextSubId++ + this.subscriptions.set(id, { + pattern, + handler: handler as EventHandler, + }) + + return () => { + this.subscriptions.delete(id) + } + } + + /** + * Get event history as an immutable array + * Events are returned in chronological order (oldest first) + */ + public getHistory(): readonly TracedEvent[] { + const size = this.config.historySize + + if (this.count === 0) { + return [] + } + + const result: TracedEvent[] = [] + + // Calculate start position (oldest event) + // If buffer is full, oldest is at writeIndex + // If not full, oldest is at 0 + const startIdx = this.count < size ? 0 : this.writeIndex + + for (let i = 0; i < this.count; i++) { + const idx = (startIdx + i) % size + const event = this.buffer[idx] + if (event) { + result.push(event) + } + } + + return Object.freeze(result) + } + + /** + * Get debug snapshot + */ + public getSnapshot(): EventBusSnapshot { + return Object.freeze({ + events: this.getHistory(), + subscriptionCount: this.subscriptions.size, + }) + } + + /** + * Replay a sequence of events + * Used for debugging and testing + */ + public replay(events: readonly TracedEvent[]): void { + this.deps.logger.withFields({ count: events.length }).log('EventBus: replaying events') + + for (const event of events) { + // Store without re-generating IDs + this.storeEvent(event) + // Dispatch to current subscribers + this.dispatch(event) + } + } + + /** + * Clear all events (for testing) + */ + public clear(): void { + this.buffer.fill(null) + this.writeIndex = 0 + this.count = 0 + } + + /** + * Get events by trace ID + */ + public getEventsByTrace(traceId: string): readonly TracedEvent[] { + return Object.freeze( + this.getHistory().filter(e => e.traceId === traceId), + ) + } + + // ============================================================ + // Private methods + // ============================================================ + + private storeEvent(event: TracedEvent): void { + const size = this.config.historySize + + // Write to current position + this.buffer[this.writeIndex] = event + + // Advance write position + this.writeIndex = (this.writeIndex + 1) % size + + // Update count (max is buffer size) + if (this.count < size) { + this.count++ + } + } + + private dispatch(event: TracedEvent): void { + // Create trace context for handlers + const childContext: TraceContext = deriveTraceContext(event.traceId, event.id) + + for (const sub of this.subscriptions.values()) { + if (!matchesPattern(sub.pattern, event.type)) + continue + + try { + // Run handler within trace context so child emissions inherit it + runWithTraceContext(childContext, () => { + sub.handler(event) + }) + } + catch (err) { + this.deps.logger + .withError(err as Error) + .withFields({ eventType: event.type, pattern: sub.pattern }) + .error('EventBus: handler error') + } + } + } +} + +/** + * Create an EventBus instance + * Factory function for cleaner API + */ +export function createEventBus(deps: { + logger: Logg + config?: Partial +}): EventBus { + return new EventBus(deps) +} diff --git a/services/minecraft/src/cognitive/os/index.ts b/services/minecraft/src/cognitive/os/index.ts new file mode 100644 index 000000000..8d20d834a --- /dev/null +++ b/services/minecraft/src/cognitive/os/index.ts @@ -0,0 +1,43 @@ +/** + * Cognitive OS - Event-sourced architecture for the cognitive engine + * + * Core principles: + * - All state changes go through TracedEvents + * - Events are immutable + * - Trace context propagates automatically + * - EventBus is the only mutable container + */ + +// EventBus +export { createEventBus, EventBus } from './event-bus' +// Rules module +export * from './rules' + +// Tracer utilities +export { + createTraceContext, + deriveTraceContext, + generateEventId, + generateTraceId, + getCurrentTraceContext, + resolveTraceContext, + runWithTraceContext, +} from './tracer' + +// Core types +export type { + EventBusConfig, + EventBusSnapshot, + EventHandler, + EventId, + EventInput, + EventPattern, + EventSource, + Subscription, + TraceContext, + TracedEvent, + TraceId, + Unsubscribe, +} from './types' + +export { freezeEvent } from './types' diff --git a/services/minecraft/src/cognitive/os/rules/accumulator.ts b/services/minecraft/src/cognitive/os/rules/accumulator.ts new file mode 100644 index 000000000..11a62cb86 --- /dev/null +++ b/services/minecraft/src/cognitive/os/rules/accumulator.ts @@ -0,0 +1,186 @@ +/** + * Accumulator - Pure functions for sliding window counting + * + * All functions are pure: (state, input) => newState + * No side effects, no mutation + */ + +import type { AccumulatorState } from './types' + +/** + * Default slot duration in milliseconds + */ +export const DEFAULT_SLOT_MS = 20 + +/** + * Create a new accumulator state with empty counts + * @param windowSlots Number of slots in the sliding window + * @param nowMs Current timestamp (for pure function compliance) + */ +export function createAccumulatorState(windowSlots: number, nowMs: number = Date.now()): AccumulatorState { + return Object.freeze({ + counts: Object.freeze(new Array(windowSlots).fill(0)), + head: 0, + total: 0, + lastUpdateMs: nowMs, + lastFireSlot: null, + }) +} + +/** + * Calculate how many slots have passed since last update + */ +export function calculateSlotDelta( + lastUpdateMs: number, + nowMs: number, + slotMs: number, +): number { + return Math.floor((nowMs - lastUpdateMs) / slotMs) +} + +/** + * Advance the accumulator by N slots (pure function) + * Zeros out expired slots and adjusts total + * @param state Current accumulator state + * @param slotsToAdvance Number of slots to advance + * @param slotMs Duration of each slot in milliseconds + */ +export function advanceSlots( + state: AccumulatorState, + slotsToAdvance: number, + slotMs: number = DEFAULT_SLOT_MS, +): AccumulatorState { + if (slotsToAdvance <= 0) { + return state + } + + const windowSize = state.counts.length + const newCounts = [...state.counts] + let newTotal = state.total + let newHead = state.head + + // Advance through slots, zeroing out expired data + const actualAdvance = Math.min(slotsToAdvance, windowSize) + for (let i = 0; i < actualAdvance; i++) { + newHead = (newHead + 1) % windowSize + // Subtract the expired slot from total + newTotal = Math.max(0, newTotal - (newCounts[newHead] ?? 0)) + // Clear the slot + newCounts[newHead] = 0 + } + + // If we advanced more than window size, everything is zeroed + if (slotsToAdvance >= windowSize) { + newCounts.fill(0) + newTotal = 0 + } + + return Object.freeze({ + counts: Object.freeze(newCounts), + head: newHead, + total: newTotal, + lastUpdateMs: state.lastUpdateMs + slotsToAdvance * slotMs, + lastFireSlot: state.lastFireSlot, + }) +} + +/** + * Increment the current slot count (pure function) + */ +export function incrementCount( + state: AccumulatorState, + incrementBy: number = 1, +): AccumulatorState { + const newCounts = [...state.counts] + newCounts[state.head] = (newCounts[state.head] ?? 0) + incrementBy + + return Object.freeze({ + counts: Object.freeze(newCounts), + head: state.head, + total: state.total + incrementBy, + lastUpdateMs: state.lastUpdateMs, + lastFireSlot: state.lastFireSlot, + }) +} + +/** + * Reset accumulator after firing (pure function) + */ +export function resetAfterFire( + state: AccumulatorState, + currentSlot: number, +): AccumulatorState { + const windowSize = state.counts.length + + return Object.freeze({ + counts: Object.freeze(new Array(windowSize).fill(0)), + head: state.head, + total: 0, + lastUpdateMs: state.lastUpdateMs, + lastFireSlot: currentSlot, + }) +} + +/** + * Process an event and check if threshold is reached (pure function) + * + * Returns [shouldFire, newState] + */ +export function processEvent( + state: AccumulatorState, + threshold: number, + nowMs: number, + slotMs: number = DEFAULT_SLOT_MS, +): readonly [boolean, AccumulatorState] { + // First advance time + const slotDelta = calculateSlotDelta(state.lastUpdateMs, nowMs, slotMs) + let newState = advanceSlots(state, slotDelta, slotMs) + + // Update lastUpdateMs to current time + newState = Object.freeze({ + ...newState, + lastUpdateMs: nowMs, + }) + + // Increment count + newState = incrementCount(newState) + + // Check threshold + if (newState.total >= threshold) { + const firedState = resetAfterFire(newState, newState.head) + return [true, firedState] as const + } + + return [false, newState] as const +} + +/** + * Parse window duration string to milliseconds + * Supports: '2s', '500ms', '1m', '100' + */ +export function parseWindowDuration(duration: string): number { + const match = duration.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/) + if (!match) { + throw new Error(`Invalid duration format: ${duration}`) + } + + const value = Number.parseFloat(match[1]) + const unit = match[2] || 'ms' + + switch (unit) { + case 'ms': return value + case 's': return value * 1000 + case 'm': return value * 60 * 1000 + default: return value + } +} + +/** + * Calculate number of slots for a given window duration + */ +export function calculateWindowSlots( + windowMs: number, + slotMs: number = DEFAULT_SLOT_MS, +): number { + return Math.max(1, Math.ceil(windowMs / slotMs)) +} diff --git a/services/minecraft/src/cognitive/os/rules/engine.ts b/services/minecraft/src/cognitive/os/rules/engine.ts new file mode 100644 index 000000000..ea5e0dfcc --- /dev/null +++ b/services/minecraft/src/cognitive/os/rules/engine.ts @@ -0,0 +1,288 @@ +/** + * Rule Engine - Orchestrates rule matching and signal generation + * + * This is the main entry point for the rule system. + * Uses pure functions internally, with state managed via EventBus. + */ + +import type { Logg } from '@guiiai/logg' + +import type { EventBus, TracedEvent } from '../index' +import type { + AccumulatorsState, + ParsedRule, + Rule, + TypeScriptRule, +} from './types' + +import { + calculateWindowSlots, + createAccumulatorState, + DEFAULT_SLOT_MS, + processEvent as processAccumulator, +} from './accumulator' +import { loadRulesFromDirectory } from './loader' +import { matchEventType, matchWhere, renderMetadata, renderTemplate } from './matcher' +import { isTypeScriptRule } from './types' + +/** + * Rule Engine configuration + */ +export interface RuleEngineConfig { + /** Directory containing YAML rules */ + readonly rulesDir: string + /** Slot duration in ms (default: 20) */ + readonly slotMs?: number +} + +/** + * Rule Engine - subscribes to EventBus and processes events through rules + */ +export class RuleEngine { + private readonly rules: Rule[] = [] + private accumulators: AccumulatorsState = {} + private unsubscribe: (() => void) | null = null + + constructor( + private readonly deps: { + eventBus: EventBus + logger: Logg + config: RuleEngineConfig + }, + ) { } + + /** + * Initialize the engine: load rules and subscribe to events + */ + public init(): void { + // Load YAML rules + const yamlRules = loadRulesFromDirectory(this.deps.config.rulesDir) + this.rules.push(...yamlRules) + + this.deps.logger.withFields({ + rulesDir: this.deps.config.rulesDir, + ruleCount: yamlRules.length, + rules: yamlRules.map(r => r.name), + }).log('RuleEngine: loaded rules') + + // Initialize accumulators for each rule + for (const rule of this.rules) { + if (!isTypeScriptRule(rule)) { + const windowSlots = calculateWindowSlots( + rule.accumulator.windowMs, + this.deps.config.slotMs ?? DEFAULT_SLOT_MS, + ) + this.accumulators = Object.freeze({ + ...this.accumulators, + [rule.name]: createAccumulatorState(windowSlots), + }) + } + } + + // Subscribe to all raw events + this.unsubscribe = this.deps.eventBus.subscribe('raw:*', (event) => { + this.processEvent(event) + }) + } + + /** + * Register a TypeScript rule (escape hatch for complex logic) + */ + public registerTypeScriptRule(rule: TypeScriptRule): void { + this.rules.push(rule) + + // Initialize accumulator for TS rule + const windowSlots = calculateWindowSlots(2000, this.deps.config.slotMs ?? DEFAULT_SLOT_MS) + this.accumulators = Object.freeze({ + ...this.accumulators, + [rule.name]: createAccumulatorState(windowSlots), + }) + + this.deps.logger.withFields({ ruleName: rule.name }).log('RuleEngine: registered TS rule') + } + + /** + * Destroy the engine: unsubscribe from events + */ + public destroy(): void { + if (this.unsubscribe) { + this.unsubscribe() + this.unsubscribe = null + } + this.rules.length = 0 + this.accumulators = {} + } + + /** + * Get current accumulator states (for debugging) + */ + public getAccumulatorStates(): AccumulatorsState { + return this.accumulators + } + + /** + * Get loaded rules (for debugging) + */ + public getRules(): readonly Rule[] { + return Object.freeze([...this.rules]) + } + + /** + * Process an event through all matching rules + */ + private processEvent(event: TracedEvent): void { + const nowMs = Date.now() + const slotMs = this.deps.config.slotMs ?? DEFAULT_SLOT_MS + + for (const rule of this.rules) { + try { + if (isTypeScriptRule(rule)) { + this.processTypeScriptRule(rule, event, nowMs) + } + else { + this.processYamlRule(rule, event, nowMs, slotMs) + } + } + catch (err) { + this.deps.logger + .withError(err as Error) + .withFields({ ruleName: isTypeScriptRule(rule) ? rule.name : rule.name }) + .error('RuleEngine: rule processing failed') + } + } + } + + /** + * Process event through a YAML rule + */ + private processYamlRule( + rule: ParsedRule, + event: TracedEvent, + nowMs: number, + slotMs: number, + ): void { + // Check event type match + if (!matchEventType(rule.trigger.eventType, event.type)) { + return + } + + // Check where conditions + if (!matchWhere(rule.trigger.where, event.payload)) { + return + } + + // Get or create accumulator state + let accState = this.accumulators[rule.name] + if (!accState) { + const windowSlots = calculateWindowSlots(rule.accumulator.windowMs, slotMs) + accState = createAccumulatorState(windowSlots) + } + + // Process through accumulator + const [fired, newAccState] = processAccumulator(accState, rule.accumulator.threshold, nowMs, slotMs) + + // Update state + this.accumulators = Object.freeze({ + ...this.accumulators, + [rule.name]: newAccState, + }) + + // If fired, emit signal + if (fired) { + this.emitSignal(rule, event) + } + } + + /** + * Process event through a TypeScript rule + */ + private processTypeScriptRule( + rule: TypeScriptRule, + event: TracedEvent, + _nowMs: number, + ): void { + // Check event pattern match + if (!matchEventType(rule.eventPattern, event.type)) { + return + } + + // Get accumulator state + const accState = this.accumulators[rule.name] + if (!accState) { + return + } + + // Call TypeScript handler + const result = rule.process(event.payload, accState) + + // Update accumulator state + this.accumulators = Object.freeze({ + ...this.accumulators, + [rule.name]: result.newAccumulatorState, + }) + + // If fired, emit signal event + if (result.fired && result.signal) { + this.deps.eventBus.emitChild(event, { + type: `signal:${result.signal.type}`, + payload: result.signal, + source: { component: 'ruleEngine', id: rule.name }, + }) + } + } + + /** + * Emit a signal from a YAML rule + */ + private emitSignal(rule: ParsedRule, sourceEvent: TracedEvent): void { + const payload = sourceEvent.payload as Record + + // Build context for template rendering + const context: Record = { + ...payload, + _event: sourceEvent, + _rule: rule, + } + + // Render description and metadata + const description = renderTemplate(rule.signal.description, context) + const metadata = renderMetadata(rule.signal.metadata, context) + + // Get sourceId from payload if available + const sourceId = (payload as { entityId?: string, sourceId?: string })?.entityId + ?? (payload as { entityId?: string, sourceId?: string })?.sourceId + + const signal = Object.freeze({ + type: rule.signal.type, + description, + confidence: rule.signal.confidence ?? 1.0, + metadata, + sourceId, + timestamp: Date.now(), + }) + + this.deps.logger.withFields({ + ruleName: rule.name, + signalType: signal.type, + description: signal.description, + }).log('RuleEngine: signal emitted') + + // Emit as child of source event + this.deps.eventBus.emitChild(sourceEvent, { + type: `signal:${signal.type}`, + payload: signal, + source: { component: 'ruleEngine', id: rule.name }, + }) + } +} + +/** + * Factory function to create RuleEngine + */ +export function createRuleEngine(deps: { + eventBus: EventBus + logger: Logg + config: RuleEngineConfig +}): RuleEngine { + return new RuleEngine(deps) +} diff --git a/services/minecraft/src/cognitive/os/rules/index.ts b/services/minecraft/src/cognitive/os/rules/index.ts new file mode 100644 index 000000000..f86c7a0b5 --- /dev/null +++ b/services/minecraft/src/cognitive/os/rules/index.ts @@ -0,0 +1,54 @@ +/** + * Rules module exports + */ + +// Accumulator (pure functions) +export { + advanceSlots, + calculateSlotDelta, + calculateWindowSlots, + createAccumulatorState, + DEFAULT_SLOT_MS, + incrementCount, + parseWindowDuration, + processEvent, + resetAfterFire, +} from './accumulator' +// Engine +export { createRuleEngine, RuleEngine } from './engine' + +export type { RuleEngineConfig } from './engine' + +// Loader +export { + loadRuleFile, + loadRulesFromDirectory, + parseRule, + parseRuleFromString, +} from './loader' + +// Matcher (pure functions) +export { + buildEventType, + getNestedValue, + matchCondition, + matchEventType, + matchWhere, + renderMetadata, + renderTemplate, +} from './matcher' + +// Types +export type { + AccumulatorsState, + AccumulatorState, + ParsedRule, + Rule, + RuleMatchResult, + SignalConfig, + TypeScriptRule, + WhereClause, + WhereCondition, + YamlRule, +} from './types' +export { isTypeScriptRule } from './types' diff --git a/services/minecraft/src/cognitive/os/rules/loader.ts b/services/minecraft/src/cognitive/os/rules/loader.ts new file mode 100644 index 000000000..5a1a76acb --- /dev/null +++ b/services/minecraft/src/cognitive/os/rules/loader.ts @@ -0,0 +1,111 @@ +/** + * YAML Rule Loader + * + * Loads and parses YAML rule files from a directory + */ + +import type { ParsedRule, YamlRule } from './types' + +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { parse as parseYaml } from 'yaml' + +import { parseWindowDuration } from './accumulator' +import { buildEventType } from './matcher' + +/** + * Load and parse a single YAML rule file + */ +export function loadRuleFile(filePath: string): ParsedRule { + const content = fs.readFileSync(filePath, 'utf-8') + const yaml = parseYaml(content) as YamlRule + + return parseRule(yaml, filePath) +} + +/** + * Parse a YAML rule object into internal representation + */ +export function parseRule(yaml: YamlRule, sourcePath: string): ParsedRule { + // Validate required fields + if (!yaml.name) { + throw new Error(`Rule missing 'name' in ${sourcePath}`) + } + if (!yaml.trigger) { + throw new Error(`Rule '${yaml.name}' missing 'trigger' in ${sourcePath}`) + } + if (!yaml.trigger.modality || !yaml.trigger.kind) { + throw new Error(`Rule '${yaml.name}' trigger missing 'modality' or 'kind' in ${sourcePath}`) + } + if (!yaml.accumulator) { + throw new Error(`Rule '${yaml.name}' missing 'accumulator' in ${sourcePath}`) + } + if (!yaml.signal) { + throw new Error(`Rule '${yaml.name}' missing 'signal' in ${sourcePath}`) + } + + const windowMs = parseWindowDuration(yaml.accumulator.window) + + return Object.freeze({ + name: yaml.name, + version: yaml.version ?? 1, + trigger: Object.freeze({ + eventType: buildEventType(yaml.trigger.modality, yaml.trigger.kind), + where: yaml.trigger.where ? Object.freeze(yaml.trigger.where) : undefined, + }), + accumulator: Object.freeze({ + threshold: yaml.accumulator.threshold, + windowMs, + mode: yaml.accumulator.mode ?? 'sliding', + }), + signal: Object.freeze({ + type: yaml.signal.type, + description: yaml.signal.description, + confidence: yaml.signal.confidence ?? 1.0, + metadata: yaml.signal.metadata ? Object.freeze(yaml.signal.metadata) : undefined, + }), + sourcePath, + }) +} + +/** + * Load all YAML rules from a directory (recursively) + */ +export function loadRulesFromDirectory(dirPath: string): ParsedRule[] { + const rules: ParsedRule[] = [] + + if (!fs.existsSync(dirPath)) { + return rules + } + + const entries = fs.readdirSync(dirPath, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name) + + if (entry.isDirectory()) { + // Recurse into subdirectories + rules.push(...loadRulesFromDirectory(fullPath)) + } + else if (entry.isFile() && (entry.name.endsWith('.yaml') || entry.name.endsWith('.yml'))) { + try { + rules.push(loadRuleFile(fullPath)) + } + catch (err) { + console.error(`Failed to load rule from ${fullPath}:`, err) + } + } + } + + return rules +} + +/** + * Parse a YAML rule from string content + * Useful for testing + */ +export function parseRuleFromString(content: string, sourcePath: string = ''): ParsedRule { + const yaml = parseYaml(content) as YamlRule + return parseRule(yaml, sourcePath) +} diff --git a/services/minecraft/src/cognitive/os/rules/matcher.ts b/services/minecraft/src/cognitive/os/rules/matcher.ts new file mode 100644 index 000000000..a2a6ee4c7 --- /dev/null +++ b/services/minecraft/src/cognitive/os/rules/matcher.ts @@ -0,0 +1,168 @@ +/** + * Matcher - Pure functions for condition matching + * + * All functions are pure: (condition, value) => boolean + */ + +import type { WhereClause, WhereCondition } from './types' + +/** + * Check if a value matches a single condition + */ +export function matchCondition( + condition: WhereCondition, + value: unknown, +): boolean { + // Direct value comparison (equality) + if ( + typeof condition === 'string' + || typeof condition === 'number' + || typeof condition === 'boolean' + ) { + return value === condition + } + + // Object with operator + if (typeof condition === 'object' && condition !== null) { + if ('eq' in condition) { + return value === condition.eq + } + if ('ne' in condition) { + return value !== condition.ne + } + if ('lt' in condition) { + return typeof value === 'number' && value < condition.lt! + } + if ('lte' in condition) { + return typeof value === 'number' && value <= condition.lte! + } + if ('gt' in condition) { + return typeof value === 'number' && value > condition.gt! + } + if ('gte' in condition) { + return typeof value === 'number' && value >= condition.gte! + } + if ('in' in condition) { + return (condition.in as readonly unknown[]).includes(value) + } + if ('contains' in condition) { + return typeof value === 'string' && value.includes(condition.contains!) + } + } + + return false +} + +/** + * Get a nested value from an object using dot notation + * e.g., getNestedValue({ a: { b: 1 } }, 'a.b') => 1 + */ +export function getNestedValue(obj: unknown, path: string): unknown { + const parts = path.split('.') + let current: unknown = obj + + for (const part of parts) { + if (current === null || current === undefined) { + return undefined + } + if (typeof current !== 'object') { + return undefined + } + current = (current as Record)[part] + } + + return current +} + +/** + * Check if an event payload matches a where clause + */ +export function matchWhere( + whereClause: WhereClause | undefined, + payload: unknown, +): boolean { + if (!whereClause) { + return true + } + + for (const [path, condition] of Object.entries(whereClause)) { + const value = getNestedValue(payload, path) + if (!matchCondition(condition, value)) { + return false + } + } + + return true +} + +/** + * Build event type string from modality and kind + */ +export function buildEventType(modality: string, kind: string): string { + return `raw:${modality}:${kind}` +} + +/** + * Check if an event type matches a pattern + * Supports wildcards: 'raw:*' matches 'raw:sighted:punch' + */ +export function matchEventType(pattern: string, eventType: string): boolean { + if (pattern === '*') { + return true + } + + if (pattern.endsWith(':*')) { + const prefix = pattern.slice(0, -1) + return eventType.startsWith(prefix) + } + + return pattern === eventType +} + +/** + * Render a template string with placeholders + * e.g., 'Player {{ name }} says {{ message }}' + { name: 'Bob', message: 'Hi' } + * => 'Player Bob says Hi' + */ +export function renderTemplate( + template: string, + context: Readonly>, +): string { + return template.replace(/\{\{\s*(\w+(?:\.\w+)*)\s*\}\}/g, (_, path: string) => { + const value = getNestedValue(context, path) + return value !== undefined ? String(value) : `{{${path}}}` + }) +} + +/** + * Render metadata object with template values + */ +export function renderMetadata( + metadata: Readonly> | undefined, + context: Readonly>, +): Readonly> { + if (!metadata) { + return Object.freeze({}) + } + + const result: Record = {} + + for (const [key, value] of Object.entries(metadata)) { + if (typeof value === 'string') { + // Check if it looks like a template + if (value.includes('{{')) { + result[key] = renderTemplate(value, context) + } + else { + // Check if the value references a context field directly + const contextValue = getNestedValue(context, value) + result[key] = contextValue !== undefined ? contextValue : value + } + } + else { + result[key] = value + } + } + + return Object.freeze(result) +} diff --git a/services/minecraft/src/cognitive/os/rules/rules.test.ts b/services/minecraft/src/cognitive/os/rules/rules.test.ts new file mode 100644 index 000000000..0e4a62d9a --- /dev/null +++ b/services/minecraft/src/cognitive/os/rules/rules.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' + +import { + calculateWindowSlots, + createAccumulatorState, + parseWindowDuration, + processEvent, +} from './accumulator' +import { parseRuleFromString } from './loader' +import { + getNestedValue, + matchCondition, + matchEventType, + matchWhere, + renderTemplate, +} from './matcher' + +describe('accumulator', () => { + describe('parseWindowDuration', () => { + it('should parse milliseconds', () => { + expect(parseWindowDuration('500ms')).toBe(500) + expect(parseWindowDuration('100')).toBe(100) + }) + + it('should parse seconds', () => { + expect(parseWindowDuration('2s')).toBe(2000) + expect(parseWindowDuration('0.5s')).toBe(500) + }) + + it('should parse minutes', () => { + expect(parseWindowDuration('1m')).toBe(60000) + }) + }) + + describe('calculateWindowSlots', () => { + it('should calculate slots correctly', () => { + expect(calculateWindowSlots(2000, 20)).toBe(100) + expect(calculateWindowSlots(500, 20)).toBe(25) + }) + }) + + describe('processEvent', () => { + it('should increment count and not fire below threshold', () => { + const state = createAccumulatorState(10) + const [fired, newState] = processEvent(state, 5, Date.now()) + + expect(fired).toBe(false) + expect(newState.total).toBe(1) + }) + + it('should fire when threshold reached', () => { + let state = createAccumulatorState(10) + const now = Date.now() + + for (let i = 0; i < 4; i++) { + const [fired, newState] = processEvent(state, 5, now) + expect(fired).toBe(false) + state = newState + } + + const [fired, newState] = processEvent(state, 5, now) + expect(fired).toBe(true) + expect(newState.total).toBe(0) // Reset after fire + }) + }) +}) + +describe('matcher', () => { + describe('matchCondition', () => { + it('should match direct values', () => { + expect(matchCondition('player', 'player')).toBe(true) + expect(matchCondition(10, 10)).toBe(true) + expect(matchCondition(true, true)).toBe(true) + }) + + it('should match operators', () => { + expect(matchCondition({ lt: 10 }, 5)).toBe(true) + expect(matchCondition({ lt: 10 }, 15)).toBe(false) + expect(matchCondition({ gte: 5 }, 5)).toBe(true) + expect(matchCondition({ in: ['a', 'b'] }, 'a')).toBe(true) + }) + }) + + describe('matchWhere', () => { + it('should match all conditions', () => { + const where = { entityType: 'player', distance: { lt: 10 } } + const payload = { entityType: 'player', distance: 5 } + + expect(matchWhere(where, payload)).toBe(true) + }) + + it('should fail if any condition fails', () => { + const where = { entityType: 'player', distance: { lt: 10 } } + const payload = { entityType: 'player', distance: 15 } + + expect(matchWhere(where, payload)).toBe(false) + }) + }) + + describe('matchEventType', () => { + it('should match exact types', () => { + expect(matchEventType('raw:sighted:punch', 'raw:sighted:punch')).toBe(true) + }) + + it('should match wildcards', () => { + expect(matchEventType('raw:*', 'raw:sighted:punch')).toBe(true) + expect(matchEventType('raw:sighted:*', 'raw:sighted:punch')).toBe(true) + expect(matchEventType('signal:*', 'raw:sighted:punch')).toBe(false) + }) + }) + + describe('renderTemplate', () => { + it('should replace placeholders', () => { + const template = 'Player {{ name }} says {{ message }}' + const context = { name: 'Bob', message: 'Hello' } + + expect(renderTemplate(template, context)).toBe('Player Bob says Hello') + }) + + it('should keep unknown placeholders', () => { + const template = 'Hello {{ unknown }}' + expect(renderTemplate(template, {})).toBe('Hello {{unknown}}') + }) + }) + + describe('getNestedValue', () => { + it('should get nested values', () => { + const obj = { a: { b: { c: 42 } } } + expect(getNestedValue(obj, 'a.b.c')).toBe(42) + }) + }) +}) + +describe('loader', () => { + describe('parseRuleFromString', () => { + it('should parse a valid YAML rule', () => { + const yaml = ` +name: test-rule +version: 1 +trigger: + modality: sighted + kind: arm_swing + where: + entityType: player +accumulator: + threshold: 5 + window: 2s +signal: + type: entity_attention + description: "Player {{ displayName }} is punching" +` + const rule = parseRuleFromString(yaml) + + expect(rule.name).toBe('test-rule') + expect(rule.version).toBe(1) + expect(rule.trigger.eventType).toBe('raw:sighted:arm_swing') + expect(rule.trigger.where).toEqual({ entityType: 'player' }) + expect(rule.accumulator.threshold).toBe(5) + expect(rule.accumulator.windowMs).toBe(2000) + expect(rule.signal.type).toBe('entity_attention') + }) + }) +}) diff --git a/services/minecraft/src/cognitive/os/rules/types.ts b/services/minecraft/src/cognitive/os/rules/types.ts new file mode 100644 index 000000000..a0420d6bb --- /dev/null +++ b/services/minecraft/src/cognitive/os/rules/types.ts @@ -0,0 +1,173 @@ +/** + * YAML Rule DSL Types + * + * These types define the structure of YAML rule files. + * All types are immutable (Readonly) to enforce FP principles. + */ + +/** + * Comparison operators for where clauses + */ +export type ComparisonOperator = 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'in' | 'contains' + +/** + * A single condition in a where clause + * Can be a direct value (equality) or an object with operator + */ +export type WhereCondition + = | string + | number + | boolean + | { readonly eq?: unknown } + | { readonly ne?: unknown } + | { readonly lt?: number } + | { readonly lte?: number } + | { readonly gt?: number } + | { readonly gte?: number } + | { readonly in?: readonly unknown[] } + | { readonly contains?: string } + +/** + * Where clause - conditions to match against event payload + */ +export type WhereClause = Readonly> + +/** + * Trigger definition in YAML + */ +export interface RuleTrigger { + /** Event modality (e.g., 'sighted', 'heard', 'felt') */ + readonly modality: string + /** Event kind (e.g., 'arm_swing', 'sound') */ + readonly kind: string + /** Optional conditions on event payload */ + readonly where?: WhereClause +} + +/** + * Accumulator configuration + */ +export interface AccumulatorConfig { + /** Number of events needed to trigger */ + readonly threshold: number + /** Time window (e.g., '2s', '500ms') */ + readonly window: string + /** Window mode: sliding (default) or tumbling */ + readonly mode?: 'sliding' | 'tumbling' +} + +/** + * Signal output configuration + */ +export interface SignalConfig { + /** Signal type (e.g., 'entity_attention', 'environmental_anomaly') */ + readonly type: string + /** Description template with {{ placeholders }} */ + readonly description: string + /** Confidence score (0-1) */ + readonly confidence?: number + /** Additional metadata with templates */ + readonly metadata?: Readonly> +} + +/** + * Complete YAML rule definition + */ +export interface YamlRule { + /** Rule name (unique identifier) */ + readonly name: string + /** Rule version */ + readonly version?: number + /** Trigger configuration */ + readonly trigger: RuleTrigger + /** Accumulator configuration */ + readonly accumulator: AccumulatorConfig + /** Signal to emit when rule fires */ + readonly signal: SignalConfig +} + +/** + * Parsed and validated rule (internal representation) + */ +export interface ParsedRule { + readonly name: string + readonly version: number + readonly trigger: { + readonly eventType: string // e.g., 'raw:sighted:arm_swing' + readonly where?: WhereClause + } + readonly accumulator: { + readonly threshold: number + readonly windowMs: number + readonly mode: 'sliding' | 'tumbling' + } + readonly signal: SignalConfig + /** Source file path for debugging */ + readonly sourcePath: string +} + +/** + * Accumulator state for a single rule instance + * Immutable - each update returns a new state + */ +export interface AccumulatorState { + /** Circular buffer of event counts per slot */ + readonly counts: readonly number[] + /** Current head position in buffer */ + readonly head: number + /** Running total */ + readonly total: number + /** Last update timestamp */ + readonly lastUpdateMs: number + /** Slot when last fired */ + readonly lastFireSlot: number | null +} + +/** + * Complete state for all accumulators + */ +export type AccumulatorsState = Readonly> + +/** + * Result of processing an event through a rule + */ +export interface RuleMatchResult { + /** Whether the rule matched and fired */ + readonly fired: boolean + /** The signal to emit (if fired) */ + readonly signal?: Readonly<{ + type: string + description: string + confidence: number + metadata: Readonly> + sourceId?: string + }> + /** Updated accumulator state */ + readonly newAccumulatorState: AccumulatorState +} + +/** + * TypeScript escape hatch for complex rules + * Generic T allows type-safe payload handling + */ +export interface TypeScriptRule { + readonly name: string + readonly eventPattern: string + /** Process function - receives typed payload and accumulator state */ + readonly process: ( + payload: T, + accState: AccumulatorState, + ) => RuleMatchResult +} + +/** + * Union of rule types + */ +export type Rule = ParsedRule | TypeScriptRule + +/** + * Check if a rule is a TypeScript rule + */ +export function isTypeScriptRule(rule: Rule): rule is TypeScriptRule { + return 'process' in rule +} diff --git a/services/minecraft/src/cognitive/os/tracer.ts b/services/minecraft/src/cognitive/os/tracer.ts new file mode 100644 index 000000000..0185ff63e --- /dev/null +++ b/services/minecraft/src/cognitive/os/tracer.ts @@ -0,0 +1,98 @@ +import type { EventId, TraceContext, TraceId } from './types' + +/** + * AsyncLocalStorage-based trace context propagation + * This allows handlers to automatically inherit trace context + */ +import { AsyncLocalStorage } from 'node:async_hooks' + +import { nanoid } from 'nanoid' + +/** + * Generate a unique event ID + * Uses nanoid for compact, URL-safe IDs + */ +export function generateEventId(): EventId { + return nanoid(12) +} + +/** + * Generate a unique trace ID + */ +export function generateTraceId(): TraceId { + return nanoid(16) +} + +/** + * Create a new trace context for a fresh event chain + */ +export function createTraceContext(): TraceContext { + return Object.freeze({ + traceId: generateTraceId(), + }) +} + +/** + * Derive a child trace context from a parent event + * Preserves the same traceId but sets the parentId + */ +export function deriveTraceContext( + parentTraceId: TraceId, + parentEventId: EventId, +): TraceContext { + return Object.freeze({ + traceId: parentTraceId, + parentId: parentEventId, + }) +} + +const traceStorage = new AsyncLocalStorage() + +/** + * Get the current trace context from async local storage + * Returns undefined if not in a traced context + */ +export function getCurrentTraceContext(): TraceContext | undefined { + return traceStorage.getStore() +} + +/** + * Run a function within a trace context + * All events emitted within this context will inherit the trace + */ +export function runWithTraceContext( + context: TraceContext, + fn: () => T, +): T { + return traceStorage.run(context, fn) +} + +/** + * Create or derive trace context for an event + * If we're in a traced context, derive from it; otherwise create new + */ +export function resolveTraceContext( + explicit?: Partial, +): TraceContext { + const current = getCurrentTraceContext() + + if (explicit?.traceId) { + // Explicit context provided + return Object.freeze({ + traceId: explicit.traceId, + parentId: explicit.parentId, + }) + } + + if (current) { + // Derive from current async context + // Note: parentId should be set by the caller who knows the parent event + return Object.freeze({ + traceId: current.traceId, + parentId: current.parentId, + }) + } + + // New trace + return createTraceContext() +} diff --git a/services/minecraft/src/cognitive/os/types.ts b/services/minecraft/src/cognitive/os/types.ts new file mode 100644 index 000000000..e1e5a8183 --- /dev/null +++ b/services/minecraft/src/cognitive/os/types.ts @@ -0,0 +1,139 @@ +/** + * Core types for the Cognitive OS event-sourced architecture. + * All types are immutable by design (Readonly). + */ + +/** + * Unique identifier for events and traces + */ +export type EventId = string +export type TraceId = string + +/** + * Event source identifier + */ +export interface EventSource { + readonly component: string + readonly id?: string +} + +/** + * A traced event - the core unit of the event-sourced system. + * All fields are readonly to enforce immutability. + */ +export interface TracedEvent { + /** Unique event ID */ + readonly id: EventId + /** Trace ID shared across related events */ + readonly traceId: TraceId + /** Parent event ID (if derived from another event) */ + readonly parentId?: EventId + /** Event type identifier (e.g. 'raw:sighted:arm_swing') */ + readonly type: string + /** Event payload - should be immutable */ + readonly payload: Readonly + /** Event timestamp */ + readonly timestamp: number + /** Source component */ + readonly source: EventSource +} + +/** + * Input for creating a new event + * traceId and parentId are optional - will be auto-generated or inherited from context + * id and timestamp are always auto-generated + */ +export interface EventInput { + readonly type: string + readonly payload: Readonly + readonly source: EventSource + readonly traceId?: string + readonly parentId?: string +} + +/** + * Event handler function - should be a pure function that may emit new events + */ +export type EventHandler = (event: TracedEvent) => void + +/** + * Unsubscribe function returned by subscribe + */ +export type Unsubscribe = () => void + +/** + * Event pattern for subscription filtering + * Supports wildcards: 'raw:*' matches 'raw:sighted:punch' + */ +export type EventPattern = string + +/** + * Subscription record + */ +export interface Subscription { + readonly pattern: EventPattern + readonly handler: EventHandler +} + +/** + * EventBus configuration + */ +export interface EventBusConfig { + /** Maximum events to keep in history (ring buffer) */ + readonly historySize: number +} + +/** + * Snapshot of EventBus state for debugging + */ +export interface EventBusSnapshot { + readonly events: readonly TracedEvent[] + readonly subscriptionCount: number +} + +/** + * Trace context for propagating trace information + */ +export interface TraceContext { + readonly traceId: TraceId + readonly parentId?: EventId +} + +/** + * Deep freeze an object (recursively freeze all nested objects) + * Performance note: Use sparingly on large objects + */ +export function deepFreeze(obj: T): Readonly { + if (obj === null || typeof obj !== 'object') { + return obj + } + + // Don't freeze already frozen objects + if (Object.isFrozen(obj)) { + return obj + } + + // Freeze arrays + if (Array.isArray(obj)) { + obj.forEach(item => deepFreeze(item)) + return Object.freeze(obj) as Readonly + } + + // Freeze object properties + Object.keys(obj).forEach((key) => { + const value = (obj as Record)[key] + if (value !== null && typeof value === 'object') { + deepFreeze(value) + } + }) + + return Object.freeze(obj) +} + +/** + * Create an immutable event by deep freezing all its properties + * This ensures the entire object tree is immutable + */ +export function freezeEvent(event: TracedEvent): TracedEvent { + return deepFreeze(event) +} diff --git a/services/minecraft/src/cognitive/perception/pipeline.ts b/services/minecraft/src/cognitive/perception/pipeline.ts index 66a112425..46e448814 100644 --- a/services/minecraft/src/cognitive/perception/pipeline.ts +++ b/services/minecraft/src/cognitive/perception/pipeline.ts @@ -1,8 +1,10 @@ import type { Logg } from '@guiiai/logg' +import type { EventBus } from '../os' import type { MineflayerWithAgents } from '../types' import type { EventManager } from './event-manager' import type { PerceptionFrame } from './frame' +import type { RawPerceptionEvent } from './types/raw-events' import type { PerceptionSignal } from './types/signals' import type { PerceptionStage } from './types/stage' @@ -24,6 +26,7 @@ export class PerceptionPipeline { constructor( private readonly deps: { + eventBus: EventBus eventManager: EventManager logger: Logg }, @@ -49,8 +52,11 @@ export class PerceptionPipeline { this.currentFrame = frame try { - const raw = frame.raw as any + const raw = frame.raw as RawPerceptionEvent this.detector.ingest(raw) + + // Also emit to EventBus for rule processing + this.emitRawToEventBus(raw) } finally { this.currentFrame = null @@ -163,4 +169,21 @@ export class PerceptionPipeline { } } } + + /** + * Emit a raw perception event to the EventBus + * This bridges the perception system to the rule engine + */ + private emitRawToEventBus(raw: RawPerceptionEvent): void { + const eventType = `raw:${raw.modality}:${raw.kind}` + + this.deps.eventBus.emit({ + type: eventType, + payload: Object.freeze(raw), + source: { + component: 'perception', + id: raw.source, + }, + }) + } } diff --git a/services/minecraft/src/cognitive/perception/saliency-detector.ts b/services/minecraft/src/cognitive/perception/saliency-detector.ts index 6eb5aedcf..05137587d 100644 --- a/services/minecraft/src/cognitive/perception/saliency-detector.ts +++ b/services/minecraft/src/cognitive/perception/saliency-detector.ts @@ -187,10 +187,13 @@ export class SaliencyDetector { /** * Reset counter counts (after threshold triggered) + * Note: We only reset the counts, not triggers - triggers are visual markers + * that should persist until they naturally expire in the circular buffer */ private resetCounter(counter: WindowCounter): void { counter.total = 0 counter.counts.fill(0) + // Don't reset triggers - they're historical markers for visualization } /** diff --git a/services/minecraft/src/cognitive/rules/attention/movement.yaml b/services/minecraft/src/cognitive/rules/attention/movement.yaml new file mode 100644 index 000000000..e86d5339c --- /dev/null +++ b/services/minecraft/src/cognitive/rules/attention/movement.yaml @@ -0,0 +1,22 @@ +name: player-movement +version: 1 + +trigger: + modality: sighted + kind: entity_moved + where: + entityType: player + +accumulator: + threshold: 5 + window: 2s + +signal: + type: entity_attention + description: 'Player {{ displayName }} is moving nearby' + confidence: 0.8 + metadata: + action: move + distance: '{{ distance }}' + displayName: '{{ displayName }}' + hasLineOfSight: '{{ hasLineOfSight }}' diff --git a/services/minecraft/src/cognitive/rules/attention/punch.yaml b/services/minecraft/src/cognitive/rules/attention/punch.yaml new file mode 100644 index 000000000..16909a6e1 --- /dev/null +++ b/services/minecraft/src/cognitive/rules/attention/punch.yaml @@ -0,0 +1,25 @@ +name: punch-attention +version: 1 + +trigger: + modality: sighted + kind: arm_swing + where: + entityType: player + distance: + lt: 10 + +accumulator: + threshold: 5 + window: 2s + mode: sliding + +signal: + type: entity_attention + description: 'Player {{ displayName }} is punching nearby' + confidence: 1.0 + metadata: + action: punch + distance: '{{ distance }}' + displayName: '{{ displayName }}' + hasLineOfSight: '{{ hasLineOfSight }}' diff --git a/services/minecraft/src/cognitive/rules/attention/teabag.yaml b/services/minecraft/src/cognitive/rules/attention/teabag.yaml new file mode 100644 index 000000000..466322166 --- /dev/null +++ b/services/minecraft/src/cognitive/rules/attention/teabag.yaml @@ -0,0 +1,22 @@ +name: teabag-attention +version: 1 + +trigger: + modality: sighted + kind: sneak_toggle + where: + entityType: player + +accumulator: + threshold: 5 + window: 2s + mode: sliding + +signal: + type: entity_attention + description: 'Player {{ displayName }} is teabagging (rapid sneaking)' + confidence: 1.0 + metadata: + action: teabag + distance: '{{ distance }}' + displayName: '{{ displayName }}' diff --git a/services/minecraft/src/cognitive/rules/danger/damage.yaml b/services/minecraft/src/cognitive/rules/danger/damage.yaml new file mode 100644 index 000000000..2fd11a054 --- /dev/null +++ b/services/minecraft/src/cognitive/rules/danger/damage.yaml @@ -0,0 +1,17 @@ +name: damage-taken +version: 1 + +trigger: + modality: felt + kind: damage_taken + +accumulator: + threshold: 1 + window: 500ms + +signal: + type: saliency_high + description: 'Taken damage!' + confidence: 1.0 + metadata: + action: damage diff --git a/services/minecraft/src/debug/debug-service.ts b/services/minecraft/src/debug/debug-service.ts index 098c828a8..fafa6380f 100644 --- a/services/minecraft/src/debug/debug-service.ts +++ b/services/minecraft/src/debug/debug-service.ts @@ -1,4 +1,4 @@ -import type { BlackboardEvent, ClientCommand, LLMTraceEvent, LogEvent, QueueEvent, SaliencyEvent, ServerEvent } from './types' +import type { BlackboardEvent, ClientCommand, LLMTraceEvent, LogEvent, QueueEvent, SaliencyEvent, ServerEvent, TraceEvent } from './types' import { DebugServer } from './server' @@ -120,6 +120,34 @@ export class DebugService { this.server.broadcast(event) } + /** + * Emit a single trace event from the EventBus + */ + public emitTrace(trace: TraceEvent): void { + const event: ServerEvent = { + type: 'trace', + payload: trace, + } + this.server.broadcast(event) + } + + /** + * Emit a batch of trace events (more efficient for high-frequency events) + */ + public emitTraceBatch(traces: TraceEvent[]): void { + if (traces.length === 0) + return + + const event: ServerEvent = { + type: 'trace_batch', + payload: { + events: traces, + timestamp: Date.now(), + }, + } + this.server.broadcast(event) + } + // ============================================================ // Generic emit for custom events // ============================================================ diff --git a/services/minecraft/src/debug/types.ts b/services/minecraft/src/debug/types.ts index df87768c8..9aa834f6e 100644 --- a/services/minecraft/src/debug/types.ts +++ b/services/minecraft/src/debug/types.ts @@ -57,6 +57,37 @@ export interface SaliencyEvent { timestamp: number } +/** + * Traced event from the Cognitive OS EventBus + */ +export interface TraceEvent { + /** Unique event ID */ + id: string + /** Trace ID (shared by related events) */ + traceId: string + /** Parent event ID (for event chains) */ + parentId?: string + /** Event type (e.g., 'raw:sighted:arm_swing') */ + type: string + /** Event payload */ + payload: unknown + /** Event timestamp */ + timestamp: number + /** Source component */ + source: { + component: string + id?: string + } +} + +/** + * Batch of trace events + */ +export interface TraceBatchEvent { + events: TraceEvent[] + timestamp: number +} + // Union type for all server events export type ServerEvent = | { type: 'log', payload: LogEvent } @@ -64,6 +95,8 @@ export type ServerEvent | { type: 'blackboard', payload: BlackboardEvent } | { type: 'queue', payload: QueueEvent } | { type: 'saliency', payload: SaliencyEvent } + | { type: 'trace', payload: TraceEvent } + | { type: 'trace_batch', payload: TraceBatchEvent } | { type: 'history', payload: ServerEvent[] } | { type: 'pong', payload: { timestamp: number } } diff --git a/services/minecraft/src/debug/web/app.js b/services/minecraft/src/debug/web/app.js index 4cca69405..10c8e05ca 100644 --- a/services/minecraft/src/debug/web/app.js +++ b/services/minecraft/src/debug/web/app.js @@ -592,13 +592,230 @@ class SaliencyPanel { } colorFor(value, maxValue) { - if (!maxValue) - return 'rgba(0,0,0,0)' - const t = Math.min(1, value / maxValue) + // Always show a subtle color for empty cells, never fully transparent + if (value === 0) { + return 'rgba(30, 38, 50, 1)' // Dark background for empty cells + } + // Normalize value when we have data + const max = maxValue || 1 + const t = Math.min(1, value / max) 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})` + return `rgba(${r},${g},${b},${0.3 + 0.7 * t})` + } +} + +// ============================================================================= +// Timeline Panel (Event Tracing) +// ============================================================================= + +class TimelinePanel { + constructor(client) { + this.client = client + this.events = [] + this.filter = { type: 'all', search: '' } + this.selectedTraceId = null + this.elements = { + list: document.getElementById('timeline-list'), + container: document.getElementById('timeline-container'), + search: document.getElementById('timeline-search'), + typeFilter: document.getElementById('timeline-type-filter'), + clearBtn: document.getElementById('timeline-clear-btn'), + detail: document.getElementById('trace-detail'), + detailTitle: document.getElementById('trace-detail-title'), + detailContent: document.getElementById('trace-detail-content'), + detailClose: document.getElementById('trace-detail-close'), + } + } + + init() { + this.client.on('trace', data => this.addEvent(data)) + this.client.on('trace_batch', (data) => { + if (data.events) { + data.events.forEach(e => this.addEvent(e)) + } + }) + this.client.on('connected', () => this.reset()) + + this.elements.search?.addEventListener('input', (e) => { + this.filter.search = e.target.value.toLowerCase() + this.renderThrottled() + }) + + this.elements.typeFilter?.addEventListener('change', (e) => { + this.filter.type = e.target.value + this.renderThrottled() + }) + + this.elements.clearBtn?.addEventListener('click', () => this.clear()) + this.elements.detailClose?.addEventListener('click', () => this.hideDetail()) + + this.renderThrottled = throttle(() => this.render(), CONFIG.UPDATE_THROTTLE) + } + + addEvent(event) { + this.events.push(event) + if (this.events.length > 1000) { + this.events.shift() + } + this.renderThrottled() + } + + reset() { + this.events = [] + this.selectedTraceId = null + this.hideDetail() + this.render() + } + + clear() { + this.reset() + } + + render() { + const filtered = this.events.filter(e => this.matchesFilter(e)) + const recent = filtered.slice(-200) // Show last 200 + + if (recent.length === 0) { + this.elements.list.innerHTML = '
No events
' + return + } + + this.elements.list.innerHTML = recent.map(e => this.renderEvent(e)).join('') + + // Attach click handlers + this.elements.list.querySelectorAll('.timeline-event').forEach((el) => { + el.addEventListener('click', () => { + const traceId = el.dataset.traceId + this.showTraceDetail(traceId) + }) + }) + + // Auto-scroll + this.elements.container.scrollTop = this.elements.container.scrollHeight + } + + matchesFilter(event) { + if (this.filter.type !== 'all' && !event.type.startsWith(this.filter.type)) { + return false + } + if (this.filter.search) { + const searchStr = `${event.type} ${JSON.stringify(event.payload)}`.toLowerCase() + if (!searchStr.includes(this.filter.search)) { + return false + } + } + return true + } + + renderEvent(event) { + const time = new Date(event.timestamp).toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + }) + + const isSignal = event.type.startsWith('signal:') + const isRaw = event.type.startsWith('raw:') + const typeClass = isSignal ? 'type-signal' : (isRaw ? 'type-raw' : 'type-other') + + // Extract useful info from payload + let info = '' + if (event.payload) { + const p = event.payload + if (p.description) + info = p.description + else if (p.displayName) + info = p.displayName + else if (p.entityType) + info = p.entityType + } + + const hasParent = event.parentId ? 'has-parent' : '' + + return ` +
+ ${time} + ${escapeHtml(event.type)} + ${info ? `${escapeHtml(info)}` : ''} + +
+ ` + } + + showTraceDetail(traceId) { + this.selectedTraceId = traceId + const traceEvents = this.events.filter(e => e.traceId === traceId) + + if (traceEvents.length === 0) { + return + } + + // Build event tree + const tree = this.buildEventTree(traceEvents) + + this.elements.detailTitle.textContent = `Trace: ${traceId.slice(0, 8)}...` + this.elements.detailContent.innerHTML = this.renderEventTree(tree) + this.elements.detail.classList.remove('hidden') + + // Highlight in main list + this.elements.list.querySelectorAll('.timeline-event').forEach((el) => { + el.classList.toggle('selected', el.dataset.traceId === traceId) + }) + } + + hideDetail() { + this.elements.detail?.classList.add('hidden') + this.selectedTraceId = null + this.elements.list?.querySelectorAll('.timeline-event.selected').forEach((el) => { + el.classList.remove('selected') + }) + } + + buildEventTree(events) { + const eventMap = new Map() + const roots = [] + + // Index all events + events.forEach(e => eventMap.set(e.id, { event: e, children: [] })) + + // Build tree + events.forEach((e) => { + const node = eventMap.get(e.id) + if (e.parentId && eventMap.has(e.parentId)) { + eventMap.get(e.parentId).children.push(node) + } + else { + roots.push(node) + } + }) + + return roots + } + + renderEventTree(nodes, depth = 0) { + return nodes.map((node) => { + const e = node.event + const indent = depth * 16 + const time = new Date(e.timestamp).toLocaleTimeString('en-US', { hour12: false }) + + return ` +
+
+ ${depth > 0 ? '' : ''} + ${escapeHtml(e.type)} + ${time} +
+
${escapeHtml(JSON.stringify(e.payload, null, 2))}
+ ${node.children.length > 0 ? this.renderEventTree(node.children, depth + 1) : ''} +
+ ` + }).join('') } } @@ -615,6 +832,7 @@ class DebugApp { logs: new LogsPanel(this.client), llm: new LLMPanel(this.client), saliency: new SaliencyPanel(this.client), + timeline: new TimelinePanel(this.client), } this.paused = false } diff --git a/services/minecraft/src/debug/web/index.html b/services/minecraft/src/debug/web/index.html index 05dcb08fe..7ae3e2137 100644 --- a/services/minecraft/src/debug/web/index.html +++ b/services/minecraft/src/debug/web/index.html @@ -95,6 +95,34 @@ + +
+
+

Event Timeline

+
+ + + +
+
+
+
+
+
+ +
+
+
diff --git a/services/minecraft/src/debug/web/styles.css b/services/minecraft/src/debug/web/styles.css index be385f556..c4d6c81ae 100644 --- a/services/minecraft/src/debug/web/styles.css +++ b/services/minecraft/src/debug/web/styles.css @@ -551,4 +551,190 @@ button:hover, ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); +} + +/* ============================================================================= + Timeline Panel Styles + ============================================================================= */ + +#timeline-section { + grid-row: 2 / 3; + grid-column: 1 / 3; +} + +#llm-section { + grid-row: 3 / 4; +} + +#saliency-section { + grid-row: 4 / 5; +} + +/* Adjust grid for timeline */ +#main-grid { + grid-template-rows: minmax(250px, 1fr) minmax(180px, 0.5fr) minmax(180px, 0.4fr) minmax(150px, 0.3fr); +} + +.timeline-container { + flex: 1; + overflow-y: auto; + max-height: 300px; +} + +.timeline-list { + display: flex; + flex-direction: column; + gap: 2px; + font-family: var(--font-mono); + font-size: 11px; +} + +.timeline-event { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 3px 6px; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 2px; + cursor: pointer; + transition: background-color 0.1s; +} + +.timeline-event:hover { + background-color: var(--bg-tertiary); +} + +.timeline-event.selected { + background-color: rgba(88, 166, 255, 0.15); + border-color: var(--accent-info); +} + +.timeline-event.type-raw { + border-left: 3px solid var(--accent-info); +} + +.timeline-event.type-signal { + border-left: 3px solid var(--accent-success); +} + +.timeline-event.type-other { + border-left: 3px solid var(--text-muted); +} + +.timeline-event.has-parent::before { + content: '↳'; + color: var(--text-muted); + margin-right: -0.25rem; +} + +.timeline-time { + color: var(--text-muted); + flex-shrink: 0; + font-size: 10px; +} + +.timeline-type { + color: var(--accent-info); + flex-shrink: 0; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.timeline-event.type-signal .timeline-type { + color: var(--accent-success); +} + +.timeline-info { + color: var(--text-secondary); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.timeline-trace { + color: var(--text-muted); + font-size: 10px; + opacity: 0.5; +} + +.timeline-event:hover .timeline-trace { + opacity: 1; +} + +/* Trace Detail Panel */ +.trace-detail { + flex-shrink: 0; + max-height: 250px; + overflow-y: auto; + border-top: 1px solid var(--border-color); + background-color: var(--bg-primary); + margin-top: 0.5rem; +} + +.trace-detail.hidden { + display: none; +} + +.trace-detail-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + background-color: var(--bg-tertiary); + position: sticky; + top: 0; +} + +#trace-detail-title { + font-weight: 600; + font-size: 12px; + color: var(--accent-info); +} + +#trace-detail-content { + padding: 0.5rem; +} + +.trace-tree-node { + margin-bottom: 0.5rem; +} + +.trace-node-header { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-mono); + font-size: 11px; +} + +.trace-connector { + color: var(--text-muted); +} + +.trace-node-type { + color: var(--accent-info); + font-weight: 500; +} + +.trace-node-time { + color: var(--text-muted); + font-size: 10px; +} + +.trace-node-payload { + margin-top: 0.25rem; + padding: 0.5rem; + background-color: var(--bg-secondary); + border-radius: 3px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + white-space: pre-wrap; + word-wrap: break-word; + max-height: 100px; + overflow-y: auto; } \ No newline at end of file