nothumanallowed 9.0.5 → 9.1.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Your agents. Your machine. Your rules.**
4
4
 
5
- 38 specialized AI agents + 50 productivity tools. Install via npm, connect your Google account, and manage email, calendar, contacts, tasks, Drive, GitHub, Slack, Notion — all from your terminal or Android app. 100% local. Zero data on our servers.
5
+ 38 specialized AI agents + 50 productivity tools + web search. Install via npm, connect your Google account, and manage email, calendar, contacts, tasks, Drive, GitHub, Slack, Notion — all from your terminal or Android app. Streaming responses, multi-conversation history, export. 100% local. Zero data on our servers.
6
6
 
7
7
  ## Install
8
8
 
@@ -28,13 +28,15 @@ nha chat
28
28
 
29
29
  ## What You Can Do
30
30
 
31
- ### Chat with 50 Tools
31
+ ### Chat with 50 Tools + Web Search
32
32
 
33
33
  ```bash
34
34
  nha chat
35
35
  ```
36
36
 
37
- Ask naturally. The AI reads your email, manages your calendar, handles tasksall through conversation:
37
+ **Streaming responses** tokens appear as they're generated. **Multi-conversation** `/new`, `/list`, `/switch`, `/rename`, `/delete`. **Export** `/export md`, `/export json`. **Web search** built in.
38
+
39
+ Ask naturally. The AI reads your email, manages your calendar, searches the web, handles tasks — all through conversation:
38
40
 
39
41
  ```
40
42
  You: Read my latest emails
@@ -68,6 +70,7 @@ NHA: Available 60-min slots:
68
70
  | **GitHub** | Notifications, issues, PRs, create issues |
69
71
  | **Slack** | List channels, read messages, send messages |
70
72
  | **Notion** | Search pages/databases, read page content |
73
+ | **Web** | Web search (DuckDuckGo), fetch URL content, deep search with page extraction |
71
74
  | **Other** | Maps directions, reminders, file reading |
72
75
 
73
76
  Every tool is called directly from your machine to the provider's API. NHA servers are never involved.
@@ -226,10 +229,35 @@ Your Machine Provider APIs
226
229
 
227
230
  Anthropic (Claude), OpenAI (GPT), Google (Gemini), DeepSeek, xAI (Grok), Mistral, Cohere.
228
231
 
232
+ ## Chat Commands
233
+
234
+ Inside `nha chat`, use slash commands:
235
+
236
+ ```
237
+ /new Start a new conversation
238
+ /list List all conversations
239
+ /switch <id> Switch to a conversation
240
+ /rename <title> Rename current conversation
241
+ /delete <id> Delete a conversation
242
+ /export Export as Markdown (saved to ~/)
243
+ /export json Export as JSON (saved to ~/)
244
+ /agents List available agents
245
+ /agent <name> Switch to chatting with a specific agent
246
+ /agent off Return to NHA Chat
247
+ /create-agent Create a custom agent
248
+ /tasks Show today's tasks
249
+ /plan Run daily planner
250
+ /clear Clear current conversation
251
+ /help Show all commands
252
+ /quit Exit
253
+ ```
254
+
255
+ Inline agent routing: `@saber audit this function for SQL injection`
256
+
229
257
  ## Commands
230
258
 
231
259
  ```bash
232
- # Chat (50 tools)
260
+ # Chat (50 tools + web search, streaming)
233
261
  nha chat # Interactive chat with tools
234
262
  nha voice # Voice chat with TTS
235
263
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "9.0.5",
4
- "description": "NotHumanAllowed — 38 AI agents + unified productivity suite. Gmail, Calendar, Drive, Contacts, Tasks, GitHub, Notion, Slack, voice chat, smart scheduler. Zero-dependency CLI.",
3
+ "version": "9.1.1",
4
+ "description": "NotHumanAllowed — 38 AI agents + 50 tools + web search. Streaming chat, multi-conversation, export. Gmail, Calendar, Drive, GitHub, Notion, Slack. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "nha": "./bin/nha.mjs",
@@ -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
  *
@@ -13,11 +16,12 @@
13
16
  import readline from 'readline';
14
17
  import fs from 'fs';
15
18
  import path from 'path';
19
+ import os from 'os';
16
20
  import { loadConfig } from '../config.mjs';
17
21
  import { AGENTS_DIR, AGENTS } from '../constants.mjs';
18
- import { callLLM, parseAgentFile } from '../services/llm.mjs';
22
+ import { callLLMStream, parseAgentFile } from '../services/llm.mjs';
19
23
 
20
- import { loadChatHistory, saveChatHistory, extractMemory } from '../services/memory.mjs';
24
+ import { extractMemory } from '../services/memory.mjs';
21
25
  import { fail, info, ok, warn, C, G, Y, D, W, BOLD, NC, R } from '../ui.mjs';
22
26
  import {
23
27
  DESTRUCTIVE_ACTIONS,
@@ -33,6 +37,20 @@ import {
33
37
  getUnreadImportant,
34
38
  } from '../services/mail-router.mjs';
35
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';
36
54
 
37
55
  // ── Constants ────────────────────────────────────────────────────────────────
38
56
 
@@ -119,9 +137,23 @@ async function fetchInitialContext(config) {
119
137
  return parts.join('\n\n');
120
138
  }
121
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
+
122
154
  // ── Slash Command Handlers ───────────────────────────────────────────────────
123
155
 
124
- async function handleSlashCommand(input, config, history) {
156
+ async function handleSlashCommand(input, config, conv, rl) {
125
157
  const trimmed = input.trim();
126
158
 
127
159
  if (trimmed === '/quit' || trimmed === '/exit' || trimmed === '/q') {
@@ -129,12 +161,106 @@ async function handleSlashCommand(input, config, history) {
129
161
  process.exit(0);
130
162
  }
131
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
+
132
258
  if (trimmed === '/clear') {
133
- history.length = 0;
134
- try { saveChatHistory([]); } catch { /* non-critical */ }
259
+ conv.messages.length = 0;
260
+ saveConversation(conv);
135
261
  console.clear();
136
- console.log(` ${G}Conversation cleared (memory preserved, chat history reset).${NC}`);
137
- return true;
262
+ console.log(` ${G}Conversation cleared (memory preserved).${NC}`);
263
+ return { handled: true };
138
264
  }
139
265
 
140
266
  if (trimmed === '/tasks') {
@@ -151,7 +277,7 @@ async function handleSlashCommand(input, config, history) {
151
277
  } catch (err) {
152
278
  console.log(` ${R}Could not load tasks: ${err.message}${NC}`);
153
279
  }
154
- return true;
280
+ return { handled: true };
155
281
  }
156
282
 
157
283
  if (trimmed === '/plan') {
@@ -161,34 +287,34 @@ async function handleSlashCommand(input, config, history) {
161
287
  } catch (err) {
162
288
  console.log(` ${R}Plan error: ${err.message}${NC}`);
163
289
  }
164
- return true;
290
+ return { handled: true };
165
291
  }
166
292
 
167
293
  // /agent <name> — switch to talking with a specific agent
168
294
  if (trimmed.startsWith('/agent ')) {
169
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
+
170
303
  const agentFile = path.join(AGENTS_DIR, `${agentName}.mjs`);
171
304
  if (!fs.existsSync(agentFile)) {
172
305
  console.log(` ${R}Agent "${agentName}" not found. Available: ${AGENTS.join(', ')}${NC}`);
173
- return true;
306
+ return { handled: true };
174
307
  }
175
308
  const agentSource = fs.readFileSync(agentFile, 'utf-8');
176
309
  const { card, systemPrompt: agentSysPrompt } = parseAgentFile(agentSource, agentName);
177
310
  if (agentSysPrompt) {
178
- // Store agent context for subsequent messages
179
311
  config._chatAgent = { name: agentName, systemPrompt: agentSysPrompt, card };
180
312
  console.log(` ${G}Now chatting with ${BOLD}${card?.displayName || agentName.toUpperCase()}${NC}${G} (${card?.tagline || 'agent'})${NC}`);
181
313
  console.log(` ${D}Type /agent off to return to NHA Chat${NC}`);
182
314
  } else {
183
315
  console.log(` ${R}Agent "${agentName}" has no system prompt.${NC}`);
184
316
  }
185
- return true;
186
- }
187
-
188
- if (trimmed === '/agent off' || trimmed === '/agent reset') {
189
- delete config._chatAgent;
190
- console.log(` ${G}Switched back to NHA Chat.${NC}`);
191
- return true;
317
+ return { handled: true };
192
318
  }
193
319
 
194
320
  // /create-agent <name> "<tagline>" "<system prompt>"
@@ -198,24 +324,21 @@ async function handleSlashCommand(input, config, history) {
198
324
  console.log(`\n ${BOLD}${Y}Create Custom Agent${NC}`);
199
325
  console.log(` Usage: ${C}/create-agent mybot "Short description" "You are an expert in..."${NC}`);
200
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`);
201
- return true;
327
+ return { handled: true };
202
328
  }
203
- // Parse: name "tagline" "system prompt"
204
329
  const nameMatch = parts.match(/^(\S+)\s+(.+)/);
205
330
  if (!nameMatch) {
206
331
  console.log(` ${R}Usage: /create-agent <name> "<tagline>" "<system prompt>"${NC}`);
207
- return true;
332
+ return { handled: true };
208
333
  }
209
334
  const name = nameMatch[1].toLowerCase().replace(/[^a-z0-9_-]/g, '');
210
335
  const rest = nameMatch[2];
211
- // Split remaining by quotes
212
336
  const quoteParts = rest.match(/"([^"]*)"/g);
213
337
  let tagline = '', sysPrompt = '';
214
338
  if (quoteParts && quoteParts.length >= 2) {
215
339
  tagline = quoteParts[0].replace(/"/g, '');
216
340
  sysPrompt = quoteParts[1].replace(/"/g, '');
217
341
  } else {
218
- // Fallback: first sentence is tagline, rest is prompt
219
342
  const firstDot = rest.indexOf('.');
220
343
  if (firstDot > 0) {
221
344
  tagline = rest.slice(0, firstDot).replace(/"/g, '').trim();
@@ -228,13 +351,13 @@ async function handleSlashCommand(input, config, history) {
228
351
 
229
352
  if (!name || !tagline || !sysPrompt) {
230
353
  console.log(` ${R}All fields required. Usage: /create-agent name "tagline" "system prompt"${NC}`);
231
- return true;
354
+ return { handled: true };
232
355
  }
233
356
 
234
357
  const agentFile = path.join(AGENTS_DIR, `${name}.mjs`);
235
358
  if (fs.existsSync(agentFile)) {
236
359
  console.log(` ${R}Agent "${name}" already exists.${NC}`);
237
- return true;
360
+ return { handled: true };
238
361
  }
239
362
 
240
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`;
@@ -242,7 +365,7 @@ async function handleSlashCommand(input, config, history) {
242
365
  fs.writeFileSync(agentFile, content, 'utf-8');
243
366
  console.log(` ${G}Agent "${name}" created!${NC}`);
244
367
  console.log(` ${D}Switch to it: /agent ${name}${NC}`);
245
- return true;
368
+ return { handled: true };
246
369
  }
247
370
 
248
371
  // /agents — list available agents
@@ -258,34 +381,146 @@ async function handleSlashCommand(input, config, history) {
258
381
  console.log(` ${C}${a}${NC}`);
259
382
  }
260
383
  console.log(`\n ${D}Switch: /agent <name> | Create: /create-agent${NC}`);
261
- return true;
384
+ return { handled: true };
262
385
  }
263
386
 
264
387
  if (trimmed === '/help') {
265
388
  console.log(`
266
389
  ${BOLD}Chat Commands${NC}
267
390
 
268
- ${C}/tasks${NC} Show today's tasks
269
- ${C}/plan${NC} Run daily planner
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}
270
401
  ${C}/agents${NC} List available agents
271
402
  ${C}/agent <name>${NC} Switch to chatting with a specific agent
272
403
  ${C}/agent off${NC} Return to NHA Chat
273
- ${C}/create-agent${NC} Create a new custom agent interactively
274
- ${C}/clear${NC} Clear conversation history
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
275
410
  ${C}/help${NC} Show this help
276
411
  ${C}/quit${NC} Exit chat
277
412
 
278
- ${D}You can also type @agent in any message to route it to that agent.
413
+ ${D}Tip: Type @agent in any message to route it inline.
279
414
  Example: "@saber audit this function for SQL injection"
280
415
 
281
- Otherwise, just type naturally — the AI understands
282
- requests like "show my unread emails", "add a task to review PR #42",
283
- "what's on my calendar tomorrow?", "list GitHub issues", etc.${NC}
416
+ Type naturally — "show my unread emails", "add a task",
417
+ "what's on my calendar tomorrow?", "list GitHub issues"${NC}
284
418
  `);
285
- return true;
419
+ return { handled: true };
286
420
  }
287
421
 
288
- 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 'gmail_list':
437
+ return `Searching emails...`;
438
+ case 'gmail_read':
439
+ return `Reading email...`;
440
+ case 'gmail_send':
441
+ case 'gmail_send_attach':
442
+ return `Sending email to ${params.to || '...'}...`;
443
+ case 'gmail_reply':
444
+ return `Sending reply...`;
445
+ case 'calendar_create':
446
+ return `Creating event "${params.summary || '...'}"...`;
447
+ case 'calendar_today':
448
+ case 'calendar_tomorrow':
449
+ case 'calendar_upcoming':
450
+ case 'calendar_week':
451
+ return `Loading calendar...`;
452
+ case 'github_issues':
453
+ case 'github_prs':
454
+ return `Fetching from GitHub...`;
455
+ case 'notion_search':
456
+ return `Searching Notion...`;
457
+ case 'slack_messages':
458
+ case 'slack_channels':
459
+ return `Loading Slack...`;
460
+ default:
461
+ return `Executing ${action}...`;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Format a result header with visual indicator based on tool type.
467
+ */
468
+ function formatToolResult(action, params, result) {
469
+ switch (action) {
470
+ case 'web_search': {
471
+ const count = (result.match(/\d+\. /g) || []).length;
472
+ const deep = params.deep ? ', deep mode' : '';
473
+ return `${C}[Web Search: ${count} results${deep}]${NC}`;
474
+ }
475
+ case 'fetch_url': {
476
+ const domain = (params.url || '').replace(/^https?:\/\//, '').split('/')[0];
477
+ return `${C}[Fetched: ${domain}]${NC}`;
478
+ }
479
+ case 'gmail_list':
480
+ case 'gmail_read':
481
+ case 'gmail_send':
482
+ case 'gmail_send_attach':
483
+ case 'gmail_reply':
484
+ case 'gmail_draft':
485
+ case 'gmail_mark_read':
486
+ case 'gmail_mark_unread':
487
+ case 'gmail_archive':
488
+ case 'gmail_delete':
489
+ return `${G}[Email]${NC}`;
490
+ case 'calendar_today':
491
+ case 'calendar_tomorrow':
492
+ case 'calendar_upcoming':
493
+ case 'calendar_week':
494
+ case 'calendar_create':
495
+ case 'calendar_move':
496
+ case 'calendar_find':
497
+ case 'calendar_update':
498
+ case 'schedule_meeting':
499
+ case 'schedule_draft_email':
500
+ return `${G}[Calendar]${NC}`;
501
+ case 'task_list':
502
+ case 'task_add':
503
+ case 'task_done':
504
+ case 'task_move':
505
+ case 'task_delete':
506
+ case 'task_clear':
507
+ case 'task_edit':
508
+ return `${G}[Tasks]${NC}`;
509
+ case 'github_issues':
510
+ case 'github_prs':
511
+ case 'github_notifications':
512
+ case 'github_create_issue':
513
+ return `${G}[GitHub]${NC}`;
514
+ case 'notion_search':
515
+ case 'notion_page':
516
+ return `${G}[Notion]${NC}`;
517
+ case 'slack_channels':
518
+ case 'slack_messages':
519
+ case 'slack_send':
520
+ return `${G}[Slack]${NC}`;
521
+ default:
522
+ return `${G}[${action}]${NC}`;
523
+ }
289
524
  }
290
525
 
291
526
  // ── Main REPL ────────────────────────────────────────────────────────────────
@@ -298,12 +533,26 @@ export async function cmdChat(args) {
298
533
  process.exit(1);
299
534
  }
300
535
 
536
+ // Migrate old single-file chat history on first run
537
+ migrateOldHistory();
538
+
539
+ // Load or create active conversation
540
+ let conv = getOrCreateActive();
541
+
301
542
  console.log(`
302
543
  ${BOLD}${C}NHA Chat${NC} ${D}— Personal Operations Assistant${NC}
303
544
  ${D}Type naturally to manage emails, calendar, tasks, GitHub, Notion, Slack.${NC}
304
- ${D}Commands: /tasks /plan /clear /help /quit${NC}
545
+ ${D}Commands: /new /list /export /help /quit${NC}
305
546
  `);
306
547
 
548
+ // Show active conversation info
549
+ const turns = Math.floor(conv.messages.length / 2);
550
+ if (turns > 0) {
551
+ ok(`Conversation: "${conv.title}" (${turns} turns)`);
552
+ } else {
553
+ info(`New conversation started. (${conv.id})`);
554
+ }
555
+
307
556
  info('Loading today\'s context...');
308
557
  let initialContext = '';
309
558
  try {
@@ -322,10 +571,6 @@ export async function cmdChat(args) {
322
571
  terminal: true,
323
572
  });
324
573
 
325
- const history = loadChatHistory();
326
- if (history.length > 0) {
327
- ok(`Loaded ${Math.floor(history.length / 2)} previous conversation turns from memory.`);
328
- }
329
574
  const systemPrompt = buildSystemPrompt('NHA Chat', CHAT_PERSONA, config, initialContext);
330
575
 
331
576
  rl.on('close', () => {
@@ -356,8 +601,12 @@ export async function cmdChat(args) {
356
601
  }
357
602
 
358
603
  if (input.startsWith('/')) {
359
- const handled = await handleSlashCommand(input, config, history);
360
- if (handled) {
604
+ const result = await handleSlashCommand(input, config, conv, rl);
605
+ if (result.handled) {
606
+ // Handle conversation switch
607
+ if (result.switchTo) {
608
+ conv = result.switchTo;
609
+ }
361
610
  rl.prompt();
362
611
  continue;
363
612
  }
@@ -382,22 +631,29 @@ export async function cmdChat(args) {
382
631
  }
383
632
  }
384
633
  } else if (config._chatAgent) {
385
- // Persistent agent mode via /agent <name>
386
634
  effectiveSystemPrompt = config._chatAgent.systemPrompt;
387
635
  }
388
636
 
637
+ const history = getHistory(conv, MAX_HISTORY);
389
638
  const userMessage = serializeHistory(history, effectiveInput);
390
639
 
391
640
  process.stdout.write(`\n ${D}Thinking...${NC}`);
392
- const response = await callLLM(config, effectiveSystemPrompt, userMessage);
393
- process.stdout.write('\r' + ' '.repeat(40) + '\r');
641
+ let firstToken = true;
642
+ const response = await callLLMStream(config, effectiveSystemPrompt, userMessage, (chunk) => {
643
+ if (firstToken) {
644
+ process.stdout.write('\r' + ' '.repeat(40) + '\r\n ');
645
+ firstToken = false;
646
+ }
647
+ process.stdout.write(chunk);
648
+ });
649
+ if (firstToken) {
650
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
651
+ } else {
652
+ process.stdout.write('\n');
653
+ }
394
654
 
395
655
  const { textParts, actions } = parseActions(response);
396
-
397
- if (textParts.length > 0) {
398
- const text = textParts.join('\n\n');
399
- console.log(`\n ${W}${text}${NC}\n`);
400
- }
656
+ console.log('');
401
657
 
402
658
  for (const { action, params } of actions) {
403
659
  const isDestructive = DESTRUCTIVE_ACTIONS.has(action);
@@ -408,45 +664,35 @@ export async function cmdChat(args) {
408
664
 
409
665
  if (!confirmed) {
410
666
  console.log(` ${D}Cancelled.${NC}\n`);
411
- history.push({ role: 'user', content: input });
412
- history.push({ role: 'assistant', content: response + '\n[User cancelled this action]' });
667
+ addMessages(conv, input, response + '\n[User cancelled this action]');
413
668
  continue;
414
669
  }
415
670
  }
416
671
 
417
672
  try {
418
- process.stdout.write(` ${D}Executing ${action}...${NC}`);
673
+ // Show action-specific indicator
674
+ const toolLabel = formatToolLabel(action, params);
675
+ process.stdout.write(` ${D}${toolLabel}${NC}`);
419
676
  const result = await executeTool(action, params, config);
420
- process.stdout.write('\r' + ' '.repeat(60) + '\r');
421
- console.log(` ${G}Result:${NC}\n ${result.split('\n').join('\n ')}\n`);
422
-
423
- history.push({ role: 'user', content: input });
424
- history.push({
425
- role: 'assistant',
426
- content: response + `\n\n[Tool ${action} executed. Result: ${result}]`,
427
- });
677
+ process.stdout.write('\r' + ' '.repeat(80) + '\r');
678
+
679
+ // Show action-specific result header
680
+ const resultHeader = formatToolResult(action, params, result);
681
+ console.log(` ${resultHeader}`);
682
+ console.log(` ${result.split('\n').join('\n ')}\n`);
683
+
684
+ addMessages(conv, input, response + `\n\n[Tool ${action} executed. Result: ${result}]`);
428
685
  } catch (err) {
429
- process.stdout.write('\r' + ' '.repeat(60) + '\r');
686
+ process.stdout.write('\r' + ' '.repeat(80) + '\r');
430
687
  console.log(` ${R}Error executing ${action}: ${err.message}${NC}\n`);
431
- history.push({ role: 'user', content: input });
432
- history.push({
433
- role: 'assistant',
434
- content: response + `\n\n[Tool ${action} failed: ${err.message}]`,
435
- });
688
+ addMessages(conv, input, response + `\n\n[Tool ${action} failed: ${err.message}]`);
436
689
  }
437
690
  }
438
691
 
439
692
  if (actions.length === 0) {
440
- history.push({ role: 'user', content: input });
441
- history.push({ role: 'assistant', content: response });
442
- }
443
-
444
- while (history.length > MAX_HISTORY * 2) {
445
- history.shift();
446
- history.shift();
693
+ addMessages(conv, input, response);
447
694
  }
448
695
 
449
- try { saveChatHistory(history); } catch { /* non-critical */ }
450
696
  try { extractMemory('chat', input, response); } catch { /* non-critical */ }
451
697
  } catch (err) {
452
698
  process.stdout.write('\r' + ' '.repeat(40) + '\r');