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.
- package/CLI/cli.ts +42 -0
- package/README.md +137 -0
- package/SETUP.md +584 -0
- package/agent/action-tracker.ts +45 -0
- package/agent/agent-tools.ts +111 -0
- package/agent/approval.ts +137 -0
- package/agent/diff-view.ts +26 -0
- package/agent/orchestrator.ts +186 -0
- package/agent/tool-executor.ts +463 -0
- package/agent/types.ts +69 -0
- package/ask/orchestrator.ts +244 -0
- package/auth/auth.ts +567 -0
- package/auth/config-store.ts +77 -0
- package/auth/crypto.ts +51 -0
- package/auth/env-writer.ts +82 -0
- package/bin/jerob.js +28 -0
- package/config/ai.config.ts +163 -0
- package/email_ops/email-tools.ts +178 -0
- package/email_ops/email_functions.ts +443 -0
- package/email_ops/email_init.ts +92 -0
- package/email_ops/email_pass_store.ts +61 -0
- package/email_ops/email_server.ts +29 -0
- package/email_ops/types.ts +88 -0
- package/index.ts +176 -0
- package/package.json +88 -0
- package/plan/browser-agent/README.md +118 -0
- package/plan/browser-agent/USAGE.md +308 -0
- package/plan/browser-agent/evaluator.ts +353 -0
- package/plan/browser-agent/executor.ts +372 -0
- package/plan/browser-agent/index.ts +13 -0
- package/plan/browser-agent/orchestrator.ts +323 -0
- package/plan/browser-agent/planner.ts +200 -0
- package/plan/browser-agent/types.ts +62 -0
- package/plan/browser-tool.ts +128 -0
- package/plan/index.ts +12 -0
- package/plan/orchestrator.ts +214 -0
- package/plan/planner.ts +183 -0
- package/plan/selection.ts +50 -0
- package/plan/types.ts +13 -0
- package/plan/web-tools.ts +119 -0
- package/scheduler/ARCHITECTURE.md +263 -0
- package/scheduler/README.md +200 -0
- package/scheduler/SETUP-READY.sql +84 -0
- package/scheduler/check-status.sql +124 -0
- package/scheduler/config-sync.ts +91 -0
- package/scheduler/db-migrate.ts +271 -0
- package/scheduler/db.ts +162 -0
- package/scheduler/debug.ts +184 -0
- package/scheduler/orchestrator.ts +438 -0
- package/scheduler/planner.ts +170 -0
- package/scheduler/update-task-email.ts +70 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/.temp/gotrue-version +1 -0
- package/supabase/.temp/linked-project.json +1 -0
- package/supabase/.temp/pooler-url +1 -0
- package/supabase/.temp/postgres-version +1 -0
- package/supabase/.temp/project-ref +1 -0
- package/supabase/.temp/rest-version +1 -0
- package/supabase/.temp/storage-migration +1 -0
- package/supabase/.temp/storage-version +1 -0
- package/supabase/deploy.ps1 +50 -0
- package/supabase/functions/scheduler-tick/index.ts +496 -0
- package/supabase/supabase/.temp/linked-project.json +1 -0
- package/tsconfig.json +33 -0
- package/tui/spinner.ts +33 -0
- package/tui/spinup.ts +67 -0
- package/tui/terminal-render.ts +16 -0
- package/utils/llm-error.ts +185 -0
- 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
|
+
}
|