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.
@@ -23507,7 +23507,7 @@ var require_verify_stream = __commonJS({
23507
23507
  function isObject4(thing) {
23508
23508
  return Object.prototype.toString.call(thing) === "[object Object]";
23509
23509
  }
23510
- function safeJsonParse(thing) {
23510
+ function safeJsonParse2(thing) {
23511
23511
  if (isObject4(thing))
23512
23512
  return thing;
23513
23513
  try {
@@ -23518,7 +23518,7 @@ var require_verify_stream = __commonJS({
23518
23518
  }
23519
23519
  function headerFromJWS(jwsSig) {
23520
23520
  var encodedHeader = jwsSig.split(".", 1)[0];
23521
- return safeJsonParse(Buffer5.from(encodedHeader, "base64").toString("binary"));
23521
+ return safeJsonParse2(Buffer5.from(encodedHeader, "base64").toString("binary"));
23522
23522
  }
23523
23523
  function securedInputFromJWS(jwsSig) {
23524
23524
  return jwsSig.split(".", 2).join(".");
@@ -222595,7 +222595,7 @@ Anthropic.Models = Models2;
222595
222595
  Anthropic.Beta = Beta;
222596
222596
 
222597
222597
  // src/constants.ts
222598
- var CASCADE_VERSION = "0.13.2";
222598
+ var CASCADE_VERSION = "0.15.0";
222599
222599
  var CASCADE_CONFIG_DIR = ".cascade";
222600
222600
  var CASCADE_MD_FILE = "CASCADE.md";
222601
222601
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -249744,29 +249744,71 @@ var OllamaProvider = class extends BaseProvider {
249744
249744
  async countTokens(text) {
249745
249745
  return Math.ceil(text.length / 4);
249746
249746
  }
249747
+ /**
249748
+ * Ask the Ollama server what a model can actually do. Modern Ollama returns a
249749
+ * `capabilities` array ("completion", "tools", "vision", …) and the model's
249750
+ * real context length in `model_info` — authoritative, unlike the hardcoded
249751
+ * family-name allowlist, which silently misclassifies any unlisted family.
249752
+ * Best-effort: null on old servers / errors, callers fall back to heuristics.
249753
+ */
249754
+ async showModel(name) {
249755
+ const ac = new AbortController();
249756
+ const timer = setTimeout(() => ac.abort(), 2500);
249757
+ try {
249758
+ const resp = await fetch(`${this.baseUrl}/api/show`, {
249759
+ method: "POST",
249760
+ headers: { "Content-Type": "application/json" },
249761
+ body: JSON.stringify({ model: name }),
249762
+ signal: ac.signal
249763
+ });
249764
+ if (!resp.ok) return null;
249765
+ const data = await resp.json();
249766
+ const out = {};
249767
+ if (Array.isArray(data.capabilities)) {
249768
+ out.tools = data.capabilities.includes("tools");
249769
+ out.vision = data.capabilities.includes("vision");
249770
+ }
249771
+ for (const [k4, v6] of Object.entries(data.model_info ?? {})) {
249772
+ if (k4.endsWith(".context_length") && typeof v6 === "number" && v6 > 0) {
249773
+ out.contextWindow = v6;
249774
+ break;
249775
+ }
249776
+ }
249777
+ return out.tools !== void 0 || out.contextWindow ? out : null;
249778
+ } catch {
249779
+ return null;
249780
+ } finally {
249781
+ clearTimeout(timer);
249782
+ }
249783
+ }
249747
249784
  async listModels() {
249748
249785
  try {
249749
249786
  const response = await fetch(`${this.baseUrl}/api/tags`);
249750
249787
  if (!response.ok) return [];
249751
249788
  const data = await response.json();
249752
249789
  const supportedKeywords = ["llama3", "llama2", "gemma", "mistral", "mixtral", "qwen", "phi3", "codellama", "deepseek", "llava", "starcoder", "stable-code", "nomic-embed"];
249753
- return data.models.filter((m3) => {
249790
+ const entries = data.models.filter((m3) => {
249754
249791
  const name = m3.name.toLowerCase();
249755
249792
  return supportedKeywords.some((k4) => name.includes(k4));
249756
- }).map((m3) => ({
249757
- id: m3.name,
249758
- name: m3.name,
249759
- provider: "ollama",
249760
- contextWindow: 128e3,
249761
- isVisionCapable: m3.name.includes("llava") || m3.name.includes("vision"),
249762
- inputCostPer1kTokens: 0,
249763
- outputCostPer1kTokens: 0,
249764
- maxOutputTokens: 4e3,
249765
- supportsStreaming: true,
249766
- isLocal: true,
249767
- supportsToolUse: isToolCapable(m3.name),
249768
- minSizeB: this.parseSizeB(m3.details?.parameter_size)
249769
- }));
249793
+ });
249794
+ const shows = await Promise.all(entries.map((m3) => this.showModel(m3.name)));
249795
+ return entries.map((m3, i4) => {
249796
+ const show = shows[i4];
249797
+ return {
249798
+ id: m3.name,
249799
+ name: m3.name,
249800
+ provider: "ollama",
249801
+ contextWindow: show?.contextWindow ?? 128e3,
249802
+ isVisionCapable: show?.vision ?? (m3.name.includes("llava") || m3.name.includes("vision")),
249803
+ inputCostPer1kTokens: 0,
249804
+ outputCostPer1kTokens: 0,
249805
+ maxOutputTokens: 4e3,
249806
+ supportsStreaming: true,
249807
+ isLocal: true,
249808
+ supportsToolUse: show?.tools ?? isToolCapable(m3.name),
249809
+ minSizeB: this.parseSizeB(m3.details?.parameter_size)
249810
+ };
249811
+ });
249770
249812
  } catch {
249771
249813
  return [];
249772
249814
  }
@@ -250445,6 +250487,7 @@ function normalizeModelId(id) {
250445
250487
  var LiveDataProvider = class {
250446
250488
  snapshot = null;
250447
250489
  prices = /* @__PURE__ */ new Map();
250490
+ capabilities = /* @__PURE__ */ new Map();
250448
250491
  source = "bundled";
250449
250492
  fetchedAt = 0;
250450
250493
  loaded = false;
@@ -250473,6 +250516,9 @@ var LiveDataProvider = class {
250473
250516
  if (cache.prices) {
250474
250517
  for (const [id, p3] of Object.entries(cache.prices)) this.prices.set(id, p3);
250475
250518
  }
250519
+ if (cache.capabilities) {
250520
+ for (const [id, c4] of Object.entries(cache.capabilities)) this.capabilities.set(id, c4);
250521
+ }
250476
250522
  this.fetchedAt = cache.fetchedAt ?? 0;
250477
250523
  } catch {
250478
250524
  }
@@ -250493,9 +250539,9 @@ var LiveDataProvider = class {
250493
250539
  const ttlMs = this.opts.refreshHours * 36e5;
250494
250540
  const fresh = ttlMs > 0 && Date.now() - this.fetchedAt < ttlMs;
250495
250541
  if (!force && fresh && this.source !== "bundled") return;
250496
- const [snap, prices] = await Promise.all([
250542
+ const [snap, catalog] = await Promise.all([
250497
250543
  this.opts.live ? this.fetchSnapshot() : Promise.resolve(null),
250498
- this.opts.pricingLive ? this.fetchPrices() : Promise.resolve(null)
250544
+ this.opts.pricingLive ? this.fetchCatalog() : Promise.resolve(null)
250499
250545
  ]);
250500
250546
  let changed = false;
250501
250547
  if (snap) {
@@ -250503,8 +250549,12 @@ var LiveDataProvider = class {
250503
250549
  this.source = "live";
250504
250550
  changed = true;
250505
250551
  }
250506
- if (prices && prices.size > 0) {
250507
- this.prices = prices;
250552
+ if (catalog && catalog.prices.size > 0) {
250553
+ this.prices = catalog.prices;
250554
+ changed = true;
250555
+ }
250556
+ if (catalog && catalog.capabilities.size > 0) {
250557
+ this.capabilities = catalog.capabilities;
250508
250558
  changed = true;
250509
250559
  }
250510
250560
  if (changed) {
@@ -250526,21 +250576,43 @@ var LiveDataProvider = class {
250526
250576
  return null;
250527
250577
  }
250528
250578
  }
250529
- async fetchPrices() {
250579
+ /**
250580
+ * One fetch of the OpenRouter catalog yields BOTH pricing and capability
250581
+ * facts (context window, native tool support, input modalities) — the
250582
+ * capability fields used to be discarded while providers guessed/hardcoded
250583
+ * them in their listModels() stubs.
250584
+ */
250585
+ async fetchCatalog() {
250530
250586
  try {
250531
250587
  const resp = await withTimeout(fetch(OPENROUTER_MODELS_URL), FETCH_TIMEOUT_MS, "pricing fetch timed out");
250532
250588
  if (!resp.ok) return null;
250533
250589
  const data = await resp.json();
250534
250590
  if (!Array.isArray(data?.data)) return null;
250535
- const out = /* @__PURE__ */ new Map();
250591
+ const prices = /* @__PURE__ */ new Map();
250592
+ const capabilities = /* @__PURE__ */ new Map();
250536
250593
  for (const m3 of data.data) {
250537
- if (!m3?.id || !m3.pricing) continue;
250538
- const input = Number(m3.pricing.prompt) * 1e3;
250539
- const output = Number(m3.pricing.completion) * 1e3;
250540
- if (!Number.isFinite(input) || !Number.isFinite(output)) continue;
250541
- out.set(normalizeModelId(m3.id), { input, output });
250594
+ if (!m3?.id) continue;
250595
+ const key = normalizeModelId(m3.id);
250596
+ if (m3.pricing) {
250597
+ const input = Number(m3.pricing.prompt) * 1e3;
250598
+ const output = Number(m3.pricing.completion) * 1e3;
250599
+ if (Number.isFinite(input) && Number.isFinite(output)) {
250600
+ prices.set(key, { input, output });
250601
+ }
250602
+ }
250603
+ const cap = {};
250604
+ if (typeof m3.context_length === "number" && m3.context_length > 0) {
250605
+ cap.contextWindow = m3.context_length;
250606
+ }
250607
+ if (Array.isArray(m3.supported_parameters)) {
250608
+ cap.supportsTools = m3.supported_parameters.includes("tools");
250609
+ }
250610
+ if (Array.isArray(m3.architecture?.input_modalities)) {
250611
+ cap.inputModalities = m3.architecture.input_modalities;
250612
+ }
250613
+ if (Object.keys(cap).length > 0) capabilities.set(key, cap);
250542
250614
  }
250543
- return out;
250615
+ return { prices, capabilities };
250544
250616
  } catch {
250545
250617
  return null;
250546
250618
  }
@@ -250551,7 +250623,8 @@ var LiveDataProvider = class {
250551
250623
  const cache = {
250552
250624
  fetchedAt: this.fetchedAt,
250553
250625
  snapshot: this.snapshot ?? void 0,
250554
- prices: Object.fromEntries(this.prices)
250626
+ prices: Object.fromEntries(this.prices),
250627
+ capabilities: Object.fromEntries(this.capabilities)
250555
250628
  };
250556
250629
  await fs8__namespace.default.writeFile(this.opts.cacheFile, JSON.stringify(cache, null, 2), "utf-8");
250557
250630
  } catch {
@@ -250576,6 +250649,27 @@ var LiveDataProvider = class {
250576
250649
  return { ...m3, inputCostPer1kTokens: p3.input, outputCostPer1kTokens: p3.output };
250577
250650
  });
250578
250651
  }
250652
+ /** Current capability facts for a model id, or null when unknown. */
250653
+ getCapability(modelId) {
250654
+ return this.capabilities.get(normalizeModelId(modelId)) ?? null;
250655
+ }
250656
+ /**
250657
+ * Returns capability-corrected copies of each model (originals untouched):
250658
+ * real context windows replace the providers' hardcoded guesses, native
250659
+ * tool support replaces the assume-by-provider default, and vision is set
250660
+ * from the declared input modalities.
250661
+ */
250662
+ applyLiveCapabilities(models) {
250663
+ return models.map((m3) => {
250664
+ const c4 = this.getCapability(m3.id);
250665
+ if (!c4) return m3;
250666
+ const next = { ...m3 };
250667
+ if (c4.contextWindow) next.contextWindow = c4.contextWindow;
250668
+ if (c4.supportsTools !== void 0) next.supportsToolUse = c4.supportsTools;
250669
+ if (c4.inputModalities) next.isVisionCapable = c4.inputModalities.includes("image");
250670
+ return next;
250671
+ });
250672
+ }
250579
250673
  /** Where the active quality data came from — for /why and `cascade models`. */
250580
250674
  getDataSource() {
250581
250675
  return this.source;
@@ -250586,6 +250680,9 @@ var LiveDataProvider = class {
250586
250680
  hasLivePricing() {
250587
250681
  return this.prices.size > 0;
250588
250682
  }
250683
+ hasCapabilities() {
250684
+ return this.capabilities.size > 0;
250685
+ }
250589
250686
  };
250590
250687
 
250591
250688
  // src/core/router/benchmarks.ts
@@ -250774,6 +250871,57 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
250774
250871
  profiler.profileAll(allModels).catch(() => {
250775
250872
  });
250776
250873
  }
250874
+ /**
250875
+ * One-time native tool-call probe for local/compat models with NO capability
250876
+ * metadata. llama.cpp / LM Studio "/models" endpoints return ids only, so
250877
+ * `supportsToolUse` stays undefined and the T3 tool gate ASSUMES native
250878
+ * support — wrong for many local builds, which then fumble tool-format
250879
+ * output. Each unknown model is probed once with a trivial tool; the verdict
250880
+ * persists in the MemoryStore's model_cache (via the previously-dormant
250881
+ * getModelProfile read-back), so a model is probed once EVER, not per run.
250882
+ * Cloud providers are never probed — their metadata is authoritative.
250883
+ */
250884
+ async probeLocalToolSupport(store) {
250885
+ const unknown2 = this.selector.getAllAvailableModels().filter(
250886
+ (m3) => m3.provider === "openai-compatible" && m3.supportsToolUse === void 0
250887
+ );
250888
+ for (const model of unknown2) {
250889
+ const cached2 = store.getModelProfile(model.id, model.provider);
250890
+ let verdict = cached2?.supportsToolUse;
250891
+ if (verdict === void 0) {
250892
+ const probed = await this.probeNativeToolCall(model);
250893
+ if (probed === null) continue;
250894
+ verdict = probed;
250895
+ store.saveModelCapability(model.id, model.provider, { supportsToolUse: verdict });
250896
+ }
250897
+ this.selector.addDynamicModel({ ...model, supportsToolUse: verdict });
250898
+ }
250899
+ for (const tier of ["T1", "T2", "T3"]) {
250900
+ const cur = this.tierModels.get(tier);
250901
+ if (!cur) continue;
250902
+ const fresh = this.selector.getModelById(cur.id);
250903
+ if (fresh) this.tierModels.set(tier, fresh);
250904
+ }
250905
+ }
250906
+ async probeNativeToolCall(model) {
250907
+ try {
250908
+ const cfg = this.config.providers.find((p3) => p3.type === model.provider) ?? { type: model.provider };
250909
+ const provider = this.createProvider(cfg, model);
250910
+ const result = await withTimeout(provider.generate({
250911
+ messages: [{ role: "user", content: "Call the echo tool with text set to 'ping'. Use the tool; do not answer in prose." }],
250912
+ tools: [{
250913
+ name: "echo",
250914
+ description: "Echo the given text back to the caller.",
250915
+ inputSchema: { type: "object", properties: { text: { type: "string", description: "Text to echo" } }, required: ["text"] }
250916
+ }],
250917
+ maxTokens: 80,
250918
+ temperature: 0
250919
+ }), 3e4, "tool-support probe timed out");
250920
+ return (result.toolCalls?.length ?? 0) > 0;
250921
+ } catch {
250922
+ return null;
250923
+ }
250924
+ }
250777
250925
  /**
250778
250926
  * Cascade Auto live data: discover/validate real model ids from each cloud
250779
250927
  * provider, then fetch current public quality scores + per-token prices and
@@ -250823,12 +250971,17 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
250823
250971
  await Promise.allSettled(tasks);
250824
250972
  }
250825
250973
  /**
250826
- * Replace available models with live-priced copies and refresh the already
250827
- * resolved tier models so shared-tier cost accounting uses current prices.
250974
+ * Replace available models with live-priced AND capability-corrected copies
250975
+ * (real context windows, native tool support, vision from modalities), then
250976
+ * refresh the already resolved tier models so shared-tier cost accounting and
250977
+ * the tool-use gate both see current data.
250828
250978
  */
250829
250979
  applyLivePricing() {
250830
- if (!this.liveData?.hasLivePricing()) return;
250831
- const updated = this.liveData.applyLivePricing(this.selector.getAllAvailableModels());
250980
+ if (!this.liveData) return;
250981
+ if (!this.liveData.hasLivePricing() && !this.liveData.hasCapabilities()) return;
250982
+ let updated = this.selector.getAllAvailableModels();
250983
+ if (this.liveData.hasLivePricing()) updated = this.liveData.applyLivePricing(updated);
250984
+ if (this.liveData.hasCapabilities()) updated = this.liveData.applyLiveCapabilities(updated);
250832
250985
  for (const m3 of updated) this.selector.addDynamicModel(m3);
250833
250986
  for (const tier of ["T1", "T2", "T3"]) {
250834
250987
  const cur = this.tierModels.get(tier);
@@ -251003,6 +251156,10 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
251003
251156
  const r4 = this.config?.reinforcements;
251004
251157
  return { enabled: r4?.enabled === true, maxPerSection: r4?.maxPerSection ?? 4 };
251005
251158
  }
251159
+ /** Project-knowledge settings (config.knowledge). Facts extraction on by default. */
251160
+ getKnowledgeConfig() {
251161
+ return { factsExtraction: this.config?.knowledge?.factsExtraction !== false };
251162
+ }
251006
251163
  /**
251007
251164
  * Resolved T3 wave execution mode. 'auto' becomes 'sequential' when the T3
251008
251165
  * tier resolves to a LOCAL model (the single-GPU queue serializes anyway, so
@@ -251089,10 +251246,10 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
251089
251246
  * null when Cascade Auto is off (callers then use the shared tier model).
251090
251247
  * Pure heuristic — no extra LLM call.
251091
251248
  */
251092
- async selectModelForSubtask(tier, text) {
251249
+ async selectModelForSubtask(tier, text, opts) {
251093
251250
  if (!this.config?.cascadeAuto || !this.taskAnalyzer || !text.trim()) return null;
251094
251251
  try {
251095
- return await this.taskAnalyzer.selectModel(text, tier, this.selector);
251252
+ return await this.taskAnalyzer.selectModel(text, tier, this.selector, opts);
251096
251253
  } catch {
251097
251254
  return null;
251098
251255
  }
@@ -251847,6 +252004,13 @@ EXAMPLE \u2014 calling the "shell" tool to list files:
251847
252004
  {"name": "shell", "input": {"command": "ls -la"}}
251848
252005
  </tool_call>
251849
252006
 
252007
+ When you have enough information, stop calling tools and write your final answer.`;
252008
+ }
252009
+ function buildTextToolReminder(tools) {
252010
+ return `
252011
+ TOOL USE REMINDER:
252012
+ 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.
252013
+ Available tools: ${tools.map((t4) => t4.name).join(", ")}.
251850
252014
  When you have enough information, stop calling tools and write your final answer.`;
251851
252015
  }
251852
252016
 
@@ -252086,6 +252250,9 @@ Now execute your subtask using this context where relevant.`
252086
252250
  } catch (e3) {
252087
252251
  this.log("Failed to write to World State DB");
252088
252252
  }
252253
+ if (this.router.getKnowledgeConfig?.().factsExtraction !== false) {
252254
+ await this.extractAndStoreFacts(db, assignment, output);
252255
+ }
252089
252256
  }
252090
252257
  this.setStatus("COMPLETED", output);
252091
252258
  this.sendStatusUpdate({ progressPct: 100, currentAction: "Subtask complete", status: "IN_PROGRESS", output });
@@ -252147,7 +252314,7 @@ Now execute your subtask using this context where relevant.`
252147
252314
  let subtaskModel;
252148
252315
  try {
252149
252316
  const subtaskText = `${this.assignment?.subtaskTitle ?? ""} ${this.assignment?.description ?? ""} ${this.assignment?.expectedOutput ?? ""}`;
252150
- subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText) ?? void 0;
252317
+ subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText, { requiresToolUse: tools.length > 0 }) ?? void 0;
252151
252318
  if (subtaskModel) {
252152
252319
  this.log(`Cascade Auto: routing this subtask to ${subtaskModel.provider}:${subtaskModel.id}`);
252153
252320
  }
@@ -252155,7 +252322,8 @@ Now execute your subtask using this context where relevant.`
252155
252322
  }
252156
252323
  const effectiveModel = subtaskModel ?? this.router.getModelForTier("T3");
252157
252324
  const useTextTools = effectiveModel?.supportsToolUse === false && tools.length > 0;
252158
- const textToolSuffix = useTextTools ? buildTextToolSystemPrompt(tools) : "";
252325
+ let sentFullTextContract = false;
252326
+ let textContractSignature = "";
252159
252327
  while (iterations < MAX_ITERATIONS) {
252160
252328
  iterations++;
252161
252329
  this.throwIfCancelled();
@@ -252169,6 +252337,17 @@ Now execute your subtask using this context where relevant.`
252169
252337
  ${g2.text}`
252170
252338
  });
252171
252339
  }
252340
+ let textToolSuffix = "";
252341
+ if (useTextTools) {
252342
+ const signature = tools.map((t4) => t4.name).join(",");
252343
+ if (!sentFullTextContract || signature !== textContractSignature) {
252344
+ textToolSuffix = buildTextToolSystemPrompt(tools);
252345
+ sentFullTextContract = true;
252346
+ textContractSignature = signature;
252347
+ } else {
252348
+ textToolSuffix = buildTextToolReminder(tools);
252349
+ }
252350
+ }
252172
252351
  const options = {
252173
252352
  messages: this.context.getMessages(),
252174
252353
  systemPrompt: this.systemPromptOverride + systemPrompt + (this.hierarchyContext ? `
@@ -252658,6 +252837,41 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
252658
252837
  };
252659
252838
  }
252660
252839
  }
252840
+ /**
252841
+ * world-state v2: distill a completed subtask's output into durable
252842
+ * `(entity, relation, value)` facts and upsert them into the knowledge graph.
252843
+ * A bounded, cheap T3-tier call; entirely best-effort — any failure is swallowed
252844
+ * so it never blocks or fails the subtask. Respects the subtask's privacy tier
252845
+ * (a local-only subtask extracts on a local model too, never leaking to cloud).
252846
+ */
252847
+ async extractAndStoreFacts(db, assignment, output) {
252848
+ try {
252849
+ const prompt = `Extract durable project facts from this completed subtask.
252850
+ Return ONLY a JSON array of {"entity","relation","value"} triples describing lasting
252851
+ facts about the codebase/project \u2014 e.g. {"entity":"auth module","relation":"uses","value":"JWT"}.
252852
+ Ignore transient step-by-step details. At most 6 triples. If nothing durable, return [].
252853
+
252854
+ Subtask: ${assignment.subtaskTitle}
252855
+ Output:
252856
+ ${output.slice(0, 4e3)}`;
252857
+ const result = await this.router.generate("T3", {
252858
+ messages: [{ role: "user", content: prompt }],
252859
+ maxTokens: 300,
252860
+ temperature: 0,
252861
+ ...this.localOnlyMatch ? { forceLocal: true } : {}
252862
+ });
252863
+ const match = /\[[\s\S]*\]/.exec(result.content);
252864
+ if (!match) return;
252865
+ const facts = JSON.parse(match[0]);
252866
+ if (!Array.isArray(facts)) return;
252867
+ for (const f4 of facts.slice(0, 8)) {
252868
+ if (f4 && typeof f4.entity === "string" && typeof f4.relation === "string" && typeof f4.value === "string") {
252869
+ db.upsertFact(f4.entity, f4.relation, f4.value, this.id);
252870
+ }
252871
+ }
252872
+ } catch {
252873
+ }
252874
+ }
252661
252875
  async correctOutput(originalOutput, failures) {
252662
252876
  const correctionPrompt = `The following output failed these checks: ${failures.join(", ")}.
252663
252877
 
@@ -254060,10 +254274,24 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
254060
254274
  }
254061
254275
  async decomposeTask(prompt, systemContext) {
254062
254276
  const db = this.router.getWorldStateDB?.();
254063
- const worldStateContext = db ? `
254277
+ let worldStateContext = "";
254278
+ if (db) {
254279
+ const knowledge = db.getFormattedKnowledge(prompt);
254280
+ if (knowledge) {
254281
+ worldStateContext = `
254282
+
254283
+ PROJECT KNOWLEDGE (relevant facts about this codebase):
254284
+ ${knowledge}`;
254285
+ } else {
254286
+ const log = db.getFormattedState();
254287
+ if (log && log !== "World State is currently empty.") {
254288
+ worldStateContext = `
254064
254289
 
254065
254290
  PROJECT WORLD STATE (Current architecture and recent changes):
254066
- ${db.getFormattedState()}` : "";
254291
+ ${log}`;
254292
+ }
254293
+ }
254294
+ }
254067
254295
  const contextSection = systemContext ? `
254068
254296
  Project context:
254069
254297
  ${systemContext}` : "";
@@ -294499,7 +294727,15 @@ var ToolsConfigSchema = external_exports.object({
294499
294727
  mcpServers: external_exports.array(McpServerConfigSchema).optional(),
294500
294728
  mcpTrusted: external_exports.array(external_exports.string()).optional(),
294501
294729
  /** Web search backends — at least one should be configured for best results */
294502
- webSearch: WebSearchConfigSchema.optional()
294730
+ webSearch: WebSearchConfigSchema.optional(),
294731
+ /**
294732
+ * Sandbox runtime for LLM-authored dynamic tools:
294733
+ * - 'isolate': hard V8 isolate (isolated-vm) — no Node globals, true capability
294734
+ * confinement. Requires the optional native `isolated-vm` dependency.
294735
+ * - 'worker': node:worker_threads (resource/kill limits, but not capability-confined).
294736
+ * - 'auto' (default): use the isolate when `isolated-vm` loads, else fall back to worker.
294737
+ */
294738
+ dynamicToolSandbox: external_exports.enum(["isolate", "worker", "auto"]).default("auto")
294503
294739
  });
294504
294740
  var HookDefinitionSchema = external_exports.object({
294505
294741
  name: external_exports.string().optional(),
@@ -294611,10 +294847,20 @@ var CascadeConfigSchema = external_exports.object({
294611
294847
  /**
294612
294848
  * Runtime Tool Creation: when true, T3 workers can generate and register new tools
294613
294849
  * at runtime via the ToolCreator when no existing tool can handle a required operation.
294614
- * Generated tools are session-scoped and sandboxed in node:vm.
294850
+ * Generated tools are session-scoped and sandboxed (see tools.dynamicToolSandbox).
294615
294851
  * HTTP calls from generated tools require approval.
294616
294852
  */
294617
294853
  enableToolCreation: external_exports.boolean().default(true),
294854
+ /**
294855
+ * Project knowledge (world state). `factsExtraction`: after each worker
294856
+ * completes, run a cheap extraction pass that distills its output into
294857
+ * queryable entity/relation/value facts (superseding older facts), which T1
294858
+ * queries during planning instead of replaying the whole linear log. Best-effort
294859
+ * and non-blocking; set false to keep only the raw linear log.
294860
+ */
294861
+ knowledge: external_exports.object({
294862
+ factsExtraction: external_exports.boolean().default(true)
294863
+ }).default({}),
294618
294864
  /**
294619
294865
  * Persist runtime-generated tools to .cascade/dynamic-tools.json and reload them
294620
294866
  * on startup for cross-run dedup. Reloaded (and peer-received) tools are always
@@ -294907,13 +295153,17 @@ var TaskAnalyzer = class {
294907
295153
  * Scores tier-eligible models using cost efficiency + historical performance.
294908
295154
  * Falls back to the priority-list default when no candidates have history.
294909
295155
  */
294910
- async selectModel(prompt, tier, selector) {
295156
+ async selectModel(prompt, tier, selector, opts) {
294911
295157
  const profile = await this.analyze(prompt);
294912
295158
  if (profile.requiresVision) {
294913
295159
  return selector.selectVisionModel();
294914
295160
  }
294915
- const candidates = selector.getCandidatesForTier(tier);
295161
+ let candidates = selector.getCandidatesForTier(tier);
294916
295162
  if (candidates.length === 0) return selector.selectForTier(tier);
295163
+ if (opts?.requiresToolUse) {
295164
+ const toolCapable = candidates.filter((m3) => m3.supportsToolUse !== false);
295165
+ if (toolCapable.length > 0) candidates = toolCapable;
295166
+ }
294917
295167
  const scored = candidates.map((m3) => ({
294918
295168
  model: m3,
294919
295169
  score: this.scoreModel(m3, profile)
@@ -295092,6 +295342,28 @@ var ModelPerformanceTracker = class {
295092
295342
  return Math.max(0.1, 1 - normalised * complexityWeight);
295093
295343
  }
295094
295344
  };
295345
+ var ivmCache;
295346
+ var ivmWarned = false;
295347
+ async function loadIsolatedVm() {
295348
+ if (ivmCache !== void 0) return ivmCache;
295349
+ try {
295350
+ const specifier = "isolated-vm";
295351
+ const mod = await import(specifier);
295352
+ ivmCache = mod.default ?? mod;
295353
+ } catch {
295354
+ ivmCache = null;
295355
+ }
295356
+ return ivmCache;
295357
+ }
295358
+ function safeJsonParse(text) {
295359
+ if (typeof text !== "string") return {};
295360
+ try {
295361
+ const v6 = JSON.parse(text);
295362
+ return v6 && typeof v6 === "object" ? v6 : {};
295363
+ } catch {
295364
+ return {};
295365
+ }
295366
+ }
295095
295367
  var DYNAMIC_TOOLS_FILE = "dynamic-tools.json";
295096
295368
  function normalizeToolSchema(schema) {
295097
295369
  if (schema && schema["type"] === "object" && typeof schema["properties"] === "object") {
@@ -295194,7 +295466,12 @@ var DynamicTool = class extends BaseTool {
295194
295466
  getEscalator;
295195
295467
  /** Untrusted = loaded from disk / a peer; its dangerous calls always re-prompt. */
295196
295468
  trusted;
295197
- constructor(spec, registry2, getEscalator, trusted) {
295469
+ /** Resolve the configured sandbox mode at call time (default 'auto'). */
295470
+ getSandboxMode;
295471
+ /** Optional diagnostics sink (routed through the host so it survives the Ink TUI). */
295472
+ log;
295473
+ constructor(spec, registry2, getEscalator, trusted, getSandboxMode = () => "auto", log = () => {
295474
+ }) {
295198
295475
  super();
295199
295476
  this.name = spec.name;
295200
295477
  this.description = spec.description;
@@ -295204,6 +295481,8 @@ var DynamicTool = class extends BaseTool {
295204
295481
  this.registry = registry2;
295205
295482
  this.getEscalator = getEscalator;
295206
295483
  this.trusted = trusted;
295484
+ this.getSandboxMode = getSandboxMode;
295485
+ this.log = log;
295207
295486
  }
295208
295487
  isDangerous() {
295209
295488
  return this._isDangerous;
@@ -295240,8 +295519,94 @@ var DynamicTool = class extends BaseTool {
295240
295519
  return `Error calling ${toolName}: ${err instanceof Error ? err.message : String(err)}`;
295241
295520
  }
295242
295521
  };
295522
+ const mode = this.getSandboxMode();
295523
+ if (mode !== "worker") {
295524
+ const ivm = await loadIsolatedVm();
295525
+ if (ivm) return this.runInIsolate(ivm, input, callTool);
295526
+ if (mode === "isolate" && !ivmWarned) {
295527
+ ivmWarned = true;
295528
+ 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.");
295529
+ }
295530
+ }
295243
295531
  return this.runInWorker(input, callTool);
295244
295532
  }
295533
+ /**
295534
+ * Run the generated code in a hard V8 isolate (isolated-vm). The isolate global
295535
+ * has no Node built-ins, so the code cannot see `process`, `require`, the
295536
+ * filesystem, or the network — it reaches the host ONLY through the injected
295537
+ * `callTool` (escalator-gated on the main thread) and `fetch` (SSRF-guarded via
295538
+ * bridgeFetch). `script.run({ timeout })` bounds synchronous CPU; an outer
295539
+ * wall-clock race + `isolate.dispose()` bounds async runaway (a never-resolving
295540
+ * await), mirroring the worker's terminate().
295541
+ */
295542
+ async runInIsolate(ivm, input, callTool) {
295543
+ const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
295544
+ const isolate = new ivm.Isolate({ memoryLimit: 128 });
295545
+ let disposed = false;
295546
+ const dispose = () => {
295547
+ if (!disposed) {
295548
+ disposed = true;
295549
+ try {
295550
+ isolate.dispose();
295551
+ } catch {
295552
+ }
295553
+ }
295554
+ };
295555
+ try {
295556
+ const context = await isolate.createContext();
295557
+ const jail = context.global;
295558
+ await jail.set("_callTool", new ivm.Reference(async (name, inputJson) => {
295559
+ const out = await callTool(String(name), safeJsonParse(inputJson));
295560
+ return String(out);
295561
+ }));
295562
+ await jail.set("_fetch", new ivm.Reference(async (url, initJson) => {
295563
+ const r4 = await bridgeFetch(String(url), safeJsonParse(initJson));
295564
+ return JSON.stringify(r4);
295565
+ }));
295566
+ await jail.set("_input", new ivm.ExternalCopy(input).copyInto());
295567
+ await jail.set("_code", this.executeCode);
295568
+ const bootstrap = `
295569
+ const callTool = (name, toolInput) => _callTool.apply(undefined,
295570
+ [String(name), JSON.stringify(toolInput || {})],
295571
+ { result: { promise: true }, arguments: { copy: true } });
295572
+ const fetch = async (url, init) => {
295573
+ const raw = await _fetch.apply(undefined,
295574
+ [String(url), JSON.stringify(init || null)],
295575
+ { result: { promise: true }, arguments: { copy: true } });
295576
+ const r = JSON.parse(raw);
295577
+ if (r && r.__error) throw new Error(r.__error);
295578
+ return {
295579
+ ok: r.ok, status: r.status, statusText: r.statusText,
295580
+ headers: { get: (k) => (String(k).toLowerCase() === 'content-type' ? r.contentType : null) },
295581
+ text: async () => r.body,
295582
+ json: async () => JSON.parse(r.body),
295583
+ };
295584
+ };
295585
+ const console = { log() {}, error() {} };
295586
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
295587
+ const fn = new AsyncFunction('input', 'callTool', 'fetch', 'console', _code);
295588
+ (async () => { const r = await fn(_input, callTool, fetch, console); return String(r == null ? '' : r); })();
295589
+ `;
295590
+ const script = await isolate.compileScript(bootstrap);
295591
+ const runPromise = script.run(context, { timeout: timeoutMs, promise: true }).then((v6) => String(v6 ?? ""));
295592
+ const timeoutPromise = new Promise((resolve) => {
295593
+ const t4 = setTimeout(() => {
295594
+ dispose();
295595
+ resolve(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`);
295596
+ }, timeoutMs + 500);
295597
+ t4.unref?.();
295598
+ });
295599
+ return await Promise.race([runPromise, timeoutPromise]);
295600
+ } catch (err) {
295601
+ const msg = err instanceof Error ? err.message : String(err);
295602
+ if (/timed? out/i.test(msg)) {
295603
+ return `Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`;
295604
+ }
295605
+ return `Tool error: ${msg}`;
295606
+ } finally {
295607
+ dispose();
295608
+ }
295609
+ }
295245
295610
  /** Spawn the worker, service its callTool/fetch bridge, enforce the kill timeout. */
295246
295611
  runInWorker(input, callTool) {
295247
295612
  const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
@@ -295321,16 +295686,19 @@ var ToolCreator = class {
295321
295686
  workspacePath;
295322
295687
  /** When false, persisted tools are neither loaded nor written. */
295323
295688
  persistEnabled;
295689
+ /** Sandbox runtime for generated tools; passed to each DynamicTool. */
295690
+ sandboxMode;
295324
295691
  logger;
295325
295692
  /** name → spec, for persistence, broadcast, and re-registration. */
295326
295693
  specs = /* @__PURE__ */ new Map();
295327
295694
  /** capability fingerprint → tool name, so the same need isn't re-generated. */
295328
295695
  capabilityIndex = /* @__PURE__ */ new Map();
295329
- constructor(router, registry2, workspacePath, persistEnabled = true) {
295696
+ constructor(router, registry2, workspacePath, persistEnabled = true, sandboxMode = "auto") {
295330
295697
  this.router = router;
295331
295698
  this.registry = registry2;
295332
295699
  this.workspacePath = workspacePath;
295333
295700
  this.persistEnabled = persistEnabled;
295701
+ this.sandboxMode = sandboxMode;
295334
295702
  }
295335
295703
  setPermissionEscalator(escalator) {
295336
295704
  this.escalator = escalator;
@@ -295418,7 +295786,14 @@ Required capability: ${description.slice(0, 300)}`;
295418
295786
  this.specs.set(spec.name, spec);
295419
295787
  return;
295420
295788
  }
295421
- const tool = new DynamicTool(spec, this.registry, () => this.escalator, trusted);
295789
+ const tool = new DynamicTool(
295790
+ spec,
295791
+ this.registry,
295792
+ () => this.escalator,
295793
+ trusted,
295794
+ () => this.sandboxMode,
295795
+ (msg) => this.log(msg)
295796
+ );
295422
295797
  this.registry.register(tool);
295423
295798
  this.specs.set(spec.name, spec);
295424
295799
  this.capabilityIndex.set(capabilityKey(`${spec.description}`), spec.name);
@@ -295467,6 +295842,9 @@ Required capability: ${description.slice(0, 300)}`;
295467
295842
  return Array.from(this.specs.keys());
295468
295843
  }
295469
295844
  };
295845
+ function normalizeKey(text) {
295846
+ return text.trim().toLowerCase().replace(/\s+/g, " ");
295847
+ }
295470
295848
  var WorldStateDB = class {
295471
295849
  constructor(workspacePath, debugMode = false) {
295472
295850
  this.workspacePath = workspacePath;
@@ -295488,6 +295866,16 @@ var WorldStateDB = class {
295488
295866
  encrypted_payload TEXT NOT NULL
295489
295867
  )
295490
295868
  `);
295869
+ this.db.exec(`
295870
+ CREATE TABLE IF NOT EXISTS facts (
295871
+ entity TEXT NOT NULL,
295872
+ relation TEXT NOT NULL,
295873
+ encrypted_value TEXT NOT NULL,
295874
+ source_worker TEXT NOT NULL,
295875
+ timestamp TEXT NOT NULL,
295876
+ PRIMARY KEY (entity, relation)
295877
+ )
295878
+ `);
295491
295879
  }
295492
295880
  workspacePath;
295493
295881
  debugMode;
@@ -295561,6 +295949,131 @@ var WorldStateDB = class {
295561
295949
  if (entries.length === 0) return "World State is currently empty.";
295562
295950
  return entries.map((e3, idx) => `[${e3.timestamp}] Step ${idx + 1} (${e3.workerId}): ${e3.summary}`).join("\n");
295563
295951
  }
295952
+ // ── world-state v2: queryable facts ──────────────
295953
+ /**
295954
+ * Upsert a fact. `(entity, relation)` is normalized so casing/whitespace don't
295955
+ * fragment the key; an existing pair is superseded (value + provenance updated)
295956
+ * rather than duplicated. Empty entity/relation/value are ignored.
295957
+ */
295958
+ upsertFact(entity, relation, value, sourceWorker, timestamp) {
295959
+ const e3 = normalizeKey(entity);
295960
+ const r4 = normalizeKey(relation);
295961
+ const v6 = value.trim();
295962
+ if (!e3 || !r4 || !v6) return;
295963
+ const encrypted = this.encrypt(JSON.stringify({ value: v6 }));
295964
+ const stmt = this.db.prepare(`
295965
+ INSERT INTO facts (entity, relation, encrypted_value, source_worker, timestamp)
295966
+ VALUES (?, ?, ?, ?, ?)
295967
+ ON CONFLICT(entity, relation) DO UPDATE SET
295968
+ encrypted_value = excluded.encrypted_value,
295969
+ source_worker = excluded.source_worker,
295970
+ timestamp = excluded.timestamp
295971
+ `);
295972
+ stmt.run(e3, r4, encrypted, sourceWorker, timestamp ?? (/* @__PURE__ */ new Date()).toISOString());
295973
+ this.dumpDebugIfNeeded();
295974
+ }
295975
+ rowToFact(row) {
295976
+ let value;
295977
+ try {
295978
+ value = JSON.parse(this.decrypt(row.encrypted_value)).value;
295979
+ } catch {
295980
+ value = "[Decryption Failed - Payload Corrupted]";
295981
+ }
295982
+ return { entity: row.entity, relation: row.relation, value, sourceWorker: row.source_worker, timestamp: row.timestamp };
295983
+ }
295984
+ /** All facts whose entity matches one of the (normalized) query entities. */
295985
+ getFactsForEntities(entities) {
295986
+ const keys = Array.from(new Set(entities.map(normalizeKey).filter(Boolean)));
295987
+ if (keys.length === 0) return [];
295988
+ const placeholders = keys.map(() => "?").join(", ");
295989
+ const stmt = this.db.prepare(
295990
+ `SELECT entity, relation, encrypted_value, source_worker, timestamp FROM facts WHERE entity IN (${placeholders}) ORDER BY entity ASC, relation ASC`
295991
+ );
295992
+ return stmt.all(...keys).map((row) => this.rowToFact(row));
295993
+ }
295994
+ /** Every fact (used for tests / debug / whole-graph views). */
295995
+ getAllFacts() {
295996
+ const stmt = this.db.prepare("SELECT entity, relation, encrypted_value, source_worker, timestamp FROM facts ORDER BY entity ASC, relation ASC");
295997
+ return stmt.all().map((row) => this.rowToFact(row));
295998
+ }
295999
+ /**
296000
+ * A compact, human/LLM-readable fact block for T1/T2 planning. When `entities`
296001
+ * is given, only facts about those entities are included; otherwise all facts.
296002
+ * Returns '' when there are none so callers can fall back to the linear log.
296003
+ */
296004
+ getFormattedFacts(entities) {
296005
+ const facts = entities && entities.length > 0 ? this.getFactsForEntities(entities) : this.getAllFacts();
296006
+ if (facts.length === 0) return "";
296007
+ return facts.map((f4) => `- ${f4.entity} ${f4.relation} ${f4.value}`).join("\n");
296008
+ }
296009
+ /**
296010
+ * A compact knowledge block for T1/T2 planning. When `prompt` is given, facts
296011
+ * whose entity/relation/value mention a prompt token are preferred (relevance
296012
+ * filter); otherwise, or when nothing matches, all facts are used (capped at
296013
+ * `limit`). Returns '' when there are no facts, so the caller can fall back to
296014
+ * the raw linear log — this replaces replaying the whole log during planning.
296015
+ */
296016
+ getFormattedKnowledge(prompt, limit2 = 40) {
296017
+ const all = this.getAllFacts();
296018
+ if (all.length === 0) return "";
296019
+ let selected = all;
296020
+ if (prompt && prompt.trim()) {
296021
+ const tokens = Array.from(new Set(prompt.toLowerCase().match(/[a-z0-9_./-]{3,}/g) ?? []));
296022
+ if (tokens.length > 0) {
296023
+ const matched = all.filter((f4) => {
296024
+ const hay = `${f4.entity} ${f4.relation} ${f4.value}`.toLowerCase();
296025
+ return tokens.some((t4) => hay.includes(t4));
296026
+ });
296027
+ if (matched.length > 0) selected = matched;
296028
+ }
296029
+ }
296030
+ return selected.slice(0, limit2).map((f4) => `- ${f4.entity} ${f4.relation} ${f4.value}`).join("\n");
296031
+ }
296032
+ // ── Export / Import ──────────────────────────
296033
+ //
296034
+ // Knowledge travels DECRYPTED in the export bundle (a portable plaintext
296035
+ // JSON file — the encryption key never leaves this machine) and re-encrypts
296036
+ // with the LOCAL key on import.
296037
+ exportKnowledge() {
296038
+ return { facts: this.getAllFacts(), worldLog: this.getAllEntries() };
296039
+ }
296040
+ /**
296041
+ * Merge imported knowledge. Facts upsert on (entity, relation) with
296042
+ * newer-timestamp-wins (a local fact newer than the imported one is kept);
296043
+ * log entries append with fresh ids, skipping exact duplicates
296044
+ * (worker + timestamp + summary). Returns counts of what actually landed.
296045
+ */
296046
+ importKnowledge(data) {
296047
+ let facts = 0;
296048
+ let logEntries = 0;
296049
+ if (Array.isArray(data.facts)) {
296050
+ const local = new Map(this.getAllFacts().map((f4) => [`${f4.entity}\0${f4.relation}`, f4.timestamp]));
296051
+ for (const f4 of data.facts) {
296052
+ if (!f4 || typeof f4.entity !== "string" || typeof f4.relation !== "string" || typeof f4.value !== "string") continue;
296053
+ const key = `${normalizeKey(f4.entity)}\0${normalizeKey(f4.relation)}`;
296054
+ const localTs = local.get(key);
296055
+ const importTs = f4.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
296056
+ if (localTs && localTs >= importTs) continue;
296057
+ this.upsertFact(f4.entity, f4.relation, f4.value, f4.sourceWorker ?? "imported", importTs);
296058
+ facts++;
296059
+ }
296060
+ }
296061
+ if (Array.isArray(data.worldLog)) {
296062
+ const seen = new Set(this.getAllEntries().map((e3) => `${e3.workerId}\0${e3.timestamp}\0${e3.summary}`));
296063
+ const stmt = this.db.prepare("INSERT INTO world_state (id, timestamp, worker_id, encrypted_payload) VALUES (?, ?, ?, ?)");
296064
+ for (const e3 of data.worldLog) {
296065
+ if (!e3 || typeof e3.summary !== "string") continue;
296066
+ const workerId = e3.workerId ?? "imported";
296067
+ const timestamp = e3.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
296068
+ const key = `${workerId}\0${timestamp}\0${e3.summary}`;
296069
+ if (seen.has(key)) continue;
296070
+ stmt.run(crypto4__default.default.randomUUID(), timestamp, workerId, this.encrypt(JSON.stringify({ summary: e3.summary })));
296071
+ seen.add(key);
296072
+ logEntries++;
296073
+ }
296074
+ }
296075
+ return { facts, logEntries };
296076
+ }
295564
296077
  dumpDebugIfNeeded() {
295565
296078
  if (!this.debugMode) return;
295566
296079
  try {
@@ -295700,7 +296213,8 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
295700
296213
  }
295701
296214
  const cfg = this.config;
295702
296215
  if (cfg["enableToolCreation"] === true) {
295703
- this.toolCreator = new ToolCreator(this.router, this.toolRegistry, this.workspacePath, cfg["persistDynamicTools"] !== false);
296216
+ const sandboxMode = this.config.tools?.dynamicToolSandbox ?? "auto";
296217
+ this.toolCreator = new ToolCreator(this.router, this.toolRegistry, this.workspacePath, cfg["persistDynamicTools"] !== false, sandboxMode);
295704
296218
  this.toolCreator.setLogger((m3) => {
295705
296219
  if (this.listenerCount("log") > 0) this.emit("log", { level: "info", message: m3 });
295706
296220
  });
@@ -295938,6 +296452,10 @@ ${last3.partialOutput}` : "");
295938
296452
  this.router.profileModels(this.store).catch(() => {
295939
296453
  });
295940
296454
  }
296455
+ if (this.store) {
296456
+ this.router.probeLocalToolSupport(this.store).catch(() => {
296457
+ });
296458
+ }
295941
296459
  if (this.config.cascadeAuto) {
295942
296460
  this.router.refreshLiveData().catch(() => {
295943
296461
  });
@@ -297012,6 +297530,78 @@ Original error: ${err.message}`
297012
297530
  `).all(`%${query}%`, limit2);
297013
297531
  return rows.map(this.deserializeMessage);
297014
297532
  }
297533
+ // ── Export / Import ──────────────────────────
297534
+ //
297535
+ // Chats travel as full Session objects (messages included) inside a
297536
+ // plaintext-JSON bundle. Import NEVER overwrites: every imported session
297537
+ // gets a fresh id (title suffixed "(imported)") and fresh message ids, so
297538
+ // re-importing the same bundle can duplicate but can never clobber.
297539
+ /** Full sessions (with messages) for the given ids, or ALL sessions. */
297540
+ exportSessions(ids) {
297541
+ const targets = ids && ids.length > 0 ? ids : this.db.prepare("SELECT id FROM sessions ORDER BY updated_at DESC").all().map((r4) => r4.id);
297542
+ const out = [];
297543
+ for (const id of targets) {
297544
+ const s3 = this.getSession(id);
297545
+ if (s3) out.push(s3);
297546
+ }
297547
+ return out;
297548
+ }
297549
+ /**
297550
+ * Import sessions from a bundle. Returns the created `{id, title}` rows so
297551
+ * the caller can mirror them into the runtime session list (the sidebar
297552
+ * reads runtime_sessions, not sessions). Malformed entries are skipped.
297553
+ */
297554
+ importSessions(sessions) {
297555
+ const out = [];
297556
+ const msgStmt = this.db.prepare(`
297557
+ INSERT INTO messages (id, session_id, role, content, timestamp, tokens, agent_messages)
297558
+ VALUES (?, ?, ?, ?, ?, ?, ?)
297559
+ `);
297560
+ for (const s3 of sessions) {
297561
+ if (!s3 || typeof s3 !== "object") continue;
297562
+ const newId = crypto4.randomUUID();
297563
+ const now = (/* @__PURE__ */ new Date()).toISOString();
297564
+ const title = `${String(s3.title || "Untitled").slice(0, 200)} (imported)`;
297565
+ const metadata = s3.metadata && typeof s3.metadata === "object" ? s3.metadata : { totalTokens: 0, totalCostUsd: 0, modelsUsed: [], toolsUsed: [], taskCount: 0 };
297566
+ this.db.prepare(`
297567
+ INSERT INTO sessions (id, title, created_at, updated_at, identity_id, workspace_path, metadata)
297568
+ VALUES (?, ?, ?, ?, ?, ?, ?)
297569
+ `).run(newId, title, s3.createdAt ?? now, s3.updatedAt ?? now, s3.identityId ?? "default", s3.workspacePath ?? "", JSON.stringify(metadata));
297570
+ for (const m3 of Array.isArray(s3.messages) ? s3.messages : []) {
297571
+ if (!m3 || m3.role !== "user" && m3.role !== "assistant" && m3.role !== "system") continue;
297572
+ msgStmt.run(
297573
+ crypto4.randomUUID(),
297574
+ newId,
297575
+ m3.role,
297576
+ typeof m3.content === "string" ? m3.content : JSON.stringify(m3.content),
297577
+ m3.timestamp ?? now,
297578
+ m3.tokens ? JSON.stringify(m3.tokens) : null,
297579
+ m3.agentMessages ? JSON.stringify(m3.agentMessages) : null
297580
+ );
297581
+ }
297582
+ out.push({ id: newId, title });
297583
+ }
297584
+ return out;
297585
+ }
297586
+ /** Import identities/personas, deduped by (case-insensitive) name — an
297587
+ * existing name is skipped, never replaced. Imports are never the default. */
297588
+ importIdentities(identities) {
297589
+ const existing = new Set(this.listIdentities().map((i4) => i4.name.toLowerCase()));
297590
+ let imported = 0;
297591
+ for (const ident of identities ?? []) {
297592
+ if (!ident?.name || typeof ident.name !== "string") continue;
297593
+ if (existing.has(ident.name.toLowerCase())) continue;
297594
+ this.createIdentity({
297595
+ ...ident,
297596
+ id: crypto4.randomUUID(),
297597
+ isDefault: false,
297598
+ createdAt: ident.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
297599
+ });
297600
+ existing.add(ident.name.toLowerCase());
297601
+ imported++;
297602
+ }
297603
+ return imported;
297604
+ }
297015
297605
  // ── Identities ────────────────────────────────
297016
297606
  createIdentity(identity) {
297017
297607
  this.db.prepare(`
@@ -297189,6 +297779,23 @@ Original error: ${err.message}`
297189
297779
  const row = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(`${provider}:${modelId}`);
297190
297780
  return row ? JSON.parse(row.metadata) : void 0;
297191
297781
  }
297782
+ /**
297783
+ * Persist a probed capability verdict (currently: native tool support) into
297784
+ * the model's cached profile, merging with whatever is already recorded.
297785
+ * Read back via getModelProfile — so a model is probed once ever, not once
297786
+ * per run.
297787
+ */
297788
+ saveModelCapability(modelId, provider, caps) {
297789
+ const cacheKey = `${provider}:${modelId}`;
297790
+ const existing = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(cacheKey);
297791
+ 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 };
297792
+ if (caps.supportsToolUse !== void 0) meta.supportsToolUse = caps.supportsToolUse;
297793
+ this.db.prepare(`
297794
+ INSERT INTO model_cache (id, provider, model_id, name, metadata, updated_at)
297795
+ VALUES (?, ?, ?, ?, ?, ?)
297796
+ ON CONFLICT(id) DO UPDATE SET metadata = excluded.metadata, updated_at = excluded.updated_at
297797
+ `).run(cacheKey, provider, modelId, meta.name ?? modelId, JSON.stringify(meta), (/* @__PURE__ */ new Date()).toISOString());
297798
+ }
297192
297799
  getProfiledModelIds() {
297193
297800
  const rows = this.db.prepare(
297194
297801
  "SELECT model_id FROM model_cache WHERE json_extract(metadata, '$.specializations') IS NOT NULL"
@@ -299129,6 +299736,60 @@ ${prompt}`;
299129
299736
  this.socket.broadcast("runtime:refresh", { scope: "global" });
299130
299737
  res.json({ ok: true });
299131
299738
  });
299739
+ this.app.get("/api/export", auth, (req, res) => {
299740
+ try {
299741
+ const sessionsParam = typeof req.query["sessions"] === "string" ? req.query["sessions"] : "all";
299742
+ const includeMemories = req.query["memories"] === "1" || req.query["memories"] === "true";
299743
+ const ids = sessionsParam === "all" ? void 0 : sessionsParam.split(",").map((s3) => s3.trim()).filter(Boolean);
299744
+ const bundle = {
299745
+ format: "cascade-export@1",
299746
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
299747
+ sessions: this.store.exportSessions(ids)
299748
+ };
299749
+ if (includeMemories) {
299750
+ const ws2 = new WorldStateDB(this.workspacePath);
299751
+ try {
299752
+ bundle["memories"] = { ...ws2.exportKnowledge(), identities: this.store.listIdentities() };
299753
+ } finally {
299754
+ ws2.close();
299755
+ }
299756
+ }
299757
+ res.json(bundle);
299758
+ } catch (err) {
299759
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
299760
+ }
299761
+ });
299762
+ this.app.post("/api/import", auth, mutationLimiter, (req, res) => {
299763
+ try {
299764
+ const bundle = req.body;
299765
+ if (!bundle || bundle.format !== "cascade-export@1") {
299766
+ res.status(400).json({ error: "Not a cascade-export@1 bundle." });
299767
+ return;
299768
+ }
299769
+ const importedSessions = Array.isArray(bundle.sessions) ? this.store.importSessions(bundle.sessions) : [];
299770
+ for (const s3 of importedSessions) this.persistRuntimeRow(s3.id, s3.title, "COMPLETED");
299771
+ let facts = 0;
299772
+ let logEntries = 0;
299773
+ let identities = 0;
299774
+ if (bundle.memories) {
299775
+ const ws2 = new WorldStateDB(this.workspacePath);
299776
+ try {
299777
+ const counts = ws2.importKnowledge(bundle.memories);
299778
+ facts = counts.facts;
299779
+ logEntries = counts.logEntries;
299780
+ } finally {
299781
+ ws2.close();
299782
+ }
299783
+ if (Array.isArray(bundle.memories.identities)) {
299784
+ identities = this.store.importIdentities(bundle.memories.identities);
299785
+ }
299786
+ }
299787
+ this.socket.broadcast("runtime:refresh", { scope: "workspace" });
299788
+ res.json({ ok: true, imported: { sessions: importedSessions.length, facts, logEntries, identities } });
299789
+ } catch (err) {
299790
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
299791
+ }
299792
+ });
299132
299793
  this.app.post("/api/sessions/:id/rollback", auth, mutationLimiter, async (req, res) => {
299133
299794
  const sessionId = req.params.id;
299134
299795
  const taskIds = this.sessionTaskIds.get(sessionId) ?? [];