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