@jellyos/agent 0.1.4 → 0.1.5

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 (94) hide show
  1. package/README.npm.md +212 -0
  2. package/bin/jellyos-mcp +26 -0
  3. package/dist/api/ExtensionAPI.d.ts +6 -0
  4. package/dist/cli.js +114 -48
  5. package/dist/index.d.ts +15 -2
  6. package/dist/index.js +13 -3
  7. package/dist/mcp/entry.d.ts +2 -0
  8. package/dist/mcp/entry.js +71 -0
  9. package/dist/mcp/server.d.ts +31 -0
  10. package/dist/mcp/server.js +128 -0
  11. package/dist/models/ModelRegistry.d.ts +12 -1
  12. package/dist/models/ModelRegistry.js +105 -9
  13. package/dist/runner/AgentRunner.d.ts +19 -2
  14. package/dist/runner/AgentRunner.js +247 -17
  15. package/dist/runner/ModelClient.d.ts +10 -1
  16. package/dist/runner/ModelClient.js +79 -6
  17. package/dist/runner/SwarmRouter.d.ts +6 -6
  18. package/dist/runner/SwarmRouter.js +73 -24
  19. package/dist/runner/ToolDispatcher.d.ts +10 -0
  20. package/dist/runner/ToolDispatcher.js +106 -2
  21. package/dist/scheduler/AgentScheduler.d.ts +118 -0
  22. package/dist/scheduler/AgentScheduler.js +253 -0
  23. package/dist/session/ContextStore.d.ts +96 -0
  24. package/dist/session/ContextStore.js +207 -0
  25. package/dist/session/GoalManager.d.ts +101 -0
  26. package/dist/session/GoalManager.js +167 -0
  27. package/dist/session/MemoryStore.d.ts +48 -0
  28. package/dist/session/MemoryStore.js +166 -0
  29. package/dist/session/SessionManager.d.ts +45 -4
  30. package/dist/session/SessionManager.js +151 -8
  31. package/dist/telemetry/Tracer.d.ts +48 -0
  32. package/dist/telemetry/Tracer.js +102 -0
  33. package/dist/tests/ContextStore.test.d.ts +2 -0
  34. package/dist/tests/ContextStore.test.js +74 -0
  35. package/dist/tests/ModelRegistry.test.d.ts +2 -0
  36. package/dist/tests/ModelRegistry.test.js +69 -0
  37. package/dist/tests/SessionManager.test.d.ts +2 -0
  38. package/dist/tests/SessionManager.test.js +108 -0
  39. package/dist/tests/TechnicalAnalysis.test.d.ts +2 -0
  40. package/dist/tests/TechnicalAnalysis.test.js +109 -0
  41. package/dist/tools/MarketSentiment.d.ts +166 -0
  42. package/dist/tools/MarketSentiment.js +209 -0
  43. package/dist/tools/NewsSentiment.js +40 -13
  44. package/dist/tools/PriceFeed.d.ts +2 -0
  45. package/dist/tools/PriceFeed.js +79 -27
  46. package/dist/tools/TechnicalAnalysis.d.ts +37 -0
  47. package/dist/tools/TechnicalAnalysis.js +85 -0
  48. package/dist/tui/App.d.ts +2 -2
  49. package/dist/tui/App.js +280 -117
  50. package/dist/tui/REPL.d.ts +2 -1
  51. package/dist/tui/REPL.js +11 -6
  52. package/package.json +9 -4
  53. package/dist/api/ExtensionAPI.d.ts.map +0 -1
  54. package/dist/api/ExtensionAPI.js.map +0 -1
  55. package/dist/api/Registry.d.ts.map +0 -1
  56. package/dist/api/Registry.js.map +0 -1
  57. package/dist/cli.d.ts.map +0 -1
  58. package/dist/cli.js.map +0 -1
  59. package/dist/index.d.ts.map +0 -1
  60. package/dist/index.js.map +0 -1
  61. package/dist/loader.d.ts.map +0 -1
  62. package/dist/loader.js.map +0 -1
  63. package/dist/models/CostTracker.d.ts.map +0 -1
  64. package/dist/models/CostTracker.js.map +0 -1
  65. package/dist/models/ModelRegistry.d.ts.map +0 -1
  66. package/dist/models/ModelRegistry.js.map +0 -1
  67. package/dist/models/index.d.ts.map +0 -1
  68. package/dist/models/index.js.map +0 -1
  69. package/dist/runner/AgentRunner.d.ts.map +0 -1
  70. package/dist/runner/AgentRunner.js.map +0 -1
  71. package/dist/runner/ModelClient.d.ts.map +0 -1
  72. package/dist/runner/ModelClient.js.map +0 -1
  73. package/dist/runner/SwarmRouter.d.ts.map +0 -1
  74. package/dist/runner/SwarmRouter.js.map +0 -1
  75. package/dist/runner/ToolDispatcher.d.ts.map +0 -1
  76. package/dist/runner/ToolDispatcher.js.map +0 -1
  77. package/dist/session/SessionManager.d.ts.map +0 -1
  78. package/dist/session/SessionManager.js.map +0 -1
  79. package/dist/tools/NewsSentiment.d.ts.map +0 -1
  80. package/dist/tools/NewsSentiment.js.map +0 -1
  81. package/dist/tools/PriceFeed.d.ts.map +0 -1
  82. package/dist/tools/PriceFeed.js.map +0 -1
  83. package/dist/tools/TechnicalAnalysis.d.ts.map +0 -1
  84. package/dist/tools/TechnicalAnalysis.js.map +0 -1
  85. package/dist/tools/index.d.ts.map +0 -1
  86. package/dist/tools/index.js.map +0 -1
  87. package/dist/tui/App.d.ts.map +0 -1
  88. package/dist/tui/App.js.map +0 -1
  89. package/dist/tui/REPL.d.ts.map +0 -1
  90. package/dist/tui/REPL.js.map +0 -1
  91. package/dist/tui/StatusBar.d.ts.map +0 -1
  92. package/dist/tui/StatusBar.js.map +0 -1
  93. package/dist/tui/theme.d.ts.map +0 -1
  94. 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
- * Splits a complex prompt into 2–5 focused sub-task strings.
37
- * Uses simple heuristics so no extra model call is needed.
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
- export function decompose(prompt, maxTasks) {
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
- // ── Reviewer synthesis ───────────────────────────────────────────────────────
58
- async function reviewerSynthesize(originalPrompt, allResults, systemPrompt, modelReg) {
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
- const context = results
65
- .map((r, i) => `### Sub-task ${i + 1}: ${r.task}\n${r.result}`)
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. The following sub-tasks were run in response to the user's original request.\n\n` +
72
- `**Original request:** ${originalPrompt}\n\n${context}\n\n` +
73
- `Write a concise, unified answer that directly addresses the original request using all the above findings.`,
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
- const tasks = decompose(prompt, this.maxAgents);
114
- const chain = resolveModelChain();
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
- const synthesis = await reviewerSynthesize(prompt, subResults, systemPrompt, this.modelRegistry);
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
- params = JSON.parse(tc.function.arguments || "{}");
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