@kkothari/tokenpilot 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/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # TokenPilot
2
+
3
+ **Terminal-first AI assistant for vibecoders.** TokenPilot sits between you and
4
+ your AI coding tool (Claude Code, Cursor, Codex, Gemini CLI, …). Before a prompt
5
+ is sent, it estimates how expensive the prompt will be, flags whether it's too
6
+ broad, and rewrites it into a focused version that keeps the AI working on the
7
+ right part of your project instead of searching the whole codebase.
8
+
9
+ > Like Grammarly for AI coding prompts — but built for the terminal.
10
+
11
+ This repo is the **CLI core** (MVP). Analysis, token estimation, prompt
12
+ rewriting, and session tracking all run **locally, offline, with no API key.**
13
+
14
+ ---
15
+
16
+ ## Quick start
17
+
18
+ No install needed to try it (zero runtime dependencies, Node ≥ 18):
19
+
20
+ ```bash
21
+ node bin/tokenpilot.js analyze "Fix the dashboard bug"
22
+ ```
23
+
24
+ Or link it globally so `tokenpilot` is on your PATH:
25
+
26
+ ```bash
27
+ npm link # from this directory
28
+ tokenpilot start
29
+ ```
30
+
31
+ ---
32
+
33
+ ## What you get
34
+
35
+ ```
36
+ $ tokenpilot analyze "Fix the dashboard bug related to token usage"
37
+
38
+ ⚠ BROAD breadth 68/100
39
+ ████████████████░░░░░░░░
40
+
41
+ Projected cost ~30,867 tokens (prompt 11 + exploration 30,856) ≈ $0.19
42
+
43
+ Issues
44
+ ✕ Broad scope ("the dashboard") — likely to trigger a full-project search.
45
+ ✕ Vague instruction ("fix") with no specific target.
46
+ ! No file, path, or symbol referenced — the assistant must go find it.
47
+
48
+ Likely-relevant files
49
+ ▫ src/Dashboard.tsx
50
+ ▫ src/tokenCalc.ts
51
+ ▫ src/usageApi.ts
52
+
53
+ ────────────────────────────────────────────────────────────
54
+ Suggested prompt saves ~25,801 tokens (84%)
55
+
56
+ Fix the dashboard bug related to token usage. Begin by checking
57
+ `src/Dashboard.tsx`, `src/tokenCalc.ts`, `src/usageApi.ts`. Do not
58
+ redesign the UI or refactor unrelated components. Make the smallest
59
+ change necessary. Briefly explain which files you modified and why.
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Commands
65
+
66
+ | Command | What it does |
67
+ | --- | --- |
68
+ | `tokenpilot start` | Interactive REPL — analyzes every prompt before you send it, tracks running savings. The primary experience. |
69
+ | `tokenpilot analyze "<prompt>"` | Analyze one prompt and print a focused rewrite. |
70
+ | `tokenpilot stats` | Session analytics: tokens projected, saved, $ saved, most expensive prompts. |
71
+ | `tokenpilot history` | Every prompt analyzed this session. |
72
+ | `tokenpilot reset` | Start a fresh session. |
73
+ | `tokenpilot help` | Full help. |
74
+
75
+ ### Options
76
+
77
+ | Flag | Meaning |
78
+ | --- | --- |
79
+ | `--provider <id>` | Pricing model for `$` estimates: `claude-opus`, `claude-sonnet` (default), `claude-haiku`, `gpt-4o`, `gemini-pro`. |
80
+ | `--json` | Machine-readable output (for `analyze`). |
81
+ | `--no-scan` | Skip the project file scan. |
82
+ | `--no-track` | Don't record the prompt in the session. |
83
+
84
+ In the `start` REPL: `:stats`, `:clear`, `:quit`.
85
+
86
+ ---
87
+
88
+ ## How it works
89
+
90
+ Everything is local, deterministic, and dependency-free.
91
+
92
+ - **`src/core/tokenizer.js`** — offline token estimator (blends chars/4 and
93
+ words/0.75, with a punctuation bump). Within ~10–15% of real BPE counts.
94
+ - **`src/core/analyzer.js`** — the heuristic engine. Detects vague verbs, broad
95
+ scope, heavy-change verbs, missing file references, over-short/over-long
96
+ prompts, missing scope guards, and near-duplicate prompts. Produces a 0–100
97
+ **breadth score** and projects total token cost. A concrete file reference and
98
+ an explicit scope guard each measurably lower the projected exploration cost —
99
+ because those are what actually bound how far the AI wanders.
100
+ - **`src/core/scanner.js`** — scans the working directory for source files whose
101
+ names/paths match your prompt keywords, so rewrites can name real files.
102
+ - **`src/core/rewrite.js`** — turns intent + missing pieces into a focused
103
+ prompt: original intent → concrete starting point → scope guard → report-back.
104
+ - **`src/core/session.js`** — local session log at `~/.tokenpilot/session.json`.
105
+
106
+ ---
107
+
108
+ ## Roadmap
109
+
110
+ The MVP CLI is here. Still to come, per the product vision:
111
+
112
+ - Browser-based account auth (GitHub / Google) and terminal ↔ account linking
113
+ - Dashboard sync + web analytics
114
+ - Optional AI-powered rewrites (hybrid: heuristics for detection, an LLM call
115
+ for the rewrite) via the Vercel AI Gateway
116
+ - Landing page and `npx tokenpilot install`
117
+
118
+ ---
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ npm test # run the unit test suite (node --test)
124
+ ```
125
+
126
+ MIT.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // TokenPilot CLI entry point.
3
+ import { run } from "../src/cli.js";
4
+
5
+ const code = run();
6
+ if (typeof code === "number" && code !== 0) {
7
+ process.exitCode = code;
8
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@kkothari/tokenpilot",
3
+ "version": "0.1.0",
4
+ "description": "Terminal-first AI assistant that analyzes coding prompts before they reach AI tools, flags broad prompts, estimates token cost, and rewrites vague prompts into focused ones.",
5
+ "type": "module",
6
+ "bin": {
7
+ "tokenpilot": "bin/tokenpilot.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/tokenpilot.js start",
11
+ "analyze": "node bin/tokenpilot.js analyze",
12
+ "test": "node --test"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "src",
20
+ "README.md"
21
+ ],
22
+ "keywords": [
23
+ "ai",
24
+ "tokens",
25
+ "prompt",
26
+ "cli",
27
+ "claude",
28
+ "cursor",
29
+ "llm",
30
+ "developer-tools"
31
+ ],
32
+ "license": "MIT"
33
+ }
package/src/cli.js ADDED
@@ -0,0 +1,105 @@
1
+ // Argument parsing and command dispatch for the `tokenpilot` CLI.
2
+
3
+ import { runAnalyze } from "./commands/analyze.js";
4
+ import { runStart } from "./commands/start.js";
5
+ import { runStats, runHistory, runReset } from "./commands/stats.js";
6
+ import { colors } from "./ui/colors.js";
7
+ import { banner } from "./ui/format.js";
8
+ import { PROVIDERS, DEFAULT_PROVIDER } from "./config.js";
9
+
10
+ const VERSION = "0.1.0";
11
+
12
+ // Minimal flag parser: separates --flags (and --key value) from positionals.
13
+ function parseArgs(argv) {
14
+ const flags = {};
15
+ const positionals = [];
16
+ for (let i = 0; i < argv.length; i++) {
17
+ const arg = argv[i];
18
+ if (arg.startsWith("--")) {
19
+ const key = arg.slice(2);
20
+ if (key.startsWith("no-")) {
21
+ flags[key.slice(3)] = false;
22
+ } else if (key === "json" || key === "help" || key === "version") {
23
+ flags[key] = true;
24
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
25
+ flags[key] = argv[++i];
26
+ } else {
27
+ flags[key] = true;
28
+ }
29
+ } else {
30
+ positionals.push(arg);
31
+ }
32
+ }
33
+ return { flags, positionals };
34
+ }
35
+
36
+ function printHelp() {
37
+ const c = colors;
38
+ console.log(`
39
+ ${banner()}
40
+
41
+ ${c.bold("USAGE")}
42
+ tokenpilot <command> [options]
43
+
44
+ ${c.bold("COMMANDS")}
45
+ ${c.cyan("start")} Launch the interactive terminal companion (REPL)
46
+ ${c.cyan("analyze")} ${c.gray('"<prompt>"')} Analyze a single prompt and suggest a focused rewrite
47
+ ${c.cyan("stats")} Show token-savings analytics for this session
48
+ ${c.cyan("history")} List every prompt analyzed this session
49
+ ${c.cyan("reset")} Clear the current session and start fresh
50
+ ${c.cyan("help")} Show this help
51
+
52
+ ${c.bold("OPTIONS")}
53
+ ${c.gray("--provider <id>")} Pricing model for $ estimates ${c.gray(
54
+ `(default: ${DEFAULT_PROVIDER})`
55
+ )}
56
+ ${c.gray(Object.keys(PROVIDERS).join(", "))}
57
+ ${c.gray("--json")} Machine-readable output (analyze)
58
+ ${c.gray("--no-scan")} Skip the project file scan
59
+ ${c.gray("--no-track")} Don't record this prompt in the session
60
+ ${c.gray("--version")} Print version
61
+
62
+ ${c.bold("EXAMPLES")}
63
+ ${c.gray("$")} tokenpilot analyze ${c.gray('"Fix the dashboard bug"')}
64
+ ${c.gray("$")} tokenpilot analyze ${c.gray('"add auth"')} --provider claude-opus
65
+ ${c.gray("$")} tokenpilot start
66
+ ${c.gray("$")} tokenpilot stats
67
+ `);
68
+ }
69
+
70
+ export function run(argv = process.argv.slice(2)) {
71
+ const { flags, positionals } = parseArgs(argv);
72
+
73
+ if (flags.version) {
74
+ console.log(`tokenpilot ${VERSION}`);
75
+ return 0;
76
+ }
77
+
78
+ const command = positionals[0] || (flags.help ? "help" : "help");
79
+
80
+ switch (command) {
81
+ case "analyze": {
82
+ const prompt = positionals.slice(1).join(" ");
83
+ return runAnalyze(prompt, flags);
84
+ }
85
+ case "start":
86
+ return runStart(flags);
87
+ case "stats":
88
+ return runStats(flags);
89
+ case "history":
90
+ return runHistory(flags);
91
+ case "reset":
92
+ return runReset(flags);
93
+ case "help":
94
+ printHelp();
95
+ return 0;
96
+ default:
97
+ console.error(
98
+ colors.red(`Unknown command: ${command}`) +
99
+ "\n Run " +
100
+ colors.cyan("tokenpilot help") +
101
+ " to see available commands."
102
+ );
103
+ return 1;
104
+ }
105
+ }
@@ -0,0 +1,72 @@
1
+ // `tokenpilot analyze "<prompt>"` — analyze one prompt and suggest a focused
2
+ // rewrite. Supports --json for programmatic use and --no-scan to skip the disk
3
+ // scan.
4
+
5
+ import { optimize } from "../core/rewrite.js";
6
+ import { findRelevantFiles } from "../core/scanner.js";
7
+ import { renderAnalysis, banner } from "../ui/format.js";
8
+ import { colors } from "../ui/colors.js";
9
+ import { record } from "../core/session.js";
10
+ import { DEFAULT_PROVIDER } from "../config.js";
11
+
12
+ export function runAnalyze(prompt, flags = {}) {
13
+ if (!prompt || !prompt.trim()) {
14
+ console.error(
15
+ colors.red("No prompt provided.") +
16
+ ' Usage: tokenpilot analyze "your prompt here"'
17
+ );
18
+ return 1;
19
+ }
20
+
21
+ const provider = flags.provider || DEFAULT_PROVIDER;
22
+ const relevantFiles =
23
+ flags.scan === false ? [] : findRelevantFiles(prompt, process.cwd());
24
+
25
+ const result = optimize(prompt, { relevantFiles });
26
+
27
+ if (flags.json) {
28
+ console.log(
29
+ JSON.stringify(
30
+ {
31
+ prompt,
32
+ breadthScore: result.analysis.breadthScore,
33
+ rating: result.analysis.rating,
34
+ projectedTokens: result.analysis.projectedTokens,
35
+ promptTokens: result.analysis.promptTokens,
36
+ explorationTokens: result.analysis.explorationTokens,
37
+ issues: result.analysis.issues,
38
+ relevantFiles,
39
+ suggestion: result.focused.text,
40
+ optimizedTokens: result.rewrittenAnalysis.projectedTokens,
41
+ savedTokens: result.savedTokens,
42
+ savedPct: result.savedPct,
43
+ },
44
+ null,
45
+ 2
46
+ )
47
+ );
48
+ } else {
49
+ console.log(banner());
50
+ console.log(
51
+ renderAnalysis(result.analysis, {
52
+ optimize: result,
53
+ relevantFiles,
54
+ provider,
55
+ })
56
+ );
57
+ }
58
+
59
+ // Track it (best-effort) unless disabled.
60
+ if (flags.track !== false) {
61
+ record({
62
+ prompt,
63
+ breadthScore: result.analysis.breadthScore,
64
+ rating: result.analysis.rating,
65
+ projectedTokens: result.analysis.projectedTokens,
66
+ optimizedTokens: result.rewrittenAnalysis.projectedTokens,
67
+ savedTokens: result.savedTokens,
68
+ });
69
+ }
70
+
71
+ return 0;
72
+ }
@@ -0,0 +1,131 @@
1
+ // `tokenpilot start` — the always-on terminal companion.
2
+ //
3
+ // Opens an interactive REPL that analyzes every prompt you paste before you
4
+ // send it to your AI coding tool, shows a focused rewrite, and tracks running
5
+ // token savings for the session. This is the primary TokenPilot experience.
6
+ //
7
+ // (Account linking / browser auth is stubbed for the MVP — see the note printed
8
+ // on launch. Analysis and tracking work fully offline today.)
9
+
10
+ import readline from "node:readline";
11
+ import { optimize } from "../core/rewrite.js";
12
+ import { findRelevantFiles } from "../core/scanner.js";
13
+ import { renderAnalysis, banner, num, money } from "../ui/format.js";
14
+ import { colors } from "../ui/colors.js";
15
+ import { load, record, summarize } from "../core/session.js";
16
+ import { DEFAULT_PROVIDER } from "../config.js";
17
+
18
+ export function runStart(flags = {}) {
19
+ const provider = flags.provider || DEFAULT_PROVIDER;
20
+
21
+ console.log("");
22
+ console.log(banner());
23
+ console.log(
24
+ colors.gray(
25
+ " Session tracking active. Paste a prompt to analyze it before you send it."
26
+ )
27
+ );
28
+ console.log(
29
+ colors.gray(
30
+ " Commands: ") +
31
+ colors.cyan(":stats") +
32
+ colors.gray(" summary · ") +
33
+ colors.cyan(":clear") +
34
+ colors.gray(" screen · ") +
35
+ colors.cyan(":quit") +
36
+ colors.gray(" exit")
37
+ );
38
+ console.log(
39
+ colors.gray(" Account linking (browser auth) arrives in the next build.")
40
+ );
41
+ console.log("");
42
+
43
+ const rl = readline.createInterface({
44
+ input: process.stdin,
45
+ output: process.stdout,
46
+ prompt: colors.cyan("tokenpilot") + colors.gray(" › "),
47
+ });
48
+
49
+ // Keep an in-memory history for duplicate detection within this REPL.
50
+ const history = (load().prompts || []).map((p) => p.prompt);
51
+
52
+ rl.prompt();
53
+
54
+ rl.on("line", (line) => {
55
+ const input = line.trim();
56
+
57
+ if (!input) {
58
+ rl.prompt();
59
+ return;
60
+ }
61
+
62
+ // REPL commands.
63
+ if (input === ":quit" || input === ":q" || input === ":exit") {
64
+ rl.close();
65
+ return;
66
+ }
67
+ if (input === ":clear" || input === ":cls") {
68
+ console.clear();
69
+ rl.prompt();
70
+ return;
71
+ }
72
+ if (input === ":stats") {
73
+ printMiniStats(provider);
74
+ rl.prompt();
75
+ return;
76
+ }
77
+
78
+ // Analyze the prompt.
79
+ const relevantFiles =
80
+ flags.scan === false ? [] : findRelevantFiles(input, process.cwd());
81
+ const result = optimize(input, { relevantFiles, history });
82
+
83
+ console.log(
84
+ renderAnalysis(result.analysis, {
85
+ optimize: result,
86
+ relevantFiles,
87
+ provider,
88
+ })
89
+ );
90
+
91
+ history.push(input);
92
+ record({
93
+ prompt: input,
94
+ breadthScore: result.analysis.breadthScore,
95
+ rating: result.analysis.rating,
96
+ projectedTokens: result.analysis.projectedTokens,
97
+ optimizedTokens: result.rewrittenAnalysis.projectedTokens,
98
+ savedTokens: result.savedTokens,
99
+ });
100
+
101
+ rl.prompt();
102
+ });
103
+
104
+ rl.on("close", () => {
105
+ const s = summarize(load(), provider);
106
+ console.log("");
107
+ console.log(
108
+ colors.gray(" Session ended · ") +
109
+ `${num(s.total)} prompts analyzed, ` +
110
+ colors.green(`~${num(s.savedTokens)} tokens saved`) +
111
+ colors.gray(` (≈ ${money(s.dollarsSaved)})`)
112
+ );
113
+ console.log(colors.gray(" Run ") + colors.cyan("tokenpilot stats") + colors.gray(" any time.\n"));
114
+ process.exit(0);
115
+ });
116
+ }
117
+
118
+ function printMiniStats(provider) {
119
+ const s = summarize(load(), provider);
120
+ console.log("");
121
+ console.log(
122
+ colors.bold(" This session ") +
123
+ colors.gray(`${num(s.total)} prompts · ${num(s.broad)} broad`)
124
+ );
125
+ console.log(
126
+ ` Projected ${colors.cyan(num(s.projectedTokens))} tokens · ` +
127
+ colors.green(`saved ~${num(s.savedTokens)} (${s.savedPct}%)`) +
128
+ colors.gray(` ≈ ${money(s.dollarsSaved)}`)
129
+ );
130
+ console.log("");
131
+ }
@@ -0,0 +1,108 @@
1
+ // `tokenpilot stats` — session analytics summary.
2
+ // `tokenpilot history` — the full prompt log.
3
+ // `tokenpilot reset` — start a fresh session.
4
+
5
+ import { load, summarize, reset as resetSession, SESSION_FILE } from "../core/session.js";
6
+ import { colors } from "../ui/colors.js";
7
+ import { banner, num, money, bar } from "../ui/format.js";
8
+ import { DEFAULT_PROVIDER, PROVIDERS } from "../config.js";
9
+
10
+ function truncate(s, n = 52) {
11
+ s = String(s).replace(/\s+/g, " ");
12
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
13
+ }
14
+
15
+ export function runStats(flags = {}) {
16
+ const provider = flags.provider || DEFAULT_PROVIDER;
17
+ const s = summarize(load(), provider);
18
+
19
+ console.log("");
20
+ console.log(banner());
21
+
22
+ if (s.total === 0) {
23
+ console.log(
24
+ colors.gray(
25
+ "\n No prompts tracked yet. Run `tokenpilot start` or `tokenpilot analyze \"…\"`.\n"
26
+ )
27
+ );
28
+ return 0;
29
+ }
30
+
31
+ const label = (PROVIDERS[provider] || PROVIDERS[DEFAULT_PROVIDER]).label;
32
+ console.log(
33
+ colors.gray(
34
+ ` Session ${s.id} · since ${new Date(s.startedAt).toLocaleString()} · ${label}\n`
35
+ )
36
+ );
37
+
38
+ const row = (k, v) => console.log(` ${colors.gray(k.padEnd(20))}${v}`);
39
+ row("Prompts analyzed", num(s.total));
40
+ row("Broad prompts", `${num(s.broad)} ${colors.gray(`of ${num(s.total)}`)}`);
41
+ row("Projected tokens", colors.cyan(num(s.projectedTokens)));
42
+ row("Optimized tokens", num(s.optimizedTokens));
43
+ row(
44
+ "Tokens saved",
45
+ colors.green(`~${num(s.savedTokens)}`) + colors.gray(` (${s.savedPct}%)`)
46
+ );
47
+ row("Estimated $ saved", colors.green(money(s.dollarsSaved)));
48
+ console.log(` ${colors.gray("Savings".padEnd(20))}${bar(s.savedPct)}`);
49
+
50
+ if (s.mostExpensive.length) {
51
+ console.log("\n " + colors.bold("Most expensive prompts"));
52
+ for (const p of s.mostExpensive) {
53
+ console.log(
54
+ ` ${colors.yellow(String(num(p.projectedTokens)).padStart(7))} ${colors.gray(
55
+ truncate(p.prompt)
56
+ )}`
57
+ );
58
+ }
59
+ }
60
+
61
+ if (s.biggestSavers.some((p) => p.savedTokens > 0)) {
62
+ console.log("\n " + colors.bold("Biggest savers"));
63
+ for (const p of s.biggestSavers) {
64
+ if (!p.savedTokens) continue;
65
+ console.log(
66
+ ` ${colors.green("-" + num(p.savedTokens)).padStart(18)} ${colors.gray(
67
+ truncate(p.prompt)
68
+ )}`
69
+ );
70
+ }
71
+ }
72
+
73
+ console.log(colors.gray(`\n Data: ${SESSION_FILE}\n`));
74
+ return 0;
75
+ }
76
+
77
+ export function runHistory() {
78
+ const session = load();
79
+ const prompts = session.prompts || [];
80
+ console.log("");
81
+ console.log(banner());
82
+ if (prompts.length === 0) {
83
+ console.log(colors.gray("\n No prompt history yet.\n"));
84
+ return 0;
85
+ }
86
+ console.log("");
87
+ prompts.forEach((p, i) => {
88
+ const tag =
89
+ p.rating === "broad"
90
+ ? colors.red("BROAD")
91
+ : p.rating === "moderate"
92
+ ? colors.yellow("MOD ")
93
+ : colors.green("OK ");
94
+ console.log(
95
+ ` ${colors.gray(String(i + 1).padStart(3))}. [${tag}] ` +
96
+ `${colors.cyan(String(num(p.projectedTokens)).padStart(6))}t ` +
97
+ colors.gray(truncate(p.prompt, 56))
98
+ );
99
+ });
100
+ console.log("");
101
+ return 0;
102
+ }
103
+
104
+ export function runReset() {
105
+ resetSession();
106
+ console.log(colors.green("\n ✓ Session reset. A fresh session has begun.\n"));
107
+ return 0;
108
+ }
package/src/config.js ADDED
@@ -0,0 +1,31 @@
1
+ // Central configuration and pricing model for TokenPilot.
2
+ // Prices are blended $ per 1M tokens (input + output averaged) and are only
3
+ // used to turn token estimates into rough dollar figures. They are deliberately
4
+ // approximate — the point is relative savings, not billing accuracy.
5
+
6
+ export const PROVIDERS = {
7
+ "claude-opus": { label: "Claude Opus", pricePer1M: 30 },
8
+ "claude-sonnet": { label: "Claude Sonnet", pricePer1M: 6 },
9
+ "claude-haiku": { label: "Claude Haiku", pricePer1M: 1.5 },
10
+ "gpt-4o": { label: "GPT-4o", pricePer1M: 7.5 },
11
+ "gemini-pro": { label: "Gemini Pro", pricePer1M: 4 },
12
+ };
13
+
14
+ export const DEFAULT_PROVIDER = "claude-sonnet";
15
+
16
+ // How many tokens a single unfocused prompt tends to burn once the coding
17
+ // assistant starts searching and reading files. Used as the ceiling that a
18
+ // maximally broad prompt (breadthScore = 100) is projected to cost.
19
+ export const MAX_EXPLORATION_TOKENS = 45000;
20
+
21
+ // Even a perfectly scoped prompt still costs *some* exploration on top of the
22
+ // prompt text itself. This is the floor.
23
+ export const MIN_EXPLORATION_TOKENS = 800;
24
+
25
+ export function priceFor(providerKey = DEFAULT_PROVIDER) {
26
+ return (PROVIDERS[providerKey] || PROVIDERS[DEFAULT_PROVIDER]).pricePer1M;
27
+ }
28
+
29
+ export function dollarsFor(tokens, providerKey = DEFAULT_PROVIDER) {
30
+ return (tokens / 1_000_000) * priceFor(providerKey);
31
+ }
@@ -0,0 +1,218 @@
1
+ // The prompt analysis engine.
2
+ //
3
+ // Given a raw prompt (and optional session history), it detects the patterns
4
+ // that make AI coding assistants waste tokens, produces a 0-100 "breadth score"
5
+ // (higher = broader = more wasteful), and projects how many tokens the prompt
6
+ // is likely to burn once the assistant starts exploring the codebase.
7
+ //
8
+ // Everything here is local, rule-based, and deterministic. No network, no keys.
9
+
10
+ import { estimateTokens } from "./tokenizer.js";
11
+ import {
12
+ MAX_EXPLORATION_TOKENS,
13
+ MIN_EXPLORATION_TOKENS,
14
+ } from "../config.js";
15
+
16
+ // --- Signal dictionaries ----------------------------------------------------
17
+
18
+ // Vague action verbs that describe intent without a concrete target.
19
+ const VAGUE_VERBS = [
20
+ "fix", "improve", "optimize", "clean up", "cleanup", "enhance", "polish",
21
+ "handle", "update", "tweak", "sort out", "deal with", "work on", "look into",
22
+ ];
23
+
24
+ // Words that widen scope to large or unbounded areas of the codebase.
25
+ const BROAD_SCOPE = [
26
+ "everything", "the whole", "entire", "all the", "the codebase", "the project",
27
+ "the app", "the application", "the system", "the frontend", "the backend",
28
+ "the ui", "the dashboard", "the site", "the website", "throughout",
29
+ "across the", "anywhere", "wherever",
30
+ ];
31
+
32
+ // Words that trigger large, sprawling changes.
33
+ const HEAVY_CHANGE = [
34
+ "refactor", "redesign", "rewrite", "overhaul", "restructure", "rearchitect",
35
+ "migrate", "modernize", "revamp", "rework", "clean up the whole",
36
+ ];
37
+
38
+ // A prompt that references a file, path, or symbol is already fairly scoped.
39
+ const FILE_REF = /([\w./-]+\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|php|vue|svelte|css|scss|html|json|md))\b/i;
40
+ const PATH_REF = /(^|\s)(src|app|lib|components|pages|api|routes|packages|apps)[\\/][\w./-]+/i;
41
+ const CODE_REF = /`[^`]+`/; // backtick-wrapped symbol or identifier
42
+
43
+ // Explicit scope guards the developer might already include.
44
+ const HAS_CONSTRAINT =
45
+ /(smallest change|do not refactor|don'?t refactor|only (change|modify|touch)|without (changing|touching)|minimal|just this|scope|leave .* unchanged)/i;
46
+
47
+ // --- Helpers ----------------------------------------------------------------
48
+
49
+ const includesAny = (text, phrases) =>
50
+ phrases.filter((p) => text.includes(p));
51
+
52
+ function clamp(n, lo, hi) {
53
+ return Math.max(lo, Math.min(hi, n));
54
+ }
55
+
56
+ // --- Main analysis ----------------------------------------------------------
57
+
58
+ /**
59
+ * @param {string} prompt
60
+ * @param {object} [opts]
61
+ * @param {string[]} [opts.history] previously-seen prompts this session
62
+ * @returns {object} analysis result
63
+ */
64
+ export function analyzePrompt(prompt, opts = {}) {
65
+ const raw = String(prompt || "").trim();
66
+ const text = raw.toLowerCase();
67
+ const words = raw.split(/\s+/).filter(Boolean);
68
+ const history = opts.history || [];
69
+
70
+ const issues = [];
71
+ const add = (id, severity, weight, message, hint) =>
72
+ issues.push({ id, severity, weight, message, hint });
73
+
74
+ const hasFileRef = FILE_REF.test(raw) || PATH_REF.test(raw) || CODE_REF.test(raw);
75
+
76
+ // 1. Vague action verb with no concrete target.
77
+ const vague = includesAny(text, VAGUE_VERBS);
78
+ if (vague.length && !hasFileRef) {
79
+ add(
80
+ "vague-verb",
81
+ "high",
82
+ 22,
83
+ `Vague instruction ("${vague[0]}") with no specific target.`,
84
+ "Name the file, function, or exact behavior to change."
85
+ );
86
+ }
87
+
88
+ // 2. Broad scope language.
89
+ const broad = includesAny(text, BROAD_SCOPE);
90
+ if (broad.length) {
91
+ add(
92
+ "broad-scope",
93
+ "high",
94
+ 24,
95
+ `Broad scope ("${broad[0]}") — likely to trigger a full-project search.`,
96
+ "Point at the specific area or files involved."
97
+ );
98
+ }
99
+
100
+ // 3. Heavy / sprawling change verbs.
101
+ const heavy = includesAny(text, HEAVY_CHANGE);
102
+ if (heavy.length) {
103
+ add(
104
+ "heavy-change",
105
+ "medium",
106
+ 16,
107
+ `Large-change verb ("${heavy[0]}") can cause unrelated rewrites.`,
108
+ "Constrain it: smallest change necessary, no unrelated refactors."
109
+ );
110
+ }
111
+
112
+ // 4. No file / path / symbol reference at all.
113
+ if (!hasFileRef) {
114
+ add(
115
+ "no-file-ref",
116
+ "medium",
117
+ 14,
118
+ "No file, path, or symbol referenced — the assistant must go find it.",
119
+ "Add a starting point, e.g. `Dashboard.tsx` or `src/api/usage.ts`."
120
+ );
121
+ }
122
+
123
+ // 5. Too short to be actionable.
124
+ if (words.length > 0 && words.length < 5) {
125
+ add(
126
+ "too-short",
127
+ "medium",
128
+ 12,
129
+ `Very short prompt (${words.length} words) — likely ambiguous.`,
130
+ "Add the what, where, and any constraints."
131
+ );
132
+ }
133
+
134
+ // 6. Overly long / context-dumping prompt.
135
+ const promptTokens = estimateTokens(raw);
136
+ if (promptTokens > 500) {
137
+ add(
138
+ "excessive-context",
139
+ "low",
140
+ 8,
141
+ `Long prompt (~${promptTokens} tokens) — may include unnecessary context.`,
142
+ "Trim to the essentials the assistant actually needs."
143
+ );
144
+ }
145
+
146
+ // 7. No explicit scope guard.
147
+ if (!HAS_CONSTRAINT.test(raw) && (vague.length || broad.length || heavy.length)) {
148
+ add(
149
+ "no-constraint",
150
+ "low",
151
+ 8,
152
+ "No scope guard — nothing stops the assistant from wandering.",
153
+ 'Add: "Make the smallest change necessary; don\'t refactor unrelated code."'
154
+ );
155
+ }
156
+
157
+ // 8. Repeated / near-duplicate of an earlier prompt this session.
158
+ const repeated = history.find((h) => similarity(h, raw) > 0.85);
159
+ if (repeated) {
160
+ add(
161
+ "repeated",
162
+ "medium",
163
+ 12,
164
+ "Near-duplicate of an earlier prompt — risk of repeating work.",
165
+ "Reference the previous result instead of starting over."
166
+ );
167
+ }
168
+
169
+ // --- Score & projections --------------------------------------------------
170
+
171
+ const rawScore = issues.reduce((s, i) => s + i.weight, 0);
172
+ const breadthScore = clamp(Math.round(rawScore), 0, 100);
173
+
174
+ // Project total tokens the prompt is likely to burn: prompt text + an
175
+ // exploration cost that scales with breadth.
176
+ let exploration =
177
+ MIN_EXPLORATION_TOKENS +
178
+ (MAX_EXPLORATION_TOKENS - MIN_EXPLORATION_TOKENS) * (breadthScore / 100);
179
+
180
+ // Two things genuinely bound how far the assistant wanders — and they're
181
+ // exactly what a good rewrite adds. Reward them multiplicatively so an
182
+ // optimized prompt projects a realistically lower cost.
183
+ const hasConstraint = HAS_CONSTRAINT.test(raw);
184
+ if (hasFileRef) exploration *= 0.45; // a concrete starting point
185
+ if (hasConstraint) exploration *= 0.6; // an explicit scope guard
186
+ exploration = Math.round(Math.max(MIN_EXPLORATION_TOKENS, exploration));
187
+
188
+ const projectedTokens = promptTokens + exploration;
189
+
190
+ return {
191
+ prompt: raw,
192
+ empty: raw.length === 0,
193
+ words: words.length,
194
+ hasFileRef,
195
+ issues: issues.sort((a, b) => b.weight - a.weight),
196
+ breadthScore,
197
+ rating: ratingFor(breadthScore),
198
+ promptTokens,
199
+ explorationTokens: exploration,
200
+ projectedTokens,
201
+ };
202
+ }
203
+
204
+ export function ratingFor(score) {
205
+ if (score >= 55) return "broad";
206
+ if (score >= 25) return "moderate";
207
+ return "focused";
208
+ }
209
+
210
+ // Rough token-set Jaccard similarity for duplicate detection.
211
+ function similarity(a, b) {
212
+ const setA = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
213
+ const setB = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
214
+ if (setA.size === 0 || setB.size === 0) return 0;
215
+ let inter = 0;
216
+ for (const w of setA) if (setB.has(w)) inter++;
217
+ return inter / (setA.size + setB.size - inter);
218
+ }
@@ -0,0 +1,101 @@
1
+ // Turns an analysis result into a focused, rewritten prompt — deterministically,
2
+ // with no AI call. The strategy: preserve the developer's intent, then layer on
3
+ // the scoping scaffolding their prompt was missing (a starting point, a scope
4
+ // guard, and a report-back instruction).
5
+ //
6
+ // When a real project is on disk, `relevantFiles` are woven in so the rewrite
7
+ // names concrete files. Otherwise we insert a clearly-marked placeholder the
8
+ // developer can fill in.
9
+
10
+ import { analyzePrompt } from "./analyzer.js";
11
+ import { estimateTokens } from "./tokenizer.js";
12
+ import {
13
+ MAX_EXPLORATION_TOKENS,
14
+ MIN_EXPLORATION_TOKENS,
15
+ } from "../config.js";
16
+
17
+ function capitalize(s) {
18
+ return s.charAt(0).toUpperCase() + s.slice(1);
19
+ }
20
+
21
+ // Ensure the intent sentence ends cleanly with a single period.
22
+ function normalizeIntent(prompt) {
23
+ let s = prompt.trim().replace(/\s+/g, " ");
24
+ if (!s) return s;
25
+ s = capitalize(s);
26
+ if (!/[.!?]$/.test(s)) s += ".";
27
+ return s;
28
+ }
29
+
30
+ /**
31
+ * @param {object} analysis result from analyzePrompt()
32
+ * @param {object} [opts]
33
+ * @param {string[]} [opts.relevantFiles] files surfaced by the scanner
34
+ * @returns {{ text: string, notes: string[] }}
35
+ */
36
+ export function buildFocusedPrompt(analysis, opts = {}) {
37
+ const relevantFiles = opts.relevantFiles || [];
38
+ const has = (id) => analysis.issues.some((i) => i.id === id);
39
+ const parts = [];
40
+ const notes = [];
41
+
42
+ // 1. Keep the original intent as the lead sentence.
43
+ parts.push(normalizeIntent(analysis.prompt));
44
+
45
+ // 2. Give the assistant a concrete starting point.
46
+ if (!analysis.hasFileRef) {
47
+ if (relevantFiles.length) {
48
+ const list = relevantFiles.map((f) => `\`${f}\``).join(", ");
49
+ parts.push(`Begin by checking ${list}.`);
50
+ notes.push("Added likely-relevant files from a project scan.");
51
+ } else {
52
+ parts.push(
53
+ "Begin by checking [name the 1-3 files most likely involved]."
54
+ );
55
+ notes.push(
56
+ "No project scan available — fill in the bracketed starting files."
57
+ );
58
+ }
59
+ }
60
+
61
+ // 3. Add a scope guard when the prompt could sprawl.
62
+ if (has("broad-scope") || has("heavy-change") || has("no-constraint") || has("vague-verb")) {
63
+ parts.push(
64
+ "Do not redesign the UI or refactor unrelated components. Make the smallest change necessary."
65
+ );
66
+ notes.push("Added a scope guard to prevent unrelated changes.");
67
+ }
68
+
69
+ // 4. Ask for a short report of what changed — cheap and keeps output tight.
70
+ parts.push("Briefly explain which files you modified and why.");
71
+
72
+ const text = parts.join(" ");
73
+
74
+ return { text, notes };
75
+ }
76
+
77
+ // Convenience: analyze + rewrite + compute projected savings in one call.
78
+ export function optimize(prompt, opts = {}) {
79
+ const analysis = analyzePrompt(prompt, opts);
80
+ const focused = buildFocusedPrompt(analysis, opts);
81
+
82
+ // Re-analyze the rewritten prompt to project its (lower) token cost.
83
+ const rewrittenAnalysis = analyzePrompt(focused.text, opts);
84
+ const savedTokens = Math.max(
85
+ 0,
86
+ analysis.projectedTokens - rewrittenAnalysis.projectedTokens
87
+ );
88
+
89
+ return {
90
+ analysis,
91
+ focused,
92
+ rewrittenAnalysis,
93
+ savedTokens,
94
+ savedPct:
95
+ analysis.projectedTokens > 0
96
+ ? Math.round((savedTokens / analysis.projectedTokens) * 100)
97
+ : 0,
98
+ };
99
+ }
100
+
101
+ export { estimateTokens, MAX_EXPLORATION_TOKENS, MIN_EXPLORATION_TOKENS };
@@ -0,0 +1,112 @@
1
+ // Lightweight project scanner.
2
+ //
3
+ // Given a prompt, we try to point the coding assistant at the files it most
4
+ // likely needs — so its rewritten prompt can say "start by checking X, Y"
5
+ // instead of triggering a full-project search. This is pure filename/path
6
+ // heuristics: fast, offline, and good enough to seed a focused prompt.
7
+
8
+ import { readdirSync, statSync } from "node:fs";
9
+ import { join, relative, basename, extname, sep } from "node:path";
10
+
11
+ const IGNORE_DIRS = new Set([
12
+ "node_modules",
13
+ ".git",
14
+ ".next",
15
+ "dist",
16
+ "build",
17
+ "out",
18
+ "coverage",
19
+ ".turbo",
20
+ ".cache",
21
+ "vendor",
22
+ ".venv",
23
+ "__pycache__",
24
+ ]);
25
+
26
+ const SOURCE_EXTS = new Set([
27
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
28
+ ".py", ".go", ".rs", ".java", ".rb", ".php",
29
+ ".vue", ".svelte", ".css", ".scss", ".html",
30
+ ]);
31
+
32
+ // Words too generic to be useful as file-matching signal.
33
+ const STOPWORDS = new Set([
34
+ "the", "a", "an", "and", "or", "but", "to", "of", "in", "on", "for", "with",
35
+ "fix", "add", "make", "update", "change", "improve", "better", "please",
36
+ "bug", "issue", "error", "problem", "code", "file", "files", "app", "this",
37
+ "that", "it", "my", "our", "some", "any", "all", "new", "old", "use", "using",
38
+ ]);
39
+
40
+ function walk(dir, root, files, depth) {
41
+ if (depth > 6) return; // keep scans shallow and fast
42
+ let entries;
43
+ try {
44
+ entries = readdirSync(dir, { withFileTypes: true });
45
+ } catch {
46
+ return;
47
+ }
48
+ for (const entry of entries) {
49
+ if (entry.name.startsWith(".") && entry.name !== ".") continue;
50
+ const full = join(dir, entry.name);
51
+ if (entry.isDirectory()) {
52
+ if (IGNORE_DIRS.has(entry.name)) continue;
53
+ walk(full, root, files, depth + 1);
54
+ } else if (entry.isFile()) {
55
+ if (SOURCE_EXTS.has(extname(entry.name))) {
56
+ // Normalize to forward slashes — these paths go into prompts.
57
+ files.push(relative(root, full).split(sep).join("/"));
58
+ if (files.length > 4000) return; // safety cap for huge repos
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ export function keywordsFromPrompt(prompt) {
65
+ return [
66
+ ...new Set(
67
+ String(prompt)
68
+ .toLowerCase()
69
+ .split(/[^a-z0-9]+/)
70
+ .filter((w) => w.length >= 3 && !STOPWORDS.has(w))
71
+ ),
72
+ ];
73
+ }
74
+
75
+ // Returns up to `limit` files most relevant to the prompt keywords, each with a
76
+ // score. Returns [] when there's no project on disk or nothing matches.
77
+ export function findRelevantFiles(prompt, root = process.cwd(), limit = 4) {
78
+ let stat;
79
+ try {
80
+ stat = statSync(root);
81
+ } catch {
82
+ return [];
83
+ }
84
+ if (!stat.isDirectory()) return [];
85
+
86
+ const files = [];
87
+ walk(root, root, files, 0);
88
+ if (files.length === 0) return [];
89
+
90
+ const keywords = keywordsFromPrompt(prompt);
91
+ if (keywords.length === 0) return [];
92
+
93
+ const scored = [];
94
+ for (const file of files) {
95
+ const lowerPath = file.toLowerCase();
96
+ const base = basename(lowerPath, extname(lowerPath));
97
+ let score = 0;
98
+ for (const kw of keywords) {
99
+ if (base === kw) score += 10; // exact filename match
100
+ else if (base.includes(kw)) score += 5; // filename contains keyword
101
+ else if (lowerPath.includes(kw)) score += 2; // somewhere in the path
102
+ }
103
+ if (score > 0) {
104
+ // Prefer shallower files; deep paths are usually less central.
105
+ const depthPenalty = lowerPath.split("/").length * 0.25;
106
+ scored.push({ file, score: score - depthPenalty });
107
+ }
108
+ }
109
+
110
+ scored.sort((a, b) => b.score - a.score);
111
+ return scored.slice(0, limit).map((s) => s.file);
112
+ }
@@ -0,0 +1,108 @@
1
+ // Local session tracking.
2
+ //
3
+ // TokenPilot keeps a lightweight record of the prompts analyzed this session in
4
+ // a JSON file under the user's home directory. This powers `tokenpilot stats`
5
+ // and, later, dashboard sync. Everything is local and append-only within a
6
+ // session; `reset` starts a fresh one.
7
+
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { dollarsFor, DEFAULT_PROVIDER } from "../config.js";
17
+
18
+ const DIR = join(homedir(), ".tokenpilot");
19
+ const FILE = join(DIR, "session.json");
20
+
21
+ function emptySession() {
22
+ return {
23
+ id: `sess_${Date.now().toString(36)}`,
24
+ startedAt: new Date().toISOString(),
25
+ provider: DEFAULT_PROVIDER,
26
+ prompts: [],
27
+ };
28
+ }
29
+
30
+ export function load() {
31
+ try {
32
+ if (!existsSync(FILE)) return emptySession();
33
+ const data = JSON.parse(readFileSync(FILE, "utf8"));
34
+ if (!data || !Array.isArray(data.prompts)) return emptySession();
35
+ return data;
36
+ } catch {
37
+ return emptySession();
38
+ }
39
+ }
40
+
41
+ export function save(session) {
42
+ try {
43
+ if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true });
44
+ writeFileSync(FILE, JSON.stringify(session, null, 2), "utf8");
45
+ return true;
46
+ } catch {
47
+ return false; // tracking is best-effort; never block the user
48
+ }
49
+ }
50
+
51
+ export function reset() {
52
+ const fresh = emptySession();
53
+ save(fresh);
54
+ return fresh;
55
+ }
56
+
57
+ /**
58
+ * Record one analyzed prompt into the current session.
59
+ * @param {object} entry { prompt, breadthScore, rating, projectedTokens,
60
+ * optimizedTokens, savedTokens }
61
+ */
62
+ export function record(entry) {
63
+ const session = load();
64
+ session.prompts.push({
65
+ at: new Date().toISOString(),
66
+ prompt: entry.prompt,
67
+ breadthScore: entry.breadthScore,
68
+ rating: entry.rating,
69
+ projectedTokens: entry.projectedTokens,
70
+ optimizedTokens: entry.optimizedTokens,
71
+ savedTokens: entry.savedTokens,
72
+ });
73
+ save(session);
74
+ return session;
75
+ }
76
+
77
+ export function summarize(session = load(), provider = session.provider) {
78
+ const prompts = session.prompts || [];
79
+ const total = prompts.length;
80
+ const projected = prompts.reduce((s, p) => s + (p.projectedTokens || 0), 0);
81
+ const optimized = prompts.reduce((s, p) => s + (p.optimizedTokens || 0), 0);
82
+ const saved = prompts.reduce((s, p) => s + (p.savedTokens || 0), 0);
83
+ const broad = prompts.filter((p) => p.rating === "broad").length;
84
+
85
+ const mostExpensive = [...prompts]
86
+ .sort((a, b) => (b.projectedTokens || 0) - (a.projectedTokens || 0))
87
+ .slice(0, 3);
88
+ const biggestSavers = [...prompts]
89
+ .sort((a, b) => (b.savedTokens || 0) - (a.savedTokens || 0))
90
+ .slice(0, 3);
91
+
92
+ return {
93
+ id: session.id,
94
+ startedAt: session.startedAt,
95
+ provider,
96
+ total,
97
+ broad,
98
+ projectedTokens: projected,
99
+ optimizedTokens: optimized,
100
+ savedTokens: saved,
101
+ savedPct: projected > 0 ? Math.round((saved / projected) * 100) : 0,
102
+ dollarsSaved: dollarsFor(saved, provider),
103
+ mostExpensive,
104
+ biggestSavers,
105
+ };
106
+ }
107
+
108
+ export const SESSION_FILE = FILE;
@@ -0,0 +1,36 @@
1
+ // Lightweight token estimator.
2
+ //
3
+ // Real tokenizers (tiktoken, the Claude tokenizer) require large vocab files or
4
+ // a network call. For a terminal companion we want an instant, offline, "close
5
+ // enough" estimate. We blend two well-known heuristics and take the larger,
6
+ // which tracks actual BPE token counts within ~10-15% for typical English +
7
+ // code prompts:
8
+ //
9
+ // • ~4 characters per token
10
+ // • ~0.75 tokens per word (i.e. ~1.33 words per token)
11
+ //
12
+ // Code and punctuation tokenize denser than prose, so we nudge the estimate up
13
+ // when the text is punctuation-heavy.
14
+
15
+ export function estimateTokens(text) {
16
+ if (!text) return 0;
17
+ const trimmed = String(text).trim();
18
+ if (!trimmed) return 0;
19
+
20
+ const chars = trimmed.length;
21
+ const words = trimmed.split(/\s+/).filter(Boolean).length;
22
+
23
+ const byChars = chars / 4;
24
+ const byWords = words / 0.75;
25
+
26
+ // Punctuation / symbol density bumps token count (each often its own token).
27
+ const punctuation = (trimmed.match(/[^\w\s]/g) || []).length;
28
+ const punctuationBoost = punctuation * 0.5;
29
+
30
+ return Math.max(1, Math.round(Math.max(byChars, byWords) + punctuationBoost));
31
+ }
32
+
33
+ // Convenience: estimate tokens for many strings at once.
34
+ export function estimateTokensBatch(texts) {
35
+ return texts.reduce((sum, t) => sum + estimateTokens(t), 0);
36
+ }
@@ -0,0 +1,39 @@
1
+ // Zero-dependency ANSI color helpers.
2
+ // Colors are disabled automatically when output is not a TTY, when NO_COLOR is
3
+ // set, or when TOKENPILOT_NO_COLOR is set — so piped output stays clean.
4
+
5
+ const enabled =
6
+ Boolean(process.stdout.isTTY) &&
7
+ !process.env.NO_COLOR &&
8
+ !process.env.TOKENPILOT_NO_COLOR;
9
+
10
+ const ESC = String.fromCharCode(27); // the ASCII escape character
11
+ const code = (open, close) => (text) =>
12
+ enabled ? `${ESC}[${open}m${text}${ESC}[${close}m` : String(text);
13
+
14
+ export const colors = {
15
+ enabled,
16
+ bold: code(1, 22),
17
+ dim: code(2, 22),
18
+ italic: code(3, 23),
19
+ underline: code(4, 24),
20
+
21
+ red: code(31, 39),
22
+ green: code(32, 39),
23
+ yellow: code(33, 39),
24
+ blue: code(34, 39),
25
+ magenta: code(35, 39),
26
+ cyan: code(36, 39),
27
+ gray: code(90, 39),
28
+
29
+ bgRed: code(41, 49),
30
+ bgYellow: code(43, 49),
31
+ bgGreen: code(42, 49),
32
+ };
33
+
34
+ // Severity → color mapping used across the UI.
35
+ export const severityColor = {
36
+ high: colors.red,
37
+ medium: colors.yellow,
38
+ low: colors.blue,
39
+ };
@@ -0,0 +1,133 @@
1
+ // Terminal rendering for TokenPilot. Boxes, bars, and the analysis report.
2
+
3
+ import { colors, severityColor } from "./colors.js";
4
+ import { dollarsFor } from "../config.js";
5
+
6
+ const RATING_STYLE = {
7
+ focused: { color: colors.green, icon: "✓", label: "FOCUSED" },
8
+ moderate: { color: colors.yellow, icon: "○", label: "MODERATE" },
9
+ broad: { color: colors.red, icon: "⚠", label: "BROAD" },
10
+ };
11
+
12
+ const SEV_ICON = { high: "✕", medium: "!", low: "·" };
13
+
14
+ export function num(n) {
15
+ return Number(n).toLocaleString("en-US");
16
+ }
17
+
18
+ export function money(n) {
19
+ return `$${n.toFixed(n < 1 ? 4 : 2)}`;
20
+ }
21
+
22
+ // A compact horizontal meter, e.g. breadth score.
23
+ export function bar(value, max = 100, width = 24) {
24
+ const filled = Math.round((Math.min(value, max) / max) * width);
25
+ const color =
26
+ value >= 55 ? colors.red : value >= 25 ? colors.yellow : colors.green;
27
+ return color("█".repeat(filled)) + colors.gray("░".repeat(width - filled));
28
+ }
29
+
30
+ // Wrap text to a width, preserving words.
31
+ export function wrap(text, width = 66, indent = " ") {
32
+ const words = String(text).split(/\s+/);
33
+ const lines = [];
34
+ let line = "";
35
+ for (const w of words) {
36
+ if ((line + " " + w).trim().length > width) {
37
+ lines.push(line);
38
+ line = w;
39
+ } else {
40
+ line = (line + " " + w).trim();
41
+ }
42
+ }
43
+ if (line) lines.push(line);
44
+ return lines.map((l) => indent + l).join("\n");
45
+ }
46
+
47
+ function rule(char = "─", width = 60) {
48
+ return colors.gray(char.repeat(width));
49
+ }
50
+
51
+ // The full analysis report for a single prompt.
52
+ export function renderAnalysis(result, opts = {}) {
53
+ const { optimize, relevantFiles = [], provider } = opts;
54
+ const a = result;
55
+ const style = RATING_STYLE[a.rating] || RATING_STYLE.moderate;
56
+ const out = [];
57
+
58
+ out.push("");
59
+ out.push(
60
+ `${style.color(colors.bold(`${style.icon} ${style.label}`))} ` +
61
+ colors.gray(`breadth ${a.breadthScore}/100`)
62
+ );
63
+ out.push(` ${bar(a.breadthScore)}`);
64
+ out.push("");
65
+
66
+ // Projected cost line.
67
+ const dollars = provider ? dollarsFor(a.projectedTokens, provider) : null;
68
+ out.push(
69
+ colors.bold(" Projected cost ") +
70
+ `~${colors.cyan(num(a.projectedTokens))} tokens` +
71
+ colors.gray(
72
+ ` (prompt ${num(a.promptTokens)} + exploration ${num(
73
+ a.explorationTokens
74
+ )})`
75
+ ) +
76
+ (dollars !== null ? colors.gray(` ≈ ${money(dollars)}`) : "")
77
+ );
78
+
79
+ // Issues.
80
+ if (a.issues.length) {
81
+ out.push("");
82
+ out.push(colors.bold(" Issues"));
83
+ for (const issue of a.issues) {
84
+ const sc = severityColor[issue.severity] || colors.gray;
85
+ out.push(` ${sc(SEV_ICON[issue.severity] || "·")} ${issue.message}`);
86
+ if (issue.hint) out.push(colors.gray(` → ${issue.hint}`));
87
+ }
88
+ } else {
89
+ out.push("");
90
+ out.push(colors.green(" ✓ No issues detected — this prompt is well scoped."));
91
+ }
92
+
93
+ // Relevant files (from scan) shown when we have them and the prompt lacked refs.
94
+ if (relevantFiles.length && !a.hasFileRef) {
95
+ out.push("");
96
+ out.push(colors.bold(" Likely-relevant files"));
97
+ for (const f of relevantFiles) out.push(` ${colors.magenta("▫")} ${f}`);
98
+ }
99
+
100
+ // Optimized rewrite — only worth showing when it meaningfully helps.
101
+ if (optimize && optimize.savedTokens < 100) {
102
+ out.push("");
103
+ out.push(rule());
104
+ out.push(
105
+ colors.green(" ✓ Already well-scoped — no rewrite needed.")
106
+ );
107
+ } else if (optimize) {
108
+ out.push("");
109
+ out.push(rule());
110
+ out.push(
111
+ colors.green(colors.bold(" Suggested prompt")) +
112
+ colors.gray(
113
+ ` saves ~${num(optimize.savedTokens)} tokens (${optimize.savedPct}%)`
114
+ )
115
+ );
116
+ out.push("");
117
+ out.push(colors.green(wrap(optimize.focused.text, 64, " ")));
118
+ if (optimize.focused.notes.length) {
119
+ out.push("");
120
+ for (const n of optimize.focused.notes) {
121
+ out.push(colors.gray(` · ${n}`));
122
+ }
123
+ }
124
+ }
125
+
126
+ out.push("");
127
+ return out.join("\n");
128
+ }
129
+
130
+ export function banner() {
131
+ const name = colors.cyan(colors.bold("TokenPilot"));
132
+ return `${name} ${colors.gray("— focus your prompts, save your tokens")}`;
133
+ }