runcap 0.1.1 → 0.2.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 CHANGED
@@ -2,30 +2,42 @@
2
2
 
3
3
  [![CI](https://github.com/kirder24-code/ai-agent-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/kirder24-code/ai-agent-manager/actions/workflows/ci.yml)
4
4
 
5
- **Know what your coding agent will cost before you build it — and set a hard ceiling so it never surprises you.**
5
+ ![Runcap terminal demo: estimate, cap, compress, stop](docs/assets/demo.svg)
6
+
7
+ **Know what your coding agent will cost before you build it, and set a hard ceiling so it never surprises you.**
6
8
 
7
9
  Runcap estimates the cost of an agent run as a range, enforces a hard spend ceiling that physically stops the run, and when the agent gets stuck it hands you the exact rescue prompt. Free, MIT, 100% local. Your code and tokens never touch a server.
8
10
 
9
- > Every other tool here is a rear-view mirror it shows you the bill *after* you paid it. Runcap estimates the bill *before* you start and caps it. It is a circuit breaker, not a dashboard.
11
+ > Every other tool here is a rear-view mirror - it shows you the bill *after* you paid it. Runcap estimates the bill *before* you start and caps it. It is a circuit breaker, not a dashboard.
10
12
 
11
13
  ## Why
12
14
 
13
- Multi-agent coding runs burn roughly **15x more tokens** than a single chat ([Anthropic engineering](https://www.anthropic.com/engineering/built-multi-agent-research-system)). Agents loop on the same error, rewrite plans, and hand you a confident summary while the task is not actually done. You find out what it cost when the invoice or the subscription limit arrives.
15
+ Multi-agent coding runs burn roughly **15x more tokens** than a single chat ([Anthropic engineering](https://www.anthropic.com/engineering/built-multi-agent-research-system)). Agents loop on the same error, rewrite plans, and hand you a confident summary while the task is not actually done. You find out what it cost when the invoice - or the subscription limit - arrives.
14
16
 
15
17
  Observability tools (Langfuse, Helicone, LangSmith, AgentOps) measure the past. Gateways (LiteLLM, Portkey, OpenRouter) route the present. None of them stop the spend *before* it happens. Runcap does the one thing the rear-view mirror can't:
16
18
 
17
19
  ```text
18
- estimate before build → cap during run → rescue when stuckverify it finished
20
+ estimate before build → cap during run → compress every callrescue when stuck
19
21
  ```
20
22
 
21
23
  ## The honest claim
22
24
 
23
- Runcap does **not** promise an exact cost oracle. Agent trajectories are stochastic nobody, including the model labs, can predict the exact token count of a run. So Runcap gives you a **range plus a hard cap**:
25
+ Runcap does **not** promise an exact cost oracle. Agent trajectories are stochastic - nobody, including the model labs, can predict the exact token count of a run. So Runcap gives you a **range plus a hard cap**:
24
26
 
25
- > "This build is roughly $37. Cap it at $10." then it kills the run the second it hits the ceiling.
27
+ > "This build is roughly $3-7. Cap it at $10." - then it kills the run the second it hits the ceiling.
26
28
 
27
29
  The range is the headline. The hard cap is the product.
28
30
 
31
+ ## Who this is for
32
+
33
+ Runcap is a developer tool. It works by running a local gateway that your agent's API calls pass through, so it can price and cap them before they reach the paid provider. That means you need three things already in place:
34
+
35
+ - **Your own provider API key** (OpenAI or Anthropic). Runcap does not sell or supply model access.
36
+ - **Your own agent** - Claude Code, Codex, or any script that calls the OpenAI/Anthropic API.
37
+ - **Comfort running a CLI** and a local process on your machine.
38
+
39
+ If you have those, Runcap caps your spend in one command. If you are looking for a no-account web app that runs the AI for you, this is not that - it is a circuit breaker for a setup you already own.
40
+
29
41
  ## 60-second demo
30
42
 
31
43
  No API key required.
@@ -48,7 +60,7 @@ Fuel: 24% (medium confidence)
48
60
  Recommendation: Do not launch as one broad mission. Split into one vertical slice with a verification command.
49
61
  ```
50
62
 
51
- **2. Wrap a run and get a rescue prompt the moment it gets stuck:**
63
+ **2. Wrap a run - and get a rescue prompt the moment it gets stuck:**
52
64
 
53
65
  ```text
54
66
  $ runcap run --label demo -- npm run build
@@ -111,19 +123,25 @@ ANTHROPIC_API_KEY=sk-ant-... AIM_DAILY_BUDGET_USD=5 runcap gateway
111
123
 
112
124
  When spend crosses the ceiling, the next call returns `429 budget_guard` instead of money leaving your account. Try it with no key: `runcap gateway --mock`.
113
125
 
126
+ ## Token compression (built in, no extra deps)
127
+
128
+ Every request that passes through the gateway is compressed before it's forwarded: embedded JSON is re-serialized compactly, long log/stack-trace dumps are collapsed to head + tail, and trailing whitespace is squeezed. This is **lossless by construction** - your prose instructions and code semantics are never altered, only machine "garbage" is trimmed. It's pure Node with **zero ML or native dependencies**, so it installs everywhere without the build pain heavier compressors have.
129
+
130
+ The dashboard shows the result as one number: **"You saved $X · N tokens compressed · would have spent $Y."** Disable it with `AIM_COMPRESS=off` if you ever want raw passthrough.
131
+
114
132
  ## Pricing table
115
133
 
116
- Costs are calculated from a sourced multi-provider table Anthropic (Opus / Sonnet / Haiku) and OpenAI (GPT-5 family + legacy GPT-4), with cache-read and batch discounts handled labeled with source and verification date. When a model is unknown, Runcap says `unknown_price` rather than guessing.
134
+ Costs are calculated from a sourced multi-provider table - Anthropic (Opus / Sonnet / Haiku) and OpenAI (GPT-5 family + legacy GPT-4), with cache-read and batch discounts handled - labeled with source and verification date. When a model is unknown, Runcap says `unknown_price` rather than guessing.
117
135
 
118
136
  ## Trust model
119
137
 
120
138
  Runcap is built not to fake certainty. Every important output carries a truth label:
121
139
 
122
- - `observed` git diff, exit code, file changes, terminal output;
123
- - `calculated` parsed errors, diff hashes, stuck score, cost from the sourced price table;
124
- - `provider_usage` token usage returned by the upstream provider;
125
- - `manual_calibration` subscription % you entered before/after a run;
126
- - `unknown` Runcap cannot honestly know.
140
+ - `observed` - git diff, exit code, file changes, terminal output;
141
+ - `calculated` - parsed errors, diff hashes, stuck score, cost from the sourced price table;
142
+ - `provider_usage` - token usage returned by the upstream provider;
143
+ - `manual_calibration` - subscription % you entered before/after a run;
144
+ - `unknown` - Runcap cannot honestly know.
127
145
 
128
146
  If it cannot prove something, it says so.
129
147
 
@@ -132,14 +150,15 @@ If it cannot prove something, it says so.
132
150
  | Tier | Price | What you get |
133
151
  |---|---|---|
134
152
  | **OSS** (MIT, local) | $0 forever | All local runs, cost estimation, hard cap, run wrapping, stuck detection, rescue prompts, local dashboard. Never crippleware. |
153
+ | **Founding Pro** (limited) | **$49 once** | Lifetime Pro at the founder price - pay once, keep Pro forever, before it moves to $19/mo. |
135
154
  | **Pro** | $19/mo | Cloud sync across machines, hosted dashboard, estimate-vs-actual trends, shareable reports, alerts on cap breach |
136
155
  | **Team** | $49/seat/mo | Shared budget pools, org-wide ceilings, per-project rollups, role-based caps |
137
156
 
138
- The local core is free forever. Only persistence, collaboration, and aggregation are paid the things that only matter once data leaves your laptop.
157
+ The local core is free forever. Only persistence, collaboration, and aggregation are paid - the things that only matter once data leaves your laptop.
139
158
 
140
159
  ## Current stage
141
160
 
142
- A working local tool, not a hosted SaaS. Ready for: wrapping real Codex / Claude / Cursor sessions, catching stuck agents, and proving rescue prompts save time. Not yet: a hosted cloud platform or a universal observability standard. It is not trying to replace Langfuse or LiteLLM it does the thing they don't.
161
+ A working local tool, not a hosted SaaS. Ready for: wrapping real Codex / Claude / Cursor sessions, catching stuck agents, and proving rescue prompts save time. Not yet: a hosted cloud platform or a universal observability standard. It is not trying to replace Langfuse or LiteLLM - it does the thing they don't.
143
162
 
144
163
  ## Documentation
145
164
 
package/bin/runcap.mjs CHANGED
@@ -16,19 +16,36 @@ import {
16
16
  startDashboard,
17
17
  startGateway,
18
18
  showStatus,
19
+ setBudgetCap,
20
+ clearBudgetCap,
21
+ currentBudgetCap,
22
+ hasStoredCap,
23
+ welcome,
19
24
  templates
20
25
  } from "../src/mission-control.mjs";
26
+ import {
27
+ loginCommand,
28
+ logoutCommand,
29
+ whoamiCommand,
30
+ syncRun,
31
+ planToRun
32
+ } from "../src/cloud.mjs";
33
+ import { alertsCommand } from "../src/alerts.mjs";
21
34
 
22
35
  const args = process.argv.slice(2);
23
- const command = args[0] ?? "help";
36
+ const command = args[0] ?? "welcome";
24
37
 
25
38
  function usage() {
26
39
  console.log(`Runcap — cap every agent run before it starts
27
40
 
28
41
  Usage:
29
- runcap run [--label name] [--fuel-before 24] -- <command...>
30
- runcap plan [--fuel 24] [--quality high|balanced|cheap] -- <goal...>
42
+ runcap run [--label name] [--cap|--no-cap] [--mock] -- <command...>
43
+ (auto-enforces your cap; no manual gateway/base-URL setup)
44
+ runcap plan [--fuel 24] [--quality high|balanced|cheap] [--apply-cap] -- <goal...>
31
45
  runcap plans
46
+ runcap cap <usd> (set the hard cap the gateway enforces)
47
+ runcap cap show (show the current cap)
48
+ runcap cap clear (remove the stored cap)
32
49
  runcap preflight -- <command or prompt...>
33
50
  runcap status
34
51
  runcap list
@@ -40,6 +57,10 @@ Usage:
40
57
  runcap gateway [--port 8792] [--mock]
41
58
  runcap setup
42
59
  runcap doctor
60
+ runcap login <license-key> (Pro: enable cloud sync + hosted dashboard)
61
+ runcap logout
62
+ runcap whoami
63
+ runcap alerts [list|add|test|clear] (Pro: phone alerts when a run hits its cap)
43
64
  runcap fuel set <percent>
44
65
  runcap fuel calibrate <mission-id> <after-percent>
45
66
 
@@ -62,24 +83,50 @@ function takeOption(input, name) {
62
83
  return value;
63
84
  }
64
85
 
86
+ function takeFlag(input, name) {
87
+ const index = input.indexOf(name);
88
+ if (index === -1) return false;
89
+ input.splice(index, 1);
90
+ return true;
91
+ }
92
+
65
93
  try {
66
- if (command === "help" || command === "--help" || command === "-h") {
94
+ if (command === "welcome") {
95
+ console.log(await welcome());
96
+ } else if (command === "help" || command === "--help" || command === "-h") {
67
97
  usage();
68
98
  } else if (command === "run") {
69
99
  const runArgs = args.slice(1);
70
100
  const label = takeOption(runArgs, "--label");
71
101
  const fuelBefore = takeOption(runArgs, "--fuel-before");
102
+ const forceCap = takeFlag(runArgs, "--cap");
103
+ const noCap = takeFlag(runArgs, "--no-cap");
104
+ const mock = takeFlag(runArgs, "--mock");
72
105
  const separator = runArgs.indexOf("--");
73
106
  const childArgs = separator === -1 ? runArgs : runArgs.slice(separator + 1);
74
107
  if (childArgs.length === 0) {
75
108
  throw new Error("Missing command after `aim run --`.");
76
109
  }
110
+ // Zero-config: auto-wrap with the cap gateway when a cap is set (or forced),
111
+ // unless explicitly disabled. No manual gateway start, no base-URL exports.
112
+ const capConfigured = Boolean(process.env.AIM_DAILY_BUDGET_USD) || hasStoredCap();
113
+ const autoGateway = !noCap && (forceCap || mock || capConfigured);
114
+ if (!autoGateway && !noCap && !capConfigured) {
115
+ console.log("runcap: no cap set, running without the gateway. Set one with `runcap cap <usd>` to enforce a budget.\n");
116
+ }
77
117
  const result = await runMission({
78
118
  command: childArgs,
79
119
  label,
80
- fuelBefore: fuelBefore === undefined ? undefined : Number(fuelBefore)
120
+ fuelBefore: fuelBefore === undefined ? undefined : Number(fuelBefore),
121
+ autoGateway,
122
+ mock
81
123
  });
82
124
  console.log(result.summary);
125
+ if (result.capSummary) {
126
+ const c = result.capSummary;
127
+ const capLine = c.capUsd === null ? "no cap" : `cap $${c.capUsd.toFixed(2)}`;
128
+ console.log(`\nRuncap: cap enforced (${capLine}). This run spent ~$${c.spentThisRunUsd.toFixed(4)} (window total $${c.spentWindowUsd.toFixed(4)}).`);
129
+ }
83
130
  } else if (command === "preflight") {
84
131
  const runArgs = args.slice(1);
85
132
  const separator = runArgs.indexOf("--");
@@ -90,6 +137,9 @@ try {
90
137
  console.log(await preflightMission(childArgs));
91
138
  } else if (command === "plan") {
92
139
  const planArgs = args.slice(1);
140
+ const applyCapIndex = planArgs.indexOf("--apply-cap");
141
+ const applyCap = applyCapIndex !== -1;
142
+ if (applyCap) planArgs.splice(applyCapIndex, 1);
93
143
  const fuelPercent = takeOption(planArgs, "--fuel");
94
144
  const quality = takeOption(planArgs, "--quality") ?? "high";
95
145
  const separator = planArgs.indexOf("--");
@@ -117,6 +167,30 @@ try {
117
167
  `Report: .runcap/plans/${plan.id}/plan.md`,
118
168
  ""
119
169
  ].join("\n"));
170
+ if (applyCap) {
171
+ console.log(await setBudgetCap(plan.budget.recommendedCapUsd, { source: `plan:${plan.id}` }));
172
+ console.log("");
173
+ }
174
+ const sync = await syncRun(planToRun(plan));
175
+ if (sync === "synced") console.log("Cloud: synced to your Runcap Pro dashboard.");
176
+ else if (sync && sync.startsWith("sync_failed")) console.log(`Cloud: ${sync}`);
177
+ } else if (command === "login") {
178
+ console.log(await loginCommand(args[1]));
179
+ } else if (command === "logout") {
180
+ console.log(await logoutCommand());
181
+ } else if (command === "whoami") {
182
+ console.log(await whoamiCommand());
183
+ } else if (command === "alerts") {
184
+ console.log(await alertsCommand(args.slice(1)));
185
+ } else if (command === "cap") {
186
+ const sub = args[1];
187
+ if (sub === undefined || sub === "show") {
188
+ console.log(currentBudgetCap());
189
+ } else if (sub === "clear") {
190
+ console.log(await clearBudgetCap());
191
+ } else {
192
+ console.log(await setBudgetCap(sub));
193
+ }
120
194
  } else if (command === "plans") {
121
195
  console.log(await listPlans());
122
196
  } else if (command === "status") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcap",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Cap every agent run before it starts: estimate cost, set a hard ceiling that stops the run, rescue stuck agents. Local, MIT, nothing uploaded.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,8 +34,8 @@
34
34
  "LICENSE"
35
35
  ],
36
36
  "bin": {
37
- "runcap": "./bin/runcap.mjs",
38
- "aim": "./bin/runcap.mjs"
37
+ "runcap": "bin/runcap.mjs",
38
+ "aim": "bin/runcap.mjs"
39
39
  },
40
40
  "scripts": {
41
41
  "setup": "node ./bin/runcap.mjs setup",
package/src/alerts.mjs ADDED
@@ -0,0 +1,145 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { readLicense } from "./cloud.mjs";
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), ".runcap");
8
+ const ALERTS_FILE = path.join(CONFIG_DIR, "alerts.json");
9
+
10
+ async function readAlerts() {
11
+ if (!existsSync(ALERTS_FILE)) return { channels: [] };
12
+ try {
13
+ const raw = await readFile(ALERTS_FILE, "utf8");
14
+ const data = JSON.parse(raw);
15
+ return { channels: Array.isArray(data.channels) ? data.channels : [] };
16
+ } catch {
17
+ return { channels: [] };
18
+ }
19
+ }
20
+
21
+ async function writeAlerts(config) {
22
+ await mkdir(CONFIG_DIR, { recursive: true });
23
+ await writeFile(ALERTS_FILE, JSON.stringify(config, null, 2));
24
+ }
25
+
26
+ function describeChannel(c) {
27
+ if (c.type === "telegram") return `telegram (chat ${c.chatId})`;
28
+ if (c.type === "whatsapp") return `whatsapp (${c.phone})`;
29
+ if (c.type === "webhook") return `webhook (${c.url})`;
30
+ return c.type;
31
+ }
32
+
33
+ async function deliverToChannel(channel, text) {
34
+ if (channel.type === "telegram") {
35
+ const resp = await fetch(`https://api.telegram.org/bot${channel.token}/sendMessage`, {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify({ chat_id: channel.chatId, text })
39
+ });
40
+ return resp.ok;
41
+ }
42
+ if (channel.type === "whatsapp") {
43
+ // CallMeBot free WhatsApp API: user supplies their own phone + apikey.
44
+ const url = `https://api.callmebot.com/whatsapp.php?phone=${encodeURIComponent(channel.phone)}&text=${encodeURIComponent(text)}&apikey=${encodeURIComponent(channel.apikey)}`;
45
+ const resp = await fetch(url);
46
+ return resp.ok;
47
+ }
48
+ if (channel.type === "webhook") {
49
+ // Send both keys so Slack ("text") and Discord ("content") both work.
50
+ const resp = await fetch(channel.url, {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify({ text, content: text })
54
+ });
55
+ return resp.ok;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ // Best-effort, never throws into the caller. Pro-gated: requires a stored license.
61
+ export async function sendAlert(text) {
62
+ const license = await readLicense();
63
+ if (!license) return null; // free tier: no alerts
64
+ const { channels } = await readAlerts();
65
+ if (!channels.length) return null;
66
+ const results = [];
67
+ for (const ch of channels) {
68
+ try {
69
+ const ok = await deliverToChannel(ch, text);
70
+ results.push(ok ? describeChannel(ch) : `${describeChannel(ch)} (failed)`);
71
+ } catch (err) {
72
+ results.push(`${describeChannel(ch)} (error: ${err.message})`);
73
+ }
74
+ }
75
+ return results;
76
+ }
77
+
78
+ export async function alertsCommand(args) {
79
+ const sub = args[0] ?? "list";
80
+
81
+ if (sub === "list") {
82
+ const { channels } = await readAlerts();
83
+ const license = await readLicense();
84
+ const lines = [];
85
+ if (!license) {
86
+ lines.push("Alerts are a Runcap Pro feature. Run `runcap login <key>` to enable them.");
87
+ lines.push("");
88
+ }
89
+ if (!channels.length) {
90
+ lines.push("No alert channels configured.");
91
+ lines.push("");
92
+ lines.push("Add one (the run that breaches your cap will ping you on your phone):");
93
+ lines.push(" runcap alerts add telegram <bot-token> <chat-id>");
94
+ lines.push(" runcap alerts add whatsapp <phone> <callmebot-apikey>");
95
+ lines.push(" runcap alerts add webhook <url> (Slack / Discord / custom)");
96
+ return lines.join("\n");
97
+ }
98
+ lines.push("Configured alert channels:");
99
+ channels.forEach((c, i) => lines.push(` ${i + 1}. ${describeChannel(c)}`));
100
+ lines.push("");
101
+ lines.push("Test them with: runcap alerts test");
102
+ return lines.join("\n");
103
+ }
104
+
105
+ if (sub === "add") {
106
+ const type = args[1];
107
+ const { channels } = await readAlerts();
108
+ let channel;
109
+ if (type === "telegram") {
110
+ const token = args[2];
111
+ const chatId = args[3];
112
+ if (!token || !chatId) throw new Error("Usage: runcap alerts add telegram <bot-token> <chat-id>");
113
+ channel = { type: "telegram", token, chatId };
114
+ } else if (type === "whatsapp") {
115
+ const phone = args[2];
116
+ const apikey = args[3];
117
+ if (!phone || !apikey) throw new Error("Usage: runcap alerts add whatsapp <phone> <callmebot-apikey>");
118
+ channel = { type: "whatsapp", phone, apikey };
119
+ } else if (type === "webhook") {
120
+ const url = args[2];
121
+ if (!url) throw new Error("Usage: runcap alerts add webhook <url>");
122
+ channel = { type: "webhook", url };
123
+ } else {
124
+ throw new Error("Unknown channel type. Use: telegram | whatsapp | webhook");
125
+ }
126
+ channels.push(channel);
127
+ await writeAlerts({ channels });
128
+ return `Added ${describeChannel(channel)}. Run \`runcap alerts test\` to confirm it reaches your phone.`;
129
+ }
130
+
131
+ if (sub === "test") {
132
+ const license = await readLicense();
133
+ if (!license) return "Alerts are Pro-only. Run `runcap login <key>` first.";
134
+ const results = await sendAlert("Runcap test alert — your cap-breach notifications are working.");
135
+ if (!results) return "No channels configured. Add one with `runcap alerts add ...`.";
136
+ return `Test sent to: ${results.join(", ")}`;
137
+ }
138
+
139
+ if (sub === "clear" || sub === "off") {
140
+ await writeAlerts({ channels: [] });
141
+ return "Cleared all alert channels.";
142
+ }
143
+
144
+ throw new Error("Usage: runcap alerts [list|add|test|clear]");
145
+ }
package/src/cloud.mjs ADDED
@@ -0,0 +1,90 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const CONFIG_DIR = path.join(os.homedir(), ".runcap");
7
+ const LICENSE_FILE = path.join(CONFIG_DIR, "license.json");
8
+ const DEFAULT_ENDPOINT = "https://launchsoloai.com/api/runcap-ingest";
9
+
10
+ export async function readLicense() {
11
+ if (!existsSync(LICENSE_FILE)) return null;
12
+ try {
13
+ const raw = await readFile(LICENSE_FILE, "utf8");
14
+ const data = JSON.parse(raw);
15
+ return data.key ? data : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export async function saveLicense(key, endpoint) {
22
+ await mkdir(CONFIG_DIR, { recursive: true });
23
+ const data = { key: key.trim(), endpoint: endpoint || DEFAULT_ENDPOINT, savedAt: new Date().toISOString() };
24
+ await writeFile(LICENSE_FILE, JSON.stringify(data, null, 2));
25
+ return data;
26
+ }
27
+
28
+ export async function clearLicense() {
29
+ if (existsSync(LICENSE_FILE)) {
30
+ await writeFile(LICENSE_FILE, JSON.stringify({}, null, 2));
31
+ }
32
+ }
33
+
34
+ function maskKey(key) {
35
+ if (!key || key.length < 8) return "****";
36
+ return `${key.slice(0, 4)}...${key.slice(-4)}`;
37
+ }
38
+
39
+ export async function loginCommand(key) {
40
+ if (!key) throw new Error("Usage: runcap login <license-key>");
41
+ const saved = await saveLicense(key);
42
+ return [
43
+ `Saved Runcap Pro license ${maskKey(saved.key)}.`,
44
+ `Cloud sync is now ON. Future plans and runs sync to your hosted dashboard.`,
45
+ `Dashboard: https://launchsoloai.com/runcap/dashboard`
46
+ ].join("\n");
47
+ }
48
+
49
+ export async function logoutCommand() {
50
+ await clearLicense();
51
+ return "Logged out. Cloud sync is OFF. The local core keeps working as before.";
52
+ }
53
+
54
+ export async function whoamiCommand() {
55
+ const lic = await readLicense();
56
+ if (!lic) return "Not logged in. Local core only (free). Run `runcap login <key>` to enable Pro cloud sync.";
57
+ return `Logged in with license ${maskKey(lic.key)}. Cloud sync ON → ${lic.endpoint}`;
58
+ }
59
+
60
+ // Best-effort: never throws into the caller's flow. Returns a short status string.
61
+ export async function syncRun(run) {
62
+ const lic = await readLicense();
63
+ if (!lic) return null; // free mode, silent
64
+
65
+ try {
66
+ const resp = await fetch(lic.endpoint, {
67
+ method: "POST",
68
+ headers: { "Content-Type": "application/json" },
69
+ body: JSON.stringify({ license_key: lic.key, run })
70
+ });
71
+ if (resp.ok) return "synced";
72
+ if (resp.status === 403) return "sync_failed: license rejected (run `runcap whoami`)";
73
+ return `sync_failed: server ${resp.status}`;
74
+ } catch (err) {
75
+ return `sync_failed: ${err.message}`;
76
+ }
77
+ }
78
+
79
+ export function planToRun(plan) {
80
+ return {
81
+ mission_id: plan.id,
82
+ label: plan.goal,
83
+ estimate_low: plan.budget?.costLowUsd ?? 0,
84
+ estimate_high: plan.budget?.costHighUsd ?? 0,
85
+ cap: plan.budget?.recommendedCapUsd ?? null,
86
+ actual: null,
87
+ capped: false,
88
+ status: "planned"
89
+ };
90
+ }
@@ -0,0 +1,169 @@
1
+ // Runcap token compressor — pure Node, no ML, no native deps.
2
+ //
3
+ // Headroom (the popular Python tool) proves the demand but pays for it with
4
+ // onnxruntime/HF model weights that break installs on macOS Intel, Windows MSVC,
5
+ // etc. Runcap takes the opposite bet: only the deterministic, lossless-by-construction
6
+ // reductions that need zero dependencies and can never silently change an answer.
7
+ //
8
+ // What we compress (and why it is safe):
9
+ // - JSON whitespace inside string-embedded JSON blobs (re-serialize compact).
10
+ // - Repeated blank lines and trailing whitespace in long text blocks.
11
+ // - Long log / stack-trace runs collapsed to head + tail + "(N lines elided)".
12
+ // What we never touch:
13
+ // - The user's actual prose instructions.
14
+ // - Code semantics (we only strip trailing whitespace, never tokens).
15
+ // - Anything under a conservative size threshold (compression has overhead).
16
+ //
17
+ // Every reduction is COUNTED so the gateway can show one honest number:
18
+ // "X tokens saved by compression". Token counts are an estimate (~4 chars/token),
19
+ // labeled `estimated`, never claimed as provider-exact.
20
+
21
+ const CHARS_PER_TOKEN = 4;
22
+ const MIN_FIELD_CHARS = 200; // below this, compression overhead isn't worth it
23
+ const LOG_HEAD_LINES = 12;
24
+ const LOG_TAIL_LINES = 8;
25
+ const LOG_COLLAPSE_THRESHOLD = 40; // collapse runs longer than this
26
+
27
+ export function estimateTokens(text) {
28
+ if (!text) return 0;
29
+ return Math.ceil(String(text).length / CHARS_PER_TOKEN);
30
+ }
31
+
32
+ // Re-serialize an embedded JSON string compactly. Handles two shapes safely:
33
+ // 1. The whole field is JSON ("{...}" or "[...]").
34
+ // 2. A short text prefix followed by a JSON blob ("Here is the data:\n{...}").
35
+ // In case 2 we only touch the JSON tail and keep the prefix verbatim, so prose
36
+ // is never altered. Returns null if nothing valid/smaller was found.
37
+ function compactEmbeddedJson(value) {
38
+ const trimmed = value.trim();
39
+ // Case 1: entire field is JSON.
40
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
41
+ try {
42
+ const compact = JSON.stringify(JSON.parse(trimmed));
43
+ if (compact.length < value.length) return compact;
44
+ } catch {
45
+ // fall through to prefix handling
46
+ }
47
+ }
48
+ // Case 2: a prefix then a JSON blob. Find the first { or [ and try to parse
49
+ // from there to end. Only accept if the tail is valid JSON in full.
50
+ const idx = value.search(/[{[]/);
51
+ if (idx > 0) {
52
+ const prefix = value.slice(0, idx);
53
+ // Keep the prefix small/prose-like; don't swallow huge text blocks.
54
+ if (prefix.length <= 200) {
55
+ const tail = value.slice(idx).trim();
56
+ try {
57
+ const compact = JSON.stringify(JSON.parse(tail));
58
+ const rebuilt = prefix + compact;
59
+ if (rebuilt.length < value.length) return rebuilt;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ const LOG_LINE_RE = /^\s*(\d{4}-\d{2}-\d{2}[T ]|\[?\d{2}:\d{2}:\d{2}|DEBUG|INFO|WARN|ERROR|TRACE|at\s+\w|\s+File ")/;
69
+
70
+ // Collapse a long, log-like block: keep the head and tail (the parts a model
71
+ // actually needs to diagnose), elide the repetitive middle.
72
+ function collapseLogBlock(value) {
73
+ const lines = value.split("\n");
74
+ if (lines.length <= LOG_COLLAPSE_THRESHOLD) return null;
75
+ const logish = lines.filter((l) => LOG_LINE_RE.test(l)).length;
76
+ // Only collapse if it really looks like logs/stack traces, not prose.
77
+ if (logish < lines.length * 0.5) return null;
78
+ const head = lines.slice(0, LOG_HEAD_LINES);
79
+ const tail = lines.slice(-LOG_TAIL_LINES);
80
+ const elided = lines.length - head.length - tail.length;
81
+ if (elided <= 0) return null;
82
+ return [...head, `... (${elided} repetitive log lines elided by Runcap) ...`, ...tail].join("\n");
83
+ }
84
+
85
+ // Collapse 3+ blank lines to 1, and strip trailing whitespace ONLY on lines
86
+ // that are part of a multi-line block. We deliberately leave single-line prose
87
+ // (and its final trailing space) untouched so instructions are never altered.
88
+ function squeezeWhitespace(value) {
89
+ const lines = value.split("\n");
90
+ if (lines.length < 3) return null; // not a structural block; leave prose alone
91
+ const squeezed = lines
92
+ .map((l) => l.replace(/[ \t]+$/g, ""))
93
+ .join("\n")
94
+ .replace(/\n{3,}/g, "\n\n");
95
+ return squeezed.length < value.length ? squeezed : null;
96
+ }
97
+
98
+ // Compress a single string field through the safe ladder. Returns the smallest
99
+ // safe result (or the original if nothing helped).
100
+ function compressField(value) {
101
+ if (typeof value !== "string" || value.length < MIN_FIELD_CHARS) return value;
102
+ let out = value;
103
+ const json = compactEmbeddedJson(out);
104
+ if (json !== null) out = json;
105
+ const logs = collapseLogBlock(out);
106
+ if (logs !== null && logs.length < out.length) out = logs;
107
+ const ws = squeezeWhitespace(out);
108
+ if (ws !== null && ws.length < out.length) out = ws;
109
+ return out;
110
+ }
111
+
112
+ // Walk an OpenAI- or Anthropic-shaped request body and compress message content.
113
+ // Returns { body, before, after, savedChars, savedTokens, touched }.
114
+ export function compressRequestBody(body) {
115
+ const result = { body, savedChars: 0, savedTokens: 0, touched: 0, before: 0, after: 0 };
116
+ if (!body || typeof body !== "object") return result;
117
+
118
+ const measureBefore = JSON.stringify(body).length;
119
+ let touched = 0;
120
+
121
+ const compressContent = (content) => {
122
+ if (typeof content === "string") {
123
+ const next = compressField(content);
124
+ if (next !== content) touched += 1;
125
+ return next;
126
+ }
127
+ if (Array.isArray(content)) {
128
+ return content.map((part) => {
129
+ if (part && typeof part === "object" && typeof part.text === "string") {
130
+ const next = compressField(part.text);
131
+ if (next !== part.text) touched += 1;
132
+ return { ...part, text: next };
133
+ }
134
+ return part;
135
+ });
136
+ }
137
+ return content;
138
+ };
139
+
140
+ let next = body;
141
+ // OpenAI chat.completions: messages[].content
142
+ if (Array.isArray(body.messages)) {
143
+ next = {
144
+ ...body,
145
+ messages: body.messages.map((m) =>
146
+ m && typeof m === "object" && "content" in m ? { ...m, content: compressContent(m.content) } : m
147
+ )
148
+ };
149
+ }
150
+ // Anthropic system prompt (string or block array)
151
+ if (next.system !== undefined) {
152
+ next = { ...next, system: compressContent(next.system) };
153
+ }
154
+ // OpenAI responses API / raw input
155
+ if (typeof next.input === "string") {
156
+ next = { ...next, input: compressContent(next.input) };
157
+ }
158
+
159
+ const measureAfter = JSON.stringify(next).length;
160
+ const savedChars = Math.max(0, measureBefore - measureAfter);
161
+ return {
162
+ body: next,
163
+ before: measureBefore,
164
+ after: measureAfter,
165
+ savedChars,
166
+ savedTokens: Math.round(savedChars / CHARS_PER_TOKEN),
167
+ touched
168
+ };
169
+ }