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/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;
@@ -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
+ }
@@ -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: opts.model,
180
+ model: resolvedModel,
158
181
  timeout: opts.timeout ? opts.timeout * 1000 : 300_000,
159
182
  workstream: opts.workstream ?? this._engine._activeWorkstream,
160
183
  });