Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions packages/bot/src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
* @see @bot-orchestration
*/

import { EventEmitter } from 'node:events';
import { execSync } from 'node:child_process';
import path from 'node:path';
import { createLogger, type NormalizedMessage } from '@kynetic-bot/core';
import { createLogger, TypedEventEmitter, type NormalizedMessage } from '@kynetic-bot/core';
import { ChannelRegistry, ChannelLifecycle, StreamingSplitTracker } from '@kynetic-bot/channels';
import {
AgentLifecycle,
Expand All @@ -33,6 +32,7 @@ import {
type SummaryProvider,
type StderrProvider,
type SessionLifecycleEvents,
type SessionState,
} from '@kynetic-bot/messaging';
import {
KbotShadow,
Expand Down Expand Up @@ -120,7 +120,8 @@ export interface EscalationContext {
* @trait-observable - Bot emits events for message lifecycle, errors, state changes,
* session management, and tool execution.
*/
export interface BotEvents {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Index signature required for TypedEventEmitter compatibility
export interface BotEvents extends Record<string, (...args: any[]) => void> {
/** Message received from channel, before processing */
'message:received': (msg: NormalizedMessage) => void;

Expand Down Expand Up @@ -175,21 +176,24 @@ export interface BotEvents {
'agent:state': (from: string, to: string) => void;

/** New session created */
'session:created': (data: { sessionKey: string; acpSessionId: string }) => void;
'session:created': (data: { sessionKey: string; state: SessionState }) => void;

/** Session rotated due to context limits */
'session:rotated': (data: {
sessionKey: string;
oldSessionId: string;
newSessionId: string;
reason: string;
newState: SessionState;
}) => void;

/** Session recovered after agent restart */
'session:recovered': (data: { sessionKey: string; acpSessionId: string }) => void;
'session:recovered': (data: {
sessionKey: string;
state: SessionState;
fromConversationId: string;
}) => void;

/** Context restoration failed during session recovery */
'session:restore:error': (data: { sessionKey: string; error: string }) => void;
'session:restore:error': (data: { sessionKey: string; error: string | Error }) => void;

/** Context usage tracking error */
'usage:error': (data: unknown) => void;
Expand Down Expand Up @@ -239,7 +243,7 @@ export interface BotOptions {
* @trait-graceful-shutdown - Drains messages before stopping
* @trait-health-monitored - Delegates to AgentLifecycle health monitoring
*/
export class Bot extends EventEmitter {
export class Bot extends TypedEventEmitter<BotEvents> {
private state: BotState = 'idle';
private readonly config: BotConfig;
private readonly registry: ChannelRegistry;
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ export { parseSessionKey, buildSessionKey, isValidSessionKey } from './utils/ses

// Utilities - Errors
export { KyneticError, UnknownAgentError, InvalidSessionKeyError } from './utils/errors.js';

// Utilities - Typed Event Emitter
export { TypedEventEmitter } from './utils/typed-event-emitter.js';
143 changes: 143 additions & 0 deletions packages/core/src/utils/typed-event-emitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* TypedEventEmitter - Type-safe event emitter
*
* Provides compile-time type safety for event names and payloads.
* Extends Node.js EventEmitter with typed emit/on/off/once methods.
*
* @example
* ```typescript
* interface MyEvents {
* 'user:login': (userId: string, timestamp: Date) => void;
* 'user:logout': (userId: string) => void;
* error: (error: Error) => void;
* }
*
* class MyClass extends TypedEventEmitter<MyEvents> {
* doSomething() {
* // Type-safe: correct event name and parameters
* this.emit('user:login', 'user123', new Date());
*
* // Compile error: invalid event name
* this.emit('invalid', 'data');
*
* // Compile error: wrong parameter types
* this.emit('user:login', 123, 'not-a-date');
* }
* }
*
* const instance = new MyClass();
* // Type-safe listener
* instance.on('user:login', (userId, timestamp) => {
* // userId is inferred as string
* // timestamp is inferred as Date
* });
* ```
*/

import { EventEmitter } from 'node:events';

/**
* Type helper to extract event names from event map
*/
type EventNames<T> = keyof T & (string | symbol);

/**
* Type helper to extract parameters from event handler
*/
type EventParams<T, K extends EventNames<T>> = T[K] extends (...args: infer P) => void ? P : never;

/**
* Type-safe EventEmitter base class
*
* Generic parameter T should be an interface mapping event names to handler signatures:
* ```typescript
* interface MyEvents {
* 'event:name': (param1: Type1, param2: Type2) => void;
* }
* ```
*/
export class TypedEventEmitter<
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic constraint requires any for maximum flexibility
T extends Record<string, (...args: any[]) => void>,
> extends EventEmitter {
/**
* Emit a typed event
*
* @param event - Event name (type-checked against T)
* @param args - Event arguments (type-checked against handler signature)
* @returns true if event had listeners, false otherwise
*/
emit<K extends EventNames<T>>(event: K, ...args: EventParams<T, K>): boolean {
return super.emit(event, ...args);
}

/**
* Add a typed event listener
*
* @param event - Event name (type-checked against T)
* @param listener - Event handler (type-checked against handler signature)
* @returns this (for chaining)
*/
on<K extends EventNames<T>>(event: K, listener: T[K]): this;
// Overload for backward compatibility with generic EventEmitter interfaces
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Backward compatibility with untyped EventEmitter requires any
on(event: string, listener: (...args: any[]) => void): this;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Implementation signature requires any for overload resolution
on(event: any, listener: any): this {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- Overload implementation requires passing any to super
return super.on(event, listener);
}

/**
* Add a one-time typed event listener
*
* @param event - Event name (type-checked against T)
* @param listener - Event handler (type-checked against handler signature)
* @returns this (for chaining)
*/
once<K extends EventNames<T>>(event: K, listener: T[K]): this {
return super.once(event, listener);
}

/**
* Remove a typed event listener
*
* @param event - Event name (type-checked against T)
* @param listener - Event handler (type-checked against handler signature)
* @returns this (for chaining)
*/
off<K extends EventNames<T>>(event: K, listener: T[K]): this {
return super.off(event, listener);
}

/**
* Remove all listeners for a typed event, or all listeners if no event specified
*
* @param event - Optional event name (type-checked against T)
* @returns this (for chaining)
*/
removeAllListeners<K extends EventNames<T>>(event?: K): this {
return super.removeAllListeners(event);
}

/**
* Get all listeners for a typed event
*
* @param event - Event name (type-checked against T)
* @returns Array of listeners
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- EventEmitter.listeners() returns Function[]
listeners<K extends EventNames<T>>(event: K): Function[] {
return super.listeners(event);
}

/**
* Get listener count for a typed event
*
* @param event - Event name (type-checked against T)
* @returns Number of listeners
*/
listenerCount<K extends EventNames<T>>(event: K): number {
return super.listenerCount(event);
}
}