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,319 @@
1
+ /**
2
+ * Shared LLM provider service — zero dependencies.
3
+ * Used by both `nha ask` (interactive) and PAO pipeline (batch).
4
+ *
5
+ * Supports: Anthropic, OpenAI, Gemini, DeepSeek, Grok, Mistral, Cohere.
6
+ */
7
+
8
+ // ── Providers ──────────────────────────────────────────────────────────────
9
+
10
+ export async function callAnthropic(apiKey, model, systemPrompt, userMessage, stream = false) {
11
+ const body = {
12
+ model: model || 'claude-sonnet-4-20250514',
13
+ max_tokens: 8192,
14
+ system: systemPrompt,
15
+ messages: [{ role: 'user', content: userMessage }],
16
+ stream,
17
+ };
18
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
19
+ method: 'POST',
20
+ headers: {
21
+ 'Content-Type': 'application/json',
22
+ 'x-api-key': apiKey,
23
+ 'anthropic-version': '2023-06-01',
24
+ },
25
+ body: JSON.stringify(body),
26
+ });
27
+ if (!res.ok) {
28
+ const err = await res.text();
29
+ throw new Error(`Anthropic ${res.status}: ${err}`);
30
+ }
31
+ if (stream) return streamSSE(res, 'anthropic');
32
+ const data = await res.json();
33
+ return data.content?.[0]?.text || '';
34
+ }
35
+
36
+ export async function callOpenAI(apiKey, model, systemPrompt, userMessage, stream = false) {
37
+ const body = {
38
+ model: model || 'gpt-4o',
39
+ max_tokens: 8192,
40
+ messages: [
41
+ { role: 'system', content: systemPrompt },
42
+ { role: 'user', content: userMessage },
43
+ ],
44
+ stream,
45
+ };
46
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ 'Authorization': `Bearer ${apiKey}`,
51
+ },
52
+ body: JSON.stringify(body),
53
+ });
54
+ if (!res.ok) {
55
+ const err = await res.text();
56
+ throw new Error(`OpenAI ${res.status}: ${err}`);
57
+ }
58
+ if (stream) return streamSSE(res, 'openai');
59
+ const data = await res.json();
60
+ return data.choices?.[0]?.message?.content || '';
61
+ }
62
+
63
+ export async function callGemini(apiKey, model, systemPrompt, userMessage, _stream = false) {
64
+ const m = model || 'gemini-2.5-pro-preview-05-06';
65
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent?key=${apiKey}`;
66
+ const body = {
67
+ system_instruction: { parts: [{ text: systemPrompt }] },
68
+ contents: [{ parts: [{ text: userMessage }] }],
69
+ generationConfig: { maxOutputTokens: 8192 },
70
+ };
71
+ const res = await fetch(url, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify(body),
75
+ });
76
+ if (!res.ok) {
77
+ const err = await res.text();
78
+ throw new Error(`Gemini ${res.status}: ${err}`);
79
+ }
80
+ const data = await res.json();
81
+ return data.candidates?.[0]?.content?.parts?.[0]?.text || '';
82
+ }
83
+
84
+ export async function callDeepSeek(apiKey, model, systemPrompt, userMessage, stream = false) {
85
+ const body = {
86
+ model: model || 'deepseek-chat',
87
+ max_tokens: 8192,
88
+ messages: [
89
+ { role: 'system', content: systemPrompt },
90
+ { role: 'user', content: userMessage },
91
+ ],
92
+ stream,
93
+ };
94
+ const res = await fetch('https://api.deepseek.com/v1/chat/completions', {
95
+ method: 'POST',
96
+ headers: {
97
+ 'Content-Type': 'application/json',
98
+ 'Authorization': `Bearer ${apiKey}`,
99
+ },
100
+ body: JSON.stringify(body),
101
+ });
102
+ if (!res.ok) {
103
+ const err = await res.text();
104
+ throw new Error(`DeepSeek ${res.status}: ${err}`);
105
+ }
106
+ if (stream) return streamSSE(res, 'openai');
107
+ const data = await res.json();
108
+ return data.choices?.[0]?.message?.content || '';
109
+ }
110
+
111
+ export async function callGrok(apiKey, model, systemPrompt, userMessage, stream = false) {
112
+ const body = {
113
+ model: model || 'grok-3-latest',
114
+ max_tokens: 8192,
115
+ messages: [
116
+ { role: 'system', content: systemPrompt },
117
+ { role: 'user', content: userMessage },
118
+ ],
119
+ stream,
120
+ };
121
+ const res = await fetch('https://api.x.ai/v1/chat/completions', {
122
+ method: 'POST',
123
+ headers: {
124
+ 'Content-Type': 'application/json',
125
+ 'Authorization': `Bearer ${apiKey}`,
126
+ },
127
+ body: JSON.stringify(body),
128
+ });
129
+ if (!res.ok) {
130
+ const err = await res.text();
131
+ throw new Error(`Grok ${res.status}: ${err}`);
132
+ }
133
+ if (stream) return streamSSE(res, 'openai');
134
+ const data = await res.json();
135
+ return data.choices?.[0]?.message?.content || '';
136
+ }
137
+
138
+ export async function callMistral(apiKey, model, systemPrompt, userMessage, stream = false) {
139
+ const body = {
140
+ model: model || 'mistral-large-latest',
141
+ max_tokens: 8192,
142
+ messages: [
143
+ { role: 'system', content: systemPrompt },
144
+ { role: 'user', content: userMessage },
145
+ ],
146
+ stream,
147
+ };
148
+ const res = await fetch('https://api.mistral.ai/v1/chat/completions', {
149
+ method: 'POST',
150
+ headers: {
151
+ 'Content-Type': 'application/json',
152
+ 'Authorization': `Bearer ${apiKey}`,
153
+ },
154
+ body: JSON.stringify(body),
155
+ });
156
+ if (!res.ok) {
157
+ const err = await res.text();
158
+ throw new Error(`Mistral ${res.status}: ${err}`);
159
+ }
160
+ if (stream) return streamSSE(res, 'openai');
161
+ const data = await res.json();
162
+ return data.choices?.[0]?.message?.content || '';
163
+ }
164
+
165
+ export async function callCohere(apiKey, model, systemPrompt, userMessage, _stream = false) {
166
+ const body = {
167
+ model: model || 'command-r-plus',
168
+ max_tokens: 8192,
169
+ preamble: systemPrompt,
170
+ message: userMessage,
171
+ };
172
+ const res = await fetch('https://api.cohere.ai/v1/chat', {
173
+ method: 'POST',
174
+ headers: {
175
+ 'Content-Type': 'application/json',
176
+ 'Authorization': `Bearer ${apiKey}`,
177
+ },
178
+ body: JSON.stringify(body),
179
+ });
180
+ if (!res.ok) {
181
+ const err = await res.text();
182
+ throw new Error(`Cohere ${res.status}: ${err}`);
183
+ }
184
+ const data = await res.json();
185
+ return data.text || '';
186
+ }
187
+
188
+ // ── SSE Stream Parser ──────────────────────────────────────────────────────
189
+
190
+ export async function streamSSE(res, format) {
191
+ const reader = res.body.getReader();
192
+ const decoder = new TextDecoder();
193
+ let buffer = '';
194
+ let fullText = '';
195
+
196
+ while (true) {
197
+ const { done, value } = await reader.read();
198
+ if (done) break;
199
+
200
+ buffer += decoder.decode(value, { stream: true });
201
+ const lines = buffer.split('\n');
202
+ buffer = lines.pop() || '';
203
+
204
+ for (const line of lines) {
205
+ if (!line.startsWith('data: ')) continue;
206
+ const data = line.slice(6).trim();
207
+ if (data === '[DONE]') continue;
208
+
209
+ try {
210
+ const json = JSON.parse(data);
211
+ let chunk = '';
212
+
213
+ if (format === 'anthropic') {
214
+ if (json.type === 'content_block_delta') {
215
+ chunk = json.delta?.text || '';
216
+ }
217
+ } else {
218
+ chunk = json.choices?.[0]?.delta?.content || '';
219
+ }
220
+
221
+ if (chunk) {
222
+ process.stdout.write(chunk);
223
+ fullText += chunk;
224
+ }
225
+ } catch {}
226
+ }
227
+ }
228
+
229
+ process.stdout.write('\n');
230
+ return fullText;
231
+ }
232
+
233
+ // ── Router ─────────────────────────────────────────────────────────────────
234
+
235
+ const PROVIDERS = {
236
+ anthropic: callAnthropic,
237
+ openai: callOpenAI,
238
+ gemini: callGemini,
239
+ deepseek: callDeepSeek,
240
+ grok: callGrok,
241
+ mistral: callMistral,
242
+ cohere: callCohere,
243
+ };
244
+
245
+ export function getProviderCall(provider) {
246
+ return PROVIDERS[provider] || null;
247
+ }
248
+
249
+ export function getApiKey(config, provider) {
250
+ const keyMap = {
251
+ anthropic: config.llm.apiKey,
252
+ openai: config.llm.openaiKey || config.llm.apiKey,
253
+ gemini: config.llm.geminiKey || config.llm.apiKey,
254
+ deepseek: config.llm.deepseekKey || config.llm.apiKey,
255
+ grok: config.llm.grokKey || config.llm.apiKey,
256
+ mistral: config.llm.mistralKey || config.llm.apiKey,
257
+ cohere: config.llm.cohereKey || config.llm.apiKey,
258
+ };
259
+ return keyMap[provider] || config.llm.apiKey;
260
+ }
261
+
262
+ /**
263
+ * Call an LLM provider with the given prompt. No streaming.
264
+ * @returns {Promise<string>} The LLM response text.
265
+ */
266
+ export async function callLLM(config, systemPrompt, userMessage, opts = {}) {
267
+ const provider = opts.provider || config.llm.provider || 'anthropic';
268
+ const model = opts.model || config.llm.model || null;
269
+ const apiKey = getApiKey(config, provider);
270
+ if (!apiKey) throw new Error(`No API key for ${provider}`);
271
+
272
+ const callFn = getProviderCall(provider);
273
+ if (!callFn) throw new Error(`Unknown provider: ${provider}`);
274
+
275
+ return callFn(apiKey, model, systemPrompt, userMessage, false);
276
+ }
277
+
278
+ /**
279
+ * Call an agent by name — loads the agent file, calls LLM, returns response.
280
+ * No streaming. Used by PAO pipeline for batch agent calls.
281
+ */
282
+ export async function callAgent(config, agentName, userMessage, opts = {}) {
283
+ const { AGENTS_DIR } = await import('../constants.mjs');
284
+ const fs = await import('fs');
285
+ const path = await import('path');
286
+
287
+ const agentFile = path.default.join(AGENTS_DIR, `${agentName}.mjs`);
288
+ if (!fs.default.existsSync(agentFile)) throw new Error(`Agent ${agentName} not found`);
289
+
290
+ const source = fs.default.readFileSync(agentFile, 'utf-8');
291
+ const { systemPrompt } = parseAgentFile(source, agentName);
292
+ if (!systemPrompt) throw new Error(`Agent ${agentName} has no SYSTEM_PROMPT`);
293
+
294
+ return callLLM(config, systemPrompt, userMessage, opts);
295
+ }
296
+
297
+ // ── Agent File Parser (shared) ─────────────────────────────────────────────
298
+
299
+ export function parseAgentFile(source, agentName) {
300
+ let card = { displayName: agentName.toUpperCase(), category: 'agent', tagline: '' };
301
+ let systemPrompt = '';
302
+
303
+ const cardMatch = source.match(/export\s+var\s+AGENT_CARD\s*=\s*(\{[\s\S]*?\});/);
304
+ if (cardMatch) {
305
+ try { card = new Function('return ' + cardMatch[1])(); } catch {}
306
+ }
307
+
308
+ const promptMatch = source.match(/export\s+var\s+SYSTEM_PROMPT\s*=\s*([\s\S]*?);(?:\n\nexport|\n\nvar|\n\n\/\/)/);
309
+ if (promptMatch) {
310
+ try { systemPrompt = new Function('return ' + promptMatch[1])(); } catch {}
311
+ }
312
+
313
+ if (!systemPrompt) {
314
+ const simpleMatch = source.match(/SYSTEM_PROMPT\s*=\s*'([\s\S]*?)';/);
315
+ if (simpleMatch) systemPrompt = simpleMatch[1];
316
+ }
317
+
318
+ return { card, systemPrompt };
319
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Desktop + webhook notifications — zero dependencies.
3
+ * macOS: osascript, Linux: notify-send, Webhooks: fetch POST
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import os from 'os';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { NHA_DIR } from '../constants.mjs';
11
+
12
+ const LOG_FILE = path.join(NHA_DIR, 'ops', 'daemon', 'notifications.log');
13
+
14
+ /**
15
+ * Send a notification via all configured channels.
16
+ * @param {string} title
17
+ * @param {string} body
18
+ * @param {object} config — NHA config
19
+ */
20
+ export async function notify(title, body, config) {
21
+ const ops = config.ops || {};
22
+ const notifs = ops.notifications || {};
23
+
24
+ // Desktop notification
25
+ if (notifs.desktop !== false) {
26
+ sendDesktop(title, body);
27
+ }
28
+
29
+ // Terminal log
30
+ if (notifs.terminal !== false) {
31
+ logToFile(title, body);
32
+ }
33
+
34
+ // Telegram webhook
35
+ if (ops.webhooks?.telegram) {
36
+ sendTelegram(ops.webhooks.telegram, title, body).catch(() => {});
37
+ }
38
+
39
+ // Discord webhook
40
+ if (ops.webhooks?.discord) {
41
+ sendDiscord(ops.webhooks.discord, title, body).catch(() => {});
42
+ }
43
+ }
44
+
45
+ function sendDesktop(title, body) {
46
+ const platform = os.platform();
47
+ try {
48
+ if (platform === 'darwin') {
49
+ const escaped = body.replace(/"/g, '\\"').slice(0, 200);
50
+ execSync(`osascript -e 'display notification "${escaped}" with title "NHA: ${title.replace(/"/g, '\\"')}"'`);
51
+ } else if (platform === 'linux') {
52
+ execSync(`notify-send "NHA: ${title}" "${body.slice(0, 200)}"`);
53
+ }
54
+ // Windows: PowerShell toast (best effort)
55
+ else if (platform === 'win32') {
56
+ execSync(`powershell -Command "New-BurntToastNotification -Text 'NHA: ${title}', '${body.slice(0, 200)}'" 2>NUL`);
57
+ }
58
+ } catch { /* non-fatal */ }
59
+ }
60
+
61
+ function logToFile(title, body) {
62
+ const dir = path.dirname(LOG_FILE);
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ const timestamp = new Date().toISOString();
65
+ const line = `[${timestamp}] ${title}: ${body}\n`;
66
+ fs.appendFileSync(LOG_FILE, line, { mode: 0o600 });
67
+
68
+ // Rotate if > 1MB
69
+ try {
70
+ const stat = fs.statSync(LOG_FILE);
71
+ if (stat.size > 1_048_576) {
72
+ const rotated = LOG_FILE + '.1';
73
+ if (fs.existsSync(rotated)) fs.rmSync(rotated);
74
+ fs.renameSync(LOG_FILE, rotated);
75
+ }
76
+ } catch {}
77
+ }
78
+
79
+ async function sendTelegram(config, title, body) {
80
+ // config format: "botToken:chatId" or { botToken, chatId }
81
+ let botToken, chatId;
82
+ if (typeof config === 'string') {
83
+ [botToken, chatId] = config.split(':');
84
+ } else {
85
+ botToken = config.botToken;
86
+ chatId = config.chatId;
87
+ }
88
+ if (!botToken || !chatId) return;
89
+
90
+ await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify({
94
+ chat_id: chatId,
95
+ text: `*NHA: ${title}*\n${body}`,
96
+ parse_mode: 'Markdown',
97
+ }),
98
+ });
99
+ }
100
+
101
+ async function sendDiscord(webhookUrl, title, body) {
102
+ await fetch(webhookUrl, {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({
106
+ content: `**NHA: ${title}**\n${body}`,
107
+ }),
108
+ });
109
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * PAO Background Daemon — watches Gmail + Calendar, triggers agents.
3
+ *
4
+ * Runs as detached child process. Polls every 5 min (mail), 15 min (calendar).
5
+ * Generates daily plan at configured time. Sends notifications.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { spawn } from 'child_process';
11
+ import { NHA_DIR } from '../constants.mjs';
12
+ import { loadConfig } from '../config.mjs';
13
+ import { loadTokens } from './token-store.mjs';
14
+ import { getUnreadImportant } from './google-gmail.mjs';
15
+ import { getUpcomingEvents } from './google-calendar.mjs';
16
+ import { notify } from './notification.mjs';
17
+ import { callAgent } from './llm.mjs';
18
+ import { runPlanningPipeline } from './ops-pipeline.mjs';
19
+
20
+ const DAEMON_DIR = path.join(NHA_DIR, 'ops', 'daemon');
21
+ const PID_FILE = path.join(DAEMON_DIR, 'daemon.pid');
22
+ const STATE_FILE = path.join(DAEMON_DIR, 'state.json');
23
+ const LOG_FILE = path.join(DAEMON_DIR, 'daemon.log');
24
+
25
+ // ── Daemon Control ─────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Check if daemon is running.
29
+ */
30
+ export function isRunning() {
31
+ if (!fs.existsSync(PID_FILE)) return false;
32
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
33
+ try {
34
+ process.kill(pid, 0); // signal 0 = check if alive
35
+ return true;
36
+ } catch {
37
+ // PID file stale — clean up
38
+ fs.rmSync(PID_FILE, { force: true });
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Start the daemon as a detached background process.
45
+ */
46
+ export function startDaemon() {
47
+ if (isRunning()) {
48
+ return { ok: false, message: 'Daemon already running' };
49
+ }
50
+
51
+ fs.mkdirSync(DAEMON_DIR, { recursive: true });
52
+
53
+ // The daemon runs this same file with --daemon-loop flag
54
+ const logFd = fs.openSync(LOG_FILE, 'a');
55
+ const child = spawn(process.execPath, [
56
+ path.join(NHA_DIR, '..', 'packages', 'nha-cli', 'src', 'services', 'ops-daemon.mjs'),
57
+ '--daemon-loop',
58
+ ], {
59
+ detached: true,
60
+ stdio: ['ignore', logFd, logFd],
61
+ env: { ...process.env, NHA_DAEMON: '1' },
62
+ });
63
+
64
+ child.unref();
65
+ fs.writeFileSync(PID_FILE, String(child.pid), { mode: 0o600 });
66
+ fs.closeSync(logFd);
67
+
68
+ return { ok: true, pid: child.pid };
69
+ }
70
+
71
+ /**
72
+ * Stop the daemon.
73
+ */
74
+ export function stopDaemon() {
75
+ if (!fs.existsSync(PID_FILE)) {
76
+ return { ok: false, message: 'No daemon running' };
77
+ }
78
+
79
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
80
+ try {
81
+ process.kill(pid, 'SIGTERM');
82
+ fs.rmSync(PID_FILE, { force: true });
83
+ return { ok: true, pid };
84
+ } catch {
85
+ fs.rmSync(PID_FILE, { force: true });
86
+ return { ok: false, message: 'Daemon process not found (stale PID)' };
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Get daemon status.
92
+ */
93
+ export function getDaemonStatus() {
94
+ const running = isRunning();
95
+ let state = {};
96
+ if (fs.existsSync(STATE_FILE)) {
97
+ try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); } catch {}
98
+ }
99
+ let pid = null;
100
+ if (fs.existsSync(PID_FILE)) {
101
+ pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
102
+ }
103
+ return { running, pid, ...state };
104
+ }
105
+
106
+ // ── Daemon Loop (runs in background process) ───────────────────────────────
107
+
108
+ function saveState(state) {
109
+ fs.mkdirSync(DAEMON_DIR, { recursive: true });
110
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
111
+ }
112
+
113
+ function log(msg) {
114
+ const ts = new Date().toISOString();
115
+ console.log(`[${ts}] ${msg}`);
116
+ }
117
+
118
+ async function daemonLoop() {
119
+ log('NHA PAO Daemon started');
120
+ const config = loadConfig();
121
+
122
+ const state = {
123
+ startedAt: new Date().toISOString(),
124
+ lastMailCheck: null,
125
+ lastCalendarCheck: null,
126
+ lastPlanGenerated: null,
127
+ errors: 0,
128
+ };
129
+ saveState(state);
130
+
131
+ const MAIL_INTERVAL = config.ops?.pollIntervalMail || 300_000; // 5 min
132
+ const CAL_INTERVAL = config.ops?.pollIntervalCalendar || 900_000; // 15 min
133
+ const MEETING_ALERT = config.ops?.meetingAlertMinutes || 30;
134
+ const PLAN_TIME = config.ops?.planTime || '07:00';
135
+
136
+ let lastMailCheck = 0;
137
+ let lastCalCheck = 0;
138
+ let todayPlanDone = false;
139
+ let knownEmailIds = new Set();
140
+
141
+ // Main loop
142
+ setInterval(async () => {
143
+ const now = Date.now();
144
+ const tokens = loadTokens();
145
+ if (!tokens) return; // not authenticated
146
+
147
+ // ── Mail check ─────────────────────────────────────────
148
+ if (now - lastMailCheck > MAIL_INTERVAL) {
149
+ lastMailCheck = now;
150
+ try {
151
+ const emails = await getUnreadImportant(config, 10);
152
+ const newEmails = emails.filter(e => !knownEmailIds.has(e.id));
153
+
154
+ for (const email of newEmails) {
155
+ knownEmailIds.add(email.id);
156
+
157
+ // SABER quick scan for suspicious emails
158
+ if (email.urls.length > 0 || email.from.includes('paypal') || email.from.includes('bank') || email.subject.toLowerCase().includes('urgent')) {
159
+ try {
160
+ const scanResult = await callAgent(config, 'saber',
161
+ `Quick security scan: From="${email.from}" Subject="${email.subject}" URLs=${email.urls.join(', ')}\nIs this potentially phishing? Respond: SAFE or FLAGGED with reason (one line).`
162
+ );
163
+ if (scanResult.toUpperCase().includes('FLAGGED')) {
164
+ await notify('Security Alert', `Suspicious email from ${email.from}: ${email.subject}\n${scanResult}`, config);
165
+ }
166
+ } catch { /* non-fatal */ }
167
+ }
168
+
169
+ // Notify about important new emails
170
+ if (email.isImportant) {
171
+ await notify('Important Email', `From: ${email.from}\n${email.subject}`, config);
172
+ }
173
+ }
174
+
175
+ state.lastMailCheck = new Date().toISOString();
176
+ saveState(state);
177
+ log(`Mail check: ${emails.length} unread, ${newEmails.length} new`);
178
+ } catch (err) {
179
+ state.errors++;
180
+ log(`Mail check error: ${err.message}`);
181
+ }
182
+ }
183
+
184
+ // ── Calendar check ─────────────────────────────────────
185
+ if (now - lastCalCheck > CAL_INTERVAL) {
186
+ lastCalCheck = now;
187
+ try {
188
+ const upcoming = await getUpcomingEvents(config, 1); // next 1 hour
189
+
190
+ for (const event of upcoming) {
191
+ const eventStart = new Date(event.start).getTime();
192
+ const minutesUntil = (eventStart - now) / 60000;
193
+
194
+ if (minutesUntil > 0 && minutesUntil <= MEETING_ALERT) {
195
+ // Generate quick brief with HERALD
196
+ try {
197
+ const brief = await callAgent(config, 'herald',
198
+ `Meeting in ${Math.round(minutesUntil)} minutes: "${event.summary}"\nAttendees: ${event.attendees.map(a => a.name || a.email).join(', ')}\nDescription: ${event.description.slice(0, 500)}\n\nGenerate a 3-sentence brief.`
199
+ );
200
+ await notify(`Meeting in ${Math.round(minutesUntil)}min`, `${event.summary}\n${brief}`, config);
201
+ } catch {
202
+ await notify(`Meeting in ${Math.round(minutesUntil)}min`, event.summary, config);
203
+ }
204
+ }
205
+ }
206
+
207
+ state.lastCalendarCheck = new Date().toISOString();
208
+ saveState(state);
209
+ log(`Calendar check: ${upcoming.length} upcoming events`);
210
+ } catch (err) {
211
+ state.errors++;
212
+ log(`Calendar check error: ${err.message}`);
213
+ }
214
+ }
215
+
216
+ // ── Daily plan generation ──────────────────────────────
217
+ const nowTime = new Date();
218
+ const currentTime = `${String(nowTime.getHours()).padStart(2, '0')}:${String(nowTime.getMinutes()).padStart(2, '0')}`;
219
+ const todayStr = nowTime.toISOString().split('T')[0];
220
+
221
+ if (!todayPlanDone && currentTime >= PLAN_TIME && currentTime < PLAN_TIME.replace(/:(\d+)/, (_, m) => ':' + String(Number(m) + 5).padStart(2, '0'))) {
222
+ todayPlanDone = true;
223
+ try {
224
+ log('Generating daily plan...');
225
+ await runPlanningPipeline(config, { date: todayStr, refresh: true });
226
+ state.lastPlanGenerated = new Date().toISOString();
227
+ saveState(state);
228
+ await notify('Daily Plan Ready', `Your plan for ${todayStr} is ready. Run "nha plan --show" to view.`, config);
229
+ log('Daily plan generated');
230
+ } catch (err) {
231
+ log(`Plan generation error: ${err.message}`);
232
+ }
233
+ }
234
+
235
+ // Reset plan flag at midnight
236
+ if (currentTime === '00:00') todayPlanDone = false;
237
+
238
+ }, 60_000); // Check every 60 seconds
239
+ }
240
+
241
+ // ── Direct execution (daemon mode) ─────────────────────────────────────────
242
+ if (process.argv.includes('--daemon-loop')) {
243
+ daemonLoop().catch(err => {
244
+ console.error('Daemon fatal error:', err);
245
+ process.exit(1);
246
+ });
247
+ }