prompt-router 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ege
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,245 @@
1
+ # prompt-router
2
+
3
+ **Route every prompt to the AI that deserves it.**
4
+
5
+ [![CI](https://github.com/PTB-0/prompt-router/actions/workflows/ci.yml/badge.svg)](https://github.com/PTB-0/prompt-router/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![Node >= 18](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
8
+
9
+ Sending "what's the capital of France?" to a frontier coding agent is a waste. Sending "build the production feature" to a 7B model is a disaster. **prompt-router** ends both: it optimizes your prompt, detects what you actually want, and dispatches it to the right backend — automatically, with a one-key override.
10
+
11
+ > **Make the simple work cheap. Make the hard work easy.**
12
+
13
+ ```
14
+ $ prompt-router "fix the login bug in auth.ts"
15
+
16
+ ────────────────────────────────────────────────────────
17
+ prompt-router — optimized & routed
18
+ ────────────────────────────────────────────────────────
19
+
20
+ ORIGINAL
21
+ fix the login bug in auth.ts
22
+
23
+ OPTIMIZED
24
+ In auth.ts, identify and fix the bug causing login failures.
25
+ Check token validation and session handling. Keep the change
26
+ minimal and do not modify unrelated files.
27
+
28
+ ROUTE → Claude Code
29
+ (code, complexity 0.4, confidence 0.9)
30
+
31
+ ────────────────────────────────────────────────────────
32
+ [Y]es [n]o, original [e]dit [c]laude [l]ocal [o]penrouter (15s timeout → Y):
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Table of contents
38
+
39
+ - [How it works](#how-it-works)
40
+ - [Installation](#installation)
41
+ - [Quick start](#quick-start)
42
+ - [Usage](#usage)
43
+ - [The plan-first pipeline](#the-plan-first-pipeline)
44
+ - [Local models](#local-models)
45
+ - [Configuration](#configuration)
46
+ - [Graceful degradation](#graceful-degradation)
47
+ - [Privacy](#privacy)
48
+ - [Development](#development)
49
+ - [Roadmap](#roadmap)
50
+ - [Related](#related)
51
+
52
+ ---
53
+
54
+ ## How it works
55
+
56
+ One free LLM call on [OpenRouter](https://openrouter.ai) does two jobs at once: it **rewrites your prompt** to be precise and actionable, and it **classifies** it. Instant heuristics (code verbs in English and Turkish, file references, whether you're inside a repo) pre-filter the obvious cases before the network is even touched.
57
+
58
+ ```
59
+ ┌──────────────────────────────┐
60
+ prompt ──────▶│ optimize + classify │ one free LLM call
61
+ │ (+ instant heuristics) │
62
+ └──────────────┬───────────────┘
63
+
64
+ ┌─────────────────┼──────────────────┐
65
+ ▼ ▼ ▼
66
+ code task simple question broad question
67
+ │ │ │
68
+ complex? ──▶ draft plan ▼ ▼
69
+ │ you approve local model OpenRouter
70
+ ▼ (LM Studio, (strong free model
71
+ Claude Code Ollama, ...) + fallback chain)
72
+ ```
73
+
74
+ | Your prompt looks like… | Goes to | Why |
75
+ |---|---|---|
76
+ | A coding task | **Claude Code** | Agentic execution where it matters |
77
+ | A *complex* coding task | **Claude Code + plan** | A free model drafts an implementation plan first; you review it; Claude starts with a head start instead of exploring |
78
+ | A quick question | **Your local model** | Instant, free, private, no rate limits |
79
+ | A broad, open-ended question | **OpenRouter free model** | Long-form answers without burning Claude usage |
80
+
81
+ **The router is deliberately paranoid.** Misrouting a code task to a small model costs you real work; over-serving a question only costs tokens. So whenever signals conflict or confidence is low, it escalates to the stronger backend — and you always get the confirmation prompt with single-key overrides.
82
+
83
+ ## Installation
84
+
85
+ ```bash
86
+ npm install -g prompt-router
87
+ ```
88
+
89
+ Requires Node.js ≥ 18 and the [Claude Code CLI](https://claude.com/claude-code) for the coding route.
90
+
91
+ ## Quick start
92
+
93
+ **1.** Get a free API key at [openrouter.ai](https://openrouter.ai) and create `~/.config/prompt-router/.env`:
94
+
95
+ ```env
96
+ OPENROUTER_API_KEY=sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxx
97
+ ```
98
+
99
+ **2.** *(Optional but recommended)* Run a local model with [LM Studio](https://lmstudio.ai) — prompt-router will even start the server for you via `lms server start`. No local model? Simple questions fall back to OpenRouter automatically.
100
+
101
+ **3.** Route something:
102
+
103
+ ```bash
104
+ prompt-router "what is a monad?"
105
+ ```
106
+
107
+ ## Usage
108
+
109
+ ```bash
110
+ prompt-router "fix the race condition in the session store" # → Claude Code (plan-first if complex)
111
+ prompt-router "what's the difference between TCP and UDP?" # → local model
112
+ prompt-router "compare event sourcing with CRUD for a bank" # → OpenRouter
113
+
114
+ prompt-router -c "and which one scales better?" # follow-up: carries conversation memory
115
+ prompt-router --to local "explain this simply" # force a backend (claude | local | openrouter)
116
+ prompt-router --no-route "quick edit" # skip everything, straight to Claude Code
117
+ prompt-router --stats # how much Claude usage you've saved
118
+ prompt-router --clear-session # forget the stored conversation
119
+ ```
120
+
121
+ Every routed prompt shows the confirmation bar first:
122
+
123
+ | Key | Action |
124
+ |---|---|
125
+ | `Y` / Enter / timeout | Accept the optimized prompt and the chosen route |
126
+ | `n` | Keep your original wording (same route) |
127
+ | `e` | Edit the optimized prompt in `$EDITOR` |
128
+ | `c` / `l` / `o` | Override the route: **c**laude, **l**ocal, **o**penrouter |
129
+
130
+ ## The plan-first pipeline
131
+
132
+ For code tasks classified above the complexity threshold, prompt-router inserts one extra step before Claude Code starts:
133
+
134
+ 1. A strong free model drafts a concise, numbered implementation plan.
135
+ 2. The plan is shown to you — accept it, edit it, or skip it.
136
+ 3. The approved plan is attached to your prompt, so Claude Code begins executing instead of exploring.
137
+
138
+ You spend zero premium tokens on planning, and the expensive agent gets a map. This is the "make the hard work easy" half of the deal.
139
+
140
+ ## Local models
141
+
142
+ Any OpenAI-compatible server works — LM Studio, Ollama, llama.cpp, vLLM:
143
+
144
+ - **LM Studio** (default): prompt-router probes `http://localhost:1234/v1`, and if the server is down it runs `lms server start` and waits for it to come up.
145
+ - **Ollama or anything else**: point `local.baseUrl` at it (e.g. `http://localhost:11434/v1`) and set your model name.
146
+ - **No GPU?** Set `local.enabled: false` (or just don't run a server) — simple questions route to OpenRouter instead. Nothing breaks.
147
+
148
+ ## Configuration
149
+
150
+ Everything lives in `~/.config/prompt-router/` (override the directory with `PROMPT_ROUTER_DIR`). All fields are optional — these are the defaults:
151
+
152
+ ```jsonc
153
+ // ~/.config/prompt-router/config.json
154
+ {
155
+ "local": {
156
+ "baseUrl": "http://localhost:1234/v1",
157
+ "model": "gemma-4-12b-qat",
158
+ "autoStart": true, // try `lms server start` when the server is down
159
+ "enabled": true // false = never route locally
160
+ },
161
+ "openrouter": {
162
+ "baseUrl": "https://openrouter.ai/api/v1",
163
+ "classifierModels": [ // fallback chains — first healthy model wins
164
+ "meta-llama/llama-3.1-8b-instruct:free",
165
+ "google/gemma-2-9b-it:free",
166
+ "mistralai/mistral-7b-instruct:free"
167
+ ],
168
+ "answerModels": [
169
+ "meta-llama/llama-3.3-70b-instruct:free",
170
+ "deepseek/deepseek-chat:free",
171
+ "google/gemma-2-9b-it:free"
172
+ ],
173
+ "planModels": [
174
+ "meta-llama/llama-3.3-70b-instruct:free",
175
+ "deepseek/deepseek-chat:free",
176
+ "meta-llama/llama-3.1-8b-instruct:free"
177
+ ]
178
+ },
179
+ "thresholds": {
180
+ "confidence": 0.6, // below this, the route is flagged for your attention
181
+ "planComplexity": 0.7 // at or above this, code tasks get the plan-first pipeline
182
+ },
183
+ "session": { "maxMessages": 12 },
184
+ "logging": { "routingLog": false },
185
+ "timeoutMs": 8000
186
+ }
187
+ ```
188
+
189
+ Free models come and go — the model lists are **fallback chains you can edit without waiting for a release**.
190
+
191
+ | Environment variable | Overrides |
192
+ |---|---|
193
+ | `OPENROUTER_API_KEY` | API key (required for classification, deep answers, plans) |
194
+ | `PROMPT_ROUTER_LOCAL_URL` | `local.baseUrl` |
195
+ | `PROMPT_ROUTER_LOCAL_MODEL` | `local.model` |
196
+ | `PROMPT_ROUTER_TIMEOUT` | `timeoutMs` |
197
+ | `PROMPT_ROUTER_DIR` | Config/session/stats directory |
198
+
199
+ ## Graceful degradation
200
+
201
+ prompt-router assumes things fail and never leaves you stranded:
202
+
203
+ | When… | It… |
204
+ |---|---|
205
+ | The classifier is down or you have no API key | Sends your original prompt straight to Claude Code |
206
+ | The local server is down and can't be started | Falls back to OpenRouter |
207
+ | A free model is rate-limited mid-stream | Retries with the next model in the chain |
208
+ | Every answer backend fails | Hands off to Claude Code |
209
+ | Claude Code itself isn't installed | Prints your prompt so it's never lost |
210
+ | The prompt is trivially short | Skips optimization entirely (like `--no-route`) |
211
+
212
+ ## Privacy
213
+
214
+ - **Prompt content is never logged.** Not in stats, not in the routing log.
215
+ - `--stats` stores three counters in a local file. That's it.
216
+ - The opt-in routing log (`logging.routingLog: true`) records timestamps, targets, and confidence flags — useful for tuning thresholds, still content-free.
217
+ - Conversation memory (`-c`) lives in one local JSON file you can delete anytime with `--clear-session`.
218
+
219
+ ## Development
220
+
221
+ ```bash
222
+ git clone https://github.com/PTB-0/prompt-router
223
+ cd prompt-router
224
+ pnpm install
225
+ pnpm test # vitest — the routing logic is fully unit-tested
226
+ pnpm typecheck # TypeScript strict, no `any`
227
+ pnpm dev "your prompt"
228
+ ```
229
+
230
+ Built test-first: every routing rule in `src/route.ts` and every heuristic in `src/heuristics.ts` is pinned by a test in `test/`. CI runs the suite on Linux and Windows, Node 18 and 22.
231
+
232
+ ## Roadmap
233
+
234
+ - [ ] `prompt-router init` — interactive setup wizard
235
+ - [ ] npm publish with GitHub Actions provenance
236
+ - [ ] Reuse [prompt-op](https://github.com/PTB-0/prompt-op)'s optimizer as a library dependency
237
+ - [ ] Per-backend capability manifests
238
+
239
+ ## Related
240
+
241
+ - [prompt-op](https://github.com/PTB-0/prompt-op) — the sibling tool this grew out of: prompt optimization for Claude Code, without routing.
242
+
243
+ ## License
244
+
245
+ [MIT](LICENSE) © 2026 Ege
@@ -0,0 +1,68 @@
1
+ import { chatCompletion, withModelFallback } from "./llm.js";
2
+ export const CLASSIFY_SYSTEM_PROMPT = `You are the routing brain of prompt-router, a CLI that optimizes a prompt and routes it to the right AI backend.
3
+
4
+ Rewrite the user's prompt to be precise and actionable while preserving their intent exactly — do not add features they did not ask for. If the prompt is already specific, keep it unchanged. Write the rewritten prompt in the same language the user used.
5
+
6
+ Then classify it:
7
+ - "code": writing, fixing, refactoring, reviewing, or explaining project code; anything meant for a coding agent.
8
+ - "simple-qa": a short factual or everyday question answerable in a few sentences.
9
+ - "deep-qa": a broad, open-ended, or multi-part question that needs a long, reasoned answer.
10
+
11
+ complexity (0 to 1): for "code", how large or architectural the task is (0 = one-line fix, 1 = multi-file production feature). For questions, how much reasoning the answer needs.
12
+ confidence (0 to 1): how sure you are about the category.
13
+
14
+ Respond with ONLY this JSON object — no markdown fence, no commentary:
15
+ {"optimized_prompt": "...", "category": "code" | "simple-qa" | "deep-qa", "complexity": 0.0, "confidence": 0.0}`;
16
+ function isRecord(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function toScore(value) {
20
+ const num = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
21
+ if (Number.isNaN(num))
22
+ return null;
23
+ return Math.min(1, Math.max(0, num));
24
+ }
25
+ function isCategory(value) {
26
+ return value === "code" || value === "simple-qa" || value === "deep-qa";
27
+ }
28
+ export function parseClassification(raw) {
29
+ const start = raw.indexOf("{");
30
+ const end = raw.lastIndexOf("}");
31
+ if (start === -1 || end <= start)
32
+ return null;
33
+ let data;
34
+ try {
35
+ data = JSON.parse(raw.slice(start, end + 1));
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ if (!isRecord(data))
41
+ return null;
42
+ const optimizedPrompt = typeof data["optimized_prompt"] === "string" ? data["optimized_prompt"].trim() : "";
43
+ const complexity = toScore(data["complexity"]);
44
+ const confidence = toScore(data["confidence"]);
45
+ const category = data["category"];
46
+ if (!optimizedPrompt || complexity === null || confidence === null || !isCategory(category)) {
47
+ return null;
48
+ }
49
+ return { optimizedPrompt, category, complexity, confidence };
50
+ }
51
+ export async function classify(prompt, config) {
52
+ if (!config.openrouter.apiKey)
53
+ return null;
54
+ return withModelFallback(config.openrouter.classifierModels, async (model) => {
55
+ const raw = await chatCompletion({
56
+ baseUrl: config.openrouter.baseUrl,
57
+ apiKey: config.openrouter.apiKey,
58
+ model,
59
+ messages: [
60
+ { role: "system", content: CLASSIFY_SYSTEM_PROMPT },
61
+ { role: "user", content: prompt },
62
+ ],
63
+ maxTokens: 1024,
64
+ timeoutMs: config.timeoutMs,
65
+ });
66
+ return raw ? parseClassification(raw) : null;
67
+ });
68
+ }
package/dist/config.js ADDED
@@ -0,0 +1,112 @@
1
+ import { config as loadDotenv } from "dotenv";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ const DEFAULTS = {
6
+ openrouter: {
7
+ baseUrl: "https://openrouter.ai/api/v1",
8
+ apiKey: undefined,
9
+ classifierModels: [
10
+ "meta-llama/llama-3.1-8b-instruct:free",
11
+ "google/gemma-2-9b-it:free",
12
+ "mistralai/mistral-7b-instruct:free",
13
+ ],
14
+ answerModels: [
15
+ "meta-llama/llama-3.3-70b-instruct:free",
16
+ "deepseek/deepseek-chat:free",
17
+ "google/gemma-2-9b-it:free",
18
+ ],
19
+ planModels: [
20
+ "meta-llama/llama-3.3-70b-instruct:free",
21
+ "deepseek/deepseek-chat:free",
22
+ "meta-llama/llama-3.1-8b-instruct:free",
23
+ ],
24
+ },
25
+ local: {
26
+ baseUrl: "http://localhost:1234/v1",
27
+ model: "gemma-4-12b-qat",
28
+ autoStart: true,
29
+ enabled: true,
30
+ },
31
+ thresholds: {
32
+ confidence: 0.6,
33
+ planComplexity: 0.7,
34
+ },
35
+ session: {
36
+ maxMessages: 12,
37
+ },
38
+ logging: {
39
+ routingLog: false,
40
+ },
41
+ timeoutMs: 8000,
42
+ };
43
+ function isRecord(value) {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
+ }
46
+ function pickString(value, fallback) {
47
+ return typeof value === "string" && value ? value : fallback;
48
+ }
49
+ function pickBoolean(value, fallback) {
50
+ return typeof value === "boolean" ? value : fallback;
51
+ }
52
+ function pickPositive(value, fallback) {
53
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
54
+ }
55
+ function pickScore(value, fallback) {
56
+ return typeof value === "number" && value >= 0 && value <= 1 ? value : fallback;
57
+ }
58
+ function pickStringArray(value, fallback) {
59
+ return Array.isArray(value) && value.length > 0 && value.every((x) => typeof x === "string")
60
+ ? value
61
+ : fallback;
62
+ }
63
+ export function resolveConfig(fileCfg, env) {
64
+ const cfg = structuredClone(DEFAULTS);
65
+ const file = isRecord(fileCfg) ? fileCfg : {};
66
+ const or = isRecord(file["openrouter"]) ? file["openrouter"] : {};
67
+ cfg.openrouter.baseUrl = pickString(or["baseUrl"], cfg.openrouter.baseUrl);
68
+ cfg.openrouter.classifierModels = pickStringArray(or["classifierModels"], cfg.openrouter.classifierModels);
69
+ cfg.openrouter.answerModels = pickStringArray(or["answerModels"], cfg.openrouter.answerModels);
70
+ cfg.openrouter.planModels = pickStringArray(or["planModels"], cfg.openrouter.planModels);
71
+ const local = isRecord(file["local"]) ? file["local"] : {};
72
+ cfg.local.baseUrl = pickString(local["baseUrl"], cfg.local.baseUrl);
73
+ cfg.local.model = pickString(local["model"], cfg.local.model);
74
+ cfg.local.autoStart = pickBoolean(local["autoStart"], cfg.local.autoStart);
75
+ cfg.local.enabled = pickBoolean(local["enabled"], cfg.local.enabled);
76
+ const thresholds = isRecord(file["thresholds"]) ? file["thresholds"] : {};
77
+ cfg.thresholds.confidence = pickScore(thresholds["confidence"], cfg.thresholds.confidence);
78
+ cfg.thresholds.planComplexity = pickScore(thresholds["planComplexity"], cfg.thresholds.planComplexity);
79
+ const session = isRecord(file["session"]) ? file["session"] : {};
80
+ cfg.session.maxMessages = pickPositive(session["maxMessages"], cfg.session.maxMessages);
81
+ const logging = isRecord(file["logging"]) ? file["logging"] : {};
82
+ cfg.logging.routingLog = pickBoolean(logging["routingLog"], cfg.logging.routingLog);
83
+ cfg.timeoutMs = pickPositive(file["timeoutMs"], cfg.timeoutMs);
84
+ // Environment overrides beat the file.
85
+ if (env.OPENROUTER_API_KEY)
86
+ cfg.openrouter.apiKey = env.OPENROUTER_API_KEY;
87
+ if (env.PROMPT_ROUTER_LOCAL_URL)
88
+ cfg.local.baseUrl = env.PROMPT_ROUTER_LOCAL_URL;
89
+ if (env.PROMPT_ROUTER_LOCAL_MODEL)
90
+ cfg.local.model = env.PROMPT_ROUTER_LOCAL_MODEL;
91
+ if (env.PROMPT_ROUTER_TIMEOUT) {
92
+ cfg.timeoutMs = pickPositive(Number.parseInt(env.PROMPT_ROUTER_TIMEOUT, 10), cfg.timeoutMs);
93
+ }
94
+ return cfg;
95
+ }
96
+ export function configDir() {
97
+ return process.env.PROMPT_ROUTER_DIR ?? path.join(os.homedir(), ".config", "prompt-router");
98
+ }
99
+ export function loadConfig() {
100
+ const dir = configDir();
101
+ // Global install reads ~/.config/prompt-router/.env first; cwd/.env covers local dev.
102
+ loadDotenv({ path: path.join(dir, ".env") });
103
+ loadDotenv();
104
+ let fileCfg;
105
+ try {
106
+ fileCfg = JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8"));
107
+ }
108
+ catch {
109
+ fileCfg = undefined;
110
+ }
111
+ return resolveConfig(fileCfg, process.env);
112
+ }
@@ -0,0 +1,19 @@
1
+ const CODE_VERBS = /\b(fix|implement|refactor|debug|rewrite|write|add|create|build|install|configure|deploy|migrate|optimi[sz]e|rename|remove|update)\b/i;
2
+ const CODE_VERBS_TR = /(düzelt|yeniden yaz|ekle|oluştur|kur|geliştir|uygula|değiştir|kaldır|güncelle|derle|düzenle)/i;
3
+ const CODE_ARTIFACTS = /(\.[a-z]{1,4}\b|\bfunction\b|\bclass\b|\bbug\b|\berror\b|\bexception\b|\bapi\b|\bendpoint\b|\bdatabase\b|\bcomponent\b|\brepo\b|\bcommit\b|```|\bstack trace\b|\bhata\b|\bfonksiyon\b|\bveritabanı\b|\bkod\b)/i;
4
+ const ERROR_TOKENS = /\w+(error|exception)\b/i;
5
+ const MAX_SIMPLE_QA_WORDS = 12;
6
+ export function heuristicCategory(prompt, ctx) {
7
+ const text = prompt.trim();
8
+ if (!text)
9
+ return null;
10
+ const verb = CODE_VERBS.test(text) || CODE_VERBS_TR.test(text);
11
+ const artifact = CODE_ARTIFACTS.test(text) || ERROR_TOKENS.test(text);
12
+ if (verb && (artifact || ctx.inCodeProject))
13
+ return "code";
14
+ const wordCount = text.split(/\s+/).length;
15
+ if (/\?\s*$/.test(text) && wordCount <= MAX_SIMPLE_QA_WORDS && !verb && !artifact) {
16
+ return "simple-qa";
17
+ }
18
+ return null;
19
+ }
package/dist/index.js ADDED
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "child_process";
3
+ import * as fs from "fs";
4
+ import * as os from "os";
5
+ import * as path from "path";
6
+ import pc from "picocolors";
7
+ import { classify } from "./classify.js";
8
+ import { configDir, loadConfig } from "./config.js";
9
+ import { heuristicCategory } from "./heuristics.js";
10
+ import { streamChat, withModelFallback } from "./llm.js";
11
+ import { ensureLocalServer } from "./local.js";
12
+ import { attachPlan, generatePlan } from "./plan.js";
13
+ import { decideRoute } from "./route.js";
14
+ import { appendToSession, clearSession, loadSession } from "./session.js";
15
+ import { formatStats, loadStats, recordRoute } from "./stats.js";
16
+ import { askPlanChoice, askRouteChoice, showError, showPassThrough, showPlan, showRouting, startSpinner, TARGET_LABELS, } from "./ui.js";
17
+ const MIN_PROMPT_LENGTH = 10;
18
+ const ANSWER_TIMEOUT_FLOOR_MS = 30_000;
19
+ const USAGE = `Usage: prompt-router "your prompt"
20
+ -c, --continue carry the previous conversation into this one
21
+ --to <target> force a backend: claude | local | openrouter
22
+ --no-route skip optimization and routing, go straight to Claude Code
23
+ --stats show routing statistics
24
+ --clear-session forget the stored conversation
25
+ `;
26
+ function parseArgs(argv) {
27
+ const args = {
28
+ prompt: "",
29
+ continueSession: false,
30
+ noRoute: false,
31
+ forceTarget: null,
32
+ showStats: false,
33
+ clear: false,
34
+ };
35
+ const parts = [];
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const arg = argv[i];
38
+ if (arg === undefined)
39
+ continue;
40
+ if (arg === "-c" || arg === "--continue")
41
+ args.continueSession = true;
42
+ else if (arg === "--no-route")
43
+ args.noRoute = true;
44
+ else if (arg === "--stats")
45
+ args.showStats = true;
46
+ else if (arg === "--clear-session")
47
+ args.clear = true;
48
+ else if (arg === "--to") {
49
+ const target = argv[++i];
50
+ if (target === "claude" || target === "local" || target === "openrouter") {
51
+ args.forceTarget = target;
52
+ }
53
+ else {
54
+ process.stderr.write("prompt-router: --to expects claude | local | openrouter\n");
55
+ process.exit(1);
56
+ }
57
+ }
58
+ else
59
+ parts.push(arg);
60
+ }
61
+ args.prompt = parts.join(" ").trim();
62
+ return args;
63
+ }
64
+ const PROJECT_MARKERS = [
65
+ ".git",
66
+ "package.json",
67
+ "pnpm-workspace.yaml",
68
+ "Cargo.toml",
69
+ "go.mod",
70
+ "pyproject.toml",
71
+ "requirements.txt",
72
+ "build.gradle",
73
+ "pom.xml",
74
+ ];
75
+ function detectCodeProject() {
76
+ return PROJECT_MARKERS.some((marker) => fs.existsSync(path.join(process.cwd(), marker)));
77
+ }
78
+ function routeDetail(target, config) {
79
+ if (target === "local")
80
+ return `${TARGET_LABELS.local} (${config.local.model})`;
81
+ if (target === "openrouter") {
82
+ return `${TARGET_LABELS.openrouter} (${config.openrouter.answerModels[0] ?? "free model"})`;
83
+ }
84
+ return TARGET_LABELS.claude;
85
+ }
86
+ function runClaude(text, continueSession) {
87
+ const claudeArgs = continueSession ? ["-c", text] : [text];
88
+ const result = spawnSync("claude", claudeArgs, {
89
+ stdio: "inherit",
90
+ shell: process.platform === "win32",
91
+ });
92
+ if (result.error) {
93
+ showError(`failed to run claude: ${result.error.message}`);
94
+ process.stderr.write("Your prompt, so it is not lost:\n\n");
95
+ process.stdout.write(text + "\n");
96
+ process.exit(1);
97
+ }
98
+ process.exit(result.status ?? 1);
99
+ }
100
+ function openInEditor(content) {
101
+ const tmpFile = path.join(os.tmpdir(), `prompt-router-${Date.now()}.txt`);
102
+ fs.writeFileSync(tmpFile, content, "utf8");
103
+ const editor = process.env.EDITOR ?? (process.platform === "win32" ? "notepad" : "vi");
104
+ spawnSync(editor, [tmpFile], { stdio: "inherit", shell: process.platform === "win32" });
105
+ const edited = fs.readFileSync(tmpFile, "utf8").trim();
106
+ fs.unlinkSync(tmpFile);
107
+ return edited || content;
108
+ }
109
+ function logRouting(config, decision) {
110
+ // Opt-in and content-free by design: categories and targets only, never the prompt.
111
+ if (!config.logging.routingLog)
112
+ return;
113
+ try {
114
+ const dir = configDir();
115
+ fs.mkdirSync(dir, { recursive: true });
116
+ fs.appendFileSync(path.join(dir, "routing-log.jsonl"), JSON.stringify({
117
+ ts: new Date().toISOString(),
118
+ target: decision.target,
119
+ planFirst: decision.planFirst,
120
+ uncertain: decision.uncertain,
121
+ }) + "\n", "utf8");
122
+ }
123
+ catch {
124
+ // logging must never break routing
125
+ }
126
+ }
127
+ async function runClaudeRoute(prompt, decision, config, args) {
128
+ let finalPrompt = prompt;
129
+ if (decision.planFirst) {
130
+ const stopSpinner = startSpinner("Drafting plan...");
131
+ const plan = await generatePlan(prompt, config);
132
+ stopSpinner();
133
+ if (plan) {
134
+ showPlan(plan);
135
+ const planChoice = await askPlanChoice();
136
+ process.stderr.write("\n");
137
+ if (planChoice === "accept")
138
+ finalPrompt = attachPlan(prompt, plan);
139
+ else if (planChoice === "edit")
140
+ finalPrompt = attachPlan(prompt, openInEditor(plan));
141
+ // "skip" keeps the prompt as-is
142
+ }
143
+ else {
144
+ showPassThrough("plan generation unavailable — sending the prompt without a plan");
145
+ }
146
+ }
147
+ runClaude(finalPrompt, args.continueSession);
148
+ }
149
+ async function runChatRoute(prompt, decision, config, args) {
150
+ const dir = configDir();
151
+ const history = args.continueSession ? loadSession(dir) : [];
152
+ const messages = [
153
+ ...history.map((m) => ({ role: m.role, content: m.content })),
154
+ { role: "user", content: prompt },
155
+ ];
156
+ const timeoutMs = Math.max(config.timeoutMs, ANSWER_TIMEOUT_FLOOR_MS);
157
+ const writeDelta = (text) => {
158
+ process.stdout.write(text);
159
+ };
160
+ let answer = null;
161
+ if (decision.target === "local") {
162
+ const stopSpinner = startSpinner("Reaching local model...");
163
+ const up = await ensureLocalServer(config);
164
+ stopSpinner();
165
+ if (up) {
166
+ answer = await streamChat({ baseUrl: config.local.baseUrl, model: config.local.model, messages, timeoutMs }, writeDelta);
167
+ }
168
+ if (answer === null) {
169
+ showPassThrough("local model unavailable — falling back to OpenRouter");
170
+ }
171
+ }
172
+ if (answer === null) {
173
+ if (!config.openrouter.apiKey) {
174
+ showPassThrough("no OPENROUTER_API_KEY — handing off to Claude Code");
175
+ runClaude(prompt, args.continueSession);
176
+ }
177
+ answer = await withModelFallback(config.openrouter.answerModels, async (model) => {
178
+ let wrote = false;
179
+ const result = await streamChat({
180
+ baseUrl: config.openrouter.baseUrl,
181
+ apiKey: config.openrouter.apiKey,
182
+ model,
183
+ messages,
184
+ timeoutMs,
185
+ }, (text) => {
186
+ wrote = true;
187
+ writeDelta(text);
188
+ });
189
+ if (result === null && wrote) {
190
+ process.stdout.write(pc.dim("\n[stream interrupted — retrying with another model]\n"));
191
+ }
192
+ return result;
193
+ });
194
+ }
195
+ if (answer === null) {
196
+ showPassThrough("all answer backends failed — handing off to Claude Code");
197
+ runClaude(prompt, args.continueSession);
198
+ }
199
+ process.stdout.write("\n");
200
+ appendToSession(dir, [
201
+ { role: "user", content: prompt },
202
+ { role: "assistant", content: answer },
203
+ ], config.session.maxMessages);
204
+ }
205
+ async function main() {
206
+ const args = parseArgs(process.argv.slice(2));
207
+ const dir = configDir();
208
+ if (args.showStats) {
209
+ process.stdout.write(formatStats(loadStats(dir)) + "\n");
210
+ return;
211
+ }
212
+ if (args.clear) {
213
+ clearSession(dir);
214
+ process.stderr.write("prompt-router: session cleared.\n");
215
+ return;
216
+ }
217
+ if (!args.prompt) {
218
+ process.stderr.write(USAGE);
219
+ process.exit(1);
220
+ }
221
+ const config = loadConfig();
222
+ if (args.noRoute || args.prompt.length < MIN_PROMPT_LENGTH) {
223
+ runClaude(args.prompt, args.continueSession);
224
+ }
225
+ const heuristic = heuristicCategory(args.prompt, { inCodeProject: detectCodeProject() });
226
+ const stopSpinner = startSpinner("Optimizing & routing...");
227
+ const cls = await classify(args.prompt, config);
228
+ stopSpinner();
229
+ if (!cls)
230
+ showPassThrough("optimizer unavailable — using the original prompt");
231
+ let decision = args.forceTarget
232
+ ? { target: args.forceTarget, planFirst: false, uncertain: false }
233
+ : decideRoute(cls, heuristic, {
234
+ confidenceThreshold: config.thresholds.confidence,
235
+ planComplexityThreshold: config.thresholds.planComplexity,
236
+ localAvailable: config.local.enabled,
237
+ });
238
+ let finalPrompt = cls?.optimizedPrompt ?? args.prompt;
239
+ if (!args.forceTarget) {
240
+ showRouting(args.prompt, finalPrompt, decision, routeDetail(decision.target, config), cls);
241
+ const choice = await askRouteChoice();
242
+ process.stderr.write("\n");
243
+ if (choice.action === "reject")
244
+ finalPrompt = args.prompt;
245
+ else if (choice.action === "edit")
246
+ finalPrompt = openInEditor(finalPrompt);
247
+ if (choice.overrideTarget) {
248
+ decision = {
249
+ ...decision,
250
+ target: choice.overrideTarget,
251
+ planFirst: choice.overrideTarget === "claude" ? decision.planFirst : false,
252
+ };
253
+ }
254
+ }
255
+ recordRoute(dir, decision.target);
256
+ logRouting(config, decision);
257
+ if (decision.target === "claude") {
258
+ await runClaudeRoute(finalPrompt, decision, config, args);
259
+ }
260
+ else {
261
+ await runChatRoute(finalPrompt, decision, config, args);
262
+ }
263
+ }
264
+ main().catch((err) => {
265
+ showError(err instanceof Error ? err.message : "unexpected error");
266
+ process.exit(1);
267
+ });
package/dist/llm.js ADDED
@@ -0,0 +1,153 @@
1
+ function buildHeaders(apiKey) {
2
+ const headers = {
3
+ "Content-Type": "application/json",
4
+ "HTTP-Referer": "https://github.com/PTB-0/prompt-router",
5
+ "X-Title": "prompt-router",
6
+ };
7
+ if (apiKey)
8
+ headers["Authorization"] = `Bearer ${apiKey}`;
9
+ return headers;
10
+ }
11
+ function messageContent(data) {
12
+ if (typeof data !== "object" || data === null)
13
+ return null;
14
+ const choices = data["choices"];
15
+ if (!Array.isArray(choices) || choices.length === 0)
16
+ return null;
17
+ const first = choices[0];
18
+ if (typeof first !== "object" || first === null)
19
+ return null;
20
+ const message = first["message"];
21
+ if (typeof message !== "object" || message === null)
22
+ return null;
23
+ const content = message["content"];
24
+ return typeof content === "string" ? content.trim() : null;
25
+ }
26
+ function deltaContent(data) {
27
+ if (typeof data !== "object" || data === null)
28
+ return null;
29
+ const choices = data["choices"];
30
+ if (!Array.isArray(choices) || choices.length === 0)
31
+ return null;
32
+ const first = choices[0];
33
+ if (typeof first !== "object" || first === null)
34
+ return null;
35
+ const delta = first["delta"];
36
+ if (typeof delta !== "object" || delta === null)
37
+ return null;
38
+ const content = delta["content"];
39
+ return typeof content === "string" ? content : null;
40
+ }
41
+ export async function withModelFallback(models, attempt) {
42
+ for (const model of models) {
43
+ try {
44
+ const result = await attempt(model);
45
+ if (result !== null)
46
+ return result;
47
+ }
48
+ catch {
49
+ // failed model — try the next one
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ export function extractSseDeltas(buffer) {
55
+ const deltas = [];
56
+ let rest = buffer;
57
+ for (;;) {
58
+ const separator = rest.indexOf("\n\n");
59
+ if (separator === -1)
60
+ break;
61
+ const event = rest.slice(0, separator);
62
+ rest = rest.slice(separator + 2);
63
+ for (const line of event.split("\n")) {
64
+ if (!line.startsWith("data:"))
65
+ continue;
66
+ const payload = line.slice(5).trim();
67
+ if (!payload || payload === "[DONE]")
68
+ continue;
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(payload);
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ const delta = deltaContent(parsed);
77
+ if (delta)
78
+ deltas.push(delta);
79
+ }
80
+ }
81
+ return { deltas, rest };
82
+ }
83
+ export async function chatCompletion(req) {
84
+ const fetchImpl = req.fetchImpl ?? fetch;
85
+ const controller = new AbortController();
86
+ const timer = setTimeout(() => controller.abort(), req.timeoutMs);
87
+ try {
88
+ const response = await fetchImpl(`${req.baseUrl}/chat/completions`, {
89
+ method: "POST",
90
+ headers: buildHeaders(req.apiKey),
91
+ body: JSON.stringify({
92
+ model: req.model,
93
+ messages: req.messages,
94
+ max_tokens: req.maxTokens ?? 1024,
95
+ }),
96
+ signal: controller.signal,
97
+ });
98
+ if (!response.ok)
99
+ return null;
100
+ return messageContent(await response.json());
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ finally {
106
+ clearTimeout(timer);
107
+ }
108
+ }
109
+ export async function streamChat(req, onDelta) {
110
+ const fetchImpl = req.fetchImpl ?? fetch;
111
+ const controller = new AbortController();
112
+ const timer = setTimeout(() => controller.abort(), req.timeoutMs);
113
+ try {
114
+ const response = await fetchImpl(`${req.baseUrl}/chat/completions`, {
115
+ method: "POST",
116
+ headers: buildHeaders(req.apiKey),
117
+ body: JSON.stringify({
118
+ model: req.model,
119
+ messages: req.messages,
120
+ max_tokens: req.maxTokens ?? 4096,
121
+ stream: true,
122
+ }),
123
+ signal: controller.signal,
124
+ });
125
+ // The timeout guards the connection; once headers arrive the stream may run long.
126
+ clearTimeout(timer);
127
+ if (!response.ok || !response.body)
128
+ return null;
129
+ const reader = response.body.getReader();
130
+ const decoder = new TextDecoder();
131
+ let buffer = "";
132
+ let full = "";
133
+ for (;;) {
134
+ const { done, value } = await reader.read();
135
+ if (done)
136
+ break;
137
+ buffer += decoder.decode(value, { stream: true });
138
+ const { deltas, rest } = extractSseDeltas(buffer);
139
+ buffer = rest;
140
+ for (const delta of deltas) {
141
+ full += delta;
142
+ onDelta(delta);
143
+ }
144
+ }
145
+ return full || null;
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ finally {
151
+ clearTimeout(timer);
152
+ }
153
+ }
package/dist/local.js ADDED
@@ -0,0 +1,48 @@
1
+ import { spawn } from "child_process";
2
+ const PROBE_TIMEOUT_MS = 1500;
3
+ const START_POLL_ATTEMPTS = 6;
4
+ const START_POLL_INTERVAL_MS = 1000;
5
+ export async function isServerUp(baseUrl, timeoutMs, fetchImpl = fetch) {
6
+ const controller = new AbortController();
7
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
8
+ try {
9
+ const response = await fetchImpl(`${baseUrl}/models`, { signal: controller.signal });
10
+ return response.ok;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ finally {
16
+ clearTimeout(timer);
17
+ }
18
+ }
19
+ function delay(ms) {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ }
22
+ export async function ensureLocalServer(config) {
23
+ if (!config.local.enabled)
24
+ return false;
25
+ if (await isServerUp(config.local.baseUrl, PROBE_TIMEOUT_MS))
26
+ return true;
27
+ if (!config.local.autoStart)
28
+ return false;
29
+ // LM Studio ships the `lms` CLI; `server start` is a no-op when already running.
30
+ try {
31
+ const child = spawn("lms", ["server", "start"], {
32
+ shell: process.platform === "win32",
33
+ stdio: "ignore",
34
+ detached: true,
35
+ });
36
+ child.on("error", () => undefined);
37
+ child.unref();
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ for (let attempt = 0; attempt < START_POLL_ATTEMPTS; attempt++) {
43
+ await delay(START_POLL_INTERVAL_MS);
44
+ if (await isServerUp(config.local.baseUrl, PROBE_TIMEOUT_MS))
45
+ return true;
46
+ }
47
+ return false;
48
+ }
package/dist/plan.js ADDED
@@ -0,0 +1,28 @@
1
+ import { chatCompletion, withModelFallback } from "./llm.js";
2
+ const PLAN_SYSTEM_PROMPT = `You are a senior software architect. The user's prompt will be executed by Claude Code, an agentic coding assistant. Write a concise implementation plan it can follow.
3
+
4
+ Rules:
5
+ - Output ONLY the plan, as a numbered list of concrete steps.
6
+ - Name the files, modules, and commands involved when they can be inferred.
7
+ - Include a final verification step (tests, typecheck, or a manual check).
8
+ - Stay within the user's intent — do not invent extra features.
9
+ - Write the plan in the same language the user used.`;
10
+ const PLAN_TIMEOUT_FLOOR_MS = 20_000;
11
+ export async function generatePlan(prompt, config) {
12
+ if (!config.openrouter.apiKey)
13
+ return null;
14
+ return withModelFallback(config.openrouter.planModels, (model) => chatCompletion({
15
+ baseUrl: config.openrouter.baseUrl,
16
+ apiKey: config.openrouter.apiKey,
17
+ model,
18
+ messages: [
19
+ { role: "system", content: PLAN_SYSTEM_PROMPT },
20
+ { role: "user", content: prompt },
21
+ ],
22
+ maxTokens: 2048,
23
+ timeoutMs: Math.max(config.timeoutMs, PLAN_TIMEOUT_FLOOR_MS),
24
+ }));
25
+ }
26
+ export function attachPlan(prompt, plan) {
27
+ return `${prompt}\n\n## PLAN (prepared by prompt-router, approved by the user)\nFollow this plan unless the code contradicts it:\n\n${plan}`;
28
+ }
package/dist/route.js ADDED
@@ -0,0 +1,23 @@
1
+ export function decideRoute(cls, heuristic, opts) {
2
+ if (!cls && !heuristic) {
3
+ // No signal at all: send it to the strongest backend rather than risk a weak answer.
4
+ return { target: "claude", planFirst: false, uncertain: true };
5
+ }
6
+ // Misrouting a code task to a small model is far worse than over-serving a
7
+ // question, so a code verdict from either signal wins.
8
+ const category = heuristic === "code" || cls?.category === "code"
9
+ ? "code"
10
+ : (cls?.category ?? heuristic ?? "code");
11
+ const uncertain = cls !== null && cls.confidence < opts.confidenceThreshold;
12
+ if (category === "code") {
13
+ return {
14
+ target: "claude",
15
+ planFirst: cls !== null && cls.complexity >= opts.planComplexityThreshold,
16
+ uncertain,
17
+ };
18
+ }
19
+ if (category === "simple-qa" && opts.localAvailable) {
20
+ return { target: "local", planFirst: false, uncertain };
21
+ }
22
+ return { target: "openrouter", planFirst: false, uncertain };
23
+ }
@@ -0,0 +1,31 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ function sessionFile(dir) {
4
+ return path.join(dir, "session.json");
5
+ }
6
+ function isMessage(value) {
7
+ if (typeof value !== "object" || value === null)
8
+ return false;
9
+ const record = value;
10
+ return ((record["role"] === "user" || record["role"] === "assistant") &&
11
+ typeof record["content"] === "string");
12
+ }
13
+ export function loadSession(dir) {
14
+ try {
15
+ const parsed = JSON.parse(fs.readFileSync(sessionFile(dir), "utf8"));
16
+ if (!Array.isArray(parsed))
17
+ return [];
18
+ return parsed.filter(isMessage);
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ }
24
+ export function appendToSession(dir, messages, max) {
25
+ const history = [...loadSession(dir), ...messages].slice(-max);
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ fs.writeFileSync(sessionFile(dir), JSON.stringify(history, null, 2), "utf8");
28
+ }
29
+ export function clearSession(dir) {
30
+ fs.rmSync(sessionFile(dir), { force: true });
31
+ }
package/dist/stats.js ADDED
@@ -0,0 +1,42 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ function statsFile(dir) {
4
+ return path.join(dir, "stats.json");
5
+ }
6
+ function toCount(value) {
7
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
8
+ }
9
+ export function loadStats(dir) {
10
+ try {
11
+ const parsed = JSON.parse(fs.readFileSync(statsFile(dir), "utf8"));
12
+ if (typeof parsed !== "object" || parsed === null) {
13
+ return { claude: 0, local: 0, openrouter: 0 };
14
+ }
15
+ const record = parsed;
16
+ return {
17
+ claude: toCount(record["claude"]),
18
+ local: toCount(record["local"]),
19
+ openrouter: toCount(record["openrouter"]),
20
+ };
21
+ }
22
+ catch {
23
+ return { claude: 0, local: 0, openrouter: 0 };
24
+ }
25
+ }
26
+ export function recordRoute(dir, target) {
27
+ const stats = loadStats(dir);
28
+ stats[target] += 1;
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ fs.writeFileSync(statsFile(dir), JSON.stringify(stats, null, 2), "utf8");
31
+ }
32
+ export function formatStats(stats) {
33
+ const diverted = stats.local + stats.openrouter;
34
+ const total = diverted + stats.claude;
35
+ return [
36
+ "prompt-router stats",
37
+ ` claude: ${stats.claude}`,
38
+ ` local: ${stats.local}`,
39
+ ` openrouter: ${stats.openrouter}`,
40
+ ` diverted from claude: ${diverted} of ${total} prompts`,
41
+ ].join("\n");
42
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/ui.js ADDED
@@ -0,0 +1,140 @@
1
+ import * as readline from "readline";
2
+ import pc from "picocolors";
3
+ const WIDTH = Math.min(process.stderr.columns ?? 80, 100);
4
+ const LINE = "─".repeat(WIDTH);
5
+ const TIMEOUT_MS = 15_000;
6
+ export const TARGET_LABELS = {
7
+ claude: "Claude Code",
8
+ local: "local model",
9
+ openrouter: "OpenRouter",
10
+ };
11
+ export function showRouting(original, optimized, decision, detail, cls) {
12
+ const err = process.stderr;
13
+ err.write("\n" + pc.dim(LINE) + "\n");
14
+ err.write(pc.bold(" prompt-router") + pc.dim(" — optimized & routed\n"));
15
+ err.write(pc.dim(LINE) + "\n\n");
16
+ err.write(pc.dim(" ORIGINAL\n"));
17
+ err.write(pc.dim(" " + original.replace(/\n/g, "\n ")) + "\n\n");
18
+ if (optimized !== original) {
19
+ err.write(pc.green(pc.bold(" OPTIMIZED\n")));
20
+ err.write(pc.green(" " + optimized.replace(/\n/g, "\n ")) + "\n\n");
21
+ }
22
+ const planNote = decision.planFirst ? pc.cyan(" · plan-first") : "";
23
+ const uncertainNote = decision.uncertain ? pc.yellow(" · low confidence") : "";
24
+ err.write(pc.bold(` ROUTE → ${detail}`) + planNote + uncertainNote + "\n");
25
+ if (cls) {
26
+ err.write(pc.dim(` (${cls.category}, complexity ${cls.complexity.toFixed(1)}, confidence ${cls.confidence.toFixed(1)})`) + "\n");
27
+ }
28
+ err.write("\n" + pc.dim(LINE) + "\n");
29
+ }
30
+ export function askRouteChoice() {
31
+ return new Promise((resolve) => {
32
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
33
+ process.stderr.write(" " +
34
+ pc.green("[Y]") +
35
+ pc.dim("es ") +
36
+ pc.red("[n]") +
37
+ pc.dim("o, original ") +
38
+ pc.cyan("[e]") +
39
+ pc.dim("dit ") +
40
+ pc.magenta("[c]") +
41
+ pc.dim("laude ") +
42
+ pc.magenta("[l]") +
43
+ pc.dim("ocal ") +
44
+ pc.magenta("[o]") +
45
+ pc.dim("penrouter ") +
46
+ pc.dim(`(${TIMEOUT_MS / 1000}s timeout → Y): `));
47
+ let settled = false;
48
+ const finish = (choice) => {
49
+ if (settled)
50
+ return;
51
+ settled = true;
52
+ clearTimeout(timer);
53
+ rl.close();
54
+ resolve(choice);
55
+ };
56
+ const timer = setTimeout(() => {
57
+ process.stderr.write(pc.dim("Y\n"));
58
+ finish({ action: "accept" });
59
+ }, TIMEOUT_MS);
60
+ rl.once("close", () => finish({ action: "accept" }));
61
+ rl.once("line", (line) => {
62
+ const answer = line.trim().toLowerCase();
63
+ if (answer === "n" || answer === "no")
64
+ finish({ action: "reject" });
65
+ else if (answer === "e" || answer === "edit")
66
+ finish({ action: "edit" });
67
+ else if (answer === "c")
68
+ finish({ action: "accept", overrideTarget: "claude" });
69
+ else if (answer === "l")
70
+ finish({ action: "accept", overrideTarget: "local" });
71
+ else if (answer === "o")
72
+ finish({ action: "accept", overrideTarget: "openrouter" });
73
+ else
74
+ finish({ action: "accept" });
75
+ });
76
+ });
77
+ }
78
+ export function showPlan(plan) {
79
+ const err = process.stderr;
80
+ err.write("\n" + pc.dim(LINE) + "\n");
81
+ err.write(pc.bold(" PLAN") + pc.dim(" — will be attached to the prompt\n"));
82
+ err.write(pc.dim(LINE) + "\n\n");
83
+ err.write(pc.cyan(" " + plan.replace(/\n/g, "\n ")) + "\n\n");
84
+ err.write(pc.dim(LINE) + "\n");
85
+ }
86
+ export function askPlanChoice() {
87
+ return new Promise((resolve) => {
88
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
89
+ process.stderr.write(" " +
90
+ pc.green("[Y]") +
91
+ pc.dim("es, attach ") +
92
+ pc.red("[n]") +
93
+ pc.dim("o, skip plan ") +
94
+ pc.cyan("[e]") +
95
+ pc.dim("dit ") +
96
+ pc.dim(`(${TIMEOUT_MS / 1000}s timeout → Y): `));
97
+ let settled = false;
98
+ const finish = (choice) => {
99
+ if (settled)
100
+ return;
101
+ settled = true;
102
+ clearTimeout(timer);
103
+ rl.close();
104
+ resolve(choice);
105
+ };
106
+ const timer = setTimeout(() => {
107
+ process.stderr.write(pc.dim("Y\n"));
108
+ finish("accept");
109
+ }, TIMEOUT_MS);
110
+ rl.once("close", () => finish("accept"));
111
+ rl.once("line", (line) => {
112
+ const answer = line.trim().toLowerCase();
113
+ if (answer === "n" || answer === "no")
114
+ finish("skip");
115
+ else if (answer === "e" || answer === "edit")
116
+ finish("edit");
117
+ else
118
+ finish("accept");
119
+ });
120
+ });
121
+ }
122
+ export function showPassThrough(reason) {
123
+ process.stderr.write(pc.dim(`prompt-router: ${reason}.\n`));
124
+ }
125
+ export function showError(message) {
126
+ process.stderr.write(pc.yellow(`prompt-router: ${message}\n`));
127
+ }
128
+ export function startSpinner(label) {
129
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
130
+ let i = 0;
131
+ process.stderr.write(pc.dim(` ${label}`));
132
+ const interval = setInterval(() => {
133
+ process.stderr.write("\r" + pc.dim(` ${frames[i]} ${label}`));
134
+ i = (i + 1) % frames.length;
135
+ }, 80);
136
+ return () => {
137
+ clearInterval(interval);
138
+ process.stderr.write("\r" + " ".repeat(label.length + 6) + "\r");
139
+ };
140
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "prompt-router",
3
+ "version": "0.1.0",
4
+ "description": "Route your prompts to the right AI: coding tasks to Claude Code, simple questions to your local model, deep questions to OpenRouter — with prompt optimization built in.",
5
+ "type": "module",
6
+ "bin": {
7
+ "prompt-router": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.build.json",
11
+ "dev": "tsx src/index.ts",
12
+ "typecheck": "tsc --noEmit",
13
+ "prepublishOnly": "pnpm typecheck && pnpm test && pnpm build",
14
+ "test": "vitest run",
15
+ "test:watch": "vitest"
16
+ },
17
+ "dependencies": {
18
+ "dotenv": "^16.4.5",
19
+ "picocolors": "^1.1.1"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.0.0",
23
+ "tsx": "^4.19.0",
24
+ "typescript": "^5.6.0",
25
+ "vitest": "^3.0.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "keywords": [
34
+ "claude",
35
+ "claude-code",
36
+ "router",
37
+ "llm",
38
+ "openrouter",
39
+ "lm-studio",
40
+ "prompt",
41
+ "ai",
42
+ "cli"
43
+ ],
44
+ "license": "MIT",
45
+ "author": "Ege",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/PTB-0/prompt-router"
49
+ }
50
+ }