lynkr 9.14.1 → 9.14.3

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 (96) hide show
  1. package/README.md +5 -1
  2. package/bin/cli.js +14 -6
  3. package/bin/lynkr-desktop-token.js +108 -0
  4. package/bin/lynkr-init.js +13 -1
  5. package/bin/lynkr-restart.js +125 -0
  6. package/bin/opencode-setup.js +175 -0
  7. package/bin/run.js +92 -0
  8. package/config/model-capabilities.json +20 -0
  9. package/config/model-capability-seeds.json +75 -0
  10. package/config/model-tiers.json +13 -1
  11. package/package.json +16 -22
  12. package/scripts/claude-desktop.js +212 -0
  13. package/scripts/ope-report.js +78 -0
  14. package/scripts/seed-capabilities.js +230 -0
  15. package/src/api/claude-desktop-gateway.js +123 -0
  16. package/src/api/health.js +0 -1
  17. package/src/api/mcp-broker.js +149 -0
  18. package/src/api/middleware/budget-enforcer.js +77 -7
  19. package/src/api/middleware/budget.js +36 -2
  20. package/src/api/middleware/session.js +0 -0
  21. package/src/api/openai-router.js +210 -15
  22. package/src/api/providers-handler.js +16 -1
  23. package/src/api/router.js +73 -106
  24. package/src/auth-mode.js +40 -4
  25. package/src/budget/index.js +86 -0
  26. package/src/cache/embeddings.js +125 -35
  27. package/src/cache/onnx-embedder.js +92 -0
  28. package/src/cache/prompt.js +0 -7
  29. package/src/clients/bedrock-utils.js +2 -1
  30. package/src/clients/codex-process.js +1 -1
  31. package/src/clients/codex-utils.js +0 -2
  32. package/src/clients/databricks.js +337 -46
  33. package/src/clients/gpt-utils.js +84 -8
  34. package/src/clients/health-probe.js +157 -0
  35. package/src/clients/openai-format.js +73 -23
  36. package/src/clients/provider-capabilities.js +27 -2
  37. package/src/clients/resilience.js +0 -1
  38. package/src/clients/responses-format.js +0 -56
  39. package/src/clients/routing.js +1 -1
  40. package/src/config/index.js +13 -2
  41. package/src/context/budget.js +1 -1
  42. package/src/context/compression.js +127 -19
  43. package/src/context/tool-result-compressor.js +3 -5
  44. package/src/dashboard/api.js +1 -1
  45. package/src/logger/oversized-error-stream.js +1 -1
  46. package/src/memory/extractor.js +3 -3
  47. package/src/memory/search.js +2 -2
  48. package/src/memory/store.js +9 -24
  49. package/src/memory/tools.js +14 -0
  50. package/src/observability/metrics.js +52 -3
  51. package/src/observability/otel.js +180 -0
  52. package/src/orchestrator/azure-responses-sse.js +223 -0
  53. package/src/orchestrator/index.js +216 -212
  54. package/src/orchestrator/passthrough-stream.js +7 -1
  55. package/src/orchestrator/sse-transformer.js +130 -13
  56. package/src/policy/safe-commands.js +0 -1
  57. package/src/routing/bandit.js +39 -0
  58. package/src/routing/calibration.js +21 -1
  59. package/src/routing/capabilities.js +88 -0
  60. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  61. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  62. package/src/routing/capability-seeds/family.js +105 -0
  63. package/src/routing/capability-seeds/models-dev.js +64 -0
  64. package/src/routing/capability-seeds/normalize.js +160 -0
  65. package/src/routing/capability-seeds/registry.js +125 -0
  66. package/src/routing/capability-seeds/sources.js +69 -0
  67. package/src/routing/capability-seeds/swebench.js +66 -0
  68. package/src/routing/client-profiles.js +26 -0
  69. package/src/routing/complexity-analyzer.js +5 -8
  70. package/src/routing/confidence-scorer.js +2 -2
  71. package/src/routing/cost-optimizer.js +1 -2
  72. package/src/routing/decide.js +149 -0
  73. package/src/routing/deescalator.js +38 -1
  74. package/src/routing/embedding-cache.js +0 -1
  75. package/src/routing/index.js +202 -86
  76. package/src/routing/intent-score.js +0 -1
  77. package/src/routing/knn-router.js +27 -5
  78. package/src/routing/latency-tracker.js +1 -1
  79. package/src/routing/model-registry.js +25 -0
  80. package/src/routing/model-slots.js +65 -0
  81. package/src/routing/model-tiers.js +2 -0
  82. package/src/routing/ope.js +255 -0
  83. package/src/routing/openai-model-slots.js +82 -0
  84. package/src/routing/regret-estimator.js +1 -1
  85. package/src/routing/shortfall.js +290 -0
  86. package/src/routing/stuck-detector.js +127 -0
  87. package/src/routing/telemetry.js +11 -2
  88. package/src/server.js +41 -3
  89. package/src/stores/file-store.js +5 -1
  90. package/src/tools/web-search-exec.js +182 -0
  91. package/src/training/trajectory-compressor.js +1 -1
  92. package/src/workers/pool.js +1 -1
  93. package/src/workers/worker.js +1 -1
  94. package/scripts/convert-routellm.py +0 -105
  95. package/src/diff/comments.js +0 -153
  96. package/src/policy/web-fallback.js +0 -33
package/README.md CHANGED
@@ -45,7 +45,7 @@ lynkr wrap claude
45
45
  - ✅ Sticky sessions: one routing decision per conversation via content fingerprinting, with automatic escalation when the task outgrows the model
46
46
  - ✅ TOON/RTK compression (84% token reduction on large JSON tool outputs)
47
47
  - ✅ Semantic caching (sub-300ms cache hits, 0 tokens billed)
48
- - ✅ **3-5x more usage from the same subscription limits**
48
+ - ✅ **Your subscription is used only where it counts** — simple requests route to your own local/API models, so subscription capacity goes to the work that needs it
49
49
  - ✅ Works with OAuth (Claude, Copilot, Cursor) or API keys (Aider, Codex)
50
50
 
51
51
  [Full wrap guide →](docs/wrap-guide.md)
@@ -221,6 +221,7 @@ Claude Code / Cursor / Codex / Cline / Continue
221
221
  | **Z.ai** | Cloud | GLM-4.7, GLM-4.5-Air | $ |
222
222
  | **Moonshot AI** | Cloud | Kimi K2.6, Kimi K3 | $ |
223
223
  | **Baidu Qianfan** | Cloud | ERNIE 4.5 Turbo, ERNIE X1.1 | $ (unverified — not yet probed against a live key) |
224
+ | **Fireworks AI** | Cloud | Kimi K2, GLM-5, DeepSeek V3 (serverless) | $ (unverified — not yet probed against a live key) |
224
225
 
225
226
  **4 local providers** for 100% offline, free usage. **14+ cloud providers** for scale.
226
227
 
@@ -617,6 +618,9 @@ With tier routing + token optimization: **additional 50-87% savings** on cloud p
617
618
  - [NPM Package](https://www.npmjs.com/package/lynkr) — Official releases
618
619
  - [DeepWiki](https://deepwiki.com/Fast-Editor/Lynkr) — AI-powered docs
619
620
 
621
+ This project follows our [Code of Conduct](CODE_OF_CONDUCT.md) — it applies in
622
+ all project spaces, and it tells you exactly how to report a problem.
623
+
620
624
  ---
621
625
 
622
626
  ## License
package/bin/cli.js CHANGED
@@ -6,12 +6,15 @@ const pkg = require('../package.json');
6
6
  // Subcommands. Dispatched before server boot so `lynkr usage` / `lynkr trajectory`
7
7
  // don't start the proxy. Add new subcommands here, not in scattered binaries.
8
8
  const SUBCOMMANDS = {
9
- usage: path.join(__dirname, "lynkr-usage.js"),
10
- stats: path.join(__dirname, "lynkr-usage.js"),
11
- trajectory: path.join(__dirname, "lynkr-trajectory.js"),
12
- wrap: path.join(__dirname, "wrap.js"),
13
- init: path.join(__dirname, "lynkr-init.js"),
14
- reset: path.join(__dirname, "lynkr-reset.js"),
9
+ usage: path.join(__dirname, "lynkr-usage.js"),
10
+ stats: path.join(__dirname, "lynkr-usage.js"),
11
+ trajectory: path.join(__dirname, "lynkr-trajectory.js"),
12
+ wrap: path.join(__dirname, "wrap.js"),
13
+ init: path.join(__dirname, "lynkr-init.js"),
14
+ reset: path.join(__dirname, "lynkr-reset.js"),
15
+ "desktop-token": path.join(__dirname, "lynkr-desktop-token.js"),
16
+ restart: path.join(__dirname, "lynkr-restart.js"),
17
+ run: path.join(__dirname, "run.js"),
15
18
  };
16
19
 
17
20
  const sub = process.argv[2];
@@ -51,6 +54,11 @@ Usage:
51
54
  lynkr start [options] Alias for the above
52
55
  lynkr init [options] Interactive setup wizard (writes .env, pulls classifier model)
53
56
  lynkr wrap <target> [options] Wrap CLI tools through Lynkr proxy
57
+ lynkr desktop-token <token> Apply a Claude Pro/Max OAuth token to Claude Desktop's
58
+ gateway profile (macOS only)
59
+ lynkr desktop-token --restore Restore Claude Desktop to talking to Anthropic directly
60
+ (undoes the above; macOS only)
61
+ lynkr restart Restart the local Lynkr server (pick up code/config changes)
54
62
  lynkr usage [options] Show AI spend report and tier-routing savings
55
63
  lynkr stats [options] Shareable savings-receipt card (also: lynkr usage --card)
56
64
  lynkr trajectory [options] Export agent trajectories as JSONL training data
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `lynkr desktop-token <token>` / `lynkr desktop-token --restore`
4
+ *
5
+ * Apply a freshly-minted Claude Pro/Max OAuth token to Claude Desktop's
6
+ * Lynkr gateway profile — or restore Desktop back to talking to Anthropic
7
+ * directly.
8
+ *
9
+ * This does NOT mint or refresh the token — that stays a manual, one-time
10
+ * `claude setup-token` (interactive browser approval) every time it expires.
11
+ * It does NOT restart Lynkr, either: the token lives entirely in Desktop's
12
+ * own config file (read at Desktop's launch, not Lynkr's), and Lynkr just
13
+ * forwards whatever bearer shows up on each request — it has no token state
14
+ * of its own to refresh. Restarting only matters when Lynkr's own code
15
+ * changed; use `lynkr restart` for that, separately.
16
+ *
17
+ * `lynkr desktop-token <token>`:
18
+ * 1. Installs the token into the Desktop "3p" gateway profile
19
+ * (delegates to scripts/claude-desktop.js --install)
20
+ * 2. Prints --status so you can see the profile actually took
21
+ *
22
+ * `lynkr desktop-token --restore`:
23
+ * Delegates to scripts/claude-desktop.js --restore — puts Desktop's
24
+ * deployment mode and applied-profile bookkeeping back to what they were
25
+ * before Lynkr's gateway profile was installed, and removes the profile
26
+ * + backup files. Safe to run even if nothing was ever installed (a
27
+ * no-op restore, not an error).
28
+ *
29
+ * Neither variant touches .env TIER_* values, and neither quits or
30
+ * relaunches Claude Desktop itself (that would close your open chat
31
+ * windows) — both just print the command to do so when you're ready.
32
+ *
33
+ * Usage:
34
+ * lynkr desktop-token sk-ant-oat01-...
35
+ * lynkr desktop-token --restore
36
+ */
37
+
38
+ const path = require("path");
39
+ const fs = require("fs");
40
+ const { execFileSync } = require("child_process");
41
+
42
+ const ROOT = path.join(__dirname, "..");
43
+ const ENV_PATH = path.join(ROOT, ".env");
44
+ const CLAUDE_DESKTOP_JS = path.join(ROOT, "scripts", "claude-desktop.js");
45
+
46
+ function fail(msg) {
47
+ console.error(msg);
48
+ process.exit(1);
49
+ }
50
+
51
+ function readPort() {
52
+ try {
53
+ const env = fs.readFileSync(ENV_PATH, "utf8");
54
+ const m = env.match(/^PORT=(\d+)/m);
55
+ if (m) return Number(m[1]);
56
+ } catch (err) {
57
+ console.error(`Couldn't read PORT from ${ENV_PATH} (${err.message}) — defaulting to 8081.`);
58
+ }
59
+ return 8081;
60
+ }
61
+
62
+ function main() {
63
+ const token = process.argv[2];
64
+ if (!token || token === "-h" || token === "--help") {
65
+ fail("Usage: lynkr desktop-token <sk-ant-oat-token> | --restore");
66
+ }
67
+ if (token === "--restore") {
68
+ if (process.platform !== "darwin") {
69
+ fail("Claude Desktop profile management is only supported on macOS.");
70
+ }
71
+ console.log("Restoring Claude Desktop to the usual Claude profile...");
72
+ execFileSync("node", [CLAUDE_DESKTOP_JS, "--restore"], { stdio: "inherit" });
73
+ console.log(
74
+ "\nWhen you're ready (this would close your open chats if done automatically):" +
75
+ "\n killall Claude && open -a Claude"
76
+ );
77
+ return;
78
+ }
79
+ if (!token.startsWith("sk-ant-oat")) {
80
+ fail(
81
+ `That doesn't look like a Claude Code OAuth access token (expected it to start ` +
82
+ `with "sk-ant-oat"). Got: ${token.slice(0, 12)}...\n` +
83
+ `Mint one with: claude setup-token`
84
+ );
85
+ }
86
+ if (process.platform !== "darwin") {
87
+ fail("Claude Desktop profile management is only supported on macOS.");
88
+ }
89
+
90
+ const url = `http://127.0.0.1:${readPort()}`;
91
+
92
+ console.log("Step 1/2 — installing token into Claude Desktop's Lynkr gateway profile...");
93
+ execFileSync("node", [CLAUDE_DESKTOP_JS, "--install", "--url", url, "--key", token], {
94
+ stdio: "inherit",
95
+ });
96
+
97
+ console.log("\nStep 2/2 — status:");
98
+ execFileSync("node", [CLAUDE_DESKTOP_JS, "--status"], { stdio: "inherit" });
99
+
100
+ console.log(
101
+ "\nNext (manual, on purpose — this would close your open chats if done for you):" +
102
+ "\n killall Claude && open -a Claude" +
103
+ "\n\nWhen Anthropic calls start 401ing: claude setup-token, then: lynkr desktop-token <token>" +
104
+ "\nIf you've also changed Lynkr's own code, that's a separate step: lynkr restart"
105
+ );
106
+ }
107
+
108
+ main();
package/bin/lynkr-init.js CHANGED
@@ -179,12 +179,21 @@ const PROVIDERS = {
179
179
  extras: [],
180
180
  defaultModel: 'ernie-4.5-turbo-128k',
181
181
  },
182
+ fireworks: {
183
+ label: 'Fireworks AI (serverless open models)',
184
+ local: false,
185
+ creds: [
186
+ { key: 'FIREWORKS_API_KEY', label: 'Fireworks API key (fw-...)', secret: true },
187
+ ],
188
+ extras: [],
189
+ defaultModel: 'accounts/fireworks/models/kimi-k2-instruct-0905',
190
+ },
182
191
  };
183
192
 
184
193
  const PROVIDER_ORDER = [
185
194
  'ollama', 'llamacpp', 'lmstudio',
186
195
  'azure-anthropic', 'azure-openai', 'openai', 'atlas', 'openrouter', 'edenai',
187
- 'databricks', 'bedrock', 'vertex', 'zai', 'moonshot', 'baidu',
196
+ 'databricks', 'bedrock', 'vertex', 'zai', 'moonshot', 'baidu', 'fireworks',
188
197
  ];
189
198
  const TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
190
199
 
@@ -342,6 +351,9 @@ const BASELINE_ENV = {
342
351
  BAIDU_API_KEY: '',
343
352
  BAIDU_ENDPOINT: 'https://qianfan.baidubce.com/v2/chat/completions',
344
353
  BAIDU_MODEL: 'ernie-4.5-turbo-128k',
354
+ FIREWORKS_API_KEY: '',
355
+ FIREWORKS_ENDPOINT: 'https://api.fireworks.ai/inference/v1/chat/completions',
356
+ FIREWORKS_MODEL: 'accounts/fireworks/models/kimi-k2-instruct-0905',
345
357
  LLAMACPP_ENDPOINT: 'http://localhost:8080',
346
358
  LLAMACPP_MODEL: 'default',
347
359
  LLAMACPP_TIMEOUT_MS: '120000',
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `lynkr restart`
4
+ *
5
+ * Stop whatever's listening on Lynkr's configured port and start a fresh
6
+ * `npm start`, detached so it survives this command exiting. Use this after
7
+ * changing Lynkr's own code (routing, config, etc.) — it has nothing to do
8
+ * with Claude Desktop's token or profile, which Desktop reads from disk at
9
+ * its own launch time, independent of whether Lynkr's process restarts.
10
+ * See `lynkr desktop-token` for that.
11
+ *
12
+ * Usage:
13
+ * lynkr restart
14
+ */
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const http = require("http");
19
+ const { execFileSync, spawn } = require("child_process");
20
+
21
+ const ROOT = path.join(__dirname, "..");
22
+ const ENV_PATH = path.join(ROOT, ".env");
23
+ const LOG_PATH = path.join(ROOT, "data", "logs", "lynkr-desktop.log");
24
+
25
+ function fail(msg) {
26
+ console.error(msg);
27
+ process.exit(1);
28
+ }
29
+
30
+ function readPort() {
31
+ try {
32
+ const env = fs.readFileSync(ENV_PATH, "utf8");
33
+ const m = env.match(/^PORT=(\d+)/m);
34
+ if (m) return Number(m[1]);
35
+ } catch (err) {
36
+ console.error(`Couldn't read PORT from ${ENV_PATH} (${err.message}) — defaulting to 8081.`);
37
+ }
38
+ return 8081;
39
+ }
40
+
41
+ function findListeningPid(port) {
42
+ try {
43
+ const out = execFileSync("lsof", ["-iTCP:" + port, "-sTCP:LISTEN", "-t"], {
44
+ encoding: "utf8",
45
+ stdio: ["pipe", "pipe", "pipe"],
46
+ }).trim();
47
+ return out ? out.split("\n").map((s) => s.trim()).filter(Boolean) : [];
48
+ } catch {
49
+ return []; // lsof exits non-zero when nothing matches
50
+ }
51
+ }
52
+
53
+ function healthCheck(port) {
54
+ return new Promise((resolve) => {
55
+ const req = http.get({ host: "127.0.0.1", port, path: "/health", timeout: 1500 }, (res) => {
56
+ let body = "";
57
+ res.on("data", (d) => (body += d));
58
+ res.on("end", () => {
59
+ try {
60
+ resolve(res.statusCode === 200 && JSON.parse(body).status === "ok");
61
+ } catch {
62
+ resolve(false);
63
+ }
64
+ });
65
+ });
66
+ req.on("error", () => resolve(false));
67
+ req.on("timeout", () => {
68
+ req.destroy();
69
+ resolve(false);
70
+ });
71
+ });
72
+ }
73
+
74
+ async function waitForHealth(port, timeoutMs) {
75
+ const deadline = Date.now() + timeoutMs;
76
+ while (Date.now() < deadline) {
77
+ if (await healthCheck(port)) return true;
78
+ await new Promise((r) => setTimeout(r, 500));
79
+ }
80
+ return false;
81
+ }
82
+
83
+ async function main() {
84
+ if (process.argv[2] === "-h" || process.argv[2] === "--help") {
85
+ console.log("Usage: lynkr restart");
86
+ process.exit(0);
87
+ }
88
+
89
+ const port = readPort();
90
+ const pids = findListeningPid(port);
91
+ if (pids.length) {
92
+ console.log(`Stopping existing Lynkr process (pid ${pids.join(", ")}) on port ${port}...`);
93
+ for (const pid of pids) {
94
+ try {
95
+ process.kill(Number(pid), "SIGTERM");
96
+ } catch (err) {
97
+ console.warn(` couldn't signal pid ${pid}: ${err.message}`);
98
+ }
99
+ }
100
+ const freedByDeadline = Date.now() + 5000;
101
+ while (Date.now() < freedByDeadline && findListeningPid(port).length) {
102
+ await new Promise((r) => setTimeout(r, 250));
103
+ }
104
+ } else {
105
+ console.log(`Nothing currently listening on port ${port}.`);
106
+ }
107
+
108
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
109
+ const logFd = fs.openSync(LOG_PATH, "a");
110
+ console.log(`Starting Lynkr (npm start), logging to ${LOG_PATH}...`);
111
+ const child = spawn("npm", ["start"], {
112
+ cwd: ROOT,
113
+ stdio: ["ignore", logFd, logFd],
114
+ detached: true,
115
+ });
116
+ child.unref();
117
+
118
+ const up = await waitForHealth(port, 15000);
119
+ if (!up) {
120
+ fail(`Lynkr didn't answer /health on port ${port} within 15s.\nCheck ${LOG_PATH} for errors.`);
121
+ }
122
+ console.log(`Lynkr is up on http://127.0.0.1:${port}.`);
123
+ }
124
+
125
+ main().catch((err) => fail(err.stack || String(err)));
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lynkr opencode — write the Lynkr provider into opencode's config with
4
+ * HONEST per-tier context windows.
5
+ *
6
+ * The problem this solves: opencode's compaction triggers off ONE static
7
+ * `limit.context` per model entry, but Lynkr's tier routing serves models
8
+ * with different real windows (e.g. 128k / 200k / 1M). One blended number is
9
+ * wrong for most requests: too big and opencode never compacts in time (the
10
+ * conversation outgrows the served model and Lynkr's server-side compressor
11
+ * has to amputate it mechanically); too small and big-window models are
12
+ * wasted.
13
+ *
14
+ * The fix: one opencode model entry per tier, each carrying its tier's REAL
15
+ * configured-model window (read live from this Lynkr install's TIER_* config
16
+ * and model registry), plus a "lynkr-auto" entry floored at the MINIMUM
17
+ * across tiers (safe for whatever routing picks). The per-tier ids
18
+ * (lynkr-simple/medium/complex/reasoning) pin their tier server-side — see
19
+ * src/routing/model-slots.js VIRTUAL_TIER_IDS — so the window each entry
20
+ * advertises is the window that actually serves it. Picking a tier in
21
+ * opencode's model picker doubles as tier pinning (documentation/
22
+ * tier-pinning.md), and compaction budgets stay correct per model.
23
+ *
24
+ * Usage:
25
+ * lynkr opencode # merge into ~/.config/opencode/opencode.json
26
+ * lynkr opencode --path ./opencode.json # project-scoped config instead
27
+ * lynkr opencode --base-url http://host:8081
28
+ * lynkr opencode --dry-run # print the block, write nothing
29
+ *
30
+ * Merging is non-destructive: only the `provider.lynkr` block is replaced;
31
+ * every other key in an existing opencode.json is preserved verbatim.
32
+ *
33
+ * @module bin/opencode-setup
34
+ */
35
+
36
+ const fs = require("fs");
37
+ const os = require("os");
38
+ const path = require("path");
39
+
40
+ const TIERS = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
41
+
42
+ /**
43
+ * Read each configured tier's model and its real context window.
44
+ * @returns {{ tiers: Object<string, {provider: string, model: string, contextWindow: number|null}>, minWindow: number|null }}
45
+ */
46
+ function readTierWindows() {
47
+ const config = require("../src/config");
48
+ const { contextWindowFor } = require("../src/routing/model-registry");
49
+ const tiers = {};
50
+ let minWindow = null;
51
+ for (const tier of TIERS) {
52
+ const raw = config.modelTiers?.[tier];
53
+ if (!raw) continue;
54
+ const [provider, ...rest] = raw.split(":");
55
+ const model = rest.join(":") || null;
56
+ const contextWindow = model ? contextWindowFor(model) : null;
57
+ tiers[tier] = { provider, model, contextWindow };
58
+ if (contextWindow && (!minWindow || contextWindow < minWindow)) {
59
+ minWindow = contextWindow;
60
+ }
61
+ }
62
+ return { tiers, minWindow };
63
+ }
64
+
65
+ // Conservative floor when no tier window is resolvable (fresh install,
66
+ // unpriced local models): matches the orchestrator's TOKEN_BUDGET_FALLBACK
67
+ // rationale — never advertise bigger than what's known safe.
68
+ const FALLBACK_WINDOW = 128000;
69
+
70
+ /**
71
+ * Build the `provider.lynkr` block for opencode.json. Pure — exported for
72
+ * tests.
73
+ *
74
+ * @param {{ tiers: Object, minWindow: number|null }} tierWindows
75
+ * @param {{ baseURL: string, apiKey: string }} options
76
+ * @returns {object} provider block
77
+ */
78
+ function buildLynkrProvider(tierWindows, { baseURL, apiKey }) {
79
+ const models = {
80
+ "lynkr-auto": {
81
+ name: "Lynkr Auto (tier routing)",
82
+ limit: { context: tierWindows.minWindow || FALLBACK_WINDOW },
83
+ },
84
+ };
85
+ for (const tier of TIERS) {
86
+ const entry = tierWindows.tiers[tier];
87
+ if (!entry?.model) continue;
88
+ models[`lynkr-${tier.toLowerCase()}`] = {
89
+ name: `Lynkr ${tier} (${entry.model})`,
90
+ limit: { context: entry.contextWindow || tierWindows.minWindow || FALLBACK_WINDOW },
91
+ };
92
+ }
93
+ return {
94
+ npm: "@ai-sdk/anthropic",
95
+ name: "Lynkr",
96
+ options: {
97
+ baseURL: `${baseURL.replace(/\/+$/, "")}/v1`,
98
+ apiKey,
99
+ },
100
+ models,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Merge the Lynkr provider into an existing opencode config object without
106
+ * touching anything else. Pure — exported for tests.
107
+ */
108
+ function mergeOpencodeConfig(existing, lynkrProvider) {
109
+ const merged = { ...(existing || {}) };
110
+ merged.provider = { ...(merged.provider || {}), lynkr: lynkrProvider };
111
+ return merged;
112
+ }
113
+
114
+ function defaultConfigPath() {
115
+ return path.join(os.homedir(), ".config", "opencode", "opencode.json");
116
+ }
117
+
118
+ /**
119
+ * Perform the setup. Exported so `lynkr run opencode` (bin/run.js) can
120
+ * configure-then-launch. Returns { dryRun } so the caller knows whether to
121
+ * proceed to launching.
122
+ * @param {string[]} args - CLI-style flag list
123
+ */
124
+ function runSetup(args) {
125
+ const argValue = (flag) => {
126
+ const i = args.indexOf(flag);
127
+ return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
128
+ };
129
+ const configPath = argValue("--path") || defaultConfigPath();
130
+ const baseURL = argValue("--base-url")
131
+ || `http://localhost:${process.env.PORT || 8081}`;
132
+ const apiKey = process.env.LYNKR_API_KEY || "lynkr-local";
133
+ const dryRun = args.includes("--dry-run");
134
+
135
+ const tierWindows = readTierWindows();
136
+ const provider = buildLynkrProvider(tierWindows, { baseURL, apiKey });
137
+
138
+ console.log("Lynkr → opencode provider block (per-tier context windows):\n");
139
+ for (const [id, m] of Object.entries(provider.models)) {
140
+ console.log(` ${id.padEnd(16)} limit.context=${String(m.limit.context).padStart(8)} ${m.name}`);
141
+ }
142
+ const unresolved = TIERS.filter(
143
+ (t) => tierWindows.tiers[t]?.model && !tierWindows.tiers[t]?.contextWindow
144
+ );
145
+ if (unresolved.length > 0) {
146
+ console.warn(`\n⚠ No registry context window for tier model(s): ${unresolved
147
+ .map((t) => `${t}=${tierWindows.tiers[t].model}`)
148
+ .join(", ")} — floored conservatively. Add MODEL_PRICE_OVERRIDES entries (with a context field) to fix.`);
149
+ }
150
+
151
+ if (dryRun) {
152
+ console.log(`\n--dry-run: would merge into ${configPath}`);
153
+ console.log(JSON.stringify({ provider: { lynkr: provider } }, null, 2));
154
+ return { dryRun: true };
155
+ }
156
+
157
+ let existing = {};
158
+ if (fs.existsSync(configPath)) {
159
+ try {
160
+ existing = JSON.parse(fs.readFileSync(configPath, "utf8"));
161
+ } catch (err) {
162
+ console.error(`\nRefusing to overwrite ${configPath}: it exists but isn't valid JSON (${err.message}).`);
163
+ console.error("Fix or move it, then re-run.");
164
+ process.exit(1);
165
+ }
166
+ }
167
+
168
+ const merged = mergeOpencodeConfig(existing, provider);
169
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
170
+ fs.writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
171
+ console.log(`\n✓ Merged provider.lynkr into ${configPath} (other keys preserved).`);
172
+ return { dryRun: false };
173
+ }
174
+
175
+ module.exports = { buildLynkrProvider, mergeOpencodeConfig, readTierWindows, runSetup, FALLBACK_WINDOW };
package/bin/run.js ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lynkr run <tool> — ensure the tool's Lynkr provider config is current,
4
+ * then launch the tool.
5
+ *
6
+ * Differs from `lynkr wrap` on purpose: wrap intercepts a tool's traffic via
7
+ * environment overrides at launch; `run` writes the tool's own native
8
+ * provider config (so the tool works with Lynkr even when started normally
9
+ * later) and then execs it. Config refresh on every run keeps the per-tier
10
+ * context windows in sync with TIER_* changes.
11
+ *
12
+ * Usage:
13
+ * lynkr run opencode # refresh config, launch opencode
14
+ * lynkr run opencode --dry-run # preview config, don't write or launch
15
+ * lynkr run opencode --path ./opencode.json --base-url http://host:8081
16
+ * lynkr run opencode -- --help # everything after -- goes to opencode
17
+ *
18
+ * @module bin/run
19
+ */
20
+
21
+ const { spawn } = require("child_process");
22
+
23
+ // Flags consumed by the setup step (with whether they take a value).
24
+ const SETUP_FLAGS = new Map([
25
+ ["--dry-run", false],
26
+ ["--path", true],
27
+ ["--base-url", true],
28
+ ]);
29
+
30
+ function splitArgs(args) {
31
+ const setupArgs = [];
32
+ const toolArgs = [];
33
+ let passthrough = false;
34
+ for (let i = 0; i < args.length; i++) {
35
+ const arg = args[i];
36
+ if (passthrough) {
37
+ toolArgs.push(arg);
38
+ continue;
39
+ }
40
+ if (arg === "--") {
41
+ passthrough = true;
42
+ continue;
43
+ }
44
+ if (SETUP_FLAGS.has(arg)) {
45
+ setupArgs.push(arg);
46
+ if (SETUP_FLAGS.get(arg) && i + 1 < args.length) setupArgs.push(args[++i]);
47
+ continue;
48
+ }
49
+ toolArgs.push(arg);
50
+ }
51
+ return { setupArgs, toolArgs };
52
+ }
53
+
54
+ function runOpencode(args) {
55
+ const { runSetup } = require("./opencode-setup");
56
+ const { setupArgs, toolArgs } = splitArgs(args);
57
+ const { dryRun } = runSetup(setupArgs) || {};
58
+ if (dryRun) return;
59
+
60
+ const child = spawn("opencode", toolArgs, { stdio: "inherit" });
61
+ child.on("error", (err) => {
62
+ if (err.code === "ENOENT") {
63
+ console.error("\nConfig written, but the `opencode` binary was not found on PATH.");
64
+ console.error("Install it (https://opencode.ai), then run `opencode` — the Lynkr provider is already configured.");
65
+ process.exit(127);
66
+ }
67
+ console.error(`Failed to launch opencode: ${err.message}`);
68
+ process.exit(1);
69
+ });
70
+ child.on("exit", (code, signal) => {
71
+ process.exit(signal ? 1 : (code ?? 0));
72
+ });
73
+ }
74
+
75
+ const TOOLS = { opencode: runOpencode };
76
+
77
+ function main() {
78
+ const [tool, ...rest] = process.argv.slice(2);
79
+ if (!tool || !Object.prototype.hasOwnProperty.call(TOOLS, tool)) {
80
+ console.error(`Usage: lynkr run <tool> [options] [-- tool-args]`);
81
+ console.error(`Supported tools: ${Object.keys(TOOLS).join(", ")}`);
82
+ console.error(tool ? `Unknown tool '${tool}'.` : "");
83
+ process.exit(tool ? 1 : 0);
84
+ }
85
+ TOOLS[tool](rest);
86
+ }
87
+
88
+ module.exports = { splitArgs, TOOLS };
89
+
90
+ if (require.main === module || process.env._LYNKR_SUBCMD === "run") {
91
+ main();
92
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": 1,
3
+ "heads": ["reasoning", "codegen", "debugging", "tool_use"],
4
+ "notes": "Capability profiles are decoupled from the predictor (see src/routing/capabilities.js). Editing this file re-routes traffic with zero retraining — the HyDRA shortfall-matching port. Tier profiles are seeded to mirror the legacy scalar bands (SIMPLE 0-19, MEDIUM 20-50, COMPLEX 51-75, REASONING 76-100). This file is read once at boot (same as config/model-tiers.json) — restart to pick up edits. No env vars: everything shortfall needs lives here.",
5
+ "enabled": true,
6
+ "tau": 0.24,
7
+ "weights": {
8
+ "reasoning": 0.25,
9
+ "codegen": 0.25,
10
+ "debugging": 0.25,
11
+ "tool_use": 0.25
12
+ },
13
+ "tierProfiles": {
14
+ "SIMPLE": { "reasoning": 0.2, "codegen": 0.3, "debugging": 0.2, "tool_use": 0.3 },
15
+ "MEDIUM": { "reasoning": 0.45, "codegen": 0.55, "debugging": 0.45, "tool_use": 0.55 },
16
+ "COMPLEX": { "reasoning": 0.7, "codegen": 0.75, "debugging": 0.7, "tool_use": 0.7 },
17
+ "REASONING": { "reasoning": 0.9, "codegen": 0.9, "debugging": 0.9, "tool_use": 0.9 }
18
+ },
19
+ "modelOverrides": {}
20
+ }