nothumanallowed 4.1.0 → 6.0.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.
@@ -15,10 +15,10 @@
15
15
  import fs from 'fs';
16
16
  import path from 'path';
17
17
  import { callAgent, callLLM } from './llm.mjs';
18
- import { getUnreadImportant, getTodayEmails } from './google-gmail.mjs';
19
- import { getTodayEvents, getUpcomingEvents } from './google-calendar.mjs';
18
+ import { getUnreadImportant, getTodayEmails } from './mail-router.mjs';
19
+ import { getTodayEvents, getUpcomingEvents } from './mail-router.mjs';
20
20
  import { getTasks, bulkAddTasks } from './task-store.mjs';
21
- import { loadTokens } from './token-store.mjs';
21
+ import { hasMailProvider } from './mail-router.mjs';
22
22
  import { NHA_DIR } from '../constants.mjs';
23
23
  import { info, ok, fail, warn, D, C, G, Y, W, BOLD, NC } from '../ui.mjs';
24
24
 
@@ -47,8 +47,7 @@ export async function runPlanningPipeline(config, opts = {}) {
47
47
  }
48
48
 
49
49
  const startTime = Date.now();
50
- const tokens = loadTokens();
51
- const hasGoogle = !!tokens;
50
+ const hasMail = hasMailProvider();
52
51
 
53
52
  console.log(`\n ${BOLD}NHA Daily Plan — ${dateStr}${NC}`);
54
53
  console.log(` ${D}5 specialist agents analyzing your day${NC}\n`);
@@ -60,7 +59,7 @@ export async function runPlanningPipeline(config, opts = {}) {
60
59
  let events = [];
61
60
  const tasks = getTasks(dateStr);
62
61
 
63
- if (hasGoogle) {
62
+ if (hasMail) {
64
63
  try {
65
64
  [emails, events] = await Promise.all([
66
65
  getUnreadImportant(config, 30),
@@ -68,11 +67,11 @@ export async function runPlanningPipeline(config, opts = {}) {
68
67
  ]);
69
68
  ok(`${emails.length} emails, ${events.length} events, ${tasks.length} tasks`);
70
69
  } catch (err) {
71
- warn(`Google API error: ${err.message}`);
70
+ warn(`Mail API error: ${err.message}`);
72
71
  info('Continuing with tasks only...');
73
72
  }
74
73
  } else {
75
- warn('Google not connected. Using tasks only. Run "nha google auth" to connect.');
74
+ warn('No mail provider connected. Using tasks only. Run "nha google auth" or "nha microsoft auth" to connect.');
76
75
  }
77
76
 
78
77
  // Build context for agents
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Encrypted token storage — AES-256-GCM with machine-derived key.
3
- * Stores OAuth tokens at ~/.nha/ops/tokens.enc
3
+ * Supports multiple providers (google, microsoft) via separate encrypted files.
4
+ * Stores OAuth tokens at ~/.nha/ops/tokens-<provider>.enc
5
+ * Legacy single-file (~/.nha/ops/tokens.enc) is treated as 'google' for backward compat.
4
6
  * Zero dependencies — uses Node.js native crypto.
5
7
  */
6
8
 
@@ -11,7 +13,15 @@ import path from 'path';
11
13
  import { NHA_DIR } from '../constants.mjs';
12
14
 
13
15
  const OPS_DIR = path.join(NHA_DIR, 'ops');
14
- const TOKENS_FILE = path.join(OPS_DIR, 'tokens.enc');
16
+ const LEGACY_TOKENS_FILE = path.join(OPS_DIR, 'tokens.enc');
17
+
18
+ /** Get tokens file path for a given provider */
19
+ function getTokensFile(provider) {
20
+ if (!provider || provider === 'google') {
21
+ return LEGACY_TOKENS_FILE; // backward compatible — Google uses the original file
22
+ }
23
+ return path.join(OPS_DIR, `tokens-${provider}.enc`);
24
+ }
15
25
 
16
26
  /** Derive encryption key from machine-specific fingerprint */
17
27
  function deriveKey() {
@@ -49,21 +59,25 @@ function decrypt(envelope) {
49
59
  /**
50
60
  * Save OAuth tokens (encrypted).
51
61
  * @param {object} tokens — { access_token, refresh_token, expires_at, scope, email }
62
+ * @param {string} provider — 'google' (default) or 'microsoft'
52
63
  */
53
- export function saveTokens(tokens) {
64
+ export function saveTokens(tokens, provider) {
54
65
  fs.mkdirSync(OPS_DIR, { recursive: true });
66
+ const file = getTokensFile(provider);
55
67
  const envelope = encrypt(tokens);
56
- fs.writeFileSync(TOKENS_FILE, JSON.stringify(envelope, null, 2), { mode: 0o600 });
68
+ fs.writeFileSync(file, JSON.stringify(envelope, null, 2), { mode: 0o600 });
57
69
  }
58
70
 
59
71
  /**
60
72
  * Load OAuth tokens (decrypted).
73
+ * @param {string} provider — 'google' (default) or 'microsoft'
61
74
  * @returns {object|null} tokens or null if not stored
62
75
  */
63
- export function loadTokens() {
64
- if (!fs.existsSync(TOKENS_FILE)) return null;
76
+ export function loadTokens(provider) {
77
+ const file = getTokensFile(provider);
78
+ if (!fs.existsSync(file)) return null;
65
79
  try {
66
- const envelope = JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf-8'));
80
+ const envelope = JSON.parse(fs.readFileSync(file, 'utf-8'));
67
81
  return decrypt(envelope);
68
82
  } catch {
69
83
  return null;
@@ -72,9 +86,11 @@ export function loadTokens() {
72
86
 
73
87
  /**
74
88
  * Delete stored tokens.
89
+ * @param {string} provider — 'google' (default) or 'microsoft'
75
90
  */
76
- export function deleteTokens() {
77
- if (fs.existsSync(TOKENS_FILE)) fs.rmSync(TOKENS_FILE);
91
+ export function deleteTokens(provider) {
92
+ const file = getTokensFile(provider);
93
+ if (fs.existsSync(file)) fs.rmSync(file);
78
94
  }
79
95
 
80
96
  /**
@@ -88,7 +104,7 @@ export function isExpired(tokens) {
88
104
  }
89
105
 
90
106
  /**
91
- * Refresh access token using refresh_token.
107
+ * Refresh access token using refresh_token (Google-specific).
92
108
  * @param {string} clientId
93
109
  * @param {string} clientSecret
94
110
  * @param {string} refreshToken
@@ -123,12 +139,12 @@ export async function refreshAccessToken(clientId, clientSecret, refreshToken) {
123
139
  }
124
140
 
125
141
  /**
126
- * Get a valid access token — refreshes automatically if expired.
142
+ * Get a valid Google access token — refreshes automatically if expired.
127
143
  * @param {object} config — NHA config with google.clientId etc.
128
144
  * @returns {Promise<string>} access_token
129
145
  */
130
146
  export async function getAccessToken(config) {
131
- let tokens = loadTokens();
147
+ let tokens = loadTokens('google');
132
148
  if (!tokens) throw new Error('Not authenticated. Run: nha google auth');
133
149
 
134
150
  if (isExpired(tokens)) {
@@ -137,9 +153,20 @@ export async function getAccessToken(config) {
137
153
  if (!clientId) throw new Error('Google client ID not configured');
138
154
 
139
155
  tokens = await refreshAccessToken(clientId, clientSecret, tokens.refresh_token);
140
- tokens.email = loadTokens()?.email; // preserve email
141
- saveTokens(tokens);
156
+ tokens.email = loadTokens('google')?.email; // preserve email
157
+ saveTokens(tokens, 'google');
142
158
  }
143
159
 
144
160
  return tokens.access_token;
145
161
  }
162
+
163
+ /**
164
+ * Check which providers are currently authenticated.
165
+ * @returns {{ google: boolean, microsoft: boolean }}
166
+ */
167
+ export function getAuthenticatedProviders() {
168
+ return {
169
+ google: loadTokens('google') !== null,
170
+ microsoft: loadTokens('microsoft') !== null,
171
+ };
172
+ }
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Shared tool executor and action parser — used by chat.mjs, ui.mjs, and voice.mjs.
3
+ *
4
+ * Centralizes the JSON action block parsing and tool execution logic
5
+ * to eliminate code duplication across the three PAO interfaces.
6
+ *
7
+ * Zero dependencies — pure Node.js 22.
8
+ */
9
+
10
+ import {
11
+ listMessages,
12
+ getMessage,
13
+ getUnreadImportant,
14
+ sendEmail,
15
+ createDraft,
16
+ getTodayEvents,
17
+ getUpcomingEvents,
18
+ getEventsForDate,
19
+ createEvent,
20
+ updateEvent,
21
+ } from './mail-router.mjs';
22
+
23
+ import {
24
+ getTasks,
25
+ addTask,
26
+ completeTask,
27
+ moveTask,
28
+ } from './task-store.mjs';
29
+
30
+ import { notify } from './notification.mjs';
31
+
32
+ // ── Constants ────────────────────────────────────────────────────────────────
33
+
34
+ /** Actions that mutate external state and require user confirmation. */
35
+ export const DESTRUCTIVE_ACTIONS = new Set([
36
+ 'gmail_send',
37
+ 'gmail_reply',
38
+ 'calendar_create',
39
+ 'calendar_move',
40
+ 'task_done',
41
+ 'notify_remind',
42
+ ]);
43
+
44
+ // ── System Prompt (tool definitions) ─────────────────────────────────────────
45
+
46
+ /**
47
+ * Build the tool definitions system prompt block.
48
+ * Callers should replace {{TODAY}} and {{TIMEZONE}} and append their own persona.
49
+ */
50
+ export const TOOL_DEFINITIONS = `
51
+ You have access to the following tools. When the user's message requires an action,
52
+ output EXACTLY ONE fenced JSON block per action:
53
+
54
+ \`\`\`json
55
+ {"action": "<tool_name>", "params": { ... }}
56
+ \`\`\`
57
+
58
+ You may include conversational text BEFORE or AFTER the JSON block. If no action
59
+ is needed, respond normally without any JSON block.
60
+
61
+ TOOLS:
62
+
63
+ 1. gmail_list(query: string, maxResults?: number)
64
+ Search emails. query uses Gmail search syntax (e.g. "from:boss@co.com", "is:unread subject:invoice").
65
+ Default maxResults = 10.
66
+
67
+ 2. gmail_read(messageId: string)
68
+ Read the full body of an email by its ID (returned by gmail_list).
69
+
70
+ 3. gmail_send(to: string, subject: string, body: string)
71
+ Send an email. ALWAYS confirm with the user before sending.
72
+
73
+ 4. gmail_draft(to: string, subject: string, body: string)
74
+ Create a draft email (safe — does not send).
75
+
76
+ 5. gmail_reply(messageId: string, body: string)
77
+ Reply to an existing email thread. ALWAYS confirm before sending.
78
+
79
+ 6. calendar_today()
80
+ List all events for today.
81
+
82
+ 7. calendar_tomorrow()
83
+ List all events for tomorrow.
84
+
85
+ 8. calendar_upcoming(hours?: number)
86
+ List upcoming events in the next N hours (default 2).
87
+
88
+ 9. calendar_create(summary: string, start: string, end: string, attendees?: string[], description?: string)
89
+ Create a calendar event. start/end are ISO 8601 datetime strings.
90
+ ALWAYS confirm with the user before creating.
91
+
92
+ 10. calendar_move(eventId: string, newStart: string, newEnd: string)
93
+ Reschedule an event. ALWAYS confirm before moving.
94
+
95
+ 11. task_list()
96
+ List today's tasks.
97
+
98
+ 12. task_add(description: string, priority?: "low"|"medium"|"high"|"critical", due?: string)
99
+ Add a new task for today.
100
+
101
+ 13. task_done(id: number)
102
+ Mark a task as completed. Confirm before completing.
103
+
104
+ 14. task_move(id: number, toDate: string)
105
+ Move a task to another date (YYYY-MM-DD).
106
+
107
+ 15. notify_remind(message: string, atTime: string)
108
+ Set a desktop reminder. atTime is ISO 8601 or relative like "in 30 minutes".
109
+
110
+ RULES:
111
+ - For search/read operations, execute immediately and present results conversationally.
112
+ - 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.
113
+ - When presenting email results, show From, Subject, Date, and a brief snippet. Never dump raw JSON.
114
+ - When presenting calendar events, show Time, Title, Location/Link. Format times in a human-readable way.
115
+ - When presenting tasks, show ID, Description, Priority, Status.
116
+ - 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.
117
+ - Dates: today is {{TODAY}}. Infer relative dates from this.
118
+ - The user's timezone is {{TIMEZONE}}.
119
+ - CRITICAL: when creating calendar events, always use LOCAL time in format "YYYY-MM-DDTHH:MM:SS" WITHOUT any Z suffix or timezone offset.
120
+ `.trim();
121
+
122
+ // ── Action Parser ────────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Extract JSON action blocks from LLM response text.
126
+ * Supports ```json ... ``` fenced blocks.
127
+ *
128
+ * @param {string} text
129
+ * @returns {{ textParts: string[], actions: Array<{action: string, params: object}> }}
130
+ */
131
+ export function parseActions(text) {
132
+ const actions = [];
133
+ const textParts = [];
134
+ const fenceRegex = /```json\s*\n?([\s\S]*?)```/g;
135
+ let lastIndex = 0;
136
+ let match;
137
+
138
+ while ((match = fenceRegex.exec(text)) !== null) {
139
+ const before = text.slice(lastIndex, match.index).trim();
140
+ if (before) textParts.push(before);
141
+
142
+ try {
143
+ const parsed = JSON.parse(match[1].trim());
144
+ if (parsed.action && typeof parsed.action === 'string') {
145
+ actions.push({ action: parsed.action, params: parsed.params || {} });
146
+ }
147
+ } catch {
148
+ textParts.push(match[0]);
149
+ }
150
+
151
+ lastIndex = match.index + match[0].length;
152
+ }
153
+
154
+ const trailing = text.slice(lastIndex).trim();
155
+ if (trailing) textParts.push(trailing);
156
+
157
+ return { textParts, actions };
158
+ }
159
+
160
+ // ── Formatting Helpers ───────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Format an ISO timestamp into a human-readable time string.
164
+ */
165
+ export function formatTime(isoStr) {
166
+ try {
167
+ const d = new Date(isoStr);
168
+ return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
169
+ } catch {
170
+ return isoStr;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Format an array of calendar events into human-readable text.
176
+ */
177
+ export function formatEvents(events) {
178
+ return events.map((e, i) => {
179
+ const time = e.isAllDay ? 'All day' : `${formatTime(e.start)} - ${formatTime(e.end)}`;
180
+ const location = e.location ? ` | ${e.location}` : '';
181
+ const link = e.hangoutLink ? ` | ${e.hangoutLink}` : '';
182
+ const cal = e.calendarName ? ` (${e.calendarName})` : '';
183
+ return `${i + 1}. ${time} — ${e.summary}${location}${link}${cal}`;
184
+ }).join('\n');
185
+ }
186
+
187
+ /**
188
+ * Resolve a time string into a Date. Supports ISO 8601 and relative formats
189
+ * like "in 30 minutes", "in 2 hours".
190
+ */
191
+ export function resolveTime(timeStr) {
192
+ const direct = new Date(timeStr);
193
+ if (!isNaN(direct.getTime()) && timeStr.includes('T')) return direct;
194
+
195
+ const relMatch = timeStr.match(/in\s+(\d+)\s+(minute|min|hour|hr)s?/i);
196
+ if (relMatch) {
197
+ const amount = parseInt(relMatch[1], 10);
198
+ const unit = relMatch[2].toLowerCase();
199
+ const ms = unit.startsWith('h') ? amount * 3600000 : amount * 60000;
200
+ return new Date(Date.now() + ms);
201
+ }
202
+
203
+ const today = new Date();
204
+ const timeOnly = new Date(`${today.toISOString().split('T')[0]}T${timeStr}`);
205
+ if (!isNaN(timeOnly.getTime())) return timeOnly;
206
+
207
+ return new Date(Date.now() + 1800000);
208
+ }
209
+
210
+ /**
211
+ * Build a human-readable description of an action (for confirmation prompts).
212
+ */
213
+ export function describeAction(action, params) {
214
+ switch (action) {
215
+ case 'gmail_send':
216
+ return `Send email to ${params.to} — Subject: "${params.subject}"`;
217
+ case 'gmail_reply':
218
+ return `Reply to message ${params.messageId}`;
219
+ case 'calendar_create':
220
+ return `Create event "${params.summary}" at ${formatTime(params.start)}`;
221
+ case 'calendar_move':
222
+ return `Reschedule event ${params.eventId} to ${formatTime(params.newStart)}`;
223
+ case 'task_done':
224
+ return `Mark task #${params.id} as completed`;
225
+ case 'notify_remind':
226
+ return `Set reminder: "${params.message}" at ${params.atTime}`;
227
+ default:
228
+ return `Execute ${action}`;
229
+ }
230
+ }
231
+
232
+ // ── Tool Executor ────────────────────────────────────────────────────────────
233
+
234
+ /**
235
+ * Execute a parsed tool action and return a human-readable result string.
236
+ *
237
+ * @param {string} action — tool name
238
+ * @param {object} params — tool parameters
239
+ * @param {object} config — NHA config
240
+ * @returns {Promise<string>} result description
241
+ */
242
+ export async function executeTool(action, params, config) {
243
+ switch (action) {
244
+ // ── Gmail ──────────────────────────────────────────────────────────────
245
+ case 'gmail_list': {
246
+ const query = params.query || 'is:unread';
247
+ const max = params.maxResults || 10;
248
+ const refs = await listMessages(config, query, max);
249
+ if (refs.length === 0) return 'No emails found matching that query.';
250
+
251
+ const messages = [];
252
+ for (const ref of refs.slice(0, max)) {
253
+ try {
254
+ const msg = await getMessage(config, ref.id);
255
+ messages.push(msg);
256
+ } catch { /* skip failed */ }
257
+ }
258
+
259
+ return messages.map((m, i) =>
260
+ `${i + 1}. [${m.id}] From: ${m.from} | Subject: ${m.subject} | Date: ${m.date}\n ${m.snippet.slice(0, 120)}`
261
+ ).join('\n');
262
+ }
263
+
264
+ case 'gmail_read': {
265
+ const msg = await getMessage(config, params.messageId);
266
+ return [
267
+ `From: ${msg.from}`,
268
+ `To: ${msg.to}`,
269
+ `Subject: ${msg.subject}`,
270
+ `Date: ${msg.date}`,
271
+ `---`,
272
+ msg.body.slice(0, 4000),
273
+ ].join('\n');
274
+ }
275
+
276
+ case 'gmail_send': {
277
+ await sendEmail(config, params.to, params.subject, params.body);
278
+ return `Email sent to ${params.to} with subject "${params.subject}".`;
279
+ }
280
+
281
+ case 'gmail_draft': {
282
+ await createDraft(config, params.to, params.subject, params.body);
283
+ return `Draft created for ${params.to} with subject "${params.subject}".`;
284
+ }
285
+
286
+ case 'gmail_reply': {
287
+ const original = await getMessage(config, params.messageId);
288
+ await sendEmail(config, original.from, `Re: ${original.subject}`, params.body, {
289
+ replyToMessageId: original.id,
290
+ threadId: original.threadId,
291
+ });
292
+ return `Reply sent to ${original.from} on thread "${original.subject}".`;
293
+ }
294
+
295
+ // ── Calendar ──────────────────────────────────────────────────────────
296
+ case 'calendar_today': {
297
+ const events = await getTodayEvents(config);
298
+ if (events.length === 0) return 'No events scheduled for today.';
299
+ return formatEvents(events);
300
+ }
301
+
302
+ case 'calendar_tomorrow': {
303
+ const tomorrow = new Date();
304
+ tomorrow.setDate(tomorrow.getDate() + 1);
305
+ const dateStr = tomorrow.toISOString().split('T')[0];
306
+ const events = await getEventsForDate(config, dateStr);
307
+ if (events.length === 0) return 'No events scheduled for tomorrow.';
308
+ return formatEvents(events);
309
+ }
310
+
311
+ case 'calendar_upcoming': {
312
+ const hours = params.hours || 2;
313
+ const events = await getUpcomingEvents(config, hours);
314
+ if (events.length === 0) return `No events in the next ${hours} hour(s).`;
315
+ return formatEvents(events);
316
+ }
317
+
318
+ case 'calendar_create': {
319
+ await createEvent(config, {
320
+ summary: params.summary,
321
+ start: params.start,
322
+ end: params.end,
323
+ description: params.description || '',
324
+ attendees: params.attendees || [],
325
+ });
326
+ return `Event "${params.summary}" created for ${formatTime(params.start)} - ${formatTime(params.end)}.`;
327
+ }
328
+
329
+ case 'calendar_move': {
330
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
331
+ await updateEvent(config, 'primary', params.eventId, {
332
+ start: { dateTime: new Date(params.newStart).toISOString(), timeZone: tz },
333
+ end: { dateTime: new Date(params.newEnd).toISOString(), timeZone: tz },
334
+ });
335
+ return `Event rescheduled to ${formatTime(params.newStart)} - ${formatTime(params.newEnd)}.`;
336
+ }
337
+
338
+ // ── Tasks ─────────────────────────────────────────────────────────────
339
+ case 'task_list': {
340
+ const tasks = getTasks();
341
+ if (tasks.length === 0) return 'No tasks for today.';
342
+ return tasks.map(t =>
343
+ `#${t.id} [${t.priority}] ${t.status === 'done' ? '[DONE] ' : ''}${t.description}${t.due ? ' (due: ' + t.due + ')' : ''}`
344
+ ).join('\n');
345
+ }
346
+
347
+ case 'task_add': {
348
+ const task = addTask({
349
+ description: params.description,
350
+ priority: params.priority || 'medium',
351
+ due: params.due || null,
352
+ source: 'tool',
353
+ });
354
+ return `Task #${task.id} added: "${task.description}" [${task.priority}]`;
355
+ }
356
+
357
+ case 'task_done': {
358
+ const success = completeTask(params.id);
359
+ return success ? `Task #${params.id} marked as done.` : `Task #${params.id} not found.`;
360
+ }
361
+
362
+ case 'task_move': {
363
+ const todayStr = new Date().toISOString().split('T')[0];
364
+ const toDate = params.toDate === 'tomorrow'
365
+ ? new Date(new Date().setDate(new Date().getDate() + 1)).toISOString().split('T')[0]
366
+ : params.toDate;
367
+ const success = moveTask(params.id, todayStr, toDate);
368
+ return success ? `Task #${params.id} moved to ${toDate}.` : `Task #${params.id} not found.`;
369
+ }
370
+
371
+ // ── Notifications ─────────────────────────────────────────────────────
372
+ case 'notify_remind': {
373
+ const atTime = resolveTime(params.atTime);
374
+ const delayMs = atTime.getTime() - Date.now();
375
+
376
+ if (delayMs <= 0) {
377
+ await notify('Reminder', params.message, config);
378
+ return `Reminder sent now: "${params.message}"`;
379
+ }
380
+
381
+ setTimeout(async () => {
382
+ try { await notify('Reminder', params.message, config); } catch { /* best effort */ }
383
+ }, Math.min(delayMs, 86400000));
384
+
385
+ const minutes = Math.round(delayMs / 60000);
386
+ return `Reminder set for ${formatTime(atTime.toISOString())} (in ~${minutes} min): "${params.message}"`;
387
+ }
388
+
389
+ default:
390
+ return `Unknown action: ${action}`;
391
+ }
392
+ }