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,183 @@
1
+ import {
2
+ Output,
3
+ extractJsonMiddleware,
4
+ generateText,
5
+ stepCountIs,
6
+ tool,
7
+ wrapLanguageModel,
8
+ type LanguageModel,
9
+ } from 'ai';
10
+ import { z } from 'zod';
11
+ import chalk from 'chalk';
12
+ import { ActionTracker } from '../agent/action-tracker.ts';
13
+ import { ToolExecutor } from '../agent/tool-executor.ts';
14
+ import { defaultAgentConfig } from '../agent/types.ts';
15
+ import { createWebTools } from './web-tools.ts';
16
+ import type { Plan, PlanStep } from './index.ts';
17
+ import { getAgentModel } from '../config/ai.config.ts';
18
+ import { withLLMRetry, parseLLMError } from '../utils/llm-error';
19
+
20
+ const planSchema = z.object({
21
+ researchSummary: z.string().optional(),
22
+ steps: z
23
+ .array(
24
+ z.object({
25
+ title: z.string(),
26
+ description: z.string(),
27
+ hints: z.array(z.string()).optional(),
28
+ complexity: z.enum(['low', 'medium', 'high']).optional(),
29
+ }),
30
+ )
31
+ .min(1)
32
+ .max(15),
33
+ });
34
+
35
+ function readOnlyTools(executor: ToolExecutor) {
36
+ return {
37
+ read_file: tool({
38
+ description: 'Read a workspace file (relative path).',
39
+ inputSchema: z.object({ path: z.string() }),
40
+ execute: async ({ path: p }) => executor.readFile(p),
41
+ }),
42
+ list_files: tool({
43
+ description: 'List files/dirs at a path.',
44
+ inputSchema: z.object({
45
+ path: z.string(),
46
+ recursive: z.boolean().optional().default(false),
47
+ }),
48
+ execute: async ({ path: p, recursive }) => executor.listFiles(p, recursive),
49
+ }),
50
+ search_files: tool({
51
+ description: 'Glob-search file names; optional content filter.',
52
+ inputSchema: z.object({
53
+ root: z.string(),
54
+ pattern: z.string(),
55
+ content_contains: z.string().optional(),
56
+ }),
57
+ execute: async ({ root, pattern, content_contains }) =>
58
+ executor.searchFiles(root, pattern, content_contains),
59
+ }),
60
+ analyze_codebase: tool({
61
+ description: 'Summarize codebase structure.',
62
+ inputSchema: z.object({ path: z.string().default('.') }),
63
+ execute: async ({ path: p }) => executor.analyzeCodebase(p),
64
+ }),
65
+ list_skills: tool({
66
+ description: 'List paths to bundled SKILL.md files.',
67
+ inputSchema: z.object({}),
68
+ execute: async () => executor.listSkills(),
69
+ }),
70
+ read_skill: tool({
71
+ description: 'Read a SKILL.md by absolute path.',
72
+ inputSchema: z.object({ path: z.string() }),
73
+ execute: async ({ path: p }) => executor.readSkill(p),
74
+ }),
75
+ };
76
+ }
77
+
78
+ const PLAN_INSTRUCTIONS = (codebase: string | undefined, hasWeb: boolean) => {
79
+ const parts: string[] = [
80
+ 'You are a Plan-Mode planner. You DO NOT modify files.',
81
+ ];
82
+ if (codebase)
83
+ parts.push(
84
+ `Workspace: ${codebase}`,
85
+ 'Use read-only tools for codebase/skills research.',
86
+ 'If the repository already contains an existing plan or roadmap, continue and extend it rather than replacing it. Otherwise generate a fresh, standalone roadmap as requested by the user.',
87
+ );
88
+ parts.push(
89
+ hasWeb
90
+ ? 'Web tools are available (web_search/web_crawl/fetch_url). Use only when needed.'
91
+ : 'Web tools are unavailable (no FIRECRAWL_API_KEY).',
92
+ 'Output must match the provided JSON schema.',
93
+ 'Keep it short: 1–10 steps.',
94
+ );
95
+ return parts.join('\n');
96
+ };
97
+
98
+ export async function generatePlan(goal: string, options?: { useWorkspace?: boolean }): Promise<Plan> {
99
+ const config = defaultAgentConfig();
100
+ const tracker = new ActionTracker();
101
+ const executor = new ToolExecutor(tracker, config);
102
+ const hasWeb = !!process.env.FIRECRAWL_API_KEY?.trim();
103
+ const model = wrapLanguageModel({
104
+ model: getAgentModel() as unknown as LanguageModel,
105
+ middleware: extractJsonMiddleware(),
106
+ });
107
+
108
+ const useWorkspace = options?.useWorkspace ?? true;
109
+ const tools = useWorkspace
110
+ ? { ...readOnlyTools(executor), ...(hasWeb ? createWebTools(tracker) : {}) }
111
+ : { ...(hasWeb ? createWebTools(tracker) : {}) };
112
+
113
+ // Try robust generation with retries; abort on failure so callers can show the error.
114
+ const systemPrompt = PLAN_INSTRUCTIONS(useWorkspace ? config.codebasePath : undefined, hasWeb);
115
+ const prompt = `User goal:\n${goal}`;
116
+
117
+ async function tryGenerateStructured(maxRetries = 3) {
118
+ return withLLMRetry(
119
+ () =>
120
+ generateText({
121
+ model,
122
+ tools,
123
+ stopWhen: stepCountIs(20),
124
+ system: systemPrompt,
125
+ prompt,
126
+ output: Output.object({ schema: planSchema }),
127
+ }),
128
+ { maxRetries, context: "Plan" }
129
+ );
130
+ }
131
+
132
+ let result: any;
133
+ try {
134
+ result = await tryGenerateStructured(3);
135
+ } catch (err) {
136
+ throw err;
137
+ }
138
+ // Normalize model output: support both `{ steps: [...] }` and `{ plan: [...] }`,
139
+ // and tolerate items that use `step`/`description` shape instead of `title`.
140
+ const rawOut = result.output ?? {};
141
+ let normOut: any = { ...rawOut };
142
+
143
+ if (!normOut.steps && Array.isArray(normOut.plan)) {
144
+ normOut.steps = normOut.plan;
145
+ }
146
+
147
+ if (Array.isArray(normOut.steps)) {
148
+ const normalizedItems = normOut.steps.map((it: any) => {
149
+ const title = it.title ?? (it.step !== undefined ? `Step ${it.step}` : undefined);
150
+ const description = it.description ?? it.desc ?? it.text ?? '';
151
+ const hints = Array.isArray(it.hints) ? it.hints : it.hint ? [it.hint] : undefined;
152
+ const complexity = it.complexity;
153
+ return { title, description, hints, complexity };
154
+ });
155
+ normOut.steps = normalizedItems;
156
+ }
157
+
158
+ // Try strict validation, but fall back to a best-effort mapping if validation fails.
159
+ try {
160
+ const validated = planSchema.parse(normOut);
161
+ const steps: PlanStep[] = validated.steps.map((s, i) => ({
162
+ id: `step-${i + 1}`,
163
+ title: s.title,
164
+ description: s.description,
165
+ hints: s.hints,
166
+ complexity: s.complexity,
167
+ }));
168
+ return { goal, researchSummary: validated.researchSummary, steps };
169
+ } catch (err) {
170
+ // Best-effort fallback: use whatever we can extract from normOut.steps
171
+ if (Array.isArray(normOut.steps)) {
172
+ const steps: PlanStep[] = normOut.steps.map((s: any, i: number) => ({
173
+ id: `step-${i + 1}`,
174
+ title: s.title ?? `Step ${i + 1}`,
175
+ description: s.description ?? '',
176
+ hints: Array.isArray(s.hints) ? s.hints : undefined,
177
+ complexity: s.complexity,
178
+ }));
179
+ return { goal, researchSummary: normOut.researchSummary ?? normOut.summary, steps };
180
+ }
181
+ throw err;
182
+ }
183
+ }
@@ -0,0 +1,50 @@
1
+ import { multiselect, isCancel } from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+ import type { Plan, PlanStep } from './index.ts';
4
+ import { renderHTMLMarkdown } from '../tui/terminal-render.ts';
5
+
6
+ const COMPLEXITY_COLOR: Record<NonNullable<PlanStep['complexity']>, string> = {
7
+ low: chalk.green('low'),
8
+ medium: chalk.yellow('medium'),
9
+ high: chalk.red('high'),
10
+ };
11
+
12
+ export function printPlan(plan: Plan): void {
13
+ if (plan.researchSummary?.trim()) {
14
+ console.log(chalk.bold('\n🔍 Research summary'));
15
+ console.log(renderHTMLMarkdown(plan.researchSummary));
16
+ }
17
+ console.log(chalk.bold('\n📋 Generated Plan\n'));
18
+ for (const [i, s] of plan.steps.entries()) {
19
+ const tag = s.complexity ? `[${COMPLEXITY_COLOR[s.complexity]}]` : '';
20
+ console.log(` ${chalk.cyan(`Step ${String(i + 1).padStart(2)}`)}. ${chalk.bold(s.title)} ${tag}`);
21
+ }
22
+ console.log();
23
+ }
24
+
25
+ export async function selectSteps(plan: Plan): Promise<PlanStep[]> {
26
+ const options = plan.steps.map((s: any) => ({
27
+ value: s.id,
28
+ label: s.title,
29
+ hint: s.complexity ?? '',
30
+ disabled: false,
31
+ }));
32
+
33
+ let picked: string[] | null = null;
34
+ try {
35
+ picked = await multiselect<string>({
36
+ message: 'Select steps to execute (space toggles, enter confirms)',
37
+ options,
38
+ initialValues: plan.steps.map((s: any) => s.id),
39
+ required: false,
40
+ }) as string[];
41
+ } catch (err) {
42
+ // If the multiselect UI throws (some terminals or environments), fall back to selecting all steps
43
+ console.warn('multiselect failed, defaulting to all steps');
44
+ picked = plan.steps.map((s: any) => s.id);
45
+ }
46
+
47
+ if (isCancel(picked)) return [];
48
+ const set = new Set<string>(picked);
49
+ return plan.steps.filter((s:any) => set.has(s.id));
50
+ }
package/plan/types.ts ADDED
@@ -0,0 +1,13 @@
1
+ export interface PlanStep {
2
+ id: string;
3
+ title: string;
4
+ description: string;
5
+ hints?: string[];
6
+ complexity?: 'low' | 'medium' | 'high';
7
+ }
8
+
9
+ export interface Plan {
10
+ goal: string;
11
+ researchSummary?: string;
12
+ steps: PlanStep[];
13
+ }
@@ -0,0 +1,119 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import Firecrawl from "@mendable/firecrawl-js";
4
+ import type { ActionTracker } from "../agent/action-tracker.ts";
5
+
6
+ function getKey(): string | undefined {
7
+ const v = process.env.FIRECRAWL_KEY;
8
+ return v?.replace(/^['"]|['"]$/g, "").trim() || undefined;
9
+ }
10
+
11
+ let client: Firecrawl | null = null;
12
+ function getClient(): Firecrawl {
13
+ if (client) return client;
14
+ const apiKey = getKey();
15
+ if (!apiKey) throw new Error("FIRECRAWL_API_KEY not set");
16
+ client = new Firecrawl({ apiKey });
17
+ return client;
18
+ }
19
+
20
+ function clip(s: string, n = 8000): string {
21
+ return s.length > n ? s.slice(0, n) + "\n…[truncated]" : s;
22
+ }
23
+
24
+ export function createWebTools(tracker: ActionTracker) {
25
+ return {
26
+ web_search: tool({
27
+ description: "Search the web. Returns title/url/snippet list.",
28
+ inputSchema: z.object({
29
+ query: z.string().min(1),
30
+ limit: z.number().int().min(1).max(10).optional().default(5),
31
+ }),
32
+ execute: async ({ query, limit }) => {
33
+ const res = await getClient().search(query, {
34
+ limit,
35
+ sources: ["web"],
36
+ });
37
+ const items = (res.web ?? []).slice(0, limit);
38
+ const out =
39
+ items
40
+ .map((d, i) => {
41
+ const title = ("title" in d && d.title) || "(untitled)";
42
+ const url = ("url" in d && d.url) || "";
43
+ const snip = ("snippet" in d && d.snippet) || "";
44
+ return `${i + 1}. ${title}\n ${url}\n ${snip}`;
45
+ })
46
+ .join("\n\n") || "(no results)";
47
+ tracker.log({
48
+ type: "code_analysis",
49
+ path: `web_search:${query}`,
50
+ details: { after: out, toolName: "web_search" },
51
+ status: "executed",
52
+ });
53
+ return clip(out);
54
+ },
55
+ }),
56
+
57
+ web_crawl: tool({
58
+ description: "Scrape a URL into markdown text.",
59
+ inputSchema: z.object({ url: z.string().url() }),
60
+ execute: async ({ url }) => {
61
+ const doc = await getClient().scrape(url, { formats: ["markdown"] });
62
+ const md = (doc as { markdown?: string }).markdown ?? "";
63
+ tracker.log({
64
+ type: "code_analysis",
65
+ path: `web_crawl:${url}`,
66
+ details: { after: clip(md), toolName: "web_crawl" },
67
+ status: "executed",
68
+ });
69
+ return clip(md) || "(empty)";
70
+ },
71
+ }),
72
+
73
+ fetch_url: tool({
74
+ description: "HTTP GET for a URL. Returns response body.",
75
+ inputSchema: z.object({ url: z.string().url() }),
76
+ execute: async ({ url }) => {
77
+ const r = await fetch(url, { redirect: "follow" });
78
+ const body = await r.text();
79
+ const out = clip(body, 16_000);
80
+ tracker.log({
81
+ type: "code_analysis",
82
+ path: `fetch:${url}`,
83
+ details: {
84
+ after: `HTTP ${r.status}\n\n${out}`,
85
+ toolName: "fetch_url",
86
+ },
87
+ status: "executed",
88
+ });
89
+ return `HTTP ${r.status}\n\n${out}`;
90
+ },
91
+ }),
92
+
93
+ wikipedia_search: tool({
94
+ description: "Search Wikipedia for detailed information",
95
+ inputSchema: z.object({
96
+ query: z.string().describe("The topic to search about"),
97
+ }),
98
+ execute: async ({ query }) => {
99
+ const url = `https://en.wikipedia.org/w/api.php?action=opensearch&search=${encodeURIComponent(query)}&limit=1&format=json`;
100
+ const response = await fetch(url);
101
+
102
+ if (!response.ok) {
103
+ return {
104
+ success: false,
105
+ message: "Article not found",
106
+ };
107
+ }
108
+
109
+ const data = await response.json();
110
+
111
+ return {
112
+ title: data.title,
113
+ summary: data.extract,
114
+ url: data.content_urls?.desktop?.page,
115
+ };
116
+ },
117
+ }),
118
+ };
119
+ }
@@ -0,0 +1,263 @@
1
+ # Scheduler Architecture
2
+
3
+ ## System Overview
4
+
5
+ ```
6
+ ┌─────────────────────────────────────────────────────────────────┐
7
+ │ YOUR MACHINE │
8
+ │ ┌──────────────┐ ┌────────────────────────────────┐ │
9
+ │ │ Jimmy CLI │ │ Local Storage │ │
10
+ │ │ │ │ ~/.cccontrol/googleAuth/ │ │
11
+ │ │ • Add tasks │◄────────►│ google_config.json │ │
12
+ │ │ • Manage │ │ (refresh_token) │ │
13
+ │ │ • Debug │ └────────────────────────────────┘ │
14
+ │ └──────┬───────┘ │
15
+ │ │ Writes tasks │
16
+ │ │ Syncs credentials │
17
+ └─────────┼───────────────────────────────────────────────────────┘
18
+
19
+ │ HTTPS (Supabase Client)
20
+
21
+ ┌─────────────────────────────────────────────────────────────────┐
22
+ │ SUPABASE CLOUD │
23
+ │ ┌───────────────────────────────────────────────────────────┐ │
24
+ │ │ PostgreSQL Database │ │
25
+ │ │ ┌────────────────┐ ┌────────────────┐ ┌─────────────┐ │ │
26
+ │ │ │scheduler_tasks │ │scheduler_runs │ │user_config │ │ │
27
+ │ │ │ │ │ │ │ │ │ │
28
+ │ │ │ • name │ │ • task_id │ │ • API keys │ │ │
29
+ │ │ │ • cron │ │ • status │ │ • tokens │ │ │
30
+ │ │ │ • steps │ │ • output │ │ • secrets │ │ │
31
+ │ │ │ • enabled │ │ • error │ │ │ │ │
32
+ │ │ │ • next_run_at │ │ • step_results │ │ │ │ │
33
+ │ │ └────────────────┘ └────────────────┘ └─────────────┘ │ │
34
+ │ └───────────┬───────────────┬───────────────────┬───────────┘ │
35
+ │ │ │ │ │
36
+ │ ┌───────────┴───────────────┴───────────────────┴───────────┐ │
37
+ │ │ pg_cron Extension │ │
38
+ │ │ │ │
39
+ │ │ Job: jimmy-scheduler-tick │ │
40
+ │ │ Schedule: * * * * * (every minute) │ │
41
+ │ │ Action: HTTP POST to Edge Function │ │
42
+ │ └───────────┬─────────────────────────────────────────────────┤ │
43
+ │ │ Every minute │ │
44
+ │ ▼ │ │
45
+ │ ┌─────────────────────────────────────────────────────────┐ │ │
46
+ │ │ Edge Function: scheduler-tick │ │ │
47
+ │ │ (Deno runtime in Supabase) │ │ │
48
+ │ │ │ │ │
49
+ │ │ 1. Load credentials from user_config │ │ │
50
+ │ │ 2. Query tasks WHERE enabled=true AND next_run_at <= now│ │ │
51
+ │ │ 3. For each due task: │ │ │
52
+ │ │ a. Create run record (status: running) │ │ │
53
+ │ │ b. Execute each step in order: │ │ │
54
+ │ │ • web_search → Firecrawl API │ │ │
55
+ │ │ • web_crawl → Firecrawl API │ │ │
56
+ │ │ • custom → OpenRouter/Groq LLM │ │ │
57
+ │ │ • email_send → Gmail API │ │ │
58
+ │ │ • browser → Skip (local only) │ │ │
59
+ │ │ c. Update run record (status: success/failed) │ │ │
60
+ │ │ d. Update task (last_run_at, next_run_at, count) │ │ │
61
+ │ │ e. Send summary email if configured │ │ │
62
+ │ │ 4. Return results │ │ │
63
+ │ └─────────────────────────────────────────────────────────┘ │ │
64
+ │ │ │ │
65
+ │ │ Makes API calls │ │
66
+ │ ▼ │ │
67
+ └──────────────┼──────────────────────────────────────────────────┘
68
+
69
+ │ HTTPS
70
+
71
+ ┌─────────────────────────────────────────────────────────────────┐
72
+ │ EXTERNAL APIS │
73
+ │ │
74
+ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐ │
75
+ │ │ Firecrawl │ │ OpenRouter │ │ Groq │ │ Gmail │ │
76
+ │ │ API │ │ API │ │ API │ │ API │ │
77
+ │ │ │ │ │ │ │ │ │ │
78
+ │ │ • Search │ │ • LLM │ │ • LLM │ │ • Send │ │
79
+ │ │ • Scrape │ │ • Summary │ │ • Fallback │ │ • OAuth │ │
80
+ │ └────────────┘ └────────────┘ └────────────┘ └──────────┘ │
81
+ └─────────────────────────────────────────────────────────────────┘
82
+
83
+ │ Email sent
84
+
85
+ 📧 YOUR INBOX
86
+ ```
87
+
88
+ ## Data Flow
89
+
90
+ ### 1. Task Creation
91
+ ```
92
+ User (CLI) → scheduler_tasks table
93
+ → Sets next_run_at based on cron
94
+ ```
95
+
96
+ ### 2. Credential Sync
97
+ ```
98
+ Local file: ~/.cccontrol/googleAuth/google_config.json
99
+ ↓ jimmy sync-credentials
100
+ Supabase: user_config table (key-value pairs)
101
+ ↓ Edge Function reads on every run
102
+ External APIs (authenticated)
103
+ ```
104
+
105
+ ### 3. Scheduled Execution
106
+ ```
107
+ pg_cron (every minute)
108
+
109
+ Edge Function triggered
110
+
111
+ 1. Load credentials from user_config
112
+ 2. Query due tasks
113
+ 3. For each task:
114
+ ├─ Create run record
115
+ ├─ Execute steps sequentially
116
+ ├─ Store results
117
+ ├─ Update task counters
118
+ ├─ Calculate next_run_at
119
+ └─ Send email summary
120
+
121
+ Results in scheduler_runs table
122
+ ```
123
+
124
+ ### 4. Manual Execution
125
+ ```
126
+ User (CLI: "Run now")
127
+
128
+ createRun() → scheduler_runs
129
+
130
+ runTask() → executes steps locally (Node)
131
+
132
+ finishRun() → updates scheduler_runs
133
+
134
+ updateTask() → updates counters
135
+ ```
136
+
137
+ ## Step Execution Details
138
+
139
+ ### Step Type: `web_search`
140
+ ```
141
+ Input: Natural language instruction
142
+
143
+ LLM extracts search query
144
+
145
+ Firecrawl API search
146
+
147
+ LLM summarizes results
148
+
149
+ Output: Summary text
150
+ ```
151
+
152
+ ### Step Type: `web_crawl`
153
+ ```
154
+ Input: URL or instruction
155
+
156
+ LLM extracts URL (if needed)
157
+
158
+ Firecrawl API scrape
159
+
160
+ LLM extracts relevant info
161
+
162
+ Output: Extracted content
163
+ ```
164
+
165
+ ### Step Type: `custom`
166
+ ```
167
+ Input: Natural language instruction
168
+
169
+ LLM processes with context
170
+
171
+ Output: LLM response
172
+ ```
173
+
174
+ ### Step Type: `email_send`
175
+ ```
176
+ Input: Instruction (who, what, subject)
177
+
178
+ LLM generates email params (to, subject, body)
179
+
180
+ Gmail API: refresh token → access token
181
+
182
+ Send email via Gmail API
183
+
184
+ Output: "Email sent to X"
185
+ ```
186
+
187
+ ### Step Type: `browser` (local only)
188
+ ```
189
+ Input: Instruction
190
+
191
+ Stagehand + Playwright
192
+
193
+ Browser automation
194
+
195
+ Output: Extracted data
196
+ ```
197
+
198
+ ## Key Design Decisions
199
+
200
+ ### Why Supabase?
201
+ - ✅ Always-on execution (no local process needed)
202
+ - ✅ Built-in PostgreSQL + pg_cron
203
+ - ✅ Edge Functions (Deno) for serverless execution
204
+ - ✅ Real-time database for monitoring
205
+ - ✅ Free tier supports most use cases
206
+
207
+ ### Why `user_config` Table?
208
+ - ✅ No manual secret updates on re-auth
209
+ - ✅ Dynamic credential loading
210
+ - ✅ Edge Function reads fresh values every run
211
+ - ✅ Multi-user support (if needed)
212
+
213
+ ### Why Two Execution Modes?
214
+ - **Serverless (Edge Function):** Most step types
215
+ - **Local (daemon):** Browser automation only (Playwright can't run in Edge Functions)
216
+
217
+ ### Why LLM Fallback Chain?
218
+ ```
219
+ OpenRouter (primary)
220
+ ↓ if fails or refuses
221
+ Groq (fallback)
222
+ ↓ if both fail
223
+ Error logged
224
+ ```
225
+
226
+ This ensures reliability even if one provider is down.
227
+
228
+ ## Security
229
+
230
+ - ✅ Credentials stored in Supabase (service role access only)
231
+ - ✅ Edge Function validates auth header (Bearer token)
232
+ - ✅ Gmail refresh token auto-rotates (OAuth2)
233
+ - ✅ Local config files have 0600 permissions
234
+ - ✅ No secrets in code or git
235
+
236
+ ## Monitoring
237
+
238
+ **Real-time:**
239
+ - `jimmy scheduler-debug` — CLI tool
240
+
241
+ **Historical:**
242
+ - `scheduler_runs` table — all executions
243
+ - `cron.job_run_details` — pg_cron logs
244
+ - Edge Function logs — Supabase dashboard
245
+
246
+ **Manual:**
247
+ - SQL queries in `scheduler/check-status.sql`
248
+
249
+ ## Scalability
250
+
251
+ Current setup handles:
252
+ - **Tasks:** Unlimited (Postgres capacity)
253
+ - **Executions:** 1/minute per task
254
+ - **Concurrency:** All due tasks execute in parallel (Promise.allSettled)
255
+ - **Free tier limits:**
256
+ - Supabase: 500MB DB, 2GB bandwidth/month
257
+ - OpenRouter: Pay-per-use
258
+ - Firecrawl: 500 credits/month free
259
+
260
+ To scale beyond free tier:
261
+ - Upgrade Supabase plan
262
+ - Add rate limiting
263
+ - Batch tasks into groups