nothumanallowed 9.7.2 → 9.8.1

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.
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * nha chat — Interactive conversational REPL for PAO (Personal Agent Ops).
3
3
  *
4
- * The user types natural language; an LLM interprets intent, optionally
5
- * invokes Gmail / Calendar / Tasks / GitHub / Notion / Slack APIs via a
6
- * structured JSON action protocol, and responds conversationally.
4
+ * Features:
5
+ * - Streaming responses (token-by-token display)
6
+ * - Multi-conversation management (/new, /list, /switch, /delete, /rename)
7
+ * - Export conversations (/export md, /export json)
8
+ * - @agent inline routing and /agent persistent mode
9
+ * - Tool execution with confirmation for destructive actions
7
10
  *
8
11
  * All tool definitions, parsing, and execution are in tool-executor.mjs (DRY).
9
12
  *
@@ -11,10 +14,15 @@
11
14
  */
12
15
 
13
16
  import readline from 'readline';
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import os from 'os';
14
20
  import { loadConfig } from '../config.mjs';
15
- import { callLLM } from '../services/llm.mjs';
16
- import { loadChatHistory, saveChatHistory, extractMemory } from '../services/memory.mjs';
17
- import { fail, info, ok, warn, C, G, Y, D, W, BOLD, NC, R } from '../ui.mjs';
21
+ import { AGENTS_DIR, AGENTS } from '../constants.mjs';
22
+ import { callLLMStream, parseAgentFile } from '../services/llm.mjs';
23
+
24
+ import { extractMemory } from '../services/memory.mjs';
25
+ import { fail, info, ok, warn, C, G, Y, D, W, BOLD, NC, R, M } from '../ui.mjs';
18
26
  import {
19
27
  DESTRUCTIVE_ACTIONS,
20
28
  parseActions,
@@ -29,6 +37,20 @@ import {
29
37
  getUnreadImportant,
30
38
  } from '../services/mail-router.mjs';
31
39
  import { getTasks } from '../services/task-store.mjs';
40
+ import {
41
+ createConversation,
42
+ loadConversation,
43
+ saveConversation,
44
+ deleteConversation,
45
+ listConversations,
46
+ getOrCreateActive,
47
+ setActiveId,
48
+ getHistory,
49
+ addMessages,
50
+ exportAsMarkdown,
51
+ exportAsJson,
52
+ migrateOldHistory,
53
+ } from '../services/conversations.mjs';
32
54
 
33
55
  // ── Constants ────────────────────────────────────────────────────────────────
34
56
 
@@ -115,9 +137,23 @@ async function fetchInitialContext(config) {
115
137
  return parts.join('\n\n');
116
138
  }
117
139
 
140
+ // ── Relative Time ────────────────────────────────────────────────────────────
141
+
142
+ function relativeTime(isoString) {
143
+ const ms = Date.now() - new Date(isoString).getTime();
144
+ const minutes = Math.floor(ms / 60000);
145
+ if (minutes < 1) return 'just now';
146
+ if (minutes < 60) return `${minutes}m ago`;
147
+ const hours = Math.floor(minutes / 60);
148
+ if (hours < 24) return `${hours}h ago`;
149
+ const days = Math.floor(hours / 24);
150
+ if (days < 7) return `${days}d ago`;
151
+ return new Date(isoString).toLocaleDateString();
152
+ }
153
+
118
154
  // ── Slash Command Handlers ───────────────────────────────────────────────────
119
155
 
120
- async function handleSlashCommand(input, config, history) {
156
+ async function handleSlashCommand(input, config, conv, rl) {
121
157
  const trimmed = input.trim();
122
158
 
123
159
  if (trimmed === '/quit' || trimmed === '/exit' || trimmed === '/q') {
@@ -125,12 +161,106 @@ async function handleSlashCommand(input, config, history) {
125
161
  process.exit(0);
126
162
  }
127
163
 
164
+ // ── Multi-conversation commands ──────────────────────────────────────────
165
+
166
+ if (trimmed === '/new') {
167
+ const newConv = createConversation();
168
+ // Update the outer reference via return
169
+ console.log(` ${G}New conversation started.${NC} ${D}(${newConv.id})${NC}`);
170
+ return { handled: true, switchTo: newConv };
171
+ }
172
+
173
+ if (trimmed === '/list' || trimmed === '/conversations') {
174
+ const convs = listConversations();
175
+ if (convs.length === 0) {
176
+ console.log(` ${D}No conversations yet.${NC}`);
177
+ } else {
178
+ console.log(`\n ${BOLD}Conversations${NC} (${convs.length})\n`);
179
+ for (const c of convs) {
180
+ const active = c.id === conv.id ? ` ${G}<- active${NC}` : '';
181
+ const turns = Math.floor(c.messageCount / 2);
182
+ console.log(` ${C}${c.id}${NC} ${c.title} ${D}(${turns} turns, ${relativeTime(c.updatedAt)})${NC}${active}`);
183
+ }
184
+ console.log(`\n ${D}Switch: /switch <id> | New: /new | Delete: /delete <id>${NC}`);
185
+ }
186
+ return { handled: true };
187
+ }
188
+
189
+ if (trimmed.startsWith('/switch ')) {
190
+ const targetId = trimmed.slice(8).trim();
191
+ const target = loadConversation(targetId);
192
+ if (!target) {
193
+ console.log(` ${R}Conversation "${targetId}" not found. Use /list to see all.${NC}`);
194
+ return { handled: true };
195
+ }
196
+ setActiveId(targetId);
197
+ const turns = Math.floor(target.messages.length / 2);
198
+ console.log(` ${G}Switched to:${NC} ${target.title} ${D}(${turns} turns)${NC}`);
199
+ return { handled: true, switchTo: target };
200
+ }
201
+
202
+ if (trimmed.startsWith('/delete ')) {
203
+ const targetId = trimmed.slice(8).trim();
204
+ if (targetId === conv.id) {
205
+ console.log(` ${R}Cannot delete active conversation. Switch to another first.${NC}`);
206
+ return { handled: true };
207
+ }
208
+ if (deleteConversation(targetId)) {
209
+ console.log(` ${G}Deleted conversation ${targetId}.${NC}`);
210
+ } else {
211
+ console.log(` ${R}Conversation "${targetId}" not found.${NC}`);
212
+ }
213
+ return { handled: true };
214
+ }
215
+
216
+ if (trimmed.startsWith('/rename ')) {
217
+ const newTitle = trimmed.slice(8).trim();
218
+ if (!newTitle) {
219
+ console.log(` ${R}Usage: /rename <new title>${NC}`);
220
+ return { handled: true };
221
+ }
222
+ conv.title = newTitle;
223
+ saveConversation(conv);
224
+ console.log(` ${G}Renamed to:${NC} ${newTitle}`);
225
+ return { handled: true };
226
+ }
227
+
228
+ // ── Export commands ──────────────────────────────────────────────────────
229
+
230
+ if (trimmed === '/export' || trimmed === '/export md' || trimmed === '/export markdown') {
231
+ if (conv.messages.length === 0) {
232
+ console.log(` ${D}Nothing to export — conversation is empty.${NC}`);
233
+ return { handled: true };
234
+ }
235
+ const md = exportAsMarkdown(conv);
236
+ const filename = `nha-chat-${conv.id}.md`;
237
+ const filePath = path.join(os.homedir(), filename);
238
+ fs.writeFileSync(filePath, md, 'utf-8');
239
+ console.log(` ${G}Exported as Markdown:${NC} ~/${filename}`);
240
+ return { handled: true };
241
+ }
242
+
243
+ if (trimmed === '/export json') {
244
+ if (conv.messages.length === 0) {
245
+ console.log(` ${D}Nothing to export — conversation is empty.${NC}`);
246
+ return { handled: true };
247
+ }
248
+ const json = exportAsJson(conv);
249
+ const filename = `nha-chat-${conv.id}.json`;
250
+ const filePath = path.join(os.homedir(), filename);
251
+ fs.writeFileSync(filePath, json, 'utf-8');
252
+ console.log(` ${G}Exported as JSON:${NC} ~/${filename}`);
253
+ return { handled: true };
254
+ }
255
+
256
+ // ── Original commands ────────────────────────────────────────────────────
257
+
128
258
  if (trimmed === '/clear') {
129
- history.length = 0;
130
- try { saveChatHistory([]); } catch { /* non-critical */ }
259
+ conv.messages.length = 0;
260
+ saveConversation(conv);
131
261
  console.clear();
132
- console.log(` ${G}Conversation cleared (memory preserved, chat history reset).${NC}`);
133
- return true;
262
+ console.log(` ${G}Conversation cleared (memory preserved).${NC}`);
263
+ return { handled: true };
134
264
  }
135
265
 
136
266
  if (trimmed === '/tasks') {
@@ -147,7 +277,7 @@ async function handleSlashCommand(input, config, history) {
147
277
  } catch (err) {
148
278
  console.log(` ${R}Could not load tasks: ${err.message}${NC}`);
149
279
  }
150
- return true;
280
+ return { handled: true };
151
281
  }
152
282
 
153
283
  if (trimmed === '/plan') {
@@ -157,27 +287,282 @@ async function handleSlashCommand(input, config, history) {
157
287
  } catch (err) {
158
288
  console.log(` ${R}Plan error: ${err.message}${NC}`);
159
289
  }
160
- return true;
290
+ return { handled: true };
291
+ }
292
+
293
+ // /agent <name> — switch to talking with a specific agent
294
+ if (trimmed.startsWith('/agent ')) {
295
+ const agentName = trimmed.slice(7).trim().toLowerCase();
296
+
297
+ if (agentName === 'off' || agentName === 'reset') {
298
+ delete config._chatAgent;
299
+ console.log(` ${G}Switched back to NHA Chat.${NC}`);
300
+ return { handled: true };
301
+ }
302
+
303
+ const agentFile = path.join(AGENTS_DIR, `${agentName}.mjs`);
304
+ if (!fs.existsSync(agentFile)) {
305
+ console.log(` ${R}Agent "${agentName}" not found. Available: ${AGENTS.join(', ')}${NC}`);
306
+ return { handled: true };
307
+ }
308
+ const agentSource = fs.readFileSync(agentFile, 'utf-8');
309
+ const { card, systemPrompt: agentSysPrompt } = parseAgentFile(agentSource, agentName);
310
+ if (agentSysPrompt) {
311
+ config._chatAgent = { name: agentName, systemPrompt: agentSysPrompt, card };
312
+ console.log(` ${G}Now chatting with ${BOLD}${card?.displayName || agentName.toUpperCase()}${NC}${G} (${card?.tagline || 'agent'})${NC}`);
313
+ console.log(` ${D}Type /agent off to return to NHA Chat${NC}`);
314
+ } else {
315
+ console.log(` ${R}Agent "${agentName}" has no system prompt.${NC}`);
316
+ }
317
+ return { handled: true };
318
+ }
319
+
320
+ // /create-agent <name> "<tagline>" "<system prompt>"
321
+ if (trimmed === '/create-agent' || trimmed.startsWith('/create-agent ')) {
322
+ const parts = trimmed.slice(14).trim();
323
+ if (!parts) {
324
+ console.log(`\n ${BOLD}${Y}Create Custom Agent${NC}`);
325
+ console.log(` Usage: ${C}/create-agent mybot "Short description" "You are an expert in..."${NC}`);
326
+ console.log(` Example: ${D}/create-agent chef "Italian cooking expert" "You are a master Italian chef. Always suggest authentic recipes with step-by-step instructions."${NC}\n`);
327
+ return { handled: true };
328
+ }
329
+ const nameMatch = parts.match(/^(\S+)\s+(.+)/);
330
+ if (!nameMatch) {
331
+ console.log(` ${R}Usage: /create-agent <name> "<tagline>" "<system prompt>"${NC}`);
332
+ return { handled: true };
333
+ }
334
+ const name = nameMatch[1].toLowerCase().replace(/[^a-z0-9_-]/g, '');
335
+ const rest = nameMatch[2];
336
+ const quoteParts = rest.match(/"([^"]*)"/g);
337
+ let tagline = '', sysPrompt = '';
338
+ if (quoteParts && quoteParts.length >= 2) {
339
+ tagline = quoteParts[0].replace(/"/g, '');
340
+ sysPrompt = quoteParts[1].replace(/"/g, '');
341
+ } else {
342
+ const firstDot = rest.indexOf('.');
343
+ if (firstDot > 0) {
344
+ tagline = rest.slice(0, firstDot).replace(/"/g, '').trim();
345
+ sysPrompt = rest.slice(firstDot + 1).replace(/"/g, '').trim();
346
+ } else {
347
+ tagline = rest.replace(/"/g, '').trim();
348
+ sysPrompt = tagline;
349
+ }
350
+ }
351
+
352
+ if (!name || !tagline || !sysPrompt) {
353
+ console.log(` ${R}All fields required. Usage: /create-agent name "tagline" "system prompt"${NC}`);
354
+ return { handled: true };
355
+ }
356
+
357
+ const agentFile = path.join(AGENTS_DIR, `${name}.mjs`);
358
+ if (fs.existsSync(agentFile)) {
359
+ console.log(` ${R}Agent "${name}" already exists.${NC}`);
360
+ return { handled: true };
361
+ }
362
+
363
+ const content = `// NHA Custom Agent: ${name}\n// Created: ${new Date().toISOString()}\n\nexport const CARD = {\n name: '${name}',\n displayName: '${name.toUpperCase()}',\n category: 'custom',\n tagline: '${tagline.replace(/'/g, "\\'")}',\n};\n\nexport const SYSTEM_PROMPT = \`${sysPrompt.replace(/`/g, '\\`')}\`;\n`;
364
+ if (!fs.existsSync(AGENTS_DIR)) fs.mkdirSync(AGENTS_DIR, { recursive: true });
365
+ fs.writeFileSync(agentFile, content, 'utf-8');
366
+ console.log(` ${G}Agent "${name}" created!${NC}`);
367
+ console.log(` ${D}Switch to it: /agent ${name}${NC}`);
368
+ return { handled: true };
369
+ }
370
+
371
+ // /agents — list available agents
372
+ if (trimmed === '/agents') {
373
+ const available = [];
374
+ if (fs.existsSync(AGENTS_DIR)) {
375
+ for (const f of fs.readdirSync(AGENTS_DIR)) {
376
+ if (f.endsWith('.mjs')) available.push(f.replace('.mjs', ''));
377
+ }
378
+ }
379
+ console.log(` ${BOLD}Available Agents${NC} (${available.length})`);
380
+ for (const a of available) {
381
+ console.log(` ${C}${a}${NC}`);
382
+ }
383
+ console.log(`\n ${D}Switch: /agent <name> | Create: /create-agent${NC}`);
384
+ return { handled: true };
161
385
  }
162
386
 
163
387
  if (trimmed === '/help') {
164
388
  console.log(`
165
389
  ${BOLD}Chat Commands${NC}
166
390
 
167
- ${C}/tasks${NC} Show today's tasks
168
- ${C}/plan${NC} Run daily planner
169
- ${C}/clear${NC} Clear conversation history
170
- ${C}/help${NC} Show this help
171
- ${C}/quit${NC} Exit chat
172
-
173
- ${D}Otherwise, just type naturally — the AI understands
174
- requests like "show my unread emails", "add a task to review PR #42",
175
- "what's on my calendar tomorrow?", "list GitHub issues", etc.${NC}
391
+ ${BOLD}Conversations${NC}
392
+ ${C}/new${NC} Start a new conversation
393
+ ${C}/list${NC} List all conversations
394
+ ${C}/switch <id>${NC} Switch to a conversation
395
+ ${C}/rename <title>${NC} Rename current conversation
396
+ ${C}/delete <id>${NC} Delete a conversation
397
+ ${C}/export${NC} Export as Markdown (~/)
398
+ ${C}/export json${NC} Export as JSON (~/)
399
+
400
+ ${BOLD}Agents${NC}
401
+ ${C}/agents${NC} List available agents
402
+ ${C}/agent <name>${NC} Switch to chatting with a specific agent
403
+ ${C}/agent off${NC} Return to NHA Chat
404
+ ${C}/create-agent${NC} Create a new custom agent
405
+
406
+ ${BOLD}Tools${NC}
407
+ ${C}/tasks${NC} Show today's tasks
408
+ ${C}/plan${NC} Run daily planner
409
+ ${C}/clear${NC} Clear current conversation
410
+ ${C}/help${NC} Show this help
411
+ ${C}/quit${NC} Exit chat
412
+
413
+ ${D}Tip: Type @agent in any message to route it inline.
414
+ Example: "@saber audit this function for SQL injection"
415
+
416
+ Type naturally — "show my unread emails", "add a task",
417
+ "what's on my calendar tomorrow?", "list GitHub issues"${NC}
176
418
  `);
177
- return true;
419
+ return { handled: true };
178
420
  }
179
421
 
180
- return false;
422
+ return { handled: false };
423
+ }
424
+
425
+ // ── Tool Indicators ──────────────────────────────────────────────────────────
426
+
427
+ /**
428
+ * Format a user-visible label while a tool is executing.
429
+ */
430
+ function formatToolLabel(action, params) {
431
+ switch (action) {
432
+ case 'web_search':
433
+ return `Searching the web for "${params.query || '...'}"...`;
434
+ case 'fetch_url':
435
+ return `Fetching ${params.url || 'URL'}...`;
436
+ case 'browser_open':
437
+ return `Opening ${params.url || 'page'} in browser...`;
438
+ case 'browser_screenshot':
439
+ return `Taking screenshot...`;
440
+ case 'browser_click':
441
+ return `Clicking ${params.selector || `(${params.x}, ${params.y})`}...`;
442
+ case 'browser_type':
443
+ return `Typing into ${params.selector || 'field'}...`;
444
+ case 'browser_extract':
445
+ return `Extracting content from ${params.selector || 'page'}...`;
446
+ case 'browser_js':
447
+ return `Executing JavaScript...`;
448
+ case 'browser_wait':
449
+ return `Waiting for ${params.selector || 'element'}...`;
450
+ case 'browser_scroll':
451
+ return `Scrolling ${params.direction || 'down'}...`;
452
+ case 'browser_key':
453
+ return `Pressing ${params.key || 'key'}...`;
454
+ case 'browser_close':
455
+ return `Closing browser...`;
456
+ case 'gmail_list':
457
+ return `Searching emails...`;
458
+ case 'gmail_read':
459
+ return `Reading email...`;
460
+ case 'gmail_send':
461
+ case 'gmail_send_attach':
462
+ return `Sending email to ${params.to || '...'}...`;
463
+ case 'gmail_reply':
464
+ return `Sending reply...`;
465
+ case 'calendar_create':
466
+ return `Creating event "${params.summary || '...'}"...`;
467
+ case 'calendar_today':
468
+ case 'calendar_tomorrow':
469
+ case 'calendar_upcoming':
470
+ case 'calendar_week':
471
+ return `Loading calendar...`;
472
+ case 'github_issues':
473
+ case 'github_prs':
474
+ return `Fetching from GitHub...`;
475
+ case 'notion_search':
476
+ return `Searching Notion...`;
477
+ case 'slack_messages':
478
+ case 'slack_channels':
479
+ return `Loading Slack...`;
480
+ default:
481
+ return `Executing ${action}...`;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Format a result header with visual indicator based on tool type.
487
+ */
488
+ function formatToolResult(action, params, result) {
489
+ switch (action) {
490
+ case 'web_search': {
491
+ const count = (result.match(/\d+\. /g) || []).length;
492
+ const deep = params.deep ? ', deep mode' : '';
493
+ return `${C}[Web Search: ${count} results${deep}]${NC}`;
494
+ }
495
+ case 'fetch_url': {
496
+ const domain = (params.url || '').replace(/^https?:\/\//, '').split('/')[0];
497
+ return `${C}[Fetched: ${domain}]${NC}`;
498
+ }
499
+ case 'browser_open': {
500
+ const domain = (params.url || '').replace(/^https?:\/\//, '').split('/')[0];
501
+ return `${M}[Browser: ${domain}]${NC}`;
502
+ }
503
+ case 'browser_screenshot':
504
+ return `${M}[Screenshot]${NC}`;
505
+ case 'browser_click':
506
+ return `${M}[Click: ${params.selector || `(${params.x}, ${params.y})`}]${NC}`;
507
+ case 'browser_type':
508
+ return `${M}[Typed: ${(params.text || '').slice(0, 30)}${(params.text || '').length > 30 ? '...' : ''}]${NC}`;
509
+ case 'browser_extract':
510
+ return `${M}[Extracted: ${params.selector || 'page'}]${NC}`;
511
+ case 'browser_js':
512
+ return `${M}[JS executed]${NC}`;
513
+ case 'browser_wait':
514
+ return `${M}[Found: ${params.selector}]${NC}`;
515
+ case 'browser_scroll':
516
+ return `${M}[Scrolled ${params.direction || 'down'}]${NC}`;
517
+ case 'browser_key':
518
+ return `${M}[Key: ${params.key}]${NC}`;
519
+ case 'browser_close':
520
+ return `${M}[Browser closed]${NC}`;
521
+ case 'gmail_list':
522
+ case 'gmail_read':
523
+ case 'gmail_send':
524
+ case 'gmail_send_attach':
525
+ case 'gmail_reply':
526
+ case 'gmail_draft':
527
+ case 'gmail_mark_read':
528
+ case 'gmail_mark_unread':
529
+ case 'gmail_archive':
530
+ case 'gmail_delete':
531
+ return `${G}[Email]${NC}`;
532
+ case 'calendar_today':
533
+ case 'calendar_tomorrow':
534
+ case 'calendar_upcoming':
535
+ case 'calendar_week':
536
+ case 'calendar_create':
537
+ case 'calendar_move':
538
+ case 'calendar_find':
539
+ case 'calendar_update':
540
+ case 'schedule_meeting':
541
+ case 'schedule_draft_email':
542
+ return `${G}[Calendar]${NC}`;
543
+ case 'task_list':
544
+ case 'task_add':
545
+ case 'task_done':
546
+ case 'task_move':
547
+ case 'task_delete':
548
+ case 'task_clear':
549
+ case 'task_edit':
550
+ return `${G}[Tasks]${NC}`;
551
+ case 'github_issues':
552
+ case 'github_prs':
553
+ case 'github_notifications':
554
+ case 'github_create_issue':
555
+ return `${G}[GitHub]${NC}`;
556
+ case 'notion_search':
557
+ case 'notion_page':
558
+ return `${G}[Notion]${NC}`;
559
+ case 'slack_channels':
560
+ case 'slack_messages':
561
+ case 'slack_send':
562
+ return `${G}[Slack]${NC}`;
563
+ default:
564
+ return `${G}[${action}]${NC}`;
565
+ }
181
566
  }
182
567
 
183
568
  // ── Main REPL ────────────────────────────────────────────────────────────────
@@ -190,12 +575,26 @@ export async function cmdChat(args) {
190
575
  process.exit(1);
191
576
  }
192
577
 
578
+ // Migrate old single-file chat history on first run
579
+ migrateOldHistory();
580
+
581
+ // Load or create active conversation
582
+ let conv = getOrCreateActive();
583
+
193
584
  console.log(`
194
585
  ${BOLD}${C}NHA Chat${NC} ${D}— Personal Operations Assistant${NC}
195
586
  ${D}Type naturally to manage emails, calendar, tasks, GitHub, Notion, Slack.${NC}
196
- ${D}Commands: /tasks /plan /clear /help /quit${NC}
587
+ ${D}Commands: /new /list /export /help /quit${NC}
197
588
  `);
198
589
 
590
+ // Show active conversation info
591
+ const turns = Math.floor(conv.messages.length / 2);
592
+ if (turns > 0) {
593
+ ok(`Conversation: "${conv.title}" (${turns} turns)`);
594
+ } else {
595
+ info(`New conversation started. (${conv.id})`);
596
+ }
597
+
199
598
  info('Loading today\'s context...');
200
599
  let initialContext = '';
201
600
  try {
@@ -214,10 +613,6 @@ export async function cmdChat(args) {
214
613
  terminal: true,
215
614
  });
216
615
 
217
- const history = loadChatHistory();
218
- if (history.length > 0) {
219
- ok(`Loaded ${Math.floor(history.length / 2)} previous conversation turns from memory.`);
220
- }
221
616
  const systemPrompt = buildSystemPrompt('NHA Chat', CHAT_PERSONA, config, initialContext);
222
617
 
223
618
  rl.on('close', () => {
@@ -248,26 +643,59 @@ export async function cmdChat(args) {
248
643
  }
249
644
 
250
645
  if (input.startsWith('/')) {
251
- const handled = await handleSlashCommand(input, config, history);
252
- if (handled) {
646
+ const result = await handleSlashCommand(input, config, conv, rl);
647
+ if (result.handled) {
648
+ // Handle conversation switch
649
+ if (result.switchTo) {
650
+ conv = result.switchTo;
651
+ }
253
652
  rl.prompt();
254
653
  continue;
255
654
  }
256
655
  }
257
656
 
258
657
  try {
259
- const userMessage = serializeHistory(history, input);
658
+ // Handle @agent inline routing
659
+ let effectiveSystemPrompt = systemPrompt;
660
+ let effectiveInput = input;
661
+ const atMatch = input.match(/^@(\w+)\s+(.*)/s);
662
+ if (atMatch) {
663
+ const inlineAgent = atMatch[1].toLowerCase();
664
+ const inlinePrompt = atMatch[2];
665
+ const agentFile = path.join(AGENTS_DIR, `${inlineAgent}.mjs`);
666
+ if (fs.existsSync(agentFile)) {
667
+ const agentSource = fs.readFileSync(agentFile, 'utf-8');
668
+ const { card, systemPrompt: agentSysPrompt } = parseAgentFile(agentSource, inlineAgent);
669
+ if (agentSysPrompt) {
670
+ effectiveSystemPrompt = agentSysPrompt;
671
+ effectiveInput = inlinePrompt;
672
+ process.stdout.write(` ${D}Routing to ${card?.displayName || inlineAgent.toUpperCase()}...${NC}\n`);
673
+ }
674
+ }
675
+ } else if (config._chatAgent) {
676
+ effectiveSystemPrompt = config._chatAgent.systemPrompt;
677
+ }
678
+
679
+ const history = getHistory(conv, MAX_HISTORY);
680
+ const userMessage = serializeHistory(history, effectiveInput);
260
681
 
261
682
  process.stdout.write(`\n ${D}Thinking...${NC}`);
262
- const response = await callLLM(config, systemPrompt, userMessage);
263
- process.stdout.write('\r' + ' '.repeat(40) + '\r');
683
+ let firstToken = true;
684
+ const response = await callLLMStream(config, effectiveSystemPrompt, userMessage, (chunk) => {
685
+ if (firstToken) {
686
+ process.stdout.write('\r' + ' '.repeat(40) + '\r\n ');
687
+ firstToken = false;
688
+ }
689
+ process.stdout.write(chunk);
690
+ });
691
+ if (firstToken) {
692
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
693
+ } else {
694
+ process.stdout.write('\n');
695
+ }
264
696
 
265
697
  const { textParts, actions } = parseActions(response);
266
-
267
- if (textParts.length > 0) {
268
- const text = textParts.join('\n\n');
269
- console.log(`\n ${W}${text}${NC}\n`);
270
- }
698
+ console.log('');
271
699
 
272
700
  for (const { action, params } of actions) {
273
701
  const isDestructive = DESTRUCTIVE_ACTIONS.has(action);
@@ -278,45 +706,35 @@ export async function cmdChat(args) {
278
706
 
279
707
  if (!confirmed) {
280
708
  console.log(` ${D}Cancelled.${NC}\n`);
281
- history.push({ role: 'user', content: input });
282
- history.push({ role: 'assistant', content: response + '\n[User cancelled this action]' });
709
+ addMessages(conv, input, response + '\n[User cancelled this action]');
283
710
  continue;
284
711
  }
285
712
  }
286
713
 
287
714
  try {
288
- process.stdout.write(` ${D}Executing ${action}...${NC}`);
715
+ // Show action-specific indicator
716
+ const toolLabel = formatToolLabel(action, params);
717
+ process.stdout.write(` ${D}${toolLabel}${NC}`);
289
718
  const result = await executeTool(action, params, config);
290
- process.stdout.write('\r' + ' '.repeat(60) + '\r');
291
- console.log(` ${G}Result:${NC}\n ${result.split('\n').join('\n ')}\n`);
292
-
293
- history.push({ role: 'user', content: input });
294
- history.push({
295
- role: 'assistant',
296
- content: response + `\n\n[Tool ${action} executed. Result: ${result}]`,
297
- });
719
+ process.stdout.write('\r' + ' '.repeat(80) + '\r');
720
+
721
+ // Show action-specific result header
722
+ const resultHeader = formatToolResult(action, params, result);
723
+ console.log(` ${resultHeader}`);
724
+ console.log(` ${result.split('\n').join('\n ')}\n`);
725
+
726
+ addMessages(conv, input, response + `\n\n[Tool ${action} executed. Result: ${result}]`);
298
727
  } catch (err) {
299
- process.stdout.write('\r' + ' '.repeat(60) + '\r');
728
+ process.stdout.write('\r' + ' '.repeat(80) + '\r');
300
729
  console.log(` ${R}Error executing ${action}: ${err.message}${NC}\n`);
301
- history.push({ role: 'user', content: input });
302
- history.push({
303
- role: 'assistant',
304
- content: response + `\n\n[Tool ${action} failed: ${err.message}]`,
305
- });
730
+ addMessages(conv, input, response + `\n\n[Tool ${action} failed: ${err.message}]`);
306
731
  }
307
732
  }
308
733
 
309
734
  if (actions.length === 0) {
310
- history.push({ role: 'user', content: input });
311
- history.push({ role: 'assistant', content: response });
312
- }
313
-
314
- while (history.length > MAX_HISTORY * 2) {
315
- history.shift();
316
- history.shift();
735
+ addMessages(conv, input, response);
317
736
  }
318
737
 
319
- try { saveChatHistory(history); } catch { /* non-critical */ }
320
738
  try { extractMemory('chat', input, response); } catch { /* non-critical */ }
321
739
  } catch (err) {
322
740
  process.stdout.write('\r' + ' '.repeat(40) + '\r');