nothumanallowed 9.5.2 → 9.7.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 +77 -843
- 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 +89 -120
- package/src/services/ops-daemon.mjs +236 -0
- package/src/services/screen-capture.mjs +160 -0
- package/src/services/tool-executor.mjs +86 -340
- package/src/services/web-ui.mjs +173 -422
- 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.7.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
|
@@ -276,141 +276,110 @@ export async function callLLM(config, systemPrompt, userMessage, opts = {}) {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
/**
|
|
279
|
-
* Call
|
|
280
|
-
*
|
|
281
|
-
* @
|
|
279
|
+
* Call LLM with multimodal (vision) messages — supports image content.
|
|
280
|
+
* Uses the provider's native vision format.
|
|
281
|
+
* @param {object} config
|
|
282
|
+
* @param {Array} messages - Array of { role, content } where content can be string or array of content blocks
|
|
283
|
+
* @returns {Promise<string>}
|
|
282
284
|
*/
|
|
283
|
-
export async function
|
|
284
|
-
const provider =
|
|
285
|
-
const model =
|
|
285
|
+
export async function callLLMVision(config, messages) {
|
|
286
|
+
const provider = config.llm.provider || 'anthropic';
|
|
287
|
+
const model = config.llm.model || null;
|
|
286
288
|
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
|
-
}
|
|
289
|
+
if (!apiKey) throw new Error(`No API key for ${provider}. Vision requires Claude, GPT-4, or Gemini.`);
|
|
316
290
|
|
|
317
|
-
/** Build request body for a provider */
|
|
318
|
-
function buildRequestBody(provider, model, systemPrompt, userMessage, stream) {
|
|
319
291
|
if (provider === 'anthropic') {
|
|
320
|
-
|
|
292
|
+
// Anthropic format: system separate, messages with content blocks
|
|
293
|
+
const systemMsg = messages.find(m => m.role === 'system');
|
|
294
|
+
const userMsgs = messages.filter(m => m.role !== 'system');
|
|
295
|
+
|
|
296
|
+
// Convert OpenAI-style image_url to Anthropic format
|
|
297
|
+
const anthropicMessages = userMsgs.map(m => {
|
|
298
|
+
if (typeof m.content === 'string') return m;
|
|
299
|
+
const blocks = m.content.map(block => {
|
|
300
|
+
if (block.type === 'text') return block;
|
|
301
|
+
if (block.type === 'image_url') {
|
|
302
|
+
const url = block.image_url.url;
|
|
303
|
+
const match = url.match(/^data:image\/(png|jpeg|gif|webp);base64,(.+)$/);
|
|
304
|
+
if (match) {
|
|
305
|
+
return { type: 'image', source: { type: 'base64', media_type: `image/${match[1]}`, data: match[2] } };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return block;
|
|
309
|
+
});
|
|
310
|
+
return { role: m.role, content: blocks };
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const body = {
|
|
321
314
|
model: model || 'claude-sonnet-4-20250514',
|
|
322
|
-
max_tokens:
|
|
323
|
-
system:
|
|
324
|
-
messages:
|
|
325
|
-
stream,
|
|
315
|
+
max_tokens: 4096,
|
|
316
|
+
system: systemMsg?.content || '',
|
|
317
|
+
messages: anthropicMessages,
|
|
326
318
|
};
|
|
319
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
|
322
|
+
body: JSON.stringify(body),
|
|
323
|
+
});
|
|
324
|
+
if (!res.ok) throw new Error(`Anthropic vision ${res.status}: ${await res.text()}`);
|
|
325
|
+
const data = await res.json();
|
|
326
|
+
return data.content?.[0]?.text || '';
|
|
327
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
328
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
329
|
+
if (provider === 'openai' || provider === 'deepseek' || provider === 'grok' || provider === 'mistral') {
|
|
330
|
+
// OpenAI-compatible format — works with GPT-4V, DeepSeek VL, etc.
|
|
331
|
+
const url = provider === 'openai' ? 'https://api.openai.com/v1/chat/completions'
|
|
332
|
+
: provider === 'deepseek' ? 'https://api.deepseek.com/chat/completions'
|
|
333
|
+
: provider === 'grok' ? 'https://api.x.ai/v1/chat/completions'
|
|
334
|
+
: 'https://api.mistral.ai/v1/chat/completions';
|
|
335
|
+
|
|
336
|
+
const visionModel = model || (provider === 'openai' ? 'gpt-4o' : model);
|
|
337
|
+
const body = { model: visionModel, max_tokens: 4096, messages };
|
|
338
|
+
const res = await fetch(url, {
|
|
339
|
+
method: 'POST',
|
|
340
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
341
|
+
body: JSON.stringify(body),
|
|
342
|
+
});
|
|
343
|
+
if (!res.ok) throw new Error(`${provider} vision ${res.status}: ${await res.text()}`);
|
|
344
|
+
const data = await res.json();
|
|
345
|
+
return data.choices?.[0]?.message?.content || '';
|
|
366
346
|
}
|
|
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
347
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
348
|
+
if (provider === 'gemini') {
|
|
349
|
+
// Gemini format — inline_data with base64
|
|
350
|
+
const systemMsg = messages.find(m => m.role === 'system');
|
|
351
|
+
const userMsgs = messages.filter(m => m.role !== 'system');
|
|
352
|
+
const parts = [];
|
|
353
|
+
for (const msg of userMsgs) {
|
|
354
|
+
if (typeof msg.content === 'string') {
|
|
355
|
+
parts.push({ text: msg.content });
|
|
356
|
+
} else {
|
|
357
|
+
for (const block of msg.content) {
|
|
358
|
+
if (block.type === 'text') parts.push({ text: block.text });
|
|
359
|
+
if (block.type === 'image_url') {
|
|
360
|
+
const match = block.image_url.url.match(/^data:image\/(.*?);base64,(.+)$/);
|
|
361
|
+
if (match) parts.push({ inline_data: { mime_type: `image/${match[1]}`, data: match[2] } });
|
|
400
362
|
}
|
|
401
|
-
} else {
|
|
402
|
-
chunk = json.choices?.[0]?.delta?.content || '';
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
if (chunk) {
|
|
406
|
-
fullText += chunk;
|
|
407
|
-
if (onToken) onToken(chunk);
|
|
408
363
|
}
|
|
409
|
-
}
|
|
364
|
+
}
|
|
410
365
|
}
|
|
366
|
+
|
|
367
|
+
const geminiModel = model || 'gemini-2.0-flash';
|
|
368
|
+
const body = {
|
|
369
|
+
contents: [{ parts }],
|
|
370
|
+
systemInstruction: systemMsg ? { parts: [{ text: systemMsg.content }] } : undefined,
|
|
371
|
+
};
|
|
372
|
+
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`, {
|
|
373
|
+
method: 'POST',
|
|
374
|
+
headers: { 'Content-Type': 'application/json' },
|
|
375
|
+
body: JSON.stringify(body),
|
|
376
|
+
});
|
|
377
|
+
if (!res.ok) throw new Error(`Gemini vision ${res.status}: ${await res.text()}`);
|
|
378
|
+
const data = await res.json();
|
|
379
|
+
return data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
411
380
|
}
|
|
412
381
|
|
|
413
|
-
|
|
382
|
+
throw new Error(`Vision not supported for provider: ${provider}. Use Claude, GPT-4, or Gemini.`);
|
|
414
383
|
}
|
|
415
384
|
|
|
416
385
|
/**
|
|
@@ -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;
|