costlayers 0.8.16 → 0.8.20
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 +20 -10
- package/bin/agentspend.js +588 -74
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CostLayers CLI
|
|
2
2
|
|
|
3
|
-
CostLayers
|
|
3
|
+
CostLayers is a cost-optimization layer for AI coding agents. It reduces repeated token waste from repo exploration, gives API users invoice-mode savings, and gives ChatGPT-login Codex users a usage-stretch meter that shows how much repeated context was avoided.
|
|
4
4
|
|
|
5
5
|
## Quick Start
|
|
6
6
|
|
|
@@ -8,16 +8,16 @@ Daily Codex use:
|
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
cd your-repo
|
|
11
|
-
npx -y costlayers
|
|
11
|
+
npx -y https://costlayers.com/costlayers-0.8.20.tgz codex --email you@example.com --chatgpt
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
If no API key is set, it uses ChatGPT-login usage-stretch mode.
|
|
14
|
+
This default path preserves Codex's native ChatGPT-login/provider flow and shows a usage-stretch meter. CostLayers only routes billable API traffic when you explicitly pass `--api`.
|
|
16
15
|
|
|
17
16
|
Run a one-command API savings test:
|
|
18
17
|
|
|
19
18
|
```bash
|
|
20
|
-
|
|
19
|
+
export OPENAI_API_KEY=sk-proj-...
|
|
20
|
+
npx -y https://costlayers.com/costlayers-0.8.20.tgz test --email you@example.com
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
## Usage
|
|
@@ -25,7 +25,7 @@ npx -y costlayers@latest test --email you@example.com
|
|
|
25
25
|
Inside a repo:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
npx -y costlayers
|
|
28
|
+
npx -y https://costlayers.com/costlayers-0.8.20.tgz codex --email you@example.com --chatgpt
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
This gives Codex `.agentspend/repo-pack.md` and `.agentspend/runtime-plan.md`
|
|
@@ -45,7 +45,7 @@ API write permission:
|
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
47
|
export OPENAI_API_KEY=sk-proj-...
|
|
48
|
-
npx -y costlayers
|
|
48
|
+
npx -y https://costlayers.com/costlayers-0.8.20.tgz codex --email you@example.com --api
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
ChatGPT-login Codex can be metered, but it does not create per-request OpenAI
|
|
@@ -54,14 +54,14 @@ Platform invoice savings because it is not billed through your Platform API key.
|
|
|
54
54
|
## Which Mode Should I Use?
|
|
55
55
|
|
|
56
56
|
- ChatGPT-login Codex: use `costlayers codex --email you@example.com --chatgpt` to reduce repeated repo context and stretch usage limits.
|
|
57
|
-
- OpenAI Platform API billing: set `OPENAI_API_KEY`, then use `costlayers codex --email you@example.com` for invoice-backed savings.
|
|
57
|
+
- OpenAI Platform API billing: set `OPENAI_API_KEY`, then use `costlayers codex --email you@example.com --api` for invoice-backed savings.
|
|
58
58
|
- Savings proof: set `OPENAI_API_KEY`, then run `costlayers test --email you@example.com`.
|
|
59
59
|
- Other OpenAI-compatible clients: point the client at the CostLayers gateway URL and check `costlayers gateway report`.
|
|
60
60
|
|
|
61
61
|
To install only the Codex profile after signup:
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
|
-
npx -y costlayers
|
|
64
|
+
npx -y https://costlayers.com/costlayers-0.8.20.tgz codex-profile
|
|
65
65
|
codex --profile costlayers
|
|
66
66
|
```
|
|
67
67
|
|
|
@@ -88,6 +88,8 @@ The hosted reducer defaults to quality-safe reduction:
|
|
|
88
88
|
- certified compaction preserves the current user request
|
|
89
89
|
- prior context is compacted only when there is a safe structural boundary
|
|
90
90
|
- opaque single-message prompts are forwarded unchanged
|
|
91
|
+
- hosted raw provider-response caching is off by default; receipts use hashes,
|
|
92
|
+
token counts, costs, timestamps, and quality labels
|
|
91
93
|
|
|
92
94
|
Output:
|
|
93
95
|
|
|
@@ -118,4 +120,12 @@ No private internals are included in this package.
|
|
|
118
120
|
|
|
119
121
|
## Closed Engine
|
|
120
122
|
|
|
121
|
-
The npm package is a controller. The stronger reduction engine and cost gateway run separately and are not shipped in the public package.
|
|
123
|
+
The npm package is a controller for the CostLayers optimization layer. The stronger reduction engine and cost gateway run separately and are not shipped in the public package.
|
|
124
|
+
|
|
125
|
+
## Privacy Default
|
|
126
|
+
|
|
127
|
+
The CLI scans source locally. When connected, it sends savings reports, artifact
|
|
128
|
+
hashes, and usage metadata so the dashboard can show found waste and metered
|
|
129
|
+
savings. It does not send the repo-pack preview by default. ChatGPT-login Codex
|
|
130
|
+
mode does not route model calls through CostLayers. API invoice mode routes
|
|
131
|
+
requests through the hosted optimization layer only when you pass `--api`.
|
package/bin/agentspend.js
CHANGED
|
@@ -9,8 +9,16 @@ const https = require("https");
|
|
|
9
9
|
const os = require("os");
|
|
10
10
|
const { spawnSync } = require("child_process");
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
function packageVersion() {
|
|
13
|
+
try {
|
|
14
|
+
return require(path.join(__dirname, "..", "package.json")).version || "0.8.20";
|
|
15
|
+
} catch (_err) {
|
|
16
|
+
return "0.8.20";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const VERSION = packageVersion();
|
|
21
|
+
const INSTALL_SPEC = "https://costlayers.com/costlayers-0.8.20.tgz";
|
|
14
22
|
const DEFAULT_RUNS_PER_WEEK = 20;
|
|
15
23
|
const WEEKS_PER_MONTH = 4.33;
|
|
16
24
|
const DEFAULT_EXCLUDES = new Set([
|
|
@@ -45,6 +53,8 @@ const SOURCE_EXTENSIONS = new Set([
|
|
|
45
53
|
".md", ".mdx", ".json", ".yaml", ".yml", ".toml", ".sql"
|
|
46
54
|
]);
|
|
47
55
|
|
|
56
|
+
const SEMANTIC_SLICE_LIMIT_PER_AREA = 24;
|
|
57
|
+
|
|
48
58
|
function usage(exitCode = 0) {
|
|
49
59
|
const text = `
|
|
50
60
|
CostLayers ${VERSION}
|
|
@@ -66,8 +76,8 @@ Usage:
|
|
|
66
76
|
costlayers doctor
|
|
67
77
|
|
|
68
78
|
Commands:
|
|
69
|
-
codex Start Codex with CostLayers.
|
|
70
|
-
test Run a safe read-only Codex task and print the CostLayers savings report.
|
|
79
|
+
codex Start Codex with CostLayers. Defaults to ChatGPT-login mode unless --api is passed.
|
|
80
|
+
test Run a safe read-only API invoice-mode Codex task and print the CostLayers savings report.
|
|
71
81
|
init Create .agentspend config and agent instructions.
|
|
72
82
|
scan Build repo context pack and savings report.
|
|
73
83
|
start One-command setup, signup, gateway start, and optional agent run.
|
|
@@ -107,6 +117,14 @@ function ensureDir(dir) {
|
|
|
107
117
|
fs.mkdirSync(dir, { recursive: true });
|
|
108
118
|
}
|
|
109
119
|
|
|
120
|
+
function chmodBestEffort(file, mode) {
|
|
121
|
+
try {
|
|
122
|
+
fs.chmodSync(file, mode);
|
|
123
|
+
} catch {
|
|
124
|
+
// Windows and some network filesystems may ignore POSIX modes.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
110
128
|
function writeIfMissing(file, content) {
|
|
111
129
|
if (!fs.existsSync(file)) fs.writeFileSync(file, content, "utf8");
|
|
112
130
|
}
|
|
@@ -127,7 +145,7 @@ function guardRepoRoot(repo, args) {
|
|
|
127
145
|
"",
|
|
128
146
|
"Run it inside a project folder instead:",
|
|
129
147
|
" cd path/to/your-repo",
|
|
130
|
-
` npx -y ${INSTALL_SPEC} codex --email you@example.com`,
|
|
148
|
+
` npx -y ${INSTALL_SPEC} codex --email you@example.com --chatgpt`,
|
|
131
149
|
"",
|
|
132
150
|
"Or pass --repo path/to/your-repo from anywhere.",
|
|
133
151
|
"If you really intend to scan your whole home directory, add --allow-home.",
|
|
@@ -153,10 +171,111 @@ function normalizedEmail(value) {
|
|
|
153
171
|
return String(value || "").trim().toLowerCase();
|
|
154
172
|
}
|
|
155
173
|
|
|
174
|
+
function configRoot() {
|
|
175
|
+
const base = process.env.XDG_CONFIG_HOME
|
|
176
|
+
? path.join(process.env.XDG_CONFIG_HOME, "costlayers")
|
|
177
|
+
: path.join(os.homedir(), ".config", "costlayers");
|
|
178
|
+
ensureDir(base);
|
|
179
|
+
chmodBestEffort(base, 0o700);
|
|
180
|
+
return base;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function connectionsDir() {
|
|
184
|
+
const dir = path.join(configRoot(), "connections");
|
|
185
|
+
ensureDir(dir);
|
|
186
|
+
chmodBestEffort(dir, 0o700);
|
|
187
|
+
return dir;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function connectionSecretPath(connectionId) {
|
|
191
|
+
return path.join(connectionsDir(), `${connectionId}.json`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function repoConnectionPath(repo) {
|
|
195
|
+
return path.join(repo, ".agentspend", "connection.json");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function ensureAgentSpendGitignore(outDir) {
|
|
199
|
+
ensureDir(outDir);
|
|
200
|
+
const file = path.join(outDir, ".gitignore");
|
|
201
|
+
const required = ["connection.json", "*.secret.json", "gateway-key.txt", "local-cache.json"];
|
|
202
|
+
let current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
203
|
+
const lines = new Set(current.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
|
|
204
|
+
let changed = false;
|
|
205
|
+
for (const item of required) {
|
|
206
|
+
if (!lines.has(item)) {
|
|
207
|
+
current += `${current.endsWith("\n") || current.length === 0 ? "" : "\n"}${item}\n`;
|
|
208
|
+
changed = true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (changed || !fs.existsSync(file)) fs.writeFileSync(file, current, "utf8");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function connectionIdFor(repo, connection = {}) {
|
|
215
|
+
if (connection.connection_id) return String(connection.connection_id);
|
|
216
|
+
const seed = connection.api_key || `${path.resolve(repo)}\n${connection.engine_url || ""}\n${connection.email || ""}`;
|
|
217
|
+
return `cl_${sha256(seed).slice(0, 24)}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function publicConnectionMetadata(connection) {
|
|
221
|
+
return {
|
|
222
|
+
version: VERSION,
|
|
223
|
+
connection_id: connection.connection_id,
|
|
224
|
+
engine_url: connection.engine_url,
|
|
225
|
+
email: normalizedEmail(connection.email),
|
|
226
|
+
label: connection.label || "",
|
|
227
|
+
connected_utc: connection.connected_utc || new Date().toISOString(),
|
|
228
|
+
secret_store: "~/.config/costlayers/connections",
|
|
229
|
+
note: "Live CostLayers keys are stored outside the repo. Run `costlayers dashboard` to print your private dashboard URL."
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function writePrivateJson(file, payload) {
|
|
234
|
+
ensureDir(path.dirname(file));
|
|
235
|
+
fs.writeFileSync(file, JSON.stringify(payload, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
236
|
+
chmodBestEffort(file, 0o600);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function saveConnection(repo, connection) {
|
|
240
|
+
const outDir = path.join(repo, ".agentspend");
|
|
241
|
+
ensureDir(outDir);
|
|
242
|
+
ensureAgentSpendGitignore(outDir);
|
|
243
|
+
const connectionId = connectionIdFor(repo, connection);
|
|
244
|
+
const full = {
|
|
245
|
+
...connection,
|
|
246
|
+
connection_id: connectionId,
|
|
247
|
+
email: normalizedEmail(connection.email),
|
|
248
|
+
connected_utc: connection.connected_utc || new Date().toISOString()
|
|
249
|
+
};
|
|
250
|
+
writePrivateJson(connectionSecretPath(connectionId), full);
|
|
251
|
+
fs.writeFileSync(repoConnectionPath(repo), JSON.stringify(publicConnectionMetadata(full), null, 2) + "\n", "utf8");
|
|
252
|
+
return full;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function loadStoredConnection(repo) {
|
|
256
|
+
const saved = readJsonIfExists(repoConnectionPath(repo));
|
|
257
|
+
if (!saved) return null;
|
|
258
|
+
if (saved.api_key || saved.gateway_url) {
|
|
259
|
+
const migrated = saveConnection(repo, saved);
|
|
260
|
+
process.stdout.write(`CostLayers moved the live key out of .agentspend into ~/.config/costlayers.\n`);
|
|
261
|
+
return migrated;
|
|
262
|
+
}
|
|
263
|
+
const connectionId = saved.connection_id || connectionIdFor(repo, saved);
|
|
264
|
+
const secret = readJsonIfExists(connectionSecretPath(connectionId));
|
|
265
|
+
if (secret && secret.api_key) return { ...saved, ...secret, connection_id: connectionId };
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
156
269
|
function estimateTokens(text) {
|
|
157
270
|
return Math.ceil(String(text || "").length / 4);
|
|
158
271
|
}
|
|
159
272
|
|
|
273
|
+
function stableJson(value) {
|
|
274
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
275
|
+
if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
|
276
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
160
279
|
function walkFiles(root) {
|
|
161
280
|
const out = [];
|
|
162
281
|
function walk(dir) {
|
|
@@ -280,6 +399,7 @@ function buildRepoPack(repo, summary) {
|
|
|
280
399
|
parts.push("");
|
|
281
400
|
parts.push("## Agent Operating Rule");
|
|
282
401
|
parts.push("- Start with this pack before reading many files.");
|
|
402
|
+
parts.push("- Use .agentspend/semantic-slices.md for route and symbol facts before opening broad source context.");
|
|
283
403
|
parts.push("- Prefer targeted reads of files listed above.");
|
|
284
404
|
parts.push("- If a file hash is unchanged, do not reread it unless the task requires exact code.");
|
|
285
405
|
parts.push("- Update this pack after major repo changes with `costlayers scan`.");
|
|
@@ -287,15 +407,194 @@ function buildRepoPack(repo, summary) {
|
|
|
287
407
|
return parts.join("\n");
|
|
288
408
|
}
|
|
289
409
|
|
|
410
|
+
|
|
411
|
+
function semanticArea(row) {
|
|
412
|
+
const rel = String(row.rel || "").toLowerCase();
|
|
413
|
+
const signals = row.signals || {};
|
|
414
|
+
if ((signals.routeHints || []).length > 0) return "entrypoints-routes";
|
|
415
|
+
if (/(^|\/)(__tests__|test|tests|spec|specs)(\/|$)/.test(rel) || /\.(test|spec)\.[^.]+$/.test(rel)) return "tests";
|
|
416
|
+
if (/(^|\/)(package\.json|pyproject\.toml|cargo\.toml|go\.mod|requirements[^/]*\.txt|pom\.xml|build\.gradle|dockerfile|compose\.ya?ml|tsconfig\.json|vite\.config|next\.config|nuxt\.config)/.test(rel)) return "config-build";
|
|
417
|
+
if (/\.(md|mdx)$/.test(rel)) return "docs";
|
|
418
|
+
if (/(^|\/)(schema|schemas|model|models|migration|migrations|db|database)(\/|$)/.test(rel) || /\.sql$/.test(rel)) return "data-model";
|
|
419
|
+
if (/(^|\/)(api|server|service|services|controller|controllers|handler|handlers)(\/|$)/.test(rel)) return "services";
|
|
420
|
+
if (/(^|\/)(component|components|page|pages|app|ui|view|views)(\/|$)/.test(rel)) return "ui";
|
|
421
|
+
if (/(^|\/)(cli|bin|cmd|command|commands)(\/|$)/.test(rel)) return "cli-tools";
|
|
422
|
+
return "core";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function semanticAreaTitle(area) {
|
|
426
|
+
return ({
|
|
427
|
+
"entrypoints-routes": "Entry Points And Routes",
|
|
428
|
+
"config-build": "Config And Build",
|
|
429
|
+
"data-model": "Data Model",
|
|
430
|
+
"cli-tools": "CLI And Tools",
|
|
431
|
+
services: "Services",
|
|
432
|
+
tests: "Tests",
|
|
433
|
+
docs: "Docs",
|
|
434
|
+
ui: "UI",
|
|
435
|
+
core: "Core"
|
|
436
|
+
})[area] || area;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function semanticSymbolName(definitionLine) {
|
|
440
|
+
const text = String(definitionLine || "").replace(/^\d+:\s*/, "").trim();
|
|
441
|
+
const patterns = [
|
|
442
|
+
/(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const|let|var)\s+([A-Za-z0-9_$]+)/,
|
|
443
|
+
/(?:def|class)\s+([A-Za-z0-9_]+)/,
|
|
444
|
+
/func\s+(?:\([^)]+\)\s*)?([A-Za-z0-9_]+)/
|
|
445
|
+
];
|
|
446
|
+
for (const pattern of patterns) {
|
|
447
|
+
const match = text.match(pattern);
|
|
448
|
+
if (match) return match[1];
|
|
449
|
+
}
|
|
450
|
+
return text.slice(0, 80);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function semanticFileFact(row) {
|
|
454
|
+
const signals = row.signals || {};
|
|
455
|
+
const symbols = (signals.defs || []).map(semanticSymbolName).filter(Boolean).slice(0, 12);
|
|
456
|
+
const routes = (signals.routeHints || []).slice(0, 8);
|
|
457
|
+
const imports = (signals.imports || []).slice(0, 6);
|
|
458
|
+
return {
|
|
459
|
+
path: row.rel,
|
|
460
|
+
area: semanticArea(row),
|
|
461
|
+
hash: row.hash,
|
|
462
|
+
tokens: row.tokens,
|
|
463
|
+
lines: signals.lineCount || 0,
|
|
464
|
+
symbols,
|
|
465
|
+
routes,
|
|
466
|
+
imports
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function semanticFactCount(fact) {
|
|
471
|
+
return 1 + fact.symbols.length + fact.routes.length + Math.min(fact.imports.length, 3);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function semanticFactScore(fact) {
|
|
475
|
+
return fact.routes.length * 50 + fact.symbols.length * 8 + fact.imports.length * 2 + Math.min(fact.tokens / 1000, 20);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function buildSemanticSlices(repo, summary) {
|
|
479
|
+
const facts = summary.files.map(semanticFileFact);
|
|
480
|
+
const groups = new Map();
|
|
481
|
+
for (const fact of facts) {
|
|
482
|
+
if (!groups.has(fact.area)) groups.set(fact.area, []);
|
|
483
|
+
groups.get(fact.area).push(fact);
|
|
484
|
+
}
|
|
485
|
+
const slices = Array.from(groups.entries()).map(([area, files]) => {
|
|
486
|
+
const sorted = files.slice().sort((a, b) => semanticFactScore(b) - semanticFactScore(a) || b.tokens - a.tokens || a.path.localeCompare(b.path));
|
|
487
|
+
const included = sorted.slice(0, SEMANTIC_SLICE_LIMIT_PER_AREA);
|
|
488
|
+
const sourceTokens = files.reduce((acc, item) => acc + Number(item.tokens || 0), 0);
|
|
489
|
+
const factCount = files.reduce((acc, item) => acc + semanticFactCount(item), 0);
|
|
490
|
+
const sliceSeed = {
|
|
491
|
+
area,
|
|
492
|
+
files: files.map((item) => ({ path: item.path, hash: item.hash, tokens: item.tokens })).sort((a, b) => a.path.localeCompare(b.path))
|
|
493
|
+
};
|
|
494
|
+
return {
|
|
495
|
+
id: `sem_${sha256(stableJson(sliceSeed)).slice(0, 12)}`,
|
|
496
|
+
area,
|
|
497
|
+
title: semanticAreaTitle(area),
|
|
498
|
+
file_count: files.length,
|
|
499
|
+
included_file_count: included.length,
|
|
500
|
+
omitted_file_count: Math.max(0, files.length - included.length),
|
|
501
|
+
source_tokens_covered: sourceTokens,
|
|
502
|
+
fact_count: factCount,
|
|
503
|
+
files: included
|
|
504
|
+
};
|
|
505
|
+
}).sort((a, b) => b.source_tokens_covered - a.source_tokens_covered || a.title.localeCompare(b.title));
|
|
506
|
+
const receiptSeed = {
|
|
507
|
+
artifact_version: 1,
|
|
508
|
+
mode: "offline-semantic-slices",
|
|
509
|
+
repo: path.basename(repo),
|
|
510
|
+
files_indexed: summary.files.length,
|
|
511
|
+
source_tokens_indexed: summary.totalTokens,
|
|
512
|
+
slices: slices.map((slice) => ({
|
|
513
|
+
id: slice.id,
|
|
514
|
+
area: slice.area,
|
|
515
|
+
file_count: slice.file_count,
|
|
516
|
+
source_tokens_covered: slice.source_tokens_covered,
|
|
517
|
+
fact_count: slice.fact_count
|
|
518
|
+
}))
|
|
519
|
+
};
|
|
520
|
+
return {
|
|
521
|
+
artifact_version: 1,
|
|
522
|
+
mode: "offline-semantic-slices",
|
|
523
|
+
created_utc: new Date().toISOString(),
|
|
524
|
+
repo: path.basename(repo),
|
|
525
|
+
files_indexed: summary.files.length,
|
|
526
|
+
source_tokens_indexed: summary.totalTokens,
|
|
527
|
+
file_fact_count: facts.length,
|
|
528
|
+
fact_count: slices.reduce((acc, slice) => acc + slice.fact_count, 0),
|
|
529
|
+
receipt_sha256: sha256(stableJson(receiptSeed)),
|
|
530
|
+
slices
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function buildSemanticSlicesMarkdown(index) {
|
|
535
|
+
const lines = [];
|
|
536
|
+
lines.push("# CostLayers Semantic Slices");
|
|
537
|
+
lines.push("");
|
|
538
|
+
lines.push("Offline semantic facts from local source scanning. This artifact is not used for live gateway request reduction; use it to choose exact files before opening broad context.");
|
|
539
|
+
lines.push("");
|
|
540
|
+
lines.push(`Receipt: ${index.receipt_sha256}`);
|
|
541
|
+
lines.push(`Files indexed: ${index.files_indexed.toLocaleString()}`);
|
|
542
|
+
lines.push(`Source tokens covered: ${index.source_tokens_indexed.toLocaleString()}`);
|
|
543
|
+
lines.push(`Slices: ${index.slices.length.toLocaleString()}`);
|
|
544
|
+
lines.push("");
|
|
545
|
+
for (const slice of index.slices) {
|
|
546
|
+
lines.push(`## ${slice.title}`);
|
|
547
|
+
lines.push(`- Slice id: ${slice.id}`);
|
|
548
|
+
lines.push(`- Files covered: ${slice.file_count.toLocaleString()}`);
|
|
549
|
+
lines.push(`- Source tokens covered: ${slice.source_tokens_covered.toLocaleString()}`);
|
|
550
|
+
if (slice.omitted_file_count > 0) lines.push(`- Additional files in JSON artifact: ${slice.omitted_file_count.toLocaleString()}`);
|
|
551
|
+
for (const file of slice.files.slice(0, 12)) {
|
|
552
|
+
lines.push(`- ${file.path} (${Number(file.tokens || 0).toLocaleString()} tokens, ${Number(file.lines || 0).toLocaleString()} lines, hash ${file.hash})`);
|
|
553
|
+
if (file.routes.length) lines.push(` - routes: ${file.routes.slice(0, 3).join(" | ")}`);
|
|
554
|
+
if (file.symbols.length) lines.push(` - symbols: ${file.symbols.slice(0, 8).join(", ")}`);
|
|
555
|
+
if (file.imports.length) lines.push(` - imports: ${file.imports.slice(0, 3).join(" | ")}`);
|
|
556
|
+
}
|
|
557
|
+
lines.push("");
|
|
558
|
+
}
|
|
559
|
+
return lines.join("\n");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function semanticReportSummary(index, semanticMarkdown, baselineBroadReadTokens) {
|
|
563
|
+
const semanticIndexTokens = estimateTokens(semanticMarkdown);
|
|
564
|
+
const potentialAvoided = Math.max(0, Number(baselineBroadReadTokens || 0) - semanticIndexTokens);
|
|
565
|
+
const potentialPct = baselineBroadReadTokens > 0 ? potentialAvoided / baselineBroadReadTokens * 100 : 0;
|
|
566
|
+
return {
|
|
567
|
+
mode: "offline_local_only",
|
|
568
|
+
artifact: ".agentspend/semantic-slices.md",
|
|
569
|
+
json_artifact: ".agentspend/semantic-slices.json",
|
|
570
|
+
receipt_hash: index.receipt_sha256,
|
|
571
|
+
slice_count: index.slices.length,
|
|
572
|
+
file_fact_count: index.file_fact_count,
|
|
573
|
+
fact_count: index.fact_count,
|
|
574
|
+
source_tokens_covered: index.source_tokens_indexed,
|
|
575
|
+
semantic_index_tokens: semanticIndexTokens,
|
|
576
|
+
potential_tokens_avoided_per_repeated_task: potentialAvoided,
|
|
577
|
+
potential_reduction_percent: Number(potentialPct.toFixed(2)),
|
|
578
|
+
top_slices: index.slices.slice(0, 8).map((slice) => ({
|
|
579
|
+
id: slice.id,
|
|
580
|
+
title: slice.title,
|
|
581
|
+
file_count: slice.file_count,
|
|
582
|
+
source_tokens_covered: slice.source_tokens_covered,
|
|
583
|
+
fact_count: slice.fact_count
|
|
584
|
+
}))
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
290
588
|
function buildInstructions() {
|
|
291
589
|
return `# CostLayers Agent Instructions
|
|
292
590
|
|
|
293
591
|
Before broad repo exploration:
|
|
294
592
|
|
|
295
593
|
1. Read .agentspend/repo-pack.md.
|
|
296
|
-
2.
|
|
297
|
-
3.
|
|
298
|
-
4.
|
|
594
|
+
2. Read .agentspend/semantic-slices.md for route and symbol facts when a compact semantic map is enough.
|
|
595
|
+
3. Use the listed entry points, route hints, and symbol hints to target file reads.
|
|
596
|
+
4. Avoid rereading unchanged large files unless exact code is required.
|
|
597
|
+
5. After major repo changes, ask the user to run \`costlayers scan\`.
|
|
299
598
|
|
|
300
599
|
Goal: reduce repeated context spend while preserving answer quality.
|
|
301
600
|
`;
|
|
@@ -309,15 +608,16 @@ This repo uses CostLayers to reduce repeated AI coding-agent context spend.
|
|
|
309
608
|
Before broad exploration:
|
|
310
609
|
|
|
311
610
|
1. Read .agentspend/repo-pack.md if it exists.
|
|
312
|
-
2. Read .agentspend/
|
|
313
|
-
3.
|
|
314
|
-
4.
|
|
315
|
-
5.
|
|
611
|
+
2. Read .agentspend/semantic-slices.md when route and symbol facts are enough to target the next read.
|
|
612
|
+
3. Read .agentspend/agent-instructions.md.
|
|
613
|
+
4. Prefer targeted file reads based on the repo pack.
|
|
614
|
+
5. Avoid rereading unchanged large files unless exact code is required.
|
|
615
|
+
6. After major repo changes, run or ask for \`costlayers scan\`.
|
|
316
616
|
|
|
317
617
|
`;
|
|
318
618
|
}
|
|
319
619
|
|
|
320
|
-
function buildReport(summary, repoPack, tasks, pricePer1m, runsPerWeek) {
|
|
620
|
+
function buildReport(summary, repoPack, tasks, pricePer1m, runsPerWeek, semanticIndex = null, semanticMarkdown = "") {
|
|
321
621
|
const packTokens = estimateTokens(repoPack);
|
|
322
622
|
const broadReadTokens = Math.max(
|
|
323
623
|
summary.totalTokens,
|
|
@@ -331,6 +631,7 @@ function buildReport(summary, repoPack, tasks, pricePer1m, runsPerWeek) {
|
|
|
331
631
|
const projectedWeeklyUsd = savedPerRunUsd * runsPerWeek;
|
|
332
632
|
const projectedMonthlyUsd = projectedWeeklyUsd * WEEKS_PER_MONTH;
|
|
333
633
|
const reductionPct = broadReadTokens > 0 ? avoidedPerTask / broadReadTokens * 100 : 0;
|
|
634
|
+
const semanticSummary = semanticIndex ? semanticReportSummary(semanticIndex, semanticMarkdown, broadReadTokens) : null;
|
|
334
635
|
return {
|
|
335
636
|
created_utc: new Date().toISOString(),
|
|
336
637
|
files_indexed: summary.files.length,
|
|
@@ -350,24 +651,99 @@ function buildReport(summary, repoPack, tasks, pricePer1m, runsPerWeek) {
|
|
|
350
651
|
path: row.rel,
|
|
351
652
|
tokens: row.tokens,
|
|
352
653
|
hash: row.hash
|
|
353
|
-
}))
|
|
654
|
+
})),
|
|
655
|
+
...(semanticSummary ? { semantic_slices: semanticSummary } : {})
|
|
354
656
|
};
|
|
355
657
|
}
|
|
356
658
|
|
|
659
|
+
function summaryFingerprint(summary) {
|
|
660
|
+
const files = (summary.files || []).map((row) => ({
|
|
661
|
+
path: row.rel,
|
|
662
|
+
size: row.size,
|
|
663
|
+
hash: row.hash,
|
|
664
|
+
tokens: row.tokens
|
|
665
|
+
})).sort((a, b) => a.path.localeCompare(b.path));
|
|
666
|
+
return sha256(stableJson({ artifact_version: 1, files }));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function readLocalCache(outDir) {
|
|
670
|
+
return readJsonIfExists(path.join(outDir, "local-cache.json"));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function writeLocalCache(outDir, payload) {
|
|
674
|
+
fs.writeFileSync(path.join(outDir, "local-cache.json"), JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
675
|
+
}
|
|
676
|
+
|
|
357
677
|
function scanToFiles(repo, args) {
|
|
358
678
|
const outDir = path.join(repo, ".agentspend");
|
|
359
679
|
ensureDir(outDir);
|
|
680
|
+
ensureAgentSpendGitignore(outDir);
|
|
360
681
|
const tasks = Number(args.tasks || 100);
|
|
361
682
|
const runsPerWeek = Number(args["runs-per-week"] || DEFAULT_RUNS_PER_WEEK);
|
|
362
683
|
const pricePer1m = Number(args["price-per-1m"] || 2.0);
|
|
363
684
|
const summary = summarizeRepo(repo);
|
|
685
|
+
const fingerprint = summaryFingerprint(summary);
|
|
686
|
+
const cached = readLocalCache(outDir);
|
|
687
|
+
if (!args.force && cached && cached.fingerprint === fingerprint) {
|
|
688
|
+
const packFile = path.join(outDir, "repo-pack.md");
|
|
689
|
+
const semanticJsonFile = path.join(outDir, "semantic-slices.json");
|
|
690
|
+
const semanticMdFile = path.join(outDir, "semantic-slices.md");
|
|
691
|
+
const reportFile = path.join(outDir, "savings-report.json");
|
|
692
|
+
if (fs.existsSync(packFile) && fs.existsSync(semanticJsonFile) && fs.existsSync(semanticMdFile) && fs.existsSync(reportFile)) {
|
|
693
|
+
return {
|
|
694
|
+
outDir,
|
|
695
|
+
summary,
|
|
696
|
+
pack: fs.readFileSync(packFile, "utf8"),
|
|
697
|
+
report: readJsonIfExists(reportFile),
|
|
698
|
+
semanticIndex: readJsonIfExists(semanticJsonFile),
|
|
699
|
+
semanticMarkdown: fs.readFileSync(semanticMdFile, "utf8"),
|
|
700
|
+
localCacheHit: true,
|
|
701
|
+
fingerprint
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const semanticIndex = buildSemanticSlices(repo, summary);
|
|
706
|
+
const semanticMarkdown = buildSemanticSlicesMarkdown(semanticIndex);
|
|
364
707
|
const pack = buildRepoPack(repo, summary);
|
|
365
|
-
const report = buildReport(summary, pack, tasks, pricePer1m, runsPerWeek);
|
|
708
|
+
const report = buildReport(summary, pack, tasks, pricePer1m, runsPerWeek, semanticIndex, semanticMarkdown);
|
|
366
709
|
fs.writeFileSync(path.join(outDir, "repo-pack.md"), pack, "utf8");
|
|
710
|
+
fs.writeFileSync(path.join(outDir, "semantic-slices.json"), JSON.stringify(semanticIndex, null, 2) + "\n", "utf8");
|
|
711
|
+
fs.writeFileSync(path.join(outDir, "semantic-slices.md"), semanticMarkdown, "utf8");
|
|
367
712
|
fs.writeFileSync(path.join(outDir, "agent-instructions.md"), buildInstructions(), "utf8");
|
|
368
713
|
fs.writeFileSync(path.join(outDir, "savings-report.json"), JSON.stringify(report, null, 2) + "\n", "utf8");
|
|
369
714
|
fs.writeFileSync(path.join(outDir, "savings-report.md"), reportMarkdown(report), "utf8");
|
|
370
|
-
|
|
715
|
+
writeLocalCache(outDir, {
|
|
716
|
+
artifact_version: 1,
|
|
717
|
+
fingerprint,
|
|
718
|
+
created_utc: new Date().toISOString(),
|
|
719
|
+
files_indexed: summary.files.length,
|
|
720
|
+
source_tokens_indexed: summary.totalTokens,
|
|
721
|
+
repo_pack_sha256: sha256(pack),
|
|
722
|
+
report_sha256: sha256(stableJson(report)),
|
|
723
|
+
semantic_receipt_sha256: semanticIndex.receipt_sha256
|
|
724
|
+
});
|
|
725
|
+
return { outDir, summary, pack, report, semanticIndex, semanticMarkdown, localCacheHit: false, fingerprint };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
function semanticReportMarkdown(report) {
|
|
730
|
+
const semantic = report.semantic_slices;
|
|
731
|
+
if (!semantic || !semantic.slice_count) return "";
|
|
732
|
+
const top = Array.isArray(semantic.top_slices) ? semantic.top_slices : [];
|
|
733
|
+
return `## Offline Semantic Slices
|
|
734
|
+
|
|
735
|
+
- Artifact: \`${semantic.artifact}\`
|
|
736
|
+
- JSON: \`${semantic.json_artifact}\`
|
|
737
|
+
- Receipt hash: \`${semantic.receipt_hash}\`
|
|
738
|
+
- Slice count: ${Number(semantic.slice_count || 0).toLocaleString()}
|
|
739
|
+
- Semantic index tokens: ${Number(semantic.semantic_index_tokens || 0).toLocaleString()}
|
|
740
|
+
- Potential tokens avoided per repeated task: ${Number(semantic.potential_tokens_avoided_per_repeated_task || 0).toLocaleString()}
|
|
741
|
+
- Potential reduction vs broad read: ${semantic.potential_reduction_percent}%
|
|
742
|
+
|
|
743
|
+
${top.map((slice) => `- ${slice.title}: ${Number(slice.file_count || 0).toLocaleString()} files, ${Number(slice.source_tokens_covered || 0).toLocaleString()} source tokens, ${Number(slice.fact_count || 0).toLocaleString()} facts`).join("\n")}
|
|
744
|
+
|
|
745
|
+
These slices are generated offline from local file paths, hashes, imports, routes, and symbol lines. They enrich the local receipt and runtime plan without changing live provider prompts.
|
|
746
|
+
`;
|
|
371
747
|
}
|
|
372
748
|
|
|
373
749
|
function reportMarkdown(report) {
|
|
@@ -401,15 +777,16 @@ Generated: ${report.created_utc}
|
|
|
401
777
|
|
|
402
778
|
${report.largest_files.map((row) => `- ${row.path}: ${row.tokens.toLocaleString()} tokens, hash ${row.hash}`).join("\n")}
|
|
403
779
|
|
|
404
|
-
## Caveat
|
|
780
|
+
${semanticReportMarkdown(report)}## Caveat
|
|
405
781
|
|
|
406
782
|
This public scanner estimates repeated context waste. Real savings should be validated against provider usage or invoices.
|
|
407
783
|
`;
|
|
408
784
|
}
|
|
409
785
|
|
|
410
|
-
function init(repo) {
|
|
786
|
+
function init(repo, options = {}) {
|
|
411
787
|
const outDir = path.join(repo, ".agentspend");
|
|
412
788
|
ensureDir(outDir);
|
|
789
|
+
ensureAgentSpendGitignore(outDir);
|
|
413
790
|
writeIfMissing(path.join(outDir, "config.json"), JSON.stringify({
|
|
414
791
|
version: VERSION,
|
|
415
792
|
created_utc: new Date().toISOString(),
|
|
@@ -425,17 +802,24 @@ function init(repo) {
|
|
|
425
802
|
process.stdout.write(`AGENTS.md already exists; snippet saved to ${path.join(outDir, "AGENTS_SNIPPET.md")}\n`);
|
|
426
803
|
}
|
|
427
804
|
process.stdout.write(`CostLayers initialized in ${outDir}\n`);
|
|
428
|
-
process.stdout.write("Next: costlayers scan\n");
|
|
805
|
+
if (!options.suppressNext) process.stdout.write("Next: costlayers scan\n");
|
|
429
806
|
}
|
|
430
807
|
|
|
431
|
-
function scan(repo, args) {
|
|
808
|
+
async function scan(repo, args) {
|
|
432
809
|
process.stdout.write(`Scanning repo: ${repo}\n`);
|
|
433
810
|
const precomputed = scanToFiles(repo, args);
|
|
434
811
|
const { outDir, report } = precomputed;
|
|
435
812
|
process.stdout.write(`CostLayers scan complete\n`);
|
|
813
|
+
if (precomputed.localCacheHit) process.stdout.write(`Local exact cache hit: source hashes unchanged, reused CostLayers artifacts\n`);
|
|
436
814
|
process.stdout.write(`Repo pack: ${path.join(outDir, "repo-pack.md")}\n`);
|
|
815
|
+
process.stdout.write(`Semantic slices: ${path.join(outDir, "semantic-slices.md")}\n`);
|
|
437
816
|
process.stdout.write(`Report: ${path.join(outDir, "savings-report.md")}\n`);
|
|
438
817
|
printSavingsSummary(report);
|
|
818
|
+
await trackEvent(repo, args, "cli_scan", {
|
|
819
|
+
files_indexed: report.files_indexed,
|
|
820
|
+
context_tokens_avoided_per_task: report.tokens_avoided_per_repeated_task,
|
|
821
|
+
local_cache_hit: Boolean(precomputed.localCacheHit)
|
|
822
|
+
});
|
|
439
823
|
}
|
|
440
824
|
|
|
441
825
|
function connectEngine(repo, args) {
|
|
@@ -451,21 +835,30 @@ function connectEngine(repo, args) {
|
|
|
451
835
|
api_key: args["api-key"] ? String(args["api-key"]) : null,
|
|
452
836
|
connected_utc: new Date().toISOString()
|
|
453
837
|
};
|
|
454
|
-
|
|
838
|
+
saveConnection(repo, config);
|
|
455
839
|
process.stdout.write(`Connected CostLayers engine: ${config.engine_url}\n`);
|
|
456
840
|
}
|
|
457
841
|
|
|
458
842
|
function loadConnection(repo, args) {
|
|
459
|
-
const
|
|
460
|
-
const saved = readJsonIfExists(path.join(outDir, "connection.json")) || {};
|
|
843
|
+
const saved = loadStoredConnection(repo) || {};
|
|
461
844
|
const engineUrl = String(args["engine-url"] || saved.engine_url || "").replace(/\/+$/, "");
|
|
462
845
|
if (!engineUrl) {
|
|
463
|
-
process.stderr.write("Missing engine connection. Run `
|
|
846
|
+
process.stderr.write("Missing engine connection. Run `costlayers signup --email you@example.com` first.\n");
|
|
847
|
+
process.exit(2);
|
|
848
|
+
}
|
|
849
|
+
const apiKey = args["api-key"] ? String(args["api-key"]) : saved.api_key || null;
|
|
850
|
+
if (!apiKey) {
|
|
851
|
+
process.stderr.write([
|
|
852
|
+
"CostLayers cannot find the live key for this repo.",
|
|
853
|
+
"Keys are stored outside the repo in ~/.config/costlayers/connections.",
|
|
854
|
+
"Run `costlayers signup --email you@example.com` again from this repo to create a fresh key.",
|
|
855
|
+
""
|
|
856
|
+
].join("\n"));
|
|
464
857
|
process.exit(2);
|
|
465
858
|
}
|
|
466
859
|
return {
|
|
467
860
|
engine_url: engineUrl,
|
|
468
|
-
api_key:
|
|
861
|
+
api_key: apiKey
|
|
469
862
|
};
|
|
470
863
|
}
|
|
471
864
|
|
|
@@ -507,14 +900,35 @@ function savingsProjection(report) {
|
|
|
507
900
|
};
|
|
508
901
|
}
|
|
509
902
|
|
|
903
|
+
function savingsVerdict(report) {
|
|
904
|
+
const projection = savingsProjection(report);
|
|
905
|
+
const avoided = Number(report.tokens_avoided_per_repeated_task || 0);
|
|
906
|
+
const sourceTokens = Number(report.source_tokens_indexed || 0);
|
|
907
|
+
const reduction = Number(report.estimated_reduction_percent || 0);
|
|
908
|
+
const meaningful = avoided >= 1000 && reduction >= 15 && projection.monthlyUsd >= 0.25;
|
|
909
|
+
return {
|
|
910
|
+
projection,
|
|
911
|
+
avoided,
|
|
912
|
+
sourceTokens,
|
|
913
|
+
reduction,
|
|
914
|
+
meaningful,
|
|
915
|
+
smallRepo: sourceTokens < 5000 || avoided < 1000
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
510
919
|
function dashboardUrlFromConnection(connection) {
|
|
511
920
|
return (connection.gateway_url || defaultPublicGatewayUrl(connection.engine_url, connection.api_key)).replace("/gateway/", "/engine/dashboard/");
|
|
512
921
|
}
|
|
513
922
|
|
|
514
923
|
function printSavingsSummary(report) {
|
|
515
|
-
const
|
|
516
|
-
const
|
|
517
|
-
|
|
924
|
+
const verdict = savingsVerdict(report);
|
|
925
|
+
const projection = verdict.projection;
|
|
926
|
+
const avoided = verdict.avoided;
|
|
927
|
+
if (!verdict.meaningful) {
|
|
928
|
+
process.stdout.write(`\nCostLayers installed. This repo is too small for a meaningful savings claim.\n`);
|
|
929
|
+
} else {
|
|
930
|
+
process.stdout.write(`\nCostLayers found repeated context waste\n`);
|
|
931
|
+
}
|
|
518
932
|
process.stdout.write(` Evidence: context estimate from local repo scan\n`);
|
|
519
933
|
process.stdout.write(` Tokens avoided per repeated task: ${formatInt(report.tokens_avoided_per_repeated_task)}\n`);
|
|
520
934
|
if (avoided > 0) {
|
|
@@ -525,9 +939,16 @@ function printSavingsSummary(report) {
|
|
|
525
939
|
}
|
|
526
940
|
process.stdout.write(` Estimated waste value per ${formatInt(report.repeated_tasks_modeled)} repeated tasks: ${formatUsd(report.estimated_usd_saved)}\n`);
|
|
527
941
|
process.stdout.write(` Usage-stretch estimate at ${formatInt(projection.runsPerWeek)} agent runs/week: ${formatUsd(projection.weeklyUsd)}/week, ${formatUsd(projection.monthlyUsd)}/month\n`);
|
|
942
|
+
if (!verdict.meaningful) {
|
|
943
|
+
process.stdout.write(` Verdict: no savings claim for this repo. CostLayers is strongest on large repos and repeated agent sessions.\n`);
|
|
944
|
+
}
|
|
528
945
|
process.stdout.write(` Invoice savings require API traffic through CostLayers invoice mode.\n`);
|
|
529
946
|
process.stdout.write(` Source tokens indexed: ${formatInt(report.source_tokens_indexed)}\n`);
|
|
530
947
|
process.stdout.write(` Compact repo pack: ${formatInt(report.context_pack_tokens)} tokens\n`);
|
|
948
|
+
const semantic = report.semantic_slices || null;
|
|
949
|
+
if (semantic && semantic.slice_count) {
|
|
950
|
+
process.stdout.write(` Offline semantic slices: ${formatInt(semantic.slice_count)} slices, ${formatInt(semantic.semantic_index_tokens)} tokens, receipt ${String(semantic.receipt_hash || "").slice(0, 12)}\n`);
|
|
951
|
+
}
|
|
531
952
|
}
|
|
532
953
|
|
|
533
954
|
function codexHomeDir() {
|
|
@@ -557,23 +978,22 @@ function codexArgsAfterDash(argv) {
|
|
|
557
978
|
|
|
558
979
|
function withAutoCodexMode(args = {}, options = {}) {
|
|
559
980
|
const next = { ...args };
|
|
560
|
-
const
|
|
561
|
-
const hasApiKey = Boolean(process.env[keyEnv]);
|
|
562
|
-
const wantsInvoice = apiInvoiceModeRequested(next) || (options.preferInvoice && hasApiKey);
|
|
981
|
+
const wantsInvoice = apiInvoiceModeRequested(next) || Boolean(options.forceInvoice);
|
|
563
982
|
if (!chatgptModeRequested(next) && wantsInvoice) next["codex-proxy"] = true;
|
|
564
983
|
return next;
|
|
565
984
|
}
|
|
566
985
|
|
|
567
|
-
function assertCodexProxyApiKey(args = {}) {
|
|
986
|
+
function assertCodexProxyApiKey(args = {}, rerunCommand = "") {
|
|
568
987
|
if (!codexProxyEnabled(args)) return;
|
|
569
988
|
const keyEnv = codexProxyApiKeyEnv(args);
|
|
570
989
|
if (process.env[keyEnv]) return;
|
|
990
|
+
const command = rerunCommand || `npx -y ${INSTALL_SPEC} codex --email you@example.com --api`;
|
|
571
991
|
process.stderr.write([
|
|
572
992
|
`CostLayers invoice mode needs ${keyEnv} in this shell.`,
|
|
573
993
|
"",
|
|
574
994
|
"Set an OpenAI Platform API key with Responses API write permission, then rerun:",
|
|
575
995
|
` export ${keyEnv}=sk-proj-...`,
|
|
576
|
-
`
|
|
996
|
+
` ${command}`,
|
|
577
997
|
"",
|
|
578
998
|
"ChatGPT-login Codex can be metered, but it cannot produce provider invoice savings because there is no per-request Platform invoice to reduce.",
|
|
579
999
|
""
|
|
@@ -586,6 +1006,15 @@ function profileTomlString(connection, args = {}) {
|
|
|
586
1006
|
const baseUrl = `${gateway}/v1`;
|
|
587
1007
|
const engineUrl = String(connection.engine_url || "https://costlayers.com/engine").replace(/\/+$/, "");
|
|
588
1008
|
const apiKeyEnv = codexProxyApiKeyEnv(args);
|
|
1009
|
+
const otelExporter = {
|
|
1010
|
+
"otlp-http": {
|
|
1011
|
+
endpoint: `${engineUrl}/v1/codex-meter`,
|
|
1012
|
+
protocol: "json",
|
|
1013
|
+
headers: {
|
|
1014
|
+
"x-costlayers-key": connection.api_key || ""
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
589
1018
|
const lines = [
|
|
590
1019
|
"# Generated by CostLayers. This profile sends Codex telemetry to the CostLayers meter.",
|
|
591
1020
|
"# Keep this file private because it contains your keyed CostLayers endpoint.",
|
|
@@ -611,18 +1040,27 @@ function profileTomlString(connection, args = {}) {
|
|
|
611
1040
|
"[otel]",
|
|
612
1041
|
'environment = "costlayers"',
|
|
613
1042
|
"log_user_prompt = false",
|
|
614
|
-
|
|
615
|
-
'[otel.exporter."otlp-http"]',
|
|
616
|
-
`endpoint = ${JSON.stringify(`${engineUrl}/v1/codex-meter`)}`,
|
|
617
|
-
'protocol = "json"',
|
|
618
|
-
"",
|
|
619
|
-
'[otel.exporter."otlp-http".headers]',
|
|
620
|
-
`"x-costlayers-key" = ${JSON.stringify(connection.api_key || "")}`,
|
|
1043
|
+
`exporter = ${toTomlInline(otelExporter)}`,
|
|
621
1044
|
""
|
|
622
1045
|
);
|
|
623
1046
|
return lines.join("\n");
|
|
624
1047
|
}
|
|
625
1048
|
|
|
1049
|
+
function toTomlInline(value) {
|
|
1050
|
+
if (value === null || value === undefined) return '""';
|
|
1051
|
+
if (Array.isArray(value)) return `[${value.map((item) => toTomlInline(item)).join(", ")}]`;
|
|
1052
|
+
if (typeof value === "object") {
|
|
1053
|
+
return `{ ${Object.entries(value).map(([key, item]) => `${tomlKey(key)} = ${toTomlInline(item)}`).join(", ")} }`;
|
|
1054
|
+
}
|
|
1055
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
1056
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
1057
|
+
return JSON.stringify(String(value));
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function tomlKey(key) {
|
|
1061
|
+
return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
626
1064
|
function writeCodexProfile(connection, args = {}) {
|
|
627
1065
|
const dir = codexHomeDir();
|
|
628
1066
|
ensureDir(dir);
|
|
@@ -673,19 +1111,11 @@ async function signupConnection(repo, args) {
|
|
|
673
1111
|
label: payload.label,
|
|
674
1112
|
connected_utc: new Date().toISOString()
|
|
675
1113
|
};
|
|
676
|
-
|
|
677
|
-
return connection;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
function saveConnection(repo, connection) {
|
|
681
|
-
const outDir = path.join(repo, ".agentspend");
|
|
682
|
-
ensureDir(outDir);
|
|
683
|
-
fs.writeFileSync(path.join(outDir, "connection.json"), JSON.stringify(connection, null, 2) + "\n", "utf8");
|
|
1114
|
+
return saveConnection(repo, connection);
|
|
684
1115
|
}
|
|
685
1116
|
|
|
686
1117
|
async function ensureConnection(repo, args) {
|
|
687
|
-
const
|
|
688
|
-
const saved = readJsonIfExists(path.join(outDir, "connection.json"));
|
|
1118
|
+
const saved = loadStoredConnection(repo);
|
|
689
1119
|
const requestedEmail = normalizedEmail(args.email);
|
|
690
1120
|
if (saved && saved.engine_url && saved.api_key) {
|
|
691
1121
|
if (!requestedEmail) return saved;
|
|
@@ -717,6 +1147,7 @@ async function ensureConnection(repo, args) {
|
|
|
717
1147
|
|
|
718
1148
|
async function signup(repo, args) {
|
|
719
1149
|
const connection = await signupConnection(repo, args);
|
|
1150
|
+
await trackEvent(repo, args, "signup", { label: connection.label || path.basename(repo) }, connection);
|
|
720
1151
|
process.stdout.write(`CostLayers self-serve key created\n`);
|
|
721
1152
|
process.stdout.write(`Engine: ${connection.engine_url}\n`);
|
|
722
1153
|
process.stdout.write(`Gateway: ${connection.gateway_url}\n`);
|
|
@@ -729,6 +1160,7 @@ async function signup(repo, args) {
|
|
|
729
1160
|
async function codexProfile(repo, args) {
|
|
730
1161
|
const connection = await ensureConnection(repo, args);
|
|
731
1162
|
const profilePath = writeCodexProfile(connection, args);
|
|
1163
|
+
await trackEvent(repo, args, "codex_profile", { profile_mode: codexProxyEnabled(args) ? "api" : "chatgpt" }, connection);
|
|
732
1164
|
process.stdout.write(`CostLayers Codex profile installed\n`);
|
|
733
1165
|
process.stdout.write(`Profile: ${profilePath}\n`);
|
|
734
1166
|
process.stdout.write(`Mode: ${codexProxyEnabled(args) ? "API invoice mode" : "ChatGPT usage-stretch mode, native Codex provider preserved"}\n`);
|
|
@@ -779,38 +1211,91 @@ function postJson(urlString, payload, apiKey) {
|
|
|
779
1211
|
});
|
|
780
1212
|
}
|
|
781
1213
|
|
|
1214
|
+
function engineUrlFromArgsOrConnection(args = {}, connection = null) {
|
|
1215
|
+
const fromArgs = String(args["engine-url"] || "").replace(/\/+$/, "");
|
|
1216
|
+
if (fromArgs) return fromArgs;
|
|
1217
|
+
const fromConnection = connection && connection.engine_url ? String(connection.engine_url).replace(/\/+$/, "") : "";
|
|
1218
|
+
return fromConnection || "https://costlayers.com/engine";
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
async function trackEvent(repo, args = {}, eventType, metadata = {}, connection = null) {
|
|
1222
|
+
if (args["no-telemetry"] || process.env.COSTLAYERS_DISABLE_TELEMETRY) return;
|
|
1223
|
+
const engineUrl = engineUrlFromArgsOrConnection(args, connection);
|
|
1224
|
+
const payload = {
|
|
1225
|
+
event_type: eventType,
|
|
1226
|
+
source: "costlayers-cli",
|
|
1227
|
+
email: normalizedEmail(args.email),
|
|
1228
|
+
metadata: {
|
|
1229
|
+
version: VERSION,
|
|
1230
|
+
repo_label: path.basename(repo || process.cwd()),
|
|
1231
|
+
command: args._ ? args._[0] : "",
|
|
1232
|
+
api_mode: apiInvoiceModeRequested(args),
|
|
1233
|
+
chatgpt_mode: chatgptModeRequested(args),
|
|
1234
|
+
platform: process.platform,
|
|
1235
|
+
node: process.version,
|
|
1236
|
+
...metadata
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
try {
|
|
1240
|
+
await postJson(`${engineUrl}/v1/event`, payload, connection ? connection.api_key : null);
|
|
1241
|
+
} catch {
|
|
1242
|
+
// Metrics must never block local developer workflow.
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
782
1246
|
function buildLocalPlan(report) {
|
|
1247
|
+
const semantic = report.semantic_slices || null;
|
|
1248
|
+
const instructions = [
|
|
1249
|
+
"Read .agentspend/repo-pack.md before broad exploration.",
|
|
1250
|
+
"Use .agentspend/savings-report.md to identify repeated context sources.",
|
|
1251
|
+
"Prefer targeted reads of files listed in the repo pack.",
|
|
1252
|
+
"Do not reread unchanged large files unless exact code is required."
|
|
1253
|
+
];
|
|
1254
|
+
if (semantic && semantic.slice_count) {
|
|
1255
|
+
instructions.splice(1, 0, "Read .agentspend/semantic-slices.md first when route or symbol facts are enough; open exact files only when needed.");
|
|
1256
|
+
}
|
|
783
1257
|
return {
|
|
784
1258
|
mode: "local",
|
|
785
1259
|
created_utc: new Date().toISOString(),
|
|
786
1260
|
plan_summary: "Use the repo pack before broad exploration and avoid rereading unchanged large files.",
|
|
787
1261
|
expected_value: {
|
|
788
1262
|
tokens_avoided_per_repeated_task: report.tokens_avoided_per_repeated_task,
|
|
789
|
-
estimated_usd_saved: report.estimated_usd_saved
|
|
1263
|
+
estimated_usd_saved: report.estimated_usd_saved,
|
|
1264
|
+
semantic_slice_count: semantic ? semantic.slice_count : 0,
|
|
1265
|
+
semantic_index_tokens: semantic ? semantic.semantic_index_tokens : 0,
|
|
1266
|
+
semantic_receipt_hash: semantic ? semantic.receipt_hash : ""
|
|
790
1267
|
},
|
|
791
|
-
runtime_instructions:
|
|
792
|
-
"Read .agentspend/repo-pack.md before broad exploration.",
|
|
793
|
-
"Use .agentspend/savings-report.md to identify repeated context sources.",
|
|
794
|
-
"Prefer targeted reads of files listed in the repo pack.",
|
|
795
|
-
"Do not reread unchanged large files unless exact code is required."
|
|
796
|
-
]
|
|
1268
|
+
runtime_instructions: instructions
|
|
797
1269
|
};
|
|
798
1270
|
}
|
|
799
1271
|
|
|
800
1272
|
async function fetchEnginePlan(connection, repo, pack, report) {
|
|
801
1273
|
if (!connection || !connection.engine_url) return null;
|
|
1274
|
+
const semantic = report && report.semantic_slices ? report.semantic_slices : {};
|
|
802
1275
|
const payload = {
|
|
803
1276
|
version: VERSION,
|
|
804
1277
|
repo_name: path.basename(repo),
|
|
805
1278
|
repo_pack_sha256: sha256(pack),
|
|
806
|
-
|
|
807
|
-
savings_report: report
|
|
1279
|
+
privacy_mode: argsPrivacyMode(connection),
|
|
1280
|
+
savings_report: report,
|
|
1281
|
+
local_artifacts: {
|
|
1282
|
+
repo_pack_sha256: sha256(pack),
|
|
1283
|
+
savings_report_sha256: sha256(stableJson(report || {})),
|
|
1284
|
+
semantic_receipt_hash: semantic && semantic.receipt_hash ? String(semantic.receipt_hash) : ""
|
|
1285
|
+
}
|
|
808
1286
|
};
|
|
1287
|
+
if (connection && connection.send_repo_preview) payload.repo_pack_preview = pack.slice(0, 12000);
|
|
809
1288
|
const plan = await postJson(`${connection.engine_url}/v1/plan`, payload, connection.api_key);
|
|
810
1289
|
plan.mode = plan.mode || "closed-engine";
|
|
811
1290
|
return plan;
|
|
812
1291
|
}
|
|
813
1292
|
|
|
1293
|
+
function argsPrivacyMode(connection) {
|
|
1294
|
+
return connection && connection.send_repo_preview
|
|
1295
|
+
? "opt_in_repo_preview"
|
|
1296
|
+
: "hashes_metrics_and_reports_default";
|
|
1297
|
+
}
|
|
1298
|
+
|
|
814
1299
|
function writeRuntimePrompt(outDir, plan) {
|
|
815
1300
|
const lines = [];
|
|
816
1301
|
lines.push("# CostLayers Runtime Plan");
|
|
@@ -837,7 +1322,7 @@ async function runAgent(repo, args, argv, options = {}) {
|
|
|
837
1322
|
}
|
|
838
1323
|
if (!options.skipSetup) init(repo);
|
|
839
1324
|
const { outDir, pack, report } = options.precomputed || scanToFiles(repo, args);
|
|
840
|
-
const connection =
|
|
1325
|
+
const connection = loadStoredConnection(repo);
|
|
841
1326
|
let plan = buildLocalPlan(report);
|
|
842
1327
|
if (connection && connection.engine_url) {
|
|
843
1328
|
try {
|
|
@@ -854,6 +1339,7 @@ async function runAgent(repo, args, argv, options = {}) {
|
|
|
854
1339
|
assertCodexProxyApiKey(args);
|
|
855
1340
|
const profilePath = writeCodexProfile(connection, args);
|
|
856
1341
|
commandToRun = withCostLayersCodexProfile(command);
|
|
1342
|
+
await trackEvent(repo, args, "codex_run", { profile_mode: codexProxyEnabled(args) ? "api" : "chatgpt" }, connection);
|
|
857
1343
|
process.stdout.write(`CostLayers Codex profile: ${profilePath}\n`);
|
|
858
1344
|
process.stdout.write(`Codex metering enabled: ${commandToRun.join(" ")}\n`);
|
|
859
1345
|
process.stdout.write(`Codex profile mode: ${codexProxyEnabled(args) ? "API invoice mode" : "ChatGPT usage-stretch mode; native Codex model path preserved"}\n`);
|
|
@@ -863,6 +1349,7 @@ async function runAgent(repo, args, argv, options = {}) {
|
|
|
863
1349
|
const env = {
|
|
864
1350
|
...process.env,
|
|
865
1351
|
AGENTSPEND_REPO_PACK: path.join(outDir, "repo-pack.md"),
|
|
1352
|
+
AGENTSPEND_SEMANTIC_SLICES: path.join(outDir, "semantic-slices.md"),
|
|
866
1353
|
AGENTSPEND_RUNTIME_PLAN: path.join(outDir, "runtime-plan.md")
|
|
867
1354
|
};
|
|
868
1355
|
const result = spawnSync(commandToRun[0], commandToRun.slice(1), {
|
|
@@ -897,6 +1384,7 @@ async function gateway(repo, args) {
|
|
|
897
1384
|
process.stdout.write(`CostLayers gateway ready: ${result.base_url}\n`);
|
|
898
1385
|
process.stdout.write(`Set your OpenAI-compatible base URL to: ${result.base_url}\n`);
|
|
899
1386
|
process.stdout.write(`Report: costlayers gateway report\n`);
|
|
1387
|
+
await trackEvent(repo, args, "api_mode_start", { mode: payload.mode, dry_run: payload.dry_run }, connection);
|
|
900
1388
|
return;
|
|
901
1389
|
}
|
|
902
1390
|
if (action === "report") {
|
|
@@ -916,6 +1404,7 @@ async function gateway(repo, args) {
|
|
|
916
1404
|
process.stdout.write(`saved_cost_usd: ${summary.saved_cost_usd || 0}\n`);
|
|
917
1405
|
process.stdout.write(`gateway_authenticated_actions: ${status.gateway_request_count || 0}\n`);
|
|
918
1406
|
process.stdout.write(`dashboard: ${dashboardUrlFromConnection(connection)}\n`);
|
|
1407
|
+
await trackEvent(repo, args, "gateway_report", {}, connection);
|
|
919
1408
|
return;
|
|
920
1409
|
}
|
|
921
1410
|
if (action === "stop") {
|
|
@@ -930,6 +1419,7 @@ async function gateway(repo, args) {
|
|
|
930
1419
|
async function dashboard(repo, args) {
|
|
931
1420
|
const connection = loadConnection(repo, args);
|
|
932
1421
|
const status = await postJson(`${connection.engine_url}/v1/me`, {}, connection.api_key);
|
|
1422
|
+
await trackEvent(repo, args, "dashboard_open", {}, connection);
|
|
933
1423
|
const dashboardUrl = (connection.gateway_url || defaultPublicGatewayUrl(connection.engine_url, connection.api_key)).replace("/gateway/", "/engine/dashboard/");
|
|
934
1424
|
process.stdout.write(`CostLayers Dashboard\n`);
|
|
935
1425
|
process.stdout.write(`URL: ${dashboardUrl}\n`);
|
|
@@ -950,24 +1440,24 @@ async function codexShortcut(repo, args, argv) {
|
|
|
950
1440
|
const codexTail = codexArgsAfterDash(argv);
|
|
951
1441
|
const command = codexTail.length > 0 ? codexTail : ["codex"];
|
|
952
1442
|
const commandToRun = isCodexCommand(command) ? command : ["codex", ...command];
|
|
953
|
-
const nextArgs = withAutoCodexMode(args
|
|
1443
|
+
const nextArgs = withAutoCodexMode(args);
|
|
954
1444
|
if (codexProxyEnabled(nextArgs)) {
|
|
955
|
-
process.stdout.write(`CostLayers Codex: API invoice mode enabled from ${codexProxyApiKeyEnv(nextArgs)}.\n`);
|
|
1445
|
+
process.stdout.write(`CostLayers Codex: API invoice mode explicitly enabled from ${codexProxyApiKeyEnv(nextArgs)}.\n`);
|
|
956
1446
|
} else {
|
|
957
|
-
process.stdout.write(`CostLayers Codex: ChatGPT usage-stretch mode.
|
|
1447
|
+
process.stdout.write(`CostLayers Codex: ChatGPT usage-stretch mode. Pass --api to route API-billed provider calls for invoice savings.\n`);
|
|
958
1448
|
}
|
|
959
1449
|
return start(repo, nextArgs, ["start", "--", ...commandToRun]);
|
|
960
1450
|
}
|
|
961
1451
|
|
|
962
1452
|
async function savingsTest(repo, args) {
|
|
963
|
-
const nextArgs = withAutoCodexMode(args, {
|
|
1453
|
+
const nextArgs = withAutoCodexMode({ ...args, api: true }, { forceInvoice: true });
|
|
964
1454
|
if (!codexProxyEnabled(nextArgs)) {
|
|
965
1455
|
const keyEnv = codexProxyApiKeyEnv(nextArgs);
|
|
966
1456
|
process.stderr.write([
|
|
967
1457
|
`CostLayers test needs ${keyEnv} for real API invoice savings.`,
|
|
968
1458
|
"",
|
|
969
1459
|
"Set your OpenAI Platform API key, then rerun:",
|
|
970
|
-
`
|
|
1460
|
+
` export ${keyEnv}=sk-proj-...`,
|
|
971
1461
|
` npx -y ${INSTALL_SPEC} test --email you@example.com`,
|
|
972
1462
|
"",
|
|
973
1463
|
`For ChatGPT-login metering without invoice savings, run:`,
|
|
@@ -979,6 +1469,8 @@ async function savingsTest(repo, args) {
|
|
|
979
1469
|
const prompt = typeof args.prompt === "string"
|
|
980
1470
|
? args.prompt
|
|
981
1471
|
: "Analyze this repository. Find the main entry points, data flow, and the 5 files most worth reading. Do not edit files.";
|
|
1472
|
+
assertCodexProxyApiKey(nextArgs, `npx -y ${INSTALL_SPEC} test --email you@example.com`);
|
|
1473
|
+
await trackEvent(repo, nextArgs, "api_savings_test", {}, loadStoredConnection(repo));
|
|
982
1474
|
process.stdout.write("CostLayers savings test: running one safe read-only Codex task.\n");
|
|
983
1475
|
const status = await start(repo, nextArgs, ["start", "--", "codex", "exec", "--sandbox", "read-only", prompt], { returnStatus: true });
|
|
984
1476
|
process.stdout.write("\nCostLayers savings test report\n");
|
|
@@ -995,14 +1487,20 @@ async function start(repo, args, argv, options = {}) {
|
|
|
995
1487
|
const command = dash >= 0 ? argv.slice(dash + 1) : [];
|
|
996
1488
|
const codexTelemetryRun = command.length > 0 && isCodexCommand(command) && !codexProxyEnabled(args);
|
|
997
1489
|
if (command.length > 0 && isCodexCommand(command)) assertCodexProxyApiKey(args);
|
|
998
|
-
init(repo);
|
|
1490
|
+
init(repo, { suppressNext: true });
|
|
999
1491
|
process.stdout.write(`Scanning repo: ${repo}\n`);
|
|
1000
1492
|
const precomputed = scanToFiles(repo, args);
|
|
1001
1493
|
const { outDir, pack, report } = precomputed;
|
|
1002
1494
|
process.stdout.write(`CostLayers scan complete\n`);
|
|
1495
|
+
if (precomputed.localCacheHit) process.stdout.write(`Local exact cache hit: source hashes unchanged, reused CostLayers artifacts\n`);
|
|
1003
1496
|
process.stdout.write(`Report: ${path.join(outDir, "savings-report.md")}\n`);
|
|
1004
1497
|
printSavingsSummary(report);
|
|
1005
1498
|
const connection = await ensureConnection(repo, args);
|
|
1499
|
+
await trackEvent(repo, args, "cli_start", {
|
|
1500
|
+
files_indexed: report.files_indexed,
|
|
1501
|
+
context_tokens_avoided_per_task: report.tokens_avoided_per_repeated_task,
|
|
1502
|
+
local_cache_hit: Boolean(precomputed.localCacheHit)
|
|
1503
|
+
}, connection);
|
|
1006
1504
|
try {
|
|
1007
1505
|
await fetchEnginePlan(connection, repo, pack, report);
|
|
1008
1506
|
process.stdout.write(`Dashboard synced with first-run savings\n`);
|
|
@@ -1013,7 +1511,7 @@ async function start(repo, args, argv, options = {}) {
|
|
|
1013
1511
|
let gatewayBaseUrl = connection.gateway_url || defaultPublicGatewayUrl(connection.engine_url, connection.api_key);
|
|
1014
1512
|
if (codexTelemetryRun) {
|
|
1015
1513
|
process.stdout.write(`ChatGPT-login Codex mode: native Codex provider preserved; model calls are not routed through CostLayers.\n`);
|
|
1016
|
-
process.stdout.write(`What users get:
|
|
1514
|
+
process.stdout.write(`What users get: repo context discipline and a usage-stretch meter. This does not reduce a flat ChatGPT subscription invoice.\n`);
|
|
1017
1515
|
} else {
|
|
1018
1516
|
const providerUrl = typeof args["provider-url"] === "string" ? args["provider-url"] : "https://api.openai.com";
|
|
1019
1517
|
const payload = {
|
|
@@ -1035,6 +1533,7 @@ async function start(repo, args, argv, options = {}) {
|
|
|
1035
1533
|
process.stdout.write(`OpenAI-compatible base URL: ${gatewayBaseUrl}\n`);
|
|
1036
1534
|
if (codexProxyEnabled(args)) {
|
|
1037
1535
|
process.stdout.write(`API invoice mode: Codex will use ${codexProxyApiKeyEnv(args)} through the CostLayers gateway.\n`);
|
|
1536
|
+
await trackEvent(repo, args, "api_mode_start", { mode: payload.mode, from_start: true }, connection);
|
|
1038
1537
|
}
|
|
1039
1538
|
}
|
|
1040
1539
|
process.stdout.write(`Dashboard: ${dashboardUrlFromConnection(connection)}\n`);
|
|
@@ -1046,18 +1545,33 @@ async function start(repo, args, argv, options = {}) {
|
|
|
1046
1545
|
if (command.length > 0) {
|
|
1047
1546
|
return runAgent(repo, args, argv, { skipSetup: true, precomputed, returnStatus: options.returnStatus });
|
|
1048
1547
|
}
|
|
1049
|
-
process.stdout.write(`\
|
|
1050
|
-
process.stdout.write(`
|
|
1051
|
-
process.stdout.write(`
|
|
1052
|
-
process.stdout.write(`
|
|
1053
|
-
process.stdout.write(` Or run Codex directly: codex --profile costlayers\n`);
|
|
1054
|
-
process.stdout.write(` View report: npx -y ${INSTALL_SPEC} gateway report\n`);
|
|
1055
|
-
process.stdout.write(` Dashboard: npx -y ${INSTALL_SPEC} dashboard\n`);
|
|
1548
|
+
process.stdout.write(`\nReady.\n`);
|
|
1549
|
+
process.stdout.write(` Run Codex: codex --profile costlayers\n`);
|
|
1550
|
+
process.stdout.write(` Dashboard: ${dashboardUrlFromConnection(connection)}\n`);
|
|
1551
|
+
process.stdout.write(` API invoice proof: export OPENAI_API_KEY=sk-proj-... && npx -y ${INSTALL_SPEC} test --email you@example.com\n`);
|
|
1056
1552
|
}
|
|
1057
1553
|
|
|
1058
1554
|
function doctor() {
|
|
1555
|
+
const repo = process.cwd();
|
|
1556
|
+
const outDir = path.join(repo, ".agentspend");
|
|
1557
|
+
const saved = loadStoredConnection(repo);
|
|
1558
|
+
const codexCheck = spawnSync("codex", ["--version"], {
|
|
1559
|
+
encoding: "utf8",
|
|
1560
|
+
shell: process.platform === "win32"
|
|
1561
|
+
});
|
|
1562
|
+
const profilePath = path.join(codexHomeDir(), "costlayers.config.toml");
|
|
1563
|
+
const reportPath = path.join(outDir, "savings-report.json");
|
|
1564
|
+
const connectionCount = fs.existsSync(connectionsDir())
|
|
1565
|
+
? fs.readdirSync(connectionsDir()).filter((name) => name.endsWith(".json")).length
|
|
1566
|
+
: 0;
|
|
1059
1567
|
process.stdout.write(`CostLayers ${VERSION}\n`);
|
|
1060
1568
|
process.stdout.write(`Node ${process.version}\n`);
|
|
1569
|
+
process.stdout.write(`Codex: ${codexCheck.status === 0 ? String(codexCheck.stdout || codexCheck.stderr).trim() : "not found"}\n`);
|
|
1570
|
+
process.stdout.write(`Repo: ${repo}${isHomeDirectory(repo) ? " (home directory; use --repo or cd into a project)" : ""}\n`);
|
|
1571
|
+
process.stdout.write(`Local report: ${fs.existsSync(reportPath) ? reportPath : "not found; run costlayers scan"}\n`);
|
|
1572
|
+
process.stdout.write(`Saved repo connection: ${saved && saved.api_key ? "yes" : "no"}\n`);
|
|
1573
|
+
process.stdout.write(`Private connection store entries: ${connectionCount}\n`);
|
|
1574
|
+
process.stdout.write(`Codex profile: ${fs.existsSync(profilePath) ? profilePath : "not installed"}\n`);
|
|
1061
1575
|
process.stdout.write("Status: ok\n");
|
|
1062
1576
|
}
|
|
1063
1577
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "costlayers",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.20",
|
|
4
4
|
"description": "CostLayers cost control for AI coding agents. Build compact repo context packs, gateway reports, and savings dashboards.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agentspend": "bin/agentspend.js",
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
},
|
|
9
9
|
"type": "commonjs",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
|
+
"homepage": "https://costlayers.com",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "mailto:rishabh@costlayers.com"
|
|
14
|
+
},
|
|
11
15
|
"private": false,
|
|
12
16
|
"engines": {
|
|
13
17
|
"node": ">=18"
|