nothumanallowed 2.0.0 → 2.1.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.
package/README.md CHANGED
@@ -20,6 +20,46 @@ nha ask oracle "Analyze this dataset" --file data.csv
20
20
  nha run "Design a Kubernetes deployment for a 10K RPS API"
21
21
  ```
22
22
 
23
+ ## Daily Operations (PAO)
24
+
25
+ Connect Gmail + Calendar. 5 specialist agents analyze your day.
26
+
27
+ ```bash
28
+ # Connect Google (one-time)
29
+ nha config set google-client-id YOUR_ID
30
+ nha config set google-client-secret YOUR_SECRET
31
+ nha google auth
32
+
33
+ # Generate your daily plan
34
+ nha plan
35
+
36
+ # Manage tasks
37
+ nha tasks add "Review PR #42" --priority high
38
+ nha tasks done 1
39
+ nha tasks week
40
+
41
+ # Background daemon (auto-alerts before meetings, email security scans)
42
+ nha ops start
43
+ ```
44
+
45
+ **What `nha plan` does:**
46
+ 1. **Fetches** your emails + calendar events + tasks
47
+ 2. **SABER** scans emails for phishing and security threats
48
+ 3. **HERALD** generates intelligence briefs for each meeting
49
+ 4. **ORACLE** analyzes schedule patterns and productivity
50
+ 5. **SCHEHERAZADE** prepares talking points for meetings
51
+ 6. **CONDUCTOR** synthesizes everything into a structured daily plan
52
+
53
+ OpenClaw reads your email with 1 generic agent. NHA sends it through 5 specialists.
54
+
55
+ ### Privacy
56
+
57
+ **Zero data touches NHA servers.** The only network calls are:
58
+ - Google APIs (your OAuth token, direct from your machine)
59
+ - Your LLM provider (your API key, direct from your machine)
60
+
61
+ All data stored locally in `~/.nha/ops/`. Tokens encrypted with AES-256-GCM. You own everything. Inspect it, delete it, export it anytime.
62
+
23
63
  ## The Agents
24
64
 
25
65
  38 agents across 11 domains. Each agent is a standalone `.mjs` file you own locally — inspect it, modify it, run it offline.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "NotHumanAllowed — 38 AI agents for security, code, DevOps, data & daily ops. Ask agents directly, plan your day with 5 specialist agents, manage tasks, connect Gmail + Calendar.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -12,6 +12,7 @@ import { cmdAsk } from './commands/ask.mjs';
12
12
  import { cmdPlan } from './commands/plan.mjs';
13
13
  import { cmdTasks } from './commands/tasks.mjs';
14
14
  import { cmdOps } from './commands/ops.mjs';
15
+ import { cmdChat } from './commands/chat.mjs';
15
16
  import { cmdGoogle } from './commands/google-auth.mjs';
16
17
  import { banner, info, ok, warn, fail, C, G, Y, D, W, BOLD, NC, M, B, R } from './ui.mjs';
17
18
 
@@ -54,6 +55,9 @@ export async function main(argv) {
54
55
  case 'ops':
55
56
  return cmdOps(args);
56
57
 
58
+ case 'chat':
59
+ return cmdChat(args);
60
+
57
61
  case 'google':
58
62
  return cmdGoogle(args);
59
63
 
@@ -351,6 +355,7 @@ function cmdHelp() {
351
355
  console.log(` run "prompt" ${D}--agents saber,zero${NC} Collaborate with specific agents\n`);
352
356
 
353
357
  console.log(` ${C}Daily Operations${NC} ${D}(Gmail + Calendar + Tasks)${NC}`);
358
+ console.log(` chat Interactive chat — manage email/calendar/tasks naturally`);
354
359
  console.log(` plan Generate daily plan (5 agents analyze your day)`);
355
360
  console.log(` plan --refresh Regenerate today's plan`);
356
361
  console.log(` tasks List today's tasks`);
@@ -0,0 +1,730 @@
1
+ /**
2
+ * nha chat — Interactive conversational REPL for PAO (Personal Agent Ops).
3
+ *
4
+ * The user types natural language; an LLM interprets intent, optionally
5
+ * invokes Gmail / Calendar / Tasks APIs via a structured JSON action protocol,
6
+ * and responds conversationally.
7
+ *
8
+ * Zero npm dependencies — Node.js 22 built-in readline only.
9
+ */
10
+
11
+ import readline from 'readline';
12
+ import { loadConfig } from '../config.mjs';
13
+ import { callLLM } from '../services/llm.mjs';
14
+ import { fail, info, ok, warn, C, G, Y, D, W, BOLD, NC, M, R, B } from '../ui.mjs';
15
+
16
+ // ── Gmail imports ────────────────────────────────────────────────────────────
17
+ import {
18
+ listMessages,
19
+ getMessage,
20
+ getUnreadImportant,
21
+ sendEmail,
22
+ createDraft,
23
+ } from '../services/google-gmail.mjs';
24
+
25
+ // ── Calendar imports ─────────────────────────────────────────────────────────
26
+ import {
27
+ getTodayEvents,
28
+ getUpcomingEvents,
29
+ getEventsForDate,
30
+ createEvent,
31
+ updateEvent,
32
+ } from '../services/google-calendar.mjs';
33
+
34
+ // ── Task imports ─────────────────────────────────────────────────────────────
35
+ import {
36
+ getTasks,
37
+ addTask,
38
+ completeTask,
39
+ moveTask,
40
+ } from '../services/task-store.mjs';
41
+
42
+ // ── Notification imports ─────────────────────────────────────────────────────
43
+ import { notify } from '../services/notification.mjs';
44
+
45
+ // ── Constants ────────────────────────────────────────────────────────────────
46
+
47
+ const MAX_HISTORY = 20;
48
+
49
+ /** Actions that mutate external state and require user confirmation. */
50
+ const DESTRUCTIVE_ACTIONS = new Set([
51
+ 'gmail_send',
52
+ 'gmail_reply',
53
+ 'calendar_create',
54
+ 'calendar_move',
55
+ 'task_done',
56
+ 'notify_remind',
57
+ ]);
58
+
59
+ // ── Tool Definitions (for system prompt) ─────────────────────────────────────
60
+
61
+ const TOOL_DEFINITIONS = `
62
+ You have access to the following tools. When the user's message requires an action,
63
+ output EXACTLY ONE fenced JSON block per action:
64
+
65
+ \`\`\`json
66
+ {"action": "<tool_name>", "params": { ... }}
67
+ \`\`\`
68
+
69
+ You may include conversational text BEFORE or AFTER the JSON block. If no action
70
+ is needed, respond normally without any JSON block.
71
+
72
+ TOOLS:
73
+
74
+ 1. gmail_list(query: string, maxResults?: number)
75
+ Search emails. query uses Gmail search syntax (e.g. "from:boss@co.com", "is:unread subject:invoice").
76
+ Default maxResults = 10.
77
+
78
+ 2. gmail_read(messageId: string)
79
+ Read the full body of an email by its ID (returned by gmail_list).
80
+
81
+ 3. gmail_send(to: string, subject: string, body: string)
82
+ Send an email. ALWAYS confirm with the user before sending.
83
+
84
+ 4. gmail_draft(to: string, subject: string, body: string)
85
+ Create a draft email (safe — does not send).
86
+
87
+ 5. gmail_reply(messageId: string, body: string)
88
+ Reply to an existing email thread. ALWAYS confirm before sending.
89
+
90
+ 6. calendar_today()
91
+ List all events for today.
92
+
93
+ 7. calendar_tomorrow()
94
+ List all events for tomorrow.
95
+
96
+ 8. calendar_upcoming(hours?: number)
97
+ List upcoming events in the next N hours (default 2).
98
+
99
+ 9. calendar_create(summary: string, start: string, end: string, attendees?: string[], description?: string)
100
+ Create a calendar event. start/end are ISO 8601 datetime strings.
101
+ ALWAYS confirm with the user before creating.
102
+
103
+ 10. calendar_move(eventId: string, newStart: string, newEnd: string)
104
+ Reschedule an event. ALWAYS confirm before moving.
105
+
106
+ 11. task_list()
107
+ List today's tasks.
108
+
109
+ 12. task_add(description: string, priority?: "low"|"medium"|"high"|"critical", due?: string)
110
+ Add a new task for today.
111
+
112
+ 13. task_done(id: number)
113
+ Mark a task as completed. Confirm before completing.
114
+
115
+ 14. task_move(id: number, toDate: string)
116
+ Move a task to another date (YYYY-MM-DD).
117
+
118
+ 15. notify_remind(message: string, atTime: string)
119
+ Set a desktop reminder. atTime is ISO 8601 or relative like "in 30 minutes".
120
+
121
+ RULES:
122
+ - For search/read operations, execute immediately and present results conversationally.
123
+ - For write/send/delete operations (gmail_send, gmail_reply, calendar_create, calendar_move, task_done, notify_remind), DESCRIBE what you're about to do and include the JSON block so the system can ask the user for confirmation.
124
+ - When presenting email results, show From, Subject, Date, and a brief snippet. Never dump raw JSON.
125
+ - When presenting calendar events, show Time, Title, Location/Link. Format times in a human-readable way.
126
+ - When presenting tasks, show ID, Description, Priority, Status.
127
+ - If you need multiple actions in sequence (e.g., read an email then reply), do them ONE AT A TIME — wait for the result of each before proceeding.
128
+ - Dates: today is {{TODAY}}. Infer relative dates from this.
129
+ - The user's timezone is {{TIMEZONE}}.
130
+ `.trim();
131
+
132
+ // ── Tool Executor ────────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Execute a parsed tool action and return a human-readable result string.
136
+ * @param {string} action — tool name
137
+ * @param {object} params — tool parameters
138
+ * @param {object} config — NHA config
139
+ * @returns {Promise<string>} result description
140
+ */
141
+ async function executeTool(action, params, config) {
142
+ switch (action) {
143
+ // ── Gmail ──────────────────────────────────────────────────────────────
144
+ case 'gmail_list': {
145
+ const query = params.query || 'is:unread';
146
+ const max = params.maxResults || 10;
147
+ const refs = await listMessages(config, query, max);
148
+ if (refs.length === 0) return 'No emails found matching that query.';
149
+
150
+ const messages = [];
151
+ for (const ref of refs.slice(0, max)) {
152
+ try {
153
+ const msg = await getMessage(config, ref.id);
154
+ messages.push(msg);
155
+ } catch { /* skip failed */ }
156
+ }
157
+
158
+ return messages.map((m, i) =>
159
+ `${i + 1}. [${m.id}] From: ${m.from} | Subject: ${m.subject} | Date: ${m.date}\n ${m.snippet.slice(0, 120)}`
160
+ ).join('\n');
161
+ }
162
+
163
+ case 'gmail_read': {
164
+ const msg = await getMessage(config, params.messageId);
165
+ return [
166
+ `From: ${msg.from}`,
167
+ `To: ${msg.to}`,
168
+ `Subject: ${msg.subject}`,
169
+ `Date: ${msg.date}`,
170
+ `---`,
171
+ msg.body.slice(0, 4000),
172
+ ].join('\n');
173
+ }
174
+
175
+ case 'gmail_send': {
176
+ await sendEmail(config, params.to, params.subject, params.body);
177
+ return `Email sent to ${params.to} with subject "${params.subject}".`;
178
+ }
179
+
180
+ case 'gmail_draft': {
181
+ await createDraft(config, params.to, params.subject, params.body);
182
+ return `Draft created for ${params.to} with subject "${params.subject}".`;
183
+ }
184
+
185
+ case 'gmail_reply': {
186
+ const original = await getMessage(config, params.messageId);
187
+ await sendEmail(config, original.from, `Re: ${original.subject}`, params.body, {
188
+ replyToMessageId: original.id,
189
+ threadId: original.threadId,
190
+ });
191
+ return `Reply sent to ${original.from} on thread "${original.subject}".`;
192
+ }
193
+
194
+ // ── Calendar ──────────────────────────────────────────────────────────
195
+ case 'calendar_today': {
196
+ const events = await getTodayEvents(config);
197
+ if (events.length === 0) return 'No events scheduled for today.';
198
+ return formatEvents(events);
199
+ }
200
+
201
+ case 'calendar_tomorrow': {
202
+ const tomorrow = new Date();
203
+ tomorrow.setDate(tomorrow.getDate() + 1);
204
+ const dateStr = tomorrow.toISOString().split('T')[0];
205
+ const events = await getEventsForDate(config, dateStr);
206
+ if (events.length === 0) return 'No events scheduled for tomorrow.';
207
+ return formatEvents(events);
208
+ }
209
+
210
+ case 'calendar_upcoming': {
211
+ const hours = params.hours || 2;
212
+ const events = await getUpcomingEvents(config, hours);
213
+ if (events.length === 0) return `No events in the next ${hours} hour(s).`;
214
+ return formatEvents(events);
215
+ }
216
+
217
+ case 'calendar_create': {
218
+ const result = await createEvent(config, {
219
+ summary: params.summary,
220
+ start: params.start,
221
+ end: params.end,
222
+ description: params.description || '',
223
+ attendees: params.attendees || [],
224
+ });
225
+ return `Event "${params.summary}" created for ${formatTime(params.start)} - ${formatTime(params.end)}.`;
226
+ }
227
+
228
+ case 'calendar_move': {
229
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
230
+ await updateEvent(config, 'primary', params.eventId, {
231
+ start: { dateTime: new Date(params.newStart).toISOString(), timeZone: tz },
232
+ end: { dateTime: new Date(params.newEnd).toISOString(), timeZone: tz },
233
+ });
234
+ return `Event rescheduled to ${formatTime(params.newStart)} - ${formatTime(params.newEnd)}.`;
235
+ }
236
+
237
+ // ── Tasks ─────────────────────────────────────────────────────────────
238
+ case 'task_list': {
239
+ const tasks = getTasks();
240
+ if (tasks.length === 0) return 'No tasks for today.';
241
+ return tasks.map(t =>
242
+ `#${t.id} [${t.priority}] ${t.status === 'done' ? '[DONE] ' : ''}${t.description}${t.due ? ' (due: ' + t.due + ')' : ''}`
243
+ ).join('\n');
244
+ }
245
+
246
+ case 'task_add': {
247
+ const task = addTask({
248
+ description: params.description,
249
+ priority: params.priority || 'medium',
250
+ due: params.due || null,
251
+ source: 'chat',
252
+ });
253
+ return `Task #${task.id} added: "${task.description}" [${task.priority}]`;
254
+ }
255
+
256
+ case 'task_done': {
257
+ const success = completeTask(params.id);
258
+ return success ? `Task #${params.id} marked as done.` : `Task #${params.id} not found.`;
259
+ }
260
+
261
+ case 'task_move': {
262
+ const todayStr = new Date().toISOString().split('T')[0];
263
+ const success = moveTask(params.id, todayStr, params.toDate);
264
+ return success ? `Task #${params.id} moved to ${params.toDate}.` : `Task #${params.id} not found.`;
265
+ }
266
+
267
+ // ── Notifications ─────────────────────────────────────────────────────
268
+ case 'notify_remind': {
269
+ const atTime = resolveTime(params.atTime);
270
+ const delayMs = atTime.getTime() - Date.now();
271
+
272
+ if (delayMs <= 0) {
273
+ await notify('Reminder', params.message, config);
274
+ return `Reminder sent now: "${params.message}"`;
275
+ }
276
+
277
+ // Schedule in-process timer (will only persist while the REPL is running)
278
+ setTimeout(async () => {
279
+ try {
280
+ await notify('Reminder', params.message, config);
281
+ } catch { /* best effort */ }
282
+ }, Math.min(delayMs, 86400000)); // cap at 24h
283
+
284
+ const minutes = Math.round(delayMs / 60000);
285
+ return `Reminder set for ${formatTime(atTime.toISOString())} (in ~${minutes} min): "${params.message}"`;
286
+ }
287
+
288
+ default:
289
+ return `Unknown action: ${action}`;
290
+ }
291
+ }
292
+
293
+ // ── Formatting Helpers ───────────────────────────────────────────────────────
294
+
295
+ function formatEvents(events) {
296
+ return events.map((e, i) => {
297
+ const time = e.isAllDay ? 'All day' : `${formatTime(e.start)} - ${formatTime(e.end)}`;
298
+ const location = e.location ? ` | ${e.location}` : '';
299
+ const link = e.hangoutLink ? ` | ${e.hangoutLink}` : '';
300
+ const cal = e.calendarName ? ` (${e.calendarName})` : '';
301
+ return `${i + 1}. ${time} — ${e.summary}${location}${link}${cal}`;
302
+ }).join('\n');
303
+ }
304
+
305
+ function formatTime(isoStr) {
306
+ try {
307
+ const d = new Date(isoStr);
308
+ return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
309
+ } catch {
310
+ return isoStr;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Resolve a time string into a Date. Supports ISO 8601 and relative formats
316
+ * like "in 30 minutes", "in 2 hours".
317
+ */
318
+ function resolveTime(timeStr) {
319
+ // Try ISO parse first
320
+ const direct = new Date(timeStr);
321
+ if (!isNaN(direct.getTime()) && timeStr.includes('T')) return direct;
322
+
323
+ // Relative: "in X minutes/hours"
324
+ const relMatch = timeStr.match(/in\s+(\d+)\s+(minute|min|hour|hr)s?/i);
325
+ if (relMatch) {
326
+ const amount = parseInt(relMatch[1], 10);
327
+ const unit = relMatch[2].toLowerCase();
328
+ const ms = unit.startsWith('h') ? amount * 3600000 : amount * 60000;
329
+ return new Date(Date.now() + ms);
330
+ }
331
+
332
+ // Try time-only like "14:30" or "2:30 PM" — assume today
333
+ const today = new Date();
334
+ const timeOnly = new Date(`${today.toISOString().split('T')[0]}T${timeStr}`);
335
+ if (!isNaN(timeOnly.getTime())) return timeOnly;
336
+
337
+ // Fallback: now + 30 min
338
+ return new Date(Date.now() + 1800000);
339
+ }
340
+
341
+ // ── Action Parser ────────────────────────────────────────────────────────────
342
+
343
+ /**
344
+ * Extract JSON action blocks from LLM response text.
345
+ * Supports ```json ... ``` fenced blocks.
346
+ * @param {string} text
347
+ * @returns {{ textParts: string[], actions: Array<{action: string, params: object}> }}
348
+ */
349
+ function parseActions(text) {
350
+ const actions = [];
351
+ const textParts = [];
352
+ let remaining = text;
353
+
354
+ const fenceRegex = /```json\s*\n?([\s\S]*?)```/g;
355
+ let lastIndex = 0;
356
+ let match;
357
+
358
+ while ((match = fenceRegex.exec(remaining)) !== null) {
359
+ // Capture text before this block
360
+ const before = remaining.slice(lastIndex, match.index).trim();
361
+ if (before) textParts.push(before);
362
+
363
+ try {
364
+ const parsed = JSON.parse(match[1].trim());
365
+ if (parsed.action && typeof parsed.action === 'string') {
366
+ actions.push({ action: parsed.action, params: parsed.params || {} });
367
+ }
368
+ } catch {
369
+ // Not valid JSON — treat as regular text
370
+ textParts.push(match[0]);
371
+ }
372
+
373
+ lastIndex = match.index + match[0].length;
374
+ }
375
+
376
+ // Capture trailing text
377
+ const trailing = remaining.slice(lastIndex).trim();
378
+ if (trailing) textParts.push(trailing);
379
+
380
+ return { textParts, actions };
381
+ }
382
+
383
+ // ── System Prompt Builder ────────────────────────────────────────────────────
384
+
385
+ function buildSystemPrompt(initialContext) {
386
+ const today = new Date().toISOString().split('T')[0];
387
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
388
+
389
+ let prompt = TOOL_DEFINITIONS
390
+ .replace('{{TODAY}}', today)
391
+ .replace('{{TIMEZONE}}', tz);
392
+
393
+ prompt += `\n\nYou are NHA Chat, a personal operations assistant inside the NotHumanAllowed CLI. ` +
394
+ `You help the user manage their emails, calendar, and tasks through natural conversation. ` +
395
+ `Be concise, helpful, and proactive. When presenting data, format it clearly. ` +
396
+ `Never output raw JSON to the user — always wrap results in natural language.`;
397
+
398
+ if (initialContext) {
399
+ prompt += `\n\n--- CURRENT CONTEXT (fetched at session start) ---\n${initialContext}`;
400
+ }
401
+
402
+ return prompt;
403
+ }
404
+
405
+ /**
406
+ * Serialize conversation history into a single user message string.
407
+ * Each turn is prefixed with [User] or [Assistant] to maintain role clarity.
408
+ */
409
+ function serializeHistory(history, currentMessage) {
410
+ const parts = [];
411
+
412
+ for (const turn of history) {
413
+ const prefix = turn.role === 'user' ? '[User]' : '[Assistant]';
414
+ parts.push(`${prefix} ${turn.content}`);
415
+ }
416
+
417
+ parts.push(`[User] ${currentMessage}`);
418
+ return parts.join('\n\n');
419
+ }
420
+
421
+ // ── Confirmation Prompt ──────────────────────────────────────────────────────
422
+
423
+ /**
424
+ * Ask the user for y/n confirmation before executing a destructive action.
425
+ * @param {readline.Interface} rl
426
+ * @param {string} description
427
+ * @returns {Promise<boolean>}
428
+ */
429
+ function askConfirmation(rl, description) {
430
+ return new Promise((resolve) => {
431
+ rl.question(` ${Y}?${NC} ${description} ${D}[y/n]${NC} `, (answer) => {
432
+ resolve(answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes');
433
+ });
434
+ });
435
+ }
436
+
437
+ // ── Initial Context Fetcher ──────────────────────────────────────────────────
438
+
439
+ async function fetchInitialContext(config) {
440
+ const parts = [];
441
+
442
+ // Today's events (best effort)
443
+ try {
444
+ const events = await getTodayEvents(config);
445
+ if (events.length > 0) {
446
+ parts.push(`TODAY'S CALENDAR (${events.length} events):\n` + formatEvents(events));
447
+ } else {
448
+ parts.push('TODAY\'S CALENDAR: No events scheduled.');
449
+ }
450
+ } catch {
451
+ parts.push('CALENDAR: Could not fetch (Google not connected?).');
452
+ }
453
+
454
+ // Unread emails (best effort)
455
+ try {
456
+ const emails = await getUnreadImportant(config, 10);
457
+ if (emails.length > 0) {
458
+ parts.push(`UNREAD EMAILS (${emails.length}):\n` +
459
+ emails.map((m, i) =>
460
+ `${i + 1}. [${m.id}] From: ${m.from} | Subject: ${m.subject}\n ${m.snippet.slice(0, 100)}`
461
+ ).join('\n'));
462
+ } else {
463
+ parts.push('UNREAD EMAILS: Inbox zero!');
464
+ }
465
+ } catch {
466
+ parts.push('EMAIL: Could not fetch (Google not connected?).');
467
+ }
468
+
469
+ // Today's tasks
470
+ try {
471
+ const tasks = getTasks();
472
+ if (tasks.length > 0) {
473
+ parts.push(`TODAY'S TASKS (${tasks.length}):\n` +
474
+ tasks.map(t => `#${t.id} [${t.priority}] ${t.status === 'done' ? '[DONE] ' : ''}${t.description}`).join('\n'));
475
+ } else {
476
+ parts.push('TODAY\'S TASKS: None yet.');
477
+ }
478
+ } catch {
479
+ parts.push('TASKS: Could not load.');
480
+ }
481
+
482
+ return parts.join('\n\n');
483
+ }
484
+
485
+ // ── Slash Command Handlers ───────────────────────────────────────────────────
486
+
487
+ /**
488
+ * Handle REPL-local slash commands. Returns true if handled, false to pass through.
489
+ */
490
+ async function handleSlashCommand(input, config, history) {
491
+ const trimmed = input.trim();
492
+
493
+ if (trimmed === '/quit' || trimmed === '/exit' || trimmed === '/q') {
494
+ console.log(`\n ${D}Session ended. Goodbye.${NC}\n`);
495
+ process.exit(0);
496
+ }
497
+
498
+ if (trimmed === '/clear') {
499
+ history.length = 0;
500
+ console.clear();
501
+ console.log(` ${G}Conversation cleared.${NC}`);
502
+ return true;
503
+ }
504
+
505
+ if (trimmed === '/tasks') {
506
+ try {
507
+ const tasks = getTasks();
508
+ if (tasks.length === 0) {
509
+ console.log(` ${D}No tasks for today.${NC}`);
510
+ } else {
511
+ for (const t of tasks) {
512
+ const icon = t.status === 'done' ? `${G}[done]${NC}` : `${Y}[${t.priority}]${NC}`;
513
+ console.log(` ${icon} #${t.id} ${t.description}`);
514
+ }
515
+ }
516
+ } catch (err) {
517
+ console.log(` ${R}Could not load tasks: ${err.message}${NC}`);
518
+ }
519
+ return true;
520
+ }
521
+
522
+ if (trimmed === '/plan') {
523
+ try {
524
+ const { cmdPlan } = await import('./plan.mjs');
525
+ await cmdPlan([]);
526
+ } catch (err) {
527
+ console.log(` ${R}Plan error: ${err.message}${NC}`);
528
+ }
529
+ return true;
530
+ }
531
+
532
+ if (trimmed === '/help') {
533
+ console.log(`
534
+ ${BOLD}Chat Commands${NC}
535
+
536
+ ${C}/tasks${NC} Show today's tasks
537
+ ${C}/plan${NC} Run daily planner
538
+ ${C}/clear${NC} Clear conversation history
539
+ ${C}/help${NC} Show this help
540
+ ${C}/quit${NC} Exit chat
541
+
542
+ ${D}Otherwise, just type naturally — the AI understands
543
+ requests like "show my unread emails", "add a task to review PR #42",
544
+ "what's on my calendar tomorrow?", etc.${NC}
545
+ `);
546
+ return true;
547
+ }
548
+
549
+ return false;
550
+ }
551
+
552
+ // ── Main REPL ────────────────────────────────────────────────────────────────
553
+
554
+ export async function cmdChat(args) {
555
+ const config = loadConfig();
556
+
557
+ if (!config.llm.apiKey) {
558
+ fail('No API key configured. Run: nha config set key YOUR_KEY');
559
+ process.exit(1);
560
+ }
561
+
562
+ // ── Greeting ────────────────────────────────────────────────────────────
563
+ console.log(`
564
+ ${BOLD}${C}NHA Chat${NC} ${D}— Personal Operations Assistant${NC}
565
+ ${D}Type naturally to manage emails, calendar, and tasks.${NC}
566
+ ${D}Commands: /tasks /plan /clear /help /quit${NC}
567
+ `);
568
+
569
+ // ── Fetch initial context (non-blocking display) ────────────────────────
570
+ info('Loading today\'s context...');
571
+ let initialContext = '';
572
+ try {
573
+ initialContext = await fetchInitialContext(config);
574
+ ok('Context loaded (calendar, email, tasks).');
575
+ } catch {
576
+ warn('Could not load full context. Google may not be connected.');
577
+ warn('Run "nha google auth" to connect Gmail + Calendar.');
578
+ }
579
+ console.log('');
580
+
581
+ // ── Readline setup ──────────────────────────────────────────────────────
582
+ const rl = readline.createInterface({
583
+ input: process.stdin,
584
+ output: process.stdout,
585
+ prompt: `${C}NHA>${NC} `,
586
+ terminal: true,
587
+ });
588
+
589
+ const history = [];
590
+ const systemPrompt = buildSystemPrompt(initialContext);
591
+
592
+ // ── Graceful exit ───────────────────────────────────────────────────────
593
+ rl.on('close', () => {
594
+ console.log(`\n ${D}Session ended. Goodbye.${NC}\n`);
595
+ process.exit(0);
596
+ });
597
+
598
+ // Handle SIGINT (Ctrl+C) gracefully — first press clears line, second exits
599
+ let pendingExit = false;
600
+ process.on('SIGINT', () => {
601
+ if (pendingExit) {
602
+ console.log(`\n ${D}Goodbye.${NC}\n`);
603
+ process.exit(0);
604
+ }
605
+ pendingExit = true;
606
+ console.log(`\n ${D}Press Ctrl+C again to exit, or type to continue.${NC}`);
607
+ rl.prompt();
608
+ setTimeout(() => { pendingExit = false; }, 3000);
609
+ });
610
+
611
+ // ── REPL Loop ───────────────────────────────────────────────────────────
612
+ rl.prompt();
613
+
614
+ for await (const rawLine of rl) {
615
+ const input = rawLine.trim();
616
+
617
+ // Skip empty lines
618
+ if (!input) {
619
+ rl.prompt();
620
+ continue;
621
+ }
622
+
623
+ // Handle slash commands
624
+ if (input.startsWith('/')) {
625
+ const handled = await handleSlashCommand(input, config, history);
626
+ if (handled) {
627
+ rl.prompt();
628
+ continue;
629
+ }
630
+ }
631
+
632
+ // ── LLM call ────────────────────────────────────────────────────────
633
+ try {
634
+ const userMessage = serializeHistory(history, input);
635
+
636
+ process.stdout.write(`\n ${D}Thinking...${NC}`);
637
+ const response = await callLLM(config, systemPrompt, userMessage);
638
+ // Clear the "Thinking..." indicator
639
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
640
+
641
+ // Parse response for actions
642
+ const { textParts, actions } = parseActions(response);
643
+
644
+ // Display conversational text
645
+ if (textParts.length > 0) {
646
+ const text = textParts.join('\n\n');
647
+ console.log(`\n ${W}${text}${NC}\n`);
648
+ }
649
+
650
+ // Execute actions
651
+ for (const { action, params } of actions) {
652
+ const isDestructive = DESTRUCTIVE_ACTIONS.has(action);
653
+
654
+ if (isDestructive) {
655
+ // Build a human-readable description of what will happen
656
+ const desc = describeAction(action, params);
657
+ const confirmed = await askConfirmation(rl, desc);
658
+
659
+ if (!confirmed) {
660
+ console.log(` ${D}Cancelled.${NC}\n`);
661
+ history.push({ role: 'user', content: input });
662
+ history.push({ role: 'assistant', content: response + '\n[User cancelled this action]' });
663
+ continue;
664
+ }
665
+ }
666
+
667
+ // Execute the tool
668
+ try {
669
+ process.stdout.write(` ${D}Executing ${action}...${NC}`);
670
+ const result = await executeTool(action, params, config);
671
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
672
+ console.log(` ${G}Result:${NC}\n ${result.split('\n').join('\n ')}\n`);
673
+
674
+ // Feed result back into history so the LLM knows what happened
675
+ history.push({ role: 'user', content: input });
676
+ history.push({
677
+ role: 'assistant',
678
+ content: response + `\n\n[Tool ${action} executed. Result: ${result}]`,
679
+ });
680
+ } catch (err) {
681
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
682
+ console.log(` ${R}Error executing ${action}: ${err.message}${NC}\n`);
683
+ history.push({ role: 'user', content: input });
684
+ history.push({
685
+ role: 'assistant',
686
+ content: response + `\n\n[Tool ${action} failed: ${err.message}]`,
687
+ });
688
+ }
689
+ }
690
+
691
+ // If no actions, just record the conversation turn
692
+ if (actions.length === 0) {
693
+ history.push({ role: 'user', content: input });
694
+ history.push({ role: 'assistant', content: response });
695
+ }
696
+
697
+ // Trim history to MAX_HISTORY turns (pairs of user+assistant)
698
+ while (history.length > MAX_HISTORY * 2) {
699
+ history.shift();
700
+ history.shift();
701
+ }
702
+ } catch (err) {
703
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
704
+ console.log(`\n ${R}LLM error: ${err.message}${NC}\n`);
705
+ }
706
+
707
+ rl.prompt();
708
+ }
709
+ }
710
+
711
+ // ── Action Descriptor (for confirmation prompts) ─────────────────────────────
712
+
713
+ function describeAction(action, params) {
714
+ switch (action) {
715
+ case 'gmail_send':
716
+ return `Send email to ${params.to} — Subject: "${params.subject}"`;
717
+ case 'gmail_reply':
718
+ return `Reply to message ${params.messageId}`;
719
+ case 'calendar_create':
720
+ return `Create event "${params.summary}" at ${formatTime(params.start)}`;
721
+ case 'calendar_move':
722
+ return `Reschedule event ${params.eventId} to ${formatTime(params.newStart)}`;
723
+ case 'task_done':
724
+ return `Mark task #${params.id} as completed`;
725
+ case 'notify_remind':
726
+ return `Set reminder: "${params.message}" at ${params.atTime}`;
727
+ default:
728
+ return `Execute ${action}`;
729
+ }
730
+ }
package/src/constants.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import os from 'os';
2
2
  import path from 'path';
3
3
 
4
- export const VERSION = '2.0.0';
4
+ export const VERSION = '2.1.0';
5
5
  export const BASE_URL = 'https://nothumanallowed.com/cli';
6
6
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
7
7