wispy-cli 2.7.7 → 2.7.9

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.
@@ -0,0 +1,395 @@
1
+ /**
2
+ * core/task-router.mjs — Smart Model Router for Wispy
3
+ *
4
+ * Decides WHICH model handles WHICH task based on task classification,
5
+ * model capabilities, cost/speed tradeoffs, and available providers.
6
+ */
7
+
8
+ import { PROVIDERS, detectProvider } from "./config.mjs";
9
+
10
+ // ── Model capability registry ────────────────────────────────────────────────
11
+
12
+ export const MODEL_CAPABILITIES = {
13
+ // Coding specialists
14
+ "gpt-5.4": {
15
+ strengths: ["coding", "debugging", "refactoring"],
16
+ speed: "medium",
17
+ cost: "high",
18
+ contextWindow: 128000,
19
+ provider: "openai",
20
+ },
21
+ "gpt-4o": {
22
+ strengths: ["coding", "analysis", "general"],
23
+ speed: "fast",
24
+ cost: "medium",
25
+ contextWindow: 128000,
26
+ provider: "openai",
27
+ },
28
+ "gpt-4o-mini": {
29
+ strengths: ["coding", "summarization"],
30
+ speed: "very-fast",
31
+ cost: "low",
32
+ contextWindow: 128000,
33
+ provider: "openai",
34
+ },
35
+ "o3-mini": {
36
+ strengths: ["reasoning", "math", "coding"],
37
+ speed: "slow",
38
+ cost: "medium",
39
+ contextWindow: 128000,
40
+ provider: "openai",
41
+ },
42
+
43
+ // Claude family
44
+ "claude-opus-4-20250514": {
45
+ strengths: ["architecture", "reasoning", "writing", "analysis", "design"],
46
+ speed: "slow",
47
+ cost: "very-high",
48
+ contextWindow: 200000,
49
+ provider: "anthropic",
50
+ },
51
+ "claude-sonnet-4-20250514": {
52
+ strengths: ["coding", "analysis", "review"],
53
+ speed: "medium",
54
+ cost: "medium",
55
+ contextWindow: 200000,
56
+ provider: "anthropic",
57
+ },
58
+ "claude-3-5-haiku-20241022": {
59
+ strengths: ["summarization", "formatting", "quick-tasks"],
60
+ speed: "very-fast",
61
+ cost: "low",
62
+ contextWindow: 200000,
63
+ provider: "anthropic",
64
+ },
65
+
66
+ // Gemini family
67
+ "gemini-2.5-pro": {
68
+ strengths: ["research", "analysis", "long-context", "planning"],
69
+ speed: "medium",
70
+ cost: "medium",
71
+ contextWindow: 1000000,
72
+ provider: "google",
73
+ },
74
+ "gemini-2.5-flash": {
75
+ strengths: ["summarization", "quick-tasks", "formatting"],
76
+ speed: "very-fast",
77
+ cost: "very-low",
78
+ contextWindow: 1000000,
79
+ provider: "google",
80
+ },
81
+
82
+ // Speed / free tier
83
+ "llama-3.3-70b-versatile": {
84
+ strengths: ["general", "summarization"],
85
+ speed: "very-fast",
86
+ cost: "free",
87
+ contextWindow: 32768,
88
+ provider: "groq",
89
+ },
90
+ "deepseek-chat": {
91
+ strengths: ["coding", "reasoning"],
92
+ speed: "fast",
93
+ cost: "very-low",
94
+ contextWindow: 64000,
95
+ provider: "deepseek",
96
+ },
97
+ "deepseek-reasoner": {
98
+ strengths: ["reasoning", "math", "analysis"],
99
+ speed: "slow",
100
+ cost: "low",
101
+ contextWindow: 64000,
102
+ provider: "deepseek",
103
+ },
104
+ };
105
+
106
+ // ── Provider → model map (for detecting available models) ────────────────────
107
+
108
+ const PROVIDER_ENV_KEYS = {
109
+ openai: ["OPENAI_API_KEY"],
110
+ anthropic: ["ANTHROPIC_API_KEY"],
111
+ google: ["GOOGLE_AI_KEY", "GOOGLE_GENERATIVE_AI_KEY", "GEMINI_API_KEY"],
112
+ groq: ["GROQ_API_KEY"],
113
+ deepseek: ["DEEPSEEK_API_KEY"],
114
+ };
115
+
116
+ // ── Keyword→task type maps ────────────────────────────────────────────────────
117
+
118
+ const TYPE_KEYWORDS = {
119
+ coding: [
120
+ "code", "function", "implement", "bug", "fix", "debug", "refactor", "write a", "create a",
121
+ "class", "method", "variable", "typescript", "javascript", "python", "rust", "go", "java",
122
+ ".ts", ".js", ".py", ".rs", ".go", ".java", ".cpp", ".c", "```", "error:", "syntax",
123
+ "compile", "build", "test", "unit test", "integration test",
124
+ ],
125
+ research: [
126
+ "research", "find", "search", "look up", "what is", "explain", "describe", "tell me about",
127
+ "history of", "why does", "how does", "what are", "latest", "news", "papers", "sources",
128
+ ],
129
+ analysis: [
130
+ "analyze", "analysis", "evaluate", "compare", "contrast", "pros and cons", "review",
131
+ "assess", "benchmark", "performance", "metrics", "statistics", "data", "trend",
132
+ ],
133
+ design: [
134
+ "design", "architecture", "system design", "schema", "diagram", "structure", "layout",
135
+ "plan", "blueprint", "mockup", "wireframe",
136
+ ],
137
+ review: [
138
+ "review", "check", "verify", "validate", "audit", "security", "issues", "problems",
139
+ "vulnerabilities", "code review", "pull request", "pr review",
140
+ ],
141
+ summarize: [
142
+ "summarize", "summary", "tldr", "tl;dr", "recap", "brief", "overview", "key points",
143
+ "shorten", "condense",
144
+ ],
145
+ format: [
146
+ "format", "reformat", "prettify", "lint", "clean up", "style", "markdown", "json",
147
+ "yaml", "csv", "table", "list",
148
+ ],
149
+ };
150
+
151
+ const COMPLEXITY_THRESHOLDS = {
152
+ simple: 150, // < 150 chars → simple
153
+ medium: 600, // < 600 chars → medium
154
+ // above → complex
155
+ };
156
+
157
+ // ── Task classification ──────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Classify a task into type, complexity, estimated tokens, and parallelizability.
161
+ *
162
+ * @param {string} taskText
163
+ * @returns {{ type: string, complexity: string, estimatedTokens: number, parallelizable: boolean }}
164
+ */
165
+ export function classifyTask(taskText) {
166
+ const lower = taskText.toLowerCase();
167
+
168
+ // Determine type via keyword matching
169
+ let bestType = "general";
170
+ let bestScore = 0;
171
+
172
+ for (const [type, keywords] of Object.entries(TYPE_KEYWORDS)) {
173
+ let score = 0;
174
+ for (const kw of keywords) {
175
+ if (lower.includes(kw)) score++;
176
+ }
177
+ if (score > bestScore) {
178
+ bestScore = score;
179
+ bestType = type;
180
+ }
181
+ }
182
+
183
+ // Heuristic overrides
184
+ const hasCodeBlock = taskText.includes("```");
185
+ const hasFileExtension = /\.(ts|js|py|rs|go|java|cpp|c|rb|php|swift|kt)\b/i.test(taskText);
186
+ if (hasCodeBlock || hasFileExtension) {
187
+ bestType = "coding";
188
+ }
189
+
190
+ // Complexity based on text length + heuristics
191
+ let complexity;
192
+ const len = taskText.length;
193
+ const wordCount = taskText.split(/\s+/).length;
194
+ if (len < COMPLEXITY_THRESHOLDS.simple && wordCount < 30) {
195
+ complexity = "simple";
196
+ } else if (len < COMPLEXITY_THRESHOLDS.medium && wordCount < 100) {
197
+ complexity = "medium";
198
+ } else {
199
+ complexity = "complex";
200
+ }
201
+
202
+ // More complexity signals
203
+ const complexSignals = [
204
+ "multiple", "all", "entire", "full", "complete", "end-to-end", "from scratch",
205
+ "and also", "in addition", "furthermore", "step by step",
206
+ ];
207
+ if (complexSignals.some(s => lower.includes(s))) {
208
+ if (complexity === "simple") complexity = "medium";
209
+ else if (complexity === "medium") complexity = "complex";
210
+ }
211
+
212
+ // Estimate tokens (rough: 1 token ≈ 4 chars, plus response overhead)
213
+ const estimatedTokens = Math.ceil(len / 4) + (complexity === "complex" ? 2000 : complexity === "medium" ? 800 : 300);
214
+
215
+ // Parallelizable: tasks that can be split across multiple independent workers
216
+ const parallelSignals = [
217
+ "and", "also", "multiple", "each", "for each", "list of", "all the", "several",
218
+ ];
219
+ const parallelizable = complexity === "complex" && parallelSignals.some(s => lower.includes(s));
220
+
221
+ return { type: bestType, complexity, estimatedTokens, parallelizable };
222
+ }
223
+
224
+ // ── Provider availability detection ─────────────────────────────────────────
225
+
226
+ /**
227
+ * Return the set of provider IDs that actually have API keys in env.
228
+ * @returns {Set<string>}
229
+ */
230
+ export function getAvailableProviders() {
231
+ const available = new Set();
232
+ for (const [provider, envKeys] of Object.entries(PROVIDER_ENV_KEYS)) {
233
+ if (envKeys.some(k => process.env[k])) {
234
+ available.add(provider);
235
+ }
236
+ }
237
+ return available;
238
+ }
239
+
240
+ /**
241
+ * Filter model list to only those whose provider is available.
242
+ * @param {string[]} modelNames
243
+ * @returns {string[]}
244
+ */
245
+ export function filterAvailableModels(modelNames) {
246
+ const available = getAvailableProviders();
247
+ return modelNames.filter(m => {
248
+ const cap = MODEL_CAPABILITIES[m];
249
+ return cap && available.has(cap.provider);
250
+ });
251
+ }
252
+
253
+ // ── Cost/speed ordering helpers ──────────────────────────────────────────────
254
+
255
+ const COST_ORDER = { free: 0, "very-low": 1, low: 2, medium: 3, high: 4, "very-high": 5 };
256
+ const SPEED_ORDER = { "very-fast": 0, fast: 1, medium: 2, slow: 3 };
257
+
258
+ function costScore(model) {
259
+ return COST_ORDER[MODEL_CAPABILITIES[model]?.cost] ?? 3;
260
+ }
261
+
262
+ function speedScore(model) {
263
+ return SPEED_ORDER[MODEL_CAPABILITIES[model]?.speed] ?? 2;
264
+ }
265
+
266
+ // ── Core routing logic ────────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Route a task to the best available model.
270
+ *
271
+ * @param {string|object} task — task text or { type, complexity, estimatedTokens, parallelizable }
272
+ * @param {string[]} [availableModels] — explicit list; if omitted, auto-detected from env
273
+ * @param {object} [opts]
274
+ * @param {string} [opts.costPreference] — "minimize" | "balanced" | "maximize-quality"
275
+ * @param {string} [opts.defaultModel] — fallback model
276
+ * @returns {{ model: string, provider: string, reason: string }}
277
+ */
278
+ export function routeTask(task, availableModels, opts = {}) {
279
+ const taskText = typeof task === "string" ? task : task.task ?? "";
280
+ const classification = typeof task === "object" && task.type
281
+ ? task
282
+ : classifyTask(taskText);
283
+
284
+ const { type, complexity, estimatedTokens } = classification;
285
+ const costPreference = opts.costPreference ?? "balanced";
286
+
287
+ // Determine candidate models
288
+ let candidates = availableModels;
289
+ if (!candidates || candidates.length === 0) {
290
+ candidates = filterAvailableModels(Object.keys(MODEL_CAPABILITIES));
291
+ }
292
+
293
+ // Filter to models that have valid capabilities
294
+ candidates = candidates.filter(m => MODEL_CAPABILITIES[m]);
295
+
296
+ if (candidates.length === 0) {
297
+ // No models available — fall back to default
298
+ const fallback = opts.defaultModel ?? "gemini-2.5-flash";
299
+ const cap = MODEL_CAPABILITIES[fallback];
300
+ return {
301
+ model: fallback,
302
+ provider: cap?.provider ?? "google",
303
+ reason: `No models available; using default (${fallback})`,
304
+ };
305
+ }
306
+
307
+ // Score each candidate
308
+ const scored = candidates.map(model => {
309
+ const cap = MODEL_CAPABILITIES[model];
310
+ if (!cap) return { model, score: -Infinity };
311
+
312
+ let score = 0;
313
+
314
+ // Strength match
315
+ const strengthScore = cap.strengths.includes(type) ? 10 : 0;
316
+ score += strengthScore;
317
+
318
+ // Partial strength match (related types)
319
+ const related = { coding: ["analysis", "review"], research: ["analysis"], design: ["analysis", "planning"] };
320
+ const relatedStrengths = related[type] ?? [];
321
+ for (const rs of relatedStrengths) {
322
+ if (cap.strengths.includes(rs)) score += 3;
323
+ }
324
+
325
+ // Complexity × cost/speed tradeoff
326
+ if (costPreference === "minimize") {
327
+ score -= costScore(model) * 2;
328
+ score -= speedScore(model);
329
+ } else if (costPreference === "maximize-quality") {
330
+ score += costScore(model) * 2; // prefer expensive (high quality)
331
+ score -= speedScore(model) * 0.5;
332
+ } else {
333
+ // balanced: for complex tasks lean toward quality, simple tasks lean toward speed+cost
334
+ if (complexity === "complex") {
335
+ score -= costScore(model);
336
+ score -= speedScore(model) * 0.5;
337
+ } else if (complexity === "simple") {
338
+ score -= costScore(model) * 2;
339
+ score += (3 - speedScore(model)) * 1.5;
340
+ } else {
341
+ score -= costScore(model);
342
+ score -= speedScore(model);
343
+ }
344
+ }
345
+
346
+ // Context window bonus for large tasks
347
+ if (estimatedTokens > 50000 && cap.contextWindow >= 200000) {
348
+ score += 3;
349
+ }
350
+
351
+ return { model, score, cap };
352
+ });
353
+
354
+ scored.sort((a, b) => b.score - a.score);
355
+ const best = scored[0];
356
+ const cap = best.cap ?? MODEL_CAPABILITIES[best.model];
357
+
358
+ const strengthMatch = cap?.strengths.includes(type)
359
+ ? `strengths match '${type}'`
360
+ : `best available for '${type}'`;
361
+
362
+ const reason = `${best.model} selected: ${strengthMatch}, ${cap?.speed ?? "?"} speed, ${cap?.cost ?? "?"} cost (complexity: ${complexity}, preference: ${costPreference})`;
363
+
364
+ return {
365
+ model: best.model,
366
+ provider: cap?.provider ?? "unknown",
367
+ reason,
368
+ };
369
+ }
370
+
371
+ /**
372
+ * Given a provider name, return the best cheap/fast decomposition model available.
373
+ * Used by the task decomposer.
374
+ * @returns {{ model: string, provider: string }}
375
+ */
376
+ export function getCheapDecomposerModel() {
377
+ const available = getAvailableProviders();
378
+ const cheapModels = [
379
+ { model: "gemini-2.5-flash", provider: "google" },
380
+ { model: "claude-3-5-haiku-20241022", provider: "anthropic" },
381
+ { model: "gpt-4o-mini", provider: "openai" },
382
+ { model: "llama-3.3-70b-versatile", provider: "groq" },
383
+ { model: "deepseek-chat", provider: "deepseek" },
384
+ ];
385
+ for (const entry of cheapModels) {
386
+ if (available.has(entry.provider)) return entry;
387
+ }
388
+ // Final fallback — return first available model
389
+ const allCandidates = filterAvailableModels(Object.keys(MODEL_CAPABILITIES));
390
+ if (allCandidates.length > 0) {
391
+ const m = allCandidates[0];
392
+ return { model: m, provider: MODEL_CAPABILITIES[m].provider };
393
+ }
394
+ return { model: "gemini-2.5-flash", provider: "google" };
395
+ }
package/core/tools.mjs CHANGED
@@ -275,6 +275,57 @@ export class ToolRegistry {
275
275
  required: ["id", "message"],
276
276
  },
277
277
  },
278
+ // ── Browser tools ────────────────────────────────────────────────────────
279
+ {
280
+ name: "browser_status",
281
+ description: "Check browser bridge health and current session status",
282
+ parameters: { type: "object", properties: {} },
283
+ },
284
+ {
285
+ name: "browser_tabs",
286
+ description: "List all open browser tabs",
287
+ parameters: {
288
+ type: "object",
289
+ properties: {
290
+ browser: { type: "string", enum: ["safari", "chrome"] },
291
+ },
292
+ },
293
+ },
294
+ {
295
+ name: "browser_navigate",
296
+ description: "Navigate the active browser tab to a URL",
297
+ parameters: {
298
+ type: "object",
299
+ properties: { url: { type: "string" } },
300
+ required: ["url"],
301
+ },
302
+ },
303
+ {
304
+ name: "browser_screenshot",
305
+ description: "Take a screenshot of the active browser tab",
306
+ parameters: { type: "object", properties: {} },
307
+ },
308
+ {
309
+ name: "browser_front_tab",
310
+ description: "Get info about the currently active browser tab (URL, title)",
311
+ parameters: { type: "object", properties: {} },
312
+ },
313
+ {
314
+ name: "browser_activate",
315
+ description: "Bring the browser tab to front / focus it",
316
+ parameters: { type: "object", properties: {} },
317
+ },
318
+ {
319
+ name: "browser_attach",
320
+ description: "Attach to a browser for control. Auto-selects the best available browser if no args given.",
321
+ parameters: {
322
+ type: "object",
323
+ properties: {
324
+ browser: { type: "string" },
325
+ mode: { type: "string" },
326
+ },
327
+ },
328
+ },
278
329
  ];
279
330
 
280
331
  for (const def of builtins) {
@@ -594,6 +645,14 @@ export class ToolRegistry {
594
645
  case "get_subagent_result":
595
646
  case "kill_subagent":
596
647
  case "steer_subagent":
648
+ // Browser tools — handled at engine level
649
+ case "browser_status":
650
+ case "browser_tabs":
651
+ case "browser_navigate":
652
+ case "browser_screenshot":
653
+ case "browser_front_tab":
654
+ case "browser_activate":
655
+ case "browser_attach":
597
656
  return { success: false, error: `Tool "${name}" requires engine context. Call via WispyEngine.processMessage().` };
598
657
 
599
658
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.7",
3
+ "version": "2.7.9",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",