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.
- package/README.md +49 -15
- package/install.sh +21 -5
- package/package.json +4 -2
- package/public/dashboard.html +13 -1
- package/scripts/check-native.js +97 -0
- package/src/agents/decomposition/dispatcher.js +185 -0
- package/src/agents/decomposition/gate.js +136 -0
- package/src/agents/decomposition/index.js +183 -0
- package/src/agents/decomposition/model-call.js +75 -0
- package/src/agents/decomposition/planner.js +223 -0
- package/src/agents/decomposition/synthesizer.js +89 -0
- package/src/agents/decomposition/telemetry.js +55 -0
- package/src/clients/databricks.js +215 -4
- package/src/clients/openrouter-utils.js +19 -27
- package/src/clients/prompt-cache-injection.js +49 -0
- package/src/clients/responses-format.js +7 -0
- package/src/clients/tool-call-repair.js +130 -0
- package/src/config/index.js +32 -0
- package/src/context/caveman.js +94 -0
- package/src/context/output-format-guard.js +99 -0
- package/src/context/tool-dedup.js +95 -0
- package/src/context/tool-result-compressor.js +106 -0
- package/src/dashboard/api.js +69 -18
- package/src/orchestrator/bypass.js +135 -0
- package/src/orchestrator/index.js +33 -2
- package/src/routing/index.js +47 -0
- package/src/routing/model-registry.js +89 -26
- package/src/routing/risk-analyzer.js +7 -2
- package/src/routing/session-affinity.js +96 -0
- package/src/routing/telemetry.js +16 -3
- package/src/routing/tier-fallback.js +91 -0
- package/src/server.js +2 -0
- package/src/tools/decompose.js +91 -0
- package/src/tools/lazy-loader.js +8 -0
- 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
|
-
|
|
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
|
-
|
|
681
|
+
> _Run: June 5, 2026 · Lynkr v9.3.2 · LiteLLM v1.87.1 · macOS, Apple Silicon._
|
|
658
682
|
|
|
659
|
-
|
|
683
|
+
### Token reduction (vs LiteLLM, same model & prompt)
|
|
684
|
+
|
|
685
|
+
| Mechanism | Lynkr | LiteLLM | Result |
|
|
660
686
|
|---|---|---|---|
|
|
661
|
-
|
|
|
662
|
-
|
|
|
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
|
|
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 |
|
|
679
|
-
|
|
680
|
-
| "What does git stash do?" |
|
|
681
|
-
| JWT vs cookies security analysis |
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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",
|
package/public/dashboard.html
CHANGED
|
@@ -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
|
+
};
|