cascade-ai 0.13.1 → 0.15.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.
package/dist/cli.js CHANGED
@@ -58,7 +58,7 @@ var __export = (target, all) => {
58
58
  var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SECRET_FILE, GLOBAL_CONFIG_DIR, GLOBAL_DB_FILE, GLOBAL_KEYSTORE_FILE, GLOBAL_RUNTIME_DB_FILE, DEFAULT_DASHBOARD_PORT, DEFAULT_CONTEXT_LIMIT, DEFAULT_AUTO_SUMMARIZE_AT, MODELS, T1_MODEL_PRIORITY, T2_MODEL_PRIORITY, T3_MODEL_PRIORITY, VISION_MODEL_PRIORITY, COMPLEXITY_T2_COUNT, THEME_NAMES, DEFAULT_THEME, OLLAMA_BASE_URL, LM_STUDIO_BASE_URL, AZURE_BASE_URL_TEMPLATE, TOOL_NAMES, DEFAULT_APPROVAL_REQUIRED;
59
59
  var init_constants = __esm({
60
60
  "src/constants.ts"() {
61
- CASCADE_VERSION = "0.13.1";
61
+ CASCADE_VERSION = "0.15.0";
62
62
  CASCADE_CONFIG_FILE = ".cascade/config.json";
63
63
  CASCADE_DB_FILE = ".cascade/memory.db";
64
64
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -1364,29 +1364,71 @@ var init_ollama = __esm({
1364
1364
  async countTokens(text) {
1365
1365
  return Math.ceil(text.length / 4);
1366
1366
  }
1367
+ /**
1368
+ * Ask the Ollama server what a model can actually do. Modern Ollama returns a
1369
+ * `capabilities` array ("completion", "tools", "vision", …) and the model's
1370
+ * real context length in `model_info` — authoritative, unlike the hardcoded
1371
+ * family-name allowlist, which silently misclassifies any unlisted family.
1372
+ * Best-effort: null on old servers / errors, callers fall back to heuristics.
1373
+ */
1374
+ async showModel(name) {
1375
+ const ac = new AbortController();
1376
+ const timer = setTimeout(() => ac.abort(), 2500);
1377
+ try {
1378
+ const resp = await fetch(`${this.baseUrl}/api/show`, {
1379
+ method: "POST",
1380
+ headers: { "Content-Type": "application/json" },
1381
+ body: JSON.stringify({ model: name }),
1382
+ signal: ac.signal
1383
+ });
1384
+ if (!resp.ok) return null;
1385
+ const data = await resp.json();
1386
+ const out = {};
1387
+ if (Array.isArray(data.capabilities)) {
1388
+ out.tools = data.capabilities.includes("tools");
1389
+ out.vision = data.capabilities.includes("vision");
1390
+ }
1391
+ for (const [k, v] of Object.entries(data.model_info ?? {})) {
1392
+ if (k.endsWith(".context_length") && typeof v === "number" && v > 0) {
1393
+ out.contextWindow = v;
1394
+ break;
1395
+ }
1396
+ }
1397
+ return out.tools !== void 0 || out.contextWindow ? out : null;
1398
+ } catch {
1399
+ return null;
1400
+ } finally {
1401
+ clearTimeout(timer);
1402
+ }
1403
+ }
1367
1404
  async listModels() {
1368
1405
  try {
1369
1406
  const response = await fetch(`${this.baseUrl}/api/tags`);
1370
1407
  if (!response.ok) return [];
1371
1408
  const data = await response.json();
1372
1409
  const supportedKeywords = ["llama3", "llama2", "gemma", "mistral", "mixtral", "qwen", "phi3", "codellama", "deepseek", "llava", "starcoder", "stable-code", "nomic-embed"];
1373
- return data.models.filter((m) => {
1410
+ const entries = data.models.filter((m) => {
1374
1411
  const name = m.name.toLowerCase();
1375
1412
  return supportedKeywords.some((k) => name.includes(k));
1376
- }).map((m) => ({
1377
- id: m.name,
1378
- name: m.name,
1379
- provider: "ollama",
1380
- contextWindow: 128e3,
1381
- isVisionCapable: m.name.includes("llava") || m.name.includes("vision"),
1382
- inputCostPer1kTokens: 0,
1383
- outputCostPer1kTokens: 0,
1384
- maxOutputTokens: 4e3,
1385
- supportsStreaming: true,
1386
- isLocal: true,
1387
- supportsToolUse: isToolCapable(m.name),
1388
- minSizeB: this.parseSizeB(m.details?.parameter_size)
1389
- }));
1413
+ });
1414
+ const shows = await Promise.all(entries.map((m) => this.showModel(m.name)));
1415
+ return entries.map((m, i) => {
1416
+ const show = shows[i];
1417
+ return {
1418
+ id: m.name,
1419
+ name: m.name,
1420
+ provider: "ollama",
1421
+ contextWindow: show?.contextWindow ?? 128e3,
1422
+ isVisionCapable: show?.vision ?? (m.name.includes("llava") || m.name.includes("vision")),
1423
+ inputCostPer1kTokens: 0,
1424
+ outputCostPer1kTokens: 0,
1425
+ maxOutputTokens: 4e3,
1426
+ supportsStreaming: true,
1427
+ isLocal: true,
1428
+ supportsToolUse: show?.tools ?? isToolCapable(m.name),
1429
+ minSizeB: this.parseSizeB(m.details?.parameter_size)
1430
+ };
1431
+ });
1390
1432
  } catch {
1391
1433
  return [];
1392
1434
  }
@@ -2309,6 +2351,78 @@ Original error: ${err.message}`
2309
2351
  `).all(`%${query}%`, limit);
2310
2352
  return rows.map(this.deserializeMessage);
2311
2353
  }
2354
+ // ── Export / Import ──────────────────────────
2355
+ //
2356
+ // Chats travel as full Session objects (messages included) inside a
2357
+ // plaintext-JSON bundle. Import NEVER overwrites: every imported session
2358
+ // gets a fresh id (title suffixed "(imported)") and fresh message ids, so
2359
+ // re-importing the same bundle can duplicate but can never clobber.
2360
+ /** Full sessions (with messages) for the given ids, or ALL sessions. */
2361
+ exportSessions(ids) {
2362
+ const targets = ids && ids.length > 0 ? ids : this.db.prepare("SELECT id FROM sessions ORDER BY updated_at DESC").all().map((r) => r.id);
2363
+ const out = [];
2364
+ for (const id of targets) {
2365
+ const s = this.getSession(id);
2366
+ if (s) out.push(s);
2367
+ }
2368
+ return out;
2369
+ }
2370
+ /**
2371
+ * Import sessions from a bundle. Returns the created `{id, title}` rows so
2372
+ * the caller can mirror them into the runtime session list (the sidebar
2373
+ * reads runtime_sessions, not sessions). Malformed entries are skipped.
2374
+ */
2375
+ importSessions(sessions) {
2376
+ const out = [];
2377
+ const msgStmt = this.db.prepare(`
2378
+ INSERT INTO messages (id, session_id, role, content, timestamp, tokens, agent_messages)
2379
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2380
+ `);
2381
+ for (const s of sessions) {
2382
+ if (!s || typeof s !== "object") continue;
2383
+ const newId = randomUUID();
2384
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2385
+ const title = `${String(s.title || "Untitled").slice(0, 200)} (imported)`;
2386
+ const metadata = s.metadata && typeof s.metadata === "object" ? s.metadata : { totalTokens: 0, totalCostUsd: 0, modelsUsed: [], toolsUsed: [], taskCount: 0 };
2387
+ this.db.prepare(`
2388
+ INSERT INTO sessions (id, title, created_at, updated_at, identity_id, workspace_path, metadata)
2389
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2390
+ `).run(newId, title, s.createdAt ?? now, s.updatedAt ?? now, s.identityId ?? "default", s.workspacePath ?? "", JSON.stringify(metadata));
2391
+ for (const m of Array.isArray(s.messages) ? s.messages : []) {
2392
+ if (!m || m.role !== "user" && m.role !== "assistant" && m.role !== "system") continue;
2393
+ msgStmt.run(
2394
+ randomUUID(),
2395
+ newId,
2396
+ m.role,
2397
+ typeof m.content === "string" ? m.content : JSON.stringify(m.content),
2398
+ m.timestamp ?? now,
2399
+ m.tokens ? JSON.stringify(m.tokens) : null,
2400
+ m.agentMessages ? JSON.stringify(m.agentMessages) : null
2401
+ );
2402
+ }
2403
+ out.push({ id: newId, title });
2404
+ }
2405
+ return out;
2406
+ }
2407
+ /** Import identities/personas, deduped by (case-insensitive) name — an
2408
+ * existing name is skipped, never replaced. Imports are never the default. */
2409
+ importIdentities(identities) {
2410
+ const existing = new Set(this.listIdentities().map((i) => i.name.toLowerCase()));
2411
+ let imported = 0;
2412
+ for (const ident of identities ?? []) {
2413
+ if (!ident?.name || typeof ident.name !== "string") continue;
2414
+ if (existing.has(ident.name.toLowerCase())) continue;
2415
+ this.createIdentity({
2416
+ ...ident,
2417
+ id: randomUUID(),
2418
+ isDefault: false,
2419
+ createdAt: ident.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
2420
+ });
2421
+ existing.add(ident.name.toLowerCase());
2422
+ imported++;
2423
+ }
2424
+ return imported;
2425
+ }
2312
2426
  // ── Identities ────────────────────────────────
2313
2427
  createIdentity(identity) {
2314
2428
  this.db.prepare(`
@@ -2486,6 +2600,23 @@ Original error: ${err.message}`
2486
2600
  const row = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(`${provider}:${modelId}`);
2487
2601
  return row ? JSON.parse(row.metadata) : void 0;
2488
2602
  }
2603
+ /**
2604
+ * Persist a probed capability verdict (currently: native tool support) into
2605
+ * the model's cached profile, merging with whatever is already recorded.
2606
+ * Read back via getModelProfile — so a model is probed once ever, not once
2607
+ * per run.
2608
+ */
2609
+ saveModelCapability(modelId, provider, caps) {
2610
+ const cacheKey = `${provider}:${modelId}`;
2611
+ const existing = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(cacheKey);
2612
+ const meta = existing ? JSON.parse(existing.metadata) : { id: modelId, provider, name: modelId, contextWindow: 0, isVisionCapable: false, inputCostPer1kTokens: 0, outputCostPer1kTokens: 0, maxOutputTokens: 0, supportsStreaming: false, isLocal: false };
2613
+ if (caps.supportsToolUse !== void 0) meta.supportsToolUse = caps.supportsToolUse;
2614
+ this.db.prepare(`
2615
+ INSERT INTO model_cache (id, provider, model_id, name, metadata, updated_at)
2616
+ VALUES (?, ?, ?, ?, ?, ?)
2617
+ ON CONFLICT(id) DO UPDATE SET metadata = excluded.metadata, updated_at = excluded.updated_at
2618
+ `).run(cacheKey, provider, modelId, meta.name ?? modelId, JSON.stringify(meta), (/* @__PURE__ */ new Date()).toISOString());
2619
+ }
2489
2620
  getProfiledModelIds() {
2490
2621
  const rows = this.db.prepare(
2491
2622
  "SELECT model_id FROM model_cache WHERE json_extract(metadata, '$.specializations') IS NOT NULL"
@@ -2775,7 +2906,15 @@ var ToolsConfigSchema = z.object({
2775
2906
  mcpServers: z.array(McpServerConfigSchema).optional(),
2776
2907
  mcpTrusted: z.array(z.string()).optional(),
2777
2908
  /** Web search backends — at least one should be configured for best results */
2778
- webSearch: WebSearchConfigSchema.optional()
2909
+ webSearch: WebSearchConfigSchema.optional(),
2910
+ /**
2911
+ * Sandbox runtime for LLM-authored dynamic tools:
2912
+ * - 'isolate': hard V8 isolate (isolated-vm) — no Node globals, true capability
2913
+ * confinement. Requires the optional native `isolated-vm` dependency.
2914
+ * - 'worker': node:worker_threads (resource/kill limits, but not capability-confined).
2915
+ * - 'auto' (default): use the isolate when `isolated-vm` loads, else fall back to worker.
2916
+ */
2917
+ dynamicToolSandbox: z.enum(["isolate", "worker", "auto"]).default("auto")
2779
2918
  });
2780
2919
  var HookDefinitionSchema = z.object({
2781
2920
  name: z.string().optional(),
@@ -2887,10 +3026,20 @@ var CascadeConfigSchema = z.object({
2887
3026
  /**
2888
3027
  * Runtime Tool Creation: when true, T3 workers can generate and register new tools
2889
3028
  * at runtime via the ToolCreator when no existing tool can handle a required operation.
2890
- * Generated tools are session-scoped and sandboxed in node:vm.
3029
+ * Generated tools are session-scoped and sandboxed (see tools.dynamicToolSandbox).
2891
3030
  * HTTP calls from generated tools require approval.
2892
3031
  */
2893
3032
  enableToolCreation: z.boolean().default(true),
3033
+ /**
3034
+ * Project knowledge (world state). `factsExtraction`: after each worker
3035
+ * completes, run a cheap extraction pass that distills its output into
3036
+ * queryable entity/relation/value facts (superseding older facts), which T1
3037
+ * queries during planning instead of replaying the whole linear log. Best-effort
3038
+ * and non-blocking; set false to keep only the raw linear log.
3039
+ */
3040
+ knowledge: z.object({
3041
+ factsExtraction: z.boolean().default(true)
3042
+ }).default({}),
2894
3043
  /**
2895
3044
  * Persist runtime-generated tools to .cascade/dynamic-tools.json and reload them
2896
3045
  * on startup for cross-run dedup. Reloaded (and peer-received) tools are always
@@ -2989,6 +3138,8 @@ var CascadeConfigSchema = z.object({
2989
3138
  privacy: z.object({
2990
3139
  paths: z.array(z.object({ pattern: z.string().min(1), policy: z.enum(["local-only"]) })).default([])
2991
3140
  }).optional(),
3141
+ /** Routing controls — forceTier pins the root tier, bypassing the classifier. */
3142
+ routing: z.object({ forceTier: z.enum(["auto", "T1", "T2", "T3"]).default("auto") }).optional(),
2992
3143
  /**
2993
3144
  * T3→T2 reinforcement: when enabled, a worker that discovers its subtask should
2994
3145
  * fan out can call the `request_workers` tool to have its T2 manager spawn
@@ -3986,6 +4137,7 @@ function normalizeModelId(id) {
3986
4137
  var LiveDataProvider = class {
3987
4138
  snapshot = null;
3988
4139
  prices = /* @__PURE__ */ new Map();
4140
+ capabilities = /* @__PURE__ */ new Map();
3989
4141
  source = "bundled";
3990
4142
  fetchedAt = 0;
3991
4143
  loaded = false;
@@ -4014,6 +4166,9 @@ var LiveDataProvider = class {
4014
4166
  if (cache.prices) {
4015
4167
  for (const [id, p] of Object.entries(cache.prices)) this.prices.set(id, p);
4016
4168
  }
4169
+ if (cache.capabilities) {
4170
+ for (const [id, c] of Object.entries(cache.capabilities)) this.capabilities.set(id, c);
4171
+ }
4017
4172
  this.fetchedAt = cache.fetchedAt ?? 0;
4018
4173
  } catch {
4019
4174
  }
@@ -4034,9 +4189,9 @@ var LiveDataProvider = class {
4034
4189
  const ttlMs = this.opts.refreshHours * 36e5;
4035
4190
  const fresh = ttlMs > 0 && Date.now() - this.fetchedAt < ttlMs;
4036
4191
  if (!force && fresh && this.source !== "bundled") return;
4037
- const [snap, prices] = await Promise.all([
4192
+ const [snap, catalog] = await Promise.all([
4038
4193
  this.opts.live ? this.fetchSnapshot() : Promise.resolve(null),
4039
- this.opts.pricingLive ? this.fetchPrices() : Promise.resolve(null)
4194
+ this.opts.pricingLive ? this.fetchCatalog() : Promise.resolve(null)
4040
4195
  ]);
4041
4196
  let changed = false;
4042
4197
  if (snap) {
@@ -4044,8 +4199,12 @@ var LiveDataProvider = class {
4044
4199
  this.source = "live";
4045
4200
  changed = true;
4046
4201
  }
4047
- if (prices && prices.size > 0) {
4048
- this.prices = prices;
4202
+ if (catalog && catalog.prices.size > 0) {
4203
+ this.prices = catalog.prices;
4204
+ changed = true;
4205
+ }
4206
+ if (catalog && catalog.capabilities.size > 0) {
4207
+ this.capabilities = catalog.capabilities;
4049
4208
  changed = true;
4050
4209
  }
4051
4210
  if (changed) {
@@ -4067,21 +4226,43 @@ var LiveDataProvider = class {
4067
4226
  return null;
4068
4227
  }
4069
4228
  }
4070
- async fetchPrices() {
4229
+ /**
4230
+ * One fetch of the OpenRouter catalog yields BOTH pricing and capability
4231
+ * facts (context window, native tool support, input modalities) — the
4232
+ * capability fields used to be discarded while providers guessed/hardcoded
4233
+ * them in their listModels() stubs.
4234
+ */
4235
+ async fetchCatalog() {
4071
4236
  try {
4072
4237
  const resp = await withTimeout(fetch(OPENROUTER_MODELS_URL), FETCH_TIMEOUT_MS, "pricing fetch timed out");
4073
4238
  if (!resp.ok) return null;
4074
4239
  const data = await resp.json();
4075
4240
  if (!Array.isArray(data?.data)) return null;
4076
- const out = /* @__PURE__ */ new Map();
4241
+ const prices = /* @__PURE__ */ new Map();
4242
+ const capabilities = /* @__PURE__ */ new Map();
4077
4243
  for (const m of data.data) {
4078
- if (!m?.id || !m.pricing) continue;
4079
- const input = Number(m.pricing.prompt) * 1e3;
4080
- const output = Number(m.pricing.completion) * 1e3;
4081
- if (!Number.isFinite(input) || !Number.isFinite(output)) continue;
4082
- out.set(normalizeModelId(m.id), { input, output });
4244
+ if (!m?.id) continue;
4245
+ const key = normalizeModelId(m.id);
4246
+ if (m.pricing) {
4247
+ const input = Number(m.pricing.prompt) * 1e3;
4248
+ const output = Number(m.pricing.completion) * 1e3;
4249
+ if (Number.isFinite(input) && Number.isFinite(output)) {
4250
+ prices.set(key, { input, output });
4251
+ }
4252
+ }
4253
+ const cap = {};
4254
+ if (typeof m.context_length === "number" && m.context_length > 0) {
4255
+ cap.contextWindow = m.context_length;
4256
+ }
4257
+ if (Array.isArray(m.supported_parameters)) {
4258
+ cap.supportsTools = m.supported_parameters.includes("tools");
4259
+ }
4260
+ if (Array.isArray(m.architecture?.input_modalities)) {
4261
+ cap.inputModalities = m.architecture.input_modalities;
4262
+ }
4263
+ if (Object.keys(cap).length > 0) capabilities.set(key, cap);
4083
4264
  }
4084
- return out;
4265
+ return { prices, capabilities };
4085
4266
  } catch {
4086
4267
  return null;
4087
4268
  }
@@ -4092,7 +4273,8 @@ var LiveDataProvider = class {
4092
4273
  const cache = {
4093
4274
  fetchedAt: this.fetchedAt,
4094
4275
  snapshot: this.snapshot ?? void 0,
4095
- prices: Object.fromEntries(this.prices)
4276
+ prices: Object.fromEntries(this.prices),
4277
+ capabilities: Object.fromEntries(this.capabilities)
4096
4278
  };
4097
4279
  await fs9.writeFile(this.opts.cacheFile, JSON.stringify(cache, null, 2), "utf-8");
4098
4280
  } catch {
@@ -4117,6 +4299,27 @@ var LiveDataProvider = class {
4117
4299
  return { ...m, inputCostPer1kTokens: p.input, outputCostPer1kTokens: p.output };
4118
4300
  });
4119
4301
  }
4302
+ /** Current capability facts for a model id, or null when unknown. */
4303
+ getCapability(modelId) {
4304
+ return this.capabilities.get(normalizeModelId(modelId)) ?? null;
4305
+ }
4306
+ /**
4307
+ * Returns capability-corrected copies of each model (originals untouched):
4308
+ * real context windows replace the providers' hardcoded guesses, native
4309
+ * tool support replaces the assume-by-provider default, and vision is set
4310
+ * from the declared input modalities.
4311
+ */
4312
+ applyLiveCapabilities(models) {
4313
+ return models.map((m) => {
4314
+ const c = this.getCapability(m.id);
4315
+ if (!c) return m;
4316
+ const next = { ...m };
4317
+ if (c.contextWindow) next.contextWindow = c.contextWindow;
4318
+ if (c.supportsTools !== void 0) next.supportsToolUse = c.supportsTools;
4319
+ if (c.inputModalities) next.isVisionCapable = c.inputModalities.includes("image");
4320
+ return next;
4321
+ });
4322
+ }
4120
4323
  /** Where the active quality data came from — for /why and `cascade models`. */
4121
4324
  getDataSource() {
4122
4325
  return this.source;
@@ -4127,6 +4330,9 @@ var LiveDataProvider = class {
4127
4330
  hasLivePricing() {
4128
4331
  return this.prices.size > 0;
4129
4332
  }
4333
+ hasCapabilities() {
4334
+ return this.capabilities.size > 0;
4335
+ }
4130
4336
  };
4131
4337
 
4132
4338
  // src/core/router/benchmarks.ts
@@ -4315,6 +4521,57 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
4315
4521
  profiler.profileAll(allModels).catch(() => {
4316
4522
  });
4317
4523
  }
4524
+ /**
4525
+ * One-time native tool-call probe for local/compat models with NO capability
4526
+ * metadata. llama.cpp / LM Studio "/models" endpoints return ids only, so
4527
+ * `supportsToolUse` stays undefined and the T3 tool gate ASSUMES native
4528
+ * support — wrong for many local builds, which then fumble tool-format
4529
+ * output. Each unknown model is probed once with a trivial tool; the verdict
4530
+ * persists in the MemoryStore's model_cache (via the previously-dormant
4531
+ * getModelProfile read-back), so a model is probed once EVER, not per run.
4532
+ * Cloud providers are never probed — their metadata is authoritative.
4533
+ */
4534
+ async probeLocalToolSupport(store) {
4535
+ const unknown = this.selector.getAllAvailableModels().filter(
4536
+ (m) => m.provider === "openai-compatible" && m.supportsToolUse === void 0
4537
+ );
4538
+ for (const model of unknown) {
4539
+ const cached = store.getModelProfile(model.id, model.provider);
4540
+ let verdict = cached?.supportsToolUse;
4541
+ if (verdict === void 0) {
4542
+ const probed = await this.probeNativeToolCall(model);
4543
+ if (probed === null) continue;
4544
+ verdict = probed;
4545
+ store.saveModelCapability(model.id, model.provider, { supportsToolUse: verdict });
4546
+ }
4547
+ this.selector.addDynamicModel({ ...model, supportsToolUse: verdict });
4548
+ }
4549
+ for (const tier of ["T1", "T2", "T3"]) {
4550
+ const cur = this.tierModels.get(tier);
4551
+ if (!cur) continue;
4552
+ const fresh = this.selector.getModelById(cur.id);
4553
+ if (fresh) this.tierModels.set(tier, fresh);
4554
+ }
4555
+ }
4556
+ async probeNativeToolCall(model) {
4557
+ try {
4558
+ const cfg = this.config.providers.find((p) => p.type === model.provider) ?? { type: model.provider };
4559
+ const provider = this.createProvider(cfg, model);
4560
+ const result = await withTimeout(provider.generate({
4561
+ messages: [{ role: "user", content: "Call the echo tool with text set to 'ping'. Use the tool; do not answer in prose." }],
4562
+ tools: [{
4563
+ name: "echo",
4564
+ description: "Echo the given text back to the caller.",
4565
+ inputSchema: { type: "object", properties: { text: { type: "string", description: "Text to echo" } }, required: ["text"] }
4566
+ }],
4567
+ maxTokens: 80,
4568
+ temperature: 0
4569
+ }), 3e4, "tool-support probe timed out");
4570
+ return (result.toolCalls?.length ?? 0) > 0;
4571
+ } catch {
4572
+ return null;
4573
+ }
4574
+ }
4318
4575
  /**
4319
4576
  * Cascade Auto live data: discover/validate real model ids from each cloud
4320
4577
  * provider, then fetch current public quality scores + per-token prices and
@@ -4364,12 +4621,17 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
4364
4621
  await Promise.allSettled(tasks);
4365
4622
  }
4366
4623
  /**
4367
- * Replace available models with live-priced copies and refresh the already
4368
- * resolved tier models so shared-tier cost accounting uses current prices.
4624
+ * Replace available models with live-priced AND capability-corrected copies
4625
+ * (real context windows, native tool support, vision from modalities), then
4626
+ * refresh the already resolved tier models so shared-tier cost accounting and
4627
+ * the tool-use gate both see current data.
4369
4628
  */
4370
4629
  applyLivePricing() {
4371
- if (!this.liveData?.hasLivePricing()) return;
4372
- const updated = this.liveData.applyLivePricing(this.selector.getAllAvailableModels());
4630
+ if (!this.liveData) return;
4631
+ if (!this.liveData.hasLivePricing() && !this.liveData.hasCapabilities()) return;
4632
+ let updated = this.selector.getAllAvailableModels();
4633
+ if (this.liveData.hasLivePricing()) updated = this.liveData.applyLivePricing(updated);
4634
+ if (this.liveData.hasCapabilities()) updated = this.liveData.applyLiveCapabilities(updated);
4373
4635
  for (const m of updated) this.selector.addDynamicModel(m);
4374
4636
  for (const tier of ["T1", "T2", "T3"]) {
4375
4637
  const cur = this.tierModels.get(tier);
@@ -4544,6 +4806,10 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
4544
4806
  const r = this.config?.reinforcements;
4545
4807
  return { enabled: r?.enabled === true, maxPerSection: r?.maxPerSection ?? 4 };
4546
4808
  }
4809
+ /** Project-knowledge settings (config.knowledge). Facts extraction on by default. */
4810
+ getKnowledgeConfig() {
4811
+ return { factsExtraction: this.config?.knowledge?.factsExtraction !== false };
4812
+ }
4547
4813
  /**
4548
4814
  * Resolved T3 wave execution mode. 'auto' becomes 'sequential' when the T3
4549
4815
  * tier resolves to a LOCAL model (the single-GPU queue serializes anyway, so
@@ -4630,10 +4896,10 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
4630
4896
  * null when Cascade Auto is off (callers then use the shared tier model).
4631
4897
  * Pure heuristic — no extra LLM call.
4632
4898
  */
4633
- async selectModelForSubtask(tier, text) {
4899
+ async selectModelForSubtask(tier, text, opts) {
4634
4900
  if (!this.config?.cascadeAuto || !this.taskAnalyzer || !text.trim()) return null;
4635
4901
  try {
4636
- return await this.taskAnalyzer.selectModel(text, tier, this.selector);
4902
+ return await this.taskAnalyzer.selectModel(text, tier, this.selector, opts);
4637
4903
  } catch {
4638
4904
  return null;
4639
4905
  }
@@ -4974,6 +5240,13 @@ var BaseTier = class extends EventEmitter {
4974
5240
  hierarchyContext = "";
4975
5241
  /** Propagated AbortSignal — set by the tier's `execute()` before work begins. */
4976
5242
  signal;
5243
+ /**
5244
+ * True for the run's ROOT tier (T3 for Simple, T2 for Moderate, T1 for
5245
+ * Complex). Its own synthesis stream is the user-facing answer and is
5246
+ * tagged `primary` so the desktop renders it live — background workers,
5247
+ * which would interleave, are not tagged.
5248
+ */
5249
+ isPresenter = false;
4977
5250
  constructor(role, id, parentId) {
4978
5251
  super();
4979
5252
  this.role = role;
@@ -4981,6 +5254,10 @@ var BaseTier = class extends EventEmitter {
4981
5254
  this.parentId = parentId;
4982
5255
  this.label = this.id;
4983
5256
  }
5257
+ /** Mark this tier as the run's presenter (root tier). */
5258
+ setPresenter(on = true) {
5259
+ this.isPresenter = on;
5260
+ }
4984
5261
  getStatus() {
4985
5262
  return this.status;
4986
5263
  }
@@ -5378,6 +5655,13 @@ EXAMPLE \u2014 calling the "shell" tool to list files:
5378
5655
  {"name": "shell", "input": {"command": "ls -la"}}
5379
5656
  </tool_call>
5380
5657
 
5658
+ When you have enough information, stop calling tools and write your final answer.`;
5659
+ }
5660
+ function buildTextToolReminder(tools) {
5661
+ return `
5662
+ TOOL USE REMINDER:
5663
+ Call tools with a single <tool_call>{"name": "<tool_name>", "input": { ... }}</tool_call> block (valid JSON, double quotes, one tool per turn), matching the argument shapes of your earlier calls in this conversation.
5664
+ Available tools: ${tools.map((t) => t.name).join(", ")}.
5381
5665
  When you have enough information, stop calling tools and write your final answer.`;
5382
5666
  }
5383
5667
 
@@ -5617,6 +5901,9 @@ Now execute your subtask using this context where relevant.`
5617
5901
  } catch (e) {
5618
5902
  this.log("Failed to write to World State DB");
5619
5903
  }
5904
+ if (this.router.getKnowledgeConfig?.().factsExtraction !== false) {
5905
+ await this.extractAndStoreFacts(db, assignment, output);
5906
+ }
5620
5907
  }
5621
5908
  this.setStatus("COMPLETED", output);
5622
5909
  this.sendStatusUpdate({ progressPct: 100, currentAction: "Subtask complete", status: "IN_PROGRESS", output });
@@ -5678,7 +5965,7 @@ Now execute your subtask using this context where relevant.`
5678
5965
  let subtaskModel;
5679
5966
  try {
5680
5967
  const subtaskText = `${this.assignment?.subtaskTitle ?? ""} ${this.assignment?.description ?? ""} ${this.assignment?.expectedOutput ?? ""}`;
5681
- subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText) ?? void 0;
5968
+ subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText, { requiresToolUse: tools.length > 0 }) ?? void 0;
5682
5969
  if (subtaskModel) {
5683
5970
  this.log(`Cascade Auto: routing this subtask to ${subtaskModel.provider}:${subtaskModel.id}`);
5684
5971
  }
@@ -5686,7 +5973,8 @@ Now execute your subtask using this context where relevant.`
5686
5973
  }
5687
5974
  const effectiveModel = subtaskModel ?? this.router.getModelForTier("T3");
5688
5975
  const useTextTools = effectiveModel?.supportsToolUse === false && tools.length > 0;
5689
- const textToolSuffix = useTextTools ? buildTextToolSystemPrompt(tools) : "";
5976
+ let sentFullTextContract = false;
5977
+ let textContractSignature = "";
5690
5978
  while (iterations < MAX_ITERATIONS) {
5691
5979
  iterations++;
5692
5980
  this.throwIfCancelled();
@@ -5700,6 +5988,17 @@ Now execute your subtask using this context where relevant.`
5700
5988
  ${g.text}`
5701
5989
  });
5702
5990
  }
5991
+ let textToolSuffix = "";
5992
+ if (useTextTools) {
5993
+ const signature = tools.map((t) => t.name).join(",");
5994
+ if (!sentFullTextContract || signature !== textContractSignature) {
5995
+ textToolSuffix = buildTextToolSystemPrompt(tools);
5996
+ sentFullTextContract = true;
5997
+ textContractSignature = signature;
5998
+ } else {
5999
+ textToolSuffix = buildTextToolReminder(tools);
6000
+ }
6001
+ }
5703
6002
  const options = {
5704
6003
  messages: this.context.getMessages(),
5705
6004
  systemPrompt: this.systemPromptOverride + systemPrompt + (this.hierarchyContext ? `
@@ -5716,7 +6015,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
5716
6015
  "T3",
5717
6016
  options,
5718
6017
  (chunk) => {
5719
- this.emit("stream:token", { tierId: this.id, text: chunk.text });
6018
+ this.emit("stream:token", { tierId: this.id, text: chunk.text, primary: this.isPresenter });
5720
6019
  }
5721
6020
  );
5722
6021
  let effectiveToolCalls = result.toolCalls ?? [];
@@ -6189,6 +6488,41 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
6189
6488
  };
6190
6489
  }
6191
6490
  }
6491
+ /**
6492
+ * world-state v2: distill a completed subtask's output into durable
6493
+ * `(entity, relation, value)` facts and upsert them into the knowledge graph.
6494
+ * A bounded, cheap T3-tier call; entirely best-effort — any failure is swallowed
6495
+ * so it never blocks or fails the subtask. Respects the subtask's privacy tier
6496
+ * (a local-only subtask extracts on a local model too, never leaking to cloud).
6497
+ */
6498
+ async extractAndStoreFacts(db, assignment, output) {
6499
+ try {
6500
+ const prompt = `Extract durable project facts from this completed subtask.
6501
+ Return ONLY a JSON array of {"entity","relation","value"} triples describing lasting
6502
+ facts about the codebase/project \u2014 e.g. {"entity":"auth module","relation":"uses","value":"JWT"}.
6503
+ Ignore transient step-by-step details. At most 6 triples. If nothing durable, return [].
6504
+
6505
+ Subtask: ${assignment.subtaskTitle}
6506
+ Output:
6507
+ ${output.slice(0, 4e3)}`;
6508
+ const result = await this.router.generate("T3", {
6509
+ messages: [{ role: "user", content: prompt }],
6510
+ maxTokens: 300,
6511
+ temperature: 0,
6512
+ ...this.localOnlyMatch ? { forceLocal: true } : {}
6513
+ });
6514
+ const match = /\[[\s\S]*\]/.exec(result.content);
6515
+ if (!match) return;
6516
+ const facts = JSON.parse(match[0]);
6517
+ if (!Array.isArray(facts)) return;
6518
+ for (const f of facts.slice(0, 8)) {
6519
+ if (f && typeof f.entity === "string" && typeof f.relation === "string" && typeof f.value === "string") {
6520
+ db.upsertFact(f.entity, f.relation, f.value, this.id);
6521
+ }
6522
+ }
6523
+ } catch {
6524
+ }
6525
+ }
6192
6526
  async correctOutput(originalOutput, failures) {
6193
6527
  const correctionPrompt = `The following output failed these checks: ${failures.join(", ")}.
6194
6528
 
@@ -7177,6 +7511,7 @@ ${peerOutputs}` : "";
7177
7511
  chunkEnd++;
7178
7512
  }
7179
7513
  i = chunkEnd;
7514
+ const isLastChunk = chunkEnd >= completed.length;
7180
7515
  const prompt = `Summarize these T3 worker outputs for section "${assignment.sectionTitle}" in 2-3 sentences.
7181
7516
  ${currentSummary ? `
7182
7517
  PREVIOUS SUMMARY SO FAR:
@@ -7186,6 +7521,7 @@ NEW OUTPUTS TO INTEGRATE:
7186
7521
  ` : "\nOUTPUTS:\n"}${chunkText}${peerContext}`;
7187
7522
  const messages = [{ role: "user", content: prompt }];
7188
7523
  try {
7524
+ const streamFinal = isLastChunk && this.isPresenter ? (chunk) => this.emit("stream:token", { tierId: this.id, text: chunk.text, primary: true }) : void 0;
7189
7525
  const result = await this.router.generate("T2", {
7190
7526
  messages,
7191
7527
  systemPrompt: this.systemPromptOverride + "You are a T2 Manager. Summarize the work of your T3 workers succinctly." + (this.hierarchyContext ? `
@@ -7193,7 +7529,7 @@ NEW OUTPUTS TO INTEGRATE:
7193
7529
  HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
7194
7530
  maxTokens: 500,
7195
7531
  ...this.sectionModel ? { model: this.sectionModel } : {}
7196
- });
7532
+ }, streamFinal);
7197
7533
  currentSummary = result.content;
7198
7534
  } catch (err) {
7199
7535
  this.log(`aggregateResults: LLM summarization failed at chunk \u2014 returning raw T3 outputs. Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -7245,14 +7581,11 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
7245
7581
  ...this.sectionModel ? { model: this.sectionModel } : {}
7246
7582
  });
7247
7583
  const answer = result.content.trim().toUpperCase();
7248
- if (answer.includes("YES")) {
7249
- return { requestId: req.id, approved: true, always: true, decidedBy: "T2", reasoning: "T2 LLM evaluated: consistent with section goal" };
7250
- }
7251
- if (answer.includes("NO")) {
7252
- return { requestId: req.id, approved: false, always: true, decidedBy: "T2", reasoning: "T2 LLM evaluated: inconsistent with section goal" };
7253
- }
7584
+ const verdict = answer.includes("YES") ? "approve" : answer.includes("NO") ? "deny" : "unsure";
7585
+ (req.trail ??= []).push({ tier: "T2", verdict, reason: `T2: ${verdict === "approve" ? "consistent with section goal" : verdict === "deny" ? "inconsistent with section goal" : "unsure"}` });
7254
7586
  return null;
7255
7587
  } catch {
7588
+ (req.trail ??= []).push({ tier: "T2", verdict: "unsure", reason: "T2 evaluation failed" });
7256
7589
  return null;
7257
7590
  }
7258
7591
  }
@@ -7595,10 +7928,24 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
7595
7928
  }
7596
7929
  async decomposeTask(prompt, systemContext) {
7597
7930
  const db = this.router.getWorldStateDB?.();
7598
- const worldStateContext = db ? `
7931
+ let worldStateContext = "";
7932
+ if (db) {
7933
+ const knowledge = db.getFormattedKnowledge(prompt);
7934
+ if (knowledge) {
7935
+ worldStateContext = `
7936
+
7937
+ PROJECT KNOWLEDGE (relevant facts about this codebase):
7938
+ ${knowledge}`;
7939
+ } else {
7940
+ const log = db.getFormattedState();
7941
+ if (log && log !== "World State is currently empty.") {
7942
+ worldStateContext = `
7599
7943
 
7600
7944
  PROJECT WORLD STATE (Current architecture and recent changes):
7601
- ${db.getFormattedState()}` : "";
7945
+ ${log}`;
7946
+ }
7947
+ }
7948
+ }
7602
7949
  const contextSection = systemContext ? `
7603
7950
  Project context:
7604
7951
  ${systemContext}` : "";
@@ -7934,7 +8281,7 @@ Instructions:
7934
8281
  systemPrompt: this.systemPromptOverride + "You are a final output compiler. Summarize and format the task results clearly.",
7935
8282
  maxTokens: 8e3
7936
8283
  }, (chunk) => {
7937
- this.emit("stream:token", { tierId: this.id, text: chunk.text });
8284
+ this.emit("stream:token", { tierId: this.id, text: chunk.text, primary: this.isPresenter });
7938
8285
  });
7939
8286
  return result.content;
7940
8287
  }
@@ -7963,14 +8310,11 @@ Reply with exactly one word: YES, NO, or UNSURE.
7963
8310
  temperature: 0
7964
8311
  });
7965
8312
  const answer = result.content.trim().toUpperCase();
7966
- if (answer.includes("YES")) {
7967
- return { requestId: req.id, approved: true, always: true, decidedBy: "T1", reasoning: "T1 evaluated: consistent with overall task goal" };
7968
- }
7969
- if (answer.includes("NO")) {
7970
- return { requestId: req.id, approved: false, always: true, decidedBy: "T1", reasoning: "T1 evaluated: not consistent with overall task goal" };
7971
- }
8313
+ const verdict = answer.includes("YES") ? "approve" : answer.includes("NO") ? "deny" : "unsure";
8314
+ (req.trail ??= []).push({ tier: "T1", verdict, reason: `T1: ${verdict === "approve" ? "consistent with overall task goal" : verdict === "deny" ? "not consistent with overall task goal" : "unsure"}` });
7972
8315
  return null;
7973
8316
  } catch {
8317
+ (req.trail ??= []).push({ tier: "T1", verdict: "unsure", reason: "T1 evaluation failed" });
7974
8318
  return null;
7975
8319
  }
7976
8320
  }
@@ -9984,13 +10328,17 @@ var TaskAnalyzer = class {
9984
10328
  * Scores tier-eligible models using cost efficiency + historical performance.
9985
10329
  * Falls back to the priority-list default when no candidates have history.
9986
10330
  */
9987
- async selectModel(prompt, tier, selector) {
10331
+ async selectModel(prompt, tier, selector, opts) {
9988
10332
  const profile = await this.analyze(prompt);
9989
10333
  if (profile.requiresVision) {
9990
10334
  return selector.selectVisionModel();
9991
10335
  }
9992
- const candidates2 = selector.getCandidatesForTier(tier);
10336
+ let candidates2 = selector.getCandidatesForTier(tier);
9993
10337
  if (candidates2.length === 0) return selector.selectForTier(tier);
10338
+ if (opts?.requiresToolUse) {
10339
+ const toolCapable = candidates2.filter((m) => m.supportsToolUse !== false);
10340
+ if (toolCapable.length > 0) candidates2 = toolCapable;
10341
+ }
9994
10342
  const scored = candidates2.map((m) => ({
9995
10343
  model: m,
9996
10344
  score: this.scoreModel(m, profile)
@@ -10169,6 +10517,28 @@ var ModelPerformanceTracker = class {
10169
10517
  return Math.max(0.1, 1 - normalised * complexityWeight);
10170
10518
  }
10171
10519
  };
10520
+ var ivmCache;
10521
+ var ivmWarned = false;
10522
+ async function loadIsolatedVm() {
10523
+ if (ivmCache !== void 0) return ivmCache;
10524
+ try {
10525
+ const specifier = "isolated-vm";
10526
+ const mod = await import(specifier);
10527
+ ivmCache = mod.default ?? mod;
10528
+ } catch {
10529
+ ivmCache = null;
10530
+ }
10531
+ return ivmCache;
10532
+ }
10533
+ function safeJsonParse(text) {
10534
+ if (typeof text !== "string") return {};
10535
+ try {
10536
+ const v = JSON.parse(text);
10537
+ return v && typeof v === "object" ? v : {};
10538
+ } catch {
10539
+ return {};
10540
+ }
10541
+ }
10172
10542
  var DYNAMIC_TOOLS_FILE = "dynamic-tools.json";
10173
10543
  function normalizeToolSchema(schema) {
10174
10544
  if (schema && schema["type"] === "object" && typeof schema["properties"] === "object") {
@@ -10271,7 +10641,12 @@ var DynamicTool = class extends BaseTool {
10271
10641
  getEscalator;
10272
10642
  /** Untrusted = loaded from disk / a peer; its dangerous calls always re-prompt. */
10273
10643
  trusted;
10274
- constructor(spec, registry, getEscalator, trusted) {
10644
+ /** Resolve the configured sandbox mode at call time (default 'auto'). */
10645
+ getSandboxMode;
10646
+ /** Optional diagnostics sink (routed through the host so it survives the Ink TUI). */
10647
+ log;
10648
+ constructor(spec, registry, getEscalator, trusted, getSandboxMode = () => "auto", log = () => {
10649
+ }) {
10275
10650
  super();
10276
10651
  this.name = spec.name;
10277
10652
  this.description = spec.description;
@@ -10281,6 +10656,8 @@ var DynamicTool = class extends BaseTool {
10281
10656
  this.registry = registry;
10282
10657
  this.getEscalator = getEscalator;
10283
10658
  this.trusted = trusted;
10659
+ this.getSandboxMode = getSandboxMode;
10660
+ this.log = log;
10284
10661
  }
10285
10662
  isDangerous() {
10286
10663
  return this._isDangerous;
@@ -10317,8 +10694,94 @@ var DynamicTool = class extends BaseTool {
10317
10694
  return `Error calling ${toolName}: ${err instanceof Error ? err.message : String(err)}`;
10318
10695
  }
10319
10696
  };
10697
+ const mode = this.getSandboxMode();
10698
+ if (mode !== "worker") {
10699
+ const ivm = await loadIsolatedVm();
10700
+ if (ivm) return this.runInIsolate(ivm, input, callTool);
10701
+ if (mode === "isolate" && !ivmWarned) {
10702
+ ivmWarned = true;
10703
+ this.log("[tool-creator] isolated-vm is not available (not installed or failed to build) \u2014 dynamic tools fall back to the worker sandbox, which is NOT capability-confined. Install isolated-vm for a hard isolate.");
10704
+ }
10705
+ }
10320
10706
  return this.runInWorker(input, callTool);
10321
10707
  }
10708
+ /**
10709
+ * Run the generated code in a hard V8 isolate (isolated-vm). The isolate global
10710
+ * has no Node built-ins, so the code cannot see `process`, `require`, the
10711
+ * filesystem, or the network — it reaches the host ONLY through the injected
10712
+ * `callTool` (escalator-gated on the main thread) and `fetch` (SSRF-guarded via
10713
+ * bridgeFetch). `script.run({ timeout })` bounds synchronous CPU; an outer
10714
+ * wall-clock race + `isolate.dispose()` bounds async runaway (a never-resolving
10715
+ * await), mirroring the worker's terminate().
10716
+ */
10717
+ async runInIsolate(ivm, input, callTool) {
10718
+ const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
10719
+ const isolate = new ivm.Isolate({ memoryLimit: 128 });
10720
+ let disposed = false;
10721
+ const dispose = () => {
10722
+ if (!disposed) {
10723
+ disposed = true;
10724
+ try {
10725
+ isolate.dispose();
10726
+ } catch {
10727
+ }
10728
+ }
10729
+ };
10730
+ try {
10731
+ const context = await isolate.createContext();
10732
+ const jail = context.global;
10733
+ await jail.set("_callTool", new ivm.Reference(async (name, inputJson) => {
10734
+ const out = await callTool(String(name), safeJsonParse(inputJson));
10735
+ return String(out);
10736
+ }));
10737
+ await jail.set("_fetch", new ivm.Reference(async (url, initJson) => {
10738
+ const r = await bridgeFetch(String(url), safeJsonParse(initJson));
10739
+ return JSON.stringify(r);
10740
+ }));
10741
+ await jail.set("_input", new ivm.ExternalCopy(input).copyInto());
10742
+ await jail.set("_code", this.executeCode);
10743
+ const bootstrap = `
10744
+ const callTool = (name, toolInput) => _callTool.apply(undefined,
10745
+ [String(name), JSON.stringify(toolInput || {})],
10746
+ { result: { promise: true }, arguments: { copy: true } });
10747
+ const fetch = async (url, init) => {
10748
+ const raw = await _fetch.apply(undefined,
10749
+ [String(url), JSON.stringify(init || null)],
10750
+ { result: { promise: true }, arguments: { copy: true } });
10751
+ const r = JSON.parse(raw);
10752
+ if (r && r.__error) throw new Error(r.__error);
10753
+ return {
10754
+ ok: r.ok, status: r.status, statusText: r.statusText,
10755
+ headers: { get: (k) => (String(k).toLowerCase() === 'content-type' ? r.contentType : null) },
10756
+ text: async () => r.body,
10757
+ json: async () => JSON.parse(r.body),
10758
+ };
10759
+ };
10760
+ const console = { log() {}, error() {} };
10761
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
10762
+ const fn = new AsyncFunction('input', 'callTool', 'fetch', 'console', _code);
10763
+ (async () => { const r = await fn(_input, callTool, fetch, console); return String(r == null ? '' : r); })();
10764
+ `;
10765
+ const script = await isolate.compileScript(bootstrap);
10766
+ const runPromise = script.run(context, { timeout: timeoutMs, promise: true }).then((v) => String(v ?? ""));
10767
+ const timeoutPromise = new Promise((resolve) => {
10768
+ const t = setTimeout(() => {
10769
+ dispose();
10770
+ resolve(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`);
10771
+ }, timeoutMs + 500);
10772
+ t.unref?.();
10773
+ });
10774
+ return await Promise.race([runPromise, timeoutPromise]);
10775
+ } catch (err) {
10776
+ const msg = err instanceof Error ? err.message : String(err);
10777
+ if (/timed? out/i.test(msg)) {
10778
+ return `Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`;
10779
+ }
10780
+ return `Tool error: ${msg}`;
10781
+ } finally {
10782
+ dispose();
10783
+ }
10784
+ }
10322
10785
  /** Spawn the worker, service its callTool/fetch bridge, enforce the kill timeout. */
10323
10786
  runInWorker(input, callTool) {
10324
10787
  const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
@@ -10398,16 +10861,19 @@ var ToolCreator = class {
10398
10861
  workspacePath;
10399
10862
  /** When false, persisted tools are neither loaded nor written. */
10400
10863
  persistEnabled;
10864
+ /** Sandbox runtime for generated tools; passed to each DynamicTool. */
10865
+ sandboxMode;
10401
10866
  logger;
10402
10867
  /** name → spec, for persistence, broadcast, and re-registration. */
10403
10868
  specs = /* @__PURE__ */ new Map();
10404
10869
  /** capability fingerprint → tool name, so the same need isn't re-generated. */
10405
10870
  capabilityIndex = /* @__PURE__ */ new Map();
10406
- constructor(router, registry, workspacePath, persistEnabled = true) {
10871
+ constructor(router, registry, workspacePath, persistEnabled = true, sandboxMode = "auto") {
10407
10872
  this.router = router;
10408
10873
  this.registry = registry;
10409
10874
  this.workspacePath = workspacePath;
10410
10875
  this.persistEnabled = persistEnabled;
10876
+ this.sandboxMode = sandboxMode;
10411
10877
  }
10412
10878
  setPermissionEscalator(escalator) {
10413
10879
  this.escalator = escalator;
@@ -10495,7 +10961,14 @@ Required capability: ${description.slice(0, 300)}`;
10495
10961
  this.specs.set(spec.name, spec);
10496
10962
  return;
10497
10963
  }
10498
- const tool = new DynamicTool(spec, this.registry, () => this.escalator, trusted);
10964
+ const tool = new DynamicTool(
10965
+ spec,
10966
+ this.registry,
10967
+ () => this.escalator,
10968
+ trusted,
10969
+ () => this.sandboxMode,
10970
+ (msg) => this.log(msg)
10971
+ );
10499
10972
  this.registry.register(tool);
10500
10973
  this.specs.set(spec.name, spec);
10501
10974
  this.capabilityIndex.set(capabilityKey(`${spec.description}`), spec.name);
@@ -10544,6 +11017,9 @@ Required capability: ${description.slice(0, 300)}`;
10544
11017
  return Array.from(this.specs.keys());
10545
11018
  }
10546
11019
  };
11020
+ function normalizeKey(text) {
11021
+ return text.trim().toLowerCase().replace(/\s+/g, " ");
11022
+ }
10547
11023
  var WorldStateDB = class {
10548
11024
  constructor(workspacePath, debugMode = false) {
10549
11025
  this.workspacePath = workspacePath;
@@ -10565,6 +11041,16 @@ var WorldStateDB = class {
10565
11041
  encrypted_payload TEXT NOT NULL
10566
11042
  )
10567
11043
  `);
11044
+ this.db.exec(`
11045
+ CREATE TABLE IF NOT EXISTS facts (
11046
+ entity TEXT NOT NULL,
11047
+ relation TEXT NOT NULL,
11048
+ encrypted_value TEXT NOT NULL,
11049
+ source_worker TEXT NOT NULL,
11050
+ timestamp TEXT NOT NULL,
11051
+ PRIMARY KEY (entity, relation)
11052
+ )
11053
+ `);
10568
11054
  }
10569
11055
  workspacePath;
10570
11056
  debugMode;
@@ -10638,6 +11124,131 @@ var WorldStateDB = class {
10638
11124
  if (entries.length === 0) return "World State is currently empty.";
10639
11125
  return entries.map((e, idx) => `[${e.timestamp}] Step ${idx + 1} (${e.workerId}): ${e.summary}`).join("\n");
10640
11126
  }
11127
+ // ── world-state v2: queryable facts ──────────────
11128
+ /**
11129
+ * Upsert a fact. `(entity, relation)` is normalized so casing/whitespace don't
11130
+ * fragment the key; an existing pair is superseded (value + provenance updated)
11131
+ * rather than duplicated. Empty entity/relation/value are ignored.
11132
+ */
11133
+ upsertFact(entity, relation, value, sourceWorker, timestamp) {
11134
+ const e = normalizeKey(entity);
11135
+ const r = normalizeKey(relation);
11136
+ const v = value.trim();
11137
+ if (!e || !r || !v) return;
11138
+ const encrypted = this.encrypt(JSON.stringify({ value: v }));
11139
+ const stmt = this.db.prepare(`
11140
+ INSERT INTO facts (entity, relation, encrypted_value, source_worker, timestamp)
11141
+ VALUES (?, ?, ?, ?, ?)
11142
+ ON CONFLICT(entity, relation) DO UPDATE SET
11143
+ encrypted_value = excluded.encrypted_value,
11144
+ source_worker = excluded.source_worker,
11145
+ timestamp = excluded.timestamp
11146
+ `);
11147
+ stmt.run(e, r, encrypted, sourceWorker, timestamp ?? (/* @__PURE__ */ new Date()).toISOString());
11148
+ this.dumpDebugIfNeeded();
11149
+ }
11150
+ rowToFact(row) {
11151
+ let value;
11152
+ try {
11153
+ value = JSON.parse(this.decrypt(row.encrypted_value)).value;
11154
+ } catch {
11155
+ value = "[Decryption Failed - Payload Corrupted]";
11156
+ }
11157
+ return { entity: row.entity, relation: row.relation, value, sourceWorker: row.source_worker, timestamp: row.timestamp };
11158
+ }
11159
+ /** All facts whose entity matches one of the (normalized) query entities. */
11160
+ getFactsForEntities(entities) {
11161
+ const keys = Array.from(new Set(entities.map(normalizeKey).filter(Boolean)));
11162
+ if (keys.length === 0) return [];
11163
+ const placeholders = keys.map(() => "?").join(", ");
11164
+ const stmt = this.db.prepare(
11165
+ `SELECT entity, relation, encrypted_value, source_worker, timestamp FROM facts WHERE entity IN (${placeholders}) ORDER BY entity ASC, relation ASC`
11166
+ );
11167
+ return stmt.all(...keys).map((row) => this.rowToFact(row));
11168
+ }
11169
+ /** Every fact (used for tests / debug / whole-graph views). */
11170
+ getAllFacts() {
11171
+ const stmt = this.db.prepare("SELECT entity, relation, encrypted_value, source_worker, timestamp FROM facts ORDER BY entity ASC, relation ASC");
11172
+ return stmt.all().map((row) => this.rowToFact(row));
11173
+ }
11174
+ /**
11175
+ * A compact, human/LLM-readable fact block for T1/T2 planning. When `entities`
11176
+ * is given, only facts about those entities are included; otherwise all facts.
11177
+ * Returns '' when there are none so callers can fall back to the linear log.
11178
+ */
11179
+ getFormattedFacts(entities) {
11180
+ const facts = entities && entities.length > 0 ? this.getFactsForEntities(entities) : this.getAllFacts();
11181
+ if (facts.length === 0) return "";
11182
+ return facts.map((f) => `- ${f.entity} ${f.relation} ${f.value}`).join("\n");
11183
+ }
11184
+ /**
11185
+ * A compact knowledge block for T1/T2 planning. When `prompt` is given, facts
11186
+ * whose entity/relation/value mention a prompt token are preferred (relevance
11187
+ * filter); otherwise, or when nothing matches, all facts are used (capped at
11188
+ * `limit`). Returns '' when there are no facts, so the caller can fall back to
11189
+ * the raw linear log — this replaces replaying the whole log during planning.
11190
+ */
11191
+ getFormattedKnowledge(prompt, limit = 40) {
11192
+ const all = this.getAllFacts();
11193
+ if (all.length === 0) return "";
11194
+ let selected = all;
11195
+ if (prompt && prompt.trim()) {
11196
+ const tokens = Array.from(new Set(prompt.toLowerCase().match(/[a-z0-9_./-]{3,}/g) ?? []));
11197
+ if (tokens.length > 0) {
11198
+ const matched = all.filter((f) => {
11199
+ const hay = `${f.entity} ${f.relation} ${f.value}`.toLowerCase();
11200
+ return tokens.some((t) => hay.includes(t));
11201
+ });
11202
+ if (matched.length > 0) selected = matched;
11203
+ }
11204
+ }
11205
+ return selected.slice(0, limit).map((f) => `- ${f.entity} ${f.relation} ${f.value}`).join("\n");
11206
+ }
11207
+ // ── Export / Import ──────────────────────────
11208
+ //
11209
+ // Knowledge travels DECRYPTED in the export bundle (a portable plaintext
11210
+ // JSON file — the encryption key never leaves this machine) and re-encrypts
11211
+ // with the LOCAL key on import.
11212
+ exportKnowledge() {
11213
+ return { facts: this.getAllFacts(), worldLog: this.getAllEntries() };
11214
+ }
11215
+ /**
11216
+ * Merge imported knowledge. Facts upsert on (entity, relation) with
11217
+ * newer-timestamp-wins (a local fact newer than the imported one is kept);
11218
+ * log entries append with fresh ids, skipping exact duplicates
11219
+ * (worker + timestamp + summary). Returns counts of what actually landed.
11220
+ */
11221
+ importKnowledge(data) {
11222
+ let facts = 0;
11223
+ let logEntries = 0;
11224
+ if (Array.isArray(data.facts)) {
11225
+ const local = new Map(this.getAllFacts().map((f) => [`${f.entity}\0${f.relation}`, f.timestamp]));
11226
+ for (const f of data.facts) {
11227
+ if (!f || typeof f.entity !== "string" || typeof f.relation !== "string" || typeof f.value !== "string") continue;
11228
+ const key = `${normalizeKey(f.entity)}\0${normalizeKey(f.relation)}`;
11229
+ const localTs = local.get(key);
11230
+ const importTs = f.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
11231
+ if (localTs && localTs >= importTs) continue;
11232
+ this.upsertFact(f.entity, f.relation, f.value, f.sourceWorker ?? "imported", importTs);
11233
+ facts++;
11234
+ }
11235
+ }
11236
+ if (Array.isArray(data.worldLog)) {
11237
+ const seen = new Set(this.getAllEntries().map((e) => `${e.workerId}\0${e.timestamp}\0${e.summary}`));
11238
+ const stmt = this.db.prepare("INSERT INTO world_state (id, timestamp, worker_id, encrypted_payload) VALUES (?, ?, ?, ?)");
11239
+ for (const e of data.worldLog) {
11240
+ if (!e || typeof e.summary !== "string") continue;
11241
+ const workerId = e.workerId ?? "imported";
11242
+ const timestamp = e.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
11243
+ const key = `${workerId}\0${timestamp}\0${e.summary}`;
11244
+ if (seen.has(key)) continue;
11245
+ stmt.run(crypto.randomUUID(), timestamp, workerId, this.encrypt(JSON.stringify({ summary: e.summary })));
11246
+ seen.add(key);
11247
+ logEntries++;
11248
+ }
11249
+ }
11250
+ return { facts, logEntries };
11251
+ }
10641
11252
  dumpDebugIfNeeded() {
10642
11253
  if (!this.debugMode) return;
10643
11254
  try {
@@ -10774,7 +11385,8 @@ var Cascade = class _Cascade extends EventEmitter {
10774
11385
  }
10775
11386
  const cfg = this.config;
10776
11387
  if (cfg["enableToolCreation"] === true) {
10777
- this.toolCreator = new ToolCreator(this.router, this.toolRegistry, this.workspacePath, cfg["persistDynamicTools"] !== false);
11388
+ const sandboxMode = this.config.tools?.dynamicToolSandbox ?? "auto";
11389
+ this.toolCreator = new ToolCreator(this.router, this.toolRegistry, this.workspacePath, cfg["persistDynamicTools"] !== false, sandboxMode);
10778
11390
  this.toolCreator.setLogger((m) => {
10779
11391
  if (this.listenerCount("log") > 0) this.emit("log", { level: "info", message: m });
10780
11392
  });
@@ -11012,6 +11624,10 @@ ${last.partialOutput}` : "");
11012
11624
  this.router.profileModels(this.store).catch(() => {
11013
11625
  });
11014
11626
  }
11627
+ if (this.store) {
11628
+ this.router.probeLocalToolSupport(this.store).catch(() => {
11629
+ });
11630
+ }
11015
11631
  if (this.config.cascadeAuto) {
11016
11632
  this.router.refreshLiveData().catch(() => {
11017
11633
  });
@@ -11066,6 +11682,21 @@ ${last.partialOutput}` : "");
11066
11682
  const producesArtifact = /\b(?:create|build|implement|generate|write|refactor|rewrite|add|fix|deploy|install|migrate|scaffold|set up|save (?:a|the)|report|\.(?:pdf|md|txt|json|csv|py|js|ts|tsx|jsx|html|docx?))\b/i.test(p);
11067
11683
  return inquiry && !producesArtifact;
11068
11684
  }
11685
+ /**
11686
+ * Strong, explicit signals that a task needs the full hierarchy (planning +
11687
+ * multiple sections/workers). Deliberately conservative: requires a
11688
+ * build/implementation verb AND either an app/system-scale noun or an
11689
+ * explicit multi-part structure, so ordinary single-file asks (handled as
11690
+ * Simple/Moderate) don't get over-escalated.
11691
+ */
11692
+ looksClearlyComplex(prompt) {
11693
+ const p = prompt.trim();
11694
+ if (p.length < 24) return false;
11695
+ const buildVerb = /\b(?:build|implement|create|develop|design|scaffold|refactor|migrate|architect|set up|integrate)\b/i.test(p);
11696
+ const scaleNoun = /\b(?:app(?:lication)?|system|platform|service|api|backend|frontend|full[- ]?stack|website|dashboard|pipeline|microservices?|database schema|authentication|end[- ]to[- ]end|codebase|project|multiple files|several (?:files|modules|components)|test suite)\b/i.test(p);
11697
+ const multiPart = /(?:\b(?:and|then|also|plus|as well as)\b.*\b(?:and|then|also)\b)|(?:^|\n)\s*(?:[-*]|\d+[.)])\s+/i.test(p);
11698
+ return buildVerb && (scaleNoun || multiPart);
11699
+ }
11069
11700
  // Cache glob scan results per workspace path to avoid repeated I/O.
11070
11701
  static globCache = /* @__PURE__ */ new Map();
11071
11702
  async countWorkspaceFiles(workspacePath) {
@@ -11149,10 +11780,16 @@ ${prompt}` : prompt;
11149
11780
  let verdict;
11150
11781
  if (match) {
11151
11782
  verdict = match[1] === "simple" ? "Simple" : match[1] === "moderate" ? "Moderate" : "Complex";
11152
- this.recordDecision("complexity", `${verdict} \u2014 classifier: ${reason || "no reason given"}`);
11783
+ if (verdict !== "Complex" && this.looksClearlyComplex(prompt)) {
11784
+ this.recordDecision("complexity", `Complex \u2014 heuristic floor over classifier "${verdict}": explicit multi-step build/implementation signals (T1 engaged)`);
11785
+ verdict = "Complex";
11786
+ } else {
11787
+ this.recordDecision("complexity", `${verdict} \u2014 classifier: ${reason || "no reason given"}`);
11788
+ }
11153
11789
  } else {
11154
- verdict = prompt.trim().split(/\s+/).length <= 12 ? "Simple" : "Moderate";
11155
- this.recordDecision("complexity", `${verdict} \u2014 classifier output unparseable; defaulted by length`);
11790
+ const words = prompt.trim().split(/\s+/).length;
11791
+ verdict = words <= 12 ? "Simple" : words >= 40 || this.looksClearlyComplex(prompt) ? "Complex" : "Moderate";
11792
+ this.recordDecision("complexity", `${verdict} \u2014 classifier output unparseable; defaulted by length/signals`);
11156
11793
  }
11157
11794
  return verdict;
11158
11795
  } catch {
@@ -11204,7 +11841,15 @@ ${prompt}` : prompt;
11204
11841
  }
11205
11842
  escalator.resolveUserDecision(req.id, approved, always);
11206
11843
  });
11207
- const complexity = await this.determineComplexity(options.prompt, options.workspacePath || process.cwd(), options.conversationHistory);
11844
+ const forceTier = this.config.routing?.forceTier;
11845
+ const forced = forceTier === "T1" ? "Complex" : forceTier === "T2" ? "Moderate" : forceTier === "T3" ? "Simple" : void 0;
11846
+ let complexity;
11847
+ if (forced) {
11848
+ complexity = forced;
11849
+ this.recordDecision("complexity", `${forced} \u2014 manually forced via routing.forceTier=${forceTier}`);
11850
+ } else {
11851
+ complexity = await this.determineComplexity(options.prompt, options.workspacePath || process.cwd(), options.conversationHistory);
11852
+ }
11208
11853
  this.telemetry.capture("cascade:session_start", {
11209
11854
  complexity,
11210
11855
  providerCount: this.config.providers.length,
@@ -11284,6 +11929,7 @@ ${prompt}` : prompt;
11284
11929
  try {
11285
11930
  if (complexity === "Simple") {
11286
11931
  const t3 = new T3Worker(this.router, this.toolRegistry, "root");
11932
+ t3.setPresenter(true);
11287
11933
  t3.setHierarchyContext("You are the DIRECT worker for this task. There is no T1 Administrator or T2 Manager involved in this run.");
11288
11934
  if (identityPrompt) {
11289
11935
  t3.setSystemPromptOverride(identityPrompt);
@@ -11308,6 +11954,7 @@ ${prompt}` : prompt;
11308
11954
  this.emit("tier:status", { tierId: "t3-root", status: "COMPLETED", role: "T3" });
11309
11955
  } else if (complexity === "Moderate") {
11310
11956
  const t2 = new T2Manager(this.router, this.toolRegistry, "root");
11957
+ t2.setPresenter(true);
11311
11958
  t2.setHierarchyContext("You are the ROOT Manager for this task. There is no T1 Administrator involved in this run. You are responsible for decomposing the task and managing T3 workers directly.");
11312
11959
  if (identityPrompt) {
11313
11960
  t2.setSystemPromptOverride(identityPrompt);
@@ -11357,6 +12004,7 @@ ${prompt}` : prompt;
11357
12004
  }
11358
12005
  } else {
11359
12006
  const t1 = new T1Administrator(this.router, this.toolRegistry, this.config);
12007
+ t1.setPresenter(true);
11360
12008
  t1.setHierarchyContext("You are the top-level Administrator. You are responsible for the overall plan and supervising multiple T2 Managers.");
11361
12009
  if (identityPrompt) {
11362
12010
  t1.setSystemPromptOverride(identityPrompt);
@@ -15357,7 +16005,8 @@ var DashboardSocket = class {
15357
16005
  socket.on("cascade:run", (payload) => {
15358
16006
  if (typeof payload?.prompt === "string" && payload.prompt.trim()) {
15359
16007
  const sessionId = typeof payload.sessionId === "string" && payload.sessionId.trim() ? payload.sessionId.trim() : void 0;
15360
- callback(payload.prompt.trim(), payload.model ?? "auto", socket.id, sessionId);
16008
+ const forceTier = ["T1", "T2", "T3"].includes(payload.forceTier) ? payload.forceTier : void 0;
16009
+ callback(payload.prompt.trim(), payload.model ?? "auto", socket.id, sessionId, forceTier);
15361
16010
  }
15362
16011
  });
15363
16012
  });
@@ -15387,6 +16036,13 @@ var DashboardServer = class {
15387
16036
  * of runs the session performed in this server's lifetime.
15388
16037
  */
15389
16038
  sessionTaskIds = /* @__PURE__ */ new Map();
16039
+ /**
16040
+ * Tool-approval requests awaiting a user decision from a connected client,
16041
+ * keyed by the request's uuid. The desktop shows a modal on
16042
+ * `permission:user-required` and answers with `permission:decision`; this
16043
+ * map is how that answer reaches the run that's blocked on it.
16044
+ */
16045
+ pendingApprovals = /* @__PURE__ */ new Map();
15390
16046
  port;
15391
16047
  host;
15392
16048
  workspacePath;
@@ -15445,17 +16101,18 @@ var DashboardServer = class {
15445
16101
  }
15446
16102
  this.persistConfig();
15447
16103
  });
15448
- this.socket.onCascadeRun(async (prompt, model, socketId, requestedSessionId) => {
16104
+ this.socket.onCascadeRun(async (prompt, model, socketId, requestedSessionId, forceTier) => {
15449
16105
  const sessionId = requestedSessionId ?? randomUUID();
15450
16106
  const abortController = new AbortController();
15451
16107
  this.activeControllers.set(sessionId, abortController);
15452
16108
  const runPrompt = requestedSessionId ? this.buildContinuationPrompt(sessionId, prompt) : prompt;
15453
16109
  const title = this.persistRunStart(sessionId, prompt);
15454
- const cfg = model !== "auto" ? { ...this.config, models: { ...this.config.models, t1: model } } : this.config;
16110
+ let cfg = model !== "auto" ? { ...this.config, models: { ...this.config.models, t1: model } } : this.config;
16111
+ if (forceTier) cfg = { ...cfg, routing: { ...cfg.routing, forceTier } };
15455
16112
  const cascade = new Cascade(cfg, this.workspacePath, this.store);
15456
16113
  this.activeSessions.set(sessionId, cascade);
15457
16114
  cascade.on("stream:token", (e) => {
15458
- this.socket.emitToSocket(socketId, "stream:token", { sessionId, tierId: e.tierId, text: e.text });
16115
+ this.socket.emitToSocket(socketId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
15459
16116
  });
15460
16117
  cascade.on("tier:status", (e) => {
15461
16118
  this.socket.emitToSocket(socketId, "tier:status", { sessionId, ...e });
@@ -15467,7 +16124,11 @@ var DashboardServer = class {
15467
16124
  this.socket.emitPeerMessage(e);
15468
16125
  });
15469
16126
  try {
15470
- const result = await cascade.run({ prompt: runPrompt, signal: abortController.signal });
16127
+ const result = await cascade.run({
16128
+ prompt: runPrompt,
16129
+ signal: abortController.signal,
16130
+ approvalCallback: this.makeApprovalCallback(sessionId)
16131
+ });
15471
16132
  this.recordSessionTask(sessionId, result.taskId);
15472
16133
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
15473
16134
  this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
@@ -15486,10 +16147,18 @@ var DashboardServer = class {
15486
16147
  } finally {
15487
16148
  this.activeSessions.delete(sessionId);
15488
16149
  this.activeControllers.delete(sessionId);
16150
+ this.denyPendingApprovals(sessionId);
15489
16151
  }
15490
16152
  });
15491
16153
  this.socket.onSessionHalt((sessionId) => {
15492
16154
  this.activeControllers.get(sessionId)?.abort();
16155
+ this.denyPendingApprovals(sessionId);
16156
+ });
16157
+ this.socket.onApprovalResponse(({ requestId, approved, always }) => {
16158
+ const pending = this.pendingApprovals.get(requestId);
16159
+ if (!pending) return;
16160
+ this.pendingApprovals.delete(requestId);
16161
+ pending.resolve({ approved: !!approved, always: !!always });
15493
16162
  });
15494
16163
  this.socket.onSessionSteer((message, sessionId, nodeId) => {
15495
16164
  const steered = this.steerSessions(message, sessionId, nodeId);
@@ -15657,6 +16326,29 @@ var DashboardServer = class {
15657
16326
  list.push(taskId);
15658
16327
  this.sessionTaskIds.set(sessionId, list);
15659
16328
  }
16329
+ /**
16330
+ * Approval bridge: cascade calls this when a dangerous tool escalates to the
16331
+ * user. The request itself was already pushed to the client via the
16332
+ * `permission:user-required` forward; here we just park a resolver keyed by
16333
+ * the request id and wait for the client's `permission:decision` (handled in
16334
+ * onApprovalResponse). Never auto-approves — an unanswered request stays
16335
+ * pending until the client answers, the run ends, or the escalator's own
16336
+ * timeout denies it.
16337
+ */
16338
+ makeApprovalCallback(sessionId) {
16339
+ return (request) => new Promise((resolve) => {
16340
+ this.pendingApprovals.set(request.id, { resolve, sessionId });
16341
+ });
16342
+ }
16343
+ /** Deny + clear any approvals still pending for a session (run end / abort). */
16344
+ denyPendingApprovals(sessionId) {
16345
+ for (const [id, pending] of this.pendingApprovals) {
16346
+ if (pending.sessionId === sessionId) {
16347
+ this.pendingApprovals.delete(id);
16348
+ pending.resolve({ approved: false, always: false });
16349
+ }
16350
+ }
16351
+ }
15660
16352
  persistRuntimeRow(sessionId, title, status, latestPrompt) {
15661
16353
  const now = (/* @__PURE__ */ new Date()).toISOString();
15662
16354
  const row = { sessionId, title, workspacePath: this.workspacePath, status, startedAt: now, updatedAt: now, latestPrompt, isGlobal: false };
@@ -15845,15 +16537,6 @@ ${prompt}`;
15845
16537
  if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:halt", payload);
15846
16538
  res.json({ success: true, ...payload });
15847
16539
  });
15848
- this.app.post("/api/approve", auth, mutationLimiter, (req, res) => {
15849
- const body = req.body;
15850
- const sessionId = typeof body["sessionId"] === "string" ? body["sessionId"] : void 0;
15851
- const nodeId = typeof body["nodeId"] === "string" ? body["nodeId"] : void 0;
15852
- const payload = { sessionId, nodeId, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
15853
- this.socket.broadcast("session:approve", payload);
15854
- if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:approve", payload);
15855
- res.json({ success: true, ...payload });
15856
- });
15857
16540
  this.app.post("/api/inject", auth, mutationLimiter, (req, res) => {
15858
16541
  const body = req.body;
15859
16542
  const message = typeof body["message"] === "string" ? body["message"] : void 0;
@@ -15898,6 +16581,60 @@ ${prompt}`;
15898
16581
  this.socket.broadcast("runtime:refresh", { scope: "global" });
15899
16582
  res.json({ ok: true });
15900
16583
  });
16584
+ this.app.get("/api/export", auth, (req, res) => {
16585
+ try {
16586
+ const sessionsParam = typeof req.query["sessions"] === "string" ? req.query["sessions"] : "all";
16587
+ const includeMemories = req.query["memories"] === "1" || req.query["memories"] === "true";
16588
+ const ids = sessionsParam === "all" ? void 0 : sessionsParam.split(",").map((s) => s.trim()).filter(Boolean);
16589
+ const bundle = {
16590
+ format: "cascade-export@1",
16591
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
16592
+ sessions: this.store.exportSessions(ids)
16593
+ };
16594
+ if (includeMemories) {
16595
+ const ws = new WorldStateDB(this.workspacePath);
16596
+ try {
16597
+ bundle["memories"] = { ...ws.exportKnowledge(), identities: this.store.listIdentities() };
16598
+ } finally {
16599
+ ws.close();
16600
+ }
16601
+ }
16602
+ res.json(bundle);
16603
+ } catch (err) {
16604
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
16605
+ }
16606
+ });
16607
+ this.app.post("/api/import", auth, mutationLimiter, (req, res) => {
16608
+ try {
16609
+ const bundle = req.body;
16610
+ if (!bundle || bundle.format !== "cascade-export@1") {
16611
+ res.status(400).json({ error: "Not a cascade-export@1 bundle." });
16612
+ return;
16613
+ }
16614
+ const importedSessions = Array.isArray(bundle.sessions) ? this.store.importSessions(bundle.sessions) : [];
16615
+ for (const s of importedSessions) this.persistRuntimeRow(s.id, s.title, "COMPLETED");
16616
+ let facts = 0;
16617
+ let logEntries = 0;
16618
+ let identities = 0;
16619
+ if (bundle.memories) {
16620
+ const ws = new WorldStateDB(this.workspacePath);
16621
+ try {
16622
+ const counts = ws.importKnowledge(bundle.memories);
16623
+ facts = counts.facts;
16624
+ logEntries = counts.logEntries;
16625
+ } finally {
16626
+ ws.close();
16627
+ }
16628
+ if (Array.isArray(bundle.memories.identities)) {
16629
+ identities = this.store.importIdentities(bundle.memories.identities);
16630
+ }
16631
+ }
16632
+ this.socket.broadcast("runtime:refresh", { scope: "workspace" });
16633
+ res.json({ ok: true, imported: { sessions: importedSessions.length, facts, logEntries, identities } });
16634
+ } catch (err) {
16635
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
16636
+ }
16637
+ });
15901
16638
  this.app.post("/api/sessions/:id/rollback", auth, mutationLimiter, async (req, res) => {
15902
16639
  const sessionId = req.params.id;
15903
16640
  const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
@@ -16104,7 +16841,7 @@ ${prompt}`;
16104
16841
  const cascade = new Cascade(this.config, this.workspacePath, this.store);
16105
16842
  this.activeSessions.set(sessionId, cascade);
16106
16843
  cascade.on("stream:token", (e) => {
16107
- this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text });
16844
+ this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
16108
16845
  });
16109
16846
  cascade.on("tier:status", (e) => {
16110
16847
  this.socket.broadcastToRoom(`session:${sessionId}`, "tier:status", { sessionId, ...e });
@@ -16116,7 +16853,11 @@ ${prompt}`;
16116
16853
  this.socket.emitPeerMessage(e);
16117
16854
  });
16118
16855
  try {
16119
- const result = await cascade.run({ prompt: runPrompt, identityId: body.identityId });
16856
+ const result = await cascade.run({
16857
+ prompt: runPrompt,
16858
+ identityId: body.identityId,
16859
+ approvalCallback: this.makeApprovalCallback(sessionId)
16860
+ });
16120
16861
  this.recordSessionTask(sessionId, result.taskId);
16121
16862
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
16122
16863
  this.socket.broadcast("cost:update", {
@@ -16134,6 +16875,7 @@ ${prompt}`;
16134
16875
  });
16135
16876
  } finally {
16136
16877
  this.activeSessions.delete(sessionId);
16878
+ this.denyPendingApprovals(sessionId);
16137
16879
  }
16138
16880
  })();
16139
16881
  });