refactor(minecraft): perception layer technical debt cleanup
This commit is contained in:
@@ -4,8 +4,9 @@ import type { Neuri } from 'neuri'
|
||||
import type { TaskExecutor } from '../action/task-executor'
|
||||
import type { ActionInstruction } from '../action/types'
|
||||
import type { EventManager } from '../perception/event-manager'
|
||||
import type { BotEvent, MineflayerWithAgents, StimulusPayload } from '../types'
|
||||
import type { PerceptionSignal } from '../perception/types/signals'
|
||||
import type { ReflexManager } from '../reflex/reflex-manager'
|
||||
import type { BotEvent, MineflayerWithAgents } from '../types'
|
||||
|
||||
import { system, user } from 'neuri/openai'
|
||||
|
||||
@@ -55,13 +56,9 @@ export class Brain {
|
||||
public init(bot: MineflayerWithAgents): void {
|
||||
this.log('INFO', 'Brain: Initializing...')
|
||||
|
||||
// We treat these as "Sensory Inputs" that trigger the Cognitive Cycle
|
||||
this.deps.eventManager.on<StimulusPayload>('stimulus', async (event) => {
|
||||
if (event.handled) {
|
||||
this.log('INFO', `Brain: Stimulus from ${event.source.id} already handled by reflex, ignoring.`)
|
||||
return
|
||||
}
|
||||
this.log('INFO', `Brain: Received stimulus from ${event.source.id}: ${event.payload.content}`)
|
||||
// Unified Perception Signal Handler
|
||||
this.deps.eventManager.on<PerceptionSignal>('perception', async (event) => {
|
||||
this.log('INFO', `Brain: Received perception signal: ${event.payload.type} - ${event.payload.description}`)
|
||||
await this.enqueueEvent(bot, event)
|
||||
})
|
||||
|
||||
@@ -140,8 +137,11 @@ export class Brain {
|
||||
|
||||
private contextFromEvent(event: BotEvent): string {
|
||||
switch (event.type) {
|
||||
case 'stimulus':
|
||||
return `${event.source.type} stimulus from ${event.source.id}: "${event.payload.content}"`
|
||||
case 'perception': {
|
||||
const signal = event.payload as PerceptionSignal
|
||||
const sourceInfo = signal.sourceId ? ` (source: ${signal.sourceId})` : ''
|
||||
return `Perception [${signal.type}]${sourceInfo}: ${signal.description}`
|
||||
}
|
||||
case 'feedback': {
|
||||
const { status, result, error } = event.payload
|
||||
return `Internal Feedback: ${status}. Result: ${JSON.stringify(result || error)}`
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
import type { Logg } from '@guiiai/logg'
|
||||
|
||||
import type { RawPerceptionEvent } from './types/raw-events'
|
||||
import type { PerceptionSignal } from './types/signals'
|
||||
|
||||
import { LeakyBucket } from './leaky-bucket'
|
||||
|
||||
export interface PlayerAttentionEventPayload {
|
||||
kind: 'player'
|
||||
playerName?: string
|
||||
hasLineOfSight?: boolean
|
||||
distance?: number
|
||||
playerAction: 'move' | 'punch' | 'teabag' | 'sound' | 'damage' | 'pickup'
|
||||
}
|
||||
|
||||
export interface MobAttentionEventPayload {
|
||||
kind: 'mob'
|
||||
mobName?: string
|
||||
hasLineOfSight?: boolean
|
||||
distance?: number
|
||||
mobAction: string
|
||||
}
|
||||
|
||||
export type AttentionEventPayload = PlayerAttentionEventPayload | MobAttentionEventPayload
|
||||
|
||||
export class AttentionDetector {
|
||||
private readonly buckets = new Map<string, LeakyBucket>()
|
||||
|
||||
@@ -55,7 +38,7 @@ export class AttentionDetector {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
logger: Logg
|
||||
onAttention: (payload: AttentionEventPayload) => void
|
||||
onAttention: (signal: PerceptionSignal) => void
|
||||
},
|
||||
) { }
|
||||
|
||||
@@ -98,12 +81,19 @@ export class AttentionDetector {
|
||||
if (!fired)
|
||||
return
|
||||
|
||||
this.emitAttention({
|
||||
kind: 'player',
|
||||
playerName: event.displayName,
|
||||
hasLineOfSight: event.hasLineOfSight,
|
||||
distance: event.distance,
|
||||
playerAction: 'punch',
|
||||
this.emitSignal({
|
||||
type: 'entity_attention',
|
||||
description: `Player ${event.displayName || 'unknown'} is punching nearby`,
|
||||
sourceId: event.entityId,
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'player',
|
||||
action: 'punch',
|
||||
distance: event.distance,
|
||||
hasLineOfSight: event.hasLineOfSight,
|
||||
displayName: event.displayName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,12 +109,19 @@ export class AttentionDetector {
|
||||
if (!fired)
|
||||
return
|
||||
|
||||
this.emitAttention({
|
||||
kind: 'player',
|
||||
playerName: event.displayName,
|
||||
hasLineOfSight: event.hasLineOfSight,
|
||||
distance: event.distance,
|
||||
playerAction: 'teabag',
|
||||
this.emitSignal({
|
||||
type: 'entity_attention',
|
||||
description: `Player ${event.displayName || 'unknown'} is teabagging (rapid sneaking)`,
|
||||
sourceId: event.entityId,
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'player',
|
||||
action: 'teabag',
|
||||
distance: event.distance,
|
||||
hasLineOfSight: event.hasLineOfSight,
|
||||
displayName: event.displayName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -163,12 +160,19 @@ export class AttentionDetector {
|
||||
return
|
||||
|
||||
state.emitted = true
|
||||
this.emitAttention({
|
||||
kind: 'player',
|
||||
playerName: event.displayName,
|
||||
hasLineOfSight: event.hasLineOfSight,
|
||||
distance: event.distance,
|
||||
playerAction: 'move',
|
||||
this.emitSignal({
|
||||
type: 'entity_attention',
|
||||
description: `Player ${event.displayName || 'unknown'} is moving nearby`,
|
||||
sourceId: event.entityId,
|
||||
confidence: 0.8,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'player',
|
||||
action: 'move',
|
||||
distance: event.distance,
|
||||
hasLineOfSight: event.hasLineOfSight,
|
||||
displayName: event.displayName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -184,10 +188,18 @@ export class AttentionDetector {
|
||||
if (!fired)
|
||||
return
|
||||
|
||||
this.emitAttention({
|
||||
kind: 'player',
|
||||
playerAction: 'sound',
|
||||
distance: event.distance,
|
||||
this.emitSignal({
|
||||
type: 'environmental_anomaly',
|
||||
description: `Heard sound: ${event.soundId}`,
|
||||
sourceId: event.soundId,
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'sound',
|
||||
action: 'sound',
|
||||
soundId: event.soundId,
|
||||
distance: event.distance,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,9 +215,15 @@ export class AttentionDetector {
|
||||
if (!fired)
|
||||
return
|
||||
|
||||
this.emitAttention({
|
||||
kind: 'player',
|
||||
playerAction: 'damage',
|
||||
this.emitSignal({
|
||||
type: 'saliency_high',
|
||||
description: 'Taken damage!',
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'felt',
|
||||
action: 'damage',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -221,38 +239,29 @@ export class AttentionDetector {
|
||||
if (!fired)
|
||||
return
|
||||
|
||||
this.emitAttention({
|
||||
kind: 'player',
|
||||
playerAction: 'pickup',
|
||||
this.emitSignal({
|
||||
type: 'entity_attention',
|
||||
description: 'Picked up an item',
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
kind: 'felt',
|
||||
action: 'pickup',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private emitAttention(payload: AttentionEventPayload): void {
|
||||
const key = payload.kind === 'player'
|
||||
? `emit.player.${payload.playerAction}`
|
||||
: 'emit.mob'
|
||||
private emitSignal(signal: PerceptionSignal): void {
|
||||
const key = `emit.${signal.type}.${signal.metadata.action || 'unknown'}`
|
||||
this.emittedSinceStats[key] = (this.emittedSinceStats[key] ?? 0) + 1
|
||||
|
||||
if (payload.kind === 'player') {
|
||||
this.deps.logger.withFields({
|
||||
kind: payload.kind,
|
||||
action: payload.playerAction,
|
||||
playerName: payload.playerName,
|
||||
distance: payload.distance,
|
||||
hasLineOfSight: payload.hasLineOfSight,
|
||||
}).log('AttentionDetector: emit')
|
||||
}
|
||||
else {
|
||||
this.deps.logger.withFields({
|
||||
kind: payload.kind,
|
||||
mobName: payload.mobName,
|
||||
action: payload.mobAction,
|
||||
distance: payload.distance,
|
||||
hasLineOfSight: payload.hasLineOfSight,
|
||||
}).log('AttentionDetector: emit')
|
||||
}
|
||||
this.deps.logger.withFields({
|
||||
type: signal.type,
|
||||
desc: signal.description,
|
||||
meta: signal.metadata,
|
||||
}).log('AttentionDetector: emit')
|
||||
|
||||
this.deps.onAttention(payload)
|
||||
this.deps.onAttention(signal)
|
||||
}
|
||||
|
||||
private getBucket(key: string, config: { capacity: number, leakPerSecond: number, trigger: number }): LeakyBucket {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { PerceptionFrame } from './frame'
|
||||
import type { RawPerceptionEvent } from './types/raw-events'
|
||||
import type { PerceptionStage } from './types/stage'
|
||||
|
||||
function getDistance(raw: RawPerceptionEvent): number | undefined {
|
||||
return (raw as any).distance
|
||||
}
|
||||
|
||||
function getEntityId(raw: RawPerceptionEvent): string | undefined {
|
||||
return (raw as any).entityId
|
||||
}
|
||||
|
||||
function getDisplayName(raw: RawPerceptionEvent): string | undefined {
|
||||
return (raw as any).displayName
|
||||
}
|
||||
|
||||
export class NormalizerStage implements PerceptionStage {
|
||||
public readonly name = 'normalizer'
|
||||
|
||||
private readonly lastMovedEmitAt = new Map<string, number>()
|
||||
private readonly lastSneakValue = new Map<string, boolean>()
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
maxDistance: number
|
||||
},
|
||||
) { }
|
||||
|
||||
public handle(frame: PerceptionFrame): PerceptionFrame | null {
|
||||
if (frame.kind !== 'world_raw')
|
||||
return frame
|
||||
|
||||
const raw = frame.raw as RawPerceptionEvent
|
||||
|
||||
const distance = getDistance(raw)
|
||||
if (typeof distance === 'number') {
|
||||
if (distance > this.deps.maxDistance)
|
||||
return null
|
||||
|
||||
frame.norm = {
|
||||
...frame.norm,
|
||||
distance,
|
||||
within32: distance <= this.deps.maxDistance,
|
||||
entityId: getEntityId(raw),
|
||||
displayName: getDisplayName(raw),
|
||||
}
|
||||
|
||||
// Drop expensive LOS computation for now.
|
||||
if (raw.modality === 'sighted') {
|
||||
; (raw as any).hasLineOfSight = distance <= this.deps.maxDistance
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle spammy entity movement signals
|
||||
if (raw.modality === 'sighted' && (raw as any).kind === 'entity_moved') {
|
||||
const entityId = getEntityId(raw) ?? 'unknown'
|
||||
const now = Date.now()
|
||||
const last = this.lastMovedEmitAt.get(entityId)
|
||||
if (typeof last === 'number' && now - last < 100)
|
||||
return null
|
||||
this.lastMovedEmitAt.set(entityId, now)
|
||||
}
|
||||
|
||||
// Dedupe sneak toggle state (mineflayer may emit frequent updates)
|
||||
if (raw.modality === 'sighted' && (raw as any).kind === 'sneak_toggle') {
|
||||
const entityId = getEntityId(raw) ?? 'unknown'
|
||||
const sneaking = !!(raw as any).sneaking
|
||||
const prev = this.lastSneakValue.get(entityId)
|
||||
if (prev === sneaking)
|
||||
return null
|
||||
this.lastSneakValue.set(entityId, sneaking)
|
||||
}
|
||||
|
||||
return frame
|
||||
}
|
||||
}
|
||||
@@ -78,16 +78,20 @@ describe('perceptionPipeline (e2e)', () => {
|
||||
.filter(e => e.type === 'perception')
|
||||
|
||||
expect(perceptionEvents.length).toBeGreaterThanOrEqual(1)
|
||||
expect((perceptionEvents[0] as any).payload).toMatchObject({
|
||||
|
||||
// Updated assertion for PerceptionSignal
|
||||
const signal = (perceptionEvents[0] as any).payload
|
||||
expect(signal.type).toBe('entity_attention')
|
||||
expect(signal.metadata).toMatchObject({
|
||||
kind: 'player',
|
||||
playerAction: 'punch',
|
||||
playerName: 'alice',
|
||||
action: 'punch',
|
||||
displayName: 'alice',
|
||||
})
|
||||
|
||||
pipeline.destroy()
|
||||
})
|
||||
|
||||
it('router emits stimulus for chat frames ingested into pipeline', () => {
|
||||
it('router emits perception signal for chat frames ingested into pipeline', () => {
|
||||
const logger = makeLogger()
|
||||
const eventManager = new EventManager()
|
||||
const emitSpy = vi.spyOn(eventManager, 'emit')
|
||||
@@ -100,14 +104,20 @@ describe('perceptionPipeline (e2e)', () => {
|
||||
pipeline.ingest(createPerceptionFrameFromChat('alice', 'hi'))
|
||||
pipeline.tick(0)
|
||||
|
||||
const stimulusEvents = emitSpy.mock.calls
|
||||
// Should now be a 'perception' event
|
||||
const perceptionEvents = emitSpy.mock.calls
|
||||
.map(c => c[0])
|
||||
.filter(e => e.type === 'stimulus')
|
||||
.filter(e => e.type === 'perception')
|
||||
|
||||
expect(stimulusEvents.length).toBe(1)
|
||||
expect((stimulusEvents[0] as any).payload).toMatchObject({
|
||||
content: 'hi',
|
||||
metadata: { displayName: 'alice' },
|
||||
expect(perceptionEvents.length).toBe(1)
|
||||
|
||||
const signal = (perceptionEvents[0] as any).payload
|
||||
expect(signal.type).toBe('chat_message')
|
||||
expect(signal.description).toContain('alice')
|
||||
expect(signal.description).toContain('hi')
|
||||
expect(signal.metadata).toMatchObject({
|
||||
username: 'alice',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
pipeline.destroy()
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import type { Logg } from '@guiiai/logg'
|
||||
|
||||
import type { MineflayerWithAgents } from '../types'
|
||||
import type { StimulusPayload } from '../types'
|
||||
import type { EventManager } from './event-manager'
|
||||
|
||||
import type { PerceptionFrame } from './frame'
|
||||
|
||||
import type { AttentionEventPayload } from './attention-detector'
|
||||
import type { PerceptionSignal } from './types/signals'
|
||||
import { AttentionDetector } from './attention-detector'
|
||||
import { createPerceptionFrameFromRawEvent } from './frame'
|
||||
import { MineflayerPerceptionCollector } from './mineflayer-perception-collector'
|
||||
import { NormalizerStage } from './normalizer-stage'
|
||||
import { RawEventBuffer } from './raw-event-buffer'
|
||||
import type { PerceptionStage } from './types/stage'
|
||||
|
||||
@@ -36,20 +34,17 @@ export class PerceptionPipeline {
|
||||
) {
|
||||
this.detector = new AttentionDetector({
|
||||
logger: this.deps.logger,
|
||||
onAttention: (payload) => {
|
||||
onAttention: (signal) => {
|
||||
// This is only called synchronously while we're handling a specific frame.
|
||||
// Attach derived signals to that frame; router stage will emit them.
|
||||
this.currentFrame?.signals.push({
|
||||
type: 'attention',
|
||||
payload,
|
||||
type: 'perception_signal',
|
||||
payload: signal,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
this.stages = [
|
||||
new NormalizerStage({
|
||||
maxDistance: 32,
|
||||
}),
|
||||
{
|
||||
name: 'attention',
|
||||
tick: (deltaMs) => {
|
||||
@@ -75,14 +70,23 @@ export class PerceptionPipeline {
|
||||
handle: (frame) => {
|
||||
if (frame.kind === 'chat_raw') {
|
||||
const raw = frame.raw as { username: string, message: string }
|
||||
this.deps.eventManager.emit<StimulusPayload>({
|
||||
type: 'stimulus',
|
||||
payload: {
|
||||
content: raw.message,
|
||||
metadata: {
|
||||
displayName: raw.username,
|
||||
},
|
||||
|
||||
// Convert chat to PerceptionSignal
|
||||
const signal: PerceptionSignal = {
|
||||
type: 'chat_message',
|
||||
description: `Chat from ${raw.username}: "${raw.message}"`,
|
||||
sourceId: raw.username,
|
||||
timestamp: Date.now(),
|
||||
confidence: 1.0,
|
||||
metadata: {
|
||||
username: raw.username,
|
||||
message: raw.message,
|
||||
},
|
||||
}
|
||||
|
||||
this.deps.eventManager.emit<PerceptionSignal>({
|
||||
type: 'perception',
|
||||
payload: signal,
|
||||
source: {
|
||||
type: 'minecraft',
|
||||
id: raw.username,
|
||||
@@ -91,16 +95,16 @@ export class PerceptionPipeline {
|
||||
})
|
||||
}
|
||||
|
||||
// Emit all attention signals centrally as BotEvents
|
||||
for (const signal of frame.signals) {
|
||||
if (signal.type !== 'attention')
|
||||
// Emit all perception signals centrally as BotEvents
|
||||
for (const signalWrapper of frame.signals) {
|
||||
if (signalWrapper.type !== 'perception_signal')
|
||||
continue
|
||||
|
||||
const payload = signal.payload as AttentionEventPayload
|
||||
const signal = signalWrapper.payload as PerceptionSignal
|
||||
|
||||
this.deps.eventManager.emit<AttentionEventPayload>({
|
||||
this.deps.eventManager.emit<PerceptionSignal>({
|
||||
type: 'perception',
|
||||
payload,
|
||||
payload: signal,
|
||||
source: { type: 'minecraft', id: 'perception' },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
export type PerceptionSignalType =
|
||||
| 'chat_message'
|
||||
| 'entity_attention' // e.g. someone waving, teabagging
|
||||
| 'environmental_anomaly' // e.g. sudden loud sound
|
||||
| 'saliency_high' // generic high saliency event
|
||||
|
||||
export interface PerceptionSignal {
|
||||
type: PerceptionSignalType
|
||||
description: string // Textual summary for LLM
|
||||
|
||||
// Contextual Data
|
||||
sourceId?: string // Who/What caused this
|
||||
confidence?: number // 0-1
|
||||
timestamp: number
|
||||
|
||||
// Structured Data (for logic)
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Logg } from '@guiiai/logg'
|
||||
|
||||
import type { EventManager } from '../perception/event-manager'
|
||||
import type { BotEvent, MineflayerWithAgents, StimulusPayload } from '../types'
|
||||
import type { PerceptionSignal } from '../perception/types/signals'
|
||||
import type { BotEvent, MineflayerWithAgents } from '../types'
|
||||
|
||||
import type { ReflexContextState } from './context'
|
||||
|
||||
@@ -12,8 +13,8 @@ export class ReflexManager {
|
||||
private bot: MineflayerWithAgents | null = null
|
||||
private readonly runtime: ReflexRuntime
|
||||
|
||||
private readonly onStimulusHandler = (event: BotEvent<StimulusPayload>) => {
|
||||
this.onStimulus(event)
|
||||
private readonly onPerceptionHandler = (event: BotEvent<PerceptionSignal>) => {
|
||||
this.onPerception(event)
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -31,11 +32,11 @@ export class ReflexManager {
|
||||
|
||||
public init(bot: MineflayerWithAgents): void {
|
||||
this.bot = bot
|
||||
this.deps.eventManager.on<StimulusPayload>('stimulus', this.onStimulusHandler)
|
||||
this.deps.eventManager.on<PerceptionSignal>('perception', this.onPerceptionHandler)
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.deps.eventManager.off<StimulusPayload>('stimulus', this.onStimulusHandler)
|
||||
this.deps.eventManager.off<PerceptionSignal>('perception', this.onPerceptionHandler)
|
||||
this.bot = null
|
||||
}
|
||||
|
||||
@@ -50,17 +51,23 @@ export class ReflexManager {
|
||||
return this.runtime.getContext().getSnapshot()
|
||||
}
|
||||
|
||||
private onStimulus(event: BotEvent<StimulusPayload>): void {
|
||||
private onPerception(event: BotEvent<PerceptionSignal>): void {
|
||||
const bot = this.bot
|
||||
if (!bot)
|
||||
return
|
||||
|
||||
const signal = event.payload
|
||||
// Only care about chat messages for now for social context
|
||||
if (signal.type !== 'chat_message')
|
||||
return
|
||||
|
||||
const now = Date.now()
|
||||
const message = signal.metadata.message || signal.description
|
||||
|
||||
this.runtime.getContext().updateNow(now)
|
||||
this.runtime.getContext().updateSocial({
|
||||
lastSpeaker: event.source.id,
|
||||
lastMessage: event.payload.content,
|
||||
lastMessage: message,
|
||||
lastMessageAt: now,
|
||||
})
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface CognitiveEngineOptions {
|
||||
}
|
||||
|
||||
// TODO: currently stimulus is just chat events, consider renaming to 'input' or 'user_interaction'
|
||||
export type EventCategory = 'stimulus' | 'perception' | 'feedback' | 'world_update' | 'system_alert'
|
||||
export type EventCategory = 'perception' | 'feedback' | 'world_update' | 'system_alert'
|
||||
|
||||
export interface BotEventSource {
|
||||
type: 'minecraft' | 'airi' | 'system'
|
||||
@@ -47,14 +47,6 @@ export interface BotEvent<T = any> {
|
||||
handled?: boolean // Set by Reflex layer to inhibit Conscious layer
|
||||
}
|
||||
|
||||
export interface StimulusPayload {
|
||||
content: string
|
||||
metadata?: {
|
||||
entity?: any // prismarine-entity Entity
|
||||
displayName?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorldUpdatePayload {
|
||||
event: string
|
||||
data: any
|
||||
|
||||
Reference in New Issue
Block a user