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,327 @@
1
+ /**
2
+ * core/browser.mjs — Native browser integration via local-browser-bridge
3
+ *
4
+ * Connects to the local-browser-bridge HTTP API for browser control.
5
+ * Supports: health check, capabilities, diagnostics, tabs, attach,
6
+ * navigate, activate, screenshot, sessions.
7
+ *
8
+ * All HTTP calls use native fetch() (Node 18+).
9
+ */
10
+
11
+ import { execFile } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+
14
+ const execAsync = promisify(execFile);
15
+ const NOT_RUNNING_MSG =
16
+ "Bridge not running. Start with: npx local-browser-bridge serve";
17
+
18
+ /**
19
+ * BrowserBridge — HTTP client for local-browser-bridge API.
20
+ *
21
+ * Usage:
22
+ * const bridge = new BrowserBridge();
23
+ * await bridge.ensureRunning();
24
+ * const session = await bridge.autoAttach();
25
+ * await bridge.navigate("https://example.com");
26
+ */
27
+ export class BrowserBridge {
28
+ constructor(options = {}) {
29
+ this.baseUrl = options.baseUrl ?? "http://127.0.0.1:3000";
30
+ this.defaultBrowser = options.browser ?? "safari";
31
+ this._session = null; // current active session
32
+ this._engine = options.engine ?? null; // optional WispyEngine ref for audit/trust
33
+ }
34
+
35
+ // ── Private helpers ──────────────────────────────────────────────────────────
36
+
37
+ async _get(path, params = {}) {
38
+ const url = new URL(`${this.baseUrl}${path}`);
39
+ for (const [k, v] of Object.entries(params)) {
40
+ if (v !== undefined && v !== null) url.searchParams.set(k, v);
41
+ }
42
+ try {
43
+ const res = await fetch(url.toString(), {
44
+ signal: AbortSignal.timeout(10_000),
45
+ });
46
+ if (!res.ok) {
47
+ const body = await res.text().catch(() => "");
48
+ return { error: `HTTP ${res.status}: ${body.slice(0, 200)}` };
49
+ }
50
+ return res.json();
51
+ } catch (err) {
52
+ if (err.name === "AbortError" || err.name === "TimeoutError") {
53
+ return { error: NOT_RUNNING_MSG };
54
+ }
55
+ if (err.code === "ECONNREFUSED" || err.cause?.code === "ECONNREFUSED") {
56
+ return { error: NOT_RUNNING_MSG };
57
+ }
58
+ return { error: err.message ?? NOT_RUNNING_MSG };
59
+ }
60
+ }
61
+
62
+ async _post(path, body = {}) {
63
+ try {
64
+ const res = await fetch(`${this.baseUrl}${path}`, {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify(body),
68
+ signal: AbortSignal.timeout(30_000),
69
+ });
70
+ if (!res.ok) {
71
+ const text = await res.text().catch(() => "");
72
+ return { error: `HTTP ${res.status}: ${text.slice(0, 200)}` };
73
+ }
74
+ return res.json();
75
+ } catch (err) {
76
+ if (err.name === "AbortError" || err.name === "TimeoutError") {
77
+ return { error: NOT_RUNNING_MSG };
78
+ }
79
+ if (err.code === "ECONNREFUSED" || err.cause?.code === "ECONNREFUSED") {
80
+ return { error: NOT_RUNNING_MSG };
81
+ }
82
+ return { error: err.message ?? NOT_RUNNING_MSG };
83
+ }
84
+ }
85
+
86
+ _audit(action, details = {}) {
87
+ if (!this._engine?.audit) return;
88
+ this._engine.audit.log({
89
+ type: "BROWSER_ACTION",
90
+ action,
91
+ ...details,
92
+ }).catch(() => {});
93
+ }
94
+
95
+ _trustLevel() {
96
+ if (!this._engine) return "careful";
97
+ return this._engine.config?.trustLevel ?? this._engine.permissions?.level ?? "careful";
98
+ }
99
+
100
+ _mayLog(action, detail) {
101
+ const level = this._trustLevel();
102
+ if (level !== "yolo") {
103
+ if (process.env.WISPY_DEBUG) {
104
+ process.stderr.write(`[browser] ${action}: ${detail}\n`);
105
+ }
106
+ }
107
+ this._audit(action, { detail });
108
+ }
109
+
110
+ // ── Health & discovery ───────────────────────────────────────────────────────
111
+
112
+ /** GET /health → { ok: true } */
113
+ async health() {
114
+ return this._get("/health");
115
+ }
116
+
117
+ /** GET /v1/capabilities?browser=X */
118
+ async capabilities(browser) {
119
+ return this._get("/v1/capabilities", { browser });
120
+ }
121
+
122
+ /** GET /v1/diagnostics?browser=X */
123
+ async diagnostics(browser) {
124
+ return this._get("/v1/diagnostics", { browser });
125
+ }
126
+
127
+ // ── Tab inspection ───────────────────────────────────────────────────────────
128
+
129
+ /** GET /v1/front-tab?browser=X */
130
+ async frontTab(browser) {
131
+ return this._get("/v1/front-tab", { browser: browser ?? this.defaultBrowser });
132
+ }
133
+
134
+ /**
135
+ * GET /v1/tab?browser=X&...
136
+ * query: { browser?, signature?, windowIndex?, tabIndex?, url?, title? }
137
+ */
138
+ async getTab(query = {}) {
139
+ const params = { browser: query.browser ?? this.defaultBrowser, ...query };
140
+ return this._get("/v1/tab", params);
141
+ }
142
+
143
+ /** GET /v1/tabs?browser=X */
144
+ async listTabs(browser) {
145
+ return this._get("/v1/tabs", { browser: browser ?? this.defaultBrowser });
146
+ }
147
+
148
+ // ── Session management ───────────────────────────────────────────────────────
149
+
150
+ /**
151
+ * POST /v1/attach { browser, mode?, tabTarget? }
152
+ * Stores the resulting session as this._session.
153
+ */
154
+ async attach(browser, opts = {}) {
155
+ const body = {
156
+ browser: browser ?? this.defaultBrowser,
157
+ ...opts,
158
+ };
159
+ const result = await this._post("/v1/attach", body);
160
+ if (!result.error) {
161
+ this._session = result;
162
+ }
163
+ return result;
164
+ }
165
+
166
+ /** GET /v1/sessions */
167
+ async listSessions() {
168
+ return this._get("/v1/sessions");
169
+ }
170
+
171
+ /** GET /v1/sessions/:id */
172
+ async getSession(id) {
173
+ return this._get(`/v1/sessions/${encodeURIComponent(id)}`);
174
+ }
175
+
176
+ /** POST /v1/sessions/:id/resume */
177
+ async resumeSession(id) {
178
+ const result = await this._post(`/v1/sessions/${encodeURIComponent(id)}/resume`);
179
+ if (!result.error) {
180
+ this._session = result;
181
+ }
182
+ return result;
183
+ }
184
+
185
+ // ── Actions (require active session) ─────────────────────────────────────────
186
+
187
+ /**
188
+ * Navigate to a URL.
189
+ * Uses session-scoped endpoint if sessionId/this._session is available.
190
+ */
191
+ async navigate(url, sessionId) {
192
+ this._mayLog("navigate", url);
193
+
194
+ const sid = sessionId ?? this._session?.id ?? this._session?.sessionId;
195
+ if (sid) {
196
+ return this._post(`/v1/sessions/${encodeURIComponent(sid)}/navigate`, { url });
197
+ }
198
+ return this._post("/v1/navigate", { url });
199
+ }
200
+
201
+ /**
202
+ * Bring browser tab to front / focus.
203
+ */
204
+ async activate(sessionId) {
205
+ this._mayLog("activate", sessionId ?? this._session?.id ?? "default");
206
+
207
+ const sid = sessionId ?? this._session?.id ?? this._session?.sessionId;
208
+ if (sid) {
209
+ return this._post(`/v1/sessions/${encodeURIComponent(sid)}/activate`);
210
+ }
211
+ return this._post("/v1/activate");
212
+ }
213
+
214
+ /**
215
+ * Take a screenshot.
216
+ * Returns { screenshot: "<base64>" } or { error }
217
+ */
218
+ async screenshot(sessionId) {
219
+ const sid = sessionId ?? this._session?.id ?? this._session?.sessionId;
220
+ if (sid) {
221
+ // Some implementations support GET, some POST
222
+ return this._get(`/v1/sessions/${encodeURIComponent(sid)}/screenshot`);
223
+ }
224
+ return this._post("/v1/screenshot");
225
+ }
226
+
227
+ // ── Lifecycle ────────────────────────────────────────────────────────────────
228
+
229
+ /**
230
+ * Check health, attempt to start bridge if not running.
231
+ * Returns { ok: boolean, started: boolean }
232
+ */
233
+ async ensureRunning() {
234
+ // 1. Try health
235
+ const h = await this.health();
236
+ if (h?.ok || (!h?.error && h !== null)) {
237
+ return { ok: true, started: false };
238
+ }
239
+
240
+ // 2. Try to start the bridge
241
+ let started = false;
242
+ try {
243
+ execFile("/bin/sh", ["-c", "npx local-browser-bridge serve --port 3000 &"], {
244
+ detached: true,
245
+ stdio: "ignore",
246
+ });
247
+ started = true;
248
+ } catch {
249
+ // ignore — may not have npx or may already be running
250
+ }
251
+
252
+ // 3. Wait up to 5 seconds for health to respond
253
+ const deadline = Date.now() + 5000;
254
+ while (Date.now() < deadline) {
255
+ await new Promise((r) => setTimeout(r, 500));
256
+ const h2 = await this.health();
257
+ if (h2?.ok || (!h2?.error && h2 !== null)) {
258
+ return { ok: true, started };
259
+ }
260
+ }
261
+
262
+ return { ok: false, started };
263
+ }
264
+
265
+ /**
266
+ * Auto-discover and attach to the best available browser.
267
+ * Order: safari-actionable > safari-readonly > chrome-actionable > chrome-readonly
268
+ */
269
+ async autoAttach() {
270
+ // 1. Get capabilities
271
+ const caps = await this.capabilities();
272
+ if (caps?.error) return { error: caps.error };
273
+
274
+ const browsers = Array.isArray(caps?.browsers)
275
+ ? caps.browsers
276
+ : Object.keys(caps ?? {}).filter((k) => k !== "error");
277
+
278
+ if (browsers.length === 0) {
279
+ return { error: "No browsers available via local-browser-bridge" };
280
+ }
281
+
282
+ // 2. Get diagnostics for each browser
283
+ const diagResults = [];
284
+ for (const browser of browsers) {
285
+ const d = await this.diagnostics(browser);
286
+ diagResults.push({ browser, diag: d });
287
+ }
288
+
289
+ // 3. Pick the best actionable browser
290
+ const priority = [
291
+ (d) => d.browser === "safari" && d.diag?.actionable === true,
292
+ (d) => d.browser === "chrome" && d.diag?.actionable === true,
293
+ (d) => d.diag?.actionable === true,
294
+ (d) => d.browser === "safari",
295
+ (d) => true,
296
+ ];
297
+
298
+ let chosen = null;
299
+ for (const pred of priority) {
300
+ chosen = diagResults.find(pred);
301
+ if (chosen) break;
302
+ }
303
+
304
+ if (!chosen) return { error: "No suitable browser found" };
305
+
306
+ // 4. Attach
307
+ const session = await this.attach(chosen.browser);
308
+ return session;
309
+ }
310
+
311
+ /** Summary status for display */
312
+ status() {
313
+ return {
314
+ baseUrl: this.baseUrl,
315
+ defaultBrowser: this.defaultBrowser,
316
+ session: this._session
317
+ ? {
318
+ id: this._session.id ?? this._session.sessionId,
319
+ browser: this._session.browser,
320
+ mode: this._session.mode,
321
+ }
322
+ : null,
323
+ };
324
+ }
325
+ }
326
+
327
+ export default BrowserBridge;
package/core/engine.mjs CHANGED
@@ -27,6 +27,9 @@ import { Harness } from "./harness.mjs";
27
27
  import { SyncManager, getSyncManager } from "./sync.mjs";
28
28
  import { SkillManager } from "./skills.mjs";
29
29
  import { UserModel } from "./user-model.mjs";
30
+ import { routeTask, classifyTask, filterAvailableModels } from "./task-router.mjs";
31
+ import { decomposeTask, executeDecomposedPlan } from "./task-decomposer.mjs";
32
+ import { BrowserBridge } from "./browser.mjs";
30
33
 
31
34
  const MAX_TOOL_ROUNDS = 10;
32
35
  const MAX_CONTEXT_CHARS = 40_000;
@@ -46,6 +49,7 @@ export class WispyEngine {
46
49
  this.sync = null; // SyncManager, initialized lazily
47
50
  this.skills = new SkillManager(WISPY_DIR, this);
48
51
  this.userModel = new UserModel(WISPY_DIR, this);
52
+ this.browser = new BrowserBridge({ engine: this });
49
53
  this._initialized = false;
50
54
  this._workMdContent = null;
51
55
  this._workMdLoaded = false;
@@ -87,9 +91,15 @@ export class WispyEngine {
87
91
  // Register skill tools
88
92
  this._registerSkillTools();
89
93
 
94
+ // Register routing + decomposition tools (v2.8)
95
+ this._registerRoutingTools();
96
+
90
97
  // Re-wire harness after tools are registered
91
98
  this.harness = new Harness(this.tools, this.permissions, this.audit, this.config);
92
99
 
100
+ // Start browser bridge (non-blocking, don't fail if unavailable)
101
+ this.browser.ensureRunning().catch(() => {});
102
+
93
103
  // Initialize MCP
94
104
  if (!opts.skipMcp) {
95
105
  await ensureDefaultMcpConfig(this.mcpManager.configPath);
@@ -347,6 +357,11 @@ export class WispyEngine {
347
357
  "node_list", "node_status", "node_execute",
348
358
  "update_work_context",
349
359
  "run_skill", "list_skills", "delete_skill",
360
+ "smart_route", "decompose_and_execute",
361
+ // Browser tools (v2.9)
362
+ "browser_status", "browser_tabs", "browser_navigate",
363
+ "browser_screenshot", "browser_front_tab", "browser_activate",
364
+ "browser_attach",
350
365
  ]);
351
366
 
352
367
  const harnessResult = await this.harness.execute(name, args, {
@@ -422,6 +437,26 @@ export class WispyEngine {
422
437
  return this._toolListSkills(args);
423
438
  case "delete_skill":
424
439
  return this._toolDeleteSkill(args);
440
+ // Smart routing + decomposition (v2.8)
441
+ case "smart_route":
442
+ return this._toolSmartRoute(args);
443
+ case "decompose_and_execute":
444
+ return this._toolDecomposeAndExecute(args, opts);
445
+ // Browser tools (v2.9)
446
+ case "browser_status":
447
+ return this._toolBrowserStatus();
448
+ case "browser_tabs":
449
+ return this._toolBrowserTabs(args);
450
+ case "browser_navigate":
451
+ return this._toolBrowserNavigate(args);
452
+ case "browser_screenshot":
453
+ return this._toolBrowserScreenshot(args);
454
+ case "browser_front_tab":
455
+ return this._toolBrowserFrontTab(args);
456
+ case "browser_activate":
457
+ return this._toolBrowserActivate(args);
458
+ case "browser_attach":
459
+ return this._toolBrowserAttach(args);
425
460
  default:
426
461
  return this.tools.execute(name, args);
427
462
  }
@@ -1191,6 +1226,210 @@ export class WispyEngine {
1191
1226
  return this.skills.delete(args.name);
1192
1227
  }
1193
1228
 
1229
+ // ── Smart routing + decomposition tools (v2.8) ───────────────────────────────
1230
+
1231
+ _registerRoutingTools() {
1232
+ const routingTools = [
1233
+ {
1234
+ name: "smart_route",
1235
+ description: "Analyze a task and recommend the best model to handle it based on task type, complexity, and available providers.",
1236
+ parameters: {
1237
+ type: "object",
1238
+ properties: {
1239
+ task: { type: "string", description: "The task to analyze and route" },
1240
+ cost_preference: {
1241
+ type: "string",
1242
+ enum: ["minimize", "balanced", "maximize-quality"],
1243
+ description: "Tradeoff preference (default: balanced)",
1244
+ },
1245
+ },
1246
+ required: ["task"],
1247
+ },
1248
+ },
1249
+ {
1250
+ name: "decompose_and_execute",
1251
+ description: "Split a complex task into subtasks, route each to the best model, execute in parallel, and synthesize results into a coherent response.",
1252
+ parameters: {
1253
+ type: "object",
1254
+ properties: {
1255
+ task: { type: "string", description: "The complex task to decompose and execute" },
1256
+ max_subtasks: { type: "number", description: "Maximum number of subtasks (default: 5)" },
1257
+ cost_preference: {
1258
+ type: "string",
1259
+ enum: ["minimize", "balanced", "maximize-quality"],
1260
+ description: "Cost/quality tradeoff preference (default: balanced)",
1261
+ },
1262
+ },
1263
+ required: ["task"],
1264
+ },
1265
+ },
1266
+ ];
1267
+
1268
+ for (const tool of routingTools) {
1269
+ this.tools._definitions.set(tool.name, tool);
1270
+ }
1271
+ }
1272
+
1273
+ async _toolSmartRoute(args) {
1274
+ try {
1275
+ const classification = classifyTask(args.task ?? "");
1276
+ const routing = routeTask(args.task ?? "", null, {
1277
+ costPreference: args.cost_preference ?? "balanced",
1278
+ defaultModel: this.model,
1279
+ });
1280
+
1281
+ return {
1282
+ success: true,
1283
+ recommendation: routing,
1284
+ classification,
1285
+ availableModels: filterAvailableModels(
1286
+ ["gpt-5.4", "gpt-4o", "gpt-4o-mini", "claude-opus-4-20250514", "claude-sonnet-4-20250514",
1287
+ "claude-3-5-haiku-20241022", "gemini-2.5-pro", "gemini-2.5-flash",
1288
+ "llama-3.3-70b-versatile", "deepseek-chat", "deepseek-reasoner"]
1289
+ ),
1290
+ };
1291
+ } catch (err) {
1292
+ return { success: false, error: err.message };
1293
+ }
1294
+ }
1295
+
1296
+ async _toolDecomposeAndExecute(args, parentOpts = {}) {
1297
+ try {
1298
+ const task = args.task ?? "";
1299
+ const maxSubtasks = args.max_subtasks ?? 5;
1300
+ const costPreference = args.cost_preference ?? "balanced";
1301
+
1302
+ // Step 1: Decompose
1303
+ const plan = await decomposeTask(task, {
1304
+ maxSubtasks,
1305
+ costPreference,
1306
+ engine: this,
1307
+ });
1308
+
1309
+ if (plan.subtasks.length === 0) {
1310
+ return { success: false, error: "Failed to decompose task into subtasks" };
1311
+ }
1312
+
1313
+ // Step 2: Execute
1314
+ const executionResult = await executeDecomposedPlan(plan, this, {
1315
+ costPreference,
1316
+ onSubtaskStart: (st) => {
1317
+ if (process.env.WISPY_DEBUG) {
1318
+ console.error(`[decompose] Starting subtask ${st.id}: ${st.task.slice(0, 60)}`);
1319
+ }
1320
+ },
1321
+ onSubtaskComplete: (st, result) => {
1322
+ if (process.env.WISPY_DEBUG) {
1323
+ console.error(`[decompose] Completed subtask ${st.id}`);
1324
+ }
1325
+ },
1326
+ });
1327
+
1328
+ return {
1329
+ success: true,
1330
+ plan: {
1331
+ subtaskCount: plan.subtasks.length,
1332
+ parallelGroups: plan.parallelGroups.length,
1333
+ estimatedCost: plan.estimatedCost,
1334
+ estimatedTime: plan.estimatedTime,
1335
+ },
1336
+ subtasks: executionResult.results.map(r => ({
1337
+ id: r.id,
1338
+ type: r.type,
1339
+ task: r.task.slice(0, 100),
1340
+ resultPreview: r.result?.slice(0, 200) ?? null,
1341
+ })),
1342
+ errors: executionResult.errors,
1343
+ synthesized: executionResult.synthesized,
1344
+ };
1345
+ } catch (err) {
1346
+ return { success: false, error: err.message };
1347
+ }
1348
+ }
1349
+
1350
+ // ── Browser tools (v2.9) ─────────────────────────────────────────────────────
1351
+
1352
+ async _toolBrowserStatus() {
1353
+ try {
1354
+ const health = await this.browser.health();
1355
+ const status = this.browser.status();
1356
+ return {
1357
+ success: true,
1358
+ health: health?.ok ?? false,
1359
+ bridgeUrl: this.browser.baseUrl,
1360
+ session: status.session,
1361
+ hint: health?.error ? `Start bridge: npx local-browser-bridge serve` : undefined,
1362
+ };
1363
+ } catch (err) {
1364
+ return { success: false, error: err.message };
1365
+ }
1366
+ }
1367
+
1368
+ async _toolBrowserTabs(args) {
1369
+ try {
1370
+ const result = await this.browser.listTabs(args?.browser);
1371
+ if (result?.error) return { success: false, error: result.error };
1372
+ return { success: true, tabs: result?.tabs ?? result };
1373
+ } catch (err) {
1374
+ return { success: false, error: err.message };
1375
+ }
1376
+ }
1377
+
1378
+ async _toolBrowserNavigate(args) {
1379
+ try {
1380
+ const result = await this.browser.navigate(args.url);
1381
+ if (result?.error) return { success: false, error: result.error };
1382
+ return { success: true, url: args.url, result };
1383
+ } catch (err) {
1384
+ return { success: false, error: err.message };
1385
+ }
1386
+ }
1387
+
1388
+ async _toolBrowserScreenshot(args) {
1389
+ try {
1390
+ const result = await this.browser.screenshot(args?.sessionId);
1391
+ if (result?.error) return { success: false, error: result.error };
1392
+ return { success: true, screenshot: result?.screenshot ?? result?.data, format: "base64" };
1393
+ } catch (err) {
1394
+ return { success: false, error: err.message };
1395
+ }
1396
+ }
1397
+
1398
+ async _toolBrowserFrontTab(args) {
1399
+ try {
1400
+ const result = await this.browser.frontTab(args?.browser);
1401
+ if (result?.error) return { success: false, error: result.error };
1402
+ return { success: true, tab: result };
1403
+ } catch (err) {
1404
+ return { success: false, error: err.message };
1405
+ }
1406
+ }
1407
+
1408
+ async _toolBrowserActivate(args) {
1409
+ try {
1410
+ const result = await this.browser.activate(args?.sessionId);
1411
+ if (result?.error) return { success: false, error: result.error };
1412
+ return { success: true, result };
1413
+ } catch (err) {
1414
+ return { success: false, error: err.message };
1415
+ }
1416
+ }
1417
+
1418
+ async _toolBrowserAttach(args) {
1419
+ try {
1420
+ let result;
1421
+ if (!args?.browser) {
1422
+ result = await this.browser.autoAttach();
1423
+ } else {
1424
+ result = await this.browser.attach(args.browser, { mode: args.mode });
1425
+ }
1426
+ if (result?.error) return { success: false, error: result.error };
1427
+ return { success: true, session: result };
1428
+ } catch (err) {
1429
+ return { success: false, error: err.message };
1430
+ }
1431
+ }
1432
+
1194
1433
  // ── Cleanup ──────────────────────────────────────────────────────────────────
1195
1434
 
1196
1435
  destroy() {