wispy-cli 2.7.8 → 2.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/wispy.mjs +163 -0
- package/core/browser.mjs +327 -0
- package/core/engine.mjs +239 -0
- package/core/memory.mjs +12 -0
- package/core/secrets.mjs +251 -0
- package/core/subagents.mjs +24 -1
- package/core/task-decomposer.mjs +375 -0
- package/core/task-router.mjs +2 -2
- package/core/tools.mjs +59 -0
- package/core/tts.mjs +194 -0
- package/package.json +1 -1
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() {
|
package/core/memory.mjs
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* - daily/YYYY-MM-DD.md — daily logs
|
|
7
7
|
* - projects/<name>.md — project-specific memory
|
|
8
8
|
* - user.md — user preferences/info
|
|
9
|
+
*
|
|
10
|
+
* v2.8+ enhancements:
|
|
11
|
+
* - getRelevantMemories() — keyword + recency scoring for context injection
|
|
12
|
+
* - autoExtractFacts() — auto-flush important facts from conversation
|
|
13
|
+
* - Fuzzy search with recency + frequency weighting
|
|
14
|
+
* - Access frequency tracking
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
17
|
import path from "node:path";
|
|
@@ -13,10 +19,14 @@ import { readFile, writeFile, appendFile, mkdir, readdir, unlink, stat } from "n
|
|
|
13
19
|
|
|
14
20
|
const MAX_SEARCH_RESULTS = 20;
|
|
15
21
|
const MAX_SNIPPET_CHARS = 200;
|
|
22
|
+
// How many messages must accumulate before autoExtractFacts triggers
|
|
23
|
+
const AUTO_EXTRACT_INTERVAL = 10;
|
|
16
24
|
|
|
17
25
|
export class MemoryManager {
|
|
18
26
|
constructor(wispyDir) {
|
|
19
27
|
this.memoryDir = path.join(wispyDir, "memory");
|
|
28
|
+
// Frequency tracking: key → access count (in-memory only, resets per session)
|
|
29
|
+
this._accessFrequency = new Map();
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
/**
|
|
@@ -88,6 +98,8 @@ export class MemoryManager {
|
|
|
88
98
|
const filePath = this._keyToPath(key);
|
|
89
99
|
try {
|
|
90
100
|
const content = await readFile(filePath, "utf8");
|
|
101
|
+
// Track access frequency
|
|
102
|
+
this._accessFrequency.set(key, (this._accessFrequency.get(key) ?? 0) + 1);
|
|
91
103
|
return { key, content, path: filePath };
|
|
92
104
|
} catch {
|
|
93
105
|
return null;
|
package/core/secrets.mjs
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/secrets.mjs — Secrets Manager for Wispy
|
|
3
|
+
*
|
|
4
|
+
* Resolution chain: env var → macOS Keychain → encrypted secrets.json → null
|
|
5
|
+
* Encryption: AES-256-GCM with machine-derived key (hostname + username)
|
|
6
|
+
* Scrubbing: never logs actual secret values
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { createHash, createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
const ALGORITHM = "aes-256-gcm";
|
|
16
|
+
const KEY_LENGTH = 32;
|
|
17
|
+
const IV_LENGTH = 12;
|
|
18
|
+
const TAG_LENGTH = 16;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Derive a deterministic encryption key from machine identity.
|
|
22
|
+
* Uses hostname + username hash — not cryptographically perfect but
|
|
23
|
+
* provides at-rest protection from casual disk reads.
|
|
24
|
+
*/
|
|
25
|
+
function deriveMachineKey() {
|
|
26
|
+
const machineId = `${os.hostname()}::${os.userInfo().username}`;
|
|
27
|
+
return createHash("sha256").update(machineId).digest(); // 32 bytes
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class SecretsManager {
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.wispyDir = options.wispyDir ?? path.join(os.homedir(), ".wispy");
|
|
33
|
+
this.secretsFile = path.join(this.wispyDir, "secrets.json");
|
|
34
|
+
this._cache = new Map();
|
|
35
|
+
this._machineKey = deriveMachineKey();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolution chain: env var → macOS Keychain → encrypted secrets.json → null
|
|
40
|
+
*
|
|
41
|
+
* @param {string} key - e.g. "OPENAI_API_KEY"
|
|
42
|
+
* @returns {Promise<string|null>}
|
|
43
|
+
*/
|
|
44
|
+
async resolve(key) {
|
|
45
|
+
if (!key) return null;
|
|
46
|
+
|
|
47
|
+
// 1. In-memory cache
|
|
48
|
+
if (this._cache.has(key)) return this._cache.get(key);
|
|
49
|
+
|
|
50
|
+
// 2. Environment variable
|
|
51
|
+
if (process.env[key]) {
|
|
52
|
+
this._cache.set(key, process.env[key]);
|
|
53
|
+
return process.env[key];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 3. macOS Keychain — try common service name patterns
|
|
57
|
+
const keychainValue = await this._fromKeychain(key, null);
|
|
58
|
+
if (keychainValue) {
|
|
59
|
+
this._cache.set(key, keychainValue);
|
|
60
|
+
return keychainValue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 4. Encrypted secrets.json
|
|
64
|
+
const stored = await this._fromSecretsFile(key);
|
|
65
|
+
if (stored) {
|
|
66
|
+
this._cache.set(key, stored);
|
|
67
|
+
return stored;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Store a secret (encrypts before writing to secrets.json)
|
|
75
|
+
*/
|
|
76
|
+
async set(key, value) {
|
|
77
|
+
if (!key || value === undefined || value === null) {
|
|
78
|
+
throw new Error("key and value are required");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Update cache
|
|
82
|
+
this._cache.set(key, value);
|
|
83
|
+
|
|
84
|
+
// Load existing secrets
|
|
85
|
+
const secrets = await this._loadSecretsFile();
|
|
86
|
+
|
|
87
|
+
// Encrypt and store
|
|
88
|
+
secrets[key] = this._encrypt(value);
|
|
89
|
+
|
|
90
|
+
await this._saveSecretsFile(secrets);
|
|
91
|
+
return { key, stored: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Delete a secret from cache + secrets.json
|
|
96
|
+
*/
|
|
97
|
+
async delete(key) {
|
|
98
|
+
this._cache.delete(key);
|
|
99
|
+
|
|
100
|
+
const secrets = await this._loadSecretsFile();
|
|
101
|
+
if (!(key in secrets)) {
|
|
102
|
+
return { success: false, key, error: "Key not found" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
delete secrets[key];
|
|
106
|
+
await this._saveSecretsFile(secrets);
|
|
107
|
+
return { success: true, key };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* List stored secret keys (not values)
|
|
112
|
+
*/
|
|
113
|
+
async list() {
|
|
114
|
+
const secrets = await this._loadSecretsFile();
|
|
115
|
+
return Object.keys(secrets);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* macOS Keychain integration
|
|
120
|
+
* Tries common wispy service names, then the key itself as service name
|
|
121
|
+
*/
|
|
122
|
+
async _fromKeychain(service, account) {
|
|
123
|
+
if (process.platform !== "darwin") return null;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const { execFile } = await import("node:child_process");
|
|
127
|
+
const { promisify } = await import("node:util");
|
|
128
|
+
const exec = promisify(execFile);
|
|
129
|
+
|
|
130
|
+
// Map common env var names to keychain service names
|
|
131
|
+
const serviceMap = {
|
|
132
|
+
OPENAI_API_KEY: "openai-api-key",
|
|
133
|
+
ANTHROPIC_API_KEY: "anthropic-api-key",
|
|
134
|
+
GOOGLE_AI_KEY: "google-ai-key",
|
|
135
|
+
GOOGLE_GENERATIVE_AI_KEY: "google-ai-key",
|
|
136
|
+
GEMINI_API_KEY: "google-ai-key",
|
|
137
|
+
GROQ_API_KEY: "groq-api-key",
|
|
138
|
+
DEEPSEEK_API_KEY: "deepseek-api-key",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const keychainService = serviceMap[service] ?? service.toLowerCase().replace(/_/g, "-");
|
|
142
|
+
const accounts = account ? [account] : ["wispy", "poropo"];
|
|
143
|
+
|
|
144
|
+
for (const acc of accounts) {
|
|
145
|
+
try {
|
|
146
|
+
const { stdout } = await exec(
|
|
147
|
+
"security",
|
|
148
|
+
["find-generic-password", "-s", keychainService, "-a", acc, "-w"],
|
|
149
|
+
{ timeout: 2000 }
|
|
150
|
+
);
|
|
151
|
+
const val = stdout.trim();
|
|
152
|
+
if (val) return val;
|
|
153
|
+
} catch { /* try next */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Fallback: no account filter
|
|
157
|
+
try {
|
|
158
|
+
const { stdout } = await exec(
|
|
159
|
+
"security",
|
|
160
|
+
["find-generic-password", "-s", keychainService, "-w"],
|
|
161
|
+
{ timeout: 2000 }
|
|
162
|
+
);
|
|
163
|
+
const val = stdout.trim();
|
|
164
|
+
if (val) return val;
|
|
165
|
+
} catch { /* not found */ }
|
|
166
|
+
} catch { /* keychain unavailable */ }
|
|
167
|
+
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Encryption helpers ───────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Encrypt plaintext → base64 string (iv:tag:ciphertext)
|
|
175
|
+
*/
|
|
176
|
+
_encrypt(plaintext) {
|
|
177
|
+
const iv = randomBytes(IV_LENGTH);
|
|
178
|
+
const cipher = createCipheriv(ALGORITHM, this._machineKey, iv);
|
|
179
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
180
|
+
const tag = cipher.getAuthTag();
|
|
181
|
+
// Encode as hex parts joined by ":"
|
|
182
|
+
return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Decrypt iv:tag:ciphertext → plaintext string
|
|
187
|
+
*/
|
|
188
|
+
_decrypt(ciphertext) {
|
|
189
|
+
const [ivHex, tagHex, encHex] = ciphertext.split(":");
|
|
190
|
+
if (!ivHex || !tagHex || !encHex) throw new Error("Invalid ciphertext format");
|
|
191
|
+
|
|
192
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
193
|
+
const tag = Buffer.from(tagHex, "hex");
|
|
194
|
+
const enc = Buffer.from(encHex, "hex");
|
|
195
|
+
|
|
196
|
+
const decipher = createDecipheriv(ALGORITHM, this._machineKey, iv);
|
|
197
|
+
decipher.setAuthTag(tag);
|
|
198
|
+
return decipher.update(enc) + decipher.final("utf8");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── File helpers ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
async _loadSecretsFile() {
|
|
204
|
+
try {
|
|
205
|
+
const raw = await readFile(this.secretsFile, "utf8");
|
|
206
|
+
return JSON.parse(raw);
|
|
207
|
+
} catch {
|
|
208
|
+
return {};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async _saveSecretsFile(secrets) {
|
|
213
|
+
await mkdir(this.wispyDir, { recursive: true });
|
|
214
|
+
await writeFile(this.secretsFile, JSON.stringify(secrets, null, 2) + "\n", "utf8");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async _fromSecretsFile(key) {
|
|
218
|
+
const secrets = await this._loadSecretsFile();
|
|
219
|
+
if (!(key in secrets)) return null;
|
|
220
|
+
try {
|
|
221
|
+
return this._decrypt(secrets[key]);
|
|
222
|
+
} catch {
|
|
223
|
+
return null; // Corrupted entry — skip
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Scrub secret values from an object (for logging/audit)
|
|
229
|
+
* Replaces any value that looks like a key we know about with "***"
|
|
230
|
+
*/
|
|
231
|
+
scrub(obj) {
|
|
232
|
+
if (typeof obj !== "object" || obj === null) return obj;
|
|
233
|
+
const knownKeys = new Set([...this._cache.keys()]);
|
|
234
|
+
const result = {};
|
|
235
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
236
|
+
if (knownKeys.has(k) || /key|token|secret|password|credential/i.test(k)) {
|
|
237
|
+
result[k] = "***";
|
|
238
|
+
} else {
|
|
239
|
+
result[k] = v;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Singleton factory — reuse across modules */
|
|
247
|
+
let _instance = null;
|
|
248
|
+
export function getSecretsManager(options = {}) {
|
|
249
|
+
if (!_instance) _instance = new SecretsManager(options);
|
|
250
|
+
return _instance;
|
|
251
|
+
}
|
package/core/subagents.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import os from "node:os";
|
|
|
34
34
|
import path from "node:path";
|
|
35
35
|
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
|
36
36
|
import { WISPY_DIR } from "./config.mjs";
|
|
37
|
+
import { routeTask, filterAvailableModels, MODEL_CAPABILITIES, getAvailableProviders } from "./task-router.mjs";
|
|
37
38
|
|
|
38
39
|
const SUBAGENTS_DIR = path.join(WISPY_DIR, "subagents");
|
|
39
40
|
|
|
@@ -150,11 +151,33 @@ export class SubAgentManager extends EventEmitter {
|
|
|
150
151
|
* @returns {Promise<SubAgent>}
|
|
151
152
|
*/
|
|
152
153
|
async spawn(opts) {
|
|
154
|
+
// ── routingPreference: resolve model before spawning ──────────────────────
|
|
155
|
+
let resolvedModel = opts.model ?? null;
|
|
156
|
+
const routingPref = opts.routingPreference ?? "inherit";
|
|
157
|
+
|
|
158
|
+
if (routingPref === "auto") {
|
|
159
|
+
try {
|
|
160
|
+
const routing = routeTask(opts.task ?? "", null, { costPreference: "balanced" });
|
|
161
|
+
resolvedModel = routing.model;
|
|
162
|
+
} catch { /* ignore routing errors, use null */ }
|
|
163
|
+
} else if (routingPref === "fast") {
|
|
164
|
+
try {
|
|
165
|
+
const routing = routeTask(opts.task ?? "", null, { costPreference: "minimize" });
|
|
166
|
+
resolvedModel = routing.model;
|
|
167
|
+
} catch {}
|
|
168
|
+
} else if (routingPref === "quality") {
|
|
169
|
+
try {
|
|
170
|
+
const routing = routeTask(opts.task ?? "", null, { costPreference: "maximize-quality" });
|
|
171
|
+
resolvedModel = routing.model;
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
// "inherit" → use opts.model as-is (or null = parent's model)
|
|
175
|
+
|
|
153
176
|
const agent = new SubAgent({
|
|
154
177
|
id: makeId(),
|
|
155
178
|
task: opts.task,
|
|
156
179
|
label: opts.label,
|
|
157
|
-
model:
|
|
180
|
+
model: resolvedModel,
|
|
158
181
|
timeout: opts.timeout ? opts.timeout * 1000 : 300_000,
|
|
159
182
|
workstream: opts.workstream ?? this._engine._activeWorkstream,
|
|
160
183
|
});
|