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
package/plan/planner.ts
ADDED
|
@@ -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,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
|