cascade-ai 0.13.2 → 0.15.1

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.cjs CHANGED
@@ -109,7 +109,7 @@ var __export = (target, all) => {
109
109
  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;
110
110
  var init_constants = __esm({
111
111
  "src/constants.ts"() {
112
- CASCADE_VERSION = "0.13.2";
112
+ CASCADE_VERSION = "0.15.1";
113
113
  CASCADE_CONFIG_FILE = ".cascade/config.json";
114
114
  CASCADE_DB_FILE = ".cascade/memory.db";
115
115
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -1415,29 +1415,71 @@ var init_ollama = __esm({
1415
1415
  async countTokens(text) {
1416
1416
  return Math.ceil(text.length / 4);
1417
1417
  }
1418
+ /**
1419
+ * Ask the Ollama server what a model can actually do. Modern Ollama returns a
1420
+ * `capabilities` array ("completion", "tools", "vision", …) and the model's
1421
+ * real context length in `model_info` — authoritative, unlike the hardcoded
1422
+ * family-name allowlist, which silently misclassifies any unlisted family.
1423
+ * Best-effort: null on old servers / errors, callers fall back to heuristics.
1424
+ */
1425
+ async showModel(name) {
1426
+ const ac = new AbortController();
1427
+ const timer = setTimeout(() => ac.abort(), 2500);
1428
+ try {
1429
+ const resp = await fetch(`${this.baseUrl}/api/show`, {
1430
+ method: "POST",
1431
+ headers: { "Content-Type": "application/json" },
1432
+ body: JSON.stringify({ model: name }),
1433
+ signal: ac.signal
1434
+ });
1435
+ if (!resp.ok) return null;
1436
+ const data = await resp.json();
1437
+ const out = {};
1438
+ if (Array.isArray(data.capabilities)) {
1439
+ out.tools = data.capabilities.includes("tools");
1440
+ out.vision = data.capabilities.includes("vision");
1441
+ }
1442
+ for (const [k, v] of Object.entries(data.model_info ?? {})) {
1443
+ if (k.endsWith(".context_length") && typeof v === "number" && v > 0) {
1444
+ out.contextWindow = v;
1445
+ break;
1446
+ }
1447
+ }
1448
+ return out.tools !== void 0 || out.contextWindow ? out : null;
1449
+ } catch {
1450
+ return null;
1451
+ } finally {
1452
+ clearTimeout(timer);
1453
+ }
1454
+ }
1418
1455
  async listModels() {
1419
1456
  try {
1420
1457
  const response = await fetch(`${this.baseUrl}/api/tags`);
1421
1458
  if (!response.ok) return [];
1422
1459
  const data = await response.json();
1423
1460
  const supportedKeywords = ["llama3", "llama2", "gemma", "mistral", "mixtral", "qwen", "phi3", "codellama", "deepseek", "llava", "starcoder", "stable-code", "nomic-embed"];
1424
- return data.models.filter((m) => {
1461
+ const entries = data.models.filter((m) => {
1425
1462
  const name = m.name.toLowerCase();
1426
1463
  return supportedKeywords.some((k) => name.includes(k));
1427
- }).map((m) => ({
1428
- id: m.name,
1429
- name: m.name,
1430
- provider: "ollama",
1431
- contextWindow: 128e3,
1432
- isVisionCapable: m.name.includes("llava") || m.name.includes("vision"),
1433
- inputCostPer1kTokens: 0,
1434
- outputCostPer1kTokens: 0,
1435
- maxOutputTokens: 4e3,
1436
- supportsStreaming: true,
1437
- isLocal: true,
1438
- supportsToolUse: isToolCapable(m.name),
1439
- minSizeB: this.parseSizeB(m.details?.parameter_size)
1440
- }));
1464
+ });
1465
+ const shows = await Promise.all(entries.map((m) => this.showModel(m.name)));
1466
+ return entries.map((m, i) => {
1467
+ const show = shows[i];
1468
+ return {
1469
+ id: m.name,
1470
+ name: m.name,
1471
+ provider: "ollama",
1472
+ contextWindow: show?.contextWindow ?? 128e3,
1473
+ isVisionCapable: show?.vision ?? (m.name.includes("llava") || m.name.includes("vision")),
1474
+ inputCostPer1kTokens: 0,
1475
+ outputCostPer1kTokens: 0,
1476
+ maxOutputTokens: 4e3,
1477
+ supportsStreaming: true,
1478
+ isLocal: true,
1479
+ supportsToolUse: show?.tools ?? isToolCapable(m.name),
1480
+ minSizeB: this.parseSizeB(m.details?.parameter_size)
1481
+ };
1482
+ });
1441
1483
  } catch {
1442
1484
  return [];
1443
1485
  }
@@ -2360,6 +2402,78 @@ Original error: ${err.message}`
2360
2402
  `).all(`%${query}%`, limit);
2361
2403
  return rows.map(this.deserializeMessage);
2362
2404
  }
2405
+ // ── Export / Import ──────────────────────────
2406
+ //
2407
+ // Chats travel as full Session objects (messages included) inside a
2408
+ // plaintext-JSON bundle. Import NEVER overwrites: every imported session
2409
+ // gets a fresh id (title suffixed "(imported)") and fresh message ids, so
2410
+ // re-importing the same bundle can duplicate but can never clobber.
2411
+ /** Full sessions (with messages) for the given ids, or ALL sessions. */
2412
+ exportSessions(ids) {
2413
+ const targets = ids && ids.length > 0 ? ids : this.db.prepare("SELECT id FROM sessions ORDER BY updated_at DESC").all().map((r) => r.id);
2414
+ const out = [];
2415
+ for (const id of targets) {
2416
+ const s = this.getSession(id);
2417
+ if (s) out.push(s);
2418
+ }
2419
+ return out;
2420
+ }
2421
+ /**
2422
+ * Import sessions from a bundle. Returns the created `{id, title}` rows so
2423
+ * the caller can mirror them into the runtime session list (the sidebar
2424
+ * reads runtime_sessions, not sessions). Malformed entries are skipped.
2425
+ */
2426
+ importSessions(sessions) {
2427
+ const out = [];
2428
+ const msgStmt = this.db.prepare(`
2429
+ INSERT INTO messages (id, session_id, role, content, timestamp, tokens, agent_messages)
2430
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2431
+ `);
2432
+ for (const s of sessions) {
2433
+ if (!s || typeof s !== "object") continue;
2434
+ const newId = crypto.randomUUID();
2435
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2436
+ const title = `${String(s.title || "Untitled").slice(0, 200)} (imported)`;
2437
+ const metadata = s.metadata && typeof s.metadata === "object" ? s.metadata : { totalTokens: 0, totalCostUsd: 0, modelsUsed: [], toolsUsed: [], taskCount: 0 };
2438
+ this.db.prepare(`
2439
+ INSERT INTO sessions (id, title, created_at, updated_at, identity_id, workspace_path, metadata)
2440
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2441
+ `).run(newId, title, s.createdAt ?? now, s.updatedAt ?? now, s.identityId ?? "default", s.workspacePath ?? "", JSON.stringify(metadata));
2442
+ for (const m of Array.isArray(s.messages) ? s.messages : []) {
2443
+ if (!m || m.role !== "user" && m.role !== "assistant" && m.role !== "system") continue;
2444
+ msgStmt.run(
2445
+ crypto.randomUUID(),
2446
+ newId,
2447
+ m.role,
2448
+ typeof m.content === "string" ? m.content : JSON.stringify(m.content),
2449
+ m.timestamp ?? now,
2450
+ m.tokens ? JSON.stringify(m.tokens) : null,
2451
+ m.agentMessages ? JSON.stringify(m.agentMessages) : null
2452
+ );
2453
+ }
2454
+ out.push({ id: newId, title });
2455
+ }
2456
+ return out;
2457
+ }
2458
+ /** Import identities/personas, deduped by (case-insensitive) name — an
2459
+ * existing name is skipped, never replaced. Imports are never the default. */
2460
+ importIdentities(identities) {
2461
+ const existing = new Set(this.listIdentities().map((i) => i.name.toLowerCase()));
2462
+ let imported = 0;
2463
+ for (const ident of identities ?? []) {
2464
+ if (!ident?.name || typeof ident.name !== "string") continue;
2465
+ if (existing.has(ident.name.toLowerCase())) continue;
2466
+ this.createIdentity({
2467
+ ...ident,
2468
+ id: crypto.randomUUID(),
2469
+ isDefault: false,
2470
+ createdAt: ident.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
2471
+ });
2472
+ existing.add(ident.name.toLowerCase());
2473
+ imported++;
2474
+ }
2475
+ return imported;
2476
+ }
2363
2477
  // ── Identities ────────────────────────────────
2364
2478
  createIdentity(identity) {
2365
2479
  this.db.prepare(`
@@ -2537,6 +2651,23 @@ Original error: ${err.message}`
2537
2651
  const row = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(`${provider}:${modelId}`);
2538
2652
  return row ? JSON.parse(row.metadata) : void 0;
2539
2653
  }
2654
+ /**
2655
+ * Persist a probed capability verdict (currently: native tool support) into
2656
+ * the model's cached profile, merging with whatever is already recorded.
2657
+ * Read back via getModelProfile — so a model is probed once ever, not once
2658
+ * per run.
2659
+ */
2660
+ saveModelCapability(modelId, provider, caps) {
2661
+ const cacheKey = `${provider}:${modelId}`;
2662
+ const existing = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(cacheKey);
2663
+ 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 };
2664
+ if (caps.supportsToolUse !== void 0) meta.supportsToolUse = caps.supportsToolUse;
2665
+ this.db.prepare(`
2666
+ INSERT INTO model_cache (id, provider, model_id, name, metadata, updated_at)
2667
+ VALUES (?, ?, ?, ?, ?, ?)
2668
+ ON CONFLICT(id) DO UPDATE SET metadata = excluded.metadata, updated_at = excluded.updated_at
2669
+ `).run(cacheKey, provider, modelId, meta.name ?? modelId, JSON.stringify(meta), (/* @__PURE__ */ new Date()).toISOString());
2670
+ }
2540
2671
  getProfiledModelIds() {
2541
2672
  const rows = this.db.prepare(
2542
2673
  "SELECT model_id FROM model_cache WHERE json_extract(metadata, '$.specializations') IS NOT NULL"
@@ -2826,7 +2957,15 @@ var ToolsConfigSchema = zod.z.object({
2826
2957
  mcpServers: zod.z.array(McpServerConfigSchema).optional(),
2827
2958
  mcpTrusted: zod.z.array(zod.z.string()).optional(),
2828
2959
  /** Web search backends — at least one should be configured for best results */
2829
- webSearch: WebSearchConfigSchema.optional()
2960
+ webSearch: WebSearchConfigSchema.optional(),
2961
+ /**
2962
+ * Sandbox runtime for LLM-authored dynamic tools:
2963
+ * - 'isolate': hard V8 isolate (isolated-vm) — no Node globals, true capability
2964
+ * confinement. Requires the optional native `isolated-vm` dependency.
2965
+ * - 'worker': node:worker_threads (resource/kill limits, but not capability-confined).
2966
+ * - 'auto' (default): use the isolate when `isolated-vm` loads, else fall back to worker.
2967
+ */
2968
+ dynamicToolSandbox: zod.z.enum(["isolate", "worker", "auto"]).default("auto")
2830
2969
  });
2831
2970
  var HookDefinitionSchema = zod.z.object({
2832
2971
  name: zod.z.string().optional(),
@@ -2938,10 +3077,20 @@ var CascadeConfigSchema = zod.z.object({
2938
3077
  /**
2939
3078
  * Runtime Tool Creation: when true, T3 workers can generate and register new tools
2940
3079
  * at runtime via the ToolCreator when no existing tool can handle a required operation.
2941
- * Generated tools are session-scoped and sandboxed in node:vm.
3080
+ * Generated tools are session-scoped and sandboxed (see tools.dynamicToolSandbox).
2942
3081
  * HTTP calls from generated tools require approval.
2943
3082
  */
2944
3083
  enableToolCreation: zod.z.boolean().default(true),
3084
+ /**
3085
+ * Project knowledge (world state). `factsExtraction`: after each worker
3086
+ * completes, run a cheap extraction pass that distills its output into
3087
+ * queryable entity/relation/value facts (superseding older facts), which T1
3088
+ * queries during planning instead of replaying the whole linear log. Best-effort
3089
+ * and non-blocking; set false to keep only the raw linear log.
3090
+ */
3091
+ knowledge: zod.z.object({
3092
+ factsExtraction: zod.z.boolean().default(true)
3093
+ }).default({}),
2945
3094
  /**
2946
3095
  * Persist runtime-generated tools to .cascade/dynamic-tools.json and reload them
2947
3096
  * on startup for cross-run dedup. Reloaded (and peer-received) tools are always
@@ -4039,6 +4188,7 @@ function normalizeModelId(id) {
4039
4188
  var LiveDataProvider = class {
4040
4189
  snapshot = null;
4041
4190
  prices = /* @__PURE__ */ new Map();
4191
+ capabilities = /* @__PURE__ */ new Map();
4042
4192
  source = "bundled";
4043
4193
  fetchedAt = 0;
4044
4194
  loaded = false;
@@ -4067,6 +4217,9 @@ var LiveDataProvider = class {
4067
4217
  if (cache.prices) {
4068
4218
  for (const [id, p] of Object.entries(cache.prices)) this.prices.set(id, p);
4069
4219
  }
4220
+ if (cache.capabilities) {
4221
+ for (const [id, c] of Object.entries(cache.capabilities)) this.capabilities.set(id, c);
4222
+ }
4070
4223
  this.fetchedAt = cache.fetchedAt ?? 0;
4071
4224
  } catch {
4072
4225
  }
@@ -4087,9 +4240,9 @@ var LiveDataProvider = class {
4087
4240
  const ttlMs = this.opts.refreshHours * 36e5;
4088
4241
  const fresh = ttlMs > 0 && Date.now() - this.fetchedAt < ttlMs;
4089
4242
  if (!force && fresh && this.source !== "bundled") return;
4090
- const [snap, prices] = await Promise.all([
4243
+ const [snap, catalog] = await Promise.all([
4091
4244
  this.opts.live ? this.fetchSnapshot() : Promise.resolve(null),
4092
- this.opts.pricingLive ? this.fetchPrices() : Promise.resolve(null)
4245
+ this.opts.pricingLive ? this.fetchCatalog() : Promise.resolve(null)
4093
4246
  ]);
4094
4247
  let changed = false;
4095
4248
  if (snap) {
@@ -4097,8 +4250,12 @@ var LiveDataProvider = class {
4097
4250
  this.source = "live";
4098
4251
  changed = true;
4099
4252
  }
4100
- if (prices && prices.size > 0) {
4101
- this.prices = prices;
4253
+ if (catalog && catalog.prices.size > 0) {
4254
+ this.prices = catalog.prices;
4255
+ changed = true;
4256
+ }
4257
+ if (catalog && catalog.capabilities.size > 0) {
4258
+ this.capabilities = catalog.capabilities;
4102
4259
  changed = true;
4103
4260
  }
4104
4261
  if (changed) {
@@ -4120,21 +4277,43 @@ var LiveDataProvider = class {
4120
4277
  return null;
4121
4278
  }
4122
4279
  }
4123
- async fetchPrices() {
4280
+ /**
4281
+ * One fetch of the OpenRouter catalog yields BOTH pricing and capability
4282
+ * facts (context window, native tool support, input modalities) — the
4283
+ * capability fields used to be discarded while providers guessed/hardcoded
4284
+ * them in their listModels() stubs.
4285
+ */
4286
+ async fetchCatalog() {
4124
4287
  try {
4125
4288
  const resp = await withTimeout(fetch(OPENROUTER_MODELS_URL), FETCH_TIMEOUT_MS, "pricing fetch timed out");
4126
4289
  if (!resp.ok) return null;
4127
4290
  const data = await resp.json();
4128
4291
  if (!Array.isArray(data?.data)) return null;
4129
- const out = /* @__PURE__ */ new Map();
4292
+ const prices = /* @__PURE__ */ new Map();
4293
+ const capabilities = /* @__PURE__ */ new Map();
4130
4294
  for (const m of data.data) {
4131
- if (!m?.id || !m.pricing) continue;
4132
- const input = Number(m.pricing.prompt) * 1e3;
4133
- const output = Number(m.pricing.completion) * 1e3;
4134
- if (!Number.isFinite(input) || !Number.isFinite(output)) continue;
4135
- out.set(normalizeModelId(m.id), { input, output });
4295
+ if (!m?.id) continue;
4296
+ const key = normalizeModelId(m.id);
4297
+ if (m.pricing) {
4298
+ const input = Number(m.pricing.prompt) * 1e3;
4299
+ const output = Number(m.pricing.completion) * 1e3;
4300
+ if (Number.isFinite(input) && Number.isFinite(output)) {
4301
+ prices.set(key, { input, output });
4302
+ }
4303
+ }
4304
+ const cap = {};
4305
+ if (typeof m.context_length === "number" && m.context_length > 0) {
4306
+ cap.contextWindow = m.context_length;
4307
+ }
4308
+ if (Array.isArray(m.supported_parameters)) {
4309
+ cap.supportsTools = m.supported_parameters.includes("tools");
4310
+ }
4311
+ if (Array.isArray(m.architecture?.input_modalities)) {
4312
+ cap.inputModalities = m.architecture.input_modalities;
4313
+ }
4314
+ if (Object.keys(cap).length > 0) capabilities.set(key, cap);
4136
4315
  }
4137
- return out;
4316
+ return { prices, capabilities };
4138
4317
  } catch {
4139
4318
  return null;
4140
4319
  }
@@ -4145,7 +4324,8 @@ var LiveDataProvider = class {
4145
4324
  const cache = {
4146
4325
  fetchedAt: this.fetchedAt,
4147
4326
  snapshot: this.snapshot ?? void 0,
4148
- prices: Object.fromEntries(this.prices)
4327
+ prices: Object.fromEntries(this.prices),
4328
+ capabilities: Object.fromEntries(this.capabilities)
4149
4329
  };
4150
4330
  await fs9__default.default.writeFile(this.opts.cacheFile, JSON.stringify(cache, null, 2), "utf-8");
4151
4331
  } catch {
@@ -4170,6 +4350,27 @@ var LiveDataProvider = class {
4170
4350
  return { ...m, inputCostPer1kTokens: p.input, outputCostPer1kTokens: p.output };
4171
4351
  });
4172
4352
  }
4353
+ /** Current capability facts for a model id, or null when unknown. */
4354
+ getCapability(modelId) {
4355
+ return this.capabilities.get(normalizeModelId(modelId)) ?? null;
4356
+ }
4357
+ /**
4358
+ * Returns capability-corrected copies of each model (originals untouched):
4359
+ * real context windows replace the providers' hardcoded guesses, native
4360
+ * tool support replaces the assume-by-provider default, and vision is set
4361
+ * from the declared input modalities.
4362
+ */
4363
+ applyLiveCapabilities(models) {
4364
+ return models.map((m) => {
4365
+ const c = this.getCapability(m.id);
4366
+ if (!c) return m;
4367
+ const next = { ...m };
4368
+ if (c.contextWindow) next.contextWindow = c.contextWindow;
4369
+ if (c.supportsTools !== void 0) next.supportsToolUse = c.supportsTools;
4370
+ if (c.inputModalities) next.isVisionCapable = c.inputModalities.includes("image");
4371
+ return next;
4372
+ });
4373
+ }
4173
4374
  /** Where the active quality data came from — for /why and `cascade models`. */
4174
4375
  getDataSource() {
4175
4376
  return this.source;
@@ -4180,6 +4381,9 @@ var LiveDataProvider = class {
4180
4381
  hasLivePricing() {
4181
4382
  return this.prices.size > 0;
4182
4383
  }
4384
+ hasCapabilities() {
4385
+ return this.capabilities.size > 0;
4386
+ }
4183
4387
  };
4184
4388
 
4185
4389
  // src/core/router/benchmarks.ts
@@ -4368,6 +4572,57 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
4368
4572
  profiler.profileAll(allModels).catch(() => {
4369
4573
  });
4370
4574
  }
4575
+ /**
4576
+ * One-time native tool-call probe for local/compat models with NO capability
4577
+ * metadata. llama.cpp / LM Studio "/models" endpoints return ids only, so
4578
+ * `supportsToolUse` stays undefined and the T3 tool gate ASSUMES native
4579
+ * support — wrong for many local builds, which then fumble tool-format
4580
+ * output. Each unknown model is probed once with a trivial tool; the verdict
4581
+ * persists in the MemoryStore's model_cache (via the previously-dormant
4582
+ * getModelProfile read-back), so a model is probed once EVER, not per run.
4583
+ * Cloud providers are never probed — their metadata is authoritative.
4584
+ */
4585
+ async probeLocalToolSupport(store) {
4586
+ const unknown = this.selector.getAllAvailableModels().filter(
4587
+ (m) => m.provider === "openai-compatible" && m.supportsToolUse === void 0
4588
+ );
4589
+ for (const model of unknown) {
4590
+ const cached = store.getModelProfile(model.id, model.provider);
4591
+ let verdict = cached?.supportsToolUse;
4592
+ if (verdict === void 0) {
4593
+ const probed = await this.probeNativeToolCall(model);
4594
+ if (probed === null) continue;
4595
+ verdict = probed;
4596
+ store.saveModelCapability(model.id, model.provider, { supportsToolUse: verdict });
4597
+ }
4598
+ this.selector.addDynamicModel({ ...model, supportsToolUse: verdict });
4599
+ }
4600
+ for (const tier of ["T1", "T2", "T3"]) {
4601
+ const cur = this.tierModels.get(tier);
4602
+ if (!cur) continue;
4603
+ const fresh = this.selector.getModelById(cur.id);
4604
+ if (fresh) this.tierModels.set(tier, fresh);
4605
+ }
4606
+ }
4607
+ async probeNativeToolCall(model) {
4608
+ try {
4609
+ const cfg = this.config.providers.find((p) => p.type === model.provider) ?? { type: model.provider };
4610
+ const provider = this.createProvider(cfg, model);
4611
+ const result = await withTimeout(provider.generate({
4612
+ messages: [{ role: "user", content: "Call the echo tool with text set to 'ping'. Use the tool; do not answer in prose." }],
4613
+ tools: [{
4614
+ name: "echo",
4615
+ description: "Echo the given text back to the caller.",
4616
+ inputSchema: { type: "object", properties: { text: { type: "string", description: "Text to echo" } }, required: ["text"] }
4617
+ }],
4618
+ maxTokens: 80,
4619
+ temperature: 0
4620
+ }), 3e4, "tool-support probe timed out");
4621
+ return (result.toolCalls?.length ?? 0) > 0;
4622
+ } catch {
4623
+ return null;
4624
+ }
4625
+ }
4371
4626
  /**
4372
4627
  * Cascade Auto live data: discover/validate real model ids from each cloud
4373
4628
  * provider, then fetch current public quality scores + per-token prices and
@@ -4417,12 +4672,17 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
4417
4672
  await Promise.allSettled(tasks);
4418
4673
  }
4419
4674
  /**
4420
- * Replace available models with live-priced copies and refresh the already
4421
- * resolved tier models so shared-tier cost accounting uses current prices.
4675
+ * Replace available models with live-priced AND capability-corrected copies
4676
+ * (real context windows, native tool support, vision from modalities), then
4677
+ * refresh the already resolved tier models so shared-tier cost accounting and
4678
+ * the tool-use gate both see current data.
4422
4679
  */
4423
4680
  applyLivePricing() {
4424
- if (!this.liveData?.hasLivePricing()) return;
4425
- const updated = this.liveData.applyLivePricing(this.selector.getAllAvailableModels());
4681
+ if (!this.liveData) return;
4682
+ if (!this.liveData.hasLivePricing() && !this.liveData.hasCapabilities()) return;
4683
+ let updated = this.selector.getAllAvailableModels();
4684
+ if (this.liveData.hasLivePricing()) updated = this.liveData.applyLivePricing(updated);
4685
+ if (this.liveData.hasCapabilities()) updated = this.liveData.applyLiveCapabilities(updated);
4426
4686
  for (const m of updated) this.selector.addDynamicModel(m);
4427
4687
  for (const tier of ["T1", "T2", "T3"]) {
4428
4688
  const cur = this.tierModels.get(tier);
@@ -4597,6 +4857,10 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
4597
4857
  const r = this.config?.reinforcements;
4598
4858
  return { enabled: r?.enabled === true, maxPerSection: r?.maxPerSection ?? 4 };
4599
4859
  }
4860
+ /** Project-knowledge settings (config.knowledge). Facts extraction on by default. */
4861
+ getKnowledgeConfig() {
4862
+ return { factsExtraction: this.config?.knowledge?.factsExtraction !== false };
4863
+ }
4600
4864
  /**
4601
4865
  * Resolved T3 wave execution mode. 'auto' becomes 'sequential' when the T3
4602
4866
  * tier resolves to a LOCAL model (the single-GPU queue serializes anyway, so
@@ -4683,10 +4947,10 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
4683
4947
  * null when Cascade Auto is off (callers then use the shared tier model).
4684
4948
  * Pure heuristic — no extra LLM call.
4685
4949
  */
4686
- async selectModelForSubtask(tier, text) {
4950
+ async selectModelForSubtask(tier, text, opts) {
4687
4951
  if (!this.config?.cascadeAuto || !this.taskAnalyzer || !text.trim()) return null;
4688
4952
  try {
4689
- return await this.taskAnalyzer.selectModel(text, tier, this.selector);
4953
+ return await this.taskAnalyzer.selectModel(text, tier, this.selector, opts);
4690
4954
  } catch {
4691
4955
  return null;
4692
4956
  }
@@ -5442,6 +5706,13 @@ EXAMPLE \u2014 calling the "shell" tool to list files:
5442
5706
  {"name": "shell", "input": {"command": "ls -la"}}
5443
5707
  </tool_call>
5444
5708
 
5709
+ When you have enough information, stop calling tools and write your final answer.`;
5710
+ }
5711
+ function buildTextToolReminder(tools) {
5712
+ return `
5713
+ TOOL USE REMINDER:
5714
+ 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.
5715
+ Available tools: ${tools.map((t) => t.name).join(", ")}.
5445
5716
  When you have enough information, stop calling tools and write your final answer.`;
5446
5717
  }
5447
5718
 
@@ -5681,6 +5952,9 @@ Now execute your subtask using this context where relevant.`
5681
5952
  } catch (e) {
5682
5953
  this.log("Failed to write to World State DB");
5683
5954
  }
5955
+ if (this.router.getKnowledgeConfig?.().factsExtraction !== false) {
5956
+ await this.extractAndStoreFacts(db, assignment, output);
5957
+ }
5684
5958
  }
5685
5959
  this.setStatus("COMPLETED", output);
5686
5960
  this.sendStatusUpdate({ progressPct: 100, currentAction: "Subtask complete", status: "IN_PROGRESS", output });
@@ -5742,7 +6016,7 @@ Now execute your subtask using this context where relevant.`
5742
6016
  let subtaskModel;
5743
6017
  try {
5744
6018
  const subtaskText = `${this.assignment?.subtaskTitle ?? ""} ${this.assignment?.description ?? ""} ${this.assignment?.expectedOutput ?? ""}`;
5745
- subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText) ?? void 0;
6019
+ subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText, { requiresToolUse: tools.length > 0 }) ?? void 0;
5746
6020
  if (subtaskModel) {
5747
6021
  this.log(`Cascade Auto: routing this subtask to ${subtaskModel.provider}:${subtaskModel.id}`);
5748
6022
  }
@@ -5750,7 +6024,8 @@ Now execute your subtask using this context where relevant.`
5750
6024
  }
5751
6025
  const effectiveModel = subtaskModel ?? this.router.getModelForTier("T3");
5752
6026
  const useTextTools = effectiveModel?.supportsToolUse === false && tools.length > 0;
5753
- const textToolSuffix = useTextTools ? buildTextToolSystemPrompt(tools) : "";
6027
+ let sentFullTextContract = false;
6028
+ let textContractSignature = "";
5754
6029
  while (iterations < MAX_ITERATIONS) {
5755
6030
  iterations++;
5756
6031
  this.throwIfCancelled();
@@ -5764,6 +6039,17 @@ Now execute your subtask using this context where relevant.`
5764
6039
  ${g.text}`
5765
6040
  });
5766
6041
  }
6042
+ let textToolSuffix = "";
6043
+ if (useTextTools) {
6044
+ const signature = tools.map((t) => t.name).join(",");
6045
+ if (!sentFullTextContract || signature !== textContractSignature) {
6046
+ textToolSuffix = buildTextToolSystemPrompt(tools);
6047
+ sentFullTextContract = true;
6048
+ textContractSignature = signature;
6049
+ } else {
6050
+ textToolSuffix = buildTextToolReminder(tools);
6051
+ }
6052
+ }
5767
6053
  const options = {
5768
6054
  messages: this.context.getMessages(),
5769
6055
  systemPrompt: this.systemPromptOverride + systemPrompt + (this.hierarchyContext ? `
@@ -6253,6 +6539,41 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
6253
6539
  };
6254
6540
  }
6255
6541
  }
6542
+ /**
6543
+ * world-state v2: distill a completed subtask's output into durable
6544
+ * `(entity, relation, value)` facts and upsert them into the knowledge graph.
6545
+ * A bounded, cheap T3-tier call; entirely best-effort — any failure is swallowed
6546
+ * so it never blocks or fails the subtask. Respects the subtask's privacy tier
6547
+ * (a local-only subtask extracts on a local model too, never leaking to cloud).
6548
+ */
6549
+ async extractAndStoreFacts(db, assignment, output) {
6550
+ try {
6551
+ const prompt = `Extract durable project facts from this completed subtask.
6552
+ Return ONLY a JSON array of {"entity","relation","value"} triples describing lasting
6553
+ facts about the codebase/project \u2014 e.g. {"entity":"auth module","relation":"uses","value":"JWT"}.
6554
+ Ignore transient step-by-step details. At most 6 triples. If nothing durable, return [].
6555
+
6556
+ Subtask: ${assignment.subtaskTitle}
6557
+ Output:
6558
+ ${output.slice(0, 4e3)}`;
6559
+ const result = await this.router.generate("T3", {
6560
+ messages: [{ role: "user", content: prompt }],
6561
+ maxTokens: 300,
6562
+ temperature: 0,
6563
+ ...this.localOnlyMatch ? { forceLocal: true } : {}
6564
+ });
6565
+ const match = /\[[\s\S]*\]/.exec(result.content);
6566
+ if (!match) return;
6567
+ const facts = JSON.parse(match[0]);
6568
+ if (!Array.isArray(facts)) return;
6569
+ for (const f of facts.slice(0, 8)) {
6570
+ if (f && typeof f.entity === "string" && typeof f.relation === "string" && typeof f.value === "string") {
6571
+ db.upsertFact(f.entity, f.relation, f.value, this.id);
6572
+ }
6573
+ }
6574
+ } catch {
6575
+ }
6576
+ }
6256
6577
  async correctOutput(originalOutput, failures) {
6257
6578
  const correctionPrompt = `The following output failed these checks: ${failures.join(", ")}.
6258
6579
 
@@ -7658,10 +7979,24 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
7658
7979
  }
7659
7980
  async decomposeTask(prompt, systemContext) {
7660
7981
  const db = this.router.getWorldStateDB?.();
7661
- const worldStateContext = db ? `
7982
+ let worldStateContext = "";
7983
+ if (db) {
7984
+ const knowledge = db.getFormattedKnowledge(prompt);
7985
+ if (knowledge) {
7986
+ worldStateContext = `
7987
+
7988
+ PROJECT KNOWLEDGE (relevant facts about this codebase):
7989
+ ${knowledge}`;
7990
+ } else {
7991
+ const log = db.getFormattedState();
7992
+ if (log && log !== "World State is currently empty.") {
7993
+ worldStateContext = `
7662
7994
 
7663
7995
  PROJECT WORLD STATE (Current architecture and recent changes):
7664
- ${db.getFormattedState()}` : "";
7996
+ ${log}`;
7997
+ }
7998
+ }
7999
+ }
7665
8000
  const contextSection = systemContext ? `
7666
8001
  Project context:
7667
8002
  ${systemContext}` : "";
@@ -10044,13 +10379,17 @@ var TaskAnalyzer = class {
10044
10379
  * Scores tier-eligible models using cost efficiency + historical performance.
10045
10380
  * Falls back to the priority-list default when no candidates have history.
10046
10381
  */
10047
- async selectModel(prompt, tier, selector) {
10382
+ async selectModel(prompt, tier, selector, opts) {
10048
10383
  const profile = await this.analyze(prompt);
10049
10384
  if (profile.requiresVision) {
10050
10385
  return selector.selectVisionModel();
10051
10386
  }
10052
- const candidates2 = selector.getCandidatesForTier(tier);
10387
+ let candidates2 = selector.getCandidatesForTier(tier);
10053
10388
  if (candidates2.length === 0) return selector.selectForTier(tier);
10389
+ if (opts?.requiresToolUse) {
10390
+ const toolCapable = candidates2.filter((m) => m.supportsToolUse !== false);
10391
+ if (toolCapable.length > 0) candidates2 = toolCapable;
10392
+ }
10054
10393
  const scored = candidates2.map((m) => ({
10055
10394
  model: m,
10056
10395
  score: this.scoreModel(m, profile)
@@ -10229,6 +10568,28 @@ var ModelPerformanceTracker = class {
10229
10568
  return Math.max(0.1, 1 - normalised * complexityWeight);
10230
10569
  }
10231
10570
  };
10571
+ var ivmCache;
10572
+ var ivmWarned = false;
10573
+ async function loadIsolatedVm() {
10574
+ if (ivmCache !== void 0) return ivmCache;
10575
+ try {
10576
+ const specifier = "isolated-vm";
10577
+ const mod = await import(specifier);
10578
+ ivmCache = mod.default ?? mod;
10579
+ } catch {
10580
+ ivmCache = null;
10581
+ }
10582
+ return ivmCache;
10583
+ }
10584
+ function safeJsonParse(text) {
10585
+ if (typeof text !== "string") return {};
10586
+ try {
10587
+ const v = JSON.parse(text);
10588
+ return v && typeof v === "object" ? v : {};
10589
+ } catch {
10590
+ return {};
10591
+ }
10592
+ }
10232
10593
  var DYNAMIC_TOOLS_FILE = "dynamic-tools.json";
10233
10594
  function normalizeToolSchema(schema) {
10234
10595
  if (schema && schema["type"] === "object" && typeof schema["properties"] === "object") {
@@ -10331,7 +10692,12 @@ var DynamicTool = class extends BaseTool {
10331
10692
  getEscalator;
10332
10693
  /** Untrusted = loaded from disk / a peer; its dangerous calls always re-prompt. */
10333
10694
  trusted;
10334
- constructor(spec, registry, getEscalator, trusted) {
10695
+ /** Resolve the configured sandbox mode at call time (default 'auto'). */
10696
+ getSandboxMode;
10697
+ /** Optional diagnostics sink (routed through the host so it survives the Ink TUI). */
10698
+ log;
10699
+ constructor(spec, registry, getEscalator, trusted, getSandboxMode = () => "auto", log = () => {
10700
+ }) {
10335
10701
  super();
10336
10702
  this.name = spec.name;
10337
10703
  this.description = spec.description;
@@ -10341,6 +10707,8 @@ var DynamicTool = class extends BaseTool {
10341
10707
  this.registry = registry;
10342
10708
  this.getEscalator = getEscalator;
10343
10709
  this.trusted = trusted;
10710
+ this.getSandboxMode = getSandboxMode;
10711
+ this.log = log;
10344
10712
  }
10345
10713
  isDangerous() {
10346
10714
  return this._isDangerous;
@@ -10377,8 +10745,94 @@ var DynamicTool = class extends BaseTool {
10377
10745
  return `Error calling ${toolName}: ${err instanceof Error ? err.message : String(err)}`;
10378
10746
  }
10379
10747
  };
10748
+ const mode = this.getSandboxMode();
10749
+ if (mode !== "worker") {
10750
+ const ivm = await loadIsolatedVm();
10751
+ if (ivm) return this.runInIsolate(ivm, input, callTool);
10752
+ if (mode === "isolate" && !ivmWarned) {
10753
+ ivmWarned = true;
10754
+ 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.");
10755
+ }
10756
+ }
10380
10757
  return this.runInWorker(input, callTool);
10381
10758
  }
10759
+ /**
10760
+ * Run the generated code in a hard V8 isolate (isolated-vm). The isolate global
10761
+ * has no Node built-ins, so the code cannot see `process`, `require`, the
10762
+ * filesystem, or the network — it reaches the host ONLY through the injected
10763
+ * `callTool` (escalator-gated on the main thread) and `fetch` (SSRF-guarded via
10764
+ * bridgeFetch). `script.run({ timeout })` bounds synchronous CPU; an outer
10765
+ * wall-clock race + `isolate.dispose()` bounds async runaway (a never-resolving
10766
+ * await), mirroring the worker's terminate().
10767
+ */
10768
+ async runInIsolate(ivm, input, callTool) {
10769
+ const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
10770
+ const isolate = new ivm.Isolate({ memoryLimit: 128 });
10771
+ let disposed = false;
10772
+ const dispose = () => {
10773
+ if (!disposed) {
10774
+ disposed = true;
10775
+ try {
10776
+ isolate.dispose();
10777
+ } catch {
10778
+ }
10779
+ }
10780
+ };
10781
+ try {
10782
+ const context = await isolate.createContext();
10783
+ const jail = context.global;
10784
+ await jail.set("_callTool", new ivm.Reference(async (name, inputJson) => {
10785
+ const out = await callTool(String(name), safeJsonParse(inputJson));
10786
+ return String(out);
10787
+ }));
10788
+ await jail.set("_fetch", new ivm.Reference(async (url, initJson) => {
10789
+ const r = await bridgeFetch(String(url), safeJsonParse(initJson));
10790
+ return JSON.stringify(r);
10791
+ }));
10792
+ await jail.set("_input", new ivm.ExternalCopy(input).copyInto());
10793
+ await jail.set("_code", this.executeCode);
10794
+ const bootstrap = `
10795
+ const callTool = (name, toolInput) => _callTool.apply(undefined,
10796
+ [String(name), JSON.stringify(toolInput || {})],
10797
+ { result: { promise: true }, arguments: { copy: true } });
10798
+ const fetch = async (url, init) => {
10799
+ const raw = await _fetch.apply(undefined,
10800
+ [String(url), JSON.stringify(init || null)],
10801
+ { result: { promise: true }, arguments: { copy: true } });
10802
+ const r = JSON.parse(raw);
10803
+ if (r && r.__error) throw new Error(r.__error);
10804
+ return {
10805
+ ok: r.ok, status: r.status, statusText: r.statusText,
10806
+ headers: { get: (k) => (String(k).toLowerCase() === 'content-type' ? r.contentType : null) },
10807
+ text: async () => r.body,
10808
+ json: async () => JSON.parse(r.body),
10809
+ };
10810
+ };
10811
+ const console = { log() {}, error() {} };
10812
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
10813
+ const fn = new AsyncFunction('input', 'callTool', 'fetch', 'console', _code);
10814
+ (async () => { const r = await fn(_input, callTool, fetch, console); return String(r == null ? '' : r); })();
10815
+ `;
10816
+ const script = await isolate.compileScript(bootstrap);
10817
+ const runPromise = script.run(context, { timeout: timeoutMs, promise: true }).then((v) => String(v ?? ""));
10818
+ const timeoutPromise = new Promise((resolve) => {
10819
+ const t = setTimeout(() => {
10820
+ dispose();
10821
+ resolve(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`);
10822
+ }, timeoutMs + 500);
10823
+ t.unref?.();
10824
+ });
10825
+ return await Promise.race([runPromise, timeoutPromise]);
10826
+ } catch (err) {
10827
+ const msg = err instanceof Error ? err.message : String(err);
10828
+ if (/timed? out/i.test(msg)) {
10829
+ return `Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`;
10830
+ }
10831
+ return `Tool error: ${msg}`;
10832
+ } finally {
10833
+ dispose();
10834
+ }
10835
+ }
10382
10836
  /** Spawn the worker, service its callTool/fetch bridge, enforce the kill timeout. */
10383
10837
  runInWorker(input, callTool) {
10384
10838
  const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
@@ -10458,16 +10912,19 @@ var ToolCreator = class {
10458
10912
  workspacePath;
10459
10913
  /** When false, persisted tools are neither loaded nor written. */
10460
10914
  persistEnabled;
10915
+ /** Sandbox runtime for generated tools; passed to each DynamicTool. */
10916
+ sandboxMode;
10461
10917
  logger;
10462
10918
  /** name → spec, for persistence, broadcast, and re-registration. */
10463
10919
  specs = /* @__PURE__ */ new Map();
10464
10920
  /** capability fingerprint → tool name, so the same need isn't re-generated. */
10465
10921
  capabilityIndex = /* @__PURE__ */ new Map();
10466
- constructor(router, registry, workspacePath, persistEnabled = true) {
10922
+ constructor(router, registry, workspacePath, persistEnabled = true, sandboxMode = "auto") {
10467
10923
  this.router = router;
10468
10924
  this.registry = registry;
10469
10925
  this.workspacePath = workspacePath;
10470
10926
  this.persistEnabled = persistEnabled;
10927
+ this.sandboxMode = sandboxMode;
10471
10928
  }
10472
10929
  setPermissionEscalator(escalator) {
10473
10930
  this.escalator = escalator;
@@ -10555,7 +11012,14 @@ Required capability: ${description.slice(0, 300)}`;
10555
11012
  this.specs.set(spec.name, spec);
10556
11013
  return;
10557
11014
  }
10558
- const tool = new DynamicTool(spec, this.registry, () => this.escalator, trusted);
11015
+ const tool = new DynamicTool(
11016
+ spec,
11017
+ this.registry,
11018
+ () => this.escalator,
11019
+ trusted,
11020
+ () => this.sandboxMode,
11021
+ (msg) => this.log(msg)
11022
+ );
10559
11023
  this.registry.register(tool);
10560
11024
  this.specs.set(spec.name, spec);
10561
11025
  this.capabilityIndex.set(capabilityKey(`${spec.description}`), spec.name);
@@ -10604,6 +11068,9 @@ Required capability: ${description.slice(0, 300)}`;
10604
11068
  return Array.from(this.specs.keys());
10605
11069
  }
10606
11070
  };
11071
+ function normalizeKey(text) {
11072
+ return text.trim().toLowerCase().replace(/\s+/g, " ");
11073
+ }
10607
11074
  var WorldStateDB = class {
10608
11075
  constructor(workspacePath, debugMode = false) {
10609
11076
  this.workspacePath = workspacePath;
@@ -10625,6 +11092,16 @@ var WorldStateDB = class {
10625
11092
  encrypted_payload TEXT NOT NULL
10626
11093
  )
10627
11094
  `);
11095
+ this.db.exec(`
11096
+ CREATE TABLE IF NOT EXISTS facts (
11097
+ entity TEXT NOT NULL,
11098
+ relation TEXT NOT NULL,
11099
+ encrypted_value TEXT NOT NULL,
11100
+ source_worker TEXT NOT NULL,
11101
+ timestamp TEXT NOT NULL,
11102
+ PRIMARY KEY (entity, relation)
11103
+ )
11104
+ `);
10628
11105
  }
10629
11106
  workspacePath;
10630
11107
  debugMode;
@@ -10698,6 +11175,131 @@ var WorldStateDB = class {
10698
11175
  if (entries.length === 0) return "World State is currently empty.";
10699
11176
  return entries.map((e, idx) => `[${e.timestamp}] Step ${idx + 1} (${e.workerId}): ${e.summary}`).join("\n");
10700
11177
  }
11178
+ // ── world-state v2: queryable facts ──────────────
11179
+ /**
11180
+ * Upsert a fact. `(entity, relation)` is normalized so casing/whitespace don't
11181
+ * fragment the key; an existing pair is superseded (value + provenance updated)
11182
+ * rather than duplicated. Empty entity/relation/value are ignored.
11183
+ */
11184
+ upsertFact(entity, relation, value, sourceWorker, timestamp) {
11185
+ const e = normalizeKey(entity);
11186
+ const r = normalizeKey(relation);
11187
+ const v = value.trim();
11188
+ if (!e || !r || !v) return;
11189
+ const encrypted = this.encrypt(JSON.stringify({ value: v }));
11190
+ const stmt = this.db.prepare(`
11191
+ INSERT INTO facts (entity, relation, encrypted_value, source_worker, timestamp)
11192
+ VALUES (?, ?, ?, ?, ?)
11193
+ ON CONFLICT(entity, relation) DO UPDATE SET
11194
+ encrypted_value = excluded.encrypted_value,
11195
+ source_worker = excluded.source_worker,
11196
+ timestamp = excluded.timestamp
11197
+ `);
11198
+ stmt.run(e, r, encrypted, sourceWorker, timestamp ?? (/* @__PURE__ */ new Date()).toISOString());
11199
+ this.dumpDebugIfNeeded();
11200
+ }
11201
+ rowToFact(row) {
11202
+ let value;
11203
+ try {
11204
+ value = JSON.parse(this.decrypt(row.encrypted_value)).value;
11205
+ } catch {
11206
+ value = "[Decryption Failed - Payload Corrupted]";
11207
+ }
11208
+ return { entity: row.entity, relation: row.relation, value, sourceWorker: row.source_worker, timestamp: row.timestamp };
11209
+ }
11210
+ /** All facts whose entity matches one of the (normalized) query entities. */
11211
+ getFactsForEntities(entities) {
11212
+ const keys = Array.from(new Set(entities.map(normalizeKey).filter(Boolean)));
11213
+ if (keys.length === 0) return [];
11214
+ const placeholders = keys.map(() => "?").join(", ");
11215
+ const stmt = this.db.prepare(
11216
+ `SELECT entity, relation, encrypted_value, source_worker, timestamp FROM facts WHERE entity IN (${placeholders}) ORDER BY entity ASC, relation ASC`
11217
+ );
11218
+ return stmt.all(...keys).map((row) => this.rowToFact(row));
11219
+ }
11220
+ /** Every fact (used for tests / debug / whole-graph views). */
11221
+ getAllFacts() {
11222
+ const stmt = this.db.prepare("SELECT entity, relation, encrypted_value, source_worker, timestamp FROM facts ORDER BY entity ASC, relation ASC");
11223
+ return stmt.all().map((row) => this.rowToFact(row));
11224
+ }
11225
+ /**
11226
+ * A compact, human/LLM-readable fact block for T1/T2 planning. When `entities`
11227
+ * is given, only facts about those entities are included; otherwise all facts.
11228
+ * Returns '' when there are none so callers can fall back to the linear log.
11229
+ */
11230
+ getFormattedFacts(entities) {
11231
+ const facts = entities && entities.length > 0 ? this.getFactsForEntities(entities) : this.getAllFacts();
11232
+ if (facts.length === 0) return "";
11233
+ return facts.map((f) => `- ${f.entity} ${f.relation} ${f.value}`).join("\n");
11234
+ }
11235
+ /**
11236
+ * A compact knowledge block for T1/T2 planning. When `prompt` is given, facts
11237
+ * whose entity/relation/value mention a prompt token are preferred (relevance
11238
+ * filter); otherwise, or when nothing matches, all facts are used (capped at
11239
+ * `limit`). Returns '' when there are no facts, so the caller can fall back to
11240
+ * the raw linear log — this replaces replaying the whole log during planning.
11241
+ */
11242
+ getFormattedKnowledge(prompt, limit = 40) {
11243
+ const all = this.getAllFacts();
11244
+ if (all.length === 0) return "";
11245
+ let selected = all;
11246
+ if (prompt && prompt.trim()) {
11247
+ const tokens = Array.from(new Set(prompt.toLowerCase().match(/[a-z0-9_./-]{3,}/g) ?? []));
11248
+ if (tokens.length > 0) {
11249
+ const matched = all.filter((f) => {
11250
+ const hay = `${f.entity} ${f.relation} ${f.value}`.toLowerCase();
11251
+ return tokens.some((t) => hay.includes(t));
11252
+ });
11253
+ if (matched.length > 0) selected = matched;
11254
+ }
11255
+ }
11256
+ return selected.slice(0, limit).map((f) => `- ${f.entity} ${f.relation} ${f.value}`).join("\n");
11257
+ }
11258
+ // ── Export / Import ──────────────────────────
11259
+ //
11260
+ // Knowledge travels DECRYPTED in the export bundle (a portable plaintext
11261
+ // JSON file — the encryption key never leaves this machine) and re-encrypts
11262
+ // with the LOCAL key on import.
11263
+ exportKnowledge() {
11264
+ return { facts: this.getAllFacts(), worldLog: this.getAllEntries() };
11265
+ }
11266
+ /**
11267
+ * Merge imported knowledge. Facts upsert on (entity, relation) with
11268
+ * newer-timestamp-wins (a local fact newer than the imported one is kept);
11269
+ * log entries append with fresh ids, skipping exact duplicates
11270
+ * (worker + timestamp + summary). Returns counts of what actually landed.
11271
+ */
11272
+ importKnowledge(data) {
11273
+ let facts = 0;
11274
+ let logEntries = 0;
11275
+ if (Array.isArray(data.facts)) {
11276
+ const local = new Map(this.getAllFacts().map((f) => [`${f.entity}\0${f.relation}`, f.timestamp]));
11277
+ for (const f of data.facts) {
11278
+ if (!f || typeof f.entity !== "string" || typeof f.relation !== "string" || typeof f.value !== "string") continue;
11279
+ const key = `${normalizeKey(f.entity)}\0${normalizeKey(f.relation)}`;
11280
+ const localTs = local.get(key);
11281
+ const importTs = f.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
11282
+ if (localTs && localTs >= importTs) continue;
11283
+ this.upsertFact(f.entity, f.relation, f.value, f.sourceWorker ?? "imported", importTs);
11284
+ facts++;
11285
+ }
11286
+ }
11287
+ if (Array.isArray(data.worldLog)) {
11288
+ const seen = new Set(this.getAllEntries().map((e) => `${e.workerId}\0${e.timestamp}\0${e.summary}`));
11289
+ const stmt = this.db.prepare("INSERT INTO world_state (id, timestamp, worker_id, encrypted_payload) VALUES (?, ?, ?, ?)");
11290
+ for (const e of data.worldLog) {
11291
+ if (!e || typeof e.summary !== "string") continue;
11292
+ const workerId = e.workerId ?? "imported";
11293
+ const timestamp = e.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
11294
+ const key = `${workerId}\0${timestamp}\0${e.summary}`;
11295
+ if (seen.has(key)) continue;
11296
+ stmt.run(crypto__default.default.randomUUID(), timestamp, workerId, this.encrypt(JSON.stringify({ summary: e.summary })));
11297
+ seen.add(key);
11298
+ logEntries++;
11299
+ }
11300
+ }
11301
+ return { facts, logEntries };
11302
+ }
10701
11303
  dumpDebugIfNeeded() {
10702
11304
  if (!this.debugMode) return;
10703
11305
  try {
@@ -10834,7 +11436,8 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
10834
11436
  }
10835
11437
  const cfg = this.config;
10836
11438
  if (cfg["enableToolCreation"] === true) {
10837
- this.toolCreator = new ToolCreator(this.router, this.toolRegistry, this.workspacePath, cfg["persistDynamicTools"] !== false);
11439
+ const sandboxMode = this.config.tools?.dynamicToolSandbox ?? "auto";
11440
+ this.toolCreator = new ToolCreator(this.router, this.toolRegistry, this.workspacePath, cfg["persistDynamicTools"] !== false, sandboxMode);
10838
11441
  this.toolCreator.setLogger((m) => {
10839
11442
  if (this.listenerCount("log") > 0) this.emit("log", { level: "info", message: m });
10840
11443
  });
@@ -11072,6 +11675,10 @@ ${last.partialOutput}` : "");
11072
11675
  this.router.profileModels(this.store).catch(() => {
11073
11676
  });
11074
11677
  }
11678
+ if (this.store) {
11679
+ this.router.probeLocalToolSupport(this.store).catch(() => {
11680
+ });
11681
+ }
11075
11682
  if (this.config.cascadeAuto) {
11076
11683
  this.router.refreshLiveData().catch(() => {
11077
11684
  });
@@ -16025,6 +16632,60 @@ ${prompt}`;
16025
16632
  this.socket.broadcast("runtime:refresh", { scope: "global" });
16026
16633
  res.json({ ok: true });
16027
16634
  });
16635
+ this.app.get("/api/export", auth, (req, res) => {
16636
+ try {
16637
+ const sessionsParam = typeof req.query["sessions"] === "string" ? req.query["sessions"] : "all";
16638
+ const includeMemories = req.query["memories"] === "1" || req.query["memories"] === "true";
16639
+ const ids = sessionsParam === "all" ? void 0 : sessionsParam.split(",").map((s) => s.trim()).filter(Boolean);
16640
+ const bundle = {
16641
+ format: "cascade-export@1",
16642
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
16643
+ sessions: this.store.exportSessions(ids)
16644
+ };
16645
+ if (includeMemories) {
16646
+ const ws = new WorldStateDB(this.workspacePath);
16647
+ try {
16648
+ bundle["memories"] = { ...ws.exportKnowledge(), identities: this.store.listIdentities() };
16649
+ } finally {
16650
+ ws.close();
16651
+ }
16652
+ }
16653
+ res.json(bundle);
16654
+ } catch (err) {
16655
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
16656
+ }
16657
+ });
16658
+ this.app.post("/api/import", auth, mutationLimiter, (req, res) => {
16659
+ try {
16660
+ const bundle = req.body;
16661
+ if (!bundle || bundle.format !== "cascade-export@1") {
16662
+ res.status(400).json({ error: "Not a cascade-export@1 bundle." });
16663
+ return;
16664
+ }
16665
+ const importedSessions = Array.isArray(bundle.sessions) ? this.store.importSessions(bundle.sessions) : [];
16666
+ for (const s of importedSessions) this.persistRuntimeRow(s.id, s.title, "COMPLETED");
16667
+ let facts = 0;
16668
+ let logEntries = 0;
16669
+ let identities = 0;
16670
+ if (bundle.memories) {
16671
+ const ws = new WorldStateDB(this.workspacePath);
16672
+ try {
16673
+ const counts = ws.importKnowledge(bundle.memories);
16674
+ facts = counts.facts;
16675
+ logEntries = counts.logEntries;
16676
+ } finally {
16677
+ ws.close();
16678
+ }
16679
+ if (Array.isArray(bundle.memories.identities)) {
16680
+ identities = this.store.importIdentities(bundle.memories.identities);
16681
+ }
16682
+ }
16683
+ this.socket.broadcast("runtime:refresh", { scope: "workspace" });
16684
+ res.json({ ok: true, imported: { sessions: importedSessions.length, facts, logEntries, identities } });
16685
+ } catch (err) {
16686
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
16687
+ }
16688
+ });
16028
16689
  this.app.post("/api/sessions/:id/rollback", auth, mutationLimiter, async (req, res) => {
16029
16690
  const sessionId = req.params.id;
16030
16691
  const taskIds = this.sessionTaskIds.get(sessionId) ?? [];