cascade-ai 0.13.1 → 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/README.md +6 -0
- package/dist/cli.cjs +824 -82
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +824 -82
- package/dist/cli.js.map +1 -1
- package/dist/desktop-core.cjs +826 -84
- package/dist/index.cjs +824 -82
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +223 -8
- package/dist/index.d.ts +223 -8
- package/dist/index.js +824 -82
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/dist/index.cjs
CHANGED
|
@@ -236,7 +236,7 @@ var init_audit_logger = __esm({
|
|
|
236
236
|
});
|
|
237
237
|
|
|
238
238
|
// src/constants.ts
|
|
239
|
-
var CASCADE_VERSION = "0.
|
|
239
|
+
var CASCADE_VERSION = "0.15.0";
|
|
240
240
|
var CASCADE_CONFIG_DIR = ".cascade";
|
|
241
241
|
var CASCADE_MD_FILE = "CASCADE.md";
|
|
242
242
|
var CASCADE_IGNORE_FILE = ".cascadeignore";
|
|
@@ -1482,29 +1482,71 @@ var OllamaProvider = class extends BaseProvider {
|
|
|
1482
1482
|
async countTokens(text) {
|
|
1483
1483
|
return Math.ceil(text.length / 4);
|
|
1484
1484
|
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Ask the Ollama server what a model can actually do. Modern Ollama returns a
|
|
1487
|
+
* `capabilities` array ("completion", "tools", "vision", …) and the model's
|
|
1488
|
+
* real context length in `model_info` — authoritative, unlike the hardcoded
|
|
1489
|
+
* family-name allowlist, which silently misclassifies any unlisted family.
|
|
1490
|
+
* Best-effort: null on old servers / errors, callers fall back to heuristics.
|
|
1491
|
+
*/
|
|
1492
|
+
async showModel(name) {
|
|
1493
|
+
const ac = new AbortController();
|
|
1494
|
+
const timer = setTimeout(() => ac.abort(), 2500);
|
|
1495
|
+
try {
|
|
1496
|
+
const resp = await fetch(`${this.baseUrl}/api/show`, {
|
|
1497
|
+
method: "POST",
|
|
1498
|
+
headers: { "Content-Type": "application/json" },
|
|
1499
|
+
body: JSON.stringify({ model: name }),
|
|
1500
|
+
signal: ac.signal
|
|
1501
|
+
});
|
|
1502
|
+
if (!resp.ok) return null;
|
|
1503
|
+
const data = await resp.json();
|
|
1504
|
+
const out = {};
|
|
1505
|
+
if (Array.isArray(data.capabilities)) {
|
|
1506
|
+
out.tools = data.capabilities.includes("tools");
|
|
1507
|
+
out.vision = data.capabilities.includes("vision");
|
|
1508
|
+
}
|
|
1509
|
+
for (const [k, v] of Object.entries(data.model_info ?? {})) {
|
|
1510
|
+
if (k.endsWith(".context_length") && typeof v === "number" && v > 0) {
|
|
1511
|
+
out.contextWindow = v;
|
|
1512
|
+
break;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return out.tools !== void 0 || out.contextWindow ? out : null;
|
|
1516
|
+
} catch {
|
|
1517
|
+
return null;
|
|
1518
|
+
} finally {
|
|
1519
|
+
clearTimeout(timer);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1485
1522
|
async listModels() {
|
|
1486
1523
|
try {
|
|
1487
1524
|
const response = await fetch(`${this.baseUrl}/api/tags`);
|
|
1488
1525
|
if (!response.ok) return [];
|
|
1489
1526
|
const data = await response.json();
|
|
1490
1527
|
const supportedKeywords = ["llama3", "llama2", "gemma", "mistral", "mixtral", "qwen", "phi3", "codellama", "deepseek", "llava", "starcoder", "stable-code", "nomic-embed"];
|
|
1491
|
-
|
|
1528
|
+
const entries = data.models.filter((m) => {
|
|
1492
1529
|
const name = m.name.toLowerCase();
|
|
1493
1530
|
return supportedKeywords.some((k) => name.includes(k));
|
|
1494
|
-
})
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1531
|
+
});
|
|
1532
|
+
const shows = await Promise.all(entries.map((m) => this.showModel(m.name)));
|
|
1533
|
+
return entries.map((m, i) => {
|
|
1534
|
+
const show = shows[i];
|
|
1535
|
+
return {
|
|
1536
|
+
id: m.name,
|
|
1537
|
+
name: m.name,
|
|
1538
|
+
provider: "ollama",
|
|
1539
|
+
contextWindow: show?.contextWindow ?? 128e3,
|
|
1540
|
+
isVisionCapable: show?.vision ?? (m.name.includes("llava") || m.name.includes("vision")),
|
|
1541
|
+
inputCostPer1kTokens: 0,
|
|
1542
|
+
outputCostPer1kTokens: 0,
|
|
1543
|
+
maxOutputTokens: 4e3,
|
|
1544
|
+
supportsStreaming: true,
|
|
1545
|
+
isLocal: true,
|
|
1546
|
+
supportsToolUse: show?.tools ?? isToolCapable(m.name),
|
|
1547
|
+
minSizeB: this.parseSizeB(m.details?.parameter_size)
|
|
1548
|
+
};
|
|
1549
|
+
});
|
|
1508
1550
|
} catch {
|
|
1509
1551
|
return [];
|
|
1510
1552
|
}
|
|
@@ -2181,6 +2223,7 @@ function normalizeModelId(id) {
|
|
|
2181
2223
|
var LiveDataProvider = class {
|
|
2182
2224
|
snapshot = null;
|
|
2183
2225
|
prices = /* @__PURE__ */ new Map();
|
|
2226
|
+
capabilities = /* @__PURE__ */ new Map();
|
|
2184
2227
|
source = "bundled";
|
|
2185
2228
|
fetchedAt = 0;
|
|
2186
2229
|
loaded = false;
|
|
@@ -2209,6 +2252,9 @@ var LiveDataProvider = class {
|
|
|
2209
2252
|
if (cache.prices) {
|
|
2210
2253
|
for (const [id, p] of Object.entries(cache.prices)) this.prices.set(id, p);
|
|
2211
2254
|
}
|
|
2255
|
+
if (cache.capabilities) {
|
|
2256
|
+
for (const [id, c] of Object.entries(cache.capabilities)) this.capabilities.set(id, c);
|
|
2257
|
+
}
|
|
2212
2258
|
this.fetchedAt = cache.fetchedAt ?? 0;
|
|
2213
2259
|
} catch {
|
|
2214
2260
|
}
|
|
@@ -2229,9 +2275,9 @@ var LiveDataProvider = class {
|
|
|
2229
2275
|
const ttlMs = this.opts.refreshHours * 36e5;
|
|
2230
2276
|
const fresh = ttlMs > 0 && Date.now() - this.fetchedAt < ttlMs;
|
|
2231
2277
|
if (!force && fresh && this.source !== "bundled") return;
|
|
2232
|
-
const [snap,
|
|
2278
|
+
const [snap, catalog] = await Promise.all([
|
|
2233
2279
|
this.opts.live ? this.fetchSnapshot() : Promise.resolve(null),
|
|
2234
|
-
this.opts.pricingLive ? this.
|
|
2280
|
+
this.opts.pricingLive ? this.fetchCatalog() : Promise.resolve(null)
|
|
2235
2281
|
]);
|
|
2236
2282
|
let changed = false;
|
|
2237
2283
|
if (snap) {
|
|
@@ -2239,8 +2285,12 @@ var LiveDataProvider = class {
|
|
|
2239
2285
|
this.source = "live";
|
|
2240
2286
|
changed = true;
|
|
2241
2287
|
}
|
|
2242
|
-
if (
|
|
2243
|
-
this.prices = prices;
|
|
2288
|
+
if (catalog && catalog.prices.size > 0) {
|
|
2289
|
+
this.prices = catalog.prices;
|
|
2290
|
+
changed = true;
|
|
2291
|
+
}
|
|
2292
|
+
if (catalog && catalog.capabilities.size > 0) {
|
|
2293
|
+
this.capabilities = catalog.capabilities;
|
|
2244
2294
|
changed = true;
|
|
2245
2295
|
}
|
|
2246
2296
|
if (changed) {
|
|
@@ -2262,21 +2312,43 @@ var LiveDataProvider = class {
|
|
|
2262
2312
|
return null;
|
|
2263
2313
|
}
|
|
2264
2314
|
}
|
|
2265
|
-
|
|
2315
|
+
/**
|
|
2316
|
+
* One fetch of the OpenRouter catalog yields BOTH pricing and capability
|
|
2317
|
+
* facts (context window, native tool support, input modalities) — the
|
|
2318
|
+
* capability fields used to be discarded while providers guessed/hardcoded
|
|
2319
|
+
* them in their listModels() stubs.
|
|
2320
|
+
*/
|
|
2321
|
+
async fetchCatalog() {
|
|
2266
2322
|
try {
|
|
2267
2323
|
const resp = await withTimeout(fetch(OPENROUTER_MODELS_URL), FETCH_TIMEOUT_MS, "pricing fetch timed out");
|
|
2268
2324
|
if (!resp.ok) return null;
|
|
2269
2325
|
const data = await resp.json();
|
|
2270
2326
|
if (!Array.isArray(data?.data)) return null;
|
|
2271
|
-
const
|
|
2327
|
+
const prices = /* @__PURE__ */ new Map();
|
|
2328
|
+
const capabilities = /* @__PURE__ */ new Map();
|
|
2272
2329
|
for (const m of data.data) {
|
|
2273
|
-
if (!m?.id
|
|
2274
|
-
const
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2330
|
+
if (!m?.id) continue;
|
|
2331
|
+
const key = normalizeModelId(m.id);
|
|
2332
|
+
if (m.pricing) {
|
|
2333
|
+
const input = Number(m.pricing.prompt) * 1e3;
|
|
2334
|
+
const output = Number(m.pricing.completion) * 1e3;
|
|
2335
|
+
if (Number.isFinite(input) && Number.isFinite(output)) {
|
|
2336
|
+
prices.set(key, { input, output });
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
const cap = {};
|
|
2340
|
+
if (typeof m.context_length === "number" && m.context_length > 0) {
|
|
2341
|
+
cap.contextWindow = m.context_length;
|
|
2342
|
+
}
|
|
2343
|
+
if (Array.isArray(m.supported_parameters)) {
|
|
2344
|
+
cap.supportsTools = m.supported_parameters.includes("tools");
|
|
2345
|
+
}
|
|
2346
|
+
if (Array.isArray(m.architecture?.input_modalities)) {
|
|
2347
|
+
cap.inputModalities = m.architecture.input_modalities;
|
|
2348
|
+
}
|
|
2349
|
+
if (Object.keys(cap).length > 0) capabilities.set(key, cap);
|
|
2278
2350
|
}
|
|
2279
|
-
return
|
|
2351
|
+
return { prices, capabilities };
|
|
2280
2352
|
} catch {
|
|
2281
2353
|
return null;
|
|
2282
2354
|
}
|
|
@@ -2287,7 +2359,8 @@ var LiveDataProvider = class {
|
|
|
2287
2359
|
const cache = {
|
|
2288
2360
|
fetchedAt: this.fetchedAt,
|
|
2289
2361
|
snapshot: this.snapshot ?? void 0,
|
|
2290
|
-
prices: Object.fromEntries(this.prices)
|
|
2362
|
+
prices: Object.fromEntries(this.prices),
|
|
2363
|
+
capabilities: Object.fromEntries(this.capabilities)
|
|
2291
2364
|
};
|
|
2292
2365
|
await fs4__default.default.writeFile(this.opts.cacheFile, JSON.stringify(cache, null, 2), "utf-8");
|
|
2293
2366
|
} catch {
|
|
@@ -2312,6 +2385,27 @@ var LiveDataProvider = class {
|
|
|
2312
2385
|
return { ...m, inputCostPer1kTokens: p.input, outputCostPer1kTokens: p.output };
|
|
2313
2386
|
});
|
|
2314
2387
|
}
|
|
2388
|
+
/** Current capability facts for a model id, or null when unknown. */
|
|
2389
|
+
getCapability(modelId) {
|
|
2390
|
+
return this.capabilities.get(normalizeModelId(modelId)) ?? null;
|
|
2391
|
+
}
|
|
2392
|
+
/**
|
|
2393
|
+
* Returns capability-corrected copies of each model (originals untouched):
|
|
2394
|
+
* real context windows replace the providers' hardcoded guesses, native
|
|
2395
|
+
* tool support replaces the assume-by-provider default, and vision is set
|
|
2396
|
+
* from the declared input modalities.
|
|
2397
|
+
*/
|
|
2398
|
+
applyLiveCapabilities(models) {
|
|
2399
|
+
return models.map((m) => {
|
|
2400
|
+
const c = this.getCapability(m.id);
|
|
2401
|
+
if (!c) return m;
|
|
2402
|
+
const next = { ...m };
|
|
2403
|
+
if (c.contextWindow) next.contextWindow = c.contextWindow;
|
|
2404
|
+
if (c.supportsTools !== void 0) next.supportsToolUse = c.supportsTools;
|
|
2405
|
+
if (c.inputModalities) next.isVisionCapable = c.inputModalities.includes("image");
|
|
2406
|
+
return next;
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2315
2409
|
/** Where the active quality data came from — for /why and `cascade models`. */
|
|
2316
2410
|
getDataSource() {
|
|
2317
2411
|
return this.source;
|
|
@@ -2322,6 +2416,9 @@ var LiveDataProvider = class {
|
|
|
2322
2416
|
hasLivePricing() {
|
|
2323
2417
|
return this.prices.size > 0;
|
|
2324
2418
|
}
|
|
2419
|
+
hasCapabilities() {
|
|
2420
|
+
return this.capabilities.size > 0;
|
|
2421
|
+
}
|
|
2325
2422
|
};
|
|
2326
2423
|
|
|
2327
2424
|
// src/core/router/benchmarks.ts
|
|
@@ -2510,6 +2607,57 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
|
|
|
2510
2607
|
profiler.profileAll(allModels).catch(() => {
|
|
2511
2608
|
});
|
|
2512
2609
|
}
|
|
2610
|
+
/**
|
|
2611
|
+
* One-time native tool-call probe for local/compat models with NO capability
|
|
2612
|
+
* metadata. llama.cpp / LM Studio "/models" endpoints return ids only, so
|
|
2613
|
+
* `supportsToolUse` stays undefined and the T3 tool gate ASSUMES native
|
|
2614
|
+
* support — wrong for many local builds, which then fumble tool-format
|
|
2615
|
+
* output. Each unknown model is probed once with a trivial tool; the verdict
|
|
2616
|
+
* persists in the MemoryStore's model_cache (via the previously-dormant
|
|
2617
|
+
* getModelProfile read-back), so a model is probed once EVER, not per run.
|
|
2618
|
+
* Cloud providers are never probed — their metadata is authoritative.
|
|
2619
|
+
*/
|
|
2620
|
+
async probeLocalToolSupport(store) {
|
|
2621
|
+
const unknown = this.selector.getAllAvailableModels().filter(
|
|
2622
|
+
(m) => m.provider === "openai-compatible" && m.supportsToolUse === void 0
|
|
2623
|
+
);
|
|
2624
|
+
for (const model of unknown) {
|
|
2625
|
+
const cached = store.getModelProfile(model.id, model.provider);
|
|
2626
|
+
let verdict = cached?.supportsToolUse;
|
|
2627
|
+
if (verdict === void 0) {
|
|
2628
|
+
const probed = await this.probeNativeToolCall(model);
|
|
2629
|
+
if (probed === null) continue;
|
|
2630
|
+
verdict = probed;
|
|
2631
|
+
store.saveModelCapability(model.id, model.provider, { supportsToolUse: verdict });
|
|
2632
|
+
}
|
|
2633
|
+
this.selector.addDynamicModel({ ...model, supportsToolUse: verdict });
|
|
2634
|
+
}
|
|
2635
|
+
for (const tier of ["T1", "T2", "T3"]) {
|
|
2636
|
+
const cur = this.tierModels.get(tier);
|
|
2637
|
+
if (!cur) continue;
|
|
2638
|
+
const fresh = this.selector.getModelById(cur.id);
|
|
2639
|
+
if (fresh) this.tierModels.set(tier, fresh);
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
async probeNativeToolCall(model) {
|
|
2643
|
+
try {
|
|
2644
|
+
const cfg = this.config.providers.find((p) => p.type === model.provider) ?? { type: model.provider };
|
|
2645
|
+
const provider = this.createProvider(cfg, model);
|
|
2646
|
+
const result = await withTimeout(provider.generate({
|
|
2647
|
+
messages: [{ role: "user", content: "Call the echo tool with text set to 'ping'. Use the tool; do not answer in prose." }],
|
|
2648
|
+
tools: [{
|
|
2649
|
+
name: "echo",
|
|
2650
|
+
description: "Echo the given text back to the caller.",
|
|
2651
|
+
inputSchema: { type: "object", properties: { text: { type: "string", description: "Text to echo" } }, required: ["text"] }
|
|
2652
|
+
}],
|
|
2653
|
+
maxTokens: 80,
|
|
2654
|
+
temperature: 0
|
|
2655
|
+
}), 3e4, "tool-support probe timed out");
|
|
2656
|
+
return (result.toolCalls?.length ?? 0) > 0;
|
|
2657
|
+
} catch {
|
|
2658
|
+
return null;
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2513
2661
|
/**
|
|
2514
2662
|
* Cascade Auto live data: discover/validate real model ids from each cloud
|
|
2515
2663
|
* provider, then fetch current public quality scores + per-token prices and
|
|
@@ -2559,12 +2707,17 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
|
|
|
2559
2707
|
await Promise.allSettled(tasks);
|
|
2560
2708
|
}
|
|
2561
2709
|
/**
|
|
2562
|
-
* Replace available models with live-priced
|
|
2563
|
-
*
|
|
2710
|
+
* Replace available models with live-priced AND capability-corrected copies
|
|
2711
|
+
* (real context windows, native tool support, vision from modalities), then
|
|
2712
|
+
* refresh the already resolved tier models so shared-tier cost accounting and
|
|
2713
|
+
* the tool-use gate both see current data.
|
|
2564
2714
|
*/
|
|
2565
2715
|
applyLivePricing() {
|
|
2566
|
-
if (!this.liveData
|
|
2567
|
-
|
|
2716
|
+
if (!this.liveData) return;
|
|
2717
|
+
if (!this.liveData.hasLivePricing() && !this.liveData.hasCapabilities()) return;
|
|
2718
|
+
let updated = this.selector.getAllAvailableModels();
|
|
2719
|
+
if (this.liveData.hasLivePricing()) updated = this.liveData.applyLivePricing(updated);
|
|
2720
|
+
if (this.liveData.hasCapabilities()) updated = this.liveData.applyLiveCapabilities(updated);
|
|
2568
2721
|
for (const m of updated) this.selector.addDynamicModel(m);
|
|
2569
2722
|
for (const tier of ["T1", "T2", "T3"]) {
|
|
2570
2723
|
const cur = this.tierModels.get(tier);
|
|
@@ -2739,6 +2892,10 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
|
|
|
2739
2892
|
const r = this.config?.reinforcements;
|
|
2740
2893
|
return { enabled: r?.enabled === true, maxPerSection: r?.maxPerSection ?? 4 };
|
|
2741
2894
|
}
|
|
2895
|
+
/** Project-knowledge settings (config.knowledge). Facts extraction on by default. */
|
|
2896
|
+
getKnowledgeConfig() {
|
|
2897
|
+
return { factsExtraction: this.config?.knowledge?.factsExtraction !== false };
|
|
2898
|
+
}
|
|
2742
2899
|
/**
|
|
2743
2900
|
* Resolved T3 wave execution mode. 'auto' becomes 'sequential' when the T3
|
|
2744
2901
|
* tier resolves to a LOCAL model (the single-GPU queue serializes anyway, so
|
|
@@ -2825,10 +2982,10 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
|
|
|
2825
2982
|
* null when Cascade Auto is off (callers then use the shared tier model).
|
|
2826
2983
|
* Pure heuristic — no extra LLM call.
|
|
2827
2984
|
*/
|
|
2828
|
-
async selectModelForSubtask(tier, text) {
|
|
2985
|
+
async selectModelForSubtask(tier, text, opts) {
|
|
2829
2986
|
if (!this.config?.cascadeAuto || !this.taskAnalyzer || !text.trim()) return null;
|
|
2830
2987
|
try {
|
|
2831
|
-
return await this.taskAnalyzer.selectModel(text, tier, this.selector);
|
|
2988
|
+
return await this.taskAnalyzer.selectModel(text, tier, this.selector, opts);
|
|
2832
2989
|
} catch {
|
|
2833
2990
|
return null;
|
|
2834
2991
|
}
|
|
@@ -3169,6 +3326,13 @@ var BaseTier = class extends EventEmitter__default.default {
|
|
|
3169
3326
|
hierarchyContext = "";
|
|
3170
3327
|
/** Propagated AbortSignal — set by the tier's `execute()` before work begins. */
|
|
3171
3328
|
signal;
|
|
3329
|
+
/**
|
|
3330
|
+
* True for the run's ROOT tier (T3 for Simple, T2 for Moderate, T1 for
|
|
3331
|
+
* Complex). Its own synthesis stream is the user-facing answer and is
|
|
3332
|
+
* tagged `primary` so the desktop renders it live — background workers,
|
|
3333
|
+
* which would interleave, are not tagged.
|
|
3334
|
+
*/
|
|
3335
|
+
isPresenter = false;
|
|
3172
3336
|
constructor(role, id, parentId) {
|
|
3173
3337
|
super();
|
|
3174
3338
|
this.role = role;
|
|
@@ -3176,6 +3340,10 @@ var BaseTier = class extends EventEmitter__default.default {
|
|
|
3176
3340
|
this.parentId = parentId;
|
|
3177
3341
|
this.label = this.id;
|
|
3178
3342
|
}
|
|
3343
|
+
/** Mark this tier as the run's presenter (root tier). */
|
|
3344
|
+
setPresenter(on = true) {
|
|
3345
|
+
this.isPresenter = on;
|
|
3346
|
+
}
|
|
3179
3347
|
getStatus() {
|
|
3180
3348
|
return this.status;
|
|
3181
3349
|
}
|
|
@@ -3572,6 +3740,13 @@ EXAMPLE \u2014 calling the "shell" tool to list files:
|
|
|
3572
3740
|
{"name": "shell", "input": {"command": "ls -la"}}
|
|
3573
3741
|
</tool_call>
|
|
3574
3742
|
|
|
3743
|
+
When you have enough information, stop calling tools and write your final answer.`;
|
|
3744
|
+
}
|
|
3745
|
+
function buildTextToolReminder(tools) {
|
|
3746
|
+
return `
|
|
3747
|
+
TOOL USE REMINDER:
|
|
3748
|
+
Call tools with a single <tool_call>{"name": "<tool_name>", "input": { ... }}</tool_call> block (valid JSON, double quotes, one tool per turn), matching the argument shapes of your earlier calls in this conversation.
|
|
3749
|
+
Available tools: ${tools.map((t) => t.name).join(", ")}.
|
|
3575
3750
|
When you have enough information, stop calling tools and write your final answer.`;
|
|
3576
3751
|
}
|
|
3577
3752
|
|
|
@@ -3811,6 +3986,9 @@ Now execute your subtask using this context where relevant.`
|
|
|
3811
3986
|
} catch (e) {
|
|
3812
3987
|
this.log("Failed to write to World State DB");
|
|
3813
3988
|
}
|
|
3989
|
+
if (this.router.getKnowledgeConfig?.().factsExtraction !== false) {
|
|
3990
|
+
await this.extractAndStoreFacts(db, assignment, output);
|
|
3991
|
+
}
|
|
3814
3992
|
}
|
|
3815
3993
|
this.setStatus("COMPLETED", output);
|
|
3816
3994
|
this.sendStatusUpdate({ progressPct: 100, currentAction: "Subtask complete", status: "IN_PROGRESS", output });
|
|
@@ -3872,7 +4050,7 @@ Now execute your subtask using this context where relevant.`
|
|
|
3872
4050
|
let subtaskModel;
|
|
3873
4051
|
try {
|
|
3874
4052
|
const subtaskText = `${this.assignment?.subtaskTitle ?? ""} ${this.assignment?.description ?? ""} ${this.assignment?.expectedOutput ?? ""}`;
|
|
3875
|
-
subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText) ?? void 0;
|
|
4053
|
+
subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText, { requiresToolUse: tools.length > 0 }) ?? void 0;
|
|
3876
4054
|
if (subtaskModel) {
|
|
3877
4055
|
this.log(`Cascade Auto: routing this subtask to ${subtaskModel.provider}:${subtaskModel.id}`);
|
|
3878
4056
|
}
|
|
@@ -3880,7 +4058,8 @@ Now execute your subtask using this context where relevant.`
|
|
|
3880
4058
|
}
|
|
3881
4059
|
const effectiveModel = subtaskModel ?? this.router.getModelForTier("T3");
|
|
3882
4060
|
const useTextTools = effectiveModel?.supportsToolUse === false && tools.length > 0;
|
|
3883
|
-
|
|
4061
|
+
let sentFullTextContract = false;
|
|
4062
|
+
let textContractSignature = "";
|
|
3884
4063
|
while (iterations < MAX_ITERATIONS) {
|
|
3885
4064
|
iterations++;
|
|
3886
4065
|
this.throwIfCancelled();
|
|
@@ -3894,6 +4073,17 @@ Now execute your subtask using this context where relevant.`
|
|
|
3894
4073
|
${g.text}`
|
|
3895
4074
|
});
|
|
3896
4075
|
}
|
|
4076
|
+
let textToolSuffix = "";
|
|
4077
|
+
if (useTextTools) {
|
|
4078
|
+
const signature = tools.map((t) => t.name).join(",");
|
|
4079
|
+
if (!sentFullTextContract || signature !== textContractSignature) {
|
|
4080
|
+
textToolSuffix = buildTextToolSystemPrompt(tools);
|
|
4081
|
+
sentFullTextContract = true;
|
|
4082
|
+
textContractSignature = signature;
|
|
4083
|
+
} else {
|
|
4084
|
+
textToolSuffix = buildTextToolReminder(tools);
|
|
4085
|
+
}
|
|
4086
|
+
}
|
|
3897
4087
|
const options = {
|
|
3898
4088
|
messages: this.context.getMessages(),
|
|
3899
4089
|
systemPrompt: this.systemPromptOverride + systemPrompt + (this.hierarchyContext ? `
|
|
@@ -3910,7 +4100,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
|
|
|
3910
4100
|
"T3",
|
|
3911
4101
|
options,
|
|
3912
4102
|
(chunk) => {
|
|
3913
|
-
this.emit("stream:token", { tierId: this.id, text: chunk.text });
|
|
4103
|
+
this.emit("stream:token", { tierId: this.id, text: chunk.text, primary: this.isPresenter });
|
|
3914
4104
|
}
|
|
3915
4105
|
);
|
|
3916
4106
|
let effectiveToolCalls = result.toolCalls ?? [];
|
|
@@ -4383,6 +4573,41 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
|
|
|
4383
4573
|
};
|
|
4384
4574
|
}
|
|
4385
4575
|
}
|
|
4576
|
+
/**
|
|
4577
|
+
* world-state v2: distill a completed subtask's output into durable
|
|
4578
|
+
* `(entity, relation, value)` facts and upsert them into the knowledge graph.
|
|
4579
|
+
* A bounded, cheap T3-tier call; entirely best-effort — any failure is swallowed
|
|
4580
|
+
* so it never blocks or fails the subtask. Respects the subtask's privacy tier
|
|
4581
|
+
* (a local-only subtask extracts on a local model too, never leaking to cloud).
|
|
4582
|
+
*/
|
|
4583
|
+
async extractAndStoreFacts(db, assignment, output) {
|
|
4584
|
+
try {
|
|
4585
|
+
const prompt = `Extract durable project facts from this completed subtask.
|
|
4586
|
+
Return ONLY a JSON array of {"entity","relation","value"} triples describing lasting
|
|
4587
|
+
facts about the codebase/project \u2014 e.g. {"entity":"auth module","relation":"uses","value":"JWT"}.
|
|
4588
|
+
Ignore transient step-by-step details. At most 6 triples. If nothing durable, return [].
|
|
4589
|
+
|
|
4590
|
+
Subtask: ${assignment.subtaskTitle}
|
|
4591
|
+
Output:
|
|
4592
|
+
${output.slice(0, 4e3)}`;
|
|
4593
|
+
const result = await this.router.generate("T3", {
|
|
4594
|
+
messages: [{ role: "user", content: prompt }],
|
|
4595
|
+
maxTokens: 300,
|
|
4596
|
+
temperature: 0,
|
|
4597
|
+
...this.localOnlyMatch ? { forceLocal: true } : {}
|
|
4598
|
+
});
|
|
4599
|
+
const match = /\[[\s\S]*\]/.exec(result.content);
|
|
4600
|
+
if (!match) return;
|
|
4601
|
+
const facts = JSON.parse(match[0]);
|
|
4602
|
+
if (!Array.isArray(facts)) return;
|
|
4603
|
+
for (const f of facts.slice(0, 8)) {
|
|
4604
|
+
if (f && typeof f.entity === "string" && typeof f.relation === "string" && typeof f.value === "string") {
|
|
4605
|
+
db.upsertFact(f.entity, f.relation, f.value, this.id);
|
|
4606
|
+
}
|
|
4607
|
+
}
|
|
4608
|
+
} catch {
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4386
4611
|
async correctOutput(originalOutput, failures) {
|
|
4387
4612
|
const correctionPrompt = `The following output failed these checks: ${failures.join(", ")}.
|
|
4388
4613
|
|
|
@@ -5371,6 +5596,7 @@ ${peerOutputs}` : "";
|
|
|
5371
5596
|
chunkEnd++;
|
|
5372
5597
|
}
|
|
5373
5598
|
i = chunkEnd;
|
|
5599
|
+
const isLastChunk = chunkEnd >= completed.length;
|
|
5374
5600
|
const prompt = `Summarize these T3 worker outputs for section "${assignment.sectionTitle}" in 2-3 sentences.
|
|
5375
5601
|
${currentSummary ? `
|
|
5376
5602
|
PREVIOUS SUMMARY SO FAR:
|
|
@@ -5380,6 +5606,7 @@ NEW OUTPUTS TO INTEGRATE:
|
|
|
5380
5606
|
` : "\nOUTPUTS:\n"}${chunkText}${peerContext}`;
|
|
5381
5607
|
const messages = [{ role: "user", content: prompt }];
|
|
5382
5608
|
try {
|
|
5609
|
+
const streamFinal = isLastChunk && this.isPresenter ? (chunk) => this.emit("stream:token", { tierId: this.id, text: chunk.text, primary: true }) : void 0;
|
|
5383
5610
|
const result = await this.router.generate("T2", {
|
|
5384
5611
|
messages,
|
|
5385
5612
|
systemPrompt: this.systemPromptOverride + "You are a T2 Manager. Summarize the work of your T3 workers succinctly." + (this.hierarchyContext ? `
|
|
@@ -5387,7 +5614,7 @@ NEW OUTPUTS TO INTEGRATE:
|
|
|
5387
5614
|
HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
|
|
5388
5615
|
maxTokens: 500,
|
|
5389
5616
|
...this.sectionModel ? { model: this.sectionModel } : {}
|
|
5390
|
-
});
|
|
5617
|
+
}, streamFinal);
|
|
5391
5618
|
currentSummary = result.content;
|
|
5392
5619
|
} catch (err) {
|
|
5393
5620
|
this.log(`aggregateResults: LLM summarization failed at chunk \u2014 returning raw T3 outputs. Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -5439,14 +5666,11 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
|
|
|
5439
5666
|
...this.sectionModel ? { model: this.sectionModel } : {}
|
|
5440
5667
|
});
|
|
5441
5668
|
const answer = result.content.trim().toUpperCase();
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
}
|
|
5445
|
-
if (answer.includes("NO")) {
|
|
5446
|
-
return { requestId: req.id, approved: false, always: true, decidedBy: "T2", reasoning: "T2 LLM evaluated: inconsistent with section goal" };
|
|
5447
|
-
}
|
|
5669
|
+
const verdict = answer.includes("YES") ? "approve" : answer.includes("NO") ? "deny" : "unsure";
|
|
5670
|
+
(req.trail ??= []).push({ tier: "T2", verdict, reason: `T2: ${verdict === "approve" ? "consistent with section goal" : verdict === "deny" ? "inconsistent with section goal" : "unsure"}` });
|
|
5448
5671
|
return null;
|
|
5449
5672
|
} catch {
|
|
5673
|
+
(req.trail ??= []).push({ tier: "T2", verdict: "unsure", reason: "T2 evaluation failed" });
|
|
5450
5674
|
return null;
|
|
5451
5675
|
}
|
|
5452
5676
|
}
|
|
@@ -5786,10 +6010,24 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
|
|
|
5786
6010
|
}
|
|
5787
6011
|
async decomposeTask(prompt, systemContext) {
|
|
5788
6012
|
const db = this.router.getWorldStateDB?.();
|
|
5789
|
-
|
|
6013
|
+
let worldStateContext = "";
|
|
6014
|
+
if (db) {
|
|
6015
|
+
const knowledge = db.getFormattedKnowledge(prompt);
|
|
6016
|
+
if (knowledge) {
|
|
6017
|
+
worldStateContext = `
|
|
6018
|
+
|
|
6019
|
+
PROJECT KNOWLEDGE (relevant facts about this codebase):
|
|
6020
|
+
${knowledge}`;
|
|
6021
|
+
} else {
|
|
6022
|
+
const log = db.getFormattedState();
|
|
6023
|
+
if (log && log !== "World State is currently empty.") {
|
|
6024
|
+
worldStateContext = `
|
|
5790
6025
|
|
|
5791
6026
|
PROJECT WORLD STATE (Current architecture and recent changes):
|
|
5792
|
-
${
|
|
6027
|
+
${log}`;
|
|
6028
|
+
}
|
|
6029
|
+
}
|
|
6030
|
+
}
|
|
5793
6031
|
const contextSection = systemContext ? `
|
|
5794
6032
|
Project context:
|
|
5795
6033
|
${systemContext}` : "";
|
|
@@ -6125,7 +6363,7 @@ Instructions:
|
|
|
6125
6363
|
systemPrompt: this.systemPromptOverride + "You are a final output compiler. Summarize and format the task results clearly.",
|
|
6126
6364
|
maxTokens: 8e3
|
|
6127
6365
|
}, (chunk) => {
|
|
6128
|
-
this.emit("stream:token", { tierId: this.id, text: chunk.text });
|
|
6366
|
+
this.emit("stream:token", { tierId: this.id, text: chunk.text, primary: this.isPresenter });
|
|
6129
6367
|
});
|
|
6130
6368
|
return result.content;
|
|
6131
6369
|
}
|
|
@@ -6154,14 +6392,11 @@ Reply with exactly one word: YES, NO, or UNSURE.
|
|
|
6154
6392
|
temperature: 0
|
|
6155
6393
|
});
|
|
6156
6394
|
const answer = result.content.trim().toUpperCase();
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
}
|
|
6160
|
-
if (answer.includes("NO")) {
|
|
6161
|
-
return { requestId: req.id, approved: false, always: true, decidedBy: "T1", reasoning: "T1 evaluated: not consistent with overall task goal" };
|
|
6162
|
-
}
|
|
6395
|
+
const verdict = answer.includes("YES") ? "approve" : answer.includes("NO") ? "deny" : "unsure";
|
|
6396
|
+
(req.trail ??= []).push({ tier: "T1", verdict, reason: `T1: ${verdict === "approve" ? "consistent with overall task goal" : verdict === "deny" ? "not consistent with overall task goal" : "unsure"}` });
|
|
6163
6397
|
return null;
|
|
6164
6398
|
} catch {
|
|
6399
|
+
(req.trail ??= []).push({ tier: "T1", verdict: "unsure", reason: "T1 evaluation failed" });
|
|
6165
6400
|
return null;
|
|
6166
6401
|
}
|
|
6167
6402
|
}
|
|
@@ -8054,7 +8289,15 @@ var ToolsConfigSchema = zod.z.object({
|
|
|
8054
8289
|
mcpServers: zod.z.array(McpServerConfigSchema).optional(),
|
|
8055
8290
|
mcpTrusted: zod.z.array(zod.z.string()).optional(),
|
|
8056
8291
|
/** Web search backends — at least one should be configured for best results */
|
|
8057
|
-
webSearch: WebSearchConfigSchema.optional()
|
|
8292
|
+
webSearch: WebSearchConfigSchema.optional(),
|
|
8293
|
+
/**
|
|
8294
|
+
* Sandbox runtime for LLM-authored dynamic tools:
|
|
8295
|
+
* - 'isolate': hard V8 isolate (isolated-vm) — no Node globals, true capability
|
|
8296
|
+
* confinement. Requires the optional native `isolated-vm` dependency.
|
|
8297
|
+
* - 'worker': node:worker_threads (resource/kill limits, but not capability-confined).
|
|
8298
|
+
* - 'auto' (default): use the isolate when `isolated-vm` loads, else fall back to worker.
|
|
8299
|
+
*/
|
|
8300
|
+
dynamicToolSandbox: zod.z.enum(["isolate", "worker", "auto"]).default("auto")
|
|
8058
8301
|
});
|
|
8059
8302
|
var HookDefinitionSchema = zod.z.object({
|
|
8060
8303
|
name: zod.z.string().optional(),
|
|
@@ -8166,10 +8409,20 @@ var CascadeConfigSchema = zod.z.object({
|
|
|
8166
8409
|
/**
|
|
8167
8410
|
* Runtime Tool Creation: when true, T3 workers can generate and register new tools
|
|
8168
8411
|
* at runtime via the ToolCreator when no existing tool can handle a required operation.
|
|
8169
|
-
* Generated tools are session-scoped and sandboxed
|
|
8412
|
+
* Generated tools are session-scoped and sandboxed (see tools.dynamicToolSandbox).
|
|
8170
8413
|
* HTTP calls from generated tools require approval.
|
|
8171
8414
|
*/
|
|
8172
8415
|
enableToolCreation: zod.z.boolean().default(true),
|
|
8416
|
+
/**
|
|
8417
|
+
* Project knowledge (world state). `factsExtraction`: after each worker
|
|
8418
|
+
* completes, run a cheap extraction pass that distills its output into
|
|
8419
|
+
* queryable entity/relation/value facts (superseding older facts), which T1
|
|
8420
|
+
* queries during planning instead of replaying the whole linear log. Best-effort
|
|
8421
|
+
* and non-blocking; set false to keep only the raw linear log.
|
|
8422
|
+
*/
|
|
8423
|
+
knowledge: zod.z.object({
|
|
8424
|
+
factsExtraction: zod.z.boolean().default(true)
|
|
8425
|
+
}).default({}),
|
|
8173
8426
|
/**
|
|
8174
8427
|
* Persist runtime-generated tools to .cascade/dynamic-tools.json and reload them
|
|
8175
8428
|
* on startup for cross-run dedup. Reloaded (and peer-received) tools are always
|
|
@@ -8268,6 +8521,8 @@ var CascadeConfigSchema = zod.z.object({
|
|
|
8268
8521
|
privacy: zod.z.object({
|
|
8269
8522
|
paths: zod.z.array(zod.z.object({ pattern: zod.z.string().min(1), policy: zod.z.enum(["local-only"]) })).default([])
|
|
8270
8523
|
}).optional(),
|
|
8524
|
+
/** Routing controls — forceTier pins the root tier, bypassing the classifier. */
|
|
8525
|
+
routing: zod.z.object({ forceTier: zod.z.enum(["auto", "T1", "T2", "T3"]).default("auto") }).optional(),
|
|
8271
8526
|
/**
|
|
8272
8527
|
* T3→T2 reinforcement: when enabled, a worker that discovers its subtask should
|
|
8273
8528
|
* fan out can call the `request_workers` tool to have its T2 manager spawn
|
|
@@ -8460,13 +8715,17 @@ var TaskAnalyzer = class {
|
|
|
8460
8715
|
* Scores tier-eligible models using cost efficiency + historical performance.
|
|
8461
8716
|
* Falls back to the priority-list default when no candidates have history.
|
|
8462
8717
|
*/
|
|
8463
|
-
async selectModel(prompt, tier, selector) {
|
|
8718
|
+
async selectModel(prompt, tier, selector, opts) {
|
|
8464
8719
|
const profile = await this.analyze(prompt);
|
|
8465
8720
|
if (profile.requiresVision) {
|
|
8466
8721
|
return selector.selectVisionModel();
|
|
8467
8722
|
}
|
|
8468
|
-
|
|
8723
|
+
let candidates = selector.getCandidatesForTier(tier);
|
|
8469
8724
|
if (candidates.length === 0) return selector.selectForTier(tier);
|
|
8725
|
+
if (opts?.requiresToolUse) {
|
|
8726
|
+
const toolCapable = candidates.filter((m) => m.supportsToolUse !== false);
|
|
8727
|
+
if (toolCapable.length > 0) candidates = toolCapable;
|
|
8728
|
+
}
|
|
8470
8729
|
const scored = candidates.map((m) => ({
|
|
8471
8730
|
model: m,
|
|
8472
8731
|
score: this.scoreModel(m, profile)
|
|
@@ -8645,6 +8904,28 @@ var ModelPerformanceTracker = class {
|
|
|
8645
8904
|
return Math.max(0.1, 1 - normalised * complexityWeight);
|
|
8646
8905
|
}
|
|
8647
8906
|
};
|
|
8907
|
+
var ivmCache;
|
|
8908
|
+
var ivmWarned = false;
|
|
8909
|
+
async function loadIsolatedVm() {
|
|
8910
|
+
if (ivmCache !== void 0) return ivmCache;
|
|
8911
|
+
try {
|
|
8912
|
+
const specifier = "isolated-vm";
|
|
8913
|
+
const mod = await import(specifier);
|
|
8914
|
+
ivmCache = mod.default ?? mod;
|
|
8915
|
+
} catch {
|
|
8916
|
+
ivmCache = null;
|
|
8917
|
+
}
|
|
8918
|
+
return ivmCache;
|
|
8919
|
+
}
|
|
8920
|
+
function safeJsonParse(text) {
|
|
8921
|
+
if (typeof text !== "string") return {};
|
|
8922
|
+
try {
|
|
8923
|
+
const v = JSON.parse(text);
|
|
8924
|
+
return v && typeof v === "object" ? v : {};
|
|
8925
|
+
} catch {
|
|
8926
|
+
return {};
|
|
8927
|
+
}
|
|
8928
|
+
}
|
|
8648
8929
|
var DYNAMIC_TOOLS_FILE = "dynamic-tools.json";
|
|
8649
8930
|
function normalizeToolSchema(schema) {
|
|
8650
8931
|
if (schema && schema["type"] === "object" && typeof schema["properties"] === "object") {
|
|
@@ -8747,7 +9028,12 @@ var DynamicTool = class extends BaseTool {
|
|
|
8747
9028
|
getEscalator;
|
|
8748
9029
|
/** Untrusted = loaded from disk / a peer; its dangerous calls always re-prompt. */
|
|
8749
9030
|
trusted;
|
|
8750
|
-
|
|
9031
|
+
/** Resolve the configured sandbox mode at call time (default 'auto'). */
|
|
9032
|
+
getSandboxMode;
|
|
9033
|
+
/** Optional diagnostics sink (routed through the host so it survives the Ink TUI). */
|
|
9034
|
+
log;
|
|
9035
|
+
constructor(spec, registry, getEscalator, trusted, getSandboxMode = () => "auto", log = () => {
|
|
9036
|
+
}) {
|
|
8751
9037
|
super();
|
|
8752
9038
|
this.name = spec.name;
|
|
8753
9039
|
this.description = spec.description;
|
|
@@ -8757,6 +9043,8 @@ var DynamicTool = class extends BaseTool {
|
|
|
8757
9043
|
this.registry = registry;
|
|
8758
9044
|
this.getEscalator = getEscalator;
|
|
8759
9045
|
this.trusted = trusted;
|
|
9046
|
+
this.getSandboxMode = getSandboxMode;
|
|
9047
|
+
this.log = log;
|
|
8760
9048
|
}
|
|
8761
9049
|
isDangerous() {
|
|
8762
9050
|
return this._isDangerous;
|
|
@@ -8793,8 +9081,94 @@ var DynamicTool = class extends BaseTool {
|
|
|
8793
9081
|
return `Error calling ${toolName}: ${err instanceof Error ? err.message : String(err)}`;
|
|
8794
9082
|
}
|
|
8795
9083
|
};
|
|
9084
|
+
const mode = this.getSandboxMode();
|
|
9085
|
+
if (mode !== "worker") {
|
|
9086
|
+
const ivm = await loadIsolatedVm();
|
|
9087
|
+
if (ivm) return this.runInIsolate(ivm, input, callTool);
|
|
9088
|
+
if (mode === "isolate" && !ivmWarned) {
|
|
9089
|
+
ivmWarned = true;
|
|
9090
|
+
this.log("[tool-creator] isolated-vm is not available (not installed or failed to build) \u2014 dynamic tools fall back to the worker sandbox, which is NOT capability-confined. Install isolated-vm for a hard isolate.");
|
|
9091
|
+
}
|
|
9092
|
+
}
|
|
8796
9093
|
return this.runInWorker(input, callTool);
|
|
8797
9094
|
}
|
|
9095
|
+
/**
|
|
9096
|
+
* Run the generated code in a hard V8 isolate (isolated-vm). The isolate global
|
|
9097
|
+
* has no Node built-ins, so the code cannot see `process`, `require`, the
|
|
9098
|
+
* filesystem, or the network — it reaches the host ONLY through the injected
|
|
9099
|
+
* `callTool` (escalator-gated on the main thread) and `fetch` (SSRF-guarded via
|
|
9100
|
+
* bridgeFetch). `script.run({ timeout })` bounds synchronous CPU; an outer
|
|
9101
|
+
* wall-clock race + `isolate.dispose()` bounds async runaway (a never-resolving
|
|
9102
|
+
* await), mirroring the worker's terminate().
|
|
9103
|
+
*/
|
|
9104
|
+
async runInIsolate(ivm, input, callTool) {
|
|
9105
|
+
const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
|
|
9106
|
+
const isolate = new ivm.Isolate({ memoryLimit: 128 });
|
|
9107
|
+
let disposed = false;
|
|
9108
|
+
const dispose = () => {
|
|
9109
|
+
if (!disposed) {
|
|
9110
|
+
disposed = true;
|
|
9111
|
+
try {
|
|
9112
|
+
isolate.dispose();
|
|
9113
|
+
} catch {
|
|
9114
|
+
}
|
|
9115
|
+
}
|
|
9116
|
+
};
|
|
9117
|
+
try {
|
|
9118
|
+
const context = await isolate.createContext();
|
|
9119
|
+
const jail = context.global;
|
|
9120
|
+
await jail.set("_callTool", new ivm.Reference(async (name, inputJson) => {
|
|
9121
|
+
const out = await callTool(String(name), safeJsonParse(inputJson));
|
|
9122
|
+
return String(out);
|
|
9123
|
+
}));
|
|
9124
|
+
await jail.set("_fetch", new ivm.Reference(async (url, initJson) => {
|
|
9125
|
+
const r = await bridgeFetch(String(url), safeJsonParse(initJson));
|
|
9126
|
+
return JSON.stringify(r);
|
|
9127
|
+
}));
|
|
9128
|
+
await jail.set("_input", new ivm.ExternalCopy(input).copyInto());
|
|
9129
|
+
await jail.set("_code", this.executeCode);
|
|
9130
|
+
const bootstrap = `
|
|
9131
|
+
const callTool = (name, toolInput) => _callTool.apply(undefined,
|
|
9132
|
+
[String(name), JSON.stringify(toolInput || {})],
|
|
9133
|
+
{ result: { promise: true }, arguments: { copy: true } });
|
|
9134
|
+
const fetch = async (url, init) => {
|
|
9135
|
+
const raw = await _fetch.apply(undefined,
|
|
9136
|
+
[String(url), JSON.stringify(init || null)],
|
|
9137
|
+
{ result: { promise: true }, arguments: { copy: true } });
|
|
9138
|
+
const r = JSON.parse(raw);
|
|
9139
|
+
if (r && r.__error) throw new Error(r.__error);
|
|
9140
|
+
return {
|
|
9141
|
+
ok: r.ok, status: r.status, statusText: r.statusText,
|
|
9142
|
+
headers: { get: (k) => (String(k).toLowerCase() === 'content-type' ? r.contentType : null) },
|
|
9143
|
+
text: async () => r.body,
|
|
9144
|
+
json: async () => JSON.parse(r.body),
|
|
9145
|
+
};
|
|
9146
|
+
};
|
|
9147
|
+
const console = { log() {}, error() {} };
|
|
9148
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
9149
|
+
const fn = new AsyncFunction('input', 'callTool', 'fetch', 'console', _code);
|
|
9150
|
+
(async () => { const r = await fn(_input, callTool, fetch, console); return String(r == null ? '' : r); })();
|
|
9151
|
+
`;
|
|
9152
|
+
const script = await isolate.compileScript(bootstrap);
|
|
9153
|
+
const runPromise = script.run(context, { timeout: timeoutMs, promise: true }).then((v) => String(v ?? ""));
|
|
9154
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
9155
|
+
const t = setTimeout(() => {
|
|
9156
|
+
dispose();
|
|
9157
|
+
resolve(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`);
|
|
9158
|
+
}, timeoutMs + 500);
|
|
9159
|
+
t.unref?.();
|
|
9160
|
+
});
|
|
9161
|
+
return await Promise.race([runPromise, timeoutPromise]);
|
|
9162
|
+
} catch (err) {
|
|
9163
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
9164
|
+
if (/timed? out/i.test(msg)) {
|
|
9165
|
+
return `Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`;
|
|
9166
|
+
}
|
|
9167
|
+
return `Tool error: ${msg}`;
|
|
9168
|
+
} finally {
|
|
9169
|
+
dispose();
|
|
9170
|
+
}
|
|
9171
|
+
}
|
|
8798
9172
|
/** Spawn the worker, service its callTool/fetch bridge, enforce the kill timeout. */
|
|
8799
9173
|
runInWorker(input, callTool) {
|
|
8800
9174
|
const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
|
|
@@ -8874,16 +9248,19 @@ var ToolCreator = class {
|
|
|
8874
9248
|
workspacePath;
|
|
8875
9249
|
/** When false, persisted tools are neither loaded nor written. */
|
|
8876
9250
|
persistEnabled;
|
|
9251
|
+
/** Sandbox runtime for generated tools; passed to each DynamicTool. */
|
|
9252
|
+
sandboxMode;
|
|
8877
9253
|
logger;
|
|
8878
9254
|
/** name → spec, for persistence, broadcast, and re-registration. */
|
|
8879
9255
|
specs = /* @__PURE__ */ new Map();
|
|
8880
9256
|
/** capability fingerprint → tool name, so the same need isn't re-generated. */
|
|
8881
9257
|
capabilityIndex = /* @__PURE__ */ new Map();
|
|
8882
|
-
constructor(router, registry, workspacePath, persistEnabled = true) {
|
|
9258
|
+
constructor(router, registry, workspacePath, persistEnabled = true, sandboxMode = "auto") {
|
|
8883
9259
|
this.router = router;
|
|
8884
9260
|
this.registry = registry;
|
|
8885
9261
|
this.workspacePath = workspacePath;
|
|
8886
9262
|
this.persistEnabled = persistEnabled;
|
|
9263
|
+
this.sandboxMode = sandboxMode;
|
|
8887
9264
|
}
|
|
8888
9265
|
setPermissionEscalator(escalator) {
|
|
8889
9266
|
this.escalator = escalator;
|
|
@@ -8971,7 +9348,14 @@ Required capability: ${description.slice(0, 300)}`;
|
|
|
8971
9348
|
this.specs.set(spec.name, spec);
|
|
8972
9349
|
return;
|
|
8973
9350
|
}
|
|
8974
|
-
const tool = new DynamicTool(
|
|
9351
|
+
const tool = new DynamicTool(
|
|
9352
|
+
spec,
|
|
9353
|
+
this.registry,
|
|
9354
|
+
() => this.escalator,
|
|
9355
|
+
trusted,
|
|
9356
|
+
() => this.sandboxMode,
|
|
9357
|
+
(msg) => this.log(msg)
|
|
9358
|
+
);
|
|
8975
9359
|
this.registry.register(tool);
|
|
8976
9360
|
this.specs.set(spec.name, spec);
|
|
8977
9361
|
this.capabilityIndex.set(capabilityKey(`${spec.description}`), spec.name);
|
|
@@ -9020,6 +9404,9 @@ Required capability: ${description.slice(0, 300)}`;
|
|
|
9020
9404
|
return Array.from(this.specs.keys());
|
|
9021
9405
|
}
|
|
9022
9406
|
};
|
|
9407
|
+
function normalizeKey(text) {
|
|
9408
|
+
return text.trim().toLowerCase().replace(/\s+/g, " ");
|
|
9409
|
+
}
|
|
9023
9410
|
var WorldStateDB = class {
|
|
9024
9411
|
constructor(workspacePath, debugMode = false) {
|
|
9025
9412
|
this.workspacePath = workspacePath;
|
|
@@ -9041,6 +9428,16 @@ var WorldStateDB = class {
|
|
|
9041
9428
|
encrypted_payload TEXT NOT NULL
|
|
9042
9429
|
)
|
|
9043
9430
|
`);
|
|
9431
|
+
this.db.exec(`
|
|
9432
|
+
CREATE TABLE IF NOT EXISTS facts (
|
|
9433
|
+
entity TEXT NOT NULL,
|
|
9434
|
+
relation TEXT NOT NULL,
|
|
9435
|
+
encrypted_value TEXT NOT NULL,
|
|
9436
|
+
source_worker TEXT NOT NULL,
|
|
9437
|
+
timestamp TEXT NOT NULL,
|
|
9438
|
+
PRIMARY KEY (entity, relation)
|
|
9439
|
+
)
|
|
9440
|
+
`);
|
|
9044
9441
|
}
|
|
9045
9442
|
workspacePath;
|
|
9046
9443
|
debugMode;
|
|
@@ -9114,6 +9511,131 @@ var WorldStateDB = class {
|
|
|
9114
9511
|
if (entries.length === 0) return "World State is currently empty.";
|
|
9115
9512
|
return entries.map((e, idx) => `[${e.timestamp}] Step ${idx + 1} (${e.workerId}): ${e.summary}`).join("\n");
|
|
9116
9513
|
}
|
|
9514
|
+
// ── world-state v2: queryable facts ──────────────
|
|
9515
|
+
/**
|
|
9516
|
+
* Upsert a fact. `(entity, relation)` is normalized so casing/whitespace don't
|
|
9517
|
+
* fragment the key; an existing pair is superseded (value + provenance updated)
|
|
9518
|
+
* rather than duplicated. Empty entity/relation/value are ignored.
|
|
9519
|
+
*/
|
|
9520
|
+
upsertFact(entity, relation, value, sourceWorker, timestamp) {
|
|
9521
|
+
const e = normalizeKey(entity);
|
|
9522
|
+
const r = normalizeKey(relation);
|
|
9523
|
+
const v = value.trim();
|
|
9524
|
+
if (!e || !r || !v) return;
|
|
9525
|
+
const encrypted = this.encrypt(JSON.stringify({ value: v }));
|
|
9526
|
+
const stmt = this.db.prepare(`
|
|
9527
|
+
INSERT INTO facts (entity, relation, encrypted_value, source_worker, timestamp)
|
|
9528
|
+
VALUES (?, ?, ?, ?, ?)
|
|
9529
|
+
ON CONFLICT(entity, relation) DO UPDATE SET
|
|
9530
|
+
encrypted_value = excluded.encrypted_value,
|
|
9531
|
+
source_worker = excluded.source_worker,
|
|
9532
|
+
timestamp = excluded.timestamp
|
|
9533
|
+
`);
|
|
9534
|
+
stmt.run(e, r, encrypted, sourceWorker, timestamp ?? (/* @__PURE__ */ new Date()).toISOString());
|
|
9535
|
+
this.dumpDebugIfNeeded();
|
|
9536
|
+
}
|
|
9537
|
+
rowToFact(row) {
|
|
9538
|
+
let value;
|
|
9539
|
+
try {
|
|
9540
|
+
value = JSON.parse(this.decrypt(row.encrypted_value)).value;
|
|
9541
|
+
} catch {
|
|
9542
|
+
value = "[Decryption Failed - Payload Corrupted]";
|
|
9543
|
+
}
|
|
9544
|
+
return { entity: row.entity, relation: row.relation, value, sourceWorker: row.source_worker, timestamp: row.timestamp };
|
|
9545
|
+
}
|
|
9546
|
+
/** All facts whose entity matches one of the (normalized) query entities. */
|
|
9547
|
+
getFactsForEntities(entities) {
|
|
9548
|
+
const keys = Array.from(new Set(entities.map(normalizeKey).filter(Boolean)));
|
|
9549
|
+
if (keys.length === 0) return [];
|
|
9550
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
9551
|
+
const stmt = this.db.prepare(
|
|
9552
|
+
`SELECT entity, relation, encrypted_value, source_worker, timestamp FROM facts WHERE entity IN (${placeholders}) ORDER BY entity ASC, relation ASC`
|
|
9553
|
+
);
|
|
9554
|
+
return stmt.all(...keys).map((row) => this.rowToFact(row));
|
|
9555
|
+
}
|
|
9556
|
+
/** Every fact (used for tests / debug / whole-graph views). */
|
|
9557
|
+
getAllFacts() {
|
|
9558
|
+
const stmt = this.db.prepare("SELECT entity, relation, encrypted_value, source_worker, timestamp FROM facts ORDER BY entity ASC, relation ASC");
|
|
9559
|
+
return stmt.all().map((row) => this.rowToFact(row));
|
|
9560
|
+
}
|
|
9561
|
+
/**
|
|
9562
|
+
* A compact, human/LLM-readable fact block for T1/T2 planning. When `entities`
|
|
9563
|
+
* is given, only facts about those entities are included; otherwise all facts.
|
|
9564
|
+
* Returns '' when there are none so callers can fall back to the linear log.
|
|
9565
|
+
*/
|
|
9566
|
+
getFormattedFacts(entities) {
|
|
9567
|
+
const facts = entities && entities.length > 0 ? this.getFactsForEntities(entities) : this.getAllFacts();
|
|
9568
|
+
if (facts.length === 0) return "";
|
|
9569
|
+
return facts.map((f) => `- ${f.entity} ${f.relation} ${f.value}`).join("\n");
|
|
9570
|
+
}
|
|
9571
|
+
/**
|
|
9572
|
+
* A compact knowledge block for T1/T2 planning. When `prompt` is given, facts
|
|
9573
|
+
* whose entity/relation/value mention a prompt token are preferred (relevance
|
|
9574
|
+
* filter); otherwise, or when nothing matches, all facts are used (capped at
|
|
9575
|
+
* `limit`). Returns '' when there are no facts, so the caller can fall back to
|
|
9576
|
+
* the raw linear log — this replaces replaying the whole log during planning.
|
|
9577
|
+
*/
|
|
9578
|
+
getFormattedKnowledge(prompt, limit = 40) {
|
|
9579
|
+
const all = this.getAllFacts();
|
|
9580
|
+
if (all.length === 0) return "";
|
|
9581
|
+
let selected = all;
|
|
9582
|
+
if (prompt && prompt.trim()) {
|
|
9583
|
+
const tokens = Array.from(new Set(prompt.toLowerCase().match(/[a-z0-9_./-]{3,}/g) ?? []));
|
|
9584
|
+
if (tokens.length > 0) {
|
|
9585
|
+
const matched = all.filter((f) => {
|
|
9586
|
+
const hay = `${f.entity} ${f.relation} ${f.value}`.toLowerCase();
|
|
9587
|
+
return tokens.some((t) => hay.includes(t));
|
|
9588
|
+
});
|
|
9589
|
+
if (matched.length > 0) selected = matched;
|
|
9590
|
+
}
|
|
9591
|
+
}
|
|
9592
|
+
return selected.slice(0, limit).map((f) => `- ${f.entity} ${f.relation} ${f.value}`).join("\n");
|
|
9593
|
+
}
|
|
9594
|
+
// ── Export / Import ──────────────────────────
|
|
9595
|
+
//
|
|
9596
|
+
// Knowledge travels DECRYPTED in the export bundle (a portable plaintext
|
|
9597
|
+
// JSON file — the encryption key never leaves this machine) and re-encrypts
|
|
9598
|
+
// with the LOCAL key on import.
|
|
9599
|
+
exportKnowledge() {
|
|
9600
|
+
return { facts: this.getAllFacts(), worldLog: this.getAllEntries() };
|
|
9601
|
+
}
|
|
9602
|
+
/**
|
|
9603
|
+
* Merge imported knowledge. Facts upsert on (entity, relation) with
|
|
9604
|
+
* newer-timestamp-wins (a local fact newer than the imported one is kept);
|
|
9605
|
+
* log entries append with fresh ids, skipping exact duplicates
|
|
9606
|
+
* (worker + timestamp + summary). Returns counts of what actually landed.
|
|
9607
|
+
*/
|
|
9608
|
+
importKnowledge(data) {
|
|
9609
|
+
let facts = 0;
|
|
9610
|
+
let logEntries = 0;
|
|
9611
|
+
if (Array.isArray(data.facts)) {
|
|
9612
|
+
const local = new Map(this.getAllFacts().map((f) => [`${f.entity}\0${f.relation}`, f.timestamp]));
|
|
9613
|
+
for (const f of data.facts) {
|
|
9614
|
+
if (!f || typeof f.entity !== "string" || typeof f.relation !== "string" || typeof f.value !== "string") continue;
|
|
9615
|
+
const key = `${normalizeKey(f.entity)}\0${normalizeKey(f.relation)}`;
|
|
9616
|
+
const localTs = local.get(key);
|
|
9617
|
+
const importTs = f.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
9618
|
+
if (localTs && localTs >= importTs) continue;
|
|
9619
|
+
this.upsertFact(f.entity, f.relation, f.value, f.sourceWorker ?? "imported", importTs);
|
|
9620
|
+
facts++;
|
|
9621
|
+
}
|
|
9622
|
+
}
|
|
9623
|
+
if (Array.isArray(data.worldLog)) {
|
|
9624
|
+
const seen = new Set(this.getAllEntries().map((e) => `${e.workerId}\0${e.timestamp}\0${e.summary}`));
|
|
9625
|
+
const stmt = this.db.prepare("INSERT INTO world_state (id, timestamp, worker_id, encrypted_payload) VALUES (?, ?, ?, ?)");
|
|
9626
|
+
for (const e of data.worldLog) {
|
|
9627
|
+
if (!e || typeof e.summary !== "string") continue;
|
|
9628
|
+
const workerId = e.workerId ?? "imported";
|
|
9629
|
+
const timestamp = e.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
9630
|
+
const key = `${workerId}\0${timestamp}\0${e.summary}`;
|
|
9631
|
+
if (seen.has(key)) continue;
|
|
9632
|
+
stmt.run(crypto3__default.default.randomUUID(), timestamp, workerId, this.encrypt(JSON.stringify({ summary: e.summary })));
|
|
9633
|
+
seen.add(key);
|
|
9634
|
+
logEntries++;
|
|
9635
|
+
}
|
|
9636
|
+
}
|
|
9637
|
+
return { facts, logEntries };
|
|
9638
|
+
}
|
|
9117
9639
|
dumpDebugIfNeeded() {
|
|
9118
9640
|
if (!this.debugMode) return;
|
|
9119
9641
|
try {
|
|
@@ -9250,7 +9772,8 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
|
|
|
9250
9772
|
}
|
|
9251
9773
|
const cfg = this.config;
|
|
9252
9774
|
if (cfg["enableToolCreation"] === true) {
|
|
9253
|
-
|
|
9775
|
+
const sandboxMode = this.config.tools?.dynamicToolSandbox ?? "auto";
|
|
9776
|
+
this.toolCreator = new ToolCreator(this.router, this.toolRegistry, this.workspacePath, cfg["persistDynamicTools"] !== false, sandboxMode);
|
|
9254
9777
|
this.toolCreator.setLogger((m) => {
|
|
9255
9778
|
if (this.listenerCount("log") > 0) this.emit("log", { level: "info", message: m });
|
|
9256
9779
|
});
|
|
@@ -9488,6 +10011,10 @@ ${last.partialOutput}` : "");
|
|
|
9488
10011
|
this.router.profileModels(this.store).catch(() => {
|
|
9489
10012
|
});
|
|
9490
10013
|
}
|
|
10014
|
+
if (this.store) {
|
|
10015
|
+
this.router.probeLocalToolSupport(this.store).catch(() => {
|
|
10016
|
+
});
|
|
10017
|
+
}
|
|
9491
10018
|
if (this.config.cascadeAuto) {
|
|
9492
10019
|
this.router.refreshLiveData().catch(() => {
|
|
9493
10020
|
});
|
|
@@ -9542,6 +10069,21 @@ ${last.partialOutput}` : "");
|
|
|
9542
10069
|
const producesArtifact = /\b(?:create|build|implement|generate|write|refactor|rewrite|add|fix|deploy|install|migrate|scaffold|set up|save (?:a|the)|report|\.(?:pdf|md|txt|json|csv|py|js|ts|tsx|jsx|html|docx?))\b/i.test(p);
|
|
9543
10070
|
return inquiry && !producesArtifact;
|
|
9544
10071
|
}
|
|
10072
|
+
/**
|
|
10073
|
+
* Strong, explicit signals that a task needs the full hierarchy (planning +
|
|
10074
|
+
* multiple sections/workers). Deliberately conservative: requires a
|
|
10075
|
+
* build/implementation verb AND either an app/system-scale noun or an
|
|
10076
|
+
* explicit multi-part structure, so ordinary single-file asks (handled as
|
|
10077
|
+
* Simple/Moderate) don't get over-escalated.
|
|
10078
|
+
*/
|
|
10079
|
+
looksClearlyComplex(prompt) {
|
|
10080
|
+
const p = prompt.trim();
|
|
10081
|
+
if (p.length < 24) return false;
|
|
10082
|
+
const buildVerb = /\b(?:build|implement|create|develop|design|scaffold|refactor|migrate|architect|set up|integrate)\b/i.test(p);
|
|
10083
|
+
const scaleNoun = /\b(?:app(?:lication)?|system|platform|service|api|backend|frontend|full[- ]?stack|website|dashboard|pipeline|microservices?|database schema|authentication|end[- ]to[- ]end|codebase|project|multiple files|several (?:files|modules|components)|test suite)\b/i.test(p);
|
|
10084
|
+
const multiPart = /(?:\b(?:and|then|also|plus|as well as)\b.*\b(?:and|then|also)\b)|(?:^|\n)\s*(?:[-*]|\d+[.)])\s+/i.test(p);
|
|
10085
|
+
return buildVerb && (scaleNoun || multiPart);
|
|
10086
|
+
}
|
|
9545
10087
|
// Cache glob scan results per workspace path to avoid repeated I/O.
|
|
9546
10088
|
static globCache = /* @__PURE__ */ new Map();
|
|
9547
10089
|
async countWorkspaceFiles(workspacePath) {
|
|
@@ -9625,10 +10167,16 @@ ${prompt}` : prompt;
|
|
|
9625
10167
|
let verdict;
|
|
9626
10168
|
if (match) {
|
|
9627
10169
|
verdict = match[1] === "simple" ? "Simple" : match[1] === "moderate" ? "Moderate" : "Complex";
|
|
9628
|
-
|
|
10170
|
+
if (verdict !== "Complex" && this.looksClearlyComplex(prompt)) {
|
|
10171
|
+
this.recordDecision("complexity", `Complex \u2014 heuristic floor over classifier "${verdict}": explicit multi-step build/implementation signals (T1 engaged)`);
|
|
10172
|
+
verdict = "Complex";
|
|
10173
|
+
} else {
|
|
10174
|
+
this.recordDecision("complexity", `${verdict} \u2014 classifier: ${reason || "no reason given"}`);
|
|
10175
|
+
}
|
|
9629
10176
|
} else {
|
|
9630
|
-
|
|
9631
|
-
|
|
10177
|
+
const words = prompt.trim().split(/\s+/).length;
|
|
10178
|
+
verdict = words <= 12 ? "Simple" : words >= 40 || this.looksClearlyComplex(prompt) ? "Complex" : "Moderate";
|
|
10179
|
+
this.recordDecision("complexity", `${verdict} \u2014 classifier output unparseable; defaulted by length/signals`);
|
|
9632
10180
|
}
|
|
9633
10181
|
return verdict;
|
|
9634
10182
|
} catch {
|
|
@@ -9680,7 +10228,15 @@ ${prompt}` : prompt;
|
|
|
9680
10228
|
}
|
|
9681
10229
|
escalator.resolveUserDecision(req.id, approved, always);
|
|
9682
10230
|
});
|
|
9683
|
-
const
|
|
10231
|
+
const forceTier = this.config.routing?.forceTier;
|
|
10232
|
+
const forced = forceTier === "T1" ? "Complex" : forceTier === "T2" ? "Moderate" : forceTier === "T3" ? "Simple" : void 0;
|
|
10233
|
+
let complexity;
|
|
10234
|
+
if (forced) {
|
|
10235
|
+
complexity = forced;
|
|
10236
|
+
this.recordDecision("complexity", `${forced} \u2014 manually forced via routing.forceTier=${forceTier}`);
|
|
10237
|
+
} else {
|
|
10238
|
+
complexity = await this.determineComplexity(options.prompt, options.workspacePath || process.cwd(), options.conversationHistory);
|
|
10239
|
+
}
|
|
9684
10240
|
this.telemetry.capture("cascade:session_start", {
|
|
9685
10241
|
complexity,
|
|
9686
10242
|
providerCount: this.config.providers.length,
|
|
@@ -9760,6 +10316,7 @@ ${prompt}` : prompt;
|
|
|
9760
10316
|
try {
|
|
9761
10317
|
if (complexity === "Simple") {
|
|
9762
10318
|
const t3 = new T3Worker(this.router, this.toolRegistry, "root");
|
|
10319
|
+
t3.setPresenter(true);
|
|
9763
10320
|
t3.setHierarchyContext("You are the DIRECT worker for this task. There is no T1 Administrator or T2 Manager involved in this run.");
|
|
9764
10321
|
if (identityPrompt) {
|
|
9765
10322
|
t3.setSystemPromptOverride(identityPrompt);
|
|
@@ -9784,6 +10341,7 @@ ${prompt}` : prompt;
|
|
|
9784
10341
|
this.emit("tier:status", { tierId: "t3-root", status: "COMPLETED", role: "T3" });
|
|
9785
10342
|
} else if (complexity === "Moderate") {
|
|
9786
10343
|
const t2 = new T2Manager(this.router, this.toolRegistry, "root");
|
|
10344
|
+
t2.setPresenter(true);
|
|
9787
10345
|
t2.setHierarchyContext("You are the ROOT Manager for this task. There is no T1 Administrator involved in this run. You are responsible for decomposing the task and managing T3 workers directly.");
|
|
9788
10346
|
if (identityPrompt) {
|
|
9789
10347
|
t2.setSystemPromptOverride(identityPrompt);
|
|
@@ -9833,6 +10391,7 @@ ${prompt}` : prompt;
|
|
|
9833
10391
|
}
|
|
9834
10392
|
} else {
|
|
9835
10393
|
const t1 = new T1Administrator(this.router, this.toolRegistry, this.config);
|
|
10394
|
+
t1.setPresenter(true);
|
|
9836
10395
|
t1.setHierarchyContext("You are the top-level Administrator. You are responsible for the overall plan and supervising multiple T2 Managers.");
|
|
9837
10396
|
if (identityPrompt) {
|
|
9838
10397
|
t1.setSystemPromptOverride(identityPrompt);
|
|
@@ -10527,6 +11086,78 @@ Original error: ${err.message}`
|
|
|
10527
11086
|
`).all(`%${query}%`, limit);
|
|
10528
11087
|
return rows.map(this.deserializeMessage);
|
|
10529
11088
|
}
|
|
11089
|
+
// ── Export / Import ──────────────────────────
|
|
11090
|
+
//
|
|
11091
|
+
// Chats travel as full Session objects (messages included) inside a
|
|
11092
|
+
// plaintext-JSON bundle. Import NEVER overwrites: every imported session
|
|
11093
|
+
// gets a fresh id (title suffixed "(imported)") and fresh message ids, so
|
|
11094
|
+
// re-importing the same bundle can duplicate but can never clobber.
|
|
11095
|
+
/** Full sessions (with messages) for the given ids, or ALL sessions. */
|
|
11096
|
+
exportSessions(ids) {
|
|
11097
|
+
const targets = ids && ids.length > 0 ? ids : this.db.prepare("SELECT id FROM sessions ORDER BY updated_at DESC").all().map((r) => r.id);
|
|
11098
|
+
const out = [];
|
|
11099
|
+
for (const id of targets) {
|
|
11100
|
+
const s = this.getSession(id);
|
|
11101
|
+
if (s) out.push(s);
|
|
11102
|
+
}
|
|
11103
|
+
return out;
|
|
11104
|
+
}
|
|
11105
|
+
/**
|
|
11106
|
+
* Import sessions from a bundle. Returns the created `{id, title}` rows so
|
|
11107
|
+
* the caller can mirror them into the runtime session list (the sidebar
|
|
11108
|
+
* reads runtime_sessions, not sessions). Malformed entries are skipped.
|
|
11109
|
+
*/
|
|
11110
|
+
importSessions(sessions) {
|
|
11111
|
+
const out = [];
|
|
11112
|
+
const msgStmt = this.db.prepare(`
|
|
11113
|
+
INSERT INTO messages (id, session_id, role, content, timestamp, tokens, agent_messages)
|
|
11114
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
11115
|
+
`);
|
|
11116
|
+
for (const s of sessions) {
|
|
11117
|
+
if (!s || typeof s !== "object") continue;
|
|
11118
|
+
const newId = crypto3.randomUUID();
|
|
11119
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
11120
|
+
const title = `${String(s.title || "Untitled").slice(0, 200)} (imported)`;
|
|
11121
|
+
const metadata = s.metadata && typeof s.metadata === "object" ? s.metadata : { totalTokens: 0, totalCostUsd: 0, modelsUsed: [], toolsUsed: [], taskCount: 0 };
|
|
11122
|
+
this.db.prepare(`
|
|
11123
|
+
INSERT INTO sessions (id, title, created_at, updated_at, identity_id, workspace_path, metadata)
|
|
11124
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
11125
|
+
`).run(newId, title, s.createdAt ?? now, s.updatedAt ?? now, s.identityId ?? "default", s.workspacePath ?? "", JSON.stringify(metadata));
|
|
11126
|
+
for (const m of Array.isArray(s.messages) ? s.messages : []) {
|
|
11127
|
+
if (!m || m.role !== "user" && m.role !== "assistant" && m.role !== "system") continue;
|
|
11128
|
+
msgStmt.run(
|
|
11129
|
+
crypto3.randomUUID(),
|
|
11130
|
+
newId,
|
|
11131
|
+
m.role,
|
|
11132
|
+
typeof m.content === "string" ? m.content : JSON.stringify(m.content),
|
|
11133
|
+
m.timestamp ?? now,
|
|
11134
|
+
m.tokens ? JSON.stringify(m.tokens) : null,
|
|
11135
|
+
m.agentMessages ? JSON.stringify(m.agentMessages) : null
|
|
11136
|
+
);
|
|
11137
|
+
}
|
|
11138
|
+
out.push({ id: newId, title });
|
|
11139
|
+
}
|
|
11140
|
+
return out;
|
|
11141
|
+
}
|
|
11142
|
+
/** Import identities/personas, deduped by (case-insensitive) name — an
|
|
11143
|
+
* existing name is skipped, never replaced. Imports are never the default. */
|
|
11144
|
+
importIdentities(identities) {
|
|
11145
|
+
const existing = new Set(this.listIdentities().map((i) => i.name.toLowerCase()));
|
|
11146
|
+
let imported = 0;
|
|
11147
|
+
for (const ident of identities ?? []) {
|
|
11148
|
+
if (!ident?.name || typeof ident.name !== "string") continue;
|
|
11149
|
+
if (existing.has(ident.name.toLowerCase())) continue;
|
|
11150
|
+
this.createIdentity({
|
|
11151
|
+
...ident,
|
|
11152
|
+
id: crypto3.randomUUID(),
|
|
11153
|
+
isDefault: false,
|
|
11154
|
+
createdAt: ident.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
11155
|
+
});
|
|
11156
|
+
existing.add(ident.name.toLowerCase());
|
|
11157
|
+
imported++;
|
|
11158
|
+
}
|
|
11159
|
+
return imported;
|
|
11160
|
+
}
|
|
10530
11161
|
// ── Identities ────────────────────────────────
|
|
10531
11162
|
createIdentity(identity) {
|
|
10532
11163
|
this.db.prepare(`
|
|
@@ -10704,6 +11335,23 @@ Original error: ${err.message}`
|
|
|
10704
11335
|
const row = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(`${provider}:${modelId}`);
|
|
10705
11336
|
return row ? JSON.parse(row.metadata) : void 0;
|
|
10706
11337
|
}
|
|
11338
|
+
/**
|
|
11339
|
+
* Persist a probed capability verdict (currently: native tool support) into
|
|
11340
|
+
* the model's cached profile, merging with whatever is already recorded.
|
|
11341
|
+
* Read back via getModelProfile — so a model is probed once ever, not once
|
|
11342
|
+
* per run.
|
|
11343
|
+
*/
|
|
11344
|
+
saveModelCapability(modelId, provider, caps) {
|
|
11345
|
+
const cacheKey = `${provider}:${modelId}`;
|
|
11346
|
+
const existing = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(cacheKey);
|
|
11347
|
+
const meta = existing ? JSON.parse(existing.metadata) : { id: modelId, provider, name: modelId, contextWindow: 0, isVisionCapable: false, inputCostPer1kTokens: 0, outputCostPer1kTokens: 0, maxOutputTokens: 0, supportsStreaming: false, isLocal: false };
|
|
11348
|
+
if (caps.supportsToolUse !== void 0) meta.supportsToolUse = caps.supportsToolUse;
|
|
11349
|
+
this.db.prepare(`
|
|
11350
|
+
INSERT INTO model_cache (id, provider, model_id, name, metadata, updated_at)
|
|
11351
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
11352
|
+
ON CONFLICT(id) DO UPDATE SET metadata = excluded.metadata, updated_at = excluded.updated_at
|
|
11353
|
+
`).run(cacheKey, provider, modelId, meta.name ?? modelId, JSON.stringify(meta), (/* @__PURE__ */ new Date()).toISOString());
|
|
11354
|
+
}
|
|
10707
11355
|
getProfiledModelIds() {
|
|
10708
11356
|
const rows = this.db.prepare(
|
|
10709
11357
|
"SELECT model_id FROM model_cache WHERE json_extract(metadata, '$.specializations') IS NOT NULL"
|
|
@@ -11290,7 +11938,8 @@ var DashboardSocket = class {
|
|
|
11290
11938
|
socket.on("cascade:run", (payload) => {
|
|
11291
11939
|
if (typeof payload?.prompt === "string" && payload.prompt.trim()) {
|
|
11292
11940
|
const sessionId = typeof payload.sessionId === "string" && payload.sessionId.trim() ? payload.sessionId.trim() : void 0;
|
|
11293
|
-
|
|
11941
|
+
const forceTier = ["T1", "T2", "T3"].includes(payload.forceTier) ? payload.forceTier : void 0;
|
|
11942
|
+
callback(payload.prompt.trim(), payload.model ?? "auto", socket.id, sessionId, forceTier);
|
|
11294
11943
|
}
|
|
11295
11944
|
});
|
|
11296
11945
|
});
|
|
@@ -11317,6 +11966,13 @@ var DashboardServer = class {
|
|
|
11317
11966
|
* of runs the session performed in this server's lifetime.
|
|
11318
11967
|
*/
|
|
11319
11968
|
sessionTaskIds = /* @__PURE__ */ new Map();
|
|
11969
|
+
/**
|
|
11970
|
+
* Tool-approval requests awaiting a user decision from a connected client,
|
|
11971
|
+
* keyed by the request's uuid. The desktop shows a modal on
|
|
11972
|
+
* `permission:user-required` and answers with `permission:decision`; this
|
|
11973
|
+
* map is how that answer reaches the run that's blocked on it.
|
|
11974
|
+
*/
|
|
11975
|
+
pendingApprovals = /* @__PURE__ */ new Map();
|
|
11320
11976
|
port;
|
|
11321
11977
|
host;
|
|
11322
11978
|
workspacePath;
|
|
@@ -11375,17 +12031,18 @@ var DashboardServer = class {
|
|
|
11375
12031
|
}
|
|
11376
12032
|
this.persistConfig();
|
|
11377
12033
|
});
|
|
11378
|
-
this.socket.onCascadeRun(async (prompt, model, socketId, requestedSessionId) => {
|
|
12034
|
+
this.socket.onCascadeRun(async (prompt, model, socketId, requestedSessionId, forceTier) => {
|
|
11379
12035
|
const sessionId = requestedSessionId ?? crypto3.randomUUID();
|
|
11380
12036
|
const abortController = new AbortController();
|
|
11381
12037
|
this.activeControllers.set(sessionId, abortController);
|
|
11382
12038
|
const runPrompt = requestedSessionId ? this.buildContinuationPrompt(sessionId, prompt) : prompt;
|
|
11383
12039
|
const title = this.persistRunStart(sessionId, prompt);
|
|
11384
|
-
|
|
12040
|
+
let cfg = model !== "auto" ? { ...this.config, models: { ...this.config.models, t1: model } } : this.config;
|
|
12041
|
+
if (forceTier) cfg = { ...cfg, routing: { ...cfg.routing, forceTier } };
|
|
11385
12042
|
const cascade = new Cascade(cfg, this.workspacePath, this.store);
|
|
11386
12043
|
this.activeSessions.set(sessionId, cascade);
|
|
11387
12044
|
cascade.on("stream:token", (e) => {
|
|
11388
|
-
this.socket.emitToSocket(socketId, "stream:token", { sessionId, tierId: e.tierId, text: e.text });
|
|
12045
|
+
this.socket.emitToSocket(socketId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
|
|
11389
12046
|
});
|
|
11390
12047
|
cascade.on("tier:status", (e) => {
|
|
11391
12048
|
this.socket.emitToSocket(socketId, "tier:status", { sessionId, ...e });
|
|
@@ -11397,7 +12054,11 @@ var DashboardServer = class {
|
|
|
11397
12054
|
this.socket.emitPeerMessage(e);
|
|
11398
12055
|
});
|
|
11399
12056
|
try {
|
|
11400
|
-
const result = await cascade.run({
|
|
12057
|
+
const result = await cascade.run({
|
|
12058
|
+
prompt: runPrompt,
|
|
12059
|
+
signal: abortController.signal,
|
|
12060
|
+
approvalCallback: this.makeApprovalCallback(sessionId)
|
|
12061
|
+
});
|
|
11401
12062
|
this.recordSessionTask(sessionId, result.taskId);
|
|
11402
12063
|
this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
|
|
11403
12064
|
this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
|
|
@@ -11416,10 +12077,18 @@ var DashboardServer = class {
|
|
|
11416
12077
|
} finally {
|
|
11417
12078
|
this.activeSessions.delete(sessionId);
|
|
11418
12079
|
this.activeControllers.delete(sessionId);
|
|
12080
|
+
this.denyPendingApprovals(sessionId);
|
|
11419
12081
|
}
|
|
11420
12082
|
});
|
|
11421
12083
|
this.socket.onSessionHalt((sessionId) => {
|
|
11422
12084
|
this.activeControllers.get(sessionId)?.abort();
|
|
12085
|
+
this.denyPendingApprovals(sessionId);
|
|
12086
|
+
});
|
|
12087
|
+
this.socket.onApprovalResponse(({ requestId, approved, always }) => {
|
|
12088
|
+
const pending = this.pendingApprovals.get(requestId);
|
|
12089
|
+
if (!pending) return;
|
|
12090
|
+
this.pendingApprovals.delete(requestId);
|
|
12091
|
+
pending.resolve({ approved: !!approved, always: !!always });
|
|
11423
12092
|
});
|
|
11424
12093
|
this.socket.onSessionSteer((message, sessionId, nodeId) => {
|
|
11425
12094
|
const steered = this.steerSessions(message, sessionId, nodeId);
|
|
@@ -11587,6 +12256,29 @@ var DashboardServer = class {
|
|
|
11587
12256
|
list.push(taskId);
|
|
11588
12257
|
this.sessionTaskIds.set(sessionId, list);
|
|
11589
12258
|
}
|
|
12259
|
+
/**
|
|
12260
|
+
* Approval bridge: cascade calls this when a dangerous tool escalates to the
|
|
12261
|
+
* user. The request itself was already pushed to the client via the
|
|
12262
|
+
* `permission:user-required` forward; here we just park a resolver keyed by
|
|
12263
|
+
* the request id and wait for the client's `permission:decision` (handled in
|
|
12264
|
+
* onApprovalResponse). Never auto-approves — an unanswered request stays
|
|
12265
|
+
* pending until the client answers, the run ends, or the escalator's own
|
|
12266
|
+
* timeout denies it.
|
|
12267
|
+
*/
|
|
12268
|
+
makeApprovalCallback(sessionId) {
|
|
12269
|
+
return (request) => new Promise((resolve) => {
|
|
12270
|
+
this.pendingApprovals.set(request.id, { resolve, sessionId });
|
|
12271
|
+
});
|
|
12272
|
+
}
|
|
12273
|
+
/** Deny + clear any approvals still pending for a session (run end / abort). */
|
|
12274
|
+
denyPendingApprovals(sessionId) {
|
|
12275
|
+
for (const [id, pending] of this.pendingApprovals) {
|
|
12276
|
+
if (pending.sessionId === sessionId) {
|
|
12277
|
+
this.pendingApprovals.delete(id);
|
|
12278
|
+
pending.resolve({ approved: false, always: false });
|
|
12279
|
+
}
|
|
12280
|
+
}
|
|
12281
|
+
}
|
|
11590
12282
|
persistRuntimeRow(sessionId, title, status, latestPrompt) {
|
|
11591
12283
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
11592
12284
|
const row = { sessionId, title, workspacePath: this.workspacePath, status, startedAt: now, updatedAt: now, latestPrompt, isGlobal: false };
|
|
@@ -11775,15 +12467,6 @@ ${prompt}`;
|
|
|
11775
12467
|
if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:halt", payload);
|
|
11776
12468
|
res.json({ success: true, ...payload });
|
|
11777
12469
|
});
|
|
11778
|
-
this.app.post("/api/approve", auth, mutationLimiter, (req, res) => {
|
|
11779
|
-
const body = req.body;
|
|
11780
|
-
const sessionId = typeof body["sessionId"] === "string" ? body["sessionId"] : void 0;
|
|
11781
|
-
const nodeId = typeof body["nodeId"] === "string" ? body["nodeId"] : void 0;
|
|
11782
|
-
const payload = { sessionId, nodeId, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
11783
|
-
this.socket.broadcast("session:approve", payload);
|
|
11784
|
-
if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:approve", payload);
|
|
11785
|
-
res.json({ success: true, ...payload });
|
|
11786
|
-
});
|
|
11787
12470
|
this.app.post("/api/inject", auth, mutationLimiter, (req, res) => {
|
|
11788
12471
|
const body = req.body;
|
|
11789
12472
|
const message = typeof body["message"] === "string" ? body["message"] : void 0;
|
|
@@ -11828,6 +12511,60 @@ ${prompt}`;
|
|
|
11828
12511
|
this.socket.broadcast("runtime:refresh", { scope: "global" });
|
|
11829
12512
|
res.json({ ok: true });
|
|
11830
12513
|
});
|
|
12514
|
+
this.app.get("/api/export", auth, (req, res) => {
|
|
12515
|
+
try {
|
|
12516
|
+
const sessionsParam = typeof req.query["sessions"] === "string" ? req.query["sessions"] : "all";
|
|
12517
|
+
const includeMemories = req.query["memories"] === "1" || req.query["memories"] === "true";
|
|
12518
|
+
const ids = sessionsParam === "all" ? void 0 : sessionsParam.split(",").map((s) => s.trim()).filter(Boolean);
|
|
12519
|
+
const bundle = {
|
|
12520
|
+
format: "cascade-export@1",
|
|
12521
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
12522
|
+
sessions: this.store.exportSessions(ids)
|
|
12523
|
+
};
|
|
12524
|
+
if (includeMemories) {
|
|
12525
|
+
const ws = new WorldStateDB(this.workspacePath);
|
|
12526
|
+
try {
|
|
12527
|
+
bundle["memories"] = { ...ws.exportKnowledge(), identities: this.store.listIdentities() };
|
|
12528
|
+
} finally {
|
|
12529
|
+
ws.close();
|
|
12530
|
+
}
|
|
12531
|
+
}
|
|
12532
|
+
res.json(bundle);
|
|
12533
|
+
} catch (err) {
|
|
12534
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
12535
|
+
}
|
|
12536
|
+
});
|
|
12537
|
+
this.app.post("/api/import", auth, mutationLimiter, (req, res) => {
|
|
12538
|
+
try {
|
|
12539
|
+
const bundle = req.body;
|
|
12540
|
+
if (!bundle || bundle.format !== "cascade-export@1") {
|
|
12541
|
+
res.status(400).json({ error: "Not a cascade-export@1 bundle." });
|
|
12542
|
+
return;
|
|
12543
|
+
}
|
|
12544
|
+
const importedSessions = Array.isArray(bundle.sessions) ? this.store.importSessions(bundle.sessions) : [];
|
|
12545
|
+
for (const s of importedSessions) this.persistRuntimeRow(s.id, s.title, "COMPLETED");
|
|
12546
|
+
let facts = 0;
|
|
12547
|
+
let logEntries = 0;
|
|
12548
|
+
let identities = 0;
|
|
12549
|
+
if (bundle.memories) {
|
|
12550
|
+
const ws = new WorldStateDB(this.workspacePath);
|
|
12551
|
+
try {
|
|
12552
|
+
const counts = ws.importKnowledge(bundle.memories);
|
|
12553
|
+
facts = counts.facts;
|
|
12554
|
+
logEntries = counts.logEntries;
|
|
12555
|
+
} finally {
|
|
12556
|
+
ws.close();
|
|
12557
|
+
}
|
|
12558
|
+
if (Array.isArray(bundle.memories.identities)) {
|
|
12559
|
+
identities = this.store.importIdentities(bundle.memories.identities);
|
|
12560
|
+
}
|
|
12561
|
+
}
|
|
12562
|
+
this.socket.broadcast("runtime:refresh", { scope: "workspace" });
|
|
12563
|
+
res.json({ ok: true, imported: { sessions: importedSessions.length, facts, logEntries, identities } });
|
|
12564
|
+
} catch (err) {
|
|
12565
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
12566
|
+
}
|
|
12567
|
+
});
|
|
11831
12568
|
this.app.post("/api/sessions/:id/rollback", auth, mutationLimiter, async (req, res) => {
|
|
11832
12569
|
const sessionId = req.params.id;
|
|
11833
12570
|
const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
|
|
@@ -12034,7 +12771,7 @@ ${prompt}`;
|
|
|
12034
12771
|
const cascade = new Cascade(this.config, this.workspacePath, this.store);
|
|
12035
12772
|
this.activeSessions.set(sessionId, cascade);
|
|
12036
12773
|
cascade.on("stream:token", (e) => {
|
|
12037
|
-
this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text });
|
|
12774
|
+
this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
|
|
12038
12775
|
});
|
|
12039
12776
|
cascade.on("tier:status", (e) => {
|
|
12040
12777
|
this.socket.broadcastToRoom(`session:${sessionId}`, "tier:status", { sessionId, ...e });
|
|
@@ -12046,7 +12783,11 @@ ${prompt}`;
|
|
|
12046
12783
|
this.socket.emitPeerMessage(e);
|
|
12047
12784
|
});
|
|
12048
12785
|
try {
|
|
12049
|
-
const result = await cascade.run({
|
|
12786
|
+
const result = await cascade.run({
|
|
12787
|
+
prompt: runPrompt,
|
|
12788
|
+
identityId: body.identityId,
|
|
12789
|
+
approvalCallback: this.makeApprovalCallback(sessionId)
|
|
12790
|
+
});
|
|
12050
12791
|
this.recordSessionTask(sessionId, result.taskId);
|
|
12051
12792
|
this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
|
|
12052
12793
|
this.socket.broadcast("cost:update", {
|
|
@@ -12064,6 +12805,7 @@ ${prompt}`;
|
|
|
12064
12805
|
});
|
|
12065
12806
|
} finally {
|
|
12066
12807
|
this.activeSessions.delete(sessionId);
|
|
12808
|
+
this.denyPendingApprovals(sessionId);
|
|
12067
12809
|
}
|
|
12068
12810
|
})();
|
|
12069
12811
|
});
|