lynkr 9.4.6 → 9.6.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.
Files changed (35) hide show
  1. package/README.md +49 -15
  2. package/install.sh +21 -5
  3. package/package.json +4 -2
  4. package/public/dashboard.html +13 -1
  5. package/scripts/check-native.js +97 -0
  6. package/src/agents/decomposition/dispatcher.js +185 -0
  7. package/src/agents/decomposition/gate.js +136 -0
  8. package/src/agents/decomposition/index.js +183 -0
  9. package/src/agents/decomposition/model-call.js +75 -0
  10. package/src/agents/decomposition/planner.js +223 -0
  11. package/src/agents/decomposition/synthesizer.js +89 -0
  12. package/src/agents/decomposition/telemetry.js +55 -0
  13. package/src/clients/databricks.js +215 -4
  14. package/src/clients/openrouter-utils.js +19 -27
  15. package/src/clients/prompt-cache-injection.js +49 -0
  16. package/src/clients/responses-format.js +7 -0
  17. package/src/clients/tool-call-repair.js +130 -0
  18. package/src/config/index.js +32 -0
  19. package/src/context/caveman.js +94 -0
  20. package/src/context/output-format-guard.js +99 -0
  21. package/src/context/tool-dedup.js +95 -0
  22. package/src/context/tool-result-compressor.js +106 -0
  23. package/src/dashboard/api.js +69 -18
  24. package/src/orchestrator/bypass.js +135 -0
  25. package/src/orchestrator/index.js +33 -2
  26. package/src/routing/index.js +47 -0
  27. package/src/routing/model-registry.js +89 -26
  28. package/src/routing/risk-analyzer.js +7 -2
  29. package/src/routing/session-affinity.js +96 -0
  30. package/src/routing/telemetry.js +16 -3
  31. package/src/routing/tier-fallback.js +91 -0
  32. package/src/server.js +2 -0
  33. package/src/tools/decompose.js +91 -0
  34. package/src/tools/lazy-loader.js +8 -0
  35. package/.impeccable/live/config.json +0 -8
package/README.md CHANGED
@@ -545,6 +545,28 @@ TOOL_INJECTION_ENABLED=false
545
545
  CODE_MODE_ENABLED=true
546
546
  ```
547
547
 
548
+ Always-on (no config): **smart tool selection** (server mode), **RTK tool-result
549
+ compression** (test/git/grep/lint/build/JSON output), **MCP tool dedup** (drops
550
+ built-in WebSearch/WebFetch when an Exa/Tavily MCP tool is present), and
551
+ **request bypass** (Claude CLI Warmup / title-extraction calls are answered
552
+ locally, never hitting a provider).
553
+
554
+ Optional **terse-output mode** to cut *output* tokens:
555
+ ```bash
556
+ CAVEMAN_ENABLED=true # off by default — nudges the model to be concise
557
+ CAVEMAN_LEVEL=lite # lite | full | ultra
558
+ ```
559
+
560
+ ### Cost tracking & model pricing
561
+ Per-request cost is computed from a model-pricing registry (LiteLLM → models.dev,
562
+ cached 24h) and recorded in telemetry. Models the registry doesn't know record
563
+ `cost_usd=null` (logged once) rather than a fabricated price. Pin prices for
564
+ unknown models:
565
+ ```bash
566
+ # Per-1M-token USD prices, JSON keyed by model name
567
+ MODEL_PRICE_OVERRIDES={"my-model":{"input":0.5,"output":1.5}}
568
+ ```
569
+
548
570
  ### Memory System (Titans-inspired)
549
571
  ```bash
550
572
  MEMORY_ENABLED=true
@@ -609,11 +631,13 @@ npm install -g lynkr
609
631
  curl -fsSL https://raw.githubusercontent.com/Fast-Editor/Lynkr/main/install.sh | bash
610
632
  ```
611
633
 
612
- **Homebrew**
634
+ **Homebrew** (macOS / Linux)
613
635
  ```bash
614
636
  brew tap fast-editor/lynkr
615
637
  brew install lynkr
638
+ lynkr --version
616
639
  ```
640
+ Upgrade later with `brew update && brew upgrade lynkr`. The formula tracks the latest [`lynkr` npm release](https://www.npmjs.com/package/lynkr) automatically.
617
641
 
618
642
  **Docker**
619
643
  ```bash
@@ -652,35 +676,45 @@ npm start
652
676
 
653
677
  ## Benchmark Results
654
678
 
655
- Measured on real agentic coding workloads (Claude Code / Cursor sessions) with Ollama, Moonshot, and Azure OpenAI backends. Run with `node benchmark-tier-routing.js`.
679
+ Head-to-head against **LiteLLM** on the **same backends** (Ollama `minimax-m2.5`, Moonshot, Azure OpenAI), 9 scenarios across 4 feature categories. Apples-to-apples comparison is Lynkr vs LiteLLM **billed tokens on the same scenario**. Run with `node benchmark-tier-routing.js`.
656
680
 
657
- ### Token compression
681
+ > _Run: June 5, 2026 · Lynkr v9.3.2 · LiteLLM v1.87.1 · macOS, Apple Silicon._
658
682
 
659
- | Scenario | Tokens without Lynkr | Tokens with Lynkr | Reduction |
683
+ ### Token reduction (vs LiteLLM, same model & prompt)
684
+
685
+ | Mechanism | Lynkr | LiteLLM | Result |
660
686
  |---|---|---|---|
661
- | 14-tool request (read task) | 1,042 | **547** | **47%** |
662
- | 14-tool request (write task) | 1,043 | **412** | **60%** |
663
- | Large JSON grep result (60 items) | 3,458 | **427** | **87.6%** |
687
+ | Smart tool selection (14 tools) | **959** tokens · $0.0044 | 2,085 tokens · $0.0091 | **53% fewer tokens, 52% cheaper** |
688
+ | TOON compression (60-item grep JSON) | **427** tokens · $0.009 | 3,458 tokens · $0.018 | **87.6% fewer tokens, 50% cheaper** |
664
689
 
665
- Lynkr strips irrelevant tool schemas before forwarding (smart tool selection) and binary-compresses large JSON tool results (TOON) — both happen in-process with no added latency.
690
+ Lynkr strips irrelevant tool schemas (smart tool selection) and binary-compresses large JSON tool results (TOON) — both in-process, no added latency.
666
691
 
667
692
  ### Semantic cache
668
693
 
669
694
  | | Tokens billed | Response time |
670
695
  |---|---|---|
671
696
  | First call (cold) | 2,857 | 1,891ms |
672
- | **Second call — paraphrased, cache hit** | **0** | **171ms** |
697
+ | **Second call — paraphrased, cache hit** | **0** (served from cache) | **171ms (11× faster)** |
673
698
 
674
- Near-identical prompts return cached responses in 171ms. Zero tokens billed on a cache hit.
699
+ Near-identical prompts return cached responses in 171ms. Zero model tokens billed on a cache hit.
675
700
 
676
701
  ### Tier routing
677
702
 
678
- | Request | Routed to |
679
- |---|---|
680
- | "What does git stash do?" | SIMPLE local model (free) |
681
- | JWT vs cookies security analysis | COMPLEX cloud model (correct) |
703
+ | Request | Lynkr routes to | LiteLLM routes to |
704
+ |---|---|---|
705
+ | "What does git stash do?" | `minimax-m2.5` (local, free) | Ollama (local) |
706
+ | JWT vs cookies security analysis | `moonshot` (cloud correct) | **Ollama (local — wrong call)** |
707
+
708
+ Lynkr scores each request on 15 dimensions (token count, code complexity, reasoning markers, risk signals, agentic patterns) and escalates automatically. LiteLLM's `cost-based-routing` sends everything to the cheapest model regardless of complexity.
709
+
710
+ ### Cost projection (100,000 requests/month, same backend)
711
+
712
+ | | Monthly cost | vs LiteLLM |
713
+ |---|---|---|
714
+ | LiteLLM | ~$818 | baseline |
715
+ | **Lynkr** | **~$409** | **~50% cheaper** |
682
716
 
683
- Lynkr scores each request on 15 dimensions (token count, code complexity, reasoning markers, risk signals, agentic patterns) and routes automatically. No caller changes needed.
717
+ _Based on a tool-heavy agentic session (TOON scenario). On equal footing same provider, same model Lynkr is cheaper due to token optimization._
684
718
 
685
719
  → [Full benchmark report with methodology](BENCHMARK_REPORT.md)
686
720
 
package/install.sh CHANGED
@@ -108,8 +108,24 @@ clone_or_update() {
108
108
  install_dependencies() {
109
109
  print_info "Installing dependencies..."
110
110
  cd "$INSTALL_DIR"
111
- npm install --production
111
+ # --omit=dev keeps optionalDependencies (better-sqlite3, hnswlib-node,
112
+ # tree-sitter) which back telemetry, the memory store and routing ML.
113
+ # The postinstall hook (scripts/check-native.js) verifies the native ABI
114
+ # and rebuilds if Node was upgraded — best-effort, never fails the install.
115
+ npm install --omit=dev
112
116
  print_success "Dependencies installed"
117
+
118
+ # Native optional modules need a C/C++ toolchain only if no prebuilt binary
119
+ # is available for this platform. They degrade gracefully if absent.
120
+ if ! node -e "const D=require('better-sqlite3'); new D(':memory:').close()" >/dev/null 2>&1; then
121
+ print_warning "Native module 'better-sqlite3' is not loadable."
122
+ echo " Telemetry, the memory store and sessions need it. To enable:"
123
+ echo " - Ensure a build toolchain is present (Xcode CLT on macOS, build-essential + python3 on Linux), then:"
124
+ echo " - ${BLUE}cd $INSTALL_DIR && npm run rebuild-native${NC}"
125
+ echo " Lynkr still runs without it (those features stay disabled)."
126
+ else
127
+ print_success "Native modules OK (telemetry, memory, sessions enabled)"
128
+ fi
113
129
  }
114
130
 
115
131
  # Create default .env file
@@ -131,7 +147,7 @@ create_env_file() {
131
147
  MODEL_PROVIDER=ollama
132
148
 
133
149
  # Server Configuration
134
- PORT=8080
150
+ PORT=8081
135
151
 
136
152
  # Ollama Configuration (default for local development)
137
153
  OLLAMA_MODEL=qwen2.5-coder:7b
@@ -161,7 +177,7 @@ EOF
161
177
  print_info "📝 Configuration ready! Key settings:"
162
178
  echo " • Default provider: Ollama (local, offline)"
163
179
  echo " • Memory system: Enabled (learns from conversations)"
164
- echo " • Port: 8080"
180
+ echo " • Port: 8081"
165
181
  echo ""
166
182
  print_warning "To use cloud providers (Databricks/OpenAI/Azure):"
167
183
  echo " Edit: ${BLUE}nano $INSTALL_DIR/.env${NC}"
@@ -220,7 +236,7 @@ print_next_steps() {
220
236
  echo " ${BLUE}lynkr${NC}"
221
237
  echo ""
222
238
  echo " 3. Configure Claude Code CLI:"
223
- echo " ${BLUE}export ANTHROPIC_BASE_URL=http://localhost:8080${NC}"
239
+ echo " ${BLUE}export ANTHROPIC_BASE_URL=http://localhost:8081${NC}"
224
240
  echo " ${BLUE}claude${NC}"
225
241
  echo ""
226
242
  echo " ${YELLOW}Option B: Use Cloud Providers (Databricks/OpenAI/Azure)${NC}"
@@ -238,7 +254,7 @@ print_next_steps() {
238
254
  echo " ${BLUE}lynkr${NC}"
239
255
  echo ""
240
256
  echo " 3. Configure Claude Code CLI:"
241
- echo " ${BLUE}export ANTHROPIC_BASE_URL=http://localhost:8080${NC}"
257
+ echo " ${BLUE}export ANTHROPIC_BASE_URL=http://localhost:8081${NC}"
242
258
  echo " ${BLUE}export ANTHROPIC_API_KEY=any-non-empty-value${NC} ${GREEN}← Placeholder${NC}"
243
259
  echo " ${BLUE}claude${NC}"
244
260
  echo ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "9.4.6",
3
+ "version": "9.6.0",
4
4
  "description": "Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -8,13 +8,15 @@
8
8
  "lynkr-setup": "scripts/setup.js"
9
9
  },
10
10
  "scripts": {
11
+ "postinstall": "node scripts/check-native.js",
12
+ "rebuild-native": "node scripts/check-native.js",
11
13
  "prestart": "node -e \"if(process.env.HEADROOM_ENABLED==='true'&&process.env.HEADROOM_DOCKER_ENABLED!=='false'){process.exit(0)}else{process.exit(1)}\" && docker compose --profile headroom up -d --build headroom 2>/dev/null || echo 'Headroom skipped (disabled or Docker not running)'",
12
14
  "start": "node index.js 2>&1 | npx pino-pretty --sync",
13
15
  "stop": "node -e \"if(process.env.HEADROOM_ENABLED==='true'&&process.env.HEADROOM_DOCKER_ENABLED!=='false'){process.exit(0)}else{process.exit(1)}\" && docker compose --profile headroom down || echo 'Headroom skipped (disabled or Docker not running)'",
14
16
  "dev": "nodemon index.js",
15
17
  "lint": "eslint src index.js",
16
18
  "test": "npm run test:unit && npm run test:performance",
17
- "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js test/hybrid-routing-integration.test.js test/web-tools.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/distill.test.js test/large-payload.test.js test/code-mode.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js",
19
+ "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js test/hybrid-routing-integration.test.js test/web-tools.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/distill.test.js test/large-payload.test.js test/code-mode.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/model-registry-cost.test.js test/task-decomposition.test.js test/output-format-guard.test.js test/tier-fallback.test.js",
18
20
  "test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js",
19
21
  "test:new-features": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js",
20
22
  "test:performance": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js",
@@ -244,6 +244,7 @@ const App = {
244
244
  const t = d.today;
245
245
  const s = d.stats;
246
246
 
247
+ const tierLabel = t => t === 'default' ? 'default' : String(t).toLowerCase();
247
248
  const providerCards = d.providers.length === 0
248
249
  ? `<p class="text-slate-500 text-sm">No providers configured</p>`
249
250
  : d.providers.map(p => `
@@ -251,10 +252,21 @@ const App = {
251
252
  <div class="flex items-center gap-2">
252
253
  <span class="status-dot ${providerDot(p.type)}"></span>
253
254
  <span class="text-sm font-medium text-slate-200">${p.name}</span>
255
+ ${(p.tiers || []).map(t => `<span class="badge bg-slate-600/60 text-slate-300">${tierLabel(t)}</span>`).join('')}
254
256
  </div>
255
257
  <span class="text-xs ${p.type === 'local' ? 'text-green-400' : 'text-blue-400'}">${p.type}</span>
256
258
  </div>`).join('');
257
259
 
260
+ const providerWarnings = (d.providerWarnings || []).map(w => `
261
+ <div class="flex items-center justify-between bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-3">
262
+ <div class="flex items-center gap-2">
263
+ <span class="text-amber-400 text-sm">⚠</span>
264
+ <span class="text-sm font-medium text-amber-200">${w.name}</span>
265
+ ${(w.tiers || []).map(t => `<span class="badge bg-amber-500/20 text-amber-300">${tierLabel(t)}</span>`).join('')}
266
+ </div>
267
+ <span class="text-xs text-amber-400">no credentials</span>
268
+ </div>`).join('');
269
+
258
270
  const recentRows = (d.recentRequests || []).map(r => `
259
271
  <tr class="table-row border-b border-slate-700/50">
260
272
  <td class="py-2 px-3 text-xs text-slate-500">${fmt.ago(r.timestamp)}</td>
@@ -279,7 +291,7 @@ const App = {
279
291
  <!-- Providers -->
280
292
  ${card(`
281
293
  <h3 class="text-sm font-semibold text-slate-300 mb-3">Configured Providers</h3>
282
- <div class="flex flex-col gap-2">${providerCards}</div>
294
+ <div class="flex flex-col gap-2">${providerCards}${providerWarnings}</div>
283
295
  `)}
284
296
 
285
297
  <!-- 24h Stats -->
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Native module ABI guard (postinstall).
4
+ *
5
+ * better-sqlite3 (and the other native optionalDependencies) are compiled
6
+ * against a specific Node ABI. When Node is upgraded, the prebuilt/compiled
7
+ * binary stops loading with:
8
+ *
9
+ * "was compiled against a different Node.js version using
10
+ * NODE_MODULE_VERSION 115. This version of Node.js requires
11
+ * NODE_MODULE_VERSION 141."
12
+ *
13
+ * The failure is silent at runtime — telemetry, request logs, and the memory
14
+ * store all sit behind try/catch and simply go empty. This probe detects the
15
+ * mismatch and rebuilds the native modules so it self-heals on `npm install`.
16
+ *
17
+ * It is intentionally best-effort: it NEVER exits non-zero, so it can't break
18
+ * `npm install` on machines without a build toolchain (the modules are
19
+ * optional and the app degrades gracefully without them).
20
+ */
21
+
22
+ const { execSync } = require("child_process");
23
+
24
+ // Native optionalDependencies that are ABI-sensitive. If Node changed, all of
25
+ // them are stale, so we rebuild the set in one pass.
26
+ const NATIVE_DEPS = [
27
+ "better-sqlite3",
28
+ "hnswlib-node",
29
+ "tree-sitter",
30
+ "tree-sitter-javascript",
31
+ "tree-sitter-python",
32
+ "tree-sitter-typescript",
33
+ ];
34
+
35
+ function log(msg) {
36
+ console.log(`[check-native] ${msg}`);
37
+ }
38
+
39
+ /**
40
+ * Probe better-sqlite3 — the canary. `require()` alone is not enough: the
41
+ * native addon only loads when a Database is instantiated.
42
+ * @returns {"ok"|"absent"|"mismatch"}
43
+ */
44
+ function probe() {
45
+ let Database;
46
+ try {
47
+ Database = require("better-sqlite3");
48
+ } catch (err) {
49
+ if (err && err.code === "MODULE_NOT_FOUND") return "absent";
50
+ return "mismatch";
51
+ }
52
+ try {
53
+ const db = new Database(":memory:");
54
+ db.close();
55
+ return "ok";
56
+ } catch (err) {
57
+ if (/NODE_MODULE_VERSION|different Node\.js version|invalid ELF|dlopen|\.node/i.test(err.message || "")) {
58
+ return "mismatch";
59
+ }
60
+ // Some other instantiation error — not an ABI issue we can fix by rebuild.
61
+ return "ok";
62
+ }
63
+ }
64
+
65
+ function main() {
66
+ const status = probe();
67
+
68
+ if (status === "absent") {
69
+ // Optional dependency not installed (e.g. build skipped). Nothing to do.
70
+ return;
71
+ }
72
+ if (status === "ok") {
73
+ return;
74
+ }
75
+
76
+ log("native module ABI mismatch detected (Node was likely upgraded). Rebuilding native modules…");
77
+ try {
78
+ execSync(`npm rebuild ${NATIVE_DEPS.join(" ")}`, { stdio: "inherit" });
79
+ } catch {
80
+ log("rebuild did not complete (a build toolchain may be missing). Continuing — native features will be disabled until you run: npm rebuild better-sqlite3");
81
+ return;
82
+ }
83
+
84
+ // Re-probe to report the outcome.
85
+ if (probe() === "ok") {
86
+ log("native modules rebuilt successfully.");
87
+ } else {
88
+ log("native modules still not loadable after rebuild. Run `npm rebuild better-sqlite3` manually.");
89
+ }
90
+ }
91
+
92
+ try {
93
+ main();
94
+ } catch (err) {
95
+ // Never fail the install.
96
+ log(`skipped (${err.message})`);
97
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Subtask dispatcher (Phase 3).
3
+ *
4
+ * Executes a validated plan respecting its dependency DAG:
5
+ * - subtasks are grouped into topological "levels" (Kahn's algorithm)
6
+ * - subtasks in the same level have no dependency on each other → run in
7
+ * parallel via the existing ParallelCoordinator (spawnParallel)
8
+ * - a subtask receives ONLY its own prompt plus the compressed results of the
9
+ * subtasks it depends on (context isolation — the token win)
10
+ *
11
+ * The spawn functions are injectable for testing.
12
+ */
13
+
14
+ const logger = require("../../logger");
15
+
16
+ // Cap how much of a dependency's result we forward, to preserve the
17
+ // context-isolation savings (subagents already return summaries; this bounds
18
+ // pathological cases).
19
+ const MAX_CONTEXT_CHARS = 2000;
20
+
21
+ /**
22
+ * Group subtasks into dependency levels. Returns array of arrays of ids.
23
+ * Throws if the graph is unresolvable (should not happen — planner validated).
24
+ */
25
+ function topologicalLevels(subtasks) {
26
+ const byId = new Map(subtasks.map((s) => [s.id, s]));
27
+ const indegree = new Map(subtasks.map((s) => [s.id, 0]));
28
+ const dependents = new Map(subtasks.map((s) => [s.id, []]));
29
+
30
+ for (const s of subtasks) {
31
+ for (const dep of s.dependsOn) {
32
+ if (!byId.has(dep)) continue;
33
+ indegree.set(s.id, indegree.get(s.id) + 1);
34
+ dependents.get(dep).push(s.id);
35
+ }
36
+ }
37
+
38
+ const levels = [];
39
+ let frontier = subtasks.filter((s) => indegree.get(s.id) === 0).map((s) => s.id);
40
+ const resolved = new Set();
41
+
42
+ while (frontier.length > 0) {
43
+ levels.push(frontier);
44
+ const next = [];
45
+ for (const id of frontier) {
46
+ resolved.add(id);
47
+ for (const child of dependents.get(id)) {
48
+ indegree.set(child, indegree.get(child) - 1);
49
+ if (indegree.get(child) === 0) next.push(child);
50
+ }
51
+ }
52
+ frontier = next;
53
+ }
54
+
55
+ if (resolved.size !== subtasks.length) {
56
+ throw new Error("Unresolvable subtask graph (cycle or dangling dependency)");
57
+ }
58
+ return levels;
59
+ }
60
+
61
+ function compressResult(text) {
62
+ if (typeof text !== "string") return String(text ?? "");
63
+ if (text.length <= MAX_CONTEXT_CHARS) return text;
64
+ return text.slice(0, MAX_CONTEXT_CHARS) + "\n…[truncated]";
65
+ }
66
+
67
+ function buildContextForSubtask(subtask, resultsById) {
68
+ if (!subtask.dependsOn || subtask.dependsOn.length === 0) return null;
69
+ const parts = [];
70
+ for (const dep of subtask.dependsOn) {
71
+ const r = resultsById.get(dep);
72
+ if (r && r.success && r.result) {
73
+ parts.push(`Result of subtask ${dep}:\n${compressResult(r.result)}`);
74
+ } else if (r) {
75
+ parts.push(`Subtask ${dep} did not complete successfully.`);
76
+ }
77
+ }
78
+ return parts.length > 0 ? parts.join("\n\n") : null;
79
+ }
80
+
81
+ /**
82
+ * Dispatch a validated plan.
83
+ * @param {Object} plan - { subtasks: [...] }
84
+ * @param {Object} [options]
85
+ * @param {string} [options.sessionId]
86
+ * @param {string} [options.cwd]
87
+ * @param {Function} [options.spawnParallel] - (agentTypes[], prompts[], opts) => results[]
88
+ * @returns {Promise<{results: Array, levels: Array, stats: Object}>}
89
+ */
90
+ async function dispatchPlan(plan, options = {}) {
91
+ const spawnParallel = options.spawnParallel || require("../index").spawnParallel;
92
+ const subtasks = plan.subtasks;
93
+ const byId = new Map(subtasks.map((s) => [s.id, s]));
94
+ const levels = topologicalLevels(subtasks);
95
+ const resultsById = new Map();
96
+
97
+ let totalInputTokens = 0;
98
+ let totalOutputTokens = 0;
99
+ let totalSubagents = 0;
100
+
101
+ for (let li = 0; li < levels.length; li++) {
102
+ const levelIds = levels[li];
103
+ const levelSubtasks = levelIds.map((id) => byId.get(id));
104
+
105
+ const agentTypes = levelSubtasks.map((s) => s.agentType);
106
+ const prompts = levelSubtasks.map((s) => s.prompt);
107
+ const perTaskContext = levelSubtasks.map((s) => buildContextForSubtask(s, resultsById));
108
+
109
+ logger.info(
110
+ { level: li, count: levelIds.length, ids: levelIds },
111
+ "[Decomposition] Dispatching subtask level"
112
+ );
113
+
114
+ // spawnParallel shares one options object; pass per-task context by spawning
115
+ // the level as individual parallel calls when contexts differ.
116
+ const levelResults = await runLevel(
117
+ spawnParallel,
118
+ agentTypes,
119
+ prompts,
120
+ perTaskContext,
121
+ options
122
+ );
123
+
124
+ levelResults.forEach((res, idx) => {
125
+ const st = levelSubtasks[idx];
126
+ totalSubagents += 1;
127
+ totalInputTokens += res?.stats?.inputTokens || 0;
128
+ totalOutputTokens += res?.stats?.outputTokens || 0;
129
+ resultsById.set(st.id, {
130
+ id: st.id,
131
+ agentType: st.agentType,
132
+ success: !!res?.success,
133
+ result: res?.success ? res.result : null,
134
+ error: res?.success ? null : res?.error || "unknown error",
135
+ stats: res?.stats || {},
136
+ });
137
+ });
138
+ }
139
+
140
+ const results = subtasks.map((s) => resultsById.get(s.id));
141
+ return {
142
+ results,
143
+ levels,
144
+ stats: {
145
+ subagents: totalSubagents,
146
+ inputTokens: totalInputTokens,
147
+ outputTokens: totalOutputTokens,
148
+ },
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Run one level. When subtasks in the level have differing injected contexts we
154
+ * spawn them as separate parallel calls (each with its own mainContext), then
155
+ * await all. When none need context, a single spawnParallel batch is used.
156
+ */
157
+ async function runLevel(spawnParallel, agentTypes, prompts, perTaskContext, options) {
158
+ const anyContext = perTaskContext.some((c) => c);
159
+
160
+ if (!anyContext) {
161
+ return spawnParallel(agentTypes, prompts, {
162
+ sessionId: options.sessionId,
163
+ cwd: options.cwd,
164
+ });
165
+ }
166
+
167
+ // Mixed/with-context: one spawnParallel call per subtask so each gets its own
168
+ // mainContext, executed concurrently.
169
+ const calls = agentTypes.map((type, i) =>
170
+ spawnParallel([type], [prompts[i]], {
171
+ sessionId: options.sessionId,
172
+ cwd: options.cwd,
173
+ mainContext: perTaskContext[i] ? { relevant_context: perTaskContext[i] } : undefined,
174
+ }).then((arr) => arr[0])
175
+ );
176
+ return Promise.all(calls);
177
+ }
178
+
179
+ module.exports = {
180
+ dispatchPlan,
181
+ topologicalLevels,
182
+ buildContextForSubtask,
183
+ compressResult,
184
+ MAX_CONTEXT_CHARS,
185
+ };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Decomposition gate (Phase 1).
3
+ *
4
+ * Decides whether breaking a task into isolated-context subtasks is actually
5
+ * worth it. This is the make-or-break of the feature: naive "decompose
6
+ * everything" loses money, because every subagent carries fixed overhead
7
+ * (planning + per-agent handoff/summarisation). Decomposition only pays off
8
+ * when the task is (a) genuinely complex, (b) large enough to amortise that
9
+ * overhead, and (c) divisible into reasonably independent units.
10
+ *
11
+ * Pure and synchronous so it can be unit-tested without a model. The caller
12
+ * supplies a pre-computed complexity `analysis` (from routing/complexity-analyzer)
13
+ * and the raw payload.
14
+ */
15
+
16
+ const DEFAULTS = {
17
+ minComplexity: 60, // 0-100; only decompose genuinely complex work
18
+ minTokens: 3000, // estimated monolithic tokens; below this the overhead wins
19
+ minIndependentUnits: 2, // need at least 2 separable pieces to bother
20
+ maxSubtasks: 6,
21
+ };
22
+
23
+ const ENUMERATION_RE = /^\s*(?:[-*+]|\d+[.)]|step\s+\d+\b)/gim;
24
+ const CONJUNCTION_RE = /\b(?:and then|then|also|additionally|as well as|after that|finally|next,)\b/gi;
25
+ const IMPERATIVE_RE = /\b(?:add|create|build|implement|write|refactor|update|fix|remove|delete|migrate|test|document|configure|set up|wire|integrate|generate)\b/gi;
26
+ const FILE_PATH_RE = /\b[\w./-]+\.(?:js|ts|tsx|jsx|py|go|rs|java|rb|c|cpp|h|json|yaml|yml|md|sql|sh|css|html)\b/gi;
27
+
28
+ function _uniqueMatches(text, re) {
29
+ const set = new Set();
30
+ const matches = text.match(re) || [];
31
+ for (const m of matches) set.add(m.toLowerCase().trim());
32
+ return set;
33
+ }
34
+
35
+ /**
36
+ * Heuristically estimate how many independent units a task contains.
37
+ * Conservative: takes the strongest of several weak signals rather than summing
38
+ * them, so a single rambling sentence doesn't look like five subtasks.
39
+ * @param {string} text
40
+ * @returns {number}
41
+ */
42
+ function estimateIndependentUnits(text) {
43
+ if (!text || typeof text !== "string") return 1;
44
+
45
+ const enumerated = (text.match(ENUMERATION_RE) || []).length;
46
+ const conjunctions = (text.match(CONJUNCTION_RE) || []).length;
47
+ const imperatives = _uniqueMatches(text, IMPERATIVE_RE).size;
48
+ const files = _uniqueMatches(text, FILE_PATH_RE).size;
49
+
50
+ // Each signal is an independent lower-bound estimate of separable units.
51
+ const signals = [
52
+ enumerated, // explicit list items
53
+ conjunctions + 1, // "do A and then B" → 2 units
54
+ imperatives, // distinct action verbs
55
+ files, // distinct files usually map to distinct work
56
+ ];
57
+
58
+ const estimate = Math.max(...signals, 1);
59
+ return estimate;
60
+ }
61
+
62
+ /**
63
+ * Decide whether to decompose.
64
+ * @param {Object} analysis - result of analyzeComplexity(payload)
65
+ * @param {Object} payload - the request payload
66
+ * @param {Object} [options]
67
+ * @param {Object} [options.config] - threshold overrides (see DEFAULTS)
68
+ * @param {string} [options.riskLevel] - 'low'|'medium'|'high'; 'high' disables decomposition
69
+ * @param {string} [options.taskText] - explicit task text (else derived from analysis/payload)
70
+ * @returns {{ decompose: boolean, reason: string, signals: Object }}
71
+ */
72
+ function shouldDecompose(analysis, payload = {}, options = {}) {
73
+ const cfg = { ...DEFAULTS, ...(options.config || {}) };
74
+
75
+ const score = Number(analysis?.score ?? 0);
76
+ const estimatedTokens = Number(
77
+ analysis?.breakdown?.tokens?.estimated ?? options.estimatedTokens ?? 0
78
+ );
79
+
80
+ const taskText =
81
+ options.taskText ||
82
+ analysis?.content ||
83
+ _firstUserText(payload) ||
84
+ "";
85
+
86
+ const independentUnits = estimateIndependentUnits(taskText);
87
+
88
+ const signals = {
89
+ score,
90
+ estimatedTokens,
91
+ independentUnits,
92
+ riskLevel: options.riskLevel || "low",
93
+ thresholds: cfg,
94
+ };
95
+
96
+ // Never decompose high-risk work — keep it in one capable context where the
97
+ // exempt-from-laziness concerns (validation/security) stay coherent.
98
+ if (options.riskLevel === "high") {
99
+ return { decompose: false, reason: "high_risk_skip", signals };
100
+ }
101
+
102
+ if (score < cfg.minComplexity) {
103
+ return { decompose: false, reason: "below_complexity_threshold", signals };
104
+ }
105
+
106
+ if (estimatedTokens < cfg.minTokens) {
107
+ return { decompose: false, reason: "too_small_to_amortise_overhead", signals };
108
+ }
109
+
110
+ if (independentUnits < cfg.minIndependentUnits) {
111
+ return { decompose: false, reason: "not_divisible", signals };
112
+ }
113
+
114
+ return { decompose: true, reason: "decompose_worthwhile", signals };
115
+ }
116
+
117
+ function _firstUserText(payload) {
118
+ const messages = payload?.messages;
119
+ if (!Array.isArray(messages)) return "";
120
+ const user = [...messages].reverse().find((m) => m.role === "user");
121
+ if (!user) return "";
122
+ if (typeof user.content === "string") return user.content;
123
+ if (Array.isArray(user.content)) {
124
+ return user.content
125
+ .filter((b) => b?.type === "text" || typeof b?.text === "string")
126
+ .map((b) => b.text || "")
127
+ .join("\n");
128
+ }
129
+ return "";
130
+ }
131
+
132
+ module.exports = {
133
+ shouldDecompose,
134
+ estimateIndependentUnits,
135
+ DEFAULTS,
136
+ };