nothumanallowed 9.5.2 → 9.6.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 +154 -305
- package/bin/nha.mjs +34 -3
- package/package.json +2 -2
- package/src/cli.mjs +105 -153
- package/src/commands/ask.mjs +18 -206
- package/src/commands/chat.mjs +64 -482
- package/src/commands/ui.mjs +41 -837
- package/src/config.mjs +0 -2
- package/src/constants.mjs +1 -1
- package/src/services/google-oauth.mjs +21 -12
- package/src/services/llm.mjs +0 -138
- package/src/services/ops-daemon.mjs +236 -0
- package/src/services/screen-capture.mjs +160 -0
- package/src/services/tool-executor.mjs +88 -335
- package/src/services/web-ui.mjs +126 -423
- package/src/services/browser-engine.mjs +0 -1240
- package/src/services/conversations.mjs +0 -277
- package/src/services/web-tools.mjs +0 -430
package/src/config.mjs
CHANGED
package/src/constants.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
|
|
8
|
-
export const VERSION = '9.
|
|
8
|
+
export const VERSION = '9.6.0';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
|
@@ -11,9 +11,8 @@ import os from 'os';
|
|
|
11
11
|
import { saveTokens, loadTokens, deleteTokens } from './token-store.mjs';
|
|
12
12
|
import { info, ok, fail, warn } from '../ui.mjs';
|
|
13
13
|
|
|
14
|
-
// NHA published OAuth client —
|
|
15
|
-
const DEFAULT_CLIENT_ID = '
|
|
16
|
-
const DEFAULT_CLIENT_SECRET = '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';
|
|
14
|
+
// NHA published OAuth client (Desktop app type — client_id is not a secret)
|
|
15
|
+
const DEFAULT_CLIENT_ID = ''; // Will be set when Google Cloud project is verified
|
|
17
16
|
const SCOPES = [
|
|
18
17
|
'https://www.googleapis.com/auth/gmail.modify',
|
|
19
18
|
'https://www.googleapis.com/auth/gmail.send',
|
|
@@ -80,7 +79,7 @@ function waitForCallback(state, port) {
|
|
|
80
79
|
return;
|
|
81
80
|
}
|
|
82
81
|
|
|
83
|
-
if (!code ||
|
|
82
|
+
if (!code || returnedState !== state) {
|
|
84
83
|
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
85
84
|
res.end('<html><body><h2>Invalid callback</h2><p>Missing code or state mismatch.</p></body></html>');
|
|
86
85
|
server.close();
|
|
@@ -160,7 +159,20 @@ async function getUserEmail(accessToken) {
|
|
|
160
159
|
*/
|
|
161
160
|
export async function runAuthFlow(config) {
|
|
162
161
|
const clientId = config.google?.clientId || DEFAULT_CLIENT_ID;
|
|
163
|
-
const clientSecret = config.google?.clientSecret ||
|
|
162
|
+
const clientSecret = config.google?.clientSecret || '';
|
|
163
|
+
|
|
164
|
+
if (!clientId) {
|
|
165
|
+
fail('Google OAuth client ID not configured.');
|
|
166
|
+
info('Get credentials from Google Cloud Console:');
|
|
167
|
+
info(' 1. Go to https://console.cloud.google.com/apis/credentials');
|
|
168
|
+
info(' 2. Create an OAuth 2.0 Client ID (Desktop app type)');
|
|
169
|
+
info(' 3. Enable Gmail API and Calendar API');
|
|
170
|
+
info(' 4. Run:');
|
|
171
|
+
info(' nha config set google-client-id YOUR_CLIENT_ID');
|
|
172
|
+
info(' nha config set google-client-secret YOUR_CLIENT_SECRET');
|
|
173
|
+
info(' 5. Run: nha google auth');
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
164
176
|
|
|
165
177
|
// Find available port
|
|
166
178
|
let port = 0;
|
|
@@ -180,16 +192,13 @@ export async function runAuthFlow(config) {
|
|
|
180
192
|
return false;
|
|
181
193
|
}
|
|
182
194
|
|
|
183
|
-
|
|
184
|
-
// The callback page extracts the code and redirects to localhost
|
|
185
|
-
const serverRedirectUri = 'https://nothumanallowed.com/auth/callback';
|
|
186
|
-
const localCallbackUrl = `http://127.0.0.1:${port}/callback`;
|
|
195
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
187
196
|
const { verifier, challenge } = generatePKCE();
|
|
188
|
-
const state = crypto.randomBytes(32).toString('hex')
|
|
197
|
+
const state = crypto.randomBytes(32).toString('hex');
|
|
189
198
|
|
|
190
199
|
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
191
200
|
authUrl.searchParams.set('client_id', clientId);
|
|
192
|
-
authUrl.searchParams.set('redirect_uri',
|
|
201
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
193
202
|
authUrl.searchParams.set('response_type', 'code');
|
|
194
203
|
authUrl.searchParams.set('scope', SCOPES);
|
|
195
204
|
authUrl.searchParams.set('state', state);
|
|
@@ -206,7 +215,7 @@ export async function runAuthFlow(config) {
|
|
|
206
215
|
const { code } = await waitForCallback(state, port);
|
|
207
216
|
info('Authorization code received. Exchanging for tokens...');
|
|
208
217
|
|
|
209
|
-
const tokenData = await exchangeCode(code, verifier, clientId, clientSecret,
|
|
218
|
+
const tokenData = await exchangeCode(code, verifier, clientId, clientSecret, redirectUri);
|
|
210
219
|
const email = await getUserEmail(tokenData.access_token);
|
|
211
220
|
|
|
212
221
|
const tokens = {
|
package/src/services/llm.mjs
CHANGED
|
@@ -275,144 +275,6 @@ export async function callLLM(config, systemPrompt, userMessage, opts = {}) {
|
|
|
275
275
|
return callFn(apiKey, model, systemPrompt, userMessage, false);
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
/**
|
|
279
|
-
* Call an LLM provider with streaming enabled.
|
|
280
|
-
* Calls onToken(chunk) for each token, returns full text at the end.
|
|
281
|
-
* @returns {Promise<string>} The full LLM response text.
|
|
282
|
-
*/
|
|
283
|
-
export async function callLLMStream(config, systemPrompt, userMessage, onToken, opts = {}) {
|
|
284
|
-
const provider = opts.provider || config.llm.provider || 'anthropic';
|
|
285
|
-
const model = opts.model || config.llm.model || null;
|
|
286
|
-
const apiKey = getApiKey(config, provider);
|
|
287
|
-
if (!apiKey) throw new Error(`No API key for ${provider}`);
|
|
288
|
-
|
|
289
|
-
const callFn = getProviderCall(provider);
|
|
290
|
-
if (!callFn) throw new Error(`Unknown provider: ${provider}`);
|
|
291
|
-
|
|
292
|
-
// Gemini and Cohere don't support streaming — fall back to non-streaming
|
|
293
|
-
if (provider === 'gemini' || provider === 'cohere') {
|
|
294
|
-
const text = await callFn(apiKey, model, systemPrompt, userMessage, false);
|
|
295
|
-
if (onToken) onToken(text);
|
|
296
|
-
return text;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const format = provider === 'anthropic' ? 'anthropic' : 'openai';
|
|
300
|
-
const body = buildRequestBody(provider, model, systemPrompt, userMessage, true);
|
|
301
|
-
const url = getProviderUrl(provider, model, apiKey);
|
|
302
|
-
const headers = getProviderHeaders(provider, apiKey);
|
|
303
|
-
|
|
304
|
-
const res = await fetch(url, {
|
|
305
|
-
method: 'POST',
|
|
306
|
-
headers,
|
|
307
|
-
body: JSON.stringify(body),
|
|
308
|
-
});
|
|
309
|
-
if (!res.ok) {
|
|
310
|
-
const err = await res.text();
|
|
311
|
-
throw new Error(`${provider} ${res.status}: ${err}`);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
return streamSSEWithCallback(res, format, onToken);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** Build request body for a provider */
|
|
318
|
-
function buildRequestBody(provider, model, systemPrompt, userMessage, stream) {
|
|
319
|
-
if (provider === 'anthropic') {
|
|
320
|
-
return {
|
|
321
|
-
model: model || 'claude-sonnet-4-20250514',
|
|
322
|
-
max_tokens: 8192,
|
|
323
|
-
system: systemPrompt,
|
|
324
|
-
messages: [{ role: 'user', content: userMessage }],
|
|
325
|
-
stream,
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
// OpenAI-compatible format (OpenAI, DeepSeek, Grok, Mistral)
|
|
329
|
-
const modelDefaults = {
|
|
330
|
-
openai: 'gpt-4o',
|
|
331
|
-
deepseek: 'deepseek-chat',
|
|
332
|
-
grok: 'grok-3-latest',
|
|
333
|
-
mistral: 'mistral-large-latest',
|
|
334
|
-
};
|
|
335
|
-
return {
|
|
336
|
-
model: model || modelDefaults[provider] || 'gpt-4o',
|
|
337
|
-
max_tokens: 8192,
|
|
338
|
-
messages: [
|
|
339
|
-
{ role: 'system', content: systemPrompt },
|
|
340
|
-
{ role: 'user', content: userMessage },
|
|
341
|
-
],
|
|
342
|
-
stream,
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** Get provider API URL */
|
|
347
|
-
function getProviderUrl(provider, model, apiKey) {
|
|
348
|
-
const urls = {
|
|
349
|
-
anthropic: 'https://api.anthropic.com/v1/messages',
|
|
350
|
-
openai: 'https://api.openai.com/v1/chat/completions',
|
|
351
|
-
deepseek: 'https://api.deepseek.com/v1/chat/completions',
|
|
352
|
-
grok: 'https://api.x.ai/v1/chat/completions',
|
|
353
|
-
mistral: 'https://api.mistral.ai/v1/chat/completions',
|
|
354
|
-
};
|
|
355
|
-
return urls[provider] || urls.openai;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** Get provider request headers */
|
|
359
|
-
function getProviderHeaders(provider, apiKey) {
|
|
360
|
-
if (provider === 'anthropic') {
|
|
361
|
-
return {
|
|
362
|
-
'Content-Type': 'application/json',
|
|
363
|
-
'x-api-key': apiKey,
|
|
364
|
-
'anthropic-version': '2023-06-01',
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
return {
|
|
368
|
-
'Content-Type': 'application/json',
|
|
369
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/** SSE stream parser with onToken callback (does NOT write to stdout directly) */
|
|
374
|
-
async function streamSSEWithCallback(res, format, onToken) {
|
|
375
|
-
const reader = res.body.getReader();
|
|
376
|
-
const decoder = new TextDecoder();
|
|
377
|
-
let buffer = '';
|
|
378
|
-
let fullText = '';
|
|
379
|
-
|
|
380
|
-
while (true) {
|
|
381
|
-
const { done, value } = await reader.read();
|
|
382
|
-
if (done) break;
|
|
383
|
-
|
|
384
|
-
buffer += decoder.decode(value, { stream: true });
|
|
385
|
-
const lines = buffer.split('\n');
|
|
386
|
-
buffer = lines.pop() || '';
|
|
387
|
-
|
|
388
|
-
for (const line of lines) {
|
|
389
|
-
if (!line.startsWith('data: ')) continue;
|
|
390
|
-
const data = line.slice(6).trim();
|
|
391
|
-
if (data === '[DONE]') continue;
|
|
392
|
-
|
|
393
|
-
try {
|
|
394
|
-
const json = JSON.parse(data);
|
|
395
|
-
let chunk = '';
|
|
396
|
-
|
|
397
|
-
if (format === 'anthropic') {
|
|
398
|
-
if (json.type === 'content_block_delta') {
|
|
399
|
-
chunk = json.delta?.text || '';
|
|
400
|
-
}
|
|
401
|
-
} else {
|
|
402
|
-
chunk = json.choices?.[0]?.delta?.content || '';
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
if (chunk) {
|
|
406
|
-
fullText += chunk;
|
|
407
|
-
if (onToken) onToken(chunk);
|
|
408
|
-
}
|
|
409
|
-
} catch {}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
return fullText;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
278
|
/**
|
|
417
279
|
* Call an agent by name — loads the agent file, calls LLM, returns response.
|
|
418
280
|
* No streaming. Used by PAO pipeline for batch agent calls.
|
|
@@ -36,8 +36,237 @@ const STATE_FILE = path.join(DAEMON_DIR, 'state.json');
|
|
|
36
36
|
const LOG_FILE = path.join(DAEMON_DIR, 'daemon.log');
|
|
37
37
|
const BRIEFS_DIR = path.join(NHA_DIR, 'ops', 'briefs');
|
|
38
38
|
const INSIGHTS_DIR = path.join(NHA_DIR, 'ops', 'insights');
|
|
39
|
+
const CRON_FILE = path.join(DAEMON_DIR, 'cron.json');
|
|
40
|
+
const HEARTBEAT_FILE = path.join(DAEMON_DIR, 'heartbeat.json');
|
|
39
41
|
const WS_PORT = 3848;
|
|
40
42
|
|
|
43
|
+
// ── Cron / Heartbeat Persistence ────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function loadCronJobs() {
|
|
46
|
+
if (!fs.existsSync(CRON_FILE)) return [];
|
|
47
|
+
try { return JSON.parse(fs.readFileSync(CRON_FILE, 'utf-8')); } catch { return []; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveCronJobs(jobs) {
|
|
51
|
+
fs.mkdirSync(DAEMON_DIR, { recursive: true });
|
|
52
|
+
fs.writeFileSync(CRON_FILE, JSON.stringify(jobs, null, 2), { mode: 0o600 });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function loadHeartbeats() {
|
|
56
|
+
if (!fs.existsSync(HEARTBEAT_FILE)) return [];
|
|
57
|
+
try { return JSON.parse(fs.readFileSync(HEARTBEAT_FILE, 'utf-8')); } catch { return []; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function saveHeartbeats(beats) {
|
|
61
|
+
fs.mkdirSync(DAEMON_DIR, { recursive: true });
|
|
62
|
+
fs.writeFileSync(HEARTBEAT_FILE, JSON.stringify(beats, null, 2), { mode: 0o600 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse a human-readable schedule into a cron-like spec.
|
|
67
|
+
* Supports:
|
|
68
|
+
* "every 5m" / "every 2h" / "every 30min"
|
|
69
|
+
* "every monday 9am" / "every friday 17:00"
|
|
70
|
+
* "every day 8:30" / "daily 9am"
|
|
71
|
+
* "every hour"
|
|
72
|
+
* "at 14:00" (once daily)
|
|
73
|
+
*
|
|
74
|
+
* Returns { type: 'interval'|'daily'|'weekly', intervalMs?, hour?, minute?, dayOfWeek? }
|
|
75
|
+
*/
|
|
76
|
+
function parseSchedule(schedule) {
|
|
77
|
+
const s = schedule.toLowerCase().trim();
|
|
78
|
+
|
|
79
|
+
// Interval: "every 5m", "every 2h", "every 30min", "every 1 hour"
|
|
80
|
+
const intervalMatch = s.match(/every\s+(\d+)\s*(m(?:in(?:utes?)?)?|h(?:ours?)?|s(?:ec(?:onds?)?)?)/);
|
|
81
|
+
if (intervalMatch) {
|
|
82
|
+
const num = parseInt(intervalMatch[1]);
|
|
83
|
+
const unit = intervalMatch[2][0]; // m, h, or s
|
|
84
|
+
const ms = unit === 'h' ? num * 3_600_000 : unit === 'm' ? num * 60_000 : num * 1_000;
|
|
85
|
+
return { type: 'interval', intervalMs: Math.max(ms, 60_000) }; // min 1 minute
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// "every hour"
|
|
89
|
+
if (s === 'every hour' || s === 'hourly') {
|
|
90
|
+
return { type: 'interval', intervalMs: 3_600_000 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Weekly: "every monday 9am", "every friday 17:00"
|
|
94
|
+
const weeklyMatch = s.match(/every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/);
|
|
95
|
+
if (weeklyMatch) {
|
|
96
|
+
const days = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 };
|
|
97
|
+
let hour = parseInt(weeklyMatch[2]);
|
|
98
|
+
const minute = parseInt(weeklyMatch[3] || '0');
|
|
99
|
+
const ampm = weeklyMatch[4];
|
|
100
|
+
if (ampm === 'pm' && hour < 12) hour += 12;
|
|
101
|
+
if (ampm === 'am' && hour === 12) hour = 0;
|
|
102
|
+
return { type: 'weekly', dayOfWeek: days[weeklyMatch[1]], hour, minute };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Daily: "every day 8:30", "daily 9am", "at 14:00"
|
|
106
|
+
const dailyMatch = s.match(/(?:every\s*day|daily|at)\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/);
|
|
107
|
+
if (dailyMatch) {
|
|
108
|
+
let hour = parseInt(dailyMatch[1]);
|
|
109
|
+
const minute = parseInt(dailyMatch[2] || '0');
|
|
110
|
+
const ampm = dailyMatch[3];
|
|
111
|
+
if (ampm === 'pm' && hour < 12) hour += 12;
|
|
112
|
+
if (ampm === 'am' && hour === 12) hour = 0;
|
|
113
|
+
return { type: 'daily', hour, minute };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check if a cron job should run now (within the current minute).
|
|
121
|
+
*/
|
|
122
|
+
function shouldRunNow(spec, lastRun) {
|
|
123
|
+
const now = new Date();
|
|
124
|
+
|
|
125
|
+
if (spec.type === 'interval') {
|
|
126
|
+
if (!lastRun) return true;
|
|
127
|
+
return (now.getTime() - lastRun) >= spec.intervalMs;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (spec.type === 'daily') {
|
|
131
|
+
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
|
132
|
+
const targetMinutes = spec.hour * 60 + spec.minute;
|
|
133
|
+
// Run if we're in the right minute and haven't run today
|
|
134
|
+
if (Math.abs(nowMinutes - targetMinutes) > 1) return false;
|
|
135
|
+
if (lastRun) {
|
|
136
|
+
const lastRunDate = new Date(lastRun);
|
|
137
|
+
if (lastRunDate.toDateString() === now.toDateString()) return false;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (spec.type === 'weekly') {
|
|
143
|
+
if (now.getDay() !== spec.dayOfWeek) return false;
|
|
144
|
+
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
|
145
|
+
const targetMinutes = spec.hour * 60 + spec.minute;
|
|
146
|
+
if (Math.abs(nowMinutes - targetMinutes) > 1) return false;
|
|
147
|
+
if (lastRun) {
|
|
148
|
+
const lastRunDate = new Date(lastRun);
|
|
149
|
+
const daysDiff = (now.getTime() - lastRunDate.getTime()) / 86_400_000;
|
|
150
|
+
if (daysDiff < 1) return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Add a cron job. Returns the job object.
|
|
160
|
+
*/
|
|
161
|
+
export function addCronJob(schedule, prompt, options = {}) {
|
|
162
|
+
const spec = parseSchedule(schedule);
|
|
163
|
+
if (!spec) return { ok: false, error: `Cannot parse schedule: "${schedule}"` };
|
|
164
|
+
|
|
165
|
+
const jobs = loadCronJobs();
|
|
166
|
+
const job = {
|
|
167
|
+
id: crypto.randomUUID(),
|
|
168
|
+
schedule,
|
|
169
|
+
spec,
|
|
170
|
+
prompt,
|
|
171
|
+
agent: options.agent || null, // null = auto-route
|
|
172
|
+
notify: options.notify !== false, // default: send notification
|
|
173
|
+
enabled: true,
|
|
174
|
+
createdAt: new Date().toISOString(),
|
|
175
|
+
lastRun: null,
|
|
176
|
+
lastResult: null,
|
|
177
|
+
runCount: 0,
|
|
178
|
+
};
|
|
179
|
+
jobs.push(job);
|
|
180
|
+
saveCronJobs(jobs);
|
|
181
|
+
|
|
182
|
+
// Notify daemon if running (it will pick up changes on next tick)
|
|
183
|
+
if (isRunning()) {
|
|
184
|
+
wsBroadcast({ type: 'cron_added', data: { id: job.id, schedule, prompt } });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { ok: true, job };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Remove a cron job by ID or index.
|
|
192
|
+
*/
|
|
193
|
+
export function removeCronJob(idOrIndex) {
|
|
194
|
+
const jobs = loadCronJobs();
|
|
195
|
+
let removed;
|
|
196
|
+
if (typeof idOrIndex === 'number' || /^\d+$/.test(idOrIndex)) {
|
|
197
|
+
const idx = parseInt(idOrIndex) - 1; // 1-based
|
|
198
|
+
if (idx < 0 || idx >= jobs.length) return { ok: false, error: 'Invalid job index' };
|
|
199
|
+
removed = jobs.splice(idx, 1)[0];
|
|
200
|
+
} else {
|
|
201
|
+
const idx = jobs.findIndex(j => j.id === idOrIndex);
|
|
202
|
+
if (idx === -1) return { ok: false, error: 'Job not found' };
|
|
203
|
+
removed = jobs.splice(idx, 1)[0];
|
|
204
|
+
}
|
|
205
|
+
saveCronJobs(jobs);
|
|
206
|
+
return { ok: true, removed };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* List all cron jobs.
|
|
211
|
+
*/
|
|
212
|
+
export function listCronJobs() {
|
|
213
|
+
return loadCronJobs();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Add a heartbeat (recurring prompt at fixed interval).
|
|
218
|
+
* Convenience wrapper over cron with interval spec.
|
|
219
|
+
*/
|
|
220
|
+
export function addHeartbeat(intervalStr, prompt, options = {}) {
|
|
221
|
+
return addCronJob(`every ${intervalStr}`, prompt, options);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Execute cron jobs that are due. Called from daemon loop.
|
|
226
|
+
*/
|
|
227
|
+
async function executeDueCronJobs(config) {
|
|
228
|
+
const jobs = loadCronJobs();
|
|
229
|
+
let changed = false;
|
|
230
|
+
|
|
231
|
+
for (const job of jobs) {
|
|
232
|
+
if (!job.enabled) continue;
|
|
233
|
+
if (!shouldRunNow(job.spec, job.lastRun)) continue;
|
|
234
|
+
|
|
235
|
+
log(`[Cron] Executing: "${job.prompt}" (schedule: ${job.schedule})`);
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const agent = job.agent || 'conductor';
|
|
239
|
+
const result = await callAgent(config, agent, job.prompt);
|
|
240
|
+
job.lastRun = Date.now();
|
|
241
|
+
job.lastResult = result.slice(0, 500); // cap stored result
|
|
242
|
+
job.runCount++;
|
|
243
|
+
changed = true;
|
|
244
|
+
|
|
245
|
+
log(`[Cron] Completed: "${job.prompt}" → ${result.slice(0, 100)}...`);
|
|
246
|
+
|
|
247
|
+
// Broadcast result to connected clients
|
|
248
|
+
wsBroadcast({
|
|
249
|
+
type: 'cron_result',
|
|
250
|
+
timestamp: new Date().toISOString(),
|
|
251
|
+
data: { id: job.id, schedule: job.schedule, prompt: job.prompt, result: result.slice(0, 500) },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Send notification if enabled
|
|
255
|
+
if (job.notify) {
|
|
256
|
+
await notify('Scheduled Task', `${job.prompt}\n\n${result.slice(0, 200)}`, config);
|
|
257
|
+
}
|
|
258
|
+
} catch (err) {
|
|
259
|
+
log(`[Cron] Error executing "${job.prompt}": ${err.message}`);
|
|
260
|
+
job.lastRun = Date.now();
|
|
261
|
+
job.lastResult = `ERROR: ${err.message}`;
|
|
262
|
+
job.runCount++;
|
|
263
|
+
changed = true;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (changed) saveCronJobs(jobs);
|
|
268
|
+
}
|
|
269
|
+
|
|
41
270
|
// ── Daemon Control ─────────────────────────────────────────────────────────
|
|
42
271
|
|
|
43
272
|
/**
|
|
@@ -837,6 +1066,13 @@ async function daemonLoop() {
|
|
|
837
1066
|
}
|
|
838
1067
|
}
|
|
839
1068
|
|
|
1069
|
+
// ── User-defined Cron Jobs ────────────────────────────
|
|
1070
|
+
try {
|
|
1071
|
+
await executeDueCronJobs(config);
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
log(`[Cron] Execution error: ${err.message}`);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
840
1076
|
// Reset daily flags at midnight
|
|
841
1077
|
if (currentTime === '00:00') {
|
|
842
1078
|
todayPlanDone = false;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen Capture + Vision — captures desktop screenshots for LLM analysis.
|
|
3
|
+
*
|
|
4
|
+
* macOS: screencapture -x (native, silent)
|
|
5
|
+
* Linux: import -window root (ImageMagick) or gnome-screenshot or scrot
|
|
6
|
+
* Windows (WSL): PowerShell screenshot via clip
|
|
7
|
+
*
|
|
8
|
+
* Returns base64 PNG for multimodal LLM input.
|
|
9
|
+
* Zero npm dependencies.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execSync } from 'child_process';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import os from 'os';
|
|
16
|
+
|
|
17
|
+
const SCREENSHOT_DIR = path.join(os.tmpdir(), 'nha-screenshots');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Capture a screenshot of the desktop.
|
|
21
|
+
* @param {object} options
|
|
22
|
+
* @param {number} [options.monitor=1] - Monitor number (multi-monitor)
|
|
23
|
+
* @param {boolean} [options.base64=true] - Return base64 instead of file path
|
|
24
|
+
* @returns {{ ok: boolean, path?: string, base64?: string, error?: string, width?: number, height?: number }}
|
|
25
|
+
*/
|
|
26
|
+
export function captureScreen(options = {}) {
|
|
27
|
+
const { monitor = 1, base64: returnBase64 = true } = options;
|
|
28
|
+
const platform = os.platform();
|
|
29
|
+
|
|
30
|
+
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
31
|
+
const filename = `screen-${Date.now()}.png`;
|
|
32
|
+
const filepath = path.join(SCREENSHOT_DIR, filename);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
if (platform === 'darwin') {
|
|
36
|
+
// macOS: native screencapture, -x = no sound
|
|
37
|
+
const monitorFlag = monitor > 1 ? `-D ${monitor}` : '';
|
|
38
|
+
execSync(`screencapture -x ${monitorFlag} "${filepath}"`, { timeout: 10_000 });
|
|
39
|
+
} else if (platform === 'linux') {
|
|
40
|
+
// Linux: try multiple tools in order of preference
|
|
41
|
+
const tools = [
|
|
42
|
+
{ cmd: `gnome-screenshot -f "${filepath}"`, check: 'which gnome-screenshot' },
|
|
43
|
+
{ cmd: `scrot "${filepath}"`, check: 'which scrot' },
|
|
44
|
+
{ cmd: `import -window root "${filepath}"`, check: 'which import' },
|
|
45
|
+
{ cmd: `grim "${filepath}"`, check: 'which grim' }, // Wayland
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
let captured = false;
|
|
49
|
+
for (const tool of tools) {
|
|
50
|
+
try {
|
|
51
|
+
execSync(tool.check, { stdio: 'ignore' });
|
|
52
|
+
execSync(tool.cmd, { timeout: 10_000 });
|
|
53
|
+
captured = true;
|
|
54
|
+
break;
|
|
55
|
+
} catch { continue; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!captured) {
|
|
59
|
+
return { ok: false, error: 'No screenshot tool found. Install: sudo apt install scrot' };
|
|
60
|
+
}
|
|
61
|
+
} else if (platform === 'win32') {
|
|
62
|
+
// Windows: PowerShell screenshot
|
|
63
|
+
const psScript = `
|
|
64
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
65
|
+
$screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
|
66
|
+
$bitmap = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height)
|
|
67
|
+
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
|
68
|
+
$graphics.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size)
|
|
69
|
+
$bitmap.Save('${filepath.replace(/\\/g, '\\\\')}')
|
|
70
|
+
$graphics.Dispose()
|
|
71
|
+
$bitmap.Dispose()
|
|
72
|
+
`.trim();
|
|
73
|
+
execSync(`powershell -Command "${psScript}"`, { timeout: 15_000 });
|
|
74
|
+
} else {
|
|
75
|
+
return { ok: false, error: `Unsupported platform: ${platform}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!fs.existsSync(filepath)) {
|
|
79
|
+
return { ok: false, error: 'Screenshot file not created' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const stats = fs.statSync(filepath);
|
|
83
|
+
if (stats.size < 100) {
|
|
84
|
+
fs.rmSync(filepath, { force: true });
|
|
85
|
+
return { ok: false, error: 'Screenshot file is empty' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const result = { ok: true, path: filepath };
|
|
89
|
+
|
|
90
|
+
if (returnBase64) {
|
|
91
|
+
const buf = fs.readFileSync(filepath);
|
|
92
|
+
// Resize if too large (>2MB) to save tokens
|
|
93
|
+
if (buf.length > 2_000_000) {
|
|
94
|
+
try {
|
|
95
|
+
// Try to resize with sips (macOS) or convert (Linux)
|
|
96
|
+
const resized = path.join(SCREENSHOT_DIR, `resized-${filename}`);
|
|
97
|
+
if (platform === 'darwin') {
|
|
98
|
+
execSync(`sips --resampleWidth 1280 "${filepath}" --out "${resized}"`, { stdio: 'ignore', timeout: 10_000 });
|
|
99
|
+
} else {
|
|
100
|
+
execSync(`convert "${filepath}" -resize 1280x "${resized}"`, { stdio: 'ignore', timeout: 10_000 });
|
|
101
|
+
}
|
|
102
|
+
if (fs.existsSync(resized)) {
|
|
103
|
+
result.base64 = fs.readFileSync(resized).toString('base64');
|
|
104
|
+
fs.rmSync(resized, { force: true });
|
|
105
|
+
} else {
|
|
106
|
+
result.base64 = buf.toString('base64');
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
result.base64 = buf.toString('base64');
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
result.base64 = buf.toString('base64');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Clean up old screenshots (keep last 5)
|
|
117
|
+
cleanupOldScreenshots();
|
|
118
|
+
|
|
119
|
+
return result;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return { ok: false, error: `Screenshot failed: ${err.message}` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Clean up old screenshot files, keeping only the 5 most recent.
|
|
127
|
+
*/
|
|
128
|
+
function cleanupOldScreenshots() {
|
|
129
|
+
try {
|
|
130
|
+
const files = fs.readdirSync(SCREENSHOT_DIR)
|
|
131
|
+
.filter(f => f.startsWith('screen-') && f.endsWith('.png'))
|
|
132
|
+
.map(f => ({ name: f, path: path.join(SCREENSHOT_DIR, f), mtime: fs.statSync(path.join(SCREENSHOT_DIR, f)).mtimeMs }))
|
|
133
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
134
|
+
|
|
135
|
+
// Keep only last 5
|
|
136
|
+
for (const file of files.slice(5)) {
|
|
137
|
+
fs.rmSync(file.path, { force: true });
|
|
138
|
+
}
|
|
139
|
+
} catch { /* non-fatal */ }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if screen capture is available on this platform.
|
|
144
|
+
*/
|
|
145
|
+
export function isScreenCaptureAvailable() {
|
|
146
|
+
const platform = os.platform();
|
|
147
|
+
if (platform === 'darwin') return true; // screencapture always available
|
|
148
|
+
if (platform === 'win32') return true; // PowerShell always available
|
|
149
|
+
if (platform === 'linux') {
|
|
150
|
+
const tools = ['gnome-screenshot', 'scrot', 'import', 'grim'];
|
|
151
|
+
for (const tool of tools) {
|
|
152
|
+
try {
|
|
153
|
+
execSync(`which ${tool}`, { stdio: 'ignore' });
|
|
154
|
+
return true;
|
|
155
|
+
} catch { continue; }
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|