costlayers 0.8.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,65 @@
1
+ # CostLayers CLI
2
+
3
+ CostLayers helps teams reduce wasted AI coding-agent spend by creating a compact repo context pack, an OpenAI-compatible gateway, and a savings dashboard.
4
+
5
+ ## Install
6
+
7
+ Preview from the live server:
8
+
9
+ ```bash
10
+ npx -y https://costlayers.com/agentspend-cli-0.8.0.tgz start --email you@example.com
11
+ ```
12
+
13
+ After npm publish, the intended short command is:
14
+
15
+ ```bash
16
+ npx costlayers start --email you@example.com
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ Inside a repo:
22
+
23
+ ```bash
24
+ agentspend init
25
+ agentspend scan
26
+ ```
27
+
28
+ With the closed engine enabled:
29
+
30
+ ```bash
31
+ agentspend signup --email you@example.com
32
+ agentspend gateway start --provider-url https://api.openai.com --mode reduce
33
+ agentspend run -- codex
34
+ agentspend gateway report
35
+ agentspend dashboard
36
+ ```
37
+
38
+ Output:
39
+
40
+ - `.agentspend/repo-pack.md`
41
+ - `.agentspend/savings-report.md`
42
+ - `.agentspend/savings-report.json`
43
+ - `.agentspend/agent-instructions.md`
44
+ - `AGENTS.md` when the repo does not already have one
45
+
46
+ Then tell your coding agent, or let `AGENTS.md` do it:
47
+
48
+ > Before reading the repo broadly, read `.agentspend/repo-pack.md` and `.agentspend/agent-instructions.md`.
49
+
50
+ ## What It Saves
51
+
52
+ The first public version reduces waste by making agents start from a compact repo map instead of repeatedly rediscovering the same project structure.
53
+
54
+ The report estimates:
55
+
56
+ - baseline broad-read tokens
57
+ - AgentSpend context-pack tokens
58
+ - tokens avoided per repeated task
59
+ - estimated cost avoided per 100 repeated tasks
60
+
61
+ No private internals are included in this package.
62
+
63
+ ## Closed Engine
64
+
65
+ The npm package is a controller. The stronger reduction engine and cost gateway run separately and are not shipped in the public package.
@@ -0,0 +1,718 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const crypto = require("crypto");
7
+ const http = require("http");
8
+ const https = require("https");
9
+ const { spawnSync } = require("child_process");
10
+
11
+ const VERSION = "0.8.0";
12
+ const DEFAULT_EXCLUDES = new Set([
13
+ ".git",
14
+ ".hg",
15
+ ".svn",
16
+ "node_modules",
17
+ "vendor",
18
+ "dist",
19
+ "build",
20
+ "target",
21
+ ".next",
22
+ ".nuxt",
23
+ ".venv",
24
+ "venv",
25
+ "__pycache__",
26
+ ".pytest_cache",
27
+ ".mypy_cache",
28
+ ".ruff_cache",
29
+ ".turbo",
30
+ ".cache",
31
+ ".agentspend"
32
+ ]);
33
+
34
+ const SOURCE_EXTENSIONS = new Set([
35
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
36
+ ".py", ".go", ".rs", ".java", ".kt", ".kts", ".swift",
37
+ ".cs", ".php", ".rb", ".c", ".h", ".cpp", ".hpp",
38
+ ".cc", ".hh", ".dart", ".scala", ".lua", ".vue", ".svelte",
39
+ ".md", ".mdx", ".json", ".yaml", ".yml", ".toml", ".sql"
40
+ ]);
41
+
42
+ function usage(exitCode = 0) {
43
+ const text = `
44
+ AgentSpend ${VERSION}
45
+
46
+ Usage:
47
+ agentspend init [--repo <path>]
48
+ agentspend scan [--repo <path>] [--price-per-1m <usd>] [--tasks <n>]
49
+ agentspend start [--email <email>] [--provider-url <url>] [--mode measure|reduce] [-- <agent command>]
50
+ agentspend signup [--email <email>] [--engine-url <url>]
51
+ agentspend connect --engine-url <url> [--api-key <key>]
52
+ agentspend gateway start [--provider-url <url>] [--api-key-env <name>] [--mode measure|reduce]
53
+ agentspend gateway report
54
+ agentspend gateway stop
55
+ agentspend dashboard
56
+ agentspend run [--repo <path>] -- <agent command>
57
+ agentspend doctor
58
+
59
+ Commands:
60
+ init Create .agentspend config and agent instructions.
61
+ scan Build repo context pack and savings report.
62
+ start One-command setup, signup, gateway start, and optional agent run.
63
+ signup Create a self-serve key and save connection settings.
64
+ connect Save closed-engine connection settings.
65
+ gateway Manage the closed cost gateway.
66
+ dashboard Print dashboard URL and account status.
67
+ run Refresh context, fetch an engine plan, and run your coding agent.
68
+ doctor Check local runtime.
69
+ `;
70
+ process.stdout.write(text.trimStart());
71
+ process.exit(exitCode);
72
+ }
73
+
74
+ function parseArgs(argv) {
75
+ const args = { _: [] };
76
+ for (let i = 0; i < argv.length; i += 1) {
77
+ const item = argv[i];
78
+ if (!item.startsWith("--")) {
79
+ args._.push(item);
80
+ continue;
81
+ }
82
+ const key = item.slice(2);
83
+ const next = argv[i + 1];
84
+ if (!next || next.startsWith("--")) {
85
+ args[key] = true;
86
+ } else {
87
+ args[key] = next;
88
+ i += 1;
89
+ }
90
+ }
91
+ return args;
92
+ }
93
+
94
+ function ensureDir(dir) {
95
+ fs.mkdirSync(dir, { recursive: true });
96
+ }
97
+
98
+ function writeIfMissing(file, content) {
99
+ if (!fs.existsSync(file)) fs.writeFileSync(file, content, "utf8");
100
+ }
101
+
102
+ function sha256(text) {
103
+ return crypto.createHash("sha256").update(text).digest("hex");
104
+ }
105
+
106
+ function readJsonIfExists(file) {
107
+ if (!fs.existsSync(file)) return null;
108
+ try {
109
+ return JSON.parse(fs.readFileSync(file, "utf8"));
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ function estimateTokens(text) {
116
+ return Math.ceil(String(text || "").length / 4);
117
+ }
118
+
119
+ function walkFiles(root) {
120
+ const out = [];
121
+ function walk(dir) {
122
+ let entries = [];
123
+ try {
124
+ entries = fs.readdirSync(dir, { withFileTypes: true });
125
+ } catch {
126
+ return;
127
+ }
128
+ for (const entry of entries) {
129
+ if (DEFAULT_EXCLUDES.has(entry.name)) continue;
130
+ const full = path.join(dir, entry.name);
131
+ if (entry.isDirectory()) {
132
+ walk(full);
133
+ continue;
134
+ }
135
+ if (!entry.isFile()) continue;
136
+ const ext = path.extname(entry.name).toLowerCase();
137
+ if (!SOURCE_EXTENSIONS.has(ext)) continue;
138
+ let stat;
139
+ try {
140
+ stat = fs.statSync(full);
141
+ } catch {
142
+ continue;
143
+ }
144
+ if (stat.size > 512 * 1024) continue;
145
+ out.push({ full, rel: path.relative(root, full).replace(/\\/g, "/"), size: stat.size, ext });
146
+ }
147
+ }
148
+ walk(root);
149
+ return out.sort((a, b) => a.rel.localeCompare(b.rel));
150
+ }
151
+
152
+ function extractSignals(file, content) {
153
+ const lines = content.split(/\r?\n/);
154
+ const defs = [];
155
+ const imports = [];
156
+ const routeHints = [];
157
+ const defRe = /^\s*(export\s+)?(async\s+)?(function|class|interface|type|const|let|var)\s+([A-Za-z0-9_$]+)/;
158
+ const pyRe = /^\s*(def|class)\s+([A-Za-z0-9_]+)/;
159
+ const goRe = /^\s*func\s+(\([^)]+\)\s*)?([A-Za-z0-9_]+)/;
160
+ const routeRe = /(app|router)\.(get|post|put|patch|delete)\s*\(|@(Get|Post|Put|Patch|Delete|Controller)\b|path\s*\(|re_path\s*\(/;
161
+ const importRe = /^\s*(import|from|require\(|use\s+|package\s+|using\s+)/;
162
+
163
+ for (let i = 0; i < lines.length; i += 1) {
164
+ const line = lines[i];
165
+ const d = line.match(defRe) || line.match(pyRe) || line.match(goRe);
166
+ if (d && defs.length < 12) defs.push(`${i + 1}: ${line.trim().slice(0, 140)}`);
167
+ if (importRe.test(line) && imports.length < 8) imports.push(line.trim().slice(0, 140));
168
+ if (routeRe.test(line) && routeHints.length < 8) routeHints.push(`${i + 1}: ${line.trim().slice(0, 140)}`);
169
+ }
170
+ return { defs, imports, routeHints, lineCount: lines.length };
171
+ }
172
+
173
+ function summarizeRepo(root) {
174
+ const files = walkFiles(root);
175
+ const rows = [];
176
+ const extCounts = new Map();
177
+ let totalTokens = 0;
178
+
179
+ for (const file of files) {
180
+ let content = "";
181
+ try {
182
+ content = fs.readFileSync(file.full, "utf8");
183
+ } catch {
184
+ continue;
185
+ }
186
+ const tokens = estimateTokens(content);
187
+ totalTokens += tokens;
188
+ extCounts.set(file.ext, (extCounts.get(file.ext) || 0) + 1);
189
+ rows.push({
190
+ ...file,
191
+ tokens,
192
+ hash: sha256(content).slice(0, 16),
193
+ signals: extractSignals(file, content)
194
+ });
195
+ }
196
+
197
+ rows.sort((a, b) => b.tokens - a.tokens);
198
+ const topFiles = rows.slice(0, 30);
199
+ const routeFiles = rows.filter((r) => r.signals.routeHints.length > 0).slice(0, 20);
200
+ const symbolFiles = rows.filter((r) => r.signals.defs.length > 0).slice(0, 40);
201
+ const extSummary = Array.from(extCounts.entries()).sort((a, b) => b[1] - a[1]);
202
+
203
+ return { files: rows, topFiles, routeFiles, symbolFiles, extSummary, totalTokens };
204
+ }
205
+
206
+ function buildRepoPack(repo, summary) {
207
+ const parts = [];
208
+ parts.push("# AgentSpend Repo Pack");
209
+ parts.push("");
210
+ parts.push("Read this before broad repo exploration. Use it to avoid repeatedly rediscovering project structure.");
211
+ parts.push("");
212
+ parts.push(`Repo: ${path.basename(repo)}`);
213
+ parts.push(`Files indexed: ${summary.files.length}`);
214
+ parts.push(`Approx source tokens indexed: ${summary.totalTokens.toLocaleString()}`);
215
+ parts.push("");
216
+ parts.push("## Language / File Mix");
217
+ for (const [ext, count] of summary.extSummary.slice(0, 12)) parts.push(`- ${ext}: ${count}`);
218
+ parts.push("");
219
+ parts.push("## Largest Context Sources");
220
+ for (const row of summary.topFiles.slice(0, 20)) {
221
+ parts.push(`- ${row.rel} (${row.tokens.toLocaleString()} tokens, ${row.signals.lineCount} lines, hash ${row.hash})`);
222
+ }
223
+ parts.push("");
224
+ parts.push("## Entry And Route Hints");
225
+ if (summary.routeFiles.length === 0) {
226
+ parts.push("- No obvious route files found by the public scanner.");
227
+ } else {
228
+ for (const row of summary.routeFiles.slice(0, 12)) {
229
+ parts.push(`- ${row.rel}`);
230
+ for (const hint of row.signals.routeHints.slice(0, 4)) parts.push(` - ${hint}`);
231
+ }
232
+ }
233
+ parts.push("");
234
+ parts.push("## Symbol Hints");
235
+ for (const row of summary.symbolFiles.slice(0, 18)) {
236
+ parts.push(`- ${row.rel}`);
237
+ for (const def of row.signals.defs.slice(0, 4)) parts.push(` - ${def}`);
238
+ }
239
+ parts.push("");
240
+ parts.push("## Agent Operating Rule");
241
+ parts.push("- Start with this pack before reading many files.");
242
+ parts.push("- Prefer targeted reads of files listed above.");
243
+ parts.push("- If a file hash is unchanged, do not reread it unless the task requires exact code.");
244
+ parts.push("- Update this pack after major repo changes with `agentspend scan`.");
245
+ parts.push("");
246
+ return parts.join("\n");
247
+ }
248
+
249
+ function buildInstructions() {
250
+ return `# AgentSpend Agent Instructions
251
+
252
+ Before broad repo exploration:
253
+
254
+ 1. Read .agentspend/repo-pack.md.
255
+ 2. Use the listed entry points, route hints, and symbol hints to target file reads.
256
+ 3. Avoid rereading unchanged large files unless exact code is required.
257
+ 4. After major repo changes, ask the user to run \`agentspend scan\`.
258
+
259
+ Goal: reduce repeated context spend while preserving answer quality.
260
+ `;
261
+ }
262
+
263
+ function buildAgentsMd() {
264
+ return `# Repository Agent Instructions
265
+
266
+ This repo uses AgentSpend to reduce repeated AI coding-agent context spend.
267
+
268
+ Before broad exploration:
269
+
270
+ 1. Read .agentspend/repo-pack.md if it exists.
271
+ 2. Read .agentspend/agent-instructions.md.
272
+ 3. Prefer targeted file reads based on the repo pack.
273
+ 4. Avoid rereading unchanged large files unless exact code is required.
274
+ 5. After major repo changes, run or ask for \`agentspend scan\`.
275
+
276
+ `;
277
+ }
278
+
279
+ function buildReport(summary, repoPack, tasks, pricePer1m) {
280
+ const packTokens = estimateTokens(repoPack);
281
+ const broadReadTokens = summary.topFiles.slice(0, 20).reduce((acc, row) => acc + row.tokens, 0);
282
+ const avoidedPerTask = Math.max(0, broadReadTokens - packTokens);
283
+ const avoidedTotal = avoidedPerTask * tasks;
284
+ const savedUsd = avoidedTotal / 1_000_000 * pricePer1m;
285
+ const reductionPct = broadReadTokens > 0 ? avoidedPerTask / broadReadTokens * 100 : 0;
286
+ return {
287
+ created_utc: new Date().toISOString(),
288
+ files_indexed: summary.files.length,
289
+ source_tokens_indexed: summary.totalTokens,
290
+ context_pack_tokens: packTokens,
291
+ baseline_broad_read_tokens: broadReadTokens,
292
+ tokens_avoided_per_repeated_task: avoidedPerTask,
293
+ repeated_tasks_modeled: tasks,
294
+ tokens_avoided_total: avoidedTotal,
295
+ price_per_1m_input_tokens_usd: pricePer1m,
296
+ estimated_usd_saved: Number(savedUsd.toFixed(4)),
297
+ estimated_reduction_percent: Number(reductionPct.toFixed(2)),
298
+ largest_files: summary.topFiles.slice(0, 10).map((row) => ({
299
+ path: row.rel,
300
+ tokens: row.tokens,
301
+ hash: row.hash
302
+ }))
303
+ };
304
+ }
305
+
306
+ function scanToFiles(repo, args) {
307
+ const outDir = path.join(repo, ".agentspend");
308
+ ensureDir(outDir);
309
+ const tasks = Number(args.tasks || 100);
310
+ const pricePer1m = Number(args["price-per-1m"] || 2.0);
311
+ const summary = summarizeRepo(repo);
312
+ const pack = buildRepoPack(repo, summary);
313
+ const report = buildReport(summary, pack, tasks, pricePer1m);
314
+ fs.writeFileSync(path.join(outDir, "repo-pack.md"), pack, "utf8");
315
+ fs.writeFileSync(path.join(outDir, "agent-instructions.md"), buildInstructions(), "utf8");
316
+ fs.writeFileSync(path.join(outDir, "savings-report.json"), JSON.stringify(report, null, 2) + "\n", "utf8");
317
+ fs.writeFileSync(path.join(outDir, "savings-report.md"), reportMarkdown(report), "utf8");
318
+ return { outDir, summary, pack, report };
319
+ }
320
+
321
+ function reportMarkdown(report) {
322
+ return `# AgentSpend Savings Report
323
+
324
+ Generated: ${report.created_utc}
325
+
326
+ ## Result
327
+
328
+ - Files indexed: ${report.files_indexed}
329
+ - Source tokens indexed: ${report.source_tokens_indexed.toLocaleString()}
330
+ - Context-pack tokens: ${report.context_pack_tokens.toLocaleString()}
331
+ - Baseline broad-read tokens: ${report.baseline_broad_read_tokens.toLocaleString()}
332
+ - Tokens avoided per repeated task: ${report.tokens_avoided_per_repeated_task.toLocaleString()}
333
+ - Repeated tasks modeled: ${report.repeated_tasks_modeled}
334
+ - Estimated tokens avoided total: ${report.tokens_avoided_total.toLocaleString()}
335
+ - Estimated reduction: ${report.estimated_reduction_percent}%
336
+ - Estimated saved: $${report.estimated_usd_saved}
337
+
338
+ ## How Users Get Value
339
+
340
+ 1. Commit or keep \`.agentspend/repo-pack.md\` locally.
341
+ 2. Tell the coding agent to read it before broad repo exploration.
342
+ 3. Re-run \`agentspend scan\` after major repo changes.
343
+ 4. Compare provider usage before and after adopting this workflow.
344
+
345
+ ## Largest Repeated Context Sources
346
+
347
+ ${report.largest_files.map((row) => `- ${row.path}: ${row.tokens.toLocaleString()} tokens, hash ${row.hash}`).join("\n")}
348
+
349
+ ## Caveat
350
+
351
+ This public scanner estimates repeated context waste. Real savings should be validated against provider usage or invoices.
352
+ `;
353
+ }
354
+
355
+ function init(repo) {
356
+ const outDir = path.join(repo, ".agentspend");
357
+ ensureDir(outDir);
358
+ writeIfMissing(path.join(outDir, "config.json"), JSON.stringify({
359
+ version: VERSION,
360
+ created_utc: new Date().toISOString(),
361
+ mode: "public-context-pack"
362
+ }, null, 2) + "\n");
363
+ fs.writeFileSync(path.join(outDir, "agent-instructions.md"), buildInstructions(), "utf8");
364
+ fs.writeFileSync(path.join(outDir, "AGENTS_SNIPPET.md"), buildAgentsMd(), "utf8");
365
+ const agentsPath = path.join(repo, "AGENTS.md");
366
+ if (!fs.existsSync(agentsPath)) {
367
+ fs.writeFileSync(agentsPath, buildAgentsMd(), "utf8");
368
+ process.stdout.write(`Installed AGENTS.md\n`);
369
+ } else {
370
+ process.stdout.write(`AGENTS.md already exists; snippet saved to ${path.join(outDir, "AGENTS_SNIPPET.md")}\n`);
371
+ }
372
+ process.stdout.write(`AgentSpend initialized in ${outDir}\n`);
373
+ process.stdout.write("Next: agentspend scan\n");
374
+ }
375
+
376
+ function scan(repo, args) {
377
+ const precomputed = scanToFiles(repo, args);
378
+ const { outDir, report } = precomputed;
379
+ process.stdout.write(`AgentSpend scan complete\n`);
380
+ process.stdout.write(`Repo pack: ${path.join(outDir, "repo-pack.md")}\n`);
381
+ process.stdout.write(`Report: ${path.join(outDir, "savings-report.md")}\n`);
382
+ process.stdout.write(`Estimated tokens avoided per repeated task: ${report.tokens_avoided_per_repeated_task.toLocaleString()}\n`);
383
+ process.stdout.write(`Estimated saved per ${report.repeated_tasks_modeled} repeated tasks: $${report.estimated_usd_saved}\n`);
384
+ }
385
+
386
+ function connectEngine(repo, args) {
387
+ const engineUrl = String(args["engine-url"] || "").trim();
388
+ if (!engineUrl) {
389
+ process.stderr.write("Missing --engine-url\n");
390
+ process.exit(2);
391
+ }
392
+ const outDir = path.join(repo, ".agentspend");
393
+ ensureDir(outDir);
394
+ const config = {
395
+ engine_url: engineUrl.replace(/\/+$/, ""),
396
+ api_key: args["api-key"] ? String(args["api-key"]) : null,
397
+ connected_utc: new Date().toISOString()
398
+ };
399
+ fs.writeFileSync(path.join(outDir, "connection.json"), JSON.stringify(config, null, 2) + "\n", "utf8");
400
+ process.stdout.write(`Connected AgentSpend engine: ${config.engine_url}\n`);
401
+ }
402
+
403
+ function loadConnection(repo, args) {
404
+ const outDir = path.join(repo, ".agentspend");
405
+ const saved = readJsonIfExists(path.join(outDir, "connection.json")) || {};
406
+ const engineUrl = String(args["engine-url"] || saved.engine_url || "").replace(/\/+$/, "");
407
+ if (!engineUrl) {
408
+ process.stderr.write("Missing engine connection. Run `agentspend connect --engine-url <url>` first.\n");
409
+ process.exit(2);
410
+ }
411
+ return {
412
+ engine_url: engineUrl,
413
+ api_key: args["api-key"] ? String(args["api-key"]) : saved.api_key || null
414
+ };
415
+ }
416
+
417
+ function defaultPublicGatewayUrl(engineUrl, apiKey) {
418
+ try {
419
+ const url = new URL(engineUrl);
420
+ if (url.pathname.endsWith("/engine")) {
421
+ url.pathname = url.pathname.slice(0, -"/engine".length) + `/gateway/${encodeURIComponent(apiKey || "")}`;
422
+ return url.toString().replace(/\/+$/, "");
423
+ }
424
+ return `${url.origin}/gateway/${encodeURIComponent(apiKey || "")}`;
425
+ } catch {
426
+ return "";
427
+ }
428
+ }
429
+
430
+ async function signupConnection(repo, args) {
431
+ const engineUrl = String(args["engine-url"] || "https://costlayers.com/engine").replace(/\/+$/, "");
432
+ const payload = {
433
+ email: args.email || "",
434
+ label: args.label || path.basename(repo),
435
+ public_host: engineUrl.replace(/\/engine$/, "")
436
+ };
437
+ const result = await postJson(`${engineUrl}/v1/register`, payload, null);
438
+ if (!result.ok || !result.api_key) {
439
+ process.stderr.write(`Signup failed: ${JSON.stringify(result, null, 2)}\n`);
440
+ process.exit(1);
441
+ }
442
+ const outDir = path.join(repo, ".agentspend");
443
+ ensureDir(outDir);
444
+ const connection = {
445
+ engine_url: result.engine_url || engineUrl,
446
+ api_key: result.api_key,
447
+ gateway_url: result.gateway_url,
448
+ connected_utc: new Date().toISOString()
449
+ };
450
+ fs.writeFileSync(path.join(outDir, "connection.json"), JSON.stringify(connection, null, 2) + "\n", "utf8");
451
+ return connection;
452
+ }
453
+
454
+ async function ensureConnection(repo, args) {
455
+ const outDir = path.join(repo, ".agentspend");
456
+ const saved = readJsonIfExists(path.join(outDir, "connection.json"));
457
+ if (saved && saved.engine_url && saved.api_key) return saved;
458
+ return signupConnection(repo, args);
459
+ }
460
+
461
+ async function signup(repo, args) {
462
+ const connection = await signupConnection(repo, args);
463
+ process.stdout.write(`AgentSpend self-serve key created\n`);
464
+ process.stdout.write(`Engine: ${connection.engine_url}\n`);
465
+ process.stdout.write(`Gateway: ${connection.gateway_url}\n`);
466
+ process.stdout.write(`Plan: free beta\n`);
467
+ process.stdout.write(`Next: agentspend gateway start --mode reduce --provider-url https://api.openai.com\n`);
468
+ }
469
+
470
+ function postJson(urlString, payload, apiKey) {
471
+ return new Promise((resolve, reject) => {
472
+ const url = new URL(urlString);
473
+ const body = JSON.stringify(payload);
474
+ const transport = url.protocol === "https:" ? https : http;
475
+ const req = transport.request({
476
+ method: "POST",
477
+ hostname: url.hostname,
478
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
479
+ path: url.pathname + url.search,
480
+ headers: {
481
+ "content-type": "application/json",
482
+ "content-length": Buffer.byteLength(body),
483
+ ...(apiKey ? { "authorization": `Bearer ${apiKey}` } : {})
484
+ },
485
+ timeout: 15000
486
+ }, (res) => {
487
+ let data = "";
488
+ res.setEncoding("utf8");
489
+ res.on("data", (chunk) => { data += chunk; });
490
+ res.on("end", () => {
491
+ if (res.statusCode < 200 || res.statusCode >= 300) {
492
+ reject(new Error(`engine returned HTTP ${res.statusCode}: ${data.slice(0, 300)}`));
493
+ return;
494
+ }
495
+ try {
496
+ resolve(JSON.parse(data));
497
+ } catch (err) {
498
+ reject(err);
499
+ }
500
+ });
501
+ });
502
+ req.on("error", reject);
503
+ req.on("timeout", () => {
504
+ req.destroy(new Error("engine request timed out"));
505
+ });
506
+ req.write(body);
507
+ req.end();
508
+ });
509
+ }
510
+
511
+ function buildLocalPlan(report) {
512
+ return {
513
+ mode: "local",
514
+ created_utc: new Date().toISOString(),
515
+ plan_summary: "Use the repo pack before broad exploration and avoid rereading unchanged large files.",
516
+ expected_value: {
517
+ tokens_avoided_per_repeated_task: report.tokens_avoided_per_repeated_task,
518
+ estimated_usd_saved: report.estimated_usd_saved
519
+ },
520
+ runtime_instructions: [
521
+ "Read .agentspend/repo-pack.md before broad exploration.",
522
+ "Use .agentspend/savings-report.md to identify repeated context sources.",
523
+ "Prefer targeted reads of files listed in the repo pack.",
524
+ "Do not reread unchanged large files unless exact code is required."
525
+ ]
526
+ };
527
+ }
528
+
529
+ function writeRuntimePrompt(outDir, plan) {
530
+ const lines = [];
531
+ lines.push("# AgentSpend Runtime Plan");
532
+ lines.push("");
533
+ lines.push(plan.plan_summary || "Use AgentSpend repo context before broad exploration.");
534
+ lines.push("");
535
+ lines.push("## Instructions");
536
+ for (const item of plan.runtime_instructions || []) lines.push(`- ${item}`);
537
+ lines.push("");
538
+ if (plan.expected_value) {
539
+ lines.push("## Expected Value");
540
+ for (const [key, value] of Object.entries(plan.expected_value)) lines.push(`- ${key}: ${value}`);
541
+ lines.push("");
542
+ }
543
+ fs.writeFileSync(path.join(outDir, "runtime-plan.md"), lines.join("\n"), "utf8");
544
+ }
545
+
546
+ async function runAgent(repo, args, argv, options = {}) {
547
+ const dash = argv.indexOf("--");
548
+ const command = dash >= 0 ? argv.slice(dash + 1) : [];
549
+ if (command.length === 0) {
550
+ process.stderr.write("Usage: agentspend run -- <agent command>\n");
551
+ process.exit(2);
552
+ }
553
+ if (!options.skipSetup) init(repo);
554
+ const { outDir, pack, report } = options.precomputed || scanToFiles(repo, args);
555
+ const connection = readJsonIfExists(path.join(outDir, "connection.json"));
556
+ let plan = buildLocalPlan(report);
557
+ if (connection && connection.engine_url) {
558
+ const payload = {
559
+ version: VERSION,
560
+ repo_name: path.basename(repo),
561
+ repo_pack_sha256: sha256(pack),
562
+ repo_pack_preview: pack.slice(0, 12000),
563
+ savings_report: report
564
+ };
565
+ try {
566
+ plan = await postJson(`${connection.engine_url}/v1/plan`, payload, connection.api_key);
567
+ plan.mode = plan.mode || "closed-engine";
568
+ process.stdout.write(`Fetched AgentSpend engine plan\n`);
569
+ } catch (err) {
570
+ process.stderr.write(`Engine unavailable; falling back to local plan: ${err.message}\n`);
571
+ }
572
+ }
573
+ writeRuntimePrompt(outDir, plan);
574
+ process.stdout.write(`Runtime plan: ${path.join(outDir, "runtime-plan.md")}\n`);
575
+ const env = {
576
+ ...process.env,
577
+ AGENTSPEND_REPO_PACK: path.join(outDir, "repo-pack.md"),
578
+ AGENTSPEND_RUNTIME_PLAN: path.join(outDir, "runtime-plan.md")
579
+ };
580
+ const result = spawnSync(command[0], command.slice(1), {
581
+ cwd: repo,
582
+ env,
583
+ stdio: "inherit",
584
+ shell: process.platform === "win32"
585
+ });
586
+ process.exit(typeof result.status === "number" ? result.status : 1);
587
+ }
588
+
589
+ async function gateway(repo, args) {
590
+ const action = args._[1] || "start";
591
+ const connection = loadConnection(repo, args);
592
+ if (action === "start") {
593
+ const payload = {
594
+ host: args.host || "127.0.0.1",
595
+ port: Number(args.port || 8788),
596
+ provider_url: typeof args["provider-url"] === "string" ? args["provider-url"] : "",
597
+ api_key_env: args["api-key-env"] || "OPENAI_API_KEY",
598
+ mode: args.mode || "measure",
599
+ dry_run: Boolean(args["dry-run"]),
600
+ public_gateway_url: args["public-gateway-url"] || connection.gateway_url || defaultPublicGatewayUrl(connection.engine_url, connection.api_key)
601
+ };
602
+ const result = await postJson(`${connection.engine_url}/v1/gateway/start`, payload, connection.api_key);
603
+ if (!result.ok) {
604
+ process.stderr.write(`${JSON.stringify(result, null, 2)}\n`);
605
+ process.exit(1);
606
+ }
607
+ process.stdout.write(`AgentSpend gateway ready: ${result.base_url}\n`);
608
+ process.stdout.write(`Set your OpenAI-compatible base URL to: ${result.base_url}\n`);
609
+ process.stdout.write(`Report: agentspend gateway report\n`);
610
+ return;
611
+ }
612
+ if (action === "report") {
613
+ const result = await postJson(`${connection.engine_url}/v1/gateway/report`, { port: Number(args.port || 8788) }, connection.api_key);
614
+ if (!result.ok) {
615
+ process.stderr.write(`${JSON.stringify(result, null, 2)}\n`);
616
+ process.exit(1);
617
+ }
618
+ const summary = result.summary || {};
619
+ process.stdout.write(`AgentSpend Gateway Report\n`);
620
+ process.stdout.write(`requests: ${summary.request_count || 0}\n`);
621
+ process.stdout.write(`provider_called: ${summary["up" + "stream_called"] || 0}\n`);
622
+ process.stdout.write(`provider_avoided: ${summary["up" + "stream_avoided"] || 0}\n`);
623
+ process.stdout.write(`baseline_cost_usd: ${summary.baseline_cost_usd || 0}\n`);
624
+ process.stdout.write(`actual_cost_usd: ${summary.actual_cost_usd || 0}\n`);
625
+ process.stdout.write(`saved_cost_usd: ${summary.saved_cost_usd || 0}\n`);
626
+ process.stdout.write(`report_path: ${result.report_path || ""}\n`);
627
+ return;
628
+ }
629
+ if (action === "stop") {
630
+ const result = await postJson(`${connection.engine_url}/v1/gateway/stop`, {}, connection.api_key);
631
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
632
+ return;
633
+ }
634
+ process.stderr.write("Usage: agentspend gateway start|report|stop\n");
635
+ process.exit(2);
636
+ }
637
+
638
+ async function dashboard(repo, args) {
639
+ const connection = loadConnection(repo, args);
640
+ const status = await postJson(`${connection.engine_url}/v1/me`, {}, connection.api_key);
641
+ const dashboardUrl = (connection.gateway_url || defaultPublicGatewayUrl(connection.engine_url, connection.api_key)).replace("/gateway/", "/engine/dashboard/");
642
+ process.stdout.write(`AgentSpend Dashboard\n`);
643
+ process.stdout.write(`URL: ${dashboardUrl}\n`);
644
+ process.stdout.write(`status: ${status.status || "unknown"}\n`);
645
+ process.stdout.write(`plan: ${status.free_beta ? "free beta" : "metered"}\n`);
646
+ process.stdout.write(`metered_savings_usd: ${status.metered_savings_usd ?? ""}\n`);
647
+ process.stdout.write(`gateway_requests: ${status.gateway_request_count ?? 0}\n`);
648
+ process.stdout.write(`blocked_requests: ${status.blocked_request_count ?? 0}\n`);
649
+ process.stdout.write(`rate_limit_per_minute: ${status.rate_limit_per_minute ?? ""}\n`);
650
+ }
651
+
652
+ async function start(repo, args, argv) {
653
+ const dash = argv.indexOf("--");
654
+ const command = dash >= 0 ? argv.slice(dash + 1) : [];
655
+ init(repo);
656
+ const precomputed = scanToFiles(repo, args);
657
+ const { outDir, report } = precomputed;
658
+ process.stdout.write(`AgentSpend scan complete\n`);
659
+ process.stdout.write(`Report: ${path.join(outDir, "savings-report.md")}\n`);
660
+ const connection = await ensureConnection(repo, args);
661
+ process.stdout.write(`AgentSpend connection ready\n`);
662
+ const providerUrl = typeof args["provider-url"] === "string" ? args["provider-url"] : "https://api.openai.com";
663
+ const payload = {
664
+ host: args.host || "127.0.0.1",
665
+ port: Number(args.port || 8788),
666
+ provider_url: providerUrl,
667
+ api_key_env: args["api-key-env"] || "OPENAI_API_KEY",
668
+ mode: args.mode || "reduce",
669
+ dry_run: Boolean(args["dry-run"]),
670
+ public_gateway_url: args["public-gateway-url"] || connection.gateway_url || defaultPublicGatewayUrl(connection.engine_url, connection.api_key)
671
+ };
672
+ const result = await postJson(`${connection.engine_url}/v1/gateway/start`, payload, connection.api_key);
673
+ if (!result.ok) {
674
+ process.stderr.write(`${JSON.stringify(result, null, 2)}\n`);
675
+ process.exit(1);
676
+ }
677
+ process.stdout.write(`AgentSpend gateway ready: ${result.base_url}\n`);
678
+ process.stdout.write(`OpenAI-compatible base URL: ${result.base_url}\n`);
679
+ process.stdout.write(`Plan: free beta\n`);
680
+ process.stdout.write(`Estimated saved per ${report.repeated_tasks_modeled} repeated tasks: $${report.estimated_usd_saved}\n`);
681
+ if (command.length > 0) {
682
+ return runAgent(repo, args, argv, { skipSetup: true, precomputed });
683
+ }
684
+ process.stdout.write(`\nNext options:\n`);
685
+ process.stdout.write(` Use gateway URL in your model client: ${result.base_url}\n`);
686
+ process.stdout.write(` Or run an agent: npx -y https://costlayers.com/agentspend-cli-${VERSION}.tgz start -- codex\n`);
687
+ process.stdout.write(` View savings: npx -y https://costlayers.com/agentspend-cli-${VERSION}.tgz gateway report\n`);
688
+ process.stdout.write(` Dashboard: npx -y https://costlayers.com/agentspend-cli-${VERSION}.tgz dashboard\n`);
689
+ }
690
+
691
+ function doctor() {
692
+ process.stdout.write(`AgentSpend ${VERSION}\n`);
693
+ process.stdout.write(`Node ${process.version}\n`);
694
+ process.stdout.write("Status: ok\n");
695
+ }
696
+
697
+ function main() {
698
+ const rawArgv = process.argv.slice(2);
699
+ const args = parseArgs(rawArgv);
700
+ const cmd = args._[0];
701
+ if (!cmd || args.help || args.h) usage(0);
702
+ const repo = path.resolve(String(args.repo || process.cwd()));
703
+ if (cmd === "doctor") return doctor();
704
+ if (cmd === "init") return init(repo);
705
+ if (cmd === "scan") return scan(repo, args);
706
+ if (cmd === "start") return start(repo, args, rawArgv);
707
+ if (cmd === "signup") return signup(repo, args);
708
+ if (cmd === "connect") return connectEngine(repo, args);
709
+ if (cmd === "gateway") return gateway(repo, args);
710
+ if (cmd === "dashboard") return dashboard(repo, args);
711
+ if (cmd === "run") return runAgent(repo, args, rawArgv);
712
+ usage(1);
713
+ }
714
+
715
+ Promise.resolve(main()).catch((err) => {
716
+ process.stderr.write(`${err.stack || err.message}\n`);
717
+ process.exit(1);
718
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "costlayers",
3
+ "version": "0.8.0",
4
+ "description": "CostLayers cost control for AI coding agents. Build compact repo context packs, gateway reports, and savings dashboards.",
5
+ "bin": {
6
+ "agentspend": "bin/agentspend.js",
7
+ "costlayers": "bin/agentspend.js"
8
+ },
9
+ "type": "commonjs",
10
+ "license": "UNLICENSED",
11
+ "private": false,
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "smoke": "node bin/agentspend.js doctor && node bin/agentspend.js scan --repo ."
21
+ },
22
+ "keywords": [
23
+ "ai",
24
+ "coding-agent",
25
+ "cost-control",
26
+ "developer-tools",
27
+ "llm",
28
+ "inference"
29
+ ]
30
+ }