@jellyos/agent 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.npm.md +212 -0
- package/bin/jellyos-mcp +26 -0
- package/dist/api/ExtensionAPI.d.ts +11 -0
- package/dist/cli.js +127 -49
- package/dist/index.d.ts +15 -2
- package/dist/index.js +13 -3
- package/dist/loader.d.ts +2 -9
- package/dist/loader.js +2 -1
- package/dist/mcp/entry.d.ts +2 -0
- package/dist/mcp/entry.js +71 -0
- package/dist/mcp/server.d.ts +31 -0
- package/dist/mcp/server.js +128 -0
- package/dist/models/ModelRegistry.d.ts +12 -1
- package/dist/models/ModelRegistry.js +105 -9
- package/dist/runner/AgentRunner.d.ts +19 -2
- package/dist/runner/AgentRunner.js +247 -17
- package/dist/runner/ModelClient.d.ts +10 -1
- package/dist/runner/ModelClient.js +79 -6
- package/dist/runner/SwarmRouter.d.ts +6 -6
- package/dist/runner/SwarmRouter.js +73 -24
- package/dist/runner/ToolDispatcher.d.ts +10 -0
- package/dist/runner/ToolDispatcher.js +106 -2
- package/dist/scheduler/AgentScheduler.d.ts +118 -0
- package/dist/scheduler/AgentScheduler.js +253 -0
- package/dist/session/ContextStore.d.ts +96 -0
- package/dist/session/ContextStore.js +207 -0
- package/dist/session/GoalManager.d.ts +101 -0
- package/dist/session/GoalManager.js +167 -0
- package/dist/session/MemoryStore.d.ts +48 -0
- package/dist/session/MemoryStore.js +166 -0
- package/dist/session/SessionManager.d.ts +45 -4
- package/dist/session/SessionManager.js +151 -8
- package/dist/telemetry/Tracer.d.ts +48 -0
- package/dist/telemetry/Tracer.js +102 -0
- package/dist/tools/MarketSentiment.d.ts +166 -0
- package/dist/tools/MarketSentiment.js +209 -0
- package/dist/tools/NewsSentiment.js +40 -13
- package/dist/tools/PriceFeed.d.ts +2 -0
- package/dist/tools/PriceFeed.js +79 -27
- package/dist/tools/TechnicalAnalysis.d.ts +37 -0
- package/dist/tools/TechnicalAnalysis.js +85 -0
- package/dist/tui/App.d.ts +4 -3
- package/dist/tui/App.js +346 -119
- package/dist/tui/ModelSelector.d.ts +22 -0
- package/dist/tui/ModelSelector.js +86 -0
- package/dist/tui/REPL.d.ts +2 -1
- package/dist/tui/REPL.js +11 -6
- package/package.json +10 -6
- package/dist/api/ExtensionAPI.d.ts.map +0 -1
- package/dist/api/ExtensionAPI.js.map +0 -1
- package/dist/api/Registry.d.ts.map +0 -1
- package/dist/api/Registry.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/loader.d.ts.map +0 -1
- package/dist/loader.js.map +0 -1
- package/dist/models/CostTracker.d.ts.map +0 -1
- package/dist/models/CostTracker.js.map +0 -1
- package/dist/models/ModelRegistry.d.ts.map +0 -1
- package/dist/models/ModelRegistry.js.map +0 -1
- package/dist/models/index.d.ts.map +0 -1
- package/dist/models/index.js.map +0 -1
- package/dist/runner/AgentRunner.d.ts.map +0 -1
- package/dist/runner/AgentRunner.js.map +0 -1
- package/dist/runner/ModelClient.d.ts.map +0 -1
- package/dist/runner/ModelClient.js.map +0 -1
- package/dist/runner/SwarmRouter.d.ts.map +0 -1
- package/dist/runner/SwarmRouter.js.map +0 -1
- package/dist/runner/ToolDispatcher.d.ts.map +0 -1
- package/dist/runner/ToolDispatcher.js.map +0 -1
- package/dist/session/SessionManager.d.ts.map +0 -1
- package/dist/session/SessionManager.js.map +0 -1
- package/dist/tools/NewsSentiment.d.ts.map +0 -1
- package/dist/tools/NewsSentiment.js.map +0 -1
- package/dist/tools/PriceFeed.d.ts.map +0 -1
- package/dist/tools/PriceFeed.js.map +0 -1
- package/dist/tools/TechnicalAnalysis.d.ts.map +0 -1
- package/dist/tools/TechnicalAnalysis.js.map +0 -1
- package/dist/tools/index.d.ts.map +0 -1
- package/dist/tools/index.js.map +0 -1
- package/dist/tui/App.d.ts.map +0 -1
- package/dist/tui/App.js.map +0 -1
- package/dist/tui/REPL.d.ts.map +0 -1
- package/dist/tui/REPL.js.map +0 -1
- package/dist/tui/StatusBar.d.ts.map +0 -1
- package/dist/tui/StatusBar.js.map +0 -1
- package/dist/tui/theme.d.ts.map +0 -1
- package/dist/tui/theme.js.map +0 -1
|
@@ -31,46 +31,83 @@ export function scoreComplexity(prompt) {
|
|
|
31
31
|
questions * 5 +
|
|
32
32
|
Math.floor(wordCount / 8));
|
|
33
33
|
}
|
|
34
|
-
// ── Task decomposition
|
|
34
|
+
// ── Task decomposition (# 29: LLM planner with heuristic fallback) ───────────
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
36
|
+
* LLM-based task planner. Uses a cheap worker model to decompose the prompt
|
|
37
|
+
* into focused sub-tasks as a JSON array. Falls back to heuristics on failure.
|
|
38
38
|
*/
|
|
39
|
-
|
|
39
|
+
async function planSubtasks(prompt, maxTasks, modelReg) {
|
|
40
|
+
const cap = Math.max(2, Math.min(maxTasks, 5));
|
|
41
|
+
// Attempt LLM decomposition with a cheap/fast model
|
|
42
|
+
try {
|
|
43
|
+
const chain = resolveModelChain(modelReg);
|
|
44
|
+
// Prefer a worker-tier model for planning (fast + cheap)
|
|
45
|
+
const plannerCfg = chain.find(c => modelReg?.getTier(c.model) === "worker") ?? chain[chain.length - 1] ?? chain[0];
|
|
46
|
+
const client = new ModelClient({ ...plannerCfg, temperature: 0.2 }, modelReg);
|
|
47
|
+
const plannerPrompt = `Split the following request into exactly ${cap} focused, non-overlapping sub-tasks.\n` +
|
|
48
|
+
`Each sub-task must be independently answerable using data tools.\n` +
|
|
49
|
+
`Output ONLY a valid JSON array of strings. No explanation, no markdown.\n\n` +
|
|
50
|
+
`Request: ${prompt}`;
|
|
51
|
+
let output = "";
|
|
52
|
+
for await (const chunk of client.stream([
|
|
53
|
+
{ role: "system", content: "You output only valid JSON arrays of strings. No markdown, no explanation." },
|
|
54
|
+
{ role: "user", content: plannerPrompt },
|
|
55
|
+
], [])) {
|
|
56
|
+
if (chunk.type === "delta" && chunk.text)
|
|
57
|
+
output += chunk.text;
|
|
58
|
+
if (chunk.type === "error")
|
|
59
|
+
throw new Error(chunk.error);
|
|
60
|
+
}
|
|
61
|
+
// Extract JSON array from output (model might wrap in markdown)
|
|
62
|
+
const jsonMatch = output.match(/\[\s*"[\s\S]*?"\s*(?:,\s*"[\s\S]*?"\s*)*\]/);
|
|
63
|
+
if (jsonMatch) {
|
|
64
|
+
const tasks = JSON.parse(jsonMatch[0]);
|
|
65
|
+
if (Array.isArray(tasks) && tasks.every((t) => typeof t === "string") && tasks.length >= 2) {
|
|
66
|
+
return tasks.slice(0, cap);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Fall through to heuristic decomposition
|
|
72
|
+
}
|
|
73
|
+
return decomposeHeuristic(prompt, cap);
|
|
74
|
+
}
|
|
75
|
+
/** Original heuristic decomposer — used as fallback when LLM planner fails */
|
|
76
|
+
export function decomposeHeuristic(prompt, maxTasks) {
|
|
40
77
|
const cap = Math.max(2, Math.min(maxTasks, 5));
|
|
41
|
-
// Split on explicit conjunctions / punctuation
|
|
42
78
|
const parts = prompt
|
|
43
79
|
.split(/,\s*| and | also | then | additionally | plus /i)
|
|
44
80
|
.map(s => s.trim())
|
|
45
81
|
.filter(s => s.length > 4);
|
|
46
|
-
if (parts.length >= 2)
|
|
82
|
+
if (parts.length >= 2)
|
|
47
83
|
return parts.slice(0, cap);
|
|
48
|
-
}
|
|
49
|
-
// Fallback: split action verbs into separate sub-questions
|
|
50
84
|
const verbMatches = [...prompt.matchAll(/\b(analyze|compare|predict|scan|check|estimate|evaluate)\b[^,.?]*/gi)];
|
|
51
|
-
if (verbMatches.length >= 2)
|
|
85
|
+
if (verbMatches.length >= 2)
|
|
52
86
|
return verbMatches.slice(0, cap).map(m => m[0].trim());
|
|
53
|
-
}
|
|
54
|
-
// Cannot decompose meaningfully → return as-is (single task)
|
|
55
87
|
return [prompt];
|
|
56
88
|
}
|
|
57
|
-
|
|
58
|
-
|
|
89
|
+
/** Exported for tests — heuristic only, no model call */
|
|
90
|
+
export const decompose = decomposeHeuristic;
|
|
91
|
+
// ── Reviewer synthesis (#39: compact refs via ContextStore) ─────────────────
|
|
92
|
+
async function reviewerSynthesize(originalPrompt, allResults, systemPrompt, modelReg, contextRef) {
|
|
59
93
|
const chain = resolveModelChain(modelReg);
|
|
60
94
|
const cfg = chain[0];
|
|
61
95
|
const client = new ModelClient(cfg, modelReg);
|
|
62
|
-
// Filter out sub-tasks that errored — don't feed garbage into the reviewer
|
|
63
96
|
const results = allResults.filter(r => !r.error);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
.join("\n\n
|
|
97
|
+
// #39: If ContextStore holds the full results, send compact summaries + reference
|
|
98
|
+
const context = contextRef
|
|
99
|
+
? results.map((r, i) => `Sub-task ${i + 1} (${r.task.slice(0, 50)}): ${r.result.slice(0, 300)}...`).join("\n") + `\n\n${contextRef}`
|
|
100
|
+
: results
|
|
101
|
+
.map((r, i) => `### Sub-task ${i + 1}: ${r.task}\n${r.result}`)
|
|
102
|
+
.join("\n\n");
|
|
67
103
|
const messages = [
|
|
68
104
|
{ role: "system", content: systemPrompt },
|
|
69
105
|
{
|
|
70
106
|
role: "user",
|
|
71
|
-
content: `You are a synthesis reviewer.
|
|
72
|
-
`**Original request:** ${originalPrompt}\n\n
|
|
73
|
-
`
|
|
107
|
+
content: `You are a synthesis reviewer. Sub-tasks were executed for the following request.\n\n` +
|
|
108
|
+
`**Original request:** ${originalPrompt}\n\n` +
|
|
109
|
+
`**Sub-task results:**\n${context}\n\n` +
|
|
110
|
+
`Write a concise, unified answer that directly addresses the original request.`,
|
|
74
111
|
},
|
|
75
112
|
];
|
|
76
113
|
let out = "";
|
|
@@ -109,10 +146,13 @@ export class SwarmRouter {
|
|
|
109
146
|
* @param systemPrompt - Current system prompt (passed to each sub-agent + reviewer)
|
|
110
147
|
* @param onProgress - Called as each sub-task completes
|
|
111
148
|
*/
|
|
112
|
-
async run(prompt, systemPrompt, onProgress) {
|
|
113
|
-
|
|
114
|
-
const
|
|
149
|
+
async run(prompt, systemPrompt, onProgress, contextStore) {
|
|
150
|
+
// #29: Use LLM planner for task decomposition (falls back to heuristic)
|
|
151
|
+
const tasks = await planSubtasks(prompt, this.maxAgents, this.modelRegistry);
|
|
152
|
+
const chain = resolveModelChain(this.modelRegistry);
|
|
115
153
|
const subResults = [];
|
|
154
|
+
// #39: Open a task context folder to offload sub-results (saves context window)
|
|
155
|
+
const taskCtx = contextStore?.openTask(`Swarm: ${prompt.slice(0, 60)}`);
|
|
116
156
|
// Split tasks into groups of 3 (the required "groups-of-3" planner)
|
|
117
157
|
const GROUP_SIZE = 3;
|
|
118
158
|
const batches = [];
|
|
@@ -144,6 +184,10 @@ export class SwarmRouter {
|
|
|
144
184
|
error,
|
|
145
185
|
};
|
|
146
186
|
subResults.push(r);
|
|
187
|
+
// #39: Write sub-result to context file instead of keeping raw in memory
|
|
188
|
+
if (taskCtx && contextStore) {
|
|
189
|
+
contextStore.appendFinding(taskCtx.taskId, `Sub-task: ${task.slice(0, 50)}`, r.result);
|
|
190
|
+
}
|
|
147
191
|
onProgress(r, remaining);
|
|
148
192
|
};
|
|
149
193
|
// Execute batches sequentially; within each batch run up to 3 in parallel
|
|
@@ -154,7 +198,12 @@ export class SwarmRouter {
|
|
|
154
198
|
return runOne(task, modelIdx++, remaining);
|
|
155
199
|
}));
|
|
156
200
|
}
|
|
157
|
-
|
|
201
|
+
// #39: Pass context reference to reviewer (compact path vs raw dump)
|
|
202
|
+
const contextRef = taskCtx ? contextStore?.getReference(taskCtx.taskId) : undefined;
|
|
203
|
+
const synthesis = await reviewerSynthesize(prompt, subResults, systemPrompt, this.modelRegistry, contextRef);
|
|
204
|
+
// Close the context folder (auto-deletes in 5s)
|
|
205
|
+
if (taskCtx)
|
|
206
|
+
contextStore?.closeTask(taskCtx.taskId);
|
|
158
207
|
return { synthesis, subResults };
|
|
159
208
|
}
|
|
160
209
|
}
|
|
@@ -10,10 +10,20 @@ export interface ToolResult {
|
|
|
10
10
|
content: string;
|
|
11
11
|
isError: boolean;
|
|
12
12
|
}
|
|
13
|
+
/** #40: Estimate chars that will be added to context by dispatching these calls */
|
|
14
|
+
export declare function forecastContextGrowth(calls: {
|
|
15
|
+
function: {
|
|
16
|
+
name: string;
|
|
17
|
+
};
|
|
18
|
+
}[]): number;
|
|
13
19
|
export declare class ToolDispatcher {
|
|
14
20
|
private registry;
|
|
21
|
+
private failureCounts;
|
|
22
|
+
private openCircuits;
|
|
15
23
|
constructor(registry: Registry);
|
|
16
24
|
dispatch(calls: ToolCall[]): Promise<ToolResult[]>;
|
|
17
25
|
private execute;
|
|
26
|
+
private executeWithTimeout;
|
|
27
|
+
private executeInner;
|
|
18
28
|
}
|
|
19
29
|
//# sourceMappingURL=ToolDispatcher.d.ts.map
|
|
@@ -3,8 +3,65 @@
|
|
|
3
3
|
* Looks up tool by name in the Registry, validates params, runs execute().
|
|
4
4
|
*/
|
|
5
5
|
import { Value } from "@sinclair/typebox/value";
|
|
6
|
+
/**
|
|
7
|
+
* Attempt to repair common JSON errors from model output.
|
|
8
|
+
* Handles trailing commas, single quotes, unquoted keys.
|
|
9
|
+
* Returns original string if repair doesn't help.
|
|
10
|
+
*/
|
|
11
|
+
function repairJson(raw) {
|
|
12
|
+
try {
|
|
13
|
+
JSON.parse(raw);
|
|
14
|
+
return raw;
|
|
15
|
+
}
|
|
16
|
+
catch { /* fall through to repair */ }
|
|
17
|
+
const repaired = raw
|
|
18
|
+
.replace(/,\s*}/g, "}")
|
|
19
|
+
.replace(/,\s*]/g, "]")
|
|
20
|
+
.replace(/([{,]\s*)(\w+)(\s*:)/g, '$1"$2"$3') // unquoted keys
|
|
21
|
+
.replace(/:\s*'([^']*)'/g, ': "$1"'); // single-quoted values
|
|
22
|
+
try {
|
|
23
|
+
JSON.parse(repaired);
|
|
24
|
+
return repaired;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return raw;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const TOOL_TIMEOUT_MS = 30_000;
|
|
31
|
+
const CIRCUIT_OPEN_MS = 300_000;
|
|
32
|
+
const CIRCUIT_THRESHOLD = 3;
|
|
33
|
+
// #40: Estimated output sizes per tool (chars) for pre-dispatch budget forecasting
|
|
34
|
+
const TOOL_OUTPUT_ESTIMATES = {
|
|
35
|
+
get_candles: 8_000, // 100 OHLCV + TA = ~8KB
|
|
36
|
+
analyze_ta: 2_000,
|
|
37
|
+
get_prices: 500,
|
|
38
|
+
get_top_movers: 800,
|
|
39
|
+
get_market_overview: 1_000,
|
|
40
|
+
get_news: 4_000,
|
|
41
|
+
get_fear_greed: 400,
|
|
42
|
+
get_funding_rates: 600,
|
|
43
|
+
get_btc_mempool: 400,
|
|
44
|
+
get_defi_tvl: 2_000,
|
|
45
|
+
get_solana_stats: 300,
|
|
46
|
+
list_models: 3_000,
|
|
47
|
+
list_tasks: 500,
|
|
48
|
+
read_task_context: 6_000,
|
|
49
|
+
cost_report: 400,
|
|
50
|
+
list_goals: 600,
|
|
51
|
+
model_summary: 400,
|
|
52
|
+
_default: 2_000,
|
|
53
|
+
};
|
|
54
|
+
/** #40: Estimate chars that will be added to context by dispatching these calls */
|
|
55
|
+
export function forecastContextGrowth(calls) {
|
|
56
|
+
return calls.reduce((sum, tc) => {
|
|
57
|
+
const est = TOOL_OUTPUT_ESTIMATES[tc.function.name] ?? TOOL_OUTPUT_ESTIMATES["_default"];
|
|
58
|
+
return sum + est;
|
|
59
|
+
}, 0);
|
|
60
|
+
}
|
|
6
61
|
export class ToolDispatcher {
|
|
7
62
|
registry;
|
|
63
|
+
failureCounts = new Map();
|
|
64
|
+
openCircuits = new Map(); // toolName → openUntil timestamp
|
|
8
65
|
constructor(registry) {
|
|
9
66
|
this.registry = registry;
|
|
10
67
|
}
|
|
@@ -12,6 +69,52 @@ export class ToolDispatcher {
|
|
|
12
69
|
return Promise.all(calls.map(tc => this.execute(tc)));
|
|
13
70
|
}
|
|
14
71
|
async execute(tc) {
|
|
72
|
+
const toolName = tc.function.name;
|
|
73
|
+
// #6: Circuit breaker — fast-fail if tool has been consistently broken
|
|
74
|
+
const openUntil = this.openCircuits.get(toolName) ?? 0;
|
|
75
|
+
if (Date.now() < openUntil) {
|
|
76
|
+
const remainMs = Math.ceil((openUntil - Date.now()) / 1000);
|
|
77
|
+
return {
|
|
78
|
+
tool_call_id: tc.id,
|
|
79
|
+
name: toolName,
|
|
80
|
+
content: `Tool "${toolName}" is temporarily unavailable (circuit open for ${remainMs}s after repeated failures). Use a different approach or try again later.`,
|
|
81
|
+
isError: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const result = await this.executeWithTimeout(tc);
|
|
86
|
+
// Reset failure count on success
|
|
87
|
+
this.failureCounts.delete(toolName);
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
92
|
+
const failures = (this.failureCounts.get(toolName) ?? 0) + 1;
|
|
93
|
+
this.failureCounts.set(toolName, failures);
|
|
94
|
+
if (failures >= CIRCUIT_THRESHOLD) {
|
|
95
|
+
this.openCircuits.set(toolName, Date.now() + CIRCUIT_OPEN_MS);
|
|
96
|
+
this.failureCounts.delete(toolName);
|
|
97
|
+
return {
|
|
98
|
+
tool_call_id: tc.id,
|
|
99
|
+
name: toolName,
|
|
100
|
+
content: `Tool "${toolName}" failed ${CIRCUIT_THRESHOLD} times in a row. Circuit opened for 5 minutes. Error: ${errMsg}`,
|
|
101
|
+
isError: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
tool_call_id: tc.id,
|
|
106
|
+
name: toolName,
|
|
107
|
+
content: `Tool error (failure ${failures}/${CIRCUIT_THRESHOLD}): ${errMsg}`,
|
|
108
|
+
isError: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async executeWithTimeout(tc) {
|
|
113
|
+
// Race tool execution against a hard timeout
|
|
114
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Tool "${tc.function.name}" timed out after ${TOOL_TIMEOUT_MS / 1000}s`)), TOOL_TIMEOUT_MS));
|
|
115
|
+
return Promise.race([this.executeInner(tc), timeoutPromise]);
|
|
116
|
+
}
|
|
117
|
+
async executeInner(tc) {
|
|
15
118
|
const tool = this.registry.getTool(tc.function.name);
|
|
16
119
|
if (!tool) {
|
|
17
120
|
return {
|
|
@@ -23,13 +126,14 @@ export class ToolDispatcher {
|
|
|
23
126
|
}
|
|
24
127
|
let params;
|
|
25
128
|
try {
|
|
26
|
-
|
|
129
|
+
// #8: attempt JSON repair before hard-failing on malformed model output
|
|
130
|
+
params = JSON.parse(repairJson(tc.function.arguments || "{}"));
|
|
27
131
|
}
|
|
28
132
|
catch {
|
|
29
133
|
return {
|
|
30
134
|
tool_call_id: tc.id,
|
|
31
135
|
name: tc.function.name,
|
|
32
|
-
content: `Invalid JSON arguments: ${tc.function.arguments}`,
|
|
136
|
+
content: `Invalid JSON arguments (repair failed): ${tc.function.arguments}`,
|
|
33
137
|
isError: true,
|
|
34
138
|
};
|
|
35
139
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentScheduler — autonomous task scheduling. (#11)
|
|
3
|
+
*
|
|
4
|
+
* Enables the agent to act without user input:
|
|
5
|
+
* - Cron-style recurring tasks ("every 15 minutes, check BTC funding rates")
|
|
6
|
+
* - Price triggers ("when ETH drops below $2000, alert me")
|
|
7
|
+
* - One-shot future tasks ("in 30 minutes, summarize market conditions")
|
|
8
|
+
*
|
|
9
|
+
* Tasks are persisted to ~/.jelly/schedule.json and survive restarts.
|
|
10
|
+
* The scheduler polls every 60s and fires tasks via the provided callback.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* scheduler.addTask({ name: "BTC check", prompt: "check BTC price and RSI", cron: "@every_15m" });
|
|
14
|
+
* scheduler.start((task) => runner.run(task.prompt));
|
|
15
|
+
*/
|
|
16
|
+
import { type Static } from "@sinclair/typebox";
|
|
17
|
+
export interface PriceTrigger {
|
|
18
|
+
symbol: string;
|
|
19
|
+
above?: number;
|
|
20
|
+
below?: number;
|
|
21
|
+
/** Percent change threshold (e.g. 5 = 5% move either direction) */
|
|
22
|
+
changePct?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ScheduledTask {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
prompt: string;
|
|
28
|
+
/** Cron expression e.g. "@every_15m" or "0 * * * *" (hourly).
|
|
29
|
+
* Shorthand: @every_5m @every_15m @every_30m @every_1h @every_6h @hourly @daily
|
|
30
|
+
*/
|
|
31
|
+
cron?: string;
|
|
32
|
+
/** Price-based trigger */
|
|
33
|
+
trigger?: PriceTrigger;
|
|
34
|
+
/** One-shot run time (epoch ms) */
|
|
35
|
+
runAt?: number;
|
|
36
|
+
/** If true, disable after first run */
|
|
37
|
+
runOnce: boolean;
|
|
38
|
+
enabled: boolean;
|
|
39
|
+
createdAt: number;
|
|
40
|
+
lastRun?: number;
|
|
41
|
+
runCount: number;
|
|
42
|
+
}
|
|
43
|
+
export declare class AgentScheduler {
|
|
44
|
+
private tasks;
|
|
45
|
+
private timer?;
|
|
46
|
+
private lastTickMinute;
|
|
47
|
+
constructor();
|
|
48
|
+
private load;
|
|
49
|
+
private save;
|
|
50
|
+
start(onTrigger: (task: ScheduledTask) => void): void;
|
|
51
|
+
stop(): void;
|
|
52
|
+
private tick;
|
|
53
|
+
private checkPriceTrigger;
|
|
54
|
+
addTask(task: Omit<ScheduledTask, "id" | "createdAt" | "runCount">): ScheduledTask;
|
|
55
|
+
removeTask(id: string): boolean;
|
|
56
|
+
enableTask(id: string, enabled: boolean): boolean;
|
|
57
|
+
listTasks(): ScheduledTask[];
|
|
58
|
+
getTask(id: string): ScheduledTask | undefined;
|
|
59
|
+
readonly addTaskParams: import("@sinclair/typebox").TObject<{
|
|
60
|
+
name: import("@sinclair/typebox").TString;
|
|
61
|
+
prompt: import("@sinclair/typebox").TString;
|
|
62
|
+
cron: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
63
|
+
trigger_symbol: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
64
|
+
trigger_above: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
|
|
65
|
+
trigger_below: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
|
|
66
|
+
trigger_change_pct: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
|
|
67
|
+
run_in_minutes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
|
|
68
|
+
run_once: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
69
|
+
}>;
|
|
70
|
+
addTaskTool(_id: string, params: Static<typeof this.addTaskParams>): Promise<{
|
|
71
|
+
content: {
|
|
72
|
+
type: "text";
|
|
73
|
+
text: string;
|
|
74
|
+
}[];
|
|
75
|
+
details: {
|
|
76
|
+
taskId: string;
|
|
77
|
+
task: ScheduledTask;
|
|
78
|
+
};
|
|
79
|
+
}>;
|
|
80
|
+
readonly listTasksParams: import("@sinclair/typebox").TObject<{
|
|
81
|
+
enabled_only: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
82
|
+
}>;
|
|
83
|
+
listTasksTool(_id: string, params: Static<typeof this.listTasksParams>): Promise<{
|
|
84
|
+
content: {
|
|
85
|
+
type: "text";
|
|
86
|
+
text: string;
|
|
87
|
+
}[];
|
|
88
|
+
details: {
|
|
89
|
+
count?: undefined;
|
|
90
|
+
tasks?: undefined;
|
|
91
|
+
};
|
|
92
|
+
} | {
|
|
93
|
+
content: {
|
|
94
|
+
type: "text";
|
|
95
|
+
text: string;
|
|
96
|
+
}[];
|
|
97
|
+
details: {
|
|
98
|
+
count: number;
|
|
99
|
+
tasks: ScheduledTask[];
|
|
100
|
+
};
|
|
101
|
+
}>;
|
|
102
|
+
readonly removeTaskParams: import("@sinclair/typebox").TObject<{
|
|
103
|
+
id: import("@sinclair/typebox").TString;
|
|
104
|
+
}>;
|
|
105
|
+
removeTaskTool(_id: string, params: Static<typeof this.removeTaskParams>): Promise<{
|
|
106
|
+
content: {
|
|
107
|
+
type: "text";
|
|
108
|
+
text: string;
|
|
109
|
+
}[];
|
|
110
|
+
details: {
|
|
111
|
+
taskId: string;
|
|
112
|
+
success: boolean;
|
|
113
|
+
};
|
|
114
|
+
}>;
|
|
115
|
+
}
|
|
116
|
+
/** Singleton */
|
|
117
|
+
export declare const agentScheduler: AgentScheduler;
|
|
118
|
+
//# sourceMappingURL=AgentScheduler.d.ts.map
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentScheduler — autonomous task scheduling. (#11)
|
|
3
|
+
*
|
|
4
|
+
* Enables the agent to act without user input:
|
|
5
|
+
* - Cron-style recurring tasks ("every 15 minutes, check BTC funding rates")
|
|
6
|
+
* - Price triggers ("when ETH drops below $2000, alert me")
|
|
7
|
+
* - One-shot future tasks ("in 30 minutes, summarize market conditions")
|
|
8
|
+
*
|
|
9
|
+
* Tasks are persisted to ~/.jelly/schedule.json and survive restarts.
|
|
10
|
+
* The scheduler polls every 60s and fires tasks via the provided callback.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* scheduler.addTask({ name: "BTC check", prompt: "check BTC price and RSI", cron: "@every_15m" });
|
|
14
|
+
* scheduler.start((task) => runner.run(task.prompt));
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { priceFeed } from "../tools/PriceFeed.js";
|
|
21
|
+
import { Type } from "@sinclair/typebox";
|
|
22
|
+
const JELLY_HOME = process.env.JELLYOS_HOME ?? join(homedir(), ".jelly");
|
|
23
|
+
const SCHEDULE_FILE = join(JELLY_HOME, "schedule.json");
|
|
24
|
+
// ── Cron parser (minimal subset) ──────────────────────────────────────────────
|
|
25
|
+
const SHORTHAND_MAP = {
|
|
26
|
+
"@hourly": "0 * * * *",
|
|
27
|
+
"@daily": "0 0 * * *",
|
|
28
|
+
"@every_5m": "*/5 * * * *",
|
|
29
|
+
"@every_15m": "*/15 * * * *",
|
|
30
|
+
"@every_30m": "*/30 * * * *",
|
|
31
|
+
"@every_1h": "0 * * * *",
|
|
32
|
+
"@every_6h": "0 */6 * * *",
|
|
33
|
+
"@every_12h": "0 */12 * * *",
|
|
34
|
+
};
|
|
35
|
+
function parseCron(expr) {
|
|
36
|
+
const resolved = SHORTHAND_MAP[expr] ?? expr;
|
|
37
|
+
const parts = resolved.trim().split(/\s+/);
|
|
38
|
+
if (parts.length < 5)
|
|
39
|
+
return () => false;
|
|
40
|
+
const [minuteExpr, hourExpr] = parts;
|
|
41
|
+
function matchField(expr, value) {
|
|
42
|
+
if (expr === "*")
|
|
43
|
+
return true;
|
|
44
|
+
if (expr.startsWith("*/")) {
|
|
45
|
+
const step = parseInt(expr.slice(2));
|
|
46
|
+
return !isNaN(step) && value % step === 0;
|
|
47
|
+
}
|
|
48
|
+
const num = parseInt(expr);
|
|
49
|
+
return !isNaN(num) && num === value;
|
|
50
|
+
}
|
|
51
|
+
return (now) => matchField(minuteExpr, now.getMinutes()) &&
|
|
52
|
+
matchField(hourExpr, now.getHours());
|
|
53
|
+
}
|
|
54
|
+
// ── AgentScheduler ─────────────────────────────────────────────────────────────
|
|
55
|
+
export class AgentScheduler {
|
|
56
|
+
tasks = [];
|
|
57
|
+
timer;
|
|
58
|
+
lastTickMinute = -1;
|
|
59
|
+
constructor() {
|
|
60
|
+
this.load();
|
|
61
|
+
}
|
|
62
|
+
// ── Persistence ────────────────────────────────────────────────────────────
|
|
63
|
+
load() {
|
|
64
|
+
try {
|
|
65
|
+
if (!existsSync(SCHEDULE_FILE))
|
|
66
|
+
return;
|
|
67
|
+
const raw = JSON.parse(readFileSync(SCHEDULE_FILE, "utf-8"));
|
|
68
|
+
if (Array.isArray(raw))
|
|
69
|
+
this.tasks = raw;
|
|
70
|
+
}
|
|
71
|
+
catch { /* start fresh */ }
|
|
72
|
+
}
|
|
73
|
+
save() {
|
|
74
|
+
try {
|
|
75
|
+
mkdirSync(JELLY_HOME, { recursive: true });
|
|
76
|
+
writeFileSync(SCHEDULE_FILE, JSON.stringify(this.tasks, null, 2), "utf-8");
|
|
77
|
+
}
|
|
78
|
+
catch { /* best effort */ }
|
|
79
|
+
}
|
|
80
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
81
|
+
start(onTrigger) {
|
|
82
|
+
if (this.timer)
|
|
83
|
+
return;
|
|
84
|
+
// Poll every 60 seconds — aligned to minute boundaries
|
|
85
|
+
this.timer = setInterval(() => this.tick(onTrigger), 60_000);
|
|
86
|
+
// Run once immediately on start to catch any missed tasks
|
|
87
|
+
this.tick(onTrigger);
|
|
88
|
+
}
|
|
89
|
+
stop() {
|
|
90
|
+
if (this.timer) {
|
|
91
|
+
clearInterval(this.timer);
|
|
92
|
+
this.timer = undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
tick(onTrigger) {
|
|
96
|
+
const now = new Date();
|
|
97
|
+
const minute = now.getMinutes();
|
|
98
|
+
// Prevent double-firing within the same minute
|
|
99
|
+
if (minute === this.lastTickMinute)
|
|
100
|
+
return;
|
|
101
|
+
this.lastTickMinute = minute;
|
|
102
|
+
for (const task of this.tasks) {
|
|
103
|
+
if (!task.enabled)
|
|
104
|
+
continue;
|
|
105
|
+
let shouldRun = false;
|
|
106
|
+
// One-shot: run at specific time
|
|
107
|
+
if (task.runAt && !task.lastRun) {
|
|
108
|
+
shouldRun = Date.now() >= task.runAt;
|
|
109
|
+
}
|
|
110
|
+
// Cron schedule
|
|
111
|
+
if (task.cron && !shouldRun) {
|
|
112
|
+
const matches = parseCron(task.cron);
|
|
113
|
+
shouldRun = matches(now);
|
|
114
|
+
// Prevent re-firing within same minute
|
|
115
|
+
if (shouldRun && task.lastRun && Date.now() - task.lastRun < 58_000) {
|
|
116
|
+
shouldRun = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Price trigger
|
|
120
|
+
if (task.trigger && !shouldRun) {
|
|
121
|
+
shouldRun = this.checkPriceTrigger(task.trigger);
|
|
122
|
+
}
|
|
123
|
+
if (shouldRun) {
|
|
124
|
+
task.lastRun = Date.now();
|
|
125
|
+
task.runCount = (task.runCount ?? 0) + 1;
|
|
126
|
+
if (task.runOnce)
|
|
127
|
+
task.enabled = false;
|
|
128
|
+
this.save();
|
|
129
|
+
onTrigger(task);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
checkPriceTrigger(trigger) {
|
|
134
|
+
const tick = priceFeed.get(trigger.symbol.toLowerCase());
|
|
135
|
+
if (!tick)
|
|
136
|
+
return false;
|
|
137
|
+
if (trigger.above !== undefined && tick.price >= trigger.above)
|
|
138
|
+
return true;
|
|
139
|
+
if (trigger.below !== undefined && tick.price <= trigger.below)
|
|
140
|
+
return true;
|
|
141
|
+
if (trigger.changePct !== undefined && Math.abs(tick.change24h) >= trigger.changePct)
|
|
142
|
+
return true;
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
// ── Task CRUD ──────────────────────────────────────────────────────────────
|
|
146
|
+
addTask(task) {
|
|
147
|
+
const full = {
|
|
148
|
+
...task,
|
|
149
|
+
id: randomUUID().slice(0, 8),
|
|
150
|
+
createdAt: Date.now(),
|
|
151
|
+
runCount: 0,
|
|
152
|
+
};
|
|
153
|
+
this.tasks.push(full);
|
|
154
|
+
this.save();
|
|
155
|
+
return full;
|
|
156
|
+
}
|
|
157
|
+
removeTask(id) {
|
|
158
|
+
const before = this.tasks.length;
|
|
159
|
+
this.tasks = this.tasks.filter(t => t.id !== id);
|
|
160
|
+
if (this.tasks.length < before) {
|
|
161
|
+
this.save();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
enableTask(id, enabled) {
|
|
167
|
+
const t = this.tasks.find(t => t.id === id);
|
|
168
|
+
if (!t)
|
|
169
|
+
return false;
|
|
170
|
+
t.enabled = enabled;
|
|
171
|
+
this.save();
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
listTasks() { return [...this.tasks]; }
|
|
175
|
+
getTask(id) {
|
|
176
|
+
return this.tasks.find(t => t.id === id);
|
|
177
|
+
}
|
|
178
|
+
// ── Tools ──────────────────────────────────────────────────────────────────
|
|
179
|
+
addTaskParams = Type.Object({
|
|
180
|
+
name: Type.String({ description: "Task name" }),
|
|
181
|
+
prompt: Type.String({ description: "The message the agent will run" }),
|
|
182
|
+
cron: Type.Optional(Type.String({
|
|
183
|
+
description: "Cron schedule: '*/15 * * * *' or shorthand @every_15m @hourly @daily",
|
|
184
|
+
})),
|
|
185
|
+
trigger_symbol: Type.Optional(Type.String({ description: "Symbol for price trigger e.g. BTC" })),
|
|
186
|
+
trigger_above: Type.Optional(Type.Number({ description: "Fire when price goes above this" })),
|
|
187
|
+
trigger_below: Type.Optional(Type.Number({ description: "Fire when price goes below this" })),
|
|
188
|
+
trigger_change_pct: Type.Optional(Type.Number({ description: "Fire when 24h change exceeds this %" })),
|
|
189
|
+
run_in_minutes: Type.Optional(Type.Number({ description: "One-shot: run after N minutes" })),
|
|
190
|
+
run_once: Type.Optional(Type.Boolean({ description: "Disable after first run (default: false)" })),
|
|
191
|
+
});
|
|
192
|
+
async addTaskTool(_id, params) {
|
|
193
|
+
let trigger;
|
|
194
|
+
if (params.trigger_symbol) {
|
|
195
|
+
trigger = {
|
|
196
|
+
symbol: params.trigger_symbol,
|
|
197
|
+
above: params.trigger_above,
|
|
198
|
+
below: params.trigger_below,
|
|
199
|
+
changePct: params.trigger_change_pct,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const task = this.addTask({
|
|
203
|
+
name: params.name,
|
|
204
|
+
prompt: params.prompt,
|
|
205
|
+
cron: params.cron,
|
|
206
|
+
trigger,
|
|
207
|
+
runAt: params.run_in_minutes ? Date.now() + params.run_in_minutes * 60_000 : undefined,
|
|
208
|
+
runOnce: params.run_once ?? false,
|
|
209
|
+
enabled: true,
|
|
210
|
+
});
|
|
211
|
+
const desc = [
|
|
212
|
+
params.cron ? `cron: ${params.cron}` : "",
|
|
213
|
+
trigger ? `trigger: ${params.trigger_symbol} ${trigger.above ? `>$${trigger.above}` : ""} ${trigger.below ? `<$${trigger.below}` : ""}` : "",
|
|
214
|
+
params.run_in_minutes ? `runs in ${params.run_in_minutes}m` : "",
|
|
215
|
+
].filter(Boolean).join(", ");
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: "text", text: `Scheduled: [${task.id}] ${task.name}${desc ? ` (${desc})` : ""}` }],
|
|
218
|
+
details: { taskId: task.id, task },
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
listTasksParams = Type.Object({
|
|
222
|
+
enabled_only: Type.Optional(Type.Boolean({ description: "Only show enabled tasks (default: false)" })),
|
|
223
|
+
});
|
|
224
|
+
async listTasksTool(_id, params) {
|
|
225
|
+
const tasks = params.enabled_only ? this.tasks.filter(t => t.enabled) : this.tasks;
|
|
226
|
+
if (tasks.length === 0) {
|
|
227
|
+
return { content: [{ type: "text", text: "No scheduled tasks." }], details: {} };
|
|
228
|
+
}
|
|
229
|
+
const lines = tasks.map(t => {
|
|
230
|
+
const icon = t.enabled ? "🟢" : "⚪";
|
|
231
|
+
const schedule = t.cron ?? (t.trigger ? `price trigger ${t.trigger.symbol}` : t.runAt ? `at ${new Date(t.runAt).toLocaleTimeString()}` : "manual");
|
|
232
|
+
const runs = t.runCount > 0 ? ` (${t.runCount} runs)` : "";
|
|
233
|
+
return `${icon} [${t.id}] ${t.name} — ${schedule}${runs}`;
|
|
234
|
+
});
|
|
235
|
+
return {
|
|
236
|
+
content: [{ type: "text", text: `Scheduled tasks (${tasks.length}):\n${lines.join("\n")}` }],
|
|
237
|
+
details: { count: tasks.length, tasks },
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
removeTaskParams = Type.Object({
|
|
241
|
+
id: Type.String({ description: "Task ID to remove" }),
|
|
242
|
+
});
|
|
243
|
+
async removeTaskTool(_id, params) {
|
|
244
|
+
const ok = this.removeTask(params.id);
|
|
245
|
+
return {
|
|
246
|
+
content: [{ type: "text", text: ok ? `Task ${params.id} removed.` : `Task ${params.id} not found.` }],
|
|
247
|
+
details: { taskId: params.id, success: ok },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Singleton */
|
|
252
|
+
export const agentScheduler = new AgentScheduler();
|
|
253
|
+
//# sourceMappingURL=AgentScheduler.js.map
|