jerob 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CLI/cli.ts +42 -0
  2. package/README.md +137 -0
  3. package/SETUP.md +584 -0
  4. package/agent/action-tracker.ts +45 -0
  5. package/agent/agent-tools.ts +111 -0
  6. package/agent/approval.ts +137 -0
  7. package/agent/diff-view.ts +26 -0
  8. package/agent/orchestrator.ts +186 -0
  9. package/agent/tool-executor.ts +463 -0
  10. package/agent/types.ts +69 -0
  11. package/ask/orchestrator.ts +244 -0
  12. package/auth/auth.ts +567 -0
  13. package/auth/config-store.ts +77 -0
  14. package/auth/crypto.ts +51 -0
  15. package/auth/env-writer.ts +82 -0
  16. package/bin/jerob.js +28 -0
  17. package/config/ai.config.ts +163 -0
  18. package/email_ops/email-tools.ts +178 -0
  19. package/email_ops/email_functions.ts +443 -0
  20. package/email_ops/email_init.ts +92 -0
  21. package/email_ops/email_pass_store.ts +61 -0
  22. package/email_ops/email_server.ts +29 -0
  23. package/email_ops/types.ts +88 -0
  24. package/index.ts +176 -0
  25. package/package.json +88 -0
  26. package/plan/browser-agent/README.md +118 -0
  27. package/plan/browser-agent/USAGE.md +308 -0
  28. package/plan/browser-agent/evaluator.ts +353 -0
  29. package/plan/browser-agent/executor.ts +372 -0
  30. package/plan/browser-agent/index.ts +13 -0
  31. package/plan/browser-agent/orchestrator.ts +323 -0
  32. package/plan/browser-agent/planner.ts +200 -0
  33. package/plan/browser-agent/types.ts +62 -0
  34. package/plan/browser-tool.ts +128 -0
  35. package/plan/index.ts +12 -0
  36. package/plan/orchestrator.ts +214 -0
  37. package/plan/planner.ts +183 -0
  38. package/plan/selection.ts +50 -0
  39. package/plan/types.ts +13 -0
  40. package/plan/web-tools.ts +119 -0
  41. package/scheduler/ARCHITECTURE.md +263 -0
  42. package/scheduler/README.md +200 -0
  43. package/scheduler/SETUP-READY.sql +84 -0
  44. package/scheduler/check-status.sql +124 -0
  45. package/scheduler/config-sync.ts +91 -0
  46. package/scheduler/db-migrate.ts +271 -0
  47. package/scheduler/db.ts +162 -0
  48. package/scheduler/debug.ts +184 -0
  49. package/scheduler/orchestrator.ts +438 -0
  50. package/scheduler/planner.ts +170 -0
  51. package/scheduler/update-task-email.ts +70 -0
  52. package/supabase/.temp/cli-latest +1 -0
  53. package/supabase/.temp/gotrue-version +1 -0
  54. package/supabase/.temp/linked-project.json +1 -0
  55. package/supabase/.temp/pooler-url +1 -0
  56. package/supabase/.temp/postgres-version +1 -0
  57. package/supabase/.temp/project-ref +1 -0
  58. package/supabase/.temp/rest-version +1 -0
  59. package/supabase/.temp/storage-migration +1 -0
  60. package/supabase/.temp/storage-version +1 -0
  61. package/supabase/deploy.ps1 +50 -0
  62. package/supabase/functions/scheduler-tick/index.ts +496 -0
  63. package/supabase/supabase/.temp/linked-project.json +1 -0
  64. package/tsconfig.json +33 -0
  65. package/tui/spinner.ts +33 -0
  66. package/tui/spinup.ts +67 -0
  67. package/tui/terminal-render.ts +16 -0
  68. package/utils/llm-error.ts +185 -0
  69. package/utils/model-validator.ts +247 -0
@@ -0,0 +1 @@
1
+ ebqpfhzeswycagvzgtik
@@ -0,0 +1 @@
1
+ v14.5
@@ -0,0 +1 @@
1
+ optimize-existing-functions-again
@@ -0,0 +1 @@
1
+ v1.60.4
@@ -0,0 +1,50 @@
1
+ # Jimmy Scheduler - Deploy Edge Function + Sync Credentials to Supabase
2
+ # Run from project root: .\supabase\deploy.ps1
3
+ # Credentials are stored in user_config table, so re-auth is fully automatic.
4
+
5
+ Write-Host "Jimmy Scheduler Deploy" -ForegroundColor Cyan
6
+ Write-Host ""
7
+
8
+ # 1. Deploy the Edge Function
9
+ Write-Host "1/3 Deploying scheduler-tick Edge Function..." -ForegroundColor Green
10
+ supabase functions deploy scheduler-tick --no-verify-jwt
11
+
12
+ if ($LASTEXITCODE -ne 0) {
13
+ Write-Host "Deploy failed. Make sure you have run:" -ForegroundColor Red
14
+ Write-Host " supabase login" -ForegroundColor Yellow
15
+ Write-Host " supabase link --project-ref <YOUR_PROJECT_REF>" -ForegroundColor Yellow
16
+ exit 1
17
+ }
18
+
19
+ # 2. Set the two static secrets (URL + service role key)
20
+ Write-Host ""
21
+ Write-Host "2/3 Setting static secrets..." -ForegroundColor Green
22
+ $env_file = Join-Path $PSScriptRoot "../.env"
23
+ $lines = Get-Content $env_file
24
+ $supabase_url = ($lines | Select-String "^SUPABASE_URL=").ToString().Split("=")[1].Trim('"').Trim("'")
25
+ $supabase_key = ($lines | Select-String "^SUPABASE_SERVICE_ROLE_KEY=").ToString().Split("=")[1].Trim('"').Trim("'")
26
+
27
+ if ($supabase_url) {
28
+ supabase secrets set "SUPABASE_URL=$supabase_url"
29
+ }
30
+ if ($supabase_key) {
31
+ supabase secrets set "SUPABASE_SERVICE_ROLE_KEY=$supabase_key"
32
+ }
33
+
34
+ # 3. Sync credentials to user_config table via Jimmy CLI
35
+ Write-Host ""
36
+ Write-Host "3/3 Syncing credentials to Supabase user_config table..." -ForegroundColor Green
37
+ Write-Host "(This allows automatic re-auth without manual secrets updates)" -ForegroundColor DarkGray
38
+
39
+ # Call jimmy to sync
40
+ bun run index.ts sync-credentials
41
+
42
+ Write-Host ""
43
+ Write-Host "Deploy complete!" -ForegroundColor Green
44
+ Write-Host ""
45
+ Write-Host "Next steps:" -ForegroundColor Cyan
46
+ Write-Host " 1. Run the SQL in supabase/SETUP-READY.sql (one time only)" -ForegroundColor White
47
+ Write-Host " Open Supabase Dashboard → SQL Editor, paste the file and click RUN" -ForegroundColor DarkGray
48
+ Write-Host " 2. Your tasks will now run every minute in Supabase" -ForegroundColor White
49
+ Write-Host " 3. Re-auth Gmail anytime - it auto-syncs to Supabase" -ForegroundColor White
50
+ Write-Host ""
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Jimmy Scheduler – Supabase Edge Function
3
+ * Runs every minute via pg_cron, executes due tasks, writes results.
4
+ * Credentials loaded from user_config table (auto-synced on re-auth).
5
+ *
6
+ * LLM priority: Google Gemini → OpenRouter → Groq
7
+ */
8
+
9
+ import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
10
+
11
+ const SUPABASE_URL = Deno.env.get("APP_DB_URL") ?? Deno.env.get("SUPABASE_URL")!;
12
+ const SUPABASE_KEY = Deno.env.get("APP_SERVICE_KEY") ?? Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
13
+ const db = createClient(SUPABASE_URL, SUPABASE_KEY);
14
+
15
+ interface Credentials {
16
+ openrouter_key: string;
17
+ openrouter_model: string;
18
+ groq_api_key: string;
19
+ google_api_key: string; // Google Generative AI key
20
+ firecrawl_key: string;
21
+ google_client_id: string;
22
+ google_client_secret: string;
23
+ google_refresh_token: string;
24
+ }
25
+
26
+ interface TaskStep {
27
+ order: number;
28
+ type: "web_search" | "web_crawl" | "email_send" | "custom";
29
+ instruction: string;
30
+ }
31
+
32
+ interface SchedulerTask {
33
+ id: string;
34
+ name: string;
35
+ description: string;
36
+ cron: string;
37
+ steps: TaskStep[];
38
+ summary_email: string | null;
39
+ run_count: number;
40
+ }
41
+
42
+ interface StepResult {
43
+ order: number;
44
+ instruction: string;
45
+ output: string;
46
+ success: boolean;
47
+ }
48
+
49
+ // ── Credentials ───────────────────────────────────────────────────────────────
50
+
51
+ async function loadCredentials(): Promise<Credentials> {
52
+ const { data, error } = await db.from("user_config").select("key,value");
53
+ if (error) throw new Error(`Credential load failed: ${error.message}`);
54
+ const m: Record<string, string> = {};
55
+ for (const r of data ?? []) m[(r as any).key] = (r as any).value;
56
+ return {
57
+ openrouter_key: m["openrouter_key"] ?? "",
58
+ openrouter_model: m["openrouter_model"] ?? "openrouter/free",
59
+ groq_api_key: m["groq_api_key"] ?? "",
60
+ google_api_key: m["google_api_key"] ?? m["google_generative_ai_api_key"] ?? "",
61
+ firecrawl_key: m["firecrawl_key"] ?? "",
62
+ google_client_id: m["google_client_id"] ?? "",
63
+ google_client_secret: m["google_client_secret"] ?? "",
64
+ google_refresh_token: m["google_refresh_token"] ?? "",
65
+ };
66
+ }
67
+
68
+ // ── LLM with per-provider error details ──────────────────────────────────────
69
+
70
+ interface LLMAttempt {
71
+ provider: string;
72
+ error: string;
73
+ }
74
+
75
+ async function llm(prompt: string, sys: string, creds: Credentials): Promise<string> {
76
+ const attempts: LLMAttempt[] = [];
77
+
78
+ // 1. Google Gemini (gemini-3.1-flash-lite-preview or gemini-2.0-flash)
79
+ if (creds.google_api_key) {
80
+ try {
81
+ const model = "gemini-3.1-flash-lite-preview";
82
+ const res = await fetch(
83
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${creds.google_api_key}`,
84
+ {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify({
88
+ system_instruction: { parts: [{ text: sys }] },
89
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
90
+ generationConfig: { temperature: 0.3, maxOutputTokens: 2048 },
91
+ }),
92
+ }
93
+ );
94
+ if (res.ok) {
95
+ const data = await res.json();
96
+ const text = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
97
+ if (text) return text;
98
+ attempts.push({ provider: "Gemini", error: `Empty response. Raw: ${JSON.stringify(data).slice(0, 200)}` });
99
+ } else {
100
+ const body = await res.text();
101
+ attempts.push({ provider: "Gemini", error: `HTTP ${res.status}: ${body.slice(0, 200)}` });
102
+ }
103
+ } catch (e) {
104
+ attempts.push({ provider: "Gemini", error: String(e) });
105
+ }
106
+ }
107
+
108
+ // 2. OpenRouter
109
+ if (creds.openrouter_key) {
110
+ try {
111
+ const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
112
+ method: "POST",
113
+ headers: {
114
+ "Content-Type": "application/json",
115
+ Authorization: `Bearer ${creds.openrouter_key}`,
116
+ },
117
+ body: JSON.stringify({
118
+ model: creds.openrouter_model,
119
+ messages: [{ role: "system", content: sys }, { role: "user", content: prompt }],
120
+ temperature: 0.3,
121
+ max_tokens: 2048,
122
+ }),
123
+ });
124
+ if (res.ok) {
125
+ const data = await res.json();
126
+ const text = data?.choices?.[0]?.message?.content?.trim();
127
+ const isRefusal = (s: string) =>
128
+ s.toLowerCase().startsWith("user safety") ||
129
+ s.includes("i'm sorry") ||
130
+ s.includes("i cannot");
131
+ if (text && !isRefusal(text)) return text;
132
+ attempts.push({ provider: "OpenRouter", error: text ? `Model refused: ${text.slice(0, 100)}` : "Empty response" });
133
+ } else {
134
+ const body = await res.text();
135
+ attempts.push({ provider: "OpenRouter", error: `HTTP ${res.status}: ${body.slice(0, 200)}` });
136
+ }
137
+ } catch (e) {
138
+ attempts.push({ provider: "OpenRouter", error: String(e) });
139
+ }
140
+ }
141
+
142
+ // 3. Groq
143
+ if (creds.groq_api_key) {
144
+ try {
145
+ const res = await fetch("https://api.groq.com/openai/v1/chat/completions", {
146
+ method: "POST",
147
+ headers: {
148
+ "Content-Type": "application/json",
149
+ Authorization: `Bearer ${creds.groq_api_key}`,
150
+ },
151
+ body: JSON.stringify({
152
+ model: "llama-3.3-70b-versatile",
153
+ messages: [{ role: "system", content: sys }, { role: "user", content: prompt }],
154
+ temperature: 0.3,
155
+ max_tokens: 2048,
156
+ }),
157
+ });
158
+ if (res.ok) {
159
+ const data = await res.json();
160
+ const text = data?.choices?.[0]?.message?.content?.trim();
161
+ if (text) return text;
162
+ attempts.push({ provider: "Groq", error: "Empty response" });
163
+ } else {
164
+ const body = await res.text();
165
+ attempts.push({ provider: "Groq", error: `HTTP ${res.status}: ${body.slice(0, 200)}` });
166
+ }
167
+ } catch (e) {
168
+ attempts.push({ provider: "Groq", error: String(e) });
169
+ }
170
+ }
171
+
172
+ if (attempts.length === 0) {
173
+ throw new Error("No LLM keys configured. Run `jerob sync-credentials` to push your API keys to Supabase.");
174
+ }
175
+
176
+ // Surface all individual errors so you know exactly what failed
177
+ const detail = attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ");
178
+ throw new Error(`All LLM providers failed — ${detail}`);
179
+ }
180
+
181
+ // ── cron next-run calculator ──────────────────────────────────────────────────
182
+
183
+ function computeNextRun(cron: string): string {
184
+ const now = new Date();
185
+ now.setSeconds(0, 0);
186
+ const [min, hour, dom, month, dow] = cron.trim().split(/\s+/);
187
+
188
+ const match = (v: number, f: string) => {
189
+ if (f === "*") return true;
190
+ if (f.includes("/")) {
191
+ const [base, step] = f.split("/");
192
+ const start = base === "*" ? 0 : parseInt(base, 10);
193
+ return (v - start) % parseInt(step!, 10) === 0 && v >= start;
194
+ }
195
+ if (f.includes(",")) return f.split(",").map(Number).includes(v);
196
+ if (f.includes("-")) {
197
+ const [lo, hi] = f.split("-").map(Number);
198
+ return v >= lo! && v <= hi!;
199
+ }
200
+ return parseInt(f, 10) === v;
201
+ };
202
+
203
+ for (let offset = 1; offset <= 10080; offset++) {
204
+ const c = new Date(now.getTime() + offset * 60000);
205
+ if (
206
+ match(c.getUTCMinutes(), min!) &&
207
+ match(c.getUTCHours(), hour!) &&
208
+ match(c.getUTCDate(), dom!) &&
209
+ match(c.getUTCMonth() + 1, month!) &&
210
+ match(c.getUTCDay(), dow!)
211
+ ) return c.toISOString();
212
+ }
213
+ return new Date(now.getTime() + 3600000).toISOString();
214
+ }
215
+
216
+ // ── Web tools ─────────────────────────────────────────────────────────────────
217
+
218
+ async function webSearch(query: string, creds: Credentials): Promise<string> {
219
+ if (!creds.firecrawl_key) return "(web search unavailable — no FIRECRAWL_KEY)";
220
+ const res = await fetch("https://api.firecrawl.dev/v1/search", {
221
+ method: "POST",
222
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.firecrawl_key}` },
223
+ body: JSON.stringify({ query, limit: 5 }),
224
+ });
225
+ if (!res.ok) return `Search failed: HTTP ${res.status}`;
226
+ const data = await res.json();
227
+ const items: any[] = data?.data ?? data?.results ?? data?.web ?? [];
228
+ return items
229
+ .slice(0, 5)
230
+ .map((d: any, i: number) => `${i + 1}. ${d.title ?? ""}\n ${d.url ?? ""}\n ${d.description ?? d.snippet ?? ""}`)
231
+ .join("\n\n") || "(no results)";
232
+ }
233
+
234
+ async function webCrawl(url: string, creds: Credentials): Promise<string> {
235
+ if (!creds.firecrawl_key) {
236
+ try {
237
+ const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } });
238
+ const text = await res.text();
239
+ return text.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").slice(0, 6000);
240
+ } catch (e) {
241
+ return `Direct fetch failed: ${e}`;
242
+ }
243
+ }
244
+ const res = await fetch("https://api.firecrawl.dev/v1/scrape", {
245
+ method: "POST",
246
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.firecrawl_key}` },
247
+ body: JSON.stringify({ url, formats: ["markdown"] }),
248
+ });
249
+ if (!res.ok) return `Crawl failed: HTTP ${res.status}`;
250
+ const data = await res.json();
251
+ return (data?.data?.markdown ?? data?.markdown ?? "").slice(0, 6000) || "(empty)";
252
+ }
253
+
254
+ // ── Gmail ─────────────────────────────────────────────────────────────────────
255
+
256
+ async function getGmailToken(creds: Credentials): Promise<string> {
257
+ const res = await fetch("https://oauth2.googleapis.com/token", {
258
+ method: "POST",
259
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
260
+ body: new URLSearchParams({
261
+ client_id: creds.google_client_id,
262
+ client_secret: creds.google_client_secret,
263
+ refresh_token: creds.google_refresh_token,
264
+ grant_type: "refresh_token",
265
+ }),
266
+ });
267
+ if (!res.ok) throw new Error(`Gmail token refresh failed (${res.status}): ${await res.text()}`);
268
+ const data = await res.json();
269
+ if (!data.access_token) throw new Error(`Gmail token refresh returned no access_token: ${JSON.stringify(data)}`);
270
+ return data.access_token;
271
+ }
272
+
273
+ function encodeEmail(to: string, subject: string, body: string): string {
274
+ const raw = [`To: ${to}`, `Subject: ${subject}`, "Content-Type: text/plain; charset=utf-8", "", body].join("\n");
275
+ return btoa(unescape(encodeURIComponent(raw))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
276
+ }
277
+
278
+ async function sendEmail(to: string, subject: string, body: string, creds: Credentials): Promise<void> {
279
+ const token = await getGmailToken(creds);
280
+ const res = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/messages/send", {
281
+ method: "POST",
282
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
283
+ body: JSON.stringify({ raw: encodeEmail(to, subject, body) }),
284
+ });
285
+ if (!res.ok) throw new Error(`Gmail send failed (${res.status}): ${await res.text()}`);
286
+ }
287
+
288
+ // ── Step executor ─────────────────────────────────────────────────────────────
289
+
290
+ async function executeStep(step: TaskStep, prevOutputs: string[], creds: Credentials): Promise<StepResult> {
291
+ const ctx = prevOutputs.length > 0 ? `\n\nPrevious results:\n${prevOutputs.join("\n---\n")}` : "";
292
+ try {
293
+ if (step.type === "web_search") {
294
+ const query = await llm(`Extract a concise search query (max 10 words) for: "${step.instruction}"${ctx}`, "You extract search queries. Reply with only the query.", creds);
295
+ const raw = await webSearch(query.trim(), creds);
296
+ const output = await llm(`Summarize these search results in 3-5 bullets for: "${step.instruction}"\n\n${raw}`, "You are a research assistant.", creds);
297
+ return { order: step.order, instruction: step.instruction, output, success: true };
298
+ }
299
+
300
+ if (step.type === "web_crawl") {
301
+ const urlMatch = step.instruction.match(/https?:\/\/[^\s]+/);
302
+ const url = urlMatch
303
+ ? urlMatch[0]
304
+ : await llm(`What URL should I crawl for: "${step.instruction}"? Reply with only the URL.`, "You extract URLs.", creds);
305
+ const content = await webCrawl(url.trim(), creds);
306
+ const output = await llm(`Extract key info from this page for: "${step.instruction}"\n\n${content.slice(0, 4000)}`, "You are a research assistant.", creds);
307
+ return { order: step.order, instruction: step.instruction, output, success: true };
308
+ }
309
+
310
+ if (step.type === "custom") {
311
+ const output = await llm(`${step.instruction}${ctx}`, "You are a helpful automation assistant.", creds);
312
+ return { order: step.order, instruction: step.instruction, output, success: true };
313
+ }
314
+
315
+ if (step.type === "email_send") {
316
+ const paramsJson = await llm(
317
+ `Extract email parameters from this instruction and return ONLY valid JSON:
318
+ {
319
+ "to": ["email@example.com"],
320
+ "subject": "subject line",
321
+ "body": "email body using previous step results"
322
+ }
323
+
324
+ Instruction: ${step.instruction}${ctx}
325
+
326
+ Rules:
327
+ - "to" must be an array of valid email addresses found in the instruction
328
+ - If no email found, set "to" to []
329
+ - Body should summarize the previous results nicely`,
330
+ "You extract email parameters as JSON. Return only valid JSON, no explanation.",
331
+ creds
332
+ );
333
+
334
+ const jsonMatch = paramsJson.match(/\{[\s\S]*\}/);
335
+ if (!jsonMatch) throw new Error(`Could not extract JSON from LLM response: ${paramsJson.slice(0, 200)}`);
336
+ const params = JSON.parse(jsonMatch[0]);
337
+
338
+ let recipients: string[] = [];
339
+ if (Array.isArray(params.to)) {
340
+ recipients = params.to.map((e: string) => e.trim()).filter((e: string) => e.includes("@"));
341
+ } else if (typeof params.to === "string") {
342
+ recipients = params.to.split(/[,;\s]+/).map((e: string) => e.trim()).filter((e: string) => e.includes("@"));
343
+ }
344
+ recipients = recipients.filter((e) => !e.includes("example.com") && e !== "USER_EMAIL" && e.includes("."));
345
+
346
+ if (recipients.length === 0) {
347
+ return { order: step.order, instruction: step.instruction, output: "Email skipped: no valid recipient in instruction", success: false };
348
+ }
349
+
350
+ const sent: string[] = [];
351
+ for (const to of recipients) {
352
+ await sendEmail(to, params.subject, params.body, creds);
353
+ sent.push(to);
354
+ }
355
+ return { order: step.order, instruction: step.instruction, output: `Email sent to: ${sent.join(", ")}`, success: true };
356
+ }
357
+
358
+ throw new Error(`Unknown step type: ${(step as any).type}`);
359
+ } catch (err) {
360
+ const msg = err instanceof Error ? err.message : String(err);
361
+ return { order: step.order, instruction: step.instruction, output: `ERROR: ${msg}`, success: false };
362
+ }
363
+ }
364
+
365
+ // ── Task runner ───────────────────────────────────────────────────────────────
366
+
367
+ async function runTask(task: SchedulerTask, creds: Credentials): Promise<{ stepResults: StepResult[]; summary: string; success: boolean }> {
368
+ const outputs: string[] = [];
369
+ const stepResults: StepResult[] = [];
370
+
371
+ for (const step of task.steps.sort((a, b) => a.order - b.order)) {
372
+ const result = await executeStep(step, outputs, creds);
373
+ stepResults.push(result);
374
+ outputs.push(`Step ${step.order} [${step.type}]: ${result.output}`);
375
+ }
376
+
377
+ const allSuccess = stepResults.every((s) => s.success);
378
+ const summary = await llm(
379
+ `Summarize results in 3-5 bullets.\n\nTask: ${task.description}\n\nResults:\n${outputs.join("\n")}`,
380
+ "You are a concise summarizer.",
381
+ creds
382
+ );
383
+
384
+ if (task.summary_email) {
385
+ try {
386
+ await sendEmail(
387
+ task.summary_email,
388
+ `[Jimmy Scheduler] ${task.name} — ${allSuccess ? "Done" : "Partial"}`,
389
+ `Task: ${task.description}\nRan at: ${new Date().toISOString()}\n\n${summary}\n\n---\n${outputs.join("\n")}`,
390
+ creds
391
+ );
392
+ } catch (e) {
393
+ console.error(`[email summary error] ${e}`);
394
+ }
395
+ }
396
+
397
+ return { stepResults, summary, success: allSuccess };
398
+ }
399
+
400
+ // ── Entry point ───────────────────────────────────────────────────────────────
401
+
402
+ Deno.serve(async (req: Request) => {
403
+ if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
404
+
405
+ let creds: Credentials;
406
+ try {
407
+ creds = await loadCredentials();
408
+ } catch (e) {
409
+ return new Response(JSON.stringify({ error: `Credentials load failed: ${e}` }), { status: 500 });
410
+ }
411
+
412
+ const now = new Date().toISOString();
413
+ const { data: dueTasks, error } = await db
414
+ .from("scheduler_tasks")
415
+ .select("*")
416
+ .eq("enabled", true)
417
+ .lte("next_run_at", now);
418
+
419
+ if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500 });
420
+
421
+ const tasks: SchedulerTask[] = dueTasks ?? [];
422
+ if (tasks.length === 0) return new Response(JSON.stringify({ ran: 0, message: "No tasks due" }), { status: 200 });
423
+
424
+ const results: { taskId: string; name: string; status: string; error?: string }[] = [];
425
+
426
+ await Promise.allSettled(
427
+ tasks.map(async (task) => {
428
+ // Immediately advance next_run_at so concurrent ticks don't double-execute
429
+ await db
430
+ .from("scheduler_tasks")
431
+ .update({ next_run_at: computeNextRun(task.cron), updated_at: now })
432
+ .eq("id", task.id);
433
+
434
+ const { data: runRow } = await db
435
+ .from("scheduler_runs")
436
+ .insert({ task_id: task.id, status: "running", step_results: [] })
437
+ .select()
438
+ .single();
439
+ const runId: string = (runRow as any)?.id;
440
+
441
+ try {
442
+ const { stepResults, summary, success } = await runTask(task, creds);
443
+ await db.from("scheduler_runs").update({
444
+ status: success ? "success" : "failed",
445
+ output: summary,
446
+ step_results: stepResults,
447
+ finished_at: now,
448
+ }).eq("id", runId);
449
+ await db.from("scheduler_tasks").update({
450
+ last_run_at: now,
451
+ run_count: task.run_count + 1,
452
+ next_run_at: computeNextRun(task.cron),
453
+ updated_at: now,
454
+ }).eq("id", task.id);
455
+ results.push({ taskId: task.id, name: task.name, status: success ? "success" : "partial" });
456
+ } catch (err) {
457
+ const msg = err instanceof Error ? err.message : String(err);
458
+
459
+ // Classify error for the client to display meaningfully
460
+ let classified = msg;
461
+ if (/invalid.?api.?key|api key|unauthorized|401/i.test(msg)) {
462
+ classified = `LLM API Key Error: ${msg}`;
463
+ } else if (/quota|rate.?limit|429|too many requests/i.test(msg)) {
464
+ classified = `Rate Limit / Quota: ${msg}`;
465
+ } else if (/no llm keys|all llm providers failed/i.test(msg)) {
466
+ classified = `No LLM Keys Configured: ${msg}`;
467
+ } else if (/gmail|refresh.?token|oauth/i.test(msg)) {
468
+ classified = `Gmail Auth Error: ${msg}`;
469
+ } else if (/firecrawl|search failed|crawl failed/i.test(msg)) {
470
+ classified = `Web Search/Crawl Error: ${msg}`;
471
+ }
472
+
473
+ if (runId) {
474
+ await db.from("scheduler_runs").update({
475
+ status: "failed",
476
+ error: classified,
477
+ output: "",
478
+ step_results: [],
479
+ finished_at: now,
480
+ }).eq("id", runId);
481
+ }
482
+ await db.from("scheduler_tasks").update({
483
+ last_run_at: now,
484
+ next_run_at: computeNextRun(task.cron),
485
+ updated_at: now,
486
+ }).eq("id", task.id);
487
+ results.push({ taskId: task.id, name: task.name, status: "failed", error: classified });
488
+ }
489
+ })
490
+ );
491
+
492
+ return new Response(
493
+ JSON.stringify({ ran: results.length, results }),
494
+ { status: 200, headers: { "Content-Type": "application/json" } }
495
+ );
496
+ });
@@ -0,0 +1 @@
1
+ {"ref":"zbgjrdlggpifbdbblbxa","name":"Jimmy AI","organization_id":"rqgvjmsbeivsipzjozzh","organization_slug":"rqgvjmsbeivsipzjozzh"}
package/tsconfig.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext","DOM"]
5
+ ,
6
+ "target": "ESNext",
7
+ "module": "Preserve",
8
+ "moduleDetection": "force",
9
+ "jsx": "react-jsx",
10
+ "allowJs": true,
11
+ "types": ["bun","Node"],
12
+
13
+ // Bundler mode
14
+ "moduleResolution": "bundler",
15
+ "allowImportingTsExtensions": true,
16
+ "verbatimModuleSyntax": true,
17
+ "noEmit": true,
18
+
19
+ // Best practices
20
+ "strict": true,
21
+ "skipLibCheck": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedIndexedAccess": true,
24
+ "noImplicitOverride": true,
25
+
26
+ // Some stricter flags (disabled by default)
27
+ "noUnusedLocals": false,
28
+ "noUnusedParameters": false,
29
+ "noPropertyAccessFromIndexSignature": false
30
+ },
31
+ // Exclude Deno-based Supabase Edge Functions from Node/Bun compilation
32
+ "exclude": ["supabase/functions"]
33
+ }
package/tui/spinner.ts ADDED
@@ -0,0 +1,33 @@
1
+ export async function withSpinner(label: string, fn: () => Promise<any>) {
2
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
3
+ let i = 0;
4
+ const write = (s: string) => {
5
+ try {
6
+ process.stderr.write(s);
7
+ } catch (e) {
8
+ // ignore
9
+ }
10
+ };
11
+
12
+ const hideCursor = '\x1B[?25l';
13
+ const showCursor = '\x1B[?25h';
14
+
15
+ write(hideCursor);
16
+ const id = setInterval(() => {
17
+ write('\r' + label + ' ' + frames[i % frames.length]);
18
+ i++;
19
+ }, 80);
20
+
21
+ try {
22
+ const res = await fn();
23
+ clearInterval(id);
24
+ write('\r' + label + ' ✓\n');
25
+ write(showCursor);
26
+ return res;
27
+ } catch (err) {
28
+ clearInterval(id);
29
+ write('\r' + label + ' ✖\n');
30
+ write(showCursor);
31
+ throw err;
32
+ }
33
+ }