feat(minecraft): semantic percetion, belief and state pulling

This commit is contained in:
Rin
2026-02-18 11:12:02 +08:00
committed by Neko Ayaka
parent 0eca16030b
commit 8beac18ced
16 changed files with 658 additions and 29 deletions
@@ -56,11 +56,16 @@ export class Brain {
public init(bot: MineflayerWithAgents): void {
this.log('INFO', 'Brain: Initializing...')
// 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)
// })
// Perception Signal Handler - Only process chat messages for now
this.deps.eventManager.on<PerceptionSignal>('perception', async (event) => {
const signal = event.payload
// Only handle chat messages in the deliberative layer
if (signal.type !== 'chat_message')
return
this.log('INFO', `Brain: Received chat: ${signal.description}`)
await this.enqueueEvent(bot, event)
})
// Listen to Task Execution Events (Action Feedback)
this.deps.taskExecutor.on('action:completed', async ({ action, result }) => {
@@ -117,8 +117,12 @@ export function createAgentContainer(options: {
}),
// Reflex Manager (Reactive Layer)
reflexManager: asFunction(({ eventBus, logger }) =>
new ReflexManager({ eventBus, logger }),
reflexManager: asFunction(({ eventBus, perceptionPipeline, logger }) =>
new ReflexManager({
eventBus,
perception: perceptionPipeline.getPerceptionAPI(),
logger,
}),
).singleton(),
})
@@ -0,0 +1,148 @@
import type { Logg } from '@guiiai/logg'
import type { Belief, EntityState, EntityView } from '../world/types'
import { BeliefEngine } from '../semantics/belief-engine'
import { teabagPattern } from '../semantics/patterns/teabag'
import { EntityStore } from '../world/entity-store'
import { TemporalBuffer } from '../world/temporal-buffer'
/**
* Unified perception API for upper layers
* Provides entity queries and belief computations
*/
export class PerceptionAPI {
private store: EntityStore
private buffer: TemporalBuffer
private engine: BeliefEngine
constructor(
private readonly deps: {
logger: Logg
},
) {
this.deps.logger.log('PerceptionAPI: initialized')
this.store = new EntityStore()
this.buffer = new TemporalBuffer(5000) // 5s history
this.engine = new BeliefEngine()
// Register default patterns
this.engine.register(teabagPattern)
}
// ============ Entity Updates (from Mineflayer) ============
/**
* Update an entity's state (called by perception collector)
*/
updateEntity(id: string, partial: Partial<EntityState>): void {
const changes = this.store.update(id, partial)
this.buffer.recordAll(changes)
}
/**
* Remove an entity
*/
removeEntity(id: string): void {
this.store.remove(id)
this.buffer.clearEntity(id)
}
/**
* Update self position (for distance calculations)
*/
updateSelfPosition(x: number, y: number, z: number): void {
this.store.updateSelfPosition({ x, y, z } as any)
}
// ============ Entity Queries ============
/**
* Get all player entities with computed beliefs
*/
getPlayers(): EntityView[] {
return this.store.getPlayers().map(e => this.buildEntityView(e))
}
/**
* Get a specific entity by ID
*/
getEntity(id: string): EntityView | null {
const state = this.store.get(id)
if (!state)
return null
return this.buildEntityView(state)
}
// ============ Belief Queries ============
/**
* Find entities with high confidence of a pattern
*/
entitiesWithBelief(pattern: string, minConfidence: number = 0.5): EntityView[] {
return this.getPlayers().filter(e => (e.beliefs[pattern]?.confidence ?? 0) >= minConfidence)
}
/**
* Get the top belief for an entity
*/
getTopBelief(entityId: string): { pattern: string, belief: Belief } | null {
const entity = this.getEntity(entityId)
if (!entity)
return null
let top: { pattern: string, belief: Belief } | null = null
for (const [pattern, belief] of Object.entries(entity.beliefs)) {
if (!top || belief.confidence > top.belief.confidence) {
top = { pattern, belief }
}
}
return top
}
// ============ Pattern Management ============
/**
* Register a custom pattern
*/
registerPattern(pattern: Parameters<BeliefEngine['register']>[0]): void {
this.engine.register(pattern)
}
// ============ Maintenance ============
/**
* Prune old history entries
*/
prune(): void {
this.buffer.prune()
}
/**
* Clear all state
*/
clear(): void {
this.store.clear()
this.buffer.clear()
}
// ============ Internal ============
private buildEntityView(state: EntityState): EntityView {
const beliefs = this.engine.computeBeliefs(
state.id,
id => this.store.get(id),
(id, since) => this.buffer.query(id, since),
this.store.getSelfPosition(),
)
return {
id: state.id,
name: state.name ?? state.id,
type: state.type,
state,
beliefs,
distanceToSelf: this.store.distanceToSelf(state.id) ?? Infinity,
}
}
}
@@ -11,10 +11,12 @@ import type { PerceptionStage } from './types/stage'
import { DebugService } from '../../debug'
import { createPerceptionFrameFromRawEvent } from './frame'
import { MineflayerPerceptionCollector } from './mineflayer-perception-collector'
import { PerceptionAPI } from './perception-api'
import { SaliencyDetector } from './saliency-detector'
export class PerceptionPipeline {
private readonly detector: SaliencyDetector
private readonly perception: PerceptionAPI
private collector: MineflayerPerceptionCollector | null = null
private initialized = false
@@ -31,6 +33,8 @@ export class PerceptionPipeline {
logger: Logg
},
) {
this.perception = new PerceptionAPI({ logger: this.deps.logger })
this.detector = new SaliencyDetector({
logger: this.deps.logger,
onAttention: (signal) => {
@@ -44,6 +48,31 @@ export class PerceptionPipeline {
})
this.stages = [
{
name: 'entity_update',
handle: (frame) => {
if (frame.kind !== 'world_raw')
return frame
const raw = frame.raw as RawPerceptionEvent
// Feed entity updates to PerceptionAPI
if ('entityId' in raw && 'entityType' in raw) {
const entityRaw = raw as RawPerceptionEvent & { entityId: string, entityType: string, displayName?: string, pos?: { x: number, y: number, z: number } }
if (entityRaw.entityType === 'player') {
this.perception.updateEntity(entityRaw.entityId, {
id: entityRaw.entityId,
type: 'player',
name: entityRaw.displayName,
position: entityRaw.pos as any,
isSneaking: 'sneaking' in entityRaw ? (entityRaw as any).sneaking : undefined,
})
}
}
return frame
},
},
{
name: 'attention',
handle: (frame) => {
@@ -152,6 +181,13 @@ export class PerceptionPipeline {
this.initialized = false
}
/**
* Get the PerceptionAPI for querying entity beliefs
*/
public getPerceptionAPI(): PerceptionAPI {
return this.perception
}
public ingest(frame: PerceptionFrame): void {
if (!this.initialized)
return
@@ -5,22 +5,24 @@ export const teabagBehavior: ReflexBehavior = {
modes: ['social', 'idle'],
cooldownMs: 5000,
when: (ctx) => {
// Check if we recently received a teabag signal
// Check if we recently received a teabag signal
if (ctx.social.lastGesture === 'teabag') {
const now = Date.now()
const signalAge = now - (ctx.social.lastGestureAt || 0)
// Only respond if signal is fresh (< 2s)
return signalAge < 2000
}
return false
when: (_ctx, api) => {
// Check if any player is teabagging with high confidence
if (!api?.perception)
return false
const teabaggers = api.perception.entitiesWithBelief('teabag', 0.6)
return teabaggers.length > 0
},
score: () => {
// Higher priority than LookAt (50)
return 60
score: (_ctx, api) => {
// Higher priority than LookAt (50), scaled by confidence
if (!api?.perception)
return 0
const teabaggers = api.perception.entitiesWithBelief('teabag', 0.6)
if (teabaggers.length === 0)
return 0
// Use highest confidence as score boost
const maxConfidence = Math.max(...teabaggers.map(e => e.beliefs.teabag?.confidence ?? 0))
return 60 + (maxConfidence * 20)
},
run: async ({ bot }) => {
@@ -43,7 +43,14 @@ describe('reflexManager', () => {
} as any
const logger = makeLogger()
const reflex = new ReflexManager({ eventBus, logger }) // Now accepts eventBus
const perception = {
getPlayers: vi.fn(() => []),
getEntity: vi.fn(() => null),
entitiesWithBelief: vi.fn(() => []),
updateEntity: vi.fn(),
updateSelfPosition: vi.fn(),
} as any
const reflex = new ReflexManager({ eventBus, perception, logger })
const bot = makeBot()
reflex.init(bot)
@@ -1,6 +1,7 @@
import type { Logg } from '@guiiai/logg'
import type { EventBus, TracedEvent } from '../os'
import type { PerceptionAPI } from '../perception/perception-api'
import type { PerceptionSignal } from '../perception/types/signals'
import type { MineflayerWithAgents } from '../types'
import type { ReflexContextState } from './context'
@@ -19,6 +20,7 @@ export class ReflexManager {
constructor(
private readonly deps: {
eventBus: EventBus
perception: PerceptionAPI
logger: Logg
},
) {
@@ -86,7 +88,7 @@ export class ReflexManager {
// For greeting behavior compatibility, we might need to map specific signals to social state.
// Trigger behavior selection
this.runtime.tick(bot, 0)
this.runtime.tick(bot, 0, this.deps.perception)
// Emit reflex state for observability
DebugService.getInstance().emitReflexState({
@@ -1,5 +1,6 @@
import type { Logg } from '@guiiai/logg'
import type { PerceptionAPI } from '../perception/perception-api'
import type { MineflayerWithAgents } from '../types'
import type { ReflexModeId } from './modes'
import type { ReflexBehavior } from './types/behavior'
@@ -38,7 +39,7 @@ export class ReflexRuntime {
this.behaviors.push(behavior)
}
public tick(bot: MineflayerWithAgents, deltaMs: number): string | null {
public tick(bot: MineflayerWithAgents, deltaMs: number, perception: PerceptionAPI): string | null {
const now = Date.now()
this.context.updateNow(now)
@@ -73,16 +74,17 @@ export class ReflexRuntime {
this.activeBehaviorUntil = null
const ctx = this.context.getSnapshot()
const api = { bot, context: this.context, perception }
let best: { behavior: ReflexBehavior, score: number } | null = null
for (const behavior of this.behaviors) {
if (!behavior.modes.includes(this.mode))
continue
if (!behavior.when(ctx))
if (!behavior.when(ctx, api))
continue
const score = behavior.score(ctx)
const score = behavior.score(ctx, api)
if (score <= 0)
continue
@@ -102,7 +104,7 @@ export class ReflexRuntime {
this.runHistory.set(best.behavior.id, { lastRunAt: now })
try {
const maybePromise = best.behavior.run({ bot, context: this.context })
const maybePromise = best.behavior.run(api)
if (maybePromise && typeof (maybePromise as any).then === 'function') {
this.activeBehaviorUntil = now + Math.max(deltaMs, 50)
void (maybePromise as Promise<void>).finally(() => {
@@ -1,3 +1,4 @@
import type { PerceptionAPI } from '../../perception/perception-api'
import type { MineflayerWithAgents } from '../../types'
import type { ReflexContext } from '../context'
import type { ReflexModeId } from '../modes'
@@ -5,14 +6,15 @@ import type { ReflexModeId } from '../modes'
export interface ReflexApi {
bot: MineflayerWithAgents
context: ReflexContext
perception: PerceptionAPI
}
export interface ReflexBehavior {
id: string
modes: ReflexModeId[]
cooldownMs?: number
when: (ctx: ReturnType<ReflexContext['getSnapshot']>) => boolean
score: (ctx: ReturnType<ReflexContext['getSnapshot']>) => number
when: (ctx: ReturnType<ReflexContext['getSnapshot']>, api?: ReflexApi) => boolean
score: (ctx: ReturnType<ReflexContext['getSnapshot']>, api?: ReflexApi) => number
run: (api: ReflexApi) => Promise<void> | void
}
@@ -0,0 +1,77 @@
import type { Vec3 } from 'vec3'
import type { Belief, EntityState, PatternDefinition, StateChange } from '../world/types'
/**
* Computes beliefs about entity behaviors based on registered patterns
*/
export class BeliefEngine {
private patterns: Map<string, PatternDefinition> = new Map()
/**
* Register a pattern
*/
register(pattern: PatternDefinition): void {
this.patterns.set(pattern.id, pattern)
}
/**
* Unregister a pattern
*/
unregister(id: string): void {
this.patterns.delete(id)
}
/**
* Get all registered pattern IDs
*/
getPatternIds(): string[] {
return Array.from(this.patterns.keys())
}
/**
* Compute all beliefs for an entity
*/
computeBeliefs(
entityId: string,
getState: (id: string) => EntityState | null,
getHistory: (id: string, since: number) => StateChange[],
selfPosition: Vec3 | null,
): Record<string, Belief> {
const beliefs: Record<string, Belief> = {}
for (const [id, pattern] of this.patterns) {
try {
beliefs[id] = pattern.compute(entityId, getState, getHistory, selfPosition)
}
catch {
// Pattern threw an error, treat as no belief
beliefs[id] = { confidence: 0 }
}
}
return beliefs
}
/**
* Compute a single belief for an entity
*/
computeBelief(
patternId: string,
entityId: string,
getState: (id: string) => EntityState | null,
getHistory: (id: string, since: number) => StateChange[],
selfPosition: Vec3 | null,
): Belief | null {
const pattern = this.patterns.get(patternId)
if (!pattern)
return null
try {
return pattern.compute(entityId, getState, getHistory, selfPosition)
}
catch {
return { confidence: 0 }
}
}
}
@@ -0,0 +1,2 @@
export { BeliefEngine } from './belief-engine'
export { teabagPattern } from './patterns/teabag'
@@ -0,0 +1,56 @@
import type { PatternDefinition } from '../../world/types'
/**
* Teabag pattern: detects rapid crouching behavior
*/
export const teabagPattern: PatternDefinition = {
id: 'teabag',
category: 'social',
description: 'Rapid crouching, typically a greeting or taunt',
compute(entityId, getState, getHistory, selfPosition) {
// Get sneaking state changes in the last 2 seconds
const since = Date.now() - 2000
const changes = getHistory(entityId, since).filter(c => c.field === 'isSneaking')
// Need at least 4 toggles (2 full crouch cycles)
if (changes.length < 4) {
return { confidence: 0 }
}
// Check distance if we have self position
const entity = getState(entityId)
if (entity && selfPosition) {
const dx = entity.position.x - selfPosition.x
const dy = entity.position.y - selfPosition.y
const dz = entity.position.z - selfPosition.z
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz)
// Too far away, reduce confidence
if (distance > 15) {
return { confidence: 0, data: { toggles: changes.length, distance } }
}
}
// Calculate frequency (toggles per second)
const duration = (changes[changes.length - 1].timestamp - changes[0].timestamp) / 1000
const frequency = duration > 0 ? changes.length / duration : 0
// Confidence based on:
// - Number of toggles (more = more confident, up to 8)
// - Frequency (faster = more confident, up to 4 Hz)
const countFactor = Math.min(1, changes.length / 8)
const frequencyFactor = Math.min(1, frequency / 4)
const confidence = (countFactor * 0.4) + (frequencyFactor * 0.6)
return {
confidence,
data: {
toggles: changes.length,
frequency: Math.round(frequency * 100) / 100,
duration: Math.round(duration * 1000),
},
}
},
}
@@ -0,0 +1,126 @@
import type { Vec3 } from 'vec3'
import type { EntityState, StateChange } from './types'
/**
* Tracks the state of all known entities
*/
export class EntityStore {
private entities: Map<string, EntityState> = new Map()
private selfPosition: Vec3 | null = null
/**
* Update or create an entity's state
* Returns list of state changes that occurred
*/
update(id: string, partial: Partial<EntityState>): StateChange[] {
const now = Date.now()
const changes: StateChange[] = []
const existing = this.entities.get(id)
if (!existing) {
// New entity
const newState: EntityState = {
id,
type: partial.type ?? 'player',
name: partial.name,
position: partial.position ?? { x: 0, y: 0, z: 0 } as Vec3,
velocity: partial.velocity ?? { x: 0, y: 0, z: 0 } as Vec3,
yaw: partial.yaw ?? 0,
pitch: partial.pitch ?? 0,
isSneaking: partial.isSneaking ?? false,
isSprinting: partial.isSprinting ?? false,
onGround: partial.onGround ?? true,
firstSeen: now,
lastUpdate: now,
}
this.entities.set(id, newState)
return changes
}
// Track changes to relevant fields
const trackedFields: (keyof EntityState)[] = ['isSneaking', 'isSprinting', 'onGround']
for (const field of trackedFields) {
if (field in partial && partial[field] !== existing[field]) {
changes.push({
entityId: id,
field,
from: existing[field],
to: partial[field],
timestamp: now,
})
}
}
// Apply updates
Object.assign(existing, partial, { lastUpdate: now })
return changes
}
/**
* Get entity by ID
*/
get(id: string): EntityState | null {
return this.entities.get(id) ?? null
}
/**
* Get all player entities
*/
getPlayers(): EntityState[] {
return Array.from(this.entities.values()).filter(e => e.type === 'player')
}
/**
* Get all entity IDs
*/
getAllIds(): string[] {
return Array.from(this.entities.keys())
}
/**
* Remove an entity
*/
remove(id: string): void {
this.entities.delete(id)
}
/**
* Update self (bot) position for distance calculations
*/
updateSelfPosition(pos: Vec3): void {
this.selfPosition = pos
}
/**
* Get self position
*/
getSelfPosition(): Vec3 | null {
return this.selfPosition
}
/**
* Calculate distance from self to entity
*/
distanceToSelf(id: string): number | null {
if (!this.selfPosition)
return null
const entity = this.entities.get(id)
if (!entity)
return null
const dx = entity.position.x - this.selfPosition.x
const dy = entity.position.y - this.selfPosition.y
const dz = entity.position.z - this.selfPosition.z
return Math.sqrt(dx * dx + dy * dy + dz * dz)
}
/**
* Clear all entities
*/
clear(): void {
this.entities.clear()
this.selfPosition = null
}
}
@@ -0,0 +1,3 @@
export { EntityStore } from './entity-store'
export { TemporalBuffer } from './temporal-buffer'
export * from './types'
@@ -0,0 +1,85 @@
import type { StateChange } from './types'
/**
* Rolling window buffer of state changes per entity
*/
export class TemporalBuffer {
private buffer: Map<string, StateChange[]> = new Map()
private maxAge: number
constructor(maxAgeMs: number = 5000) {
this.maxAge = maxAgeMs
}
/**
* Record a state change
*/
record(change: StateChange): void {
const list = this.buffer.get(change.entityId) ?? []
list.push(change)
this.buffer.set(change.entityId, list)
}
/**
* Record multiple state changes
*/
recordAll(changes: StateChange[]): void {
for (const change of changes) {
this.record(change)
}
}
/**
* Query changes for an entity since a given timestamp
*/
query(entityId: string, since: number): StateChange[] {
const list = this.buffer.get(entityId) ?? []
return list.filter(c => c.timestamp >= since)
}
/**
* Query changes for an entity by field
*/
queryField(entityId: string, field: string, since: number): StateChange[] {
return this.query(entityId, since).filter(c => c.field === field)
}
/**
* Get all changes for an entity (within max age)
*/
getAll(entityId: string): StateChange[] {
const cutoff = Date.now() - this.maxAge
return this.query(entityId, cutoff)
}
/**
* Prune old entries from all buffers
*/
prune(): void {
const cutoff = Date.now() - this.maxAge
for (const [entityId, list] of this.buffer.entries()) {
const filtered = list.filter(c => c.timestamp >= cutoff)
if (filtered.length === 0) {
this.buffer.delete(entityId)
}
else {
this.buffer.set(entityId, filtered)
}
}
}
/**
* Clear all history for an entity
*/
clearEntity(entityId: string): void {
this.buffer.delete(entityId)
}
/**
* Clear all buffers
*/
clear(): void {
this.buffer.clear()
}
}
@@ -0,0 +1,72 @@
import type { Vec3 } from 'vec3'
/**
* State of an entity at a point in time
*/
export interface EntityState {
id: string
type: 'player' | 'mob' | 'item'
name?: string
// Position & Movement
position: Vec3
velocity: Vec3
yaw: number
pitch: number
// Status flags
isSneaking: boolean
isSprinting: boolean
onGround: boolean
// Timestamps
firstSeen: number
lastUpdate: number
}
/**
* A change in entity state
*/
export interface StateChange {
entityId: string
field: keyof EntityState
from: unknown
to: unknown
timestamp: number
}
/**
* Belief about an entity's behavior
*/
export interface Belief {
confidence: number // 0-1
data?: Record<string, unknown>
}
/**
* Definition of a pattern that computes beliefs
*/
export interface PatternDefinition {
id: string
category: 'social' | 'spatial' | 'threat' | 'neutral'
description: string
compute: (
entityId: string,
getState: (id: string) => EntityState | null,
getHistory: (id: string, since: number) => StateChange[],
selfPosition: Vec3 | null,
) => Belief
}
/**
* View of an entity for upper layers
*/
export interface EntityView {
id: string
name: string
type: EntityState['type']
state: EntityState
beliefs: Record<string, Belief>
distanceToSelf: number
}