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.cjs CHANGED
@@ -236,7 +236,7 @@ var init_audit_logger = __esm({
236
236
  });
237
237
 
238
238
  // src/constants.ts
239
- var CASCADE_VERSION = "0.13.2";
239
+ var CASCADE_VERSION = "0.15.0";
240
240
  var CASCADE_CONFIG_DIR = ".cascade";
241
241
  var CASCADE_MD_FILE = "CASCADE.md";
242
242
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -1482,29 +1482,71 @@ var OllamaProvider = class extends BaseProvider {
1482
1482
  async countTokens(text) {
1483
1483
  return Math.ceil(text.length / 4);
1484
1484
  }
1485
+ /**
1486
+ * Ask the Ollama server what a model can actually do. Modern Ollama returns a
1487
+ * `capabilities` array ("completion", "tools", "vision", …) and the model's
1488
+ * real context length in `model_info` — authoritative, unlike the hardcoded
1489
+ * family-name allowlist, which silently misclassifies any unlisted family.
1490
+ * Best-effort: null on old servers / errors, callers fall back to heuristics.
1491
+ */
1492
+ async showModel(name) {
1493
+ const ac = new AbortController();
1494
+ const timer = setTimeout(() => ac.abort(), 2500);
1495
+ try {
1496
+ const resp = await fetch(`${this.baseUrl}/api/show`, {
1497
+ method: "POST",
1498
+ headers: { "Content-Type": "application/json" },
1499
+ body: JSON.stringify({ model: name }),
1500
+ signal: ac.signal
1501
+ });
1502
+ if (!resp.ok) return null;
1503
+ const data = await resp.json();
1504
+ const out = {};
1505
+ if (Array.isArray(data.capabilities)) {
1506
+ out.tools = data.capabilities.includes("tools");
1507
+ out.vision = data.capabilities.includes("vision");
1508
+ }
1509
+ for (const [k, v] of Object.entries(data.model_info ?? {})) {
1510
+ if (k.endsWith(".context_length") && typeof v === "number" && v > 0) {
1511
+ out.contextWindow = v;
1512
+ break;
1513
+ }
1514
+ }
1515
+ return out.tools !== void 0 || out.contextWindow ? out : null;
1516
+ } catch {
1517
+ return null;
1518
+ } finally {
1519
+ clearTimeout(timer);
1520
+ }
1521
+ }
1485
1522
  async listModels() {
1486
1523
  try {
1487
1524
  const response = await fetch(`${this.baseUrl}/api/tags`);
1488
1525
  if (!response.ok) return [];
1489
1526
  const data = await response.json();
1490
1527
  const supportedKeywords = ["llama3", "llama2", "gemma", "mistral", "mixtral", "qwen", "phi3", "codellama", "deepseek", "llava", "starcoder", "stable-code", "nomic-embed"];
1491
- return data.models.filter((m) => {
1528
+ const entries = data.models.filter((m) => {
1492
1529
  const name = m.name.toLowerCase();
1493
1530
  return supportedKeywords.some((k) => name.includes(k));
1494
- }).map((m) => ({
1495
- id: m.name,
1496
- name: m.name,
1497
- provider: "ollama",
1498
- contextWindow: 128e3,
1499
- isVisionCapable: m.name.includes("llava") || m.name.includes("vision"),
1500
- inputCostPer1kTokens: 0,
1501
- outputCostPer1kTokens: 0,
1502
- maxOutputTokens: 4e3,
1503
- supportsStreaming: true,
1504
- isLocal: true,
1505
- supportsToolUse: isToolCapable(m.name),
1506
- minSizeB: this.parseSizeB(m.details?.parameter_size)
1507
- }));
1531
+ });
1532
+ const shows = await Promise.all(entries.map((m) => this.showModel(m.name)));
1533
+ return entries.map((m, i) => {
1534
+ const show = shows[i];
1535
+ return {
1536
+ id: m.name,
1537
+ name: m.name,
1538
+ provider: "ollama",
1539
+ contextWindow: show?.contextWindow ?? 128e3,
1540
+ isVisionCapable: show?.vision ?? (m.name.includes("llava") || m.name.includes("vision")),
1541
+ inputCostPer1kTokens: 0,
1542
+ outputCostPer1kTokens: 0,
1543
+ maxOutputTokens: 4e3,
1544
+ supportsStreaming: true,
1545
+ isLocal: true,
1546
+ supportsToolUse: show?.tools ?? isToolCapable(m.name),
1547
+ minSizeB: this.parseSizeB(m.details?.parameter_size)
1548
+ };
1549
+ });
1508
1550
  } catch {
1509
1551
  return [];
1510
1552
  }
@@ -2181,6 +2223,7 @@ function normalizeModelId(id) {
2181
2223
  var LiveDataProvider = class {
2182
2224
  snapshot = null;
2183
2225
  prices = /* @__PURE__ */ new Map();
2226
+ capabilities = /* @__PURE__ */ new Map();
2184
2227
  source = "bundled";
2185
2228
  fetchedAt = 0;
2186
2229
  loaded = false;
@@ -2209,6 +2252,9 @@ var LiveDataProvider = class {
2209
2252
  if (cache.prices) {
2210
2253
  for (const [id, p] of Object.entries(cache.prices)) this.prices.set(id, p);
2211
2254
  }
2255
+ if (cache.capabilities) {
2256
+ for (const [id, c] of Object.entries(cache.capabilities)) this.capabilities.set(id, c);
2257
+ }
2212
2258
  this.fetchedAt = cache.fetchedAt ?? 0;
2213
2259
  } catch {
2214
2260
  }
@@ -2229,9 +2275,9 @@ var LiveDataProvider = class {
2229
2275
  const ttlMs = this.opts.refreshHours * 36e5;
2230
2276
  const fresh = ttlMs > 0 && Date.now() - this.fetchedAt < ttlMs;
2231
2277
  if (!force && fresh && this.source !== "bundled") return;
2232
- const [snap, prices] = await Promise.all([
2278
+ const [snap, catalog] = await Promise.all([
2233
2279
  this.opts.live ? this.fetchSnapshot() : Promise.resolve(null),
2234
- this.opts.pricingLive ? this.fetchPrices() : Promise.resolve(null)
2280
+ this.opts.pricingLive ? this.fetchCatalog() : Promise.resolve(null)
2235
2281
  ]);
2236
2282
  let changed = false;
2237
2283
  if (snap) {
@@ -2239,8 +2285,12 @@ var LiveDataProvider = class {
2239
2285
  this.source = "live";
2240
2286
  changed = true;
2241
2287
  }
2242
- if (prices && prices.size > 0) {
2243
- this.prices = prices;
2288
+ if (catalog && catalog.prices.size > 0) {
2289
+ this.prices = catalog.prices;
2290
+ changed = true;
2291
+ }
2292
+ if (catalog && catalog.capabilities.size > 0) {
2293
+ this.capabilities = catalog.capabilities;
2244
2294
  changed = true;
2245
2295
  }
2246
2296
  if (changed) {
@@ -2262,21 +2312,43 @@ var LiveDataProvider = class {
2262
2312
  return null;
2263
2313
  }
2264
2314
  }
2265
- async fetchPrices() {
2315
+ /**
2316
+ * One fetch of the OpenRouter catalog yields BOTH pricing and capability
2317
+ * facts (context window, native tool support, input modalities) — the
2318
+ * capability fields used to be discarded while providers guessed/hardcoded
2319
+ * them in their listModels() stubs.
2320
+ */
2321
+ async fetchCatalog() {
2266
2322
  try {
2267
2323
  const resp = await withTimeout(fetch(OPENROUTER_MODELS_URL), FETCH_TIMEOUT_MS, "pricing fetch timed out");
2268
2324
  if (!resp.ok) return null;
2269
2325
  const data = await resp.json();
2270
2326
  if (!Array.isArray(data?.data)) return null;
2271
- const out = /* @__PURE__ */ new Map();
2327
+ const prices = /* @__PURE__ */ new Map();
2328
+ const capabilities = /* @__PURE__ */ new Map();
2272
2329
  for (const m of data.data) {
2273
- if (!m?.id || !m.pricing) continue;
2274
- const input = Number(m.pricing.prompt) * 1e3;
2275
- const output = Number(m.pricing.completion) * 1e3;
2276
- if (!Number.isFinite(input) || !Number.isFinite(output)) continue;
2277
- out.set(normalizeModelId(m.id), { input, output });
2330
+ if (!m?.id) continue;
2331
+ const key = normalizeModelId(m.id);
2332
+ if (m.pricing) {
2333
+ const input = Number(m.pricing.prompt) * 1e3;
2334
+ const output = Number(m.pricing.completion) * 1e3;
2335
+ if (Number.isFinite(input) && Number.isFinite(output)) {
2336
+ prices.set(key, { input, output });
2337
+ }
2338
+ }
2339
+ const cap = {};
2340
+ if (typeof m.context_length === "number" && m.context_length > 0) {
2341
+ cap.contextWindow = m.context_length;
2342
+ }
2343
+ if (Array.isArray(m.supported_parameters)) {
2344
+ cap.supportsTools = m.supported_parameters.includes("tools");
2345
+ }
2346
+ if (Array.isArray(m.architecture?.input_modalities)) {
2347
+ cap.inputModalities = m.architecture.input_modalities;
2348
+ }
2349
+ if (Object.keys(cap).length > 0) capabilities.set(key, cap);
2278
2350
  }
2279
- return out;
2351
+ return { prices, capabilities };
2280
2352
  } catch {
2281
2353
  return null;
2282
2354
  }
@@ -2287,7 +2359,8 @@ var LiveDataProvider = class {
2287
2359
  const cache = {
2288
2360
  fetchedAt: this.fetchedAt,
2289
2361
  snapshot: this.snapshot ?? void 0,
2290
- prices: Object.fromEntries(this.prices)
2362
+ prices: Object.fromEntries(this.prices),
2363
+ capabilities: Object.fromEntries(this.capabilities)
2291
2364
  };
2292
2365
  await fs4__default.default.writeFile(this.opts.cacheFile, JSON.stringify(cache, null, 2), "utf-8");
2293
2366
  } catch {
@@ -2312,6 +2385,27 @@ var LiveDataProvider = class {
2312
2385
  return { ...m, inputCostPer1kTokens: p.input, outputCostPer1kTokens: p.output };
2313
2386
  });
2314
2387
  }
2388
+ /** Current capability facts for a model id, or null when unknown. */
2389
+ getCapability(modelId) {
2390
+ return this.capabilities.get(normalizeModelId(modelId)) ?? null;
2391
+ }
2392
+ /**
2393
+ * Returns capability-corrected copies of each model (originals untouched):
2394
+ * real context windows replace the providers' hardcoded guesses, native
2395
+ * tool support replaces the assume-by-provider default, and vision is set
2396
+ * from the declared input modalities.
2397
+ */
2398
+ applyLiveCapabilities(models) {
2399
+ return models.map((m) => {
2400
+ const c = this.getCapability(m.id);
2401
+ if (!c) return m;
2402
+ const next = { ...m };
2403
+ if (c.contextWindow) next.contextWindow = c.contextWindow;
2404
+ if (c.supportsTools !== void 0) next.supportsToolUse = c.supportsTools;
2405
+ if (c.inputModalities) next.isVisionCapable = c.inputModalities.includes("image");
2406
+ return next;
2407
+ });
2408
+ }
2315
2409
  /** Where the active quality data came from — for /why and `cascade models`. */
2316
2410
  getDataSource() {
2317
2411
  return this.source;
@@ -2322,6 +2416,9 @@ var LiveDataProvider = class {
2322
2416
  hasLivePricing() {
2323
2417
  return this.prices.size > 0;
2324
2418
  }
2419
+ hasCapabilities() {
2420
+ return this.capabilities.size > 0;
2421
+ }
2325
2422
  };
2326
2423
 
2327
2424
  // src/core/router/benchmarks.ts
@@ -2510,6 +2607,57 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2510
2607
  profiler.profileAll(allModels).catch(() => {
2511
2608
  });
2512
2609
  }
2610
+ /**
2611
+ * One-time native tool-call probe for local/compat models with NO capability
2612
+ * metadata. llama.cpp / LM Studio "/models" endpoints return ids only, so
2613
+ * `supportsToolUse` stays undefined and the T3 tool gate ASSUMES native
2614
+ * support — wrong for many local builds, which then fumble tool-format
2615
+ * output. Each unknown model is probed once with a trivial tool; the verdict
2616
+ * persists in the MemoryStore's model_cache (via the previously-dormant
2617
+ * getModelProfile read-back), so a model is probed once EVER, not per run.
2618
+ * Cloud providers are never probed — their metadata is authoritative.
2619
+ */
2620
+ async probeLocalToolSupport(store) {
2621
+ const unknown = this.selector.getAllAvailableModels().filter(
2622
+ (m) => m.provider === "openai-compatible" && m.supportsToolUse === void 0
2623
+ );
2624
+ for (const model of unknown) {
2625
+ const cached = store.getModelProfile(model.id, model.provider);
2626
+ let verdict = cached?.supportsToolUse;
2627
+ if (verdict === void 0) {
2628
+ const probed = await this.probeNativeToolCall(model);
2629
+ if (probed === null) continue;
2630
+ verdict = probed;
2631
+ store.saveModelCapability(model.id, model.provider, { supportsToolUse: verdict });
2632
+ }
2633
+ this.selector.addDynamicModel({ ...model, supportsToolUse: verdict });
2634
+ }
2635
+ for (const tier of ["T1", "T2", "T3"]) {
2636
+ const cur = this.tierModels.get(tier);
2637
+ if (!cur) continue;
2638
+ const fresh = this.selector.getModelById(cur.id);
2639
+ if (fresh) this.tierModels.set(tier, fresh);
2640
+ }
2641
+ }
2642
+ async probeNativeToolCall(model) {
2643
+ try {
2644
+ const cfg = this.config.providers.find((p) => p.type === model.provider) ?? { type: model.provider };
2645
+ const provider = this.createProvider(cfg, model);
2646
+ const result = await withTimeout(provider.generate({
2647
+ messages: [{ role: "user", content: "Call the echo tool with text set to 'ping'. Use the tool; do not answer in prose." }],
2648
+ tools: [{
2649
+ name: "echo",
2650
+ description: "Echo the given text back to the caller.",
2651
+ inputSchema: { type: "object", properties: { text: { type: "string", description: "Text to echo" } }, required: ["text"] }
2652
+ }],
2653
+ maxTokens: 80,
2654
+ temperature: 0
2655
+ }), 3e4, "tool-support probe timed out");
2656
+ return (result.toolCalls?.length ?? 0) > 0;
2657
+ } catch {
2658
+ return null;
2659
+ }
2660
+ }
2513
2661
  /**
2514
2662
  * Cascade Auto live data: discover/validate real model ids from each cloud
2515
2663
  * provider, then fetch current public quality scores + per-token prices and
@@ -2559,12 +2707,17 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2559
2707
  await Promise.allSettled(tasks);
2560
2708
  }
2561
2709
  /**
2562
- * Replace available models with live-priced copies and refresh the already
2563
- * resolved tier models so shared-tier cost accounting uses current prices.
2710
+ * Replace available models with live-priced AND capability-corrected copies
2711
+ * (real context windows, native tool support, vision from modalities), then
2712
+ * refresh the already resolved tier models so shared-tier cost accounting and
2713
+ * the tool-use gate both see current data.
2564
2714
  */
2565
2715
  applyLivePricing() {
2566
- if (!this.liveData?.hasLivePricing()) return;
2567
- const updated = this.liveData.applyLivePricing(this.selector.getAllAvailableModels());
2716
+ if (!this.liveData) return;
2717
+ if (!this.liveData.hasLivePricing() && !this.liveData.hasCapabilities()) return;
2718
+ let updated = this.selector.getAllAvailableModels();
2719
+ if (this.liveData.hasLivePricing()) updated = this.liveData.applyLivePricing(updated);
2720
+ if (this.liveData.hasCapabilities()) updated = this.liveData.applyLiveCapabilities(updated);
2568
2721
  for (const m of updated) this.selector.addDynamicModel(m);
2569
2722
  for (const tier of ["T1", "T2", "T3"]) {
2570
2723
  const cur = this.tierModels.get(tier);
@@ -2739,6 +2892,10 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2739
2892
  const r = this.config?.reinforcements;
2740
2893
  return { enabled: r?.enabled === true, maxPerSection: r?.maxPerSection ?? 4 };
2741
2894
  }
2895
+ /** Project-knowledge settings (config.knowledge). Facts extraction on by default. */
2896
+ getKnowledgeConfig() {
2897
+ return { factsExtraction: this.config?.knowledge?.factsExtraction !== false };
2898
+ }
2742
2899
  /**
2743
2900
  * Resolved T3 wave execution mode. 'auto' becomes 'sequential' when the T3
2744
2901
  * tier resolves to a LOCAL model (the single-GPU queue serializes anyway, so
@@ -2825,10 +2982,10 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2825
2982
  * null when Cascade Auto is off (callers then use the shared tier model).
2826
2983
  * Pure heuristic — no extra LLM call.
2827
2984
  */
2828
- async selectModelForSubtask(tier, text) {
2985
+ async selectModelForSubtask(tier, text, opts) {
2829
2986
  if (!this.config?.cascadeAuto || !this.taskAnalyzer || !text.trim()) return null;
2830
2987
  try {
2831
- return await this.taskAnalyzer.selectModel(text, tier, this.selector);
2988
+ return await this.taskAnalyzer.selectModel(text, tier, this.selector, opts);
2832
2989
  } catch {
2833
2990
  return null;
2834
2991
  }
@@ -3583,6 +3740,13 @@ EXAMPLE \u2014 calling the "shell" tool to list files:
3583
3740
  {"name": "shell", "input": {"command": "ls -la"}}
3584
3741
  </tool_call>
3585
3742
 
3743
+ When you have enough information, stop calling tools and write your final answer.`;
3744
+ }
3745
+ function buildTextToolReminder(tools) {
3746
+ return `
3747
+ TOOL USE REMINDER:
3748
+ 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.
3749
+ Available tools: ${tools.map((t) => t.name).join(", ")}.
3586
3750
  When you have enough information, stop calling tools and write your final answer.`;
3587
3751
  }
3588
3752
 
@@ -3822,6 +3986,9 @@ Now execute your subtask using this context where relevant.`
3822
3986
  } catch (e) {
3823
3987
  this.log("Failed to write to World State DB");
3824
3988
  }
3989
+ if (this.router.getKnowledgeConfig?.().factsExtraction !== false) {
3990
+ await this.extractAndStoreFacts(db, assignment, output);
3991
+ }
3825
3992
  }
3826
3993
  this.setStatus("COMPLETED", output);
3827
3994
  this.sendStatusUpdate({ progressPct: 100, currentAction: "Subtask complete", status: "IN_PROGRESS", output });
@@ -3883,7 +4050,7 @@ Now execute your subtask using this context where relevant.`
3883
4050
  let subtaskModel;
3884
4051
  try {
3885
4052
  const subtaskText = `${this.assignment?.subtaskTitle ?? ""} ${this.assignment?.description ?? ""} ${this.assignment?.expectedOutput ?? ""}`;
3886
- subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText) ?? void 0;
4053
+ subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText, { requiresToolUse: tools.length > 0 }) ?? void 0;
3887
4054
  if (subtaskModel) {
3888
4055
  this.log(`Cascade Auto: routing this subtask to ${subtaskModel.provider}:${subtaskModel.id}`);
3889
4056
  }
@@ -3891,7 +4058,8 @@ Now execute your subtask using this context where relevant.`
3891
4058
  }
3892
4059
  const effectiveModel = subtaskModel ?? this.router.getModelForTier("T3");
3893
4060
  const useTextTools = effectiveModel?.supportsToolUse === false && tools.length > 0;
3894
- const textToolSuffix = useTextTools ? buildTextToolSystemPrompt(tools) : "";
4061
+ let sentFullTextContract = false;
4062
+ let textContractSignature = "";
3895
4063
  while (iterations < MAX_ITERATIONS) {
3896
4064
  iterations++;
3897
4065
  this.throwIfCancelled();
@@ -3905,6 +4073,17 @@ Now execute your subtask using this context where relevant.`
3905
4073
  ${g.text}`
3906
4074
  });
3907
4075
  }
4076
+ let textToolSuffix = "";
4077
+ if (useTextTools) {
4078
+ const signature = tools.map((t) => t.name).join(",");
4079
+ if (!sentFullTextContract || signature !== textContractSignature) {
4080
+ textToolSuffix = buildTextToolSystemPrompt(tools);
4081
+ sentFullTextContract = true;
4082
+ textContractSignature = signature;
4083
+ } else {
4084
+ textToolSuffix = buildTextToolReminder(tools);
4085
+ }
4086
+ }
3908
4087
  const options = {
3909
4088
  messages: this.context.getMessages(),
3910
4089
  systemPrompt: this.systemPromptOverride + systemPrompt + (this.hierarchyContext ? `
@@ -4394,6 +4573,41 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
4394
4573
  };
4395
4574
  }
4396
4575
  }
4576
+ /**
4577
+ * world-state v2: distill a completed subtask's output into durable
4578
+ * `(entity, relation, value)` facts and upsert them into the knowledge graph.
4579
+ * A bounded, cheap T3-tier call; entirely best-effort — any failure is swallowed
4580
+ * so it never blocks or fails the subtask. Respects the subtask's privacy tier
4581
+ * (a local-only subtask extracts on a local model too, never leaking to cloud).
4582
+ */
4583
+ async extractAndStoreFacts(db, assignment, output) {
4584
+ try {
4585
+ const prompt = `Extract durable project facts from this completed subtask.
4586
+ Return ONLY a JSON array of {"entity","relation","value"} triples describing lasting
4587
+ facts about the codebase/project \u2014 e.g. {"entity":"auth module","relation":"uses","value":"JWT"}.
4588
+ Ignore transient step-by-step details. At most 6 triples. If nothing durable, return [].
4589
+
4590
+ Subtask: ${assignment.subtaskTitle}
4591
+ Output:
4592
+ ${output.slice(0, 4e3)}`;
4593
+ const result = await this.router.generate("T3", {
4594
+ messages: [{ role: "user", content: prompt }],
4595
+ maxTokens: 300,
4596
+ temperature: 0,
4597
+ ...this.localOnlyMatch ? { forceLocal: true } : {}
4598
+ });
4599
+ const match = /\[[\s\S]*\]/.exec(result.content);
4600
+ if (!match) return;
4601
+ const facts = JSON.parse(match[0]);
4602
+ if (!Array.isArray(facts)) return;
4603
+ for (const f of facts.slice(0, 8)) {
4604
+ if (f && typeof f.entity === "string" && typeof f.relation === "string" && typeof f.value === "string") {
4605
+ db.upsertFact(f.entity, f.relation, f.value, this.id);
4606
+ }
4607
+ }
4608
+ } catch {
4609
+ }
4610
+ }
4397
4611
  async correctOutput(originalOutput, failures) {
4398
4612
  const correctionPrompt = `The following output failed these checks: ${failures.join(", ")}.
4399
4613
 
@@ -5796,10 +6010,24 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
5796
6010
  }
5797
6011
  async decomposeTask(prompt, systemContext) {
5798
6012
  const db = this.router.getWorldStateDB?.();
5799
- const worldStateContext = db ? `
6013
+ let worldStateContext = "";
6014
+ if (db) {
6015
+ const knowledge = db.getFormattedKnowledge(prompt);
6016
+ if (knowledge) {
6017
+ worldStateContext = `
6018
+
6019
+ PROJECT KNOWLEDGE (relevant facts about this codebase):
6020
+ ${knowledge}`;
6021
+ } else {
6022
+ const log = db.getFormattedState();
6023
+ if (log && log !== "World State is currently empty.") {
6024
+ worldStateContext = `
5800
6025
 
5801
6026
  PROJECT WORLD STATE (Current architecture and recent changes):
5802
- ${db.getFormattedState()}` : "";
6027
+ ${log}`;
6028
+ }
6029
+ }
6030
+ }
5803
6031
  const contextSection = systemContext ? `
5804
6032
  Project context:
5805
6033
  ${systemContext}` : "";
@@ -8061,7 +8289,15 @@ var ToolsConfigSchema = zod.z.object({
8061
8289
  mcpServers: zod.z.array(McpServerConfigSchema).optional(),
8062
8290
  mcpTrusted: zod.z.array(zod.z.string()).optional(),
8063
8291
  /** Web search backends — at least one should be configured for best results */
8064
- webSearch: WebSearchConfigSchema.optional()
8292
+ webSearch: WebSearchConfigSchema.optional(),
8293
+ /**
8294
+ * Sandbox runtime for LLM-authored dynamic tools:
8295
+ * - 'isolate': hard V8 isolate (isolated-vm) — no Node globals, true capability
8296
+ * confinement. Requires the optional native `isolated-vm` dependency.
8297
+ * - 'worker': node:worker_threads (resource/kill limits, but not capability-confined).
8298
+ * - 'auto' (default): use the isolate when `isolated-vm` loads, else fall back to worker.
8299
+ */
8300
+ dynamicToolSandbox: zod.z.enum(["isolate", "worker", "auto"]).default("auto")
8065
8301
  });
8066
8302
  var HookDefinitionSchema = zod.z.object({
8067
8303
  name: zod.z.string().optional(),
@@ -8173,10 +8409,20 @@ var CascadeConfigSchema = zod.z.object({
8173
8409
  /**
8174
8410
  * Runtime Tool Creation: when true, T3 workers can generate and register new tools
8175
8411
  * at runtime via the ToolCreator when no existing tool can handle a required operation.
8176
- * Generated tools are session-scoped and sandboxed in node:vm.
8412
+ * Generated tools are session-scoped and sandboxed (see tools.dynamicToolSandbox).
8177
8413
  * HTTP calls from generated tools require approval.
8178
8414
  */
8179
8415
  enableToolCreation: zod.z.boolean().default(true),
8416
+ /**
8417
+ * Project knowledge (world state). `factsExtraction`: after each worker
8418
+ * completes, run a cheap extraction pass that distills its output into
8419
+ * queryable entity/relation/value facts (superseding older facts), which T1
8420
+ * queries during planning instead of replaying the whole linear log. Best-effort
8421
+ * and non-blocking; set false to keep only the raw linear log.
8422
+ */
8423
+ knowledge: zod.z.object({
8424
+ factsExtraction: zod.z.boolean().default(true)
8425
+ }).default({}),
8180
8426
  /**
8181
8427
  * Persist runtime-generated tools to .cascade/dynamic-tools.json and reload them
8182
8428
  * on startup for cross-run dedup. Reloaded (and peer-received) tools are always
@@ -8469,13 +8715,17 @@ var TaskAnalyzer = class {
8469
8715
  * Scores tier-eligible models using cost efficiency + historical performance.
8470
8716
  * Falls back to the priority-list default when no candidates have history.
8471
8717
  */
8472
- async selectModel(prompt, tier, selector) {
8718
+ async selectModel(prompt, tier, selector, opts) {
8473
8719
  const profile = await this.analyze(prompt);
8474
8720
  if (profile.requiresVision) {
8475
8721
  return selector.selectVisionModel();
8476
8722
  }
8477
- const candidates = selector.getCandidatesForTier(tier);
8723
+ let candidates = selector.getCandidatesForTier(tier);
8478
8724
  if (candidates.length === 0) return selector.selectForTier(tier);
8725
+ if (opts?.requiresToolUse) {
8726
+ const toolCapable = candidates.filter((m) => m.supportsToolUse !== false);
8727
+ if (toolCapable.length > 0) candidates = toolCapable;
8728
+ }
8479
8729
  const scored = candidates.map((m) => ({
8480
8730
  model: m,
8481
8731
  score: this.scoreModel(m, profile)
@@ -8654,6 +8904,28 @@ var ModelPerformanceTracker = class {
8654
8904
  return Math.max(0.1, 1 - normalised * complexityWeight);
8655
8905
  }
8656
8906
  };
8907
+ var ivmCache;
8908
+ var ivmWarned = false;
8909
+ async function loadIsolatedVm() {
8910
+ if (ivmCache !== void 0) return ivmCache;
8911
+ try {
8912
+ const specifier = "isolated-vm";
8913
+ const mod = await import(specifier);
8914
+ ivmCache = mod.default ?? mod;
8915
+ } catch {
8916
+ ivmCache = null;
8917
+ }
8918
+ return ivmCache;
8919
+ }
8920
+ function safeJsonParse(text) {
8921
+ if (typeof text !== "string") return {};
8922
+ try {
8923
+ const v = JSON.parse(text);
8924
+ return v && typeof v === "object" ? v : {};
8925
+ } catch {
8926
+ return {};
8927
+ }
8928
+ }
8657
8929
  var DYNAMIC_TOOLS_FILE = "dynamic-tools.json";
8658
8930
  function normalizeToolSchema(schema) {
8659
8931
  if (schema && schema["type"] === "object" && typeof schema["properties"] === "object") {
@@ -8756,7 +9028,12 @@ var DynamicTool = class extends BaseTool {
8756
9028
  getEscalator;
8757
9029
  /** Untrusted = loaded from disk / a peer; its dangerous calls always re-prompt. */
8758
9030
  trusted;
8759
- constructor(spec, registry, getEscalator, trusted) {
9031
+ /** Resolve the configured sandbox mode at call time (default 'auto'). */
9032
+ getSandboxMode;
9033
+ /** Optional diagnostics sink (routed through the host so it survives the Ink TUI). */
9034
+ log;
9035
+ constructor(spec, registry, getEscalator, trusted, getSandboxMode = () => "auto", log = () => {
9036
+ }) {
8760
9037
  super();
8761
9038
  this.name = spec.name;
8762
9039
  this.description = spec.description;
@@ -8766,6 +9043,8 @@ var DynamicTool = class extends BaseTool {
8766
9043
  this.registry = registry;
8767
9044
  this.getEscalator = getEscalator;
8768
9045
  this.trusted = trusted;
9046
+ this.getSandboxMode = getSandboxMode;
9047
+ this.log = log;
8769
9048
  }
8770
9049
  isDangerous() {
8771
9050
  return this._isDangerous;
@@ -8802,8 +9081,94 @@ var DynamicTool = class extends BaseTool {
8802
9081
  return `Error calling ${toolName}: ${err instanceof Error ? err.message : String(err)}`;
8803
9082
  }
8804
9083
  };
9084
+ const mode = this.getSandboxMode();
9085
+ if (mode !== "worker") {
9086
+ const ivm = await loadIsolatedVm();
9087
+ if (ivm) return this.runInIsolate(ivm, input, callTool);
9088
+ if (mode === "isolate" && !ivmWarned) {
9089
+ ivmWarned = true;
9090
+ 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.");
9091
+ }
9092
+ }
8805
9093
  return this.runInWorker(input, callTool);
8806
9094
  }
9095
+ /**
9096
+ * Run the generated code in a hard V8 isolate (isolated-vm). The isolate global
9097
+ * has no Node built-ins, so the code cannot see `process`, `require`, the
9098
+ * filesystem, or the network — it reaches the host ONLY through the injected
9099
+ * `callTool` (escalator-gated on the main thread) and `fetch` (SSRF-guarded via
9100
+ * bridgeFetch). `script.run({ timeout })` bounds synchronous CPU; an outer
9101
+ * wall-clock race + `isolate.dispose()` bounds async runaway (a never-resolving
9102
+ * await), mirroring the worker's terminate().
9103
+ */
9104
+ async runInIsolate(ivm, input, callTool) {
9105
+ const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
9106
+ const isolate = new ivm.Isolate({ memoryLimit: 128 });
9107
+ let disposed = false;
9108
+ const dispose = () => {
9109
+ if (!disposed) {
9110
+ disposed = true;
9111
+ try {
9112
+ isolate.dispose();
9113
+ } catch {
9114
+ }
9115
+ }
9116
+ };
9117
+ try {
9118
+ const context = await isolate.createContext();
9119
+ const jail = context.global;
9120
+ await jail.set("_callTool", new ivm.Reference(async (name, inputJson) => {
9121
+ const out = await callTool(String(name), safeJsonParse(inputJson));
9122
+ return String(out);
9123
+ }));
9124
+ await jail.set("_fetch", new ivm.Reference(async (url, initJson) => {
9125
+ const r = await bridgeFetch(String(url), safeJsonParse(initJson));
9126
+ return JSON.stringify(r);
9127
+ }));
9128
+ await jail.set("_input", new ivm.ExternalCopy(input).copyInto());
9129
+ await jail.set("_code", this.executeCode);
9130
+ const bootstrap = `
9131
+ const callTool = (name, toolInput) => _callTool.apply(undefined,
9132
+ [String(name), JSON.stringify(toolInput || {})],
9133
+ { result: { promise: true }, arguments: { copy: true } });
9134
+ const fetch = async (url, init) => {
9135
+ const raw = await _fetch.apply(undefined,
9136
+ [String(url), JSON.stringify(init || null)],
9137
+ { result: { promise: true }, arguments: { copy: true } });
9138
+ const r = JSON.parse(raw);
9139
+ if (r && r.__error) throw new Error(r.__error);
9140
+ return {
9141
+ ok: r.ok, status: r.status, statusText: r.statusText,
9142
+ headers: { get: (k) => (String(k).toLowerCase() === 'content-type' ? r.contentType : null) },
9143
+ text: async () => r.body,
9144
+ json: async () => JSON.parse(r.body),
9145
+ };
9146
+ };
9147
+ const console = { log() {}, error() {} };
9148
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
9149
+ const fn = new AsyncFunction('input', 'callTool', 'fetch', 'console', _code);
9150
+ (async () => { const r = await fn(_input, callTool, fetch, console); return String(r == null ? '' : r); })();
9151
+ `;
9152
+ const script = await isolate.compileScript(bootstrap);
9153
+ const runPromise = script.run(context, { timeout: timeoutMs, promise: true }).then((v) => String(v ?? ""));
9154
+ const timeoutPromise = new Promise((resolve) => {
9155
+ const t = setTimeout(() => {
9156
+ dispose();
9157
+ resolve(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`);
9158
+ }, timeoutMs + 500);
9159
+ t.unref?.();
9160
+ });
9161
+ return await Promise.race([runPromise, timeoutPromise]);
9162
+ } catch (err) {
9163
+ const msg = err instanceof Error ? err.message : String(err);
9164
+ if (/timed? out/i.test(msg)) {
9165
+ return `Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`;
9166
+ }
9167
+ return `Tool error: ${msg}`;
9168
+ } finally {
9169
+ dispose();
9170
+ }
9171
+ }
8807
9172
  /** Spawn the worker, service its callTool/fetch bridge, enforce the kill timeout. */
8808
9173
  runInWorker(input, callTool) {
8809
9174
  const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
@@ -8883,16 +9248,19 @@ var ToolCreator = class {
8883
9248
  workspacePath;
8884
9249
  /** When false, persisted tools are neither loaded nor written. */
8885
9250
  persistEnabled;
9251
+ /** Sandbox runtime for generated tools; passed to each DynamicTool. */
9252
+ sandboxMode;
8886
9253
  logger;
8887
9254
  /** name → spec, for persistence, broadcast, and re-registration. */
8888
9255
  specs = /* @__PURE__ */ new Map();
8889
9256
  /** capability fingerprint → tool name, so the same need isn't re-generated. */
8890
9257
  capabilityIndex = /* @__PURE__ */ new Map();
8891
- constructor(router, registry, workspacePath, persistEnabled = true) {
9258
+ constructor(router, registry, workspacePath, persistEnabled = true, sandboxMode = "auto") {
8892
9259
  this.router = router;
8893
9260
  this.registry = registry;
8894
9261
  this.workspacePath = workspacePath;
8895
9262
  this.persistEnabled = persistEnabled;
9263
+ this.sandboxMode = sandboxMode;
8896
9264
  }
8897
9265
  setPermissionEscalator(escalator) {
8898
9266
  this.escalator = escalator;
@@ -8980,7 +9348,14 @@ Required capability: ${description.slice(0, 300)}`;
8980
9348
  this.specs.set(spec.name, spec);
8981
9349
  return;
8982
9350
  }
8983
- const tool = new DynamicTool(spec, this.registry, () => this.escalator, trusted);
9351
+ const tool = new DynamicTool(
9352
+ spec,
9353
+ this.registry,
9354
+ () => this.escalator,
9355
+ trusted,
9356
+ () => this.sandboxMode,
9357
+ (msg) => this.log(msg)
9358
+ );
8984
9359
  this.registry.register(tool);
8985
9360
  this.specs.set(spec.name, spec);
8986
9361
  this.capabilityIndex.set(capabilityKey(`${spec.description}`), spec.name);
@@ -9029,6 +9404,9 @@ Required capability: ${description.slice(0, 300)}`;
9029
9404
  return Array.from(this.specs.keys());
9030
9405
  }
9031
9406
  };
9407
+ function normalizeKey(text) {
9408
+ return text.trim().toLowerCase().replace(/\s+/g, " ");
9409
+ }
9032
9410
  var WorldStateDB = class {
9033
9411
  constructor(workspacePath, debugMode = false) {
9034
9412
  this.workspacePath = workspacePath;
@@ -9050,6 +9428,16 @@ var WorldStateDB = class {
9050
9428
  encrypted_payload TEXT NOT NULL
9051
9429
  )
9052
9430
  `);
9431
+ this.db.exec(`
9432
+ CREATE TABLE IF NOT EXISTS facts (
9433
+ entity TEXT NOT NULL,
9434
+ relation TEXT NOT NULL,
9435
+ encrypted_value TEXT NOT NULL,
9436
+ source_worker TEXT NOT NULL,
9437
+ timestamp TEXT NOT NULL,
9438
+ PRIMARY KEY (entity, relation)
9439
+ )
9440
+ `);
9053
9441
  }
9054
9442
  workspacePath;
9055
9443
  debugMode;
@@ -9123,6 +9511,131 @@ var WorldStateDB = class {
9123
9511
  if (entries.length === 0) return "World State is currently empty.";
9124
9512
  return entries.map((e, idx) => `[${e.timestamp}] Step ${idx + 1} (${e.workerId}): ${e.summary}`).join("\n");
9125
9513
  }
9514
+ // ── world-state v2: queryable facts ──────────────
9515
+ /**
9516
+ * Upsert a fact. `(entity, relation)` is normalized so casing/whitespace don't
9517
+ * fragment the key; an existing pair is superseded (value + provenance updated)
9518
+ * rather than duplicated. Empty entity/relation/value are ignored.
9519
+ */
9520
+ upsertFact(entity, relation, value, sourceWorker, timestamp) {
9521
+ const e = normalizeKey(entity);
9522
+ const r = normalizeKey(relation);
9523
+ const v = value.trim();
9524
+ if (!e || !r || !v) return;
9525
+ const encrypted = this.encrypt(JSON.stringify({ value: v }));
9526
+ const stmt = this.db.prepare(`
9527
+ INSERT INTO facts (entity, relation, encrypted_value, source_worker, timestamp)
9528
+ VALUES (?, ?, ?, ?, ?)
9529
+ ON CONFLICT(entity, relation) DO UPDATE SET
9530
+ encrypted_value = excluded.encrypted_value,
9531
+ source_worker = excluded.source_worker,
9532
+ timestamp = excluded.timestamp
9533
+ `);
9534
+ stmt.run(e, r, encrypted, sourceWorker, timestamp ?? (/* @__PURE__ */ new Date()).toISOString());
9535
+ this.dumpDebugIfNeeded();
9536
+ }
9537
+ rowToFact(row) {
9538
+ let value;
9539
+ try {
9540
+ value = JSON.parse(this.decrypt(row.encrypted_value)).value;
9541
+ } catch {
9542
+ value = "[Decryption Failed - Payload Corrupted]";
9543
+ }
9544
+ return { entity: row.entity, relation: row.relation, value, sourceWorker: row.source_worker, timestamp: row.timestamp };
9545
+ }
9546
+ /** All facts whose entity matches one of the (normalized) query entities. */
9547
+ getFactsForEntities(entities) {
9548
+ const keys = Array.from(new Set(entities.map(normalizeKey).filter(Boolean)));
9549
+ if (keys.length === 0) return [];
9550
+ const placeholders = keys.map(() => "?").join(", ");
9551
+ const stmt = this.db.prepare(
9552
+ `SELECT entity, relation, encrypted_value, source_worker, timestamp FROM facts WHERE entity IN (${placeholders}) ORDER BY entity ASC, relation ASC`
9553
+ );
9554
+ return stmt.all(...keys).map((row) => this.rowToFact(row));
9555
+ }
9556
+ /** Every fact (used for tests / debug / whole-graph views). */
9557
+ getAllFacts() {
9558
+ const stmt = this.db.prepare("SELECT entity, relation, encrypted_value, source_worker, timestamp FROM facts ORDER BY entity ASC, relation ASC");
9559
+ return stmt.all().map((row) => this.rowToFact(row));
9560
+ }
9561
+ /**
9562
+ * A compact, human/LLM-readable fact block for T1/T2 planning. When `entities`
9563
+ * is given, only facts about those entities are included; otherwise all facts.
9564
+ * Returns '' when there are none so callers can fall back to the linear log.
9565
+ */
9566
+ getFormattedFacts(entities) {
9567
+ const facts = entities && entities.length > 0 ? this.getFactsForEntities(entities) : this.getAllFacts();
9568
+ if (facts.length === 0) return "";
9569
+ return facts.map((f) => `- ${f.entity} ${f.relation} ${f.value}`).join("\n");
9570
+ }
9571
+ /**
9572
+ * A compact knowledge block for T1/T2 planning. When `prompt` is given, facts
9573
+ * whose entity/relation/value mention a prompt token are preferred (relevance
9574
+ * filter); otherwise, or when nothing matches, all facts are used (capped at
9575
+ * `limit`). Returns '' when there are no facts, so the caller can fall back to
9576
+ * the raw linear log — this replaces replaying the whole log during planning.
9577
+ */
9578
+ getFormattedKnowledge(prompt, limit = 40) {
9579
+ const all = this.getAllFacts();
9580
+ if (all.length === 0) return "";
9581
+ let selected = all;
9582
+ if (prompt && prompt.trim()) {
9583
+ const tokens = Array.from(new Set(prompt.toLowerCase().match(/[a-z0-9_./-]{3,}/g) ?? []));
9584
+ if (tokens.length > 0) {
9585
+ const matched = all.filter((f) => {
9586
+ const hay = `${f.entity} ${f.relation} ${f.value}`.toLowerCase();
9587
+ return tokens.some((t) => hay.includes(t));
9588
+ });
9589
+ if (matched.length > 0) selected = matched;
9590
+ }
9591
+ }
9592
+ return selected.slice(0, limit).map((f) => `- ${f.entity} ${f.relation} ${f.value}`).join("\n");
9593
+ }
9594
+ // ── Export / Import ──────────────────────────
9595
+ //
9596
+ // Knowledge travels DECRYPTED in the export bundle (a portable plaintext
9597
+ // JSON file — the encryption key never leaves this machine) and re-encrypts
9598
+ // with the LOCAL key on import.
9599
+ exportKnowledge() {
9600
+ return { facts: this.getAllFacts(), worldLog: this.getAllEntries() };
9601
+ }
9602
+ /**
9603
+ * Merge imported knowledge. Facts upsert on (entity, relation) with
9604
+ * newer-timestamp-wins (a local fact newer than the imported one is kept);
9605
+ * log entries append with fresh ids, skipping exact duplicates
9606
+ * (worker + timestamp + summary). Returns counts of what actually landed.
9607
+ */
9608
+ importKnowledge(data) {
9609
+ let facts = 0;
9610
+ let logEntries = 0;
9611
+ if (Array.isArray(data.facts)) {
9612
+ const local = new Map(this.getAllFacts().map((f) => [`${f.entity}\0${f.relation}`, f.timestamp]));
9613
+ for (const f of data.facts) {
9614
+ if (!f || typeof f.entity !== "string" || typeof f.relation !== "string" || typeof f.value !== "string") continue;
9615
+ const key = `${normalizeKey(f.entity)}\0${normalizeKey(f.relation)}`;
9616
+ const localTs = local.get(key);
9617
+ const importTs = f.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
9618
+ if (localTs && localTs >= importTs) continue;
9619
+ this.upsertFact(f.entity, f.relation, f.value, f.sourceWorker ?? "imported", importTs);
9620
+ facts++;
9621
+ }
9622
+ }
9623
+ if (Array.isArray(data.worldLog)) {
9624
+ const seen = new Set(this.getAllEntries().map((e) => `${e.workerId}\0${e.timestamp}\0${e.summary}`));
9625
+ const stmt = this.db.prepare("INSERT INTO world_state (id, timestamp, worker_id, encrypted_payload) VALUES (?, ?, ?, ?)");
9626
+ for (const e of data.worldLog) {
9627
+ if (!e || typeof e.summary !== "string") continue;
9628
+ const workerId = e.workerId ?? "imported";
9629
+ const timestamp = e.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
9630
+ const key = `${workerId}\0${timestamp}\0${e.summary}`;
9631
+ if (seen.has(key)) continue;
9632
+ stmt.run(crypto3__default.default.randomUUID(), timestamp, workerId, this.encrypt(JSON.stringify({ summary: e.summary })));
9633
+ seen.add(key);
9634
+ logEntries++;
9635
+ }
9636
+ }
9637
+ return { facts, logEntries };
9638
+ }
9126
9639
  dumpDebugIfNeeded() {
9127
9640
  if (!this.debugMode) return;
9128
9641
  try {
@@ -9259,7 +9772,8 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
9259
9772
  }
9260
9773
  const cfg = this.config;
9261
9774
  if (cfg["enableToolCreation"] === true) {
9262
- this.toolCreator = new ToolCreator(this.router, this.toolRegistry, this.workspacePath, cfg["persistDynamicTools"] !== false);
9775
+ const sandboxMode = this.config.tools?.dynamicToolSandbox ?? "auto";
9776
+ this.toolCreator = new ToolCreator(this.router, this.toolRegistry, this.workspacePath, cfg["persistDynamicTools"] !== false, sandboxMode);
9263
9777
  this.toolCreator.setLogger((m) => {
9264
9778
  if (this.listenerCount("log") > 0) this.emit("log", { level: "info", message: m });
9265
9779
  });
@@ -9497,6 +10011,10 @@ ${last.partialOutput}` : "");
9497
10011
  this.router.profileModels(this.store).catch(() => {
9498
10012
  });
9499
10013
  }
10014
+ if (this.store) {
10015
+ this.router.probeLocalToolSupport(this.store).catch(() => {
10016
+ });
10017
+ }
9500
10018
  if (this.config.cascadeAuto) {
9501
10019
  this.router.refreshLiveData().catch(() => {
9502
10020
  });
@@ -10568,6 +11086,78 @@ Original error: ${err.message}`
10568
11086
  `).all(`%${query}%`, limit);
10569
11087
  return rows.map(this.deserializeMessage);
10570
11088
  }
11089
+ // ── Export / Import ──────────────────────────
11090
+ //
11091
+ // Chats travel as full Session objects (messages included) inside a
11092
+ // plaintext-JSON bundle. Import NEVER overwrites: every imported session
11093
+ // gets a fresh id (title suffixed "(imported)") and fresh message ids, so
11094
+ // re-importing the same bundle can duplicate but can never clobber.
11095
+ /** Full sessions (with messages) for the given ids, or ALL sessions. */
11096
+ exportSessions(ids) {
11097
+ const targets = ids && ids.length > 0 ? ids : this.db.prepare("SELECT id FROM sessions ORDER BY updated_at DESC").all().map((r) => r.id);
11098
+ const out = [];
11099
+ for (const id of targets) {
11100
+ const s = this.getSession(id);
11101
+ if (s) out.push(s);
11102
+ }
11103
+ return out;
11104
+ }
11105
+ /**
11106
+ * Import sessions from a bundle. Returns the created `{id, title}` rows so
11107
+ * the caller can mirror them into the runtime session list (the sidebar
11108
+ * reads runtime_sessions, not sessions). Malformed entries are skipped.
11109
+ */
11110
+ importSessions(sessions) {
11111
+ const out = [];
11112
+ const msgStmt = this.db.prepare(`
11113
+ INSERT INTO messages (id, session_id, role, content, timestamp, tokens, agent_messages)
11114
+ VALUES (?, ?, ?, ?, ?, ?, ?)
11115
+ `);
11116
+ for (const s of sessions) {
11117
+ if (!s || typeof s !== "object") continue;
11118
+ const newId = crypto3.randomUUID();
11119
+ const now = (/* @__PURE__ */ new Date()).toISOString();
11120
+ const title = `${String(s.title || "Untitled").slice(0, 200)} (imported)`;
11121
+ const metadata = s.metadata && typeof s.metadata === "object" ? s.metadata : { totalTokens: 0, totalCostUsd: 0, modelsUsed: [], toolsUsed: [], taskCount: 0 };
11122
+ this.db.prepare(`
11123
+ INSERT INTO sessions (id, title, created_at, updated_at, identity_id, workspace_path, metadata)
11124
+ VALUES (?, ?, ?, ?, ?, ?, ?)
11125
+ `).run(newId, title, s.createdAt ?? now, s.updatedAt ?? now, s.identityId ?? "default", s.workspacePath ?? "", JSON.stringify(metadata));
11126
+ for (const m of Array.isArray(s.messages) ? s.messages : []) {
11127
+ if (!m || m.role !== "user" && m.role !== "assistant" && m.role !== "system") continue;
11128
+ msgStmt.run(
11129
+ crypto3.randomUUID(),
11130
+ newId,
11131
+ m.role,
11132
+ typeof m.content === "string" ? m.content : JSON.stringify(m.content),
11133
+ m.timestamp ?? now,
11134
+ m.tokens ? JSON.stringify(m.tokens) : null,
11135
+ m.agentMessages ? JSON.stringify(m.agentMessages) : null
11136
+ );
11137
+ }
11138
+ out.push({ id: newId, title });
11139
+ }
11140
+ return out;
11141
+ }
11142
+ /** Import identities/personas, deduped by (case-insensitive) name — an
11143
+ * existing name is skipped, never replaced. Imports are never the default. */
11144
+ importIdentities(identities) {
11145
+ const existing = new Set(this.listIdentities().map((i) => i.name.toLowerCase()));
11146
+ let imported = 0;
11147
+ for (const ident of identities ?? []) {
11148
+ if (!ident?.name || typeof ident.name !== "string") continue;
11149
+ if (existing.has(ident.name.toLowerCase())) continue;
11150
+ this.createIdentity({
11151
+ ...ident,
11152
+ id: crypto3.randomUUID(),
11153
+ isDefault: false,
11154
+ createdAt: ident.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
11155
+ });
11156
+ existing.add(ident.name.toLowerCase());
11157
+ imported++;
11158
+ }
11159
+ return imported;
11160
+ }
10571
11161
  // ── Identities ────────────────────────────────
10572
11162
  createIdentity(identity) {
10573
11163
  this.db.prepare(`
@@ -10745,6 +11335,23 @@ Original error: ${err.message}`
10745
11335
  const row = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(`${provider}:${modelId}`);
10746
11336
  return row ? JSON.parse(row.metadata) : void 0;
10747
11337
  }
11338
+ /**
11339
+ * Persist a probed capability verdict (currently: native tool support) into
11340
+ * the model's cached profile, merging with whatever is already recorded.
11341
+ * Read back via getModelProfile — so a model is probed once ever, not once
11342
+ * per run.
11343
+ */
11344
+ saveModelCapability(modelId, provider, caps) {
11345
+ const cacheKey = `${provider}:${modelId}`;
11346
+ const existing = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(cacheKey);
11347
+ 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 };
11348
+ if (caps.supportsToolUse !== void 0) meta.supportsToolUse = caps.supportsToolUse;
11349
+ this.db.prepare(`
11350
+ INSERT INTO model_cache (id, provider, model_id, name, metadata, updated_at)
11351
+ VALUES (?, ?, ?, ?, ?, ?)
11352
+ ON CONFLICT(id) DO UPDATE SET metadata = excluded.metadata, updated_at = excluded.updated_at
11353
+ `).run(cacheKey, provider, modelId, meta.name ?? modelId, JSON.stringify(meta), (/* @__PURE__ */ new Date()).toISOString());
11354
+ }
10748
11355
  getProfiledModelIds() {
10749
11356
  const rows = this.db.prepare(
10750
11357
  "SELECT model_id FROM model_cache WHERE json_extract(metadata, '$.specializations') IS NOT NULL"
@@ -11904,6 +12511,60 @@ ${prompt}`;
11904
12511
  this.socket.broadcast("runtime:refresh", { scope: "global" });
11905
12512
  res.json({ ok: true });
11906
12513
  });
12514
+ this.app.get("/api/export", auth, (req, res) => {
12515
+ try {
12516
+ const sessionsParam = typeof req.query["sessions"] === "string" ? req.query["sessions"] : "all";
12517
+ const includeMemories = req.query["memories"] === "1" || req.query["memories"] === "true";
12518
+ const ids = sessionsParam === "all" ? void 0 : sessionsParam.split(",").map((s) => s.trim()).filter(Boolean);
12519
+ const bundle = {
12520
+ format: "cascade-export@1",
12521
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
12522
+ sessions: this.store.exportSessions(ids)
12523
+ };
12524
+ if (includeMemories) {
12525
+ const ws = new WorldStateDB(this.workspacePath);
12526
+ try {
12527
+ bundle["memories"] = { ...ws.exportKnowledge(), identities: this.store.listIdentities() };
12528
+ } finally {
12529
+ ws.close();
12530
+ }
12531
+ }
12532
+ res.json(bundle);
12533
+ } catch (err) {
12534
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
12535
+ }
12536
+ });
12537
+ this.app.post("/api/import", auth, mutationLimiter, (req, res) => {
12538
+ try {
12539
+ const bundle = req.body;
12540
+ if (!bundle || bundle.format !== "cascade-export@1") {
12541
+ res.status(400).json({ error: "Not a cascade-export@1 bundle." });
12542
+ return;
12543
+ }
12544
+ const importedSessions = Array.isArray(bundle.sessions) ? this.store.importSessions(bundle.sessions) : [];
12545
+ for (const s of importedSessions) this.persistRuntimeRow(s.id, s.title, "COMPLETED");
12546
+ let facts = 0;
12547
+ let logEntries = 0;
12548
+ let identities = 0;
12549
+ if (bundle.memories) {
12550
+ const ws = new WorldStateDB(this.workspacePath);
12551
+ try {
12552
+ const counts = ws.importKnowledge(bundle.memories);
12553
+ facts = counts.facts;
12554
+ logEntries = counts.logEntries;
12555
+ } finally {
12556
+ ws.close();
12557
+ }
12558
+ if (Array.isArray(bundle.memories.identities)) {
12559
+ identities = this.store.importIdentities(bundle.memories.identities);
12560
+ }
12561
+ }
12562
+ this.socket.broadcast("runtime:refresh", { scope: "workspace" });
12563
+ res.json({ ok: true, imported: { sessions: importedSessions.length, facts, logEntries, identities } });
12564
+ } catch (err) {
12565
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
12566
+ }
12567
+ });
11907
12568
  this.app.post("/api/sessions/:id/rollback", auth, mutationLimiter, async (req, res) => {
11908
12569
  const sessionId = req.params.id;
11909
12570
  const taskIds = this.sessionTaskIds.get(sessionId) ?? [];