@albalink/agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +91 -0
  2. package/bin/albacli +31 -0
  3. package/dist/api/ExtensionAPI.d.ts +103 -0
  4. package/dist/api/ExtensionAPI.js +15 -0
  5. package/dist/api/Registry.d.ts +54 -0
  6. package/dist/api/Registry.js +103 -0
  7. package/dist/cli.d.ts +6 -0
  8. package/dist/cli.js +230 -0
  9. package/dist/index.d.ts +41 -0
  10. package/dist/index.js +41 -0
  11. package/dist/loader.d.ts +16 -0
  12. package/dist/loader.js +89 -0
  13. package/dist/mcp/entry.d.ts +2 -0
  14. package/dist/mcp/entry.js +71 -0
  15. package/dist/mcp/server.d.ts +31 -0
  16. package/dist/mcp/server.js +128 -0
  17. package/dist/models/CostTracker.d.ts +67 -0
  18. package/dist/models/CostTracker.js +151 -0
  19. package/dist/models/ModelRegistry.d.ts +159 -0
  20. package/dist/models/ModelRegistry.js +500 -0
  21. package/dist/models/index.d.ts +5 -0
  22. package/dist/models/index.js +3 -0
  23. package/dist/runner/AgentRunner.d.ts +88 -0
  24. package/dist/runner/AgentRunner.js +473 -0
  25. package/dist/runner/ModelClient.d.ts +97 -0
  26. package/dist/runner/ModelClient.js +350 -0
  27. package/dist/runner/SwarmRouter.d.ts +64 -0
  28. package/dist/runner/SwarmRouter.js +216 -0
  29. package/dist/runner/ToolDispatcher.d.ts +29 -0
  30. package/dist/runner/ToolDispatcher.js +168 -0
  31. package/dist/scheduler/AgentScheduler.d.ts +119 -0
  32. package/dist/scheduler/AgentScheduler.js +263 -0
  33. package/dist/server-entry.d.ts +11 -0
  34. package/dist/server-entry.js +15 -0
  35. package/dist/session/ContextStore.d.ts +96 -0
  36. package/dist/session/ContextStore.js +207 -0
  37. package/dist/session/GoalManager.d.ts +101 -0
  38. package/dist/session/GoalManager.js +167 -0
  39. package/dist/session/MemoryStore.d.ts +48 -0
  40. package/dist/session/MemoryStore.js +167 -0
  41. package/dist/session/SessionManager.d.ts +64 -0
  42. package/dist/session/SessionManager.js +193 -0
  43. package/dist/telemetry/Tracer.d.ts +48 -0
  44. package/dist/telemetry/Tracer.js +102 -0
  45. package/dist/tools/MarketSentiment.d.ts +166 -0
  46. package/dist/tools/MarketSentiment.js +209 -0
  47. package/dist/tools/NewsSentiment.d.ts +67 -0
  48. package/dist/tools/NewsSentiment.js +226 -0
  49. package/dist/tools/PriceFeed.d.ts +105 -0
  50. package/dist/tools/PriceFeed.js +282 -0
  51. package/dist/tools/TechnicalAnalysis.d.ts +110 -0
  52. package/dist/tools/TechnicalAnalysis.js +357 -0
  53. package/dist/tools/index.d.ts +7 -0
  54. package/dist/tools/index.js +4 -0
  55. package/dist/tui/App.d.ts +17 -0
  56. package/dist/tui/App.js +225 -0
  57. package/dist/tui/ModelSelector.d.ts +12 -0
  58. package/dist/tui/ModelSelector.js +87 -0
  59. package/dist/tui/REPL.d.ts +24 -0
  60. package/dist/tui/REPL.js +51 -0
  61. package/dist/tui/StatusBar.d.ts +14 -0
  62. package/dist/tui/StatusBar.js +14 -0
  63. package/dist/tui/theme.d.ts +30 -0
  64. package/dist/tui/theme.js +41 -0
  65. package/dist/util/safeLog.d.ts +3 -0
  66. package/dist/util/safeLog.js +21 -0
  67. package/package.json +67 -0
@@ -0,0 +1,350 @@
1
+ /**
2
+ * ModelClient — sends messages to any OpenAI-compatible model API.
3
+ *
4
+ * Provider priority (auto-detected from env):
5
+ * OpenRouter > Anthropic compat > OpenAI > local (ollama/lm-studio)
6
+ *
7
+ * Model rotation: resolveModelChain() returns up to 5 configs — the AgentRunner
8
+ * walks the chain on 429 (rate limit) or 5xx errors, with exponential backoff
9
+ * (up to 2 retries per model) before falling through.
10
+ *
11
+ * When a ModelRegistry is available, chains are dynamically built from the
12
+ * tiered pool, with per-model performance tracking and cost estimation.
13
+ *
14
+ * All outbound, all local — no inbound ports, no server.
15
+ */
16
+ // ── Model rotation chain ──────────────────────────────────────────────────────
17
+ /**
18
+ * Build the ordered model fallback chain.
19
+ *
20
+ * If a ModelRegistry is provided, builds from the tiered pool dynamically.
21
+ * Falls back to static env-var parsing otherwise.
22
+ *
23
+ * User-configurable pool: ALBA_MODEL_1 … ALBA_MODEL_5
24
+ * If any ALBA_MODEL_N vars are set they take priority; up to 5 are used in
25
+ * order. Unset slots are filled with provider-appropriate defaults.
26
+ *
27
+ * ALBA_MODEL_N format: just the model ID string, e.g.
28
+ * ALBA_MODEL_1=anthropic/claude-opus-4-5
29
+ * ALBA_MODEL_2=openai/gpt-4o
30
+ * ALBA_MODEL_3=google/gemini-2.5-pro
31
+ */
32
+ export function resolveModelChain(modelReg) {
33
+ const env = process.env;
34
+ const tokens = parseInt(env.MAX_TOKENS ?? "8192");
35
+ const temp = parseFloat(env.TEMPERATURE ?? "0.7");
36
+ // ── Collect user-configured model IDs (ALBA_MODEL_1..5) ────────────────
37
+ const userModels = [];
38
+ for (let i = 1; i <= 5; i++) {
39
+ const m = env[`ALBA_MODEL_${i}`];
40
+ if (m?.trim())
41
+ userModels.push(m.trim());
42
+ }
43
+ // ── Use ModelRegistry dynamic pool if available ──────────────────────────
44
+ if (modelReg) {
45
+ return modelReg.buildModelChain(userModels);
46
+ }
47
+ // ── Static fallback (used when ModelRegistry cannot be initialised) ───────
48
+ // ── OpenRouter — supports all providers via a single key ─────────────────
49
+ if (env.OPENROUTER_API_KEY) {
50
+ const base = "https://openrouter.ai/api/v1";
51
+ const key = env.OPENROUTER_API_KEY;
52
+ const siteUrl = env.OPENROUTER_SITE_URL ?? "https://alba.fun";
53
+ const siteName = env.OPENROUTER_SITE_NAME ?? "ALBA";
54
+ const mk = (model) => ({
55
+ baseUrl: base, apiKey: key, model, maxTokens: tokens, temperature: temp,
56
+ siteUrl, siteName,
57
+ });
58
+ const defaults = [
59
+ env.DEFAULT_MODEL ?? "anthropic/claude-sonnet-4-5",
60
+ "anthropic/claude-3-haiku",
61
+ "openai/gpt-4o-mini",
62
+ "google/gemini-flash-1.5",
63
+ "meta-llama/llama-3-8b-instruct:free",
64
+ ];
65
+ // Merge: user-configured models first, then fill remaining slots with defaults
66
+ const merged = [...userModels, ...defaults.filter(d => !userModels.includes(d))].slice(0, 5);
67
+ return merged.map(mk);
68
+ }
69
+ // ── Anthropic direct (OpenAI-compat endpoint) ────────────────────────────
70
+ if (env.ANTHROPIC_API_KEY) {
71
+ const base = "https://api.anthropic.com/v1";
72
+ const key = env.ANTHROPIC_API_KEY;
73
+ const mk = (model) => ({
74
+ baseUrl: base, apiKey: key, model, maxTokens: tokens, temperature: temp,
75
+ });
76
+ const defaults = [
77
+ env.DEFAULT_MODEL ?? "claude-sonnet-4-5-20251101",
78
+ "claude-3-haiku-20240307",
79
+ "claude-3-5-haiku-20241022",
80
+ ];
81
+ const merged = [...userModels, ...defaults.filter(d => !userModels.includes(d))].slice(0, 5);
82
+ return merged.map(mk);
83
+ }
84
+ // ── OpenAI direct ────────────────────────────────────────────────────────
85
+ if (env.OPENAI_API_KEY) {
86
+ const base = env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
87
+ const key = env.OPENAI_API_KEY;
88
+ const mk = (model) => ({
89
+ baseUrl: base, apiKey: key, model, maxTokens: tokens, temperature: temp,
90
+ });
91
+ const defaults = [
92
+ env.DEFAULT_MODEL ?? "gpt-4o",
93
+ "gpt-4o-mini",
94
+ "gpt-3.5-turbo",
95
+ ];
96
+ const merged = [...userModels, ...defaults.filter(d => !userModels.includes(d))].slice(0, 5);
97
+ return merged.map(mk);
98
+ }
99
+ // ── Local model (ollama, lm-studio, etc.) — single entry, no rotation ───
100
+ if (env.OPENAI_BASE_URL) {
101
+ return [{
102
+ baseUrl: env.OPENAI_BASE_URL,
103
+ apiKey: env.OPENAI_API_KEY ?? "local",
104
+ model: userModels[0] ?? env.DEFAULT_MODEL ?? "llama3",
105
+ maxTokens: tokens,
106
+ temperature: temp,
107
+ }];
108
+ }
109
+ // No API key configured — return a placeholder so the TUI can show setup
110
+ return [{
111
+ baseUrl: "https://openrouter.ai/api/v1",
112
+ apiKey: "",
113
+ model: "anthropic/claude-sonnet-4-5",
114
+ maxTokens: 8192,
115
+ temperature: 0.7,
116
+ }];
117
+ }
118
+ /** Convenience: returns just the primary (first) model config */
119
+ export function resolveModelConfig(modelReg) {
120
+ return resolveModelChain(modelReg)[0];
121
+ }
122
+ // ── ModelClient ───────────────────────────────────────────────────────────────
123
+ export class ModelClient {
124
+ cfg;
125
+ modelRegistry;
126
+ constructor(cfg, modelReg) {
127
+ this.cfg = cfg;
128
+ this.modelRegistry = modelReg;
129
+ }
130
+ /**
131
+ * Stream a chat completion. Yields ChatChunk objects.
132
+ * Retries up to 2 times on 429 / 5xx with exponential backoff (1s, 2s).
133
+ * On persistent HTTP error the generator yields a single { type: "error", status, error }
134
+ * chunk and returns — the caller (AgentRunner) decides whether to rotate.
135
+ * Also reports success/failure to the ModelRegistry for tiering and cooldown.
136
+ */
137
+ async *stream(messages, tools, abortSignal) {
138
+ const t0 = Date.now();
139
+ let hadError = false;
140
+ const headers = {
141
+ "Content-Type": "application/json",
142
+ "Authorization": `Bearer ${this.cfg.apiKey}`,
143
+ "User-Agent": "ALBA/1.0",
144
+ };
145
+ if (this.cfg.siteUrl)
146
+ headers["HTTP-Referer"] = this.cfg.siteUrl;
147
+ if (this.cfg.siteName)
148
+ headers["X-Title"] = this.cfg.siteName;
149
+ // #13: Detect thinking-capable models
150
+ const THINKING_MODELS = new Set([
151
+ "anthropic/claude-opus-4.7", "anthropic/claude-opus-4.7-fast",
152
+ "anthropic/claude-opus-4.6", "anthropic/claude-opus-4.6-fast",
153
+ "anthropic/claude-opus-4.5", "anthropic/claude-opus-4",
154
+ "openai/o3", "openai/o3-pro", "openai/o3-mini",
155
+ "openai/o4", "openai/o4-mini",
156
+ ]);
157
+ const isThinkingModel = THINKING_MODELS.has(this.cfg.model) || /thinking/i.test(this.cfg.model);
158
+ const useThinking = this.cfg.thinkingEnabled && isThinkingModel;
159
+ const isOSeries = /openai\/o[34]/i.test(this.cfg.model);
160
+ const isAnthropicModel = this.cfg.model.startsWith("anthropic/") ||
161
+ this.cfg.baseUrl.includes("anthropic.com");
162
+ // Build request body
163
+ const body = {
164
+ model: this.cfg.model,
165
+ max_tokens: this.cfg.maxTokens,
166
+ stream: true,
167
+ };
168
+ // #13: Temperature handling — o-series does not support temperature
169
+ if (!isOSeries) {
170
+ body.temperature = useThinking ? 1.0 : this.cfg.temperature; // thinking requires 1.0
171
+ }
172
+ // #15: Prompt caching for Anthropic — extract system message, add cache_control
173
+ if (isAnthropicModel) {
174
+ const sysMsg = messages.find(m => m.role === "system");
175
+ const rest = messages.filter(m => m.role !== "system");
176
+ if (sysMsg && typeof sysMsg.content === "string" && sysMsg.content.length > 512) {
177
+ // Cache the system prompt (saves up to 90% on repeated calls)
178
+ body.system = [{
179
+ type: "text",
180
+ text: sysMsg.content,
181
+ cache_control: { type: "ephemeral" },
182
+ }];
183
+ body.messages = rest;
184
+ }
185
+ else {
186
+ body.messages = messages;
187
+ }
188
+ // #13: Extended thinking for Claude Opus 4.x
189
+ if (useThinking) {
190
+ body.thinking = { type: "enabled", budget_tokens: this.cfg.thinkingBudget ?? 8000 };
191
+ headers["anthropic-beta"] = "thinking-v1";
192
+ }
193
+ }
194
+ else {
195
+ body.messages = messages;
196
+ }
197
+ // #13: o-series reasoning effort
198
+ if (isOSeries && useThinking) {
199
+ body.reasoning_effort = "high";
200
+ }
201
+ if (tools && tools.length > 0) {
202
+ // strict: true enforces valid JSON on GPT-4o+ and GPT-5.x
203
+ // Skip strict mode for o-series (not supported) and thinking models
204
+ body.tools = tools.map(t => ({
205
+ ...t,
206
+ function: isOSeries ? t.function : { ...t.function, strict: true },
207
+ }));
208
+ body.tool_choice = "auto";
209
+ // Disable parallel tool calls — prevents race conditions in tool_call_id map
210
+ body.parallel_tool_calls = false;
211
+ }
212
+ const MAX_RETRIES = 2;
213
+ const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);
214
+ let res;
215
+ let lastError = "";
216
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
217
+ try {
218
+ // #25: Combine user abort signal with 120s timeout
219
+ const timeoutSignal = AbortSignal.timeout(120_000);
220
+ const combinedSignal = abortSignal
221
+ ? AbortSignal.any([abortSignal, timeoutSignal])
222
+ : timeoutSignal;
223
+ res = await fetch(`${this.cfg.baseUrl}/chat/completions`, {
224
+ method: "POST",
225
+ headers,
226
+ body: JSON.stringify(body),
227
+ signal: combinedSignal,
228
+ });
229
+ }
230
+ catch (e) {
231
+ if (e?.name === "AbortError") {
232
+ yield { type: "done", finish_reason: "aborted" };
233
+ return;
234
+ }
235
+ hadError = true;
236
+ lastError = `Network error: ${e.message}`;
237
+ if (attempt < MAX_RETRIES) {
238
+ await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
239
+ continue;
240
+ }
241
+ this.modelRegistry?.recordFailure(this.cfg.model);
242
+ yield { type: "error", error: lastError, status: 0 };
243
+ return;
244
+ }
245
+ if (!res.ok && RETRY_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
246
+ hadError = true;
247
+ lastError = await res.text().catch(() => res.statusText);
248
+ await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
249
+ continue;
250
+ }
251
+ if (!res.ok) {
252
+ hadError = true;
253
+ const err = await res.text().catch(() => res.statusText);
254
+ // 404 → model removed, mark permanently deprecated
255
+ if (res.status === 404)
256
+ this.modelRegistry?.markDeprecated(this.cfg.model);
257
+ else
258
+ this.modelRegistry?.recordFailure(this.cfg.model);
259
+ yield { type: "error", error: `Model API ${res.status}: ${err}`, status: res.status };
260
+ return;
261
+ }
262
+ break; // success — got an ok response
263
+ }
264
+ // Accumulate tool calls across chunks (they arrive fragmented)
265
+ const toolCallMap = new Map();
266
+ const reader = res.body.getReader();
267
+ const decoder = new TextDecoder();
268
+ let buf = "";
269
+ while (true) {
270
+ const { done, value } = await reader.read();
271
+ if (done)
272
+ break;
273
+ buf += decoder.decode(value, { stream: true });
274
+ const lines = buf.split("\n");
275
+ buf = lines.pop() ?? "";
276
+ for (const line of lines) {
277
+ const trimmed = line.trim();
278
+ if (!trimmed || trimmed === "data: [DONE]")
279
+ continue;
280
+ if (!trimmed.startsWith("data: "))
281
+ continue;
282
+ let chunk;
283
+ try {
284
+ chunk = JSON.parse(trimmed.slice(6));
285
+ }
286
+ catch {
287
+ continue;
288
+ }
289
+ const delta = chunk.choices?.[0]?.delta;
290
+ if (!delta)
291
+ continue;
292
+ if (delta.content) {
293
+ yield { type: "delta", text: delta.content };
294
+ }
295
+ if (delta.tool_calls) {
296
+ for (const tc of delta.tool_calls) {
297
+ const idx = tc.index ?? 0;
298
+ const existing = toolCallMap.get(idx) ?? { id: "", name: "", args: "" };
299
+ if (tc.id)
300
+ existing.id += tc.id;
301
+ if (tc.function?.name)
302
+ existing.name += tc.function.name;
303
+ if (tc.function?.arguments)
304
+ existing.args += tc.function.arguments;
305
+ toolCallMap.set(idx, existing);
306
+ }
307
+ }
308
+ const finish = chunk.choices?.[0]?.finish_reason;
309
+ // Capture usage from final chunk (OpenAI/OpenRouter send this on finish)
310
+ if (chunk.usage) {
311
+ yield {
312
+ type: "done",
313
+ finish_reason: finish ?? "usage",
314
+ usage: {
315
+ prompt_tokens: chunk.usage.prompt_tokens ?? 0,
316
+ completion_tokens: chunk.usage.completion_tokens ?? 0,
317
+ },
318
+ };
319
+ }
320
+ if (finish === "tool_calls" || finish === "stop") {
321
+ if (toolCallMap.size > 0) {
322
+ const tool_calls = [...toolCallMap.values()].map(tc => ({
323
+ id: tc.id || `call_${Date.now()}`,
324
+ type: "function",
325
+ function: { name: tc.name, arguments: tc.args },
326
+ }));
327
+ yield { type: "tool_call", tool_calls };
328
+ toolCallMap.clear();
329
+ }
330
+ if (!chunk.usage)
331
+ yield { type: "done", finish_reason: finish };
332
+ }
333
+ }
334
+ }
335
+ // Flush any remaining tool calls if stream ended without finish_reason
336
+ if (toolCallMap.size > 0) {
337
+ const tool_calls = [...toolCallMap.values()].map(tc => ({
338
+ id: tc.id || `call_${Date.now()}`,
339
+ type: "function",
340
+ function: { name: tc.name, arguments: tc.args },
341
+ }));
342
+ yield { type: "tool_call", tool_calls };
343
+ }
344
+ yield { type: "done", finish_reason: "end" };
345
+ // Report success to model registry
346
+ if (!hadError)
347
+ this.modelRegistry?.recordSuccess(this.cfg.model, Date.now() - t0);
348
+ }
349
+ }
350
+ //# sourceMappingURL=ModelClient.js.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * SwarmRouter — complexity scoring and task decomposition.
3
+ *
4
+ * For complex prompts (score >= threshold) the runner decomposes the request
5
+ * into 2–5 sub-tasks, executes them with a capped worker pool, then feeds all
6
+ * results to a reviewer model that synthesises a final answer.
7
+ *
8
+ * Sub-task execution is sequential inside each worker slot to avoid hammering
9
+ * the provider; concurrency is capped at Math.min(maxAgents, os.cpus().length).
10
+ */
11
+ import type { ModelRegistry } from "../models/ModelRegistry.js";
12
+ import type { ContextStore } from "../session/ContextStore.js";
13
+ export interface SwarmConfig {
14
+ /** Maximum parallel workers (hard cap: 5). Default: min(cpuCount, 3). */
15
+ maxAgents?: number;
16
+ /** Complexity score threshold above which swarm activates. Default: 40. */
17
+ complexityThreshold?: number;
18
+ /** Use LLM planner for task decomposition (false = heuristic only for eco/normal). */
19
+ useLLM?: boolean;
20
+ }
21
+ export interface SubTaskResult {
22
+ task: string;
23
+ result: string;
24
+ model: string;
25
+ ms: number;
26
+ error?: string;
27
+ }
28
+ /**
29
+ * Returns a score 0–100 reflecting prompt complexity.
30
+ * Tuned so "check ETH price" ≈ 10, "analyze ETH and BTC then predict" ≈ 55.
31
+ */
32
+ export declare function scoreComplexity(prompt: string): number;
33
+ /** Original heuristic decomposer — used as fallback when LLM planner fails */
34
+ export declare function decomposeHeuristic(prompt: string, maxTasks: number): string[];
35
+ /** Exported for tests — heuristic only, no model call */
36
+ export declare const decompose: typeof decomposeHeuristic;
37
+ export declare class SwarmRouter {
38
+ private maxAgents;
39
+ private complexityThreshold;
40
+ private useLLM;
41
+ private modelRegistry?;
42
+ constructor(cfg?: SwarmConfig, modelReg?: ModelRegistry);
43
+ /** True when the prompt is complex enough to warrant swarm execution. */
44
+ shouldSwarm(prompt: string): boolean;
45
+ /**
46
+ * Decompose prompt into sub-tasks, execute them in groups of 3 (up to 5
47
+ * agents total), then synthesise with a reviewer model.
48
+ *
49
+ * Groups-of-3 planner:
50
+ * tasks = decompose(prompt, maxAgents) → 2–5 sub-tasks
51
+ * batches = chunk(tasks, 3) → [[t1,t2,t3],[t4,t5]] for 5 tasks
52
+ * each batch runs in parallel; batches run sequentially
53
+ * reviewer model synthesises all results into a unified answer
54
+ *
55
+ * @param prompt - Original user message
56
+ * @param systemPrompt - Current system prompt (passed to each sub-agent + reviewer)
57
+ * @param onProgress - Called as each sub-task completes
58
+ */
59
+ run(prompt: string, systemPrompt: string, onProgress: (result: SubTaskResult, remaining: number) => void, contextStore?: ContextStore): Promise<{
60
+ synthesis: string;
61
+ subResults: SubTaskResult[];
62
+ }>;
63
+ }
64
+ //# sourceMappingURL=SwarmRouter.d.ts.map
@@ -0,0 +1,216 @@
1
+ /**
2
+ * SwarmRouter — complexity scoring and task decomposition.
3
+ *
4
+ * For complex prompts (score >= threshold) the runner decomposes the request
5
+ * into 2–5 sub-tasks, executes them with a capped worker pool, then feeds all
6
+ * results to a reviewer model that synthesises a final answer.
7
+ *
8
+ * Sub-task execution is sequential inside each worker slot to avoid hammering
9
+ * the provider; concurrency is capped at Math.min(maxAgents, os.cpus().length).
10
+ */
11
+ import os from "os";
12
+ import { ModelClient, resolveModelChain } from "./ModelClient.js";
13
+ // ── Complexity scoring ───────────────────────────────────────────────────────
14
+ const CONJUNCTION_RE = /\b(and|also|then|additionally|plus|as well as|furthermore)\b/gi;
15
+ const MULTI_CHAIN_RE = /\b(eth|btc|sol|bnb|avax|matic|cosmos|atom|arb|op)\b/gi;
16
+ const ACTION_VERB_RE = /\b(analyze|compare|predict|scan|check|simulate|estimate|backtest|evaluate|assess)\b/gi;
17
+ const QUESTION_RE = /\?/g;
18
+ /**
19
+ * Returns a score 0–100 reflecting prompt complexity.
20
+ * Tuned so "check ETH price" ≈ 10, "analyze ETH and BTC then predict" ≈ 55.
21
+ */
22
+ export function scoreComplexity(prompt) {
23
+ const conjunctions = (prompt.match(CONJUNCTION_RE) ?? []).length;
24
+ const chains = new Set((prompt.match(MULTI_CHAIN_RE) ?? []).map(s => s.toLowerCase())).size;
25
+ const actions = (prompt.match(ACTION_VERB_RE) ?? []).length;
26
+ const questions = (prompt.match(QUESTION_RE) ?? []).length;
27
+ const wordCount = prompt.trim().split(/\s+/).length;
28
+ return Math.min(100, conjunctions * 12 +
29
+ chains * 8 +
30
+ actions * 10 +
31
+ questions * 5 +
32
+ Math.floor(wordCount / 8));
33
+ }
34
+ // ── Task decomposition (# 29: LLM planner with heuristic fallback) ───────────
35
+ /**
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
+ */
39
+ async function planSubtasks(prompt, maxTasks, modelReg, useLLM = true) {
40
+ const cap = Math.max(2, Math.min(maxTasks, 5));
41
+ // Heuristic-only path when useLLM is false (eco/normal modes — saves model calls)
42
+ if (!useLLM) {
43
+ return decomposeHeuristic(prompt, cap);
44
+ }
45
+ // Attempt LLM decomposition with a cheap/fast model
46
+ try {
47
+ const chain = resolveModelChain(modelReg);
48
+ // Prefer a worker-tier model for planning (fast + cheap)
49
+ const plannerCfg = chain.find(c => modelReg?.getTier(c.model) === "worker") ?? chain[chain.length - 1] ?? chain[0];
50
+ const client = new ModelClient({ ...plannerCfg, temperature: 0.2 }, modelReg);
51
+ const plannerPrompt = `Split the following request into exactly ${cap} focused, non-overlapping sub-tasks.\n` +
52
+ `Each sub-task must be independently answerable using data tools.\n` +
53
+ `Output ONLY a valid JSON array of strings. No explanation, no markdown.\n\n` +
54
+ `Request: ${prompt}`;
55
+ let output = "";
56
+ for await (const chunk of client.stream([
57
+ { role: "system", content: "You output only valid JSON arrays of strings. No markdown, no explanation." },
58
+ { role: "user", content: plannerPrompt },
59
+ ], [])) {
60
+ if (chunk.type === "delta" && chunk.text)
61
+ output += chunk.text;
62
+ if (chunk.type === "error")
63
+ throw new Error(chunk.error);
64
+ }
65
+ // Extract JSON array from output (model might wrap in markdown)
66
+ const jsonMatch = output.match(/\[\s*"[\s\S]*?"\s*(?:,\s*"[\s\S]*?"\s*)*\]/);
67
+ if (jsonMatch) {
68
+ const tasks = JSON.parse(jsonMatch[0]);
69
+ if (Array.isArray(tasks) && tasks.every((t) => typeof t === "string") && tasks.length >= 2) {
70
+ return tasks.slice(0, cap);
71
+ }
72
+ }
73
+ }
74
+ catch {
75
+ // Fall through to heuristic decomposition
76
+ }
77
+ return decomposeHeuristic(prompt, cap);
78
+ }
79
+ /** Original heuristic decomposer — used as fallback when LLM planner fails */
80
+ export function decomposeHeuristic(prompt, maxTasks) {
81
+ const cap = Math.max(2, Math.min(maxTasks, 5));
82
+ const parts = prompt
83
+ .split(/,\s*| and | also | then | additionally | plus /i)
84
+ .map(s => s.trim())
85
+ .filter(s => s.length > 4);
86
+ if (parts.length >= 2)
87
+ return parts.slice(0, cap);
88
+ const verbMatches = [...prompt.matchAll(/\b(analyze|compare|predict|scan|check|estimate|evaluate)\b[^,.?]*/gi)];
89
+ if (verbMatches.length >= 2)
90
+ return verbMatches.slice(0, cap).map(m => m[0].trim());
91
+ return [prompt];
92
+ }
93
+ /** Exported for tests — heuristic only, no model call */
94
+ export const decompose = decomposeHeuristic;
95
+ // ── Reviewer synthesis (#39: compact refs via ContextStore) ─────────────────
96
+ async function reviewerSynthesize(originalPrompt, allResults, systemPrompt, modelReg, contextRef) {
97
+ const chain = resolveModelChain(modelReg);
98
+ const cfg = chain[0];
99
+ const client = new ModelClient(cfg, modelReg);
100
+ const results = allResults.filter(r => !r.error);
101
+ // #39: If ContextStore holds the full results, send compact summaries + reference
102
+ const context = contextRef
103
+ ? results.map((r, i) => `Sub-task ${i + 1} (${r.task.slice(0, 50)}): ${r.result.slice(0, 300)}...`).join("\n") + `\n\n${contextRef}`
104
+ : results
105
+ .map((r, i) => `### Sub-task ${i + 1}: ${r.task}\n${r.result}`)
106
+ .join("\n\n");
107
+ const messages = [
108
+ { role: "system", content: systemPrompt },
109
+ {
110
+ role: "user",
111
+ content: `You are a synthesis reviewer. Sub-tasks were executed for the following request.\n\n` +
112
+ `**Original request:** ${originalPrompt}\n\n` +
113
+ `**Sub-task results:**\n${context}\n\n` +
114
+ `Write a concise, unified answer that directly addresses the original request.`,
115
+ },
116
+ ];
117
+ let out = "";
118
+ for await (const chunk of client.stream(messages, [])) {
119
+ if (chunk.type === "delta" && chunk.text)
120
+ out += chunk.text;
121
+ }
122
+ return out || results.map(r => r.result).join("\n\n---\n\n");
123
+ }
124
+ // ── SwarmRouter class ────────────────────────────────────────────────────────
125
+ export class SwarmRouter {
126
+ maxAgents;
127
+ complexityThreshold;
128
+ useLLM;
129
+ modelRegistry;
130
+ constructor(cfg = {}, modelReg) {
131
+ const cpus = os.cpus().length;
132
+ this.maxAgents = Math.min(cfg.maxAgents ?? Math.min(cpus, 3), 5);
133
+ this.complexityThreshold = cfg.complexityThreshold ?? 40;
134
+ this.useLLM = cfg.useLLM ?? true;
135
+ this.modelRegistry = modelReg;
136
+ }
137
+ /** True when the prompt is complex enough to warrant swarm execution. */
138
+ shouldSwarm(prompt) {
139
+ return scoreComplexity(prompt) >= this.complexityThreshold;
140
+ }
141
+ /**
142
+ * Decompose prompt into sub-tasks, execute them in groups of 3 (up to 5
143
+ * agents total), then synthesise with a reviewer model.
144
+ *
145
+ * Groups-of-3 planner:
146
+ * tasks = decompose(prompt, maxAgents) → 2–5 sub-tasks
147
+ * batches = chunk(tasks, 3) → [[t1,t2,t3],[t4,t5]] for 5 tasks
148
+ * each batch runs in parallel; batches run sequentially
149
+ * reviewer model synthesises all results into a unified answer
150
+ *
151
+ * @param prompt - Original user message
152
+ * @param systemPrompt - Current system prompt (passed to each sub-agent + reviewer)
153
+ * @param onProgress - Called as each sub-task completes
154
+ */
155
+ async run(prompt, systemPrompt, onProgress, contextStore) {
156
+ // #29: Use LLM planner for task decomposition (falls back to heuristic)
157
+ const tasks = await planSubtasks(prompt, this.maxAgents, this.modelRegistry, this.useLLM);
158
+ const chain = resolveModelChain(this.modelRegistry);
159
+ const subResults = [];
160
+ // #39: Open a task context folder to offload sub-results (saves context window)
161
+ const taskCtx = contextStore?.openTask(`Swarm: ${prompt.slice(0, 60)}`);
162
+ // Split tasks into groups of 3 (the required "groups-of-3" planner)
163
+ const GROUP_SIZE = 3;
164
+ const batches = [];
165
+ for (let i = 0; i < tasks.length; i += GROUP_SIZE) {
166
+ batches.push(tasks.slice(i, i + GROUP_SIZE));
167
+ }
168
+ let modelIdx = 1; // reserve chain[0] for reviewer
169
+ const runOne = async (task, mIdx, remaining) => {
170
+ const cfg = chain[mIdx % chain.length] ?? chain[0];
171
+ const client = new ModelClient(cfg, this.modelRegistry);
172
+ const msgs = [
173
+ { role: "system", content: systemPrompt },
174
+ { role: "user", content: task },
175
+ ];
176
+ const t0 = Date.now();
177
+ let out = "";
178
+ let error;
179
+ for await (const chunk of client.stream(msgs, [])) {
180
+ if (chunk.type === "delta" && chunk.text)
181
+ out += chunk.text;
182
+ if (chunk.type === "error")
183
+ error = chunk.error ?? "Sub-task model error";
184
+ }
185
+ const r = {
186
+ task,
187
+ result: out || (error ? `(error: ${error})` : "(no output)"),
188
+ model: cfg.model,
189
+ ms: Date.now() - t0,
190
+ error,
191
+ };
192
+ subResults.push(r);
193
+ // #39: Write sub-result to context file instead of keeping raw in memory
194
+ if (taskCtx && contextStore) {
195
+ contextStore.appendFinding(taskCtx.taskId, `Sub-task: ${task.slice(0, 50)}`, r.result);
196
+ }
197
+ onProgress(r, remaining);
198
+ };
199
+ // Execute batches sequentially; within each batch run up to 3 in parallel
200
+ let remaining = tasks.length;
201
+ for (const batch of batches) {
202
+ await Promise.all(batch.map(task => {
203
+ remaining--;
204
+ return runOne(task, modelIdx++, remaining);
205
+ }));
206
+ }
207
+ // #39: Pass context reference to reviewer (compact path vs raw dump)
208
+ const contextRef = taskCtx ? contextStore?.getReference(taskCtx.taskId) : undefined;
209
+ const synthesis = await reviewerSynthesize(prompt, subResults, systemPrompt, this.modelRegistry, contextRef);
210
+ // Close the context folder (auto-deletes in 5s)
211
+ if (taskCtx)
212
+ contextStore?.closeTask(taskCtx.taskId);
213
+ return { synthesis, subResults };
214
+ }
215
+ }
216
+ //# sourceMappingURL=SwarmRouter.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * ToolDispatcher — executes tool calls from the model response.
3
+ * Looks up tool by name in the Registry, validates params, runs execute().
4
+ */
5
+ import type { Registry } from "../api/Registry.js";
6
+ import type { ToolCall } from "./ModelClient.js";
7
+ export interface ToolResult {
8
+ tool_call_id: string;
9
+ name: string;
10
+ content: string;
11
+ isError: boolean;
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;
19
+ export declare class ToolDispatcher {
20
+ private registry;
21
+ private failureCounts;
22
+ private openCircuits;
23
+ constructor(registry: Registry);
24
+ dispatch(calls: ToolCall[]): Promise<ToolResult[]>;
25
+ private execute;
26
+ private executeWithTimeout;
27
+ private executeInner;
28
+ }
29
+ //# sourceMappingURL=ToolDispatcher.d.ts.map