cascade-ai 0.13.2 → 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/index.js CHANGED
@@ -190,7 +190,7 @@ var init_audit_logger = __esm({
190
190
  });
191
191
 
192
192
  // src/constants.ts
193
- var CASCADE_VERSION = "0.13.2";
193
+ var CASCADE_VERSION = "0.15.0";
194
194
  var CASCADE_CONFIG_DIR = ".cascade";
195
195
  var CASCADE_MD_FILE = "CASCADE.md";
196
196
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -1436,29 +1436,71 @@ var OllamaProvider = class extends BaseProvider {
1436
1436
  async countTokens(text) {
1437
1437
  return Math.ceil(text.length / 4);
1438
1438
  }
1439
+ /**
1440
+ * Ask the Ollama server what a model can actually do. Modern Ollama returns a
1441
+ * `capabilities` array ("completion", "tools", "vision", …) and the model's
1442
+ * real context length in `model_info` — authoritative, unlike the hardcoded
1443
+ * family-name allowlist, which silently misclassifies any unlisted family.
1444
+ * Best-effort: null on old servers / errors, callers fall back to heuristics.
1445
+ */
1446
+ async showModel(name) {
1447
+ const ac = new AbortController();
1448
+ const timer = setTimeout(() => ac.abort(), 2500);
1449
+ try {
1450
+ const resp = await fetch(`${this.baseUrl}/api/show`, {
1451
+ method: "POST",
1452
+ headers: { "Content-Type": "application/json" },
1453
+ body: JSON.stringify({ model: name }),
1454
+ signal: ac.signal
1455
+ });
1456
+ if (!resp.ok) return null;
1457
+ const data = await resp.json();
1458
+ const out = {};
1459
+ if (Array.isArray(data.capabilities)) {
1460
+ out.tools = data.capabilities.includes("tools");
1461
+ out.vision = data.capabilities.includes("vision");
1462
+ }
1463
+ for (const [k, v] of Object.entries(data.model_info ?? {})) {
1464
+ if (k.endsWith(".context_length") && typeof v === "number" && v > 0) {
1465
+ out.contextWindow = v;
1466
+ break;
1467
+ }
1468
+ }
1469
+ return out.tools !== void 0 || out.contextWindow ? out : null;
1470
+ } catch {
1471
+ return null;
1472
+ } finally {
1473
+ clearTimeout(timer);
1474
+ }
1475
+ }
1439
1476
  async listModels() {
1440
1477
  try {
1441
1478
  const response = await fetch(`${this.baseUrl}/api/tags`);
1442
1479
  if (!response.ok) return [];
1443
1480
  const data = await response.json();
1444
1481
  const supportedKeywords = ["llama3", "llama2", "gemma", "mistral", "mixtral", "qwen", "phi3", "codellama", "deepseek", "llava", "starcoder", "stable-code", "nomic-embed"];
1445
- return data.models.filter((m) => {
1482
+ const entries = data.models.filter((m) => {
1446
1483
  const name = m.name.toLowerCase();
1447
1484
  return supportedKeywords.some((k) => name.includes(k));
1448
- }).map((m) => ({
1449
- id: m.name,
1450
- name: m.name,
1451
- provider: "ollama",
1452
- contextWindow: 128e3,
1453
- isVisionCapable: m.name.includes("llava") || m.name.includes("vision"),
1454
- inputCostPer1kTokens: 0,
1455
- outputCostPer1kTokens: 0,
1456
- maxOutputTokens: 4e3,
1457
- supportsStreaming: true,
1458
- isLocal: true,
1459
- supportsToolUse: isToolCapable(m.name),
1460
- minSizeB: this.parseSizeB(m.details?.parameter_size)
1461
- }));
1485
+ });
1486
+ const shows = await Promise.all(entries.map((m) => this.showModel(m.name)));
1487
+ return entries.map((m, i) => {
1488
+ const show = shows[i];
1489
+ return {
1490
+ id: m.name,
1491
+ name: m.name,
1492
+ provider: "ollama",
1493
+ contextWindow: show?.contextWindow ?? 128e3,
1494
+ isVisionCapable: show?.vision ?? (m.name.includes("llava") || m.name.includes("vision")),
1495
+ inputCostPer1kTokens: 0,
1496
+ outputCostPer1kTokens: 0,
1497
+ maxOutputTokens: 4e3,
1498
+ supportsStreaming: true,
1499
+ isLocal: true,
1500
+ supportsToolUse: show?.tools ?? isToolCapable(m.name),
1501
+ minSizeB: this.parseSizeB(m.details?.parameter_size)
1502
+ };
1503
+ });
1462
1504
  } catch {
1463
1505
  return [];
1464
1506
  }
@@ -2135,6 +2177,7 @@ function normalizeModelId(id) {
2135
2177
  var LiveDataProvider = class {
2136
2178
  snapshot = null;
2137
2179
  prices = /* @__PURE__ */ new Map();
2180
+ capabilities = /* @__PURE__ */ new Map();
2138
2181
  source = "bundled";
2139
2182
  fetchedAt = 0;
2140
2183
  loaded = false;
@@ -2163,6 +2206,9 @@ var LiveDataProvider = class {
2163
2206
  if (cache.prices) {
2164
2207
  for (const [id, p] of Object.entries(cache.prices)) this.prices.set(id, p);
2165
2208
  }
2209
+ if (cache.capabilities) {
2210
+ for (const [id, c] of Object.entries(cache.capabilities)) this.capabilities.set(id, c);
2211
+ }
2166
2212
  this.fetchedAt = cache.fetchedAt ?? 0;
2167
2213
  } catch {
2168
2214
  }
@@ -2183,9 +2229,9 @@ var LiveDataProvider = class {
2183
2229
  const ttlMs = this.opts.refreshHours * 36e5;
2184
2230
  const fresh = ttlMs > 0 && Date.now() - this.fetchedAt < ttlMs;
2185
2231
  if (!force && fresh && this.source !== "bundled") return;
2186
- const [snap, prices] = await Promise.all([
2232
+ const [snap, catalog] = await Promise.all([
2187
2233
  this.opts.live ? this.fetchSnapshot() : Promise.resolve(null),
2188
- this.opts.pricingLive ? this.fetchPrices() : Promise.resolve(null)
2234
+ this.opts.pricingLive ? this.fetchCatalog() : Promise.resolve(null)
2189
2235
  ]);
2190
2236
  let changed = false;
2191
2237
  if (snap) {
@@ -2193,8 +2239,12 @@ var LiveDataProvider = class {
2193
2239
  this.source = "live";
2194
2240
  changed = true;
2195
2241
  }
2196
- if (prices && prices.size > 0) {
2197
- this.prices = prices;
2242
+ if (catalog && catalog.prices.size > 0) {
2243
+ this.prices = catalog.prices;
2244
+ changed = true;
2245
+ }
2246
+ if (catalog && catalog.capabilities.size > 0) {
2247
+ this.capabilities = catalog.capabilities;
2198
2248
  changed = true;
2199
2249
  }
2200
2250
  if (changed) {
@@ -2216,21 +2266,43 @@ var LiveDataProvider = class {
2216
2266
  return null;
2217
2267
  }
2218
2268
  }
2219
- async fetchPrices() {
2269
+ /**
2270
+ * One fetch of the OpenRouter catalog yields BOTH pricing and capability
2271
+ * facts (context window, native tool support, input modalities) — the
2272
+ * capability fields used to be discarded while providers guessed/hardcoded
2273
+ * them in their listModels() stubs.
2274
+ */
2275
+ async fetchCatalog() {
2220
2276
  try {
2221
2277
  const resp = await withTimeout(fetch(OPENROUTER_MODELS_URL), FETCH_TIMEOUT_MS, "pricing fetch timed out");
2222
2278
  if (!resp.ok) return null;
2223
2279
  const data = await resp.json();
2224
2280
  if (!Array.isArray(data?.data)) return null;
2225
- const out = /* @__PURE__ */ new Map();
2281
+ const prices = /* @__PURE__ */ new Map();
2282
+ const capabilities = /* @__PURE__ */ new Map();
2226
2283
  for (const m of data.data) {
2227
- if (!m?.id || !m.pricing) continue;
2228
- const input = Number(m.pricing.prompt) * 1e3;
2229
- const output = Number(m.pricing.completion) * 1e3;
2230
- if (!Number.isFinite(input) || !Number.isFinite(output)) continue;
2231
- out.set(normalizeModelId(m.id), { input, output });
2284
+ if (!m?.id) continue;
2285
+ const key = normalizeModelId(m.id);
2286
+ if (m.pricing) {
2287
+ const input = Number(m.pricing.prompt) * 1e3;
2288
+ const output = Number(m.pricing.completion) * 1e3;
2289
+ if (Number.isFinite(input) && Number.isFinite(output)) {
2290
+ prices.set(key, { input, output });
2291
+ }
2292
+ }
2293
+ const cap = {};
2294
+ if (typeof m.context_length === "number" && m.context_length > 0) {
2295
+ cap.contextWindow = m.context_length;
2296
+ }
2297
+ if (Array.isArray(m.supported_parameters)) {
2298
+ cap.supportsTools = m.supported_parameters.includes("tools");
2299
+ }
2300
+ if (Array.isArray(m.architecture?.input_modalities)) {
2301
+ cap.inputModalities = m.architecture.input_modalities;
2302
+ }
2303
+ if (Object.keys(cap).length > 0) capabilities.set(key, cap);
2232
2304
  }
2233
- return out;
2305
+ return { prices, capabilities };
2234
2306
  } catch {
2235
2307
  return null;
2236
2308
  }
@@ -2241,7 +2313,8 @@ var LiveDataProvider = class {
2241
2313
  const cache = {
2242
2314
  fetchedAt: this.fetchedAt,
2243
2315
  snapshot: this.snapshot ?? void 0,
2244
- prices: Object.fromEntries(this.prices)
2316
+ prices: Object.fromEntries(this.prices),
2317
+ capabilities: Object.fromEntries(this.capabilities)
2245
2318
  };
2246
2319
  await fs4.writeFile(this.opts.cacheFile, JSON.stringify(cache, null, 2), "utf-8");
2247
2320
  } catch {
@@ -2266,6 +2339,27 @@ var LiveDataProvider = class {
2266
2339
  return { ...m, inputCostPer1kTokens: p.input, outputCostPer1kTokens: p.output };
2267
2340
  });
2268
2341
  }
2342
+ /** Current capability facts for a model id, or null when unknown. */
2343
+ getCapability(modelId) {
2344
+ return this.capabilities.get(normalizeModelId(modelId)) ?? null;
2345
+ }
2346
+ /**
2347
+ * Returns capability-corrected copies of each model (originals untouched):
2348
+ * real context windows replace the providers' hardcoded guesses, native
2349
+ * tool support replaces the assume-by-provider default, and vision is set
2350
+ * from the declared input modalities.
2351
+ */
2352
+ applyLiveCapabilities(models) {
2353
+ return models.map((m) => {
2354
+ const c = this.getCapability(m.id);
2355
+ if (!c) return m;
2356
+ const next = { ...m };
2357
+ if (c.contextWindow) next.contextWindow = c.contextWindow;
2358
+ if (c.supportsTools !== void 0) next.supportsToolUse = c.supportsTools;
2359
+ if (c.inputModalities) next.isVisionCapable = c.inputModalities.includes("image");
2360
+ return next;
2361
+ });
2362
+ }
2269
2363
  /** Where the active quality data came from — for /why and `cascade models`. */
2270
2364
  getDataSource() {
2271
2365
  return this.source;
@@ -2276,6 +2370,9 @@ var LiveDataProvider = class {
2276
2370
  hasLivePricing() {
2277
2371
  return this.prices.size > 0;
2278
2372
  }
2373
+ hasCapabilities() {
2374
+ return this.capabilities.size > 0;
2375
+ }
2279
2376
  };
2280
2377
 
2281
2378
  // src/core/router/benchmarks.ts
@@ -2464,6 +2561,57 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2464
2561
  profiler.profileAll(allModels).catch(() => {
2465
2562
  });
2466
2563
  }
2564
+ /**
2565
+ * One-time native tool-call probe for local/compat models with NO capability
2566
+ * metadata. llama.cpp / LM Studio "/models" endpoints return ids only, so
2567
+ * `supportsToolUse` stays undefined and the T3 tool gate ASSUMES native
2568
+ * support — wrong for many local builds, which then fumble tool-format
2569
+ * output. Each unknown model is probed once with a trivial tool; the verdict
2570
+ * persists in the MemoryStore's model_cache (via the previously-dormant
2571
+ * getModelProfile read-back), so a model is probed once EVER, not per run.
2572
+ * Cloud providers are never probed — their metadata is authoritative.
2573
+ */
2574
+ async probeLocalToolSupport(store) {
2575
+ const unknown = this.selector.getAllAvailableModels().filter(
2576
+ (m) => m.provider === "openai-compatible" && m.supportsToolUse === void 0
2577
+ );
2578
+ for (const model of unknown) {
2579
+ const cached = store.getModelProfile(model.id, model.provider);
2580
+ let verdict = cached?.supportsToolUse;
2581
+ if (verdict === void 0) {
2582
+ const probed = await this.probeNativeToolCall(model);
2583
+ if (probed === null) continue;
2584
+ verdict = probed;
2585
+ store.saveModelCapability(model.id, model.provider, { supportsToolUse: verdict });
2586
+ }
2587
+ this.selector.addDynamicModel({ ...model, supportsToolUse: verdict });
2588
+ }
2589
+ for (const tier of ["T1", "T2", "T3"]) {
2590
+ const cur = this.tierModels.get(tier);
2591
+ if (!cur) continue;
2592
+ const fresh = this.selector.getModelById(cur.id);
2593
+ if (fresh) this.tierModels.set(tier, fresh);
2594
+ }
2595
+ }
2596
+ async probeNativeToolCall(model) {
2597
+ try {
2598
+ const cfg = this.config.providers.find((p) => p.type === model.provider) ?? { type: model.provider };
2599
+ const provider = this.createProvider(cfg, model);
2600
+ const result = await withTimeout(provider.generate({
2601
+ messages: [{ role: "user", content: "Call the echo tool with text set to 'ping'. Use the tool; do not answer in prose." }],
2602
+ tools: [{
2603
+ name: "echo",
2604
+ description: "Echo the given text back to the caller.",
2605
+ inputSchema: { type: "object", properties: { text: { type: "string", description: "Text to echo" } }, required: ["text"] }
2606
+ }],
2607
+ maxTokens: 80,
2608
+ temperature: 0
2609
+ }), 3e4, "tool-support probe timed out");
2610
+ return (result.toolCalls?.length ?? 0) > 0;
2611
+ } catch {
2612
+ return null;
2613
+ }
2614
+ }
2467
2615
  /**
2468
2616
  * Cascade Auto live data: discover/validate real model ids from each cloud
2469
2617
  * provider, then fetch current public quality scores + per-token prices and
@@ -2513,12 +2661,17 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2513
2661
  await Promise.allSettled(tasks);
2514
2662
  }
2515
2663
  /**
2516
- * Replace available models with live-priced copies and refresh the already
2517
- * resolved tier models so shared-tier cost accounting uses current prices.
2664
+ * Replace available models with live-priced AND capability-corrected copies
2665
+ * (real context windows, native tool support, vision from modalities), then
2666
+ * refresh the already resolved tier models so shared-tier cost accounting and
2667
+ * the tool-use gate both see current data.
2518
2668
  */
2519
2669
  applyLivePricing() {
2520
- if (!this.liveData?.hasLivePricing()) return;
2521
- const updated = this.liveData.applyLivePricing(this.selector.getAllAvailableModels());
2670
+ if (!this.liveData) return;
2671
+ if (!this.liveData.hasLivePricing() && !this.liveData.hasCapabilities()) return;
2672
+ let updated = this.selector.getAllAvailableModels();
2673
+ if (this.liveData.hasLivePricing()) updated = this.liveData.applyLivePricing(updated);
2674
+ if (this.liveData.hasCapabilities()) updated = this.liveData.applyLiveCapabilities(updated);
2522
2675
  for (const m of updated) this.selector.addDynamicModel(m);
2523
2676
  for (const tier of ["T1", "T2", "T3"]) {
2524
2677
  const cur = this.tierModels.get(tier);
@@ -2693,6 +2846,10 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2693
2846
  const r = this.config?.reinforcements;
2694
2847
  return { enabled: r?.enabled === true, maxPerSection: r?.maxPerSection ?? 4 };
2695
2848
  }
2849
+ /** Project-knowledge settings (config.knowledge). Facts extraction on by default. */
2850
+ getKnowledgeConfig() {
2851
+ return { factsExtraction: this.config?.knowledge?.factsExtraction !== false };
2852
+ }
2696
2853
  /**
2697
2854
  * Resolved T3 wave execution mode. 'auto' becomes 'sequential' when the T3
2698
2855
  * tier resolves to a LOCAL model (the single-GPU queue serializes anyway, so
@@ -2779,10 +2936,10 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2779
2936
  * null when Cascade Auto is off (callers then use the shared tier model).
2780
2937
  * Pure heuristic — no extra LLM call.
2781
2938
  */
2782
- async selectModelForSubtask(tier, text) {
2939
+ async selectModelForSubtask(tier, text, opts) {
2783
2940
  if (!this.config?.cascadeAuto || !this.taskAnalyzer || !text.trim()) return null;
2784
2941
  try {
2785
- return await this.taskAnalyzer.selectModel(text, tier, this.selector);
2942
+ return await this.taskAnalyzer.selectModel(text, tier, this.selector, opts);
2786
2943
  } catch {
2787
2944
  return null;
2788
2945
  }
@@ -3537,6 +3694,13 @@ EXAMPLE \u2014 calling the "shell" tool to list files:
3537
3694
  {"name": "shell", "input": {"command": "ls -la"}}
3538
3695
  </tool_call>
3539
3696
 
3697
+ When you have enough information, stop calling tools and write your final answer.`;
3698
+ }
3699
+ function buildTextToolReminder(tools) {
3700
+ return `
3701
+ TOOL USE REMINDER:
3702
+ 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.
3703
+ Available tools: ${tools.map((t) => t.name).join(", ")}.
3540
3704
  When you have enough information, stop calling tools and write your final answer.`;
3541
3705
  }
3542
3706
 
@@ -3776,6 +3940,9 @@ Now execute your subtask using this context where relevant.`
3776
3940
  } catch (e) {
3777
3941
  this.log("Failed to write to World State DB");
3778
3942
  }
3943
+ if (this.router.getKnowledgeConfig?.().factsExtraction !== false) {
3944
+ await this.extractAndStoreFacts(db, assignment, output);
3945
+ }
3779
3946
  }
3780
3947
  this.setStatus("COMPLETED", output);
3781
3948
  this.sendStatusUpdate({ progressPct: 100, currentAction: "Subtask complete", status: "IN_PROGRESS", output });
@@ -3837,7 +4004,7 @@ Now execute your subtask using this context where relevant.`
3837
4004
  let subtaskModel;
3838
4005
  try {
3839
4006
  const subtaskText = `${this.assignment?.subtaskTitle ?? ""} ${this.assignment?.description ?? ""} ${this.assignment?.expectedOutput ?? ""}`;
3840
- subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText) ?? void 0;
4007
+ subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText, { requiresToolUse: tools.length > 0 }) ?? void 0;
3841
4008
  if (subtaskModel) {
3842
4009
  this.log(`Cascade Auto: routing this subtask to ${subtaskModel.provider}:${subtaskModel.id}`);
3843
4010
  }
@@ -3845,7 +4012,8 @@ Now execute your subtask using this context where relevant.`
3845
4012
  }
3846
4013
  const effectiveModel = subtaskModel ?? this.router.getModelForTier("T3");
3847
4014
  const useTextTools = effectiveModel?.supportsToolUse === false && tools.length > 0;
3848
- const textToolSuffix = useTextTools ? buildTextToolSystemPrompt(tools) : "";
4015
+ let sentFullTextContract = false;
4016
+ let textContractSignature = "";
3849
4017
  while (iterations < MAX_ITERATIONS) {
3850
4018
  iterations++;
3851
4019
  this.throwIfCancelled();
@@ -3859,6 +4027,17 @@ Now execute your subtask using this context where relevant.`
3859
4027
  ${g.text}`
3860
4028
  });
3861
4029
  }
4030
+ let textToolSuffix = "";
4031
+ if (useTextTools) {
4032
+ const signature = tools.map((t) => t.name).join(",");
4033
+ if (!sentFullTextContract || signature !== textContractSignature) {
4034
+ textToolSuffix = buildTextToolSystemPrompt(tools);
4035
+ sentFullTextContract = true;
4036
+ textContractSignature = signature;
4037
+ } else {
4038
+ textToolSuffix = buildTextToolReminder(tools);
4039
+ }
4040
+ }
3862
4041
  const options = {
3863
4042
  messages: this.context.getMessages(),
3864
4043
  systemPrompt: this.systemPromptOverride + systemPrompt + (this.hierarchyContext ? `
@@ -4348,6 +4527,41 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
4348
4527
  };
4349
4528
  }
4350
4529
  }
4530
+ /**
4531
+ * world-state v2: distill a completed subtask's output into durable
4532
+ * `(entity, relation, value)` facts and upsert them into the knowledge graph.
4533
+ * A bounded, cheap T3-tier call; entirely best-effort — any failure is swallowed
4534
+ * so it never blocks or fails the subtask. Respects the subtask's privacy tier
4535
+ * (a local-only subtask extracts on a local model too, never leaking to cloud).
4536
+ */
4537
+ async extractAndStoreFacts(db, assignment, output) {
4538
+ try {
4539
+ const prompt = `Extract durable project facts from this completed subtask.
4540
+ Return ONLY a JSON array of {"entity","relation","value"} triples describing lasting
4541
+ facts about the codebase/project \u2014 e.g. {"entity":"auth module","relation":"uses","value":"JWT"}.
4542
+ Ignore transient step-by-step details. At most 6 triples. If nothing durable, return [].
4543
+
4544
+ Subtask: ${assignment.subtaskTitle}
4545
+ Output:
4546
+ ${output.slice(0, 4e3)}`;
4547
+ const result = await this.router.generate("T3", {
4548
+ messages: [{ role: "user", content: prompt }],
4549
+ maxTokens: 300,
4550
+ temperature: 0,
4551
+ ...this.localOnlyMatch ? { forceLocal: true } : {}
4552
+ });
4553
+ const match = /\[[\s\S]*\]/.exec(result.content);
4554
+ if (!match) return;
4555
+ const facts = JSON.parse(match[0]);
4556
+ if (!Array.isArray(facts)) return;
4557
+ for (const f of facts.slice(0, 8)) {
4558
+ if (f && typeof f.entity === "string" && typeof f.relation === "string" && typeof f.value === "string") {
4559
+ db.upsertFact(f.entity, f.relation, f.value, this.id);
4560
+ }
4561
+ }
4562
+ } catch {
4563
+ }
4564
+ }
4351
4565
  async correctOutput(originalOutput, failures) {
4352
4566
  const correctionPrompt = `The following output failed these checks: ${failures.join(", ")}.
4353
4567
 
@@ -5750,10 +5964,24 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
5750
5964
  }
5751
5965
  async decomposeTask(prompt, systemContext) {
5752
5966
  const db = this.router.getWorldStateDB?.();
5753
- const worldStateContext = db ? `
5967
+ let worldStateContext = "";
5968
+ if (db) {
5969
+ const knowledge = db.getFormattedKnowledge(prompt);
5970
+ if (knowledge) {
5971
+ worldStateContext = `
5972
+
5973
+ PROJECT KNOWLEDGE (relevant facts about this codebase):
5974
+ ${knowledge}`;
5975
+ } else {
5976
+ const log = db.getFormattedState();
5977
+ if (log && log !== "World State is currently empty.") {
5978
+ worldStateContext = `
5754
5979
 
5755
5980
  PROJECT WORLD STATE (Current architecture and recent changes):
5756
- ${db.getFormattedState()}` : "";
5981
+ ${log}`;
5982
+ }
5983
+ }
5984
+ }
5757
5985
  const contextSection = systemContext ? `
5758
5986
  Project context:
5759
5987
  ${systemContext}` : "";
@@ -8015,7 +8243,15 @@ var ToolsConfigSchema = z.object({
8015
8243
  mcpServers: z.array(McpServerConfigSchema).optional(),
8016
8244
  mcpTrusted: z.array(z.string()).optional(),
8017
8245
  /** Web search backends — at least one should be configured for best results */
8018
- webSearch: WebSearchConfigSchema.optional()
8246
+ webSearch: WebSearchConfigSchema.optional(),
8247
+ /**
8248
+ * Sandbox runtime for LLM-authored dynamic tools:
8249
+ * - 'isolate': hard V8 isolate (isolated-vm) — no Node globals, true capability
8250
+ * confinement. Requires the optional native `isolated-vm` dependency.
8251
+ * - 'worker': node:worker_threads (resource/kill limits, but not capability-confined).
8252
+ * - 'auto' (default): use the isolate when `isolated-vm` loads, else fall back to worker.
8253
+ */
8254
+ dynamicToolSandbox: z.enum(["isolate", "worker", "auto"]).default("auto")
8019
8255
  });
8020
8256
  var HookDefinitionSchema = z.object({
8021
8257
  name: z.string().optional(),
@@ -8127,10 +8363,20 @@ var CascadeConfigSchema = z.object({
8127
8363
  /**
8128
8364
  * Runtime Tool Creation: when true, T3 workers can generate and register new tools
8129
8365
  * at runtime via the ToolCreator when no existing tool can handle a required operation.
8130
- * Generated tools are session-scoped and sandboxed in node:vm.
8366
+ * Generated tools are session-scoped and sandboxed (see tools.dynamicToolSandbox).
8131
8367
  * HTTP calls from generated tools require approval.
8132
8368
  */
8133
8369
  enableToolCreation: z.boolean().default(true),
8370
+ /**
8371
+ * Project knowledge (world state). `factsExtraction`: after each worker
8372
+ * completes, run a cheap extraction pass that distills its output into
8373
+ * queryable entity/relation/value facts (superseding older facts), which T1
8374
+ * queries during planning instead of replaying the whole linear log. Best-effort
8375
+ * and non-blocking; set false to keep only the raw linear log.
8376
+ */
8377
+ knowledge: z.object({
8378
+ factsExtraction: z.boolean().default(true)
8379
+ }).default({}),
8134
8380
  /**
8135
8381
  * Persist runtime-generated tools to .cascade/dynamic-tools.json and reload them
8136
8382
  * on startup for cross-run dedup. Reloaded (and peer-received) tools are always
@@ -8423,13 +8669,17 @@ var TaskAnalyzer = class {
8423
8669
  * Scores tier-eligible models using cost efficiency + historical performance.
8424
8670
  * Falls back to the priority-list default when no candidates have history.
8425
8671
  */
8426
- async selectModel(prompt, tier, selector) {
8672
+ async selectModel(prompt, tier, selector, opts) {
8427
8673
  const profile = await this.analyze(prompt);
8428
8674
  if (profile.requiresVision) {
8429
8675
  return selector.selectVisionModel();
8430
8676
  }
8431
- const candidates = selector.getCandidatesForTier(tier);
8677
+ let candidates = selector.getCandidatesForTier(tier);
8432
8678
  if (candidates.length === 0) return selector.selectForTier(tier);
8679
+ if (opts?.requiresToolUse) {
8680
+ const toolCapable = candidates.filter((m) => m.supportsToolUse !== false);
8681
+ if (toolCapable.length > 0) candidates = toolCapable;
8682
+ }
8433
8683
  const scored = candidates.map((m) => ({
8434
8684
  model: m,
8435
8685
  score: this.scoreModel(m, profile)
@@ -8608,6 +8858,28 @@ var ModelPerformanceTracker = class {
8608
8858
  return Math.max(0.1, 1 - normalised * complexityWeight);
8609
8859
  }
8610
8860
  };
8861
+ var ivmCache;
8862
+ var ivmWarned = false;
8863
+ async function loadIsolatedVm() {
8864
+ if (ivmCache !== void 0) return ivmCache;
8865
+ try {
8866
+ const specifier = "isolated-vm";
8867
+ const mod = await import(specifier);
8868
+ ivmCache = mod.default ?? mod;
8869
+ } catch {
8870
+ ivmCache = null;
8871
+ }
8872
+ return ivmCache;
8873
+ }
8874
+ function safeJsonParse(text) {
8875
+ if (typeof text !== "string") return {};
8876
+ try {
8877
+ const v = JSON.parse(text);
8878
+ return v && typeof v === "object" ? v : {};
8879
+ } catch {
8880
+ return {};
8881
+ }
8882
+ }
8611
8883
  var DYNAMIC_TOOLS_FILE = "dynamic-tools.json";
8612
8884
  function normalizeToolSchema(schema) {
8613
8885
  if (schema && schema["type"] === "object" && typeof schema["properties"] === "object") {
@@ -8710,7 +8982,12 @@ var DynamicTool = class extends BaseTool {
8710
8982
  getEscalator;
8711
8983
  /** Untrusted = loaded from disk / a peer; its dangerous calls always re-prompt. */
8712
8984
  trusted;
8713
- constructor(spec, registry, getEscalator, trusted) {
8985
+ /** Resolve the configured sandbox mode at call time (default 'auto'). */
8986
+ getSandboxMode;
8987
+ /** Optional diagnostics sink (routed through the host so it survives the Ink TUI). */
8988
+ log;
8989
+ constructor(spec, registry, getEscalator, trusted, getSandboxMode = () => "auto", log = () => {
8990
+ }) {
8714
8991
  super();
8715
8992
  this.name = spec.name;
8716
8993
  this.description = spec.description;
@@ -8720,6 +8997,8 @@ var DynamicTool = class extends BaseTool {
8720
8997
  this.registry = registry;
8721
8998
  this.getEscalator = getEscalator;
8722
8999
  this.trusted = trusted;
9000
+ this.getSandboxMode = getSandboxMode;
9001
+ this.log = log;
8723
9002
  }
8724
9003
  isDangerous() {
8725
9004
  return this._isDangerous;
@@ -8756,8 +9035,94 @@ var DynamicTool = class extends BaseTool {
8756
9035
  return `Error calling ${toolName}: ${err instanceof Error ? err.message : String(err)}`;
8757
9036
  }
8758
9037
  };
9038
+ const mode = this.getSandboxMode();
9039
+ if (mode !== "worker") {
9040
+ const ivm = await loadIsolatedVm();
9041
+ if (ivm) return this.runInIsolate(ivm, input, callTool);
9042
+ if (mode === "isolate" && !ivmWarned) {
9043
+ ivmWarned = true;
9044
+ 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.");
9045
+ }
9046
+ }
8759
9047
  return this.runInWorker(input, callTool);
8760
9048
  }
9049
+ /**
9050
+ * Run the generated code in a hard V8 isolate (isolated-vm). The isolate global
9051
+ * has no Node built-ins, so the code cannot see `process`, `require`, the
9052
+ * filesystem, or the network — it reaches the host ONLY through the injected
9053
+ * `callTool` (escalator-gated on the main thread) and `fetch` (SSRF-guarded via
9054
+ * bridgeFetch). `script.run({ timeout })` bounds synchronous CPU; an outer
9055
+ * wall-clock race + `isolate.dispose()` bounds async runaway (a never-resolving
9056
+ * await), mirroring the worker's terminate().
9057
+ */
9058
+ async runInIsolate(ivm, input, callTool) {
9059
+ const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
9060
+ const isolate = new ivm.Isolate({ memoryLimit: 128 });
9061
+ let disposed = false;
9062
+ const dispose = () => {
9063
+ if (!disposed) {
9064
+ disposed = true;
9065
+ try {
9066
+ isolate.dispose();
9067
+ } catch {
9068
+ }
9069
+ }
9070
+ };
9071
+ try {
9072
+ const context = await isolate.createContext();
9073
+ const jail = context.global;
9074
+ await jail.set("_callTool", new ivm.Reference(async (name, inputJson) => {
9075
+ const out = await callTool(String(name), safeJsonParse(inputJson));
9076
+ return String(out);
9077
+ }));
9078
+ await jail.set("_fetch", new ivm.Reference(async (url, initJson) => {
9079
+ const r = await bridgeFetch(String(url), safeJsonParse(initJson));
9080
+ return JSON.stringify(r);
9081
+ }));
9082
+ await jail.set("_input", new ivm.ExternalCopy(input).copyInto());
9083
+ await jail.set("_code", this.executeCode);
9084
+ const bootstrap = `
9085
+ const callTool = (name, toolInput) => _callTool.apply(undefined,
9086
+ [String(name), JSON.stringify(toolInput || {})],
9087
+ { result: { promise: true }, arguments: { copy: true } });
9088
+ const fetch = async (url, init) => {
9089
+ const raw = await _fetch.apply(undefined,
9090
+ [String(url), JSON.stringify(init || null)],
9091
+ { result: { promise: true }, arguments: { copy: true } });
9092
+ const r = JSON.parse(raw);
9093
+ if (r && r.__error) throw new Error(r.__error);
9094
+ return {
9095
+ ok: r.ok, status: r.status, statusText: r.statusText,
9096
+ headers: { get: (k) => (String(k).toLowerCase() === 'content-type' ? r.contentType : null) },
9097
+ text: async () => r.body,
9098
+ json: async () => JSON.parse(r.body),
9099
+ };
9100
+ };
9101
+ const console = { log() {}, error() {} };
9102
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
9103
+ const fn = new AsyncFunction('input', 'callTool', 'fetch', 'console', _code);
9104
+ (async () => { const r = await fn(_input, callTool, fetch, console); return String(r == null ? '' : r); })();
9105
+ `;
9106
+ const script = await isolate.compileScript(bootstrap);
9107
+ const runPromise = script.run(context, { timeout: timeoutMs, promise: true }).then((v) => String(v ?? ""));
9108
+ const timeoutPromise = new Promise((resolve) => {
9109
+ const t = setTimeout(() => {
9110
+ dispose();
9111
+ resolve(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`);
9112
+ }, timeoutMs + 500);
9113
+ t.unref?.();
9114
+ });
9115
+ return await Promise.race([runPromise, timeoutPromise]);
9116
+ } catch (err) {
9117
+ const msg = err instanceof Error ? err.message : String(err);
9118
+ if (/timed? out/i.test(msg)) {
9119
+ return `Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`;
9120
+ }
9121
+ return `Tool error: ${msg}`;
9122
+ } finally {
9123
+ dispose();
9124
+ }
9125
+ }
8761
9126
  /** Spawn the worker, service its callTool/fetch bridge, enforce the kill timeout. */
8762
9127
  runInWorker(input, callTool) {
8763
9128
  const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
@@ -8837,16 +9202,19 @@ var ToolCreator = class {
8837
9202
  workspacePath;
8838
9203
  /** When false, persisted tools are neither loaded nor written. */
8839
9204
  persistEnabled;
9205
+ /** Sandbox runtime for generated tools; passed to each DynamicTool. */
9206
+ sandboxMode;
8840
9207
  logger;
8841
9208
  /** name → spec, for persistence, broadcast, and re-registration. */
8842
9209
  specs = /* @__PURE__ */ new Map();
8843
9210
  /** capability fingerprint → tool name, so the same need isn't re-generated. */
8844
9211
  capabilityIndex = /* @__PURE__ */ new Map();
8845
- constructor(router, registry, workspacePath, persistEnabled = true) {
9212
+ constructor(router, registry, workspacePath, persistEnabled = true, sandboxMode = "auto") {
8846
9213
  this.router = router;
8847
9214
  this.registry = registry;
8848
9215
  this.workspacePath = workspacePath;
8849
9216
  this.persistEnabled = persistEnabled;
9217
+ this.sandboxMode = sandboxMode;
8850
9218
  }
8851
9219
  setPermissionEscalator(escalator) {
8852
9220
  this.escalator = escalator;
@@ -8934,7 +9302,14 @@ Required capability: ${description.slice(0, 300)}`;
8934
9302
  this.specs.set(spec.name, spec);
8935
9303
  return;
8936
9304
  }
8937
- const tool = new DynamicTool(spec, this.registry, () => this.escalator, trusted);
9305
+ const tool = new DynamicTool(
9306
+ spec,
9307
+ this.registry,
9308
+ () => this.escalator,
9309
+ trusted,
9310
+ () => this.sandboxMode,
9311
+ (msg) => this.log(msg)
9312
+ );
8938
9313
  this.registry.register(tool);
8939
9314
  this.specs.set(spec.name, spec);
8940
9315
  this.capabilityIndex.set(capabilityKey(`${spec.description}`), spec.name);
@@ -8983,6 +9358,9 @@ Required capability: ${description.slice(0, 300)}`;
8983
9358
  return Array.from(this.specs.keys());
8984
9359
  }
8985
9360
  };
9361
+ function normalizeKey(text) {
9362
+ return text.trim().toLowerCase().replace(/\s+/g, " ");
9363
+ }
8986
9364
  var WorldStateDB = class {
8987
9365
  constructor(workspacePath, debugMode = false) {
8988
9366
  this.workspacePath = workspacePath;
@@ -9004,6 +9382,16 @@ var WorldStateDB = class {
9004
9382
  encrypted_payload TEXT NOT NULL
9005
9383
  )
9006
9384
  `);
9385
+ this.db.exec(`
9386
+ CREATE TABLE IF NOT EXISTS facts (
9387
+ entity TEXT NOT NULL,
9388
+ relation TEXT NOT NULL,
9389
+ encrypted_value TEXT NOT NULL,
9390
+ source_worker TEXT NOT NULL,
9391
+ timestamp TEXT NOT NULL,
9392
+ PRIMARY KEY (entity, relation)
9393
+ )
9394
+ `);
9007
9395
  }
9008
9396
  workspacePath;
9009
9397
  debugMode;
@@ -9077,6 +9465,131 @@ var WorldStateDB = class {
9077
9465
  if (entries.length === 0) return "World State is currently empty.";
9078
9466
  return entries.map((e, idx) => `[${e.timestamp}] Step ${idx + 1} (${e.workerId}): ${e.summary}`).join("\n");
9079
9467
  }
9468
+ // ── world-state v2: queryable facts ──────────────
9469
+ /**
9470
+ * Upsert a fact. `(entity, relation)` is normalized so casing/whitespace don't
9471
+ * fragment the key; an existing pair is superseded (value + provenance updated)
9472
+ * rather than duplicated. Empty entity/relation/value are ignored.
9473
+ */
9474
+ upsertFact(entity, relation, value, sourceWorker, timestamp) {
9475
+ const e = normalizeKey(entity);
9476
+ const r = normalizeKey(relation);
9477
+ const v = value.trim();
9478
+ if (!e || !r || !v) return;
9479
+ const encrypted = this.encrypt(JSON.stringify({ value: v }));
9480
+ const stmt = this.db.prepare(`
9481
+ INSERT INTO facts (entity, relation, encrypted_value, source_worker, timestamp)
9482
+ VALUES (?, ?, ?, ?, ?)
9483
+ ON CONFLICT(entity, relation) DO UPDATE SET
9484
+ encrypted_value = excluded.encrypted_value,
9485
+ source_worker = excluded.source_worker,
9486
+ timestamp = excluded.timestamp
9487
+ `);
9488
+ stmt.run(e, r, encrypted, sourceWorker, timestamp ?? (/* @__PURE__ */ new Date()).toISOString());
9489
+ this.dumpDebugIfNeeded();
9490
+ }
9491
+ rowToFact(row) {
9492
+ let value;
9493
+ try {
9494
+ value = JSON.parse(this.decrypt(row.encrypted_value)).value;
9495
+ } catch {
9496
+ value = "[Decryption Failed - Payload Corrupted]";
9497
+ }
9498
+ return { entity: row.entity, relation: row.relation, value, sourceWorker: row.source_worker, timestamp: row.timestamp };
9499
+ }
9500
+ /** All facts whose entity matches one of the (normalized) query entities. */
9501
+ getFactsForEntities(entities) {
9502
+ const keys = Array.from(new Set(entities.map(normalizeKey).filter(Boolean)));
9503
+ if (keys.length === 0) return [];
9504
+ const placeholders = keys.map(() => "?").join(", ");
9505
+ const stmt = this.db.prepare(
9506
+ `SELECT entity, relation, encrypted_value, source_worker, timestamp FROM facts WHERE entity IN (${placeholders}) ORDER BY entity ASC, relation ASC`
9507
+ );
9508
+ return stmt.all(...keys).map((row) => this.rowToFact(row));
9509
+ }
9510
+ /** Every fact (used for tests / debug / whole-graph views). */
9511
+ getAllFacts() {
9512
+ const stmt = this.db.prepare("SELECT entity, relation, encrypted_value, source_worker, timestamp FROM facts ORDER BY entity ASC, relation ASC");
9513
+ return stmt.all().map((row) => this.rowToFact(row));
9514
+ }
9515
+ /**
9516
+ * A compact, human/LLM-readable fact block for T1/T2 planning. When `entities`
9517
+ * is given, only facts about those entities are included; otherwise all facts.
9518
+ * Returns '' when there are none so callers can fall back to the linear log.
9519
+ */
9520
+ getFormattedFacts(entities) {
9521
+ const facts = entities && entities.length > 0 ? this.getFactsForEntities(entities) : this.getAllFacts();
9522
+ if (facts.length === 0) return "";
9523
+ return facts.map((f) => `- ${f.entity} ${f.relation} ${f.value}`).join("\n");
9524
+ }
9525
+ /**
9526
+ * A compact knowledge block for T1/T2 planning. When `prompt` is given, facts
9527
+ * whose entity/relation/value mention a prompt token are preferred (relevance
9528
+ * filter); otherwise, or when nothing matches, all facts are used (capped at
9529
+ * `limit`). Returns '' when there are no facts, so the caller can fall back to
9530
+ * the raw linear log — this replaces replaying the whole log during planning.
9531
+ */
9532
+ getFormattedKnowledge(prompt, limit = 40) {
9533
+ const all = this.getAllFacts();
9534
+ if (all.length === 0) return "";
9535
+ let selected = all;
9536
+ if (prompt && prompt.trim()) {
9537
+ const tokens = Array.from(new Set(prompt.toLowerCase().match(/[a-z0-9_./-]{3,}/g) ?? []));
9538
+ if (tokens.length > 0) {
9539
+ const matched = all.filter((f) => {
9540
+ const hay = `${f.entity} ${f.relation} ${f.value}`.toLowerCase();
9541
+ return tokens.some((t) => hay.includes(t));
9542
+ });
9543
+ if (matched.length > 0) selected = matched;
9544
+ }
9545
+ }
9546
+ return selected.slice(0, limit).map((f) => `- ${f.entity} ${f.relation} ${f.value}`).join("\n");
9547
+ }
9548
+ // ── Export / Import ──────────────────────────
9549
+ //
9550
+ // Knowledge travels DECRYPTED in the export bundle (a portable plaintext
9551
+ // JSON file — the encryption key never leaves this machine) and re-encrypts
9552
+ // with the LOCAL key on import.
9553
+ exportKnowledge() {
9554
+ return { facts: this.getAllFacts(), worldLog: this.getAllEntries() };
9555
+ }
9556
+ /**
9557
+ * Merge imported knowledge. Facts upsert on (entity, relation) with
9558
+ * newer-timestamp-wins (a local fact newer than the imported one is kept);
9559
+ * log entries append with fresh ids, skipping exact duplicates
9560
+ * (worker + timestamp + summary). Returns counts of what actually landed.
9561
+ */
9562
+ importKnowledge(data) {
9563
+ let facts = 0;
9564
+ let logEntries = 0;
9565
+ if (Array.isArray(data.facts)) {
9566
+ const local = new Map(this.getAllFacts().map((f) => [`${f.entity}\0${f.relation}`, f.timestamp]));
9567
+ for (const f of data.facts) {
9568
+ if (!f || typeof f.entity !== "string" || typeof f.relation !== "string" || typeof f.value !== "string") continue;
9569
+ const key = `${normalizeKey(f.entity)}\0${normalizeKey(f.relation)}`;
9570
+ const localTs = local.get(key);
9571
+ const importTs = f.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
9572
+ if (localTs && localTs >= importTs) continue;
9573
+ this.upsertFact(f.entity, f.relation, f.value, f.sourceWorker ?? "imported", importTs);
9574
+ facts++;
9575
+ }
9576
+ }
9577
+ if (Array.isArray(data.worldLog)) {
9578
+ const seen = new Set(this.getAllEntries().map((e) => `${e.workerId}\0${e.timestamp}\0${e.summary}`));
9579
+ const stmt = this.db.prepare("INSERT INTO world_state (id, timestamp, worker_id, encrypted_payload) VALUES (?, ?, ?, ?)");
9580
+ for (const e of data.worldLog) {
9581
+ if (!e || typeof e.summary !== "string") continue;
9582
+ const workerId = e.workerId ?? "imported";
9583
+ const timestamp = e.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
9584
+ const key = `${workerId}\0${timestamp}\0${e.summary}`;
9585
+ if (seen.has(key)) continue;
9586
+ stmt.run(crypto3.randomUUID(), timestamp, workerId, this.encrypt(JSON.stringify({ summary: e.summary })));
9587
+ seen.add(key);
9588
+ logEntries++;
9589
+ }
9590
+ }
9591
+ return { facts, logEntries };
9592
+ }
9080
9593
  dumpDebugIfNeeded() {
9081
9594
  if (!this.debugMode) return;
9082
9595
  try {
@@ -9213,7 +9726,8 @@ var Cascade = class _Cascade extends EventEmitter {
9213
9726
  }
9214
9727
  const cfg = this.config;
9215
9728
  if (cfg["enableToolCreation"] === true) {
9216
- this.toolCreator = new ToolCreator(this.router, this.toolRegistry, this.workspacePath, cfg["persistDynamicTools"] !== false);
9729
+ const sandboxMode = this.config.tools?.dynamicToolSandbox ?? "auto";
9730
+ this.toolCreator = new ToolCreator(this.router, this.toolRegistry, this.workspacePath, cfg["persistDynamicTools"] !== false, sandboxMode);
9217
9731
  this.toolCreator.setLogger((m) => {
9218
9732
  if (this.listenerCount("log") > 0) this.emit("log", { level: "info", message: m });
9219
9733
  });
@@ -9451,6 +9965,10 @@ ${last.partialOutput}` : "");
9451
9965
  this.router.profileModels(this.store).catch(() => {
9452
9966
  });
9453
9967
  }
9968
+ if (this.store) {
9969
+ this.router.probeLocalToolSupport(this.store).catch(() => {
9970
+ });
9971
+ }
9454
9972
  if (this.config.cascadeAuto) {
9455
9973
  this.router.refreshLiveData().catch(() => {
9456
9974
  });
@@ -10522,6 +11040,78 @@ Original error: ${err.message}`
10522
11040
  `).all(`%${query}%`, limit);
10523
11041
  return rows.map(this.deserializeMessage);
10524
11042
  }
11043
+ // ── Export / Import ──────────────────────────
11044
+ //
11045
+ // Chats travel as full Session objects (messages included) inside a
11046
+ // plaintext-JSON bundle. Import NEVER overwrites: every imported session
11047
+ // gets a fresh id (title suffixed "(imported)") and fresh message ids, so
11048
+ // re-importing the same bundle can duplicate but can never clobber.
11049
+ /** Full sessions (with messages) for the given ids, or ALL sessions. */
11050
+ exportSessions(ids) {
11051
+ const targets = ids && ids.length > 0 ? ids : this.db.prepare("SELECT id FROM sessions ORDER BY updated_at DESC").all().map((r) => r.id);
11052
+ const out = [];
11053
+ for (const id of targets) {
11054
+ const s = this.getSession(id);
11055
+ if (s) out.push(s);
11056
+ }
11057
+ return out;
11058
+ }
11059
+ /**
11060
+ * Import sessions from a bundle. Returns the created `{id, title}` rows so
11061
+ * the caller can mirror them into the runtime session list (the sidebar
11062
+ * reads runtime_sessions, not sessions). Malformed entries are skipped.
11063
+ */
11064
+ importSessions(sessions) {
11065
+ const out = [];
11066
+ const msgStmt = this.db.prepare(`
11067
+ INSERT INTO messages (id, session_id, role, content, timestamp, tokens, agent_messages)
11068
+ VALUES (?, ?, ?, ?, ?, ?, ?)
11069
+ `);
11070
+ for (const s of sessions) {
11071
+ if (!s || typeof s !== "object") continue;
11072
+ const newId = randomUUID();
11073
+ const now = (/* @__PURE__ */ new Date()).toISOString();
11074
+ const title = `${String(s.title || "Untitled").slice(0, 200)} (imported)`;
11075
+ const metadata = s.metadata && typeof s.metadata === "object" ? s.metadata : { totalTokens: 0, totalCostUsd: 0, modelsUsed: [], toolsUsed: [], taskCount: 0 };
11076
+ this.db.prepare(`
11077
+ INSERT INTO sessions (id, title, created_at, updated_at, identity_id, workspace_path, metadata)
11078
+ VALUES (?, ?, ?, ?, ?, ?, ?)
11079
+ `).run(newId, title, s.createdAt ?? now, s.updatedAt ?? now, s.identityId ?? "default", s.workspacePath ?? "", JSON.stringify(metadata));
11080
+ for (const m of Array.isArray(s.messages) ? s.messages : []) {
11081
+ if (!m || m.role !== "user" && m.role !== "assistant" && m.role !== "system") continue;
11082
+ msgStmt.run(
11083
+ randomUUID(),
11084
+ newId,
11085
+ m.role,
11086
+ typeof m.content === "string" ? m.content : JSON.stringify(m.content),
11087
+ m.timestamp ?? now,
11088
+ m.tokens ? JSON.stringify(m.tokens) : null,
11089
+ m.agentMessages ? JSON.stringify(m.agentMessages) : null
11090
+ );
11091
+ }
11092
+ out.push({ id: newId, title });
11093
+ }
11094
+ return out;
11095
+ }
11096
+ /** Import identities/personas, deduped by (case-insensitive) name — an
11097
+ * existing name is skipped, never replaced. Imports are never the default. */
11098
+ importIdentities(identities) {
11099
+ const existing = new Set(this.listIdentities().map((i) => i.name.toLowerCase()));
11100
+ let imported = 0;
11101
+ for (const ident of identities ?? []) {
11102
+ if (!ident?.name || typeof ident.name !== "string") continue;
11103
+ if (existing.has(ident.name.toLowerCase())) continue;
11104
+ this.createIdentity({
11105
+ ...ident,
11106
+ id: randomUUID(),
11107
+ isDefault: false,
11108
+ createdAt: ident.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
11109
+ });
11110
+ existing.add(ident.name.toLowerCase());
11111
+ imported++;
11112
+ }
11113
+ return imported;
11114
+ }
10525
11115
  // ── Identities ────────────────────────────────
10526
11116
  createIdentity(identity) {
10527
11117
  this.db.prepare(`
@@ -10699,6 +11289,23 @@ Original error: ${err.message}`
10699
11289
  const row = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(`${provider}:${modelId}`);
10700
11290
  return row ? JSON.parse(row.metadata) : void 0;
10701
11291
  }
11292
+ /**
11293
+ * Persist a probed capability verdict (currently: native tool support) into
11294
+ * the model's cached profile, merging with whatever is already recorded.
11295
+ * Read back via getModelProfile — so a model is probed once ever, not once
11296
+ * per run.
11297
+ */
11298
+ saveModelCapability(modelId, provider, caps) {
11299
+ const cacheKey = `${provider}:${modelId}`;
11300
+ const existing = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(cacheKey);
11301
+ 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 };
11302
+ if (caps.supportsToolUse !== void 0) meta.supportsToolUse = caps.supportsToolUse;
11303
+ this.db.prepare(`
11304
+ INSERT INTO model_cache (id, provider, model_id, name, metadata, updated_at)
11305
+ VALUES (?, ?, ?, ?, ?, ?)
11306
+ ON CONFLICT(id) DO UPDATE SET metadata = excluded.metadata, updated_at = excluded.updated_at
11307
+ `).run(cacheKey, provider, modelId, meta.name ?? modelId, JSON.stringify(meta), (/* @__PURE__ */ new Date()).toISOString());
11308
+ }
10702
11309
  getProfiledModelIds() {
10703
11310
  const rows = this.db.prepare(
10704
11311
  "SELECT model_id FROM model_cache WHERE json_extract(metadata, '$.specializations') IS NOT NULL"
@@ -11858,6 +12465,60 @@ ${prompt}`;
11858
12465
  this.socket.broadcast("runtime:refresh", { scope: "global" });
11859
12466
  res.json({ ok: true });
11860
12467
  });
12468
+ this.app.get("/api/export", auth, (req, res) => {
12469
+ try {
12470
+ const sessionsParam = typeof req.query["sessions"] === "string" ? req.query["sessions"] : "all";
12471
+ const includeMemories = req.query["memories"] === "1" || req.query["memories"] === "true";
12472
+ const ids = sessionsParam === "all" ? void 0 : sessionsParam.split(",").map((s) => s.trim()).filter(Boolean);
12473
+ const bundle = {
12474
+ format: "cascade-export@1",
12475
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
12476
+ sessions: this.store.exportSessions(ids)
12477
+ };
12478
+ if (includeMemories) {
12479
+ const ws = new WorldStateDB(this.workspacePath);
12480
+ try {
12481
+ bundle["memories"] = { ...ws.exportKnowledge(), identities: this.store.listIdentities() };
12482
+ } finally {
12483
+ ws.close();
12484
+ }
12485
+ }
12486
+ res.json(bundle);
12487
+ } catch (err) {
12488
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
12489
+ }
12490
+ });
12491
+ this.app.post("/api/import", auth, mutationLimiter, (req, res) => {
12492
+ try {
12493
+ const bundle = req.body;
12494
+ if (!bundle || bundle.format !== "cascade-export@1") {
12495
+ res.status(400).json({ error: "Not a cascade-export@1 bundle." });
12496
+ return;
12497
+ }
12498
+ const importedSessions = Array.isArray(bundle.sessions) ? this.store.importSessions(bundle.sessions) : [];
12499
+ for (const s of importedSessions) this.persistRuntimeRow(s.id, s.title, "COMPLETED");
12500
+ let facts = 0;
12501
+ let logEntries = 0;
12502
+ let identities = 0;
12503
+ if (bundle.memories) {
12504
+ const ws = new WorldStateDB(this.workspacePath);
12505
+ try {
12506
+ const counts = ws.importKnowledge(bundle.memories);
12507
+ facts = counts.facts;
12508
+ logEntries = counts.logEntries;
12509
+ } finally {
12510
+ ws.close();
12511
+ }
12512
+ if (Array.isArray(bundle.memories.identities)) {
12513
+ identities = this.store.importIdentities(bundle.memories.identities);
12514
+ }
12515
+ }
12516
+ this.socket.broadcast("runtime:refresh", { scope: "workspace" });
12517
+ res.json({ ok: true, imported: { sessions: importedSessions.length, facts, logEntries, identities } });
12518
+ } catch (err) {
12519
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
12520
+ }
12521
+ });
11861
12522
  this.app.post("/api/sessions/:id/rollback", auth, mutationLimiter, async (req, res) => {
11862
12523
  const sessionId = req.params.id;
11863
12524
  const taskIds = this.sessionTaskIds.get(sessionId) ?? [];