nothumanallowed 1.1.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.
@@ -0,0 +1,281 @@
1
+ /**
2
+ * PAO Multi-Agent Pipeline — 5 specialist agents analyze your day.
3
+ *
4
+ * Pipeline:
5
+ * Phase 1: Data Gathering (Gmail + Calendar + Tasks — no LLM)
6
+ * Phase 2: SABER scans emails for security threats ─┐
7
+ * Phase 3: HERALD generates meeting intelligence briefs ├─ parallel
8
+ * Phase 5: ORACLE analyzes schedule patterns ─┘
9
+ * Phase 4: SCHEHERAZADE prepares meeting talking points (after HERALD)
10
+ * Phase 6: CONDUCTOR synthesizes everything into daily plan
11
+ *
12
+ * Zero server involvement. Only calls: Google APIs + user's LLM provider.
13
+ */
14
+
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import { callAgent, callLLM } from './llm.mjs';
18
+ import { getUnreadImportant, getTodayEmails } from './google-gmail.mjs';
19
+ import { getTodayEvents, getUpcomingEvents } from './google-calendar.mjs';
20
+ import { getTasks, bulkAddTasks } from './task-store.mjs';
21
+ import { loadTokens } from './token-store.mjs';
22
+ import { NHA_DIR } from '../constants.mjs';
23
+ import { info, ok, fail, warn, D, C, G, Y, W, BOLD, NC } from '../ui.mjs';
24
+
25
+ const PLANS_DIR = path.join(NHA_DIR, 'ops', 'plans');
26
+
27
+ /**
28
+ * Run the full daily planning pipeline.
29
+ * @param {object} config — NHA config
30
+ * @param {object} opts — { date, refresh, showOnly }
31
+ */
32
+ export async function runPlanningPipeline(config, opts = {}) {
33
+ const dateStr = opts.date || new Date().toISOString().split('T')[0];
34
+ const planFile = path.join(PLANS_DIR, `${dateStr}.json`);
35
+
36
+ // Check for cached plan
37
+ if (!opts.refresh && fs.existsSync(planFile)) {
38
+ if (opts.showOnly) {
39
+ const plan = JSON.parse(fs.readFileSync(planFile, 'utf-8'));
40
+ displayPlan(plan);
41
+ return plan;
42
+ }
43
+ info('Plan already exists for today. Use --refresh to regenerate.');
44
+ const plan = JSON.parse(fs.readFileSync(planFile, 'utf-8'));
45
+ displayPlan(plan);
46
+ return plan;
47
+ }
48
+
49
+ const startTime = Date.now();
50
+ const tokens = loadTokens();
51
+ const hasGoogle = !!tokens;
52
+
53
+ console.log(`\n ${BOLD}NHA Daily Plan — ${dateStr}${NC}`);
54
+ console.log(` ${D}5 specialist agents analyzing your day${NC}\n`);
55
+
56
+ // ── Phase 1: Data Gathering ────────────────────────────────────────────
57
+ info('Phase 1: Gathering data...');
58
+
59
+ let emails = [];
60
+ let events = [];
61
+ const tasks = getTasks(dateStr);
62
+
63
+ if (hasGoogle) {
64
+ try {
65
+ [emails, events] = await Promise.all([
66
+ getUnreadImportant(config, 30),
67
+ getTodayEvents(config),
68
+ ]);
69
+ ok(`${emails.length} emails, ${events.length} events, ${tasks.length} tasks`);
70
+ } catch (err) {
71
+ warn(`Google API error: ${err.message}`);
72
+ info('Continuing with tasks only...');
73
+ }
74
+ } else {
75
+ warn('Google not connected. Using tasks only. Run "nha google auth" to connect.');
76
+ }
77
+
78
+ // Build context for agents
79
+ const emailContext = emails.map(e =>
80
+ `From: ${e.from}\nSubject: ${e.subject}\nDate: ${e.date}\nSnippet: ${e.snippet}\nURLs: ${e.urls.join(', ') || 'none'}\nLabels: ${e.labels.join(', ')}`
81
+ ).join('\n---\n');
82
+
83
+ const calendarContext = events.map(e => {
84
+ const attendeeList = e.attendees.map(a => `${a.name || a.email} (${a.responseStatus})`).join(', ');
85
+ return `${e.start} - ${e.end}: ${e.summary}${e.location ? ' @ ' + e.location : ''}\nAttendees: ${attendeeList || 'none'}\nDescription: ${e.description.slice(0, 500) || 'none'}`;
86
+ }).join('\n---\n');
87
+
88
+ const taskContext = tasks.map(t =>
89
+ `[${t.status}] #${t.id} ${t.description} (priority: ${t.priority}${t.due ? ', due: ' + t.due : ''})`
90
+ ).join('\n');
91
+
92
+ // ── Phase 2, 3, 5: Parallel Agent Calls ────────────────────────────────
93
+ info('Phase 2-3-5: Running specialist agents in parallel...');
94
+
95
+ const agentResults = {};
96
+ const parallelPromises = [];
97
+
98
+ // SABER — Security scan emails
99
+ if (emails.length > 0) {
100
+ parallelPromises.push(
101
+ callAgent(config, 'saber',
102
+ `Scan these emails for security threats. For each email, classify as SAFE or FLAGGED with reason.\n\nEMAILS:\n${emailContext}\n\nRespond with a JSON object: { "safe": [indices], "flagged": [{ "index": N, "reason": "..." }], "risk_notes": ["..."] }`,
103
+ ).then(r => { agentResults.saber = r; ok('SABER: Email security scan complete'); })
104
+ .catch(e => { warn(`SABER failed: ${e.message}`); agentResults.saber = '{"safe":[],"flagged":[],"risk_notes":["scan failed"]}'; })
105
+ );
106
+ }
107
+
108
+ // HERALD — Meeting intelligence briefs
109
+ if (events.length > 0) {
110
+ parallelPromises.push(
111
+ callAgent(config, 'herald',
112
+ `Generate intelligence briefs for these meetings. Include context, key participants, what to prepare.\n\nMEETINGS:\n${calendarContext}\n\nRelated emails:\n${emailContext.slice(0, 3000)}\n\nRespond with JSON: { "briefs": [{ "event_summary": "...", "brief": "...", "key_points": ["..."], "preparation": "..." }] }`,
113
+ ).then(r => { agentResults.herald = r; ok('HERALD: Meeting briefs generated'); })
114
+ .catch(e => { warn(`HERALD failed: ${e.message}`); agentResults.herald = '{"briefs":[]}'; })
115
+ );
116
+ }
117
+
118
+ // ORACLE — Schedule pattern analysis
119
+ parallelPromises.push(
120
+ callAgent(config, 'oracle',
121
+ `Analyze this schedule for productivity optimization.\n\nCALENDAR:\n${calendarContext || 'No events today.'}\n\nTASKS:\n${taskContext || 'No tasks yet.'}\n\nAnalyze: back-to-back meetings, free blocks, overbooked periods, optimal focus time.\nRespond with JSON: { "insights": ["..."], "recommendations": ["..."], "focus_blocks": [{ "start": "HH:MM", "end": "HH:MM", "suggestion": "..." }], "meeting_load": "light|moderate|heavy" }`,
122
+ ).then(r => { agentResults.oracle = r; ok('ORACLE: Schedule analysis complete'); })
123
+ .catch(e => { warn(`ORACLE failed: ${e.message}`); agentResults.oracle = '{"insights":[],"recommendations":[],"focus_blocks":[],"meeting_load":"unknown"}'; })
124
+ );
125
+
126
+ await Promise.all(parallelPromises);
127
+
128
+ // ── Phase 4: SCHEHERAZADE — Meeting talking points (depends on HERALD) ──
129
+ if (agentResults.herald && events.length > 0) {
130
+ info('Phase 4: SCHEHERAZADE preparing talking points...');
131
+ try {
132
+ agentResults.scheherazade = await callAgent(config, 'scheherazade',
133
+ `Based on these meeting briefs, prepare concise talking points and summaries for each meeting.\n\nMEETING BRIEFS:\n${agentResults.herald}\n\nRespond with JSON: { "preparations": [{ "meeting": "...", "talking_points": ["..."], "summary": "...", "action_items": ["..."] }] }`,
134
+ );
135
+ ok('SCHEHERAZADE: Talking points ready');
136
+ } catch (e) {
137
+ warn(`SCHEHERAZADE failed: ${e.message}`);
138
+ }
139
+ }
140
+
141
+ // ── Phase 6: CONDUCTOR — Synthesize daily plan ─────────────────────────
142
+ info('Phase 6: CONDUCTOR synthesizing daily plan...');
143
+
144
+ const conductorPrompt = `You are the NHA Daily Planner. Synthesize intelligence from 4 specialist agents into a structured daily plan.
145
+
146
+ AGENT REPORTS:
147
+ ${agentResults.saber ? `\n[SABER — Security Scan]\n${agentResults.saber}` : ''}
148
+ ${agentResults.herald ? `\n[HERALD — Meeting Briefs]\n${agentResults.herald}` : ''}
149
+ ${agentResults.scheherazade ? `\n[SCHEHERAZADE — Talking Points]\n${agentResults.scheherazade}` : ''}
150
+ ${agentResults.oracle ? `\n[ORACLE — Schedule Analysis]\n${agentResults.oracle}` : ''}
151
+
152
+ RAW DATA:
153
+ Date: ${dateStr}
154
+ Events: ${events.length}
155
+ Unread emails: ${emails.length}
156
+ Tasks: ${tasks.length}
157
+
158
+ CALENDAR:
159
+ ${calendarContext || 'No events.'}
160
+
161
+ EXISTING TASKS:
162
+ ${taskContext || 'No tasks.'}
163
+
164
+ Create a comprehensive daily plan. Output strict JSON:
165
+ {
166
+ "date": "${dateStr}",
167
+ "executive_summary": "2-3 sentence overview",
168
+ "priority_actions": [{ "time": "HH:MM", "action": "...", "source": "email|calendar|task", "priority": "critical|high|medium|low" }],
169
+ "schedule": [{ "time_start": "HH:MM", "time_end": "HH:MM", "type": "meeting|focus|break|task", "title": "...", "notes": "...", "preparation": "..." }],
170
+ "email_actions": [{ "from": "...", "subject": "...", "action": "reply|archive|flag|defer", "suggested_reply": "..." }],
171
+ "security_alerts": [],
172
+ "new_tasks": [{ "description": "...", "priority": "high|medium|low", "estimated_minutes": N, "suggested_slot": "HH:MM" }],
173
+ "insights": []
174
+ }`;
175
+
176
+ let plan;
177
+ try {
178
+ const result = await callLLM(config, conductorPrompt, 'Generate the daily plan now.', {});
179
+ // Extract JSON from response (may have markdown wrapper)
180
+ const jsonMatch = result.match(/\{[\s\S]*\}/);
181
+ if (jsonMatch) {
182
+ plan = JSON.parse(jsonMatch[0]);
183
+ } else {
184
+ plan = { date: dateStr, executive_summary: result, priority_actions: [], schedule: [], email_actions: [], security_alerts: [], new_tasks: [], insights: [] };
185
+ }
186
+ ok('CONDUCTOR: Daily plan synthesized');
187
+ } catch (e) {
188
+ fail(`CONDUCTOR failed: ${e.message}`);
189
+ plan = { date: dateStr, executive_summary: 'Plan generation failed. Check your API key and try again.', priority_actions: [], schedule: [], email_actions: [], security_alerts: [], new_tasks: [], insights: [] };
190
+ }
191
+
192
+ // Auto-create tasks suggested by agents
193
+ if (plan.new_tasks?.length > 0) {
194
+ bulkAddTasks(plan.new_tasks.map(t => ({
195
+ description: t.description,
196
+ priority: t.priority || 'medium',
197
+ source: 'agent',
198
+ estimatedMinutes: t.estimated_minutes,
199
+ suggestedSlot: t.suggested_slot,
200
+ })), dateStr);
201
+ ok(`${plan.new_tasks.length} new tasks auto-created`);
202
+ }
203
+
204
+ // Save plan
205
+ fs.mkdirSync(PLANS_DIR, { recursive: true });
206
+ const fullPlan = {
207
+ ...plan,
208
+ metadata: {
209
+ generatedAt: new Date().toISOString(),
210
+ durationMs: Date.now() - startTime,
211
+ agentsUsed: Object.keys(agentResults),
212
+ emailCount: emails.length,
213
+ eventCount: events.length,
214
+ taskCount: tasks.length,
215
+ provider: config.llm.provider,
216
+ },
217
+ };
218
+ fs.writeFileSync(planFile, JSON.stringify(fullPlan, null, 2) + '\n', { mode: 0o600 });
219
+
220
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
221
+ console.log(`\n ${D}Generated in ${elapsed}s by ${Object.keys(agentResults).length + 1} agents${NC}\n`);
222
+
223
+ displayPlan(fullPlan);
224
+ return fullPlan;
225
+ }
226
+
227
+ // ── Plan Display ───────────────────────────────────────────────────────────
228
+
229
+ function displayPlan(plan) {
230
+ console.log(` ${BOLD}${C}Executive Summary${NC}`);
231
+ console.log(` ${plan.executive_summary || 'No summary available.'}\n`);
232
+
233
+ if (plan.priority_actions?.length > 0) {
234
+ console.log(` ${BOLD}${Y}Priority Actions${NC}`);
235
+ for (const a of plan.priority_actions) {
236
+ const icon = a.priority === 'critical' ? '\x1b[0;31m!!\x1b[0m' : a.priority === 'high' ? '\x1b[1;33m!\x1b[0m' : '\x1b[2m·\x1b[0m';
237
+ console.log(` ${icon} ${a.time || ''} ${a.action} ${D}(${a.source})${NC}`);
238
+ }
239
+ console.log('');
240
+ }
241
+
242
+ if (plan.schedule?.length > 0) {
243
+ console.log(` ${BOLD}${G}Schedule${NC}`);
244
+ for (const s of plan.schedule) {
245
+ const typeIcon = s.type === 'meeting' ? '\x1b[0;36m●\x1b[0m' : s.type === 'focus' ? '\x1b[0;32m◆\x1b[0m' : s.type === 'break' ? '\x1b[2m○\x1b[0m' : '\x1b[0;33m■\x1b[0m';
246
+ console.log(` ${typeIcon} ${s.time_start}-${s.time_end} ${s.title}`);
247
+ if (s.preparation) console.log(` ${D}Prep: ${s.preparation}${NC}`);
248
+ }
249
+ console.log('');
250
+ }
251
+
252
+ if (plan.email_actions?.length > 0) {
253
+ console.log(` ${BOLD}Email Actions${NC}`);
254
+ for (const e of plan.email_actions) {
255
+ const actionColor = e.action === 'reply' ? G : e.action === 'flag' ? Y : D;
256
+ console.log(` ${actionColor}[${e.action}]${NC} ${e.subject} ${D}from ${e.from}${NC}`);
257
+ }
258
+ console.log('');
259
+ }
260
+
261
+ if (plan.security_alerts?.length > 0) {
262
+ console.log(` ${BOLD}\x1b[0;31mSecurity Alerts${NC}`);
263
+ for (const a of plan.security_alerts) {
264
+ console.log(` \x1b[0;31m!\x1b[0m ${typeof a === 'string' ? a : a.message || JSON.stringify(a)}`);
265
+ }
266
+ console.log('');
267
+ }
268
+
269
+ if (plan.insights?.length > 0) {
270
+ console.log(` ${BOLD}${D}Insights${NC}`);
271
+ for (const i of plan.insights) {
272
+ console.log(` ${D}→ ${typeof i === 'string' ? i : i.message || JSON.stringify(i)}${NC}`);
273
+ }
274
+ console.log('');
275
+ }
276
+
277
+ if (plan.metadata) {
278
+ console.log(` ${D}Plan saved to ~/.nha/ops/plans/${plan.date}.json${NC}`);
279
+ console.log(` ${D}Agents: ${plan.metadata.agentsUsed?.join(', ') || 'conductor'}${NC}\n`);
280
+ }
281
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Local task management — JSON files per day at ~/.nha/ops/tasks/.
3
+ * Zero dependencies.
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { NHA_DIR } from '../constants.mjs';
9
+
10
+ const TASKS_DIR = path.join(NHA_DIR, 'ops', 'tasks');
11
+
12
+ function todayStr() {
13
+ return new Date().toISOString().split('T')[0];
14
+ }
15
+
16
+ function taskFile(date) {
17
+ return path.join(TASKS_DIR, `${date || todayStr()}.json`);
18
+ }
19
+
20
+ function loadDay(date) {
21
+ const file = taskFile(date);
22
+ if (!fs.existsSync(file)) return { tasks: [], version: 1 };
23
+ try { return JSON.parse(fs.readFileSync(file, 'utf-8')); }
24
+ catch { return { tasks: [], version: 1 }; }
25
+ }
26
+
27
+ function saveDay(date, data) {
28
+ fs.mkdirSync(TASKS_DIR, { recursive: true });
29
+ fs.writeFileSync(taskFile(date), JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
30
+ }
31
+
32
+ /**
33
+ * Get all tasks for a date.
34
+ * @param {string} [date] — YYYY-MM-DD (default: today)
35
+ * @returns {Array} tasks
36
+ */
37
+ export function getTasks(date) {
38
+ return loadDay(date).tasks;
39
+ }
40
+
41
+ /**
42
+ * Add a new task.
43
+ * @param {object} task — { description, priority?, due?, source?, sourceRef? }
44
+ * @param {string} [date]
45
+ * @returns {object} the created task
46
+ */
47
+ export function addTask(task, date) {
48
+ const data = loadDay(date);
49
+ const id = data.tasks.length > 0 ? Math.max(...data.tasks.map(t => t.id)) + 1 : 1;
50
+ const newTask = {
51
+ id,
52
+ description: task.description,
53
+ priority: task.priority || 'medium',
54
+ status: 'pending',
55
+ due: task.due || null,
56
+ source: task.source || 'manual',
57
+ sourceRef: task.sourceRef || null,
58
+ createdAt: new Date().toISOString(),
59
+ completedAt: null,
60
+ estimatedMinutes: task.estimatedMinutes || null,
61
+ suggestedSlot: task.suggestedSlot || null,
62
+ };
63
+ data.tasks.push(newTask);
64
+ saveDay(date, data);
65
+ return newTask;
66
+ }
67
+
68
+ /**
69
+ * Mark a task as done.
70
+ * @param {number} taskId
71
+ * @param {string} [date]
72
+ * @returns {boolean} success
73
+ */
74
+ export function completeTask(taskId, date) {
75
+ const data = loadDay(date);
76
+ const task = data.tasks.find(t => t.id === taskId);
77
+ if (!task) return false;
78
+ task.status = 'done';
79
+ task.completedAt = new Date().toISOString();
80
+ saveDay(date, data);
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * Edit a task description.
86
+ */
87
+ export function editTask(taskId, description, date) {
88
+ const data = loadDay(date);
89
+ const task = data.tasks.find(t => t.id === taskId);
90
+ if (!task) return false;
91
+ task.description = description;
92
+ saveDay(date, data);
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * Move a task to another date.
98
+ */
99
+ export function moveTask(taskId, fromDate, toDate) {
100
+ const fromData = loadDay(fromDate);
101
+ const idx = fromData.tasks.findIndex(t => t.id === taskId);
102
+ if (idx === -1) return false;
103
+
104
+ const [task] = fromData.tasks.splice(idx, 1);
105
+ task.status = 'pending'; // reset status on move
106
+ task.completedAt = null;
107
+ saveDay(fromDate, fromData);
108
+
109
+ const toData = loadDay(toDate);
110
+ task.id = toData.tasks.length > 0 ? Math.max(...toData.tasks.map(t => t.id)) + 1 : 1;
111
+ toData.tasks.push(task);
112
+ saveDay(toDate, toData);
113
+ return true;
114
+ }
115
+
116
+ /**
117
+ * Get tasks for the week (Mon-Sun).
118
+ */
119
+ export function getWeekTasks() {
120
+ const now = new Date();
121
+ const monday = new Date(now);
122
+ monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
123
+
124
+ const week = [];
125
+ for (let i = 0; i < 7; i++) {
126
+ const d = new Date(monday);
127
+ d.setDate(monday.getDate() + i);
128
+ const dateStr = d.toISOString().split('T')[0];
129
+ const tasks = getTasks(dateStr);
130
+ week.push({ date: dateStr, day: d.toLocaleDateString('en', { weekday: 'short' }), tasks });
131
+ }
132
+ return week;
133
+ }
134
+
135
+ /**
136
+ * Bulk add tasks (from agent pipeline).
137
+ * @param {Array<{description, priority, due, source, estimatedMinutes, suggestedSlot}>} tasks
138
+ * @param {string} [date]
139
+ */
140
+ export function bulkAddTasks(tasks, date) {
141
+ for (const t of tasks) {
142
+ addTask(t, date);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Get daily stats.
148
+ */
149
+ export function getDayStats(date) {
150
+ const tasks = getTasks(date);
151
+ const total = tasks.length;
152
+ const done = tasks.filter(t => t.status === 'done').length;
153
+ const pending = tasks.filter(t => t.status === 'pending').length;
154
+ const high = tasks.filter(t => t.priority === 'high' || t.priority === 'critical').length;
155
+ return { total, done, pending, high, completionRate: total > 0 ? Math.round(done / total * 100) : 0 };
156
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Encrypted token storage — AES-256-GCM with machine-derived key.
3
+ * Stores OAuth tokens at ~/.nha/ops/tokens.enc
4
+ * Zero dependencies — uses Node.js native crypto.
5
+ */
6
+
7
+ import crypto from 'crypto';
8
+ import fs from 'fs';
9
+ import os from 'os';
10
+ import path from 'path';
11
+ import { NHA_DIR } from '../constants.mjs';
12
+
13
+ const OPS_DIR = path.join(NHA_DIR, 'ops');
14
+ const TOKENS_FILE = path.join(OPS_DIR, 'tokens.enc');
15
+
16
+ /** Derive encryption key from machine-specific fingerprint */
17
+ function deriveKey() {
18
+ const fingerprint = os.hostname() + os.userInfo().username + os.homedir();
19
+ return crypto.createHash('sha256').update(fingerprint).digest();
20
+ }
21
+
22
+ /** Encrypt JSON data with AES-256-GCM */
23
+ function encrypt(data) {
24
+ const key = deriveKey();
25
+ const iv = crypto.randomBytes(16);
26
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
27
+ const plaintext = JSON.stringify(data);
28
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
29
+ const tag = cipher.getAuthTag();
30
+ return {
31
+ iv: iv.toString('base64'),
32
+ tag: tag.toString('base64'),
33
+ ciphertext: encrypted.toString('base64'),
34
+ };
35
+ }
36
+
37
+ /** Decrypt AES-256-GCM data back to JSON */
38
+ function decrypt(envelope) {
39
+ const key = deriveKey();
40
+ const iv = Buffer.from(envelope.iv, 'base64');
41
+ const tag = Buffer.from(envelope.tag, 'base64');
42
+ const ciphertext = Buffer.from(envelope.ciphertext, 'base64');
43
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
44
+ decipher.setAuthTag(tag);
45
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
46
+ return JSON.parse(decrypted.toString('utf-8'));
47
+ }
48
+
49
+ /**
50
+ * Save OAuth tokens (encrypted).
51
+ * @param {object} tokens — { access_token, refresh_token, expires_at, scope, email }
52
+ */
53
+ export function saveTokens(tokens) {
54
+ fs.mkdirSync(OPS_DIR, { recursive: true });
55
+ const envelope = encrypt(tokens);
56
+ fs.writeFileSync(TOKENS_FILE, JSON.stringify(envelope, null, 2), { mode: 0o600 });
57
+ }
58
+
59
+ /**
60
+ * Load OAuth tokens (decrypted).
61
+ * @returns {object|null} tokens or null if not stored
62
+ */
63
+ export function loadTokens() {
64
+ if (!fs.existsSync(TOKENS_FILE)) return null;
65
+ try {
66
+ const envelope = JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf-8'));
67
+ return decrypt(envelope);
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Delete stored tokens.
75
+ */
76
+ export function deleteTokens() {
77
+ if (fs.existsSync(TOKENS_FILE)) fs.rmSync(TOKENS_FILE);
78
+ }
79
+
80
+ /**
81
+ * Check if access token is expired (with 5-minute buffer).
82
+ * @param {object} tokens
83
+ * @returns {boolean}
84
+ */
85
+ export function isExpired(tokens) {
86
+ if (!tokens?.expires_at) return true;
87
+ return Date.now() >= tokens.expires_at - 300_000;
88
+ }
89
+
90
+ /**
91
+ * Refresh access token using refresh_token.
92
+ * @param {string} clientId
93
+ * @param {string} clientSecret
94
+ * @param {string} refreshToken
95
+ * @returns {Promise<object>} new token set
96
+ */
97
+ export async function refreshAccessToken(clientId, clientSecret, refreshToken) {
98
+ const params = new URLSearchParams({
99
+ client_id: clientId,
100
+ client_secret: clientSecret,
101
+ refresh_token: refreshToken,
102
+ grant_type: 'refresh_token',
103
+ });
104
+
105
+ const res = await fetch('https://oauth2.googleapis.com/token', {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
108
+ body: params.toString(),
109
+ });
110
+
111
+ if (!res.ok) {
112
+ const err = await res.text();
113
+ throw new Error(`Token refresh failed: ${err}`);
114
+ }
115
+
116
+ const data = await res.json();
117
+ return {
118
+ access_token: data.access_token,
119
+ refresh_token: refreshToken, // Google doesn't always return a new refresh token
120
+ expires_at: Date.now() + (data.expires_in * 1000),
121
+ scope: data.scope,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Get a valid access token — refreshes automatically if expired.
127
+ * @param {object} config — NHA config with google.clientId etc.
128
+ * @returns {Promise<string>} access_token
129
+ */
130
+ export async function getAccessToken(config) {
131
+ let tokens = loadTokens();
132
+ if (!tokens) throw new Error('Not authenticated. Run: nha google auth');
133
+
134
+ if (isExpired(tokens)) {
135
+ const clientId = config.google?.clientId;
136
+ const clientSecret = config.google?.clientSecret || '';
137
+ if (!clientId) throw new Error('Google client ID not configured');
138
+
139
+ tokens = await refreshAccessToken(clientId, clientSecret, tokens.refresh_token);
140
+ tokens.email = loadTokens()?.email; // preserve email
141
+ saveTokens(tokens);
142
+ }
143
+
144
+ return tokens.access_token;
145
+ }