agent-world 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +42 -5
  2. package/dist/cli/index.js +550 -145
  3. package/package.json +19 -8
package/README.md CHANGED
@@ -149,13 +149,50 @@ export OLLAMA_BASE_URL="http://localhost:11434"
149
149
 
150
150
  Or create a `.env` file in your working directory with:
151
151
 
152
- ```env
153
- OPENAI_API_KEY=your-key-here
154
- ANTHROPIC_API_KEY=your-key-here
155
- GOOGLE_API_KEY=your-key-here
156
- OLLAMA_BASE_URL=http://localhost:11434
152
+ ## Testing
153
+
154
+ **Run all tests:**
155
+ ```bash
156
+ npm test # Run all unit tests
157
+ npm run test:watch # Watch mode with hot reload
158
+ npm run test:ui # Visual test UI
159
+ npm run test:coverage # Generate coverage report
160
+ ```
161
+
162
+ **Run specific tests:**
163
+ ```bash
164
+ npm test -- tests/core/events/ # Test a directory
165
+ npm test -- message-saving # Test files matching pattern
166
+ ```
167
+
168
+ **Integration tests:**
169
+ ```bash
170
+ npm run test:integration # Run integration tests with real filesystem
171
+ ```
172
+
173
+ Agent World uses Vitest for fast, modern testing with native TypeScript support.
174
+
175
+ ## Logging and Debugging
176
+
177
+ Agent World uses **scenario-based logging** to help you debug specific issues without noise. Enable only the logs you need for your current task.
178
+
179
+ ### Quick Examples
180
+
181
+ ```bash
182
+ # Database migration issues
183
+ LOG_STORAGE_MIGRATION=info npm run server
184
+
185
+ # MCP server problems
186
+ LOG_MCP=debug npm run server
187
+
188
+ # Agent response debugging
189
+ LOG_EVENTS_AGENT=debug LOG_LLM=debug npm run server
157
190
  ```
158
191
 
192
+ **For complete logging documentation**, see [Logging Guide](docs/logging-guide.md).
193
+
194
+ ## Learn More
195
+
159
196
  ### World Database Setup
160
197
 
161
198
  The worlds are stored in the SQLite database under the `~/agent-world` directory. You can change the database path by setting the environment variable `AGENT_WORLD_SQLITE_DATABASE`.
package/dist/cli/index.js CHANGED
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ /*
3
+ * cli/index.ts
4
+ * Summary: CLI entrypoint and interactive world subscription logic with event-driven prompt display.
5
+ * Implementation: Uses subscribeWorld to obtain a managed WorldSubscription, then subscribes directly to world.eventEmitter for CLI-specific handling.
6
+ * Architecture: Event-driven prompt display using world activity events instead of timers.
7
+ */
2
8
  // Load environment variables from .env file
3
9
  import dotenv from 'dotenv';
4
10
  dotenv.config({ quiet: true });
@@ -9,13 +15,18 @@ dotenv.config({ quiet: true });
9
15
  * real-time streaming, and comprehensive world management.
10
16
  *
11
17
  * FEATURES:
12
- * - Pipeline Mode: Execute commands and exit with timer-based cleanup
18
+ * - Pipeline Mode: Execute commands and exit with pure event-driven completion tracking
19
+ * - Uses world activity events (response-start, response-end, idle) for completion detection
20
+ * - No visible activity progress (clean output for scripting)
21
+ * - Extended timeout (120s) with quick exit on no activity (2s)
22
+ * - Silent timeout handling (no error messages for clean pipelines)
13
23
  * - Interactive Mode: Real-time console interface with streaming responses
24
+ * - Full activity progress display with world events, tool execution, and streaming
25
+ * - Event-driven prompt display using world idle events
14
26
  * - Unified Subscription: Both modes use subscribeWorld for consistent event handling
15
27
  * - World Management: Auto-discovery and interactive selection
16
- * - Real-time Streaming: Live agent responses via stream.ts module
28
+ * - Real-time Streaming: Live agent responses via stream.ts module (interactive only)
17
29
  * - Color Helpers: Consistent styling with simplified color functions
18
- * - Timer Management: Smart prompt restoration and exit handling
19
30
  * - Debug Logging: Configurable log levels using core logger module
20
31
  * - Environment Variables: Automatically loads .env file for API keys and configuration
21
32
  *
@@ -26,6 +37,8 @@ dotenv.config({ quiet: true });
26
37
  * - Uses readline for interactive input with proper cleanup
27
38
  * - Delegates streaming display to stream.ts module for real-time chunk accumulation
28
39
  * - Uses core logger for structured debug logging with configurable levels
40
+ * - Event-driven prompt display: listens to world 'idle' events instead of using timers
41
+ * - WorldActivityMonitor tracks agent processing and signals when world becomes idle
29
42
  *
30
43
  * USAGE:
31
44
  * Pipeline: cli --root /data/worlds --world myworld --command "/clear agent1"
@@ -39,24 +52,17 @@ import { fileURLToPath } from 'url';
39
52
  import { program } from 'commander';
40
53
  import readline from 'readline';
41
54
  import { listWorlds, subscribeWorld, createCategoryLogger, LLMProvider, enableStreaming, disableStreaming } from '../core/index.js';
55
+ import { EventType } from '../core/types.js';
42
56
  import { getDefaultRootPath } from '../core/storage/storage-factory.js';
43
- import { processCLIInput } from './commands.js';
44
- import { createStreamingState, handleWorldEventWithStreaming, } from './stream.js';
57
+ import { processCLIInput, displayChatMessages } from './commands.js';
58
+ import { createStreamingState, handleWorldEventWithStreaming, handleToolEvents, handleActivityEvents, } from './stream.js';
45
59
  import { configureLLMProvider } from '../core/llm-config.js';
46
60
  // Create CLI category logger after logger auto-initialization
47
61
  const logger = createCategoryLogger('cli');
48
- function setupPromptTimer(globalState, rl, callback, delay = 2000) {
49
- clearPromptTimer(globalState);
50
- globalState.promptTimer = setTimeout(callback, delay);
51
- }
52
- function clearPromptTimer(globalState) {
53
- if (globalState.promptTimer) {
54
- clearTimeout(globalState.promptTimer);
55
- globalState.promptTimer = undefined;
56
- }
57
- }
58
62
  function createGlobalState() {
59
- return {};
63
+ return {
64
+ awaitingResponse: false
65
+ };
60
66
  }
61
67
  // Color helpers - consolidated styling API
62
68
  const red = (text) => `\x1b[31m${text}\x1b[0m`;
@@ -76,6 +82,175 @@ const boldCyan = (text) => `\x1b[1m\x1b[36m${text}\x1b[0m`;
76
82
  const success = (text) => `${boldGreen('✓')} ${text}`;
77
83
  const error = (text) => `${boldRed('✗')} ${text}`;
78
84
  const bullet = (text) => `${gray('•')} ${text}`;
85
+ class WorldActivityMonitor {
86
+ lastEvent = null;
87
+ waiters = new Set();
88
+ captureSnapshot() {
89
+ return {
90
+ activityId: this.lastEvent?.activityId ?? 0,
91
+ type: this.lastEvent?.type ?? null
92
+ };
93
+ }
94
+ handle(event) {
95
+ // Check for valid event types
96
+ if (!event || (event.type !== 'response-start' && event.type !== 'response-end' && event.type !== 'idle')) {
97
+ return;
98
+ }
99
+ const timestampMsRaw = event.timestamp ? Date.parse(event.timestamp) : Date.now();
100
+ const timestampMs = Number.isFinite(timestampMsRaw) ? timestampMsRaw : Date.now();
101
+ this.lastEvent = {
102
+ ...event,
103
+ timestampMs
104
+ };
105
+ for (const waiter of Array.from(this.waiters)) {
106
+ // Track when we see response-start after the target activity
107
+ if (event.type === 'response-start' && event.activityId > waiter.activityId) {
108
+ waiter.seenProcessing = true;
109
+ if (waiter.noActivityTimeoutId) {
110
+ clearTimeout(waiter.noActivityTimeoutId);
111
+ waiter.noActivityTimeoutId = undefined;
112
+ }
113
+ }
114
+ // Resolve waiter on idle event if conditions are met
115
+ if (event.type === 'idle') {
116
+ const shouldResolve = event.activityId > waiter.activityId ||
117
+ (event.activityId === waiter.activityId && waiter.seenProcessing);
118
+ if (shouldResolve) {
119
+ this.finishWaiter(waiter, true);
120
+ }
121
+ }
122
+ }
123
+ }
124
+ async waitForIdle(options = {}) {
125
+ const { snapshot = this.captureSnapshot(), timeoutMs = 120_000, // Default 2 minutes for complex operations
126
+ noActivityTimeoutMs = 2_000 // Default 2 seconds for quick exit
127
+ } = options;
128
+ const targetActivityId = snapshot.activityId;
129
+ // Already idle after target activity
130
+ if (this.lastEvent && this.lastEvent.type === 'idle' && this.lastEvent.activityId > targetActivityId) {
131
+ return;
132
+ }
133
+ return new Promise((resolve, reject) => {
134
+ const waiter = {
135
+ activityId: targetActivityId,
136
+ resolveCallback: () => {
137
+ cleanup();
138
+ resolve();
139
+ },
140
+ rejectCallback: (error) => {
141
+ cleanup();
142
+ reject(error);
143
+ },
144
+ seenProcessing: false
145
+ };
146
+ const cleanup = () => {
147
+ if (waiter.timeoutId) {
148
+ clearTimeout(waiter.timeoutId);
149
+ waiter.timeoutId = undefined;
150
+ }
151
+ if (waiter.noActivityTimeoutId) {
152
+ clearTimeout(waiter.noActivityTimeoutId);
153
+ waiter.noActivityTimeoutId = undefined;
154
+ }
155
+ this.waiters.delete(waiter);
156
+ };
157
+ const last = this.lastEvent;
158
+ if (!last) {
159
+ waiter.noActivityTimeoutId = setTimeout(() => this.finishWaiter(waiter, true), noActivityTimeoutMs);
160
+ }
161
+ else {
162
+ // Track if we've seen response-start after target activity
163
+ if (last.type === 'response-start' && last.activityId > targetActivityId) {
164
+ waiter.seenProcessing = true;
165
+ }
166
+ // If already idle at target activity, set short timeout
167
+ if (last.type === 'idle' && last.activityId === targetActivityId) {
168
+ waiter.noActivityTimeoutId = setTimeout(() => this.finishWaiter(waiter, true), noActivityTimeoutMs);
169
+ }
170
+ // If already idle after target activity, resolve immediately
171
+ if (last.type === 'idle' && last.activityId > targetActivityId) {
172
+ resolve();
173
+ return;
174
+ }
175
+ }
176
+ waiter.timeoutId = setTimeout(() => {
177
+ this.finishWaiter(waiter, false, new Error('Timed out waiting for world to become idle'));
178
+ }, timeoutMs);
179
+ this.waiters.add(waiter);
180
+ });
181
+ }
182
+ reset(reason = 'World subscription reset') {
183
+ const error = new Error(reason);
184
+ for (const waiter of Array.from(this.waiters)) {
185
+ this.finishWaiter(waiter, false, error);
186
+ }
187
+ this.lastEvent = null;
188
+ }
189
+ getActiveSources() {
190
+ return this.lastEvent?.activeSources ?? [];
191
+ }
192
+ finishWaiter(waiter, resolve, error) {
193
+ if (!this.waiters.has(waiter)) {
194
+ return;
195
+ }
196
+ if (waiter.timeoutId) {
197
+ clearTimeout(waiter.timeoutId);
198
+ waiter.timeoutId = undefined;
199
+ }
200
+ if (waiter.noActivityTimeoutId) {
201
+ clearTimeout(waiter.noActivityTimeoutId);
202
+ waiter.noActivityTimeoutId = undefined;
203
+ }
204
+ this.waiters.delete(waiter);
205
+ if (resolve) {
206
+ waiter.resolveCallback();
207
+ }
208
+ else {
209
+ waiter.rejectCallback(error ?? new Error('World activity waiter cancelled'));
210
+ }
211
+ }
212
+ }
213
+ function parseActivitySource(source) {
214
+ if (!source)
215
+ return null;
216
+ if (source.startsWith('agent:')) {
217
+ return { type: 'agent', name: source.slice('agent:'.length) };
218
+ }
219
+ if (source.startsWith('message:')) {
220
+ return { type: 'message', name: source.slice('message:'.length) };
221
+ }
222
+ return null;
223
+ }
224
+ class ActivityProgressRenderer {
225
+ activeAgents = new Set();
226
+ handle(event) {
227
+ if (!event)
228
+ return;
229
+ // Reset on idle
230
+ if (event.type === 'idle') {
231
+ this.reset();
232
+ return;
233
+ }
234
+ const details = parseActivitySource(event.source);
235
+ if (!details || details.type !== 'agent') {
236
+ return;
237
+ }
238
+ // Track agent start on response-start
239
+ if (event.type === 'response-start' && !this.activeAgents.has(details.name)) {
240
+ this.activeAgents.add(details.name);
241
+ }
242
+ // Track agent end on response-end
243
+ if (event.type === 'response-end' && this.activeAgents.has(details.name)) {
244
+ this.activeAgents.delete(details.name);
245
+ }
246
+ }
247
+ reset() {
248
+ if (this.activeAgents.size > 0) {
249
+ this.activeAgents.clear();
250
+ // console.log(gray('All agents finished.'));
251
+ }
252
+ }
253
+ }
79
254
  // LLM Provider configuration from environment variables
80
255
  function configureLLMProvidersFromEnv() {
81
256
  // OpenAI
@@ -156,53 +331,105 @@ function printCLIResult(result) {
156
331
  console.log(error(result.error));
157
332
  }
158
333
  }
159
- // Pipeline mode execution with timer-based cleanup
334
+ /**
335
+ * Attach CLI event listeners to world EventEmitter
336
+ *
337
+ * @param world - World instance to attach listeners to
338
+ * @param streaming - Streaming state for interactive mode (null for pipeline mode)
339
+ * @param globalState - Global state for interactive mode (null for pipeline mode)
340
+ * @param activityMonitor - Activity monitor for tracking world events
341
+ * @param progressRenderer - Progress renderer for displaying activity
342
+ * @param rl - Readline interface for interactive mode (undefined for pipeline mode)
343
+ * @returns Map of event types to listener functions for cleanup
344
+ */
345
+ function attachCLIListeners(world, streaming, globalState, activityMonitor, progressRenderer, rl) {
346
+ const listeners = new Map();
347
+ // World activity events
348
+ const worldListener = (eventData) => {
349
+ activityMonitor.handle(eventData);
350
+ // Only render activity progress in interactive mode
351
+ if (streaming && globalState && rl) {
352
+ progressRenderer.handle(eventData);
353
+ handleWorldEvent(EventType.WORLD, eventData, streaming, globalState, activityMonitor, progressRenderer, rl);
354
+ }
355
+ // Pipeline mode: silently track events for completion detection
356
+ };
357
+ world.eventEmitter.on(EventType.WORLD, worldListener);
358
+ listeners.set(EventType.WORLD, worldListener);
359
+ // Message events
360
+ const messageListener = (eventData) => {
361
+ if (eventData.content && eventData.content.includes('Success message sent'))
362
+ return;
363
+ if (streaming && globalState && rl) {
364
+ handleWorldEvent(EventType.MESSAGE, eventData, streaming, globalState, activityMonitor, progressRenderer, rl);
365
+ }
366
+ else {
367
+ // Pipeline mode: simple console output
368
+ if (eventData.sender === 'system') {
369
+ console.log(`${boldRed('● system:')} ${eventData.content}`);
370
+ }
371
+ if (eventData.content) {
372
+ console.log(`${boldGreen('● ' + (eventData.sender || 'agent') + ':')} ${eventData.content}`);
373
+ }
374
+ }
375
+ };
376
+ world.eventEmitter.on(EventType.MESSAGE, messageListener);
377
+ listeners.set(EventType.MESSAGE, messageListener);
378
+ // SSE events (interactive mode only - pipeline mode uses non-streaming LLM calls)
379
+ if (streaming && globalState && rl) {
380
+ const sseListener = (eventData) => {
381
+ handleWorldEvent(EventType.SSE, eventData, streaming, globalState, activityMonitor, progressRenderer, rl);
382
+ };
383
+ world.eventEmitter.on(EventType.SSE, sseListener);
384
+ listeners.set(EventType.SSE, sseListener);
385
+ }
386
+ // System events
387
+ const systemListener = (eventData) => {
388
+ if (eventData.content && eventData.content.includes('Success message sent'))
389
+ return;
390
+ if (streaming && globalState && rl) {
391
+ handleWorldEvent(EventType.SYSTEM, eventData, streaming, globalState, activityMonitor, progressRenderer, rl);
392
+ }
393
+ else if (eventData.message || eventData.content) {
394
+ // Pipeline mode: system messages are handled by message listener
395
+ }
396
+ };
397
+ world.eventEmitter.on(EventType.SYSTEM, systemListener);
398
+ listeners.set(EventType.SYSTEM, systemListener);
399
+ return listeners;
400
+ }
401
+ /**
402
+ * Cleanup CLI event listeners from world EventEmitter
403
+ *
404
+ * @param world - World instance to remove listeners from
405
+ * @param listeners - Map of event types to listener functions
406
+ */
407
+ function detachCLIListeners(world, listeners) {
408
+ for (const [eventType, listener] of listeners.entries()) {
409
+ world.eventEmitter.removeListener(eventType, listener);
410
+ }
411
+ listeners.clear();
412
+ }
413
+ // Pipeline mode execution with event-driven completion tracking
160
414
  async function runPipelineMode(options, messageFromArgs) {
161
415
  disableStreaming();
416
+ let world = null;
417
+ let worldSubscription = null;
418
+ let cliListeners = null;
419
+ const activityMonitor = new WorldActivityMonitor();
420
+ const progressRenderer = new ActivityProgressRenderer();
162
421
  try {
163
- let world = null;
164
- let worldSubscription = null;
165
- let timeoutId = null;
166
- const pipelineClient = {
167
- isOpen: true,
168
- onWorldEvent: (eventType, eventData) => {
169
- if (eventData.content && eventData.content.includes('Success message sent'))
170
- return;
171
- if ((eventType === 'system' || eventType === 'world') && (eventData.message || eventData.content)) {
172
- // existing logic
173
- }
174
- else if (eventType === 'message' && eventData.sender === 'system') {
175
- const msg = eventData.content;
176
- console.log(`${boldRed('● system:')} ${msg}`);
177
- }
178
- if (eventType === 'sse' && eventData.content) {
179
- setupExitTimer(5000);
180
- }
181
- if (eventType === 'message' && eventData.content) {
182
- console.log(`${boldGreen('● ' + (eventData.sender || 'agent') + ':')} ${eventData.content}`);
183
- setupExitTimer(3000);
184
- }
185
- },
186
- onError: (error) => {
187
- console.log(red(`Error: ${error}`));
188
- }
189
- };
190
- const setupExitTimer = (delay = 2000) => {
191
- if (timeoutId)
192
- clearTimeout(timeoutId);
193
- timeoutId = setTimeout(() => {
194
- if (worldSubscription)
195
- worldSubscription.unsubscribe();
196
- process.exit(0);
197
- }, delay);
198
- };
199
422
  if (options.world) {
200
- worldSubscription = await subscribeWorld(options.world, pipelineClient);
423
+ // Subscribe to world lifecycle but do not request forwarding callbacks
424
+ worldSubscription = await subscribeWorld(options.world, { isOpen: true });
201
425
  if (!worldSubscription) {
202
426
  console.error(boldRed(`Error: World '${options.world}' not found`));
203
427
  process.exit(1);
204
428
  }
205
429
  world = worldSubscription.world;
430
+ // Attach direct listeners to the world.eventEmitter for pipeline handling
431
+ // Note: Pipeline mode uses non-streaming LLM calls, so SSE events are not needed
432
+ cliListeners = attachCLIListeners(world, null, null, activityMonitor, progressRenderer);
206
433
  }
207
434
  // Execute command from --command option
208
435
  if (options.command) {
@@ -210,14 +437,24 @@ async function runPipelineMode(options, messageFromArgs) {
210
437
  console.error(boldRed('Error: World must be specified to send user messages'));
211
438
  process.exit(1);
212
439
  }
213
- const result = await processCLIInput(options.command, world, 'HUMAN');
440
+ const snapshot = activityMonitor.captureSnapshot();
441
+ const result = await processCLIInput(options.command, world, 'human');
214
442
  printCLIResult(result);
215
- // Only set timer if sending message to world (not for commands)
216
443
  if (!options.command.startsWith('/') && world) {
217
- setupExitTimer();
444
+ try {
445
+ // Event-driven completion: wait for world idle state
446
+ await activityMonitor.waitForIdle({
447
+ snapshot,
448
+ timeoutMs: 120_000, // Extended timeout for complex operations
449
+ noActivityTimeoutMs: 2_000 // Quick exit if no activity detected
450
+ });
451
+ }
452
+ catch (error) {
453
+ // Silent exit on timeout - events may have completed
454
+ logger.debug('Pipeline mode completion timeout', { error: error.message });
455
+ }
218
456
  }
219
457
  else {
220
- // For commands, exit immediately after processing
221
458
  if (worldSubscription)
222
459
  worldSubscription.unsubscribe();
223
460
  process.exit(result.success ? 0 : 1);
@@ -233,10 +470,21 @@ async function runPipelineMode(options, messageFromArgs) {
233
470
  console.error(boldRed('Error: World must be specified to send user messages'));
234
471
  process.exit(1);
235
472
  }
236
- const result = await processCLIInput(messageFromArgs, world, 'HUMAN');
473
+ const snapshot = activityMonitor.captureSnapshot();
474
+ const result = await processCLIInput(messageFromArgs, world, 'human');
237
475
  printCLIResult(result);
238
- // Set timer with longer delay for message processing (always needed for messages)
239
- setupExitTimer(8000);
476
+ try {
477
+ // Event-driven completion: wait for world idle state
478
+ await activityMonitor.waitForIdle({
479
+ snapshot,
480
+ timeoutMs: 120_000,
481
+ noActivityTimeoutMs: 2_000
482
+ });
483
+ }
484
+ catch (error) {
485
+ // Silent exit on timeout - events may have completed
486
+ logger.debug('Pipeline mode completion timeout', { error: error.message });
487
+ }
240
488
  if (!result.success) {
241
489
  setTimeout(() => process.exit(1), 100);
242
490
  return;
@@ -253,10 +501,21 @@ async function runPipelineMode(options, messageFromArgs) {
253
501
  console.error(boldRed('Error: World must be specified to send user messages'));
254
502
  process.exit(1);
255
503
  }
504
+ const snapshot = activityMonitor.captureSnapshot();
256
505
  const result = await processCLIInput(input.trim(), world, 'HUMAN');
257
506
  printCLIResult(result);
258
- // Set timer with longer delay for message processing (always needed for stdin messages)
259
- setupExitTimer(8000);
507
+ try {
508
+ // Event-driven completion: wait for world idle state
509
+ await activityMonitor.waitForIdle({
510
+ snapshot,
511
+ timeoutMs: 120_000,
512
+ noActivityTimeoutMs: 2_000
513
+ });
514
+ }
515
+ catch (error) {
516
+ // Silent exit on timeout - events may have completed
517
+ logger.debug('Pipeline mode completion timeout', { error: error.message });
518
+ }
260
519
  if (!result.success) {
261
520
  setTimeout(() => process.exit(1), 100);
262
521
  return;
@@ -267,9 +526,22 @@ async function runPipelineMode(options, messageFromArgs) {
267
526
  if (!options.command && !messageFromArgs) {
268
527
  program.help();
269
528
  }
529
+ if (worldSubscription) {
530
+ if (cliListeners && world) {
531
+ detachCLIListeners(world, cliListeners);
532
+ }
533
+ await worldSubscription.unsubscribe();
534
+ }
535
+ process.exit(0);
270
536
  }
271
537
  catch (error) {
272
538
  console.error(boldRed('Error:'), error instanceof Error ? error.message : error);
539
+ if (worldSubscription) {
540
+ if (cliListeners && world) {
541
+ detachCLIListeners(world, cliListeners);
542
+ }
543
+ await worldSubscription.unsubscribe();
544
+ }
273
545
  process.exit(1);
274
546
  }
275
547
  }
@@ -278,36 +550,78 @@ function cleanupWorldSubscription(worldState) {
278
550
  worldState.subscription.unsubscribe();
279
551
  }
280
552
  }
281
- // World subscription handler
282
- async function handleSubscribe(rootPath, worldName, streaming, globalState, rl) {
283
- const cliClient = {
284
- isOpen: true,
285
- onWorldEvent: (eventType, eventData) => {
286
- handleWorldEvent(eventType, eventData, streaming, globalState, rl);
287
- },
288
- onError: (error) => {
289
- console.log(red(`Error: ${error}`));
290
- }
291
- };
292
- const subscription = await subscribeWorld(worldName, cliClient);
553
+ /**
554
+ * Subscribe to world and attach CLI event listeners for interactive mode
555
+ *
556
+ * @param rootPath - Root path for world storage (unused, kept for compatibility)
557
+ * @param worldName - Name of the world to subscribe to
558
+ * @param streaming - Streaming state for real-time response display
559
+ * @param globalState - Global state for timer management
560
+ * @param activityMonitor - Activity monitor for tracking world events
561
+ * @param progressRenderer - Progress renderer for displaying activity
562
+ * @param rl - Readline interface for interactive input
563
+ * @returns WorldState with subscription and world instance
564
+ */
565
+ async function handleSubscribe(rootPath, worldName, streaming, globalState, activityMonitor, progressRenderer, rl) {
566
+ // Subscribe to world lifecycle but do not request forwarding callbacks
567
+ const subscription = await subscribeWorld(worldName, { isOpen: true });
293
568
  if (!subscription)
294
569
  throw new Error('Failed to load world');
295
- return { subscription, world: subscription.world };
570
+ const world = subscription.world;
571
+ // Attach direct listeners to the world.eventEmitter for CLI handling
572
+ // Interactive mode needs all event types including SSE for streaming responses
573
+ attachCLIListeners(world, streaming, globalState, activityMonitor, progressRenderer, rl);
574
+ return { subscription, world };
296
575
  }
297
576
  // Handle world events with streaming support
298
- function handleWorldEvent(eventType, eventData, streaming, globalState, rl) {
577
+ function handleWorldEvent(eventType, eventData, streaming, globalState, activityMonitor, progressRenderer, rl) {
578
+ if (eventType === 'world') {
579
+ const payload = eventData;
580
+ // Handle activity events (new format: type = 'response-start' | 'response-end' | 'idle')
581
+ if (payload.type === 'response-start' || payload.type === 'response-end' || payload.type === 'idle') {
582
+ activityMonitor.handle(payload);
583
+ progressRenderer.handle(payload);
584
+ // Call handleActivityEvents for verbose activity logging
585
+ handleActivityEvents(payload);
586
+ if (payload.type === 'idle' && rl && globalState.awaitingResponse) {
587
+ globalState.awaitingResponse = false;
588
+ console.log(); // Empty line before prompt
589
+ rl.prompt();
590
+ }
591
+ }
592
+ // Handle tool events (migrated from sse channel)
593
+ else if (payload.type === 'tool-start' || payload.type === 'tool-result' || payload.type === 'tool-error' || payload.type === 'tool-progress') {
594
+ handleToolEvents(payload);
595
+ }
596
+ return;
597
+ }
299
598
  if (handleWorldEventWithStreaming(eventType, eventData, streaming)) {
300
599
  return;
301
600
  }
302
601
  if (eventData.content && eventData.content.includes('Success message sent'))
303
602
  return;
603
+ // Handle regular message events from agents (non-streaming or after streaming ends)
604
+ if (eventType === 'message' && eventData.sender && eventData.content) {
605
+ // Skip user messages to prevent echo
606
+ if (eventData.sender === 'human' || eventData.sender.startsWith('user')) {
607
+ return;
608
+ }
609
+ // Skip if this message was already displayed via streaming
610
+ if (streaming.messageId === eventData.messageId) {
611
+ return;
612
+ }
613
+ // Display system messages
614
+ if (eventData.sender === 'system') {
615
+ console.log(`${boldRed('● system:')} ${eventData.content}`);
616
+ return;
617
+ }
618
+ // Display agent messages (fallback for non-streaming or missed messages)
619
+ console.log(`\n${boldGreen(`● ${eventData.sender}:`)} ${eventData.content}\n`);
620
+ return;
621
+ }
304
622
  if ((eventType === 'system' || eventType === 'world') && (eventData.message || eventData.content)) {
305
623
  // existing logic
306
624
  }
307
- else if (eventType === 'message' && eventData.sender === 'system') {
308
- const msg = eventData.content;
309
- console.log(`${boldRed('● system:')} ${msg}`);
310
- }
311
625
  }
312
626
  // World discovery and selection
313
627
  async function getAvailableWorldNames(rootPath) {
@@ -361,36 +675,64 @@ async function selectWorld(rootPath, rl) {
361
675
  askForSelection();
362
676
  });
363
677
  }
678
+ // Chat discovery and selection
679
+ async function selectChat(world, chats, currentChatId, rl) {
680
+ if (chats.length === 0) {
681
+ console.log(boldRed(`No chats found in world '${world.name}'`));
682
+ return null;
683
+ }
684
+ if (chats.length === 1) {
685
+ console.log(`${boldGreen('Auto-selecting the only available chat:')} ${cyan(chats[0].name)}`);
686
+ return chats[0].id;
687
+ }
688
+ console.log(`\n${boldMagenta('Available chats:')}`);
689
+ console.log(` ${yellow('0.')} ${cyan('Cancel')}`);
690
+ chats.forEach((chat, index) => {
691
+ const isCurrent = currentChatId && chat.id === currentChatId;
692
+ const currentIndicator = isCurrent ? ' (current)' : '';
693
+ const msgCount = chat.messageCount || 0;
694
+ console.log(` ${yellow(`${index + 1}.`)} ${cyan(`${chat.name}${currentIndicator} - (${msgCount}`)}`);
695
+ });
696
+ return new Promise((resolve) => {
697
+ function askForSelection() {
698
+ rl.question(`\n${boldMagenta('Select a chat (number or name), or 0 to cancel:')} `, (answer) => {
699
+ const trimmed = answer.trim();
700
+ const num = parseInt(trimmed);
701
+ if (num === 0) {
702
+ resolve(null);
703
+ return;
704
+ }
705
+ if (!isNaN(num) && num >= 1 && num <= chats.length) {
706
+ resolve(chats[num - 1].id);
707
+ return;
708
+ }
709
+ const found = chats.find(chat => chat.name.toLowerCase() === trimmed.toLowerCase() ||
710
+ chat.name.toLowerCase().includes(trimmed.toLowerCase()) ||
711
+ chat.id === trimmed);
712
+ if (found) {
713
+ resolve(found.id);
714
+ return;
715
+ }
716
+ console.log(boldRed('Invalid selection. Please try again.'));
717
+ askForSelection();
718
+ });
719
+ }
720
+ askForSelection();
721
+ });
722
+ }
364
723
  // Interactive mode: console-based interface
365
724
  async function runInteractiveMode(options) {
366
725
  const rootPath = options.root || DEFAULT_ROOT_PATH;
367
726
  enableStreaming();
368
727
  const globalState = createGlobalState();
369
728
  const streaming = createStreamingState();
729
+ const activityMonitor = new WorldActivityMonitor();
730
+ const progressRenderer = new ActivityProgressRenderer();
370
731
  const rl = readline.createInterface({
371
732
  input: process.stdin,
372
733
  output: process.stdout,
373
734
  prompt: '> '
374
735
  });
375
- // Set up streaming callbacks
376
- streaming.wait = (delay) => {
377
- setupPromptTimer(globalState, rl, () => {
378
- if (streaming.isActive) {
379
- console.log(`\n${gray('Streaming appears stalled - waiting for user input...')}`);
380
- streaming.isActive = false;
381
- streaming.content = '';
382
- streaming.sender = undefined;
383
- streaming.messageId = undefined;
384
- rl.prompt();
385
- }
386
- else {
387
- rl.prompt();
388
- }
389
- }, delay);
390
- };
391
- streaming.stopWait = () => {
392
- clearPromptTimer(globalState);
393
- };
394
736
  console.log(boldCyan('Agent World CLI (Interactive Mode)'));
395
737
  console.log(cyan('===================================='));
396
738
  let worldState = null;
@@ -401,7 +743,9 @@ async function runInteractiveMode(options) {
401
743
  if (options.world) {
402
744
  logger.debug(`Loading world: ${options.world}`);
403
745
  try {
404
- worldState = await handleSubscribe(rootPath, options.world, streaming, globalState, rl);
746
+ activityMonitor.reset();
747
+ progressRenderer.reset();
748
+ worldState = await handleSubscribe(rootPath, options.world, streaming, globalState, activityMonitor, progressRenderer, rl);
405
749
  currentWorldName = options.world;
406
750
  console.log(success(`Connected to world: ${currentWorldName}`));
407
751
  if (worldState?.world) {
@@ -423,7 +767,9 @@ async function runInteractiveMode(options) {
423
767
  }
424
768
  logger.debug(`Loading world: ${selectedWorld}`);
425
769
  try {
426
- worldState = await handleSubscribe(rootPath, selectedWorld, streaming, globalState, rl);
770
+ activityMonitor.reset();
771
+ progressRenderer.reset();
772
+ worldState = await handleSubscribe(rootPath, selectedWorld, streaming, globalState, activityMonitor, progressRenderer, rl);
427
773
  currentWorldName = selectedWorld;
428
774
  console.log(success(`Connected to world: ${currentWorldName}`));
429
775
  if (worldState?.world) {
@@ -440,16 +786,22 @@ async function runInteractiveMode(options) {
440
786
  console.log(`\n${gray('Quick Start:')}`);
441
787
  console.log(` ${bullet(gray('World commands:'))} ${cyan('/world list')}, ${cyan('/world create')}, ${cyan('/world select')}`);
442
788
  console.log(` ${bullet(gray('Agent commands:'))} ${cyan('/agent list')}, ${cyan('/agent create Ava')}, ${cyan('/agent update Ava')}`);
443
- console.log(` ${bullet(gray('Chat commands:'))} ${cyan('/chat list --active')}, ${cyan('/chat create')}, ${cyan('/chat export')}`);
789
+ console.log(` ${bullet(gray('Chat commands:'))} ${cyan('/chat list')}, ${cyan('/chat select')}, ${cyan('/chat create')}, ${cyan('/chat export')}`);
444
790
  console.log(` ${bullet(gray('Need help?'))} ${cyan('/help world')}, ${cyan('/help agent')}, ${cyan('/help chat')}`);
445
791
  console.log(` ${bullet(gray('Type messages to talk with the world'))}`);
446
792
  console.log(` ${bullet(gray('Exit with'))} ${cyan('/quit')} ${gray('or')} ${cyan('/exit')} ${gray('or press')} ${boldYellow('Ctrl+C')}`);
447
793
  console.log(` ${bullet(gray('Enable debug logs via'))} ${cyan('--logLevel debug')}`);
448
794
  console.log('');
795
+ // Display current chat messages after Quick Start tips
796
+ if (worldState?.world) {
797
+ await displayChatMessages(worldState.world.id, worldState.world.currentChatId);
798
+ }
799
+ console.log(); // Empty line before prompt
449
800
  rl.prompt();
450
801
  rl.on('line', async (input) => {
451
802
  const trimmedInput = input.trim();
452
803
  if (!trimmedInput) {
804
+ console.log(); // Empty line before prompt
453
805
  rl.prompt();
454
806
  return;
455
807
  }
@@ -459,10 +811,6 @@ async function runInteractiveMode(options) {
459
811
  if (isExiting)
460
812
  return;
461
813
  isExiting = true;
462
- // Clear any existing timers immediately
463
- clearPromptTimer(globalState);
464
- if (streaming.stopWait)
465
- streaming.stopWait();
466
814
  console.log(`\n${boldCyan('Goodbye!')}`);
467
815
  if (worldState)
468
816
  cleanupWorldSubscription(worldState);
@@ -470,6 +818,12 @@ async function runInteractiveMode(options) {
470
818
  process.exit(0);
471
819
  }
472
820
  console.log(`\n${boldYellow('● you:')} ${trimmedInput}`);
821
+ const isCommand = trimmedInput.startsWith('/');
822
+ let snapshot = null;
823
+ if (!isCommand) {
824
+ globalState.awaitingResponse = true;
825
+ snapshot = activityMonitor.captureSnapshot();
826
+ }
473
827
  try {
474
828
  const result = await processCLIInput(trimmedInput, worldState?.world || null, 'HUMAN');
475
829
  // Handle exit commands from result (redundant, but keep for safety)
@@ -477,9 +831,6 @@ async function runInteractiveMode(options) {
477
831
  if (isExiting)
478
832
  return; // Prevent duplicate exit handling
479
833
  isExiting = true;
480
- clearPromptTimer(globalState);
481
- if (streaming.stopWait)
482
- streaming.stopWait();
483
834
  console.log(`\n${boldCyan('Goodbye!')}`);
484
835
  if (worldState)
485
836
  cleanupWorldSubscription(worldState);
@@ -492,6 +843,7 @@ async function runInteractiveMode(options) {
492
843
  const selectedWorld = await selectWorld(rootPath, rl);
493
844
  if (!selectedWorld) {
494
845
  console.log(error('No world selected.'));
846
+ console.log(); // Empty line before prompt
495
847
  rl.prompt();
496
848
  return;
497
849
  }
@@ -507,17 +859,60 @@ async function runInteractiveMode(options) {
507
859
  }
508
860
  // Subscribe to the new world
509
861
  logger.debug(`Subscribing to world: ${selectedWorld}...`);
510
- worldState = await handleSubscribe(rootPath, selectedWorld, streaming, globalState, rl);
862
+ activityMonitor.reset();
863
+ progressRenderer.reset();
864
+ worldState = await handleSubscribe(rootPath, selectedWorld, streaming, globalState, activityMonitor, progressRenderer, rl);
511
865
  currentWorldName = selectedWorld;
512
866
  console.log(success(`Connected to world: ${currentWorldName}`));
513
867
  if (worldState?.world) {
514
868
  console.log(`${gray('Agents:')} ${yellow(String(worldState.world.agents?.size || 0))} ${gray('| Turn Limit:')} ${yellow(String(worldState.world.turnLimit || 'N/A'))}`);
869
+ // Display current chat messages
870
+ await displayChatMessages(worldState.world.id, worldState.world.currentChatId);
515
871
  }
516
872
  }
517
873
  catch (err) {
518
874
  console.error(error(`Error loading world: ${err instanceof Error ? err.message : 'Unknown error'}`));
519
875
  }
520
876
  // Show prompt immediately after world selection
877
+ console.log(); // Empty line before prompt
878
+ rl.prompt();
879
+ return;
880
+ }
881
+ // Handle chat selection command
882
+ if (result.data?.selectChat && worldState?.world) {
883
+ const { chats, currentChatId } = result.data;
884
+ const selectedChatId = await selectChat(worldState.world, chats, currentChatId, rl);
885
+ if (!selectedChatId) {
886
+ console.log(error('No chat selected.'));
887
+ console.log(); // Empty line before prompt
888
+ rl.prompt();
889
+ return;
890
+ }
891
+ try {
892
+ // Restore the selected chat
893
+ const { restoreChat } = await import('../core/index.js');
894
+ const restored = await restoreChat(worldState.world.id, selectedChatId);
895
+ if (!restored) {
896
+ console.log(error(`Failed to restore chat '${selectedChatId}'`));
897
+ }
898
+ else {
899
+ const selectedChat = chats.find((c) => c.id === selectedChatId);
900
+ const chatName = selectedChat?.name || selectedChatId;
901
+ console.log(success(`Chat '${chatName}' selected and loaded`));
902
+ // Display chat messages
903
+ await displayChatMessages(worldState.world.id, selectedChatId);
904
+ // Refresh world state
905
+ // console.log(boldBlue('Refreshing world state...'));
906
+ const refreshedWorld = await worldState.subscription.refresh(rootPath);
907
+ worldState.world = refreshedWorld;
908
+ console.log(success('World state refreshed'));
909
+ }
910
+ }
911
+ catch (err) {
912
+ console.error(error(`Error loading chat: ${err instanceof Error ? err.message : 'Unknown error'}`));
913
+ }
914
+ // Show prompt immediately after chat selection
915
+ console.log(); // Empty line before prompt
521
916
  rl.prompt();
522
917
  return;
523
918
  }
@@ -529,29 +924,26 @@ async function runInteractiveMode(options) {
529
924
  !result.message.includes('Message sent to world')) {
530
925
  console.log(success(result.message));
531
926
  }
532
- if (result.data && !(result.data.sender === 'HUMAN')) {
533
- // Print a concise summary of result.data if present and not already in message
534
- if (result.data) {
535
- if (typeof result.data === 'string') {
536
- console.log(`${boldMagenta('Data:')} ${result.data}`);
537
- }
538
- else if (result.data.name) {
539
- // If it's an agent or world object
540
- console.log(`${boldMagenta('Data:')} ${result.data.name}`);
541
- }
542
- else if (Array.isArray(result.data)) {
543
- console.log(`${boldMagenta('Data:')} ${result.data.length} items`);
544
- }
545
- else {
546
- // Fallback: print keys
547
- console.log(`${boldMagenta('Data:')} ${Object.keys(result.data).join(', ')}`);
548
- }
549
- }
550
- }
927
+ // if (result.data && !(result.data.sender === 'human')) {
928
+ // // Print a concise summary of result.data if present and not already in message
929
+ // if (result.data) {
930
+ // if (typeof result.data === 'string') {
931
+ // console.log(`${boldMagenta('Data:')} ${result.data}`);
932
+ // } else if (result.data.name) {
933
+ // // If it's an agent or world object
934
+ // console.log(`${boldMagenta('Data:')} ${result.data.name}`);
935
+ // } else if (Array.isArray(result.data)) {
936
+ // console.log(`${boldMagenta('Data:')} ${result.data.length} items`);
937
+ // } else {
938
+ // // Fallback: print keys
939
+ // console.log(`${boldMagenta('Data:')} ${Object.keys(result.data).join(', ')}`);
940
+ // }
941
+ // }
942
+ // }
551
943
  // Refresh world if needed
552
944
  if (result.refreshWorld && currentWorldName && worldState) {
553
945
  try {
554
- console.log(boldBlue('Refreshing world state...'));
946
+ // console.log(boldBlue('Refreshing world state...'));
555
947
  // Use the subscription's refresh method to properly destroy old world and create new
556
948
  const refreshedWorld = await worldState.subscription.refresh(rootPath);
557
949
  worldState.world = refreshedWorld;
@@ -564,30 +956,46 @@ async function runInteractiveMode(options) {
564
956
  }
565
957
  catch (err) {
566
958
  console.error(error(`Command error: ${err instanceof Error ? err.message : 'Unknown error'}`));
959
+ if (!isCommand && globalState.awaitingResponse) {
960
+ globalState.awaitingResponse = false;
961
+ rl.prompt();
962
+ }
963
+ snapshot = null;
567
964
  }
568
- // Set timer based on input type: commands get short delay, messages get longer delay
569
- const isCommand = trimmedInput.startsWith('/');
570
- const isSelectCommand = trimmedInput.toLowerCase() === '/select';
965
+ if (!isCommand && snapshot) {
966
+ try {
967
+ await activityMonitor.waitForIdle({ snapshot });
968
+ }
969
+ catch (error) {
970
+ console.error(red(`Timed out waiting for responses: ${error.message}`));
971
+ }
972
+ finally {
973
+ if (globalState.awaitingResponse) {
974
+ globalState.awaitingResponse = false;
975
+ console.log(); // Empty line before prompt
976
+ rl.prompt();
977
+ }
978
+ }
979
+ return;
980
+ }
981
+ // For commands, show prompt immediately. For messages, world events will trigger the prompt
982
+ const isSelectCommand = trimmedInput.toLowerCase() === '/select' ||
983
+ trimmedInput.toLowerCase() === '/world select';
571
984
  if (isSelectCommand) {
572
- // For select command, prompt is already shown in the handler
985
+ // For world select command, prompt is already shown in the handler
573
986
  return;
574
987
  }
575
988
  else if (isCommand) {
576
989
  // For other commands, show prompt immediately
990
+ console.log(); // Empty line before prompt
577
991
  rl.prompt();
578
992
  }
579
- else if (streaming.wait) {
580
- // For messages, wait for potential agent responses
581
- streaming.wait(5000);
582
- }
993
+ // For messages, waitForIdle() above will handle prompt display via world 'idle' event
583
994
  });
584
995
  rl.on('close', () => {
585
996
  if (isExiting)
586
997
  return; // Prevent duplicate cleanup
587
998
  isExiting = true;
588
- clearPromptTimer(globalState);
589
- if (streaming.stopWait)
590
- streaming.stopWait();
591
999
  console.log(`\n${boldCyan('Goodbye!')}`);
592
1000
  if (worldState)
593
1001
  cleanupWorldSubscription(worldState);
@@ -598,9 +1006,6 @@ async function runInteractiveMode(options) {
598
1006
  return; // Prevent duplicate cleanup
599
1007
  isExiting = true;
600
1008
  console.log(`\n${boldCyan('Shutting down...')}`);
601
- clearPromptTimer(globalState);
602
- if (streaming.stopWait)
603
- streaming.stopWait();
604
1009
  console.log(`\n${boldCyan('Goodbye!')}`);
605
1010
  if (worldState)
606
1011
  cleanupWorldSubscription(worldState);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-world",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "exports": {
@@ -28,10 +28,19 @@
28
28
  "prestart": "npm run build",
29
29
  "start": "node dist/server/index.js",
30
30
  "cli": "npx tsx cli/index.ts",
31
+ "cli:watch": "npx tsx --watch cli/index.ts",
31
32
  "server": "npx tsx server/index.ts",
32
- "dev": "concurrently \"npm run server\" \"npm run dev --workspace=web\"",
33
- "test": "jest --config jest.config.js",
33
+ "dev": "npm-run-all --parallel server:watch web:dev",
34
+ "server:watch": "npx tsx --watch server/index.ts",
35
+ "web:dev": "npm-run-all --sequential web:wait web:start",
36
+ "web:wait": "wait-on http://127.0.0.1:3000/health",
37
+ "web:start": "npm run dev --workspace=web",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "test:ui": "vitest --ui",
41
+ "test:coverage": "vitest run --coverage",
34
42
  "test:db": "npx tsx tests/db/migration-tests.ts",
43
+ "integration": "vitest run --config vitest.integration.config.ts",
35
44
  "check": "tsc --noEmit && npm run check --workspace=web",
36
45
  "build": "npm run build --workspace=core && tsc --project tsconfig.build.json && npm run build --workspace=web",
37
46
  "pkill": "pkill -f tsx",
@@ -60,12 +69,14 @@
60
69
  "devDependencies": {
61
70
  "@types/cors": "^2.8.19",
62
71
  "@types/express": "^4.17.23",
63
- "@types/jest": "^30.0.0",
64
- "@types/node": "^24.3.0",
72
+ "@types/node": "^24.9.1",
73
+ "@vitest/coverage-v8": "^2.1.9",
74
+ "@vitest/ui": "^2.1.9",
65
75
  "commander": "^14.0.0",
66
- "concurrently": "^9.2.0",
67
- "jest": "^30.0.5",
68
- "ts-jest": "^29.4.1"
76
+ "npm-run-all": "^4.1.5",
77
+ "vite-tsconfig-paths": "^5.1.4",
78
+ "vitest": "^2.1.9",
79
+ "wait-on": "^9.0.1"
69
80
  },
70
81
  "engines": {
71
82
  "node": ">=20.0.0"