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.
Files changed (3) hide show
  1. package/README.md +20 -10
  2. package/bin/agentspend.js +588 -74
  3. package/package.json +5 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # CostLayers CLI
2
2
 
3
- CostLayers helps coding-agent users stop paying for repeated repo context. API users can route model calls through the gateway for invoice savings. ChatGPT-login Codex users get a usage-stretch meter that shows how much repeated context was avoided.
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@latest codex --email you@example.com
11
+ npx -y https://costlayers.com/costlayers-0.8.20.tgz codex --email you@example.com --chatgpt
12
12
  ```
13
13
 
14
- If `OPENAI_API_KEY` is set, CostLayers automatically uses API invoice mode.
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
- npx -y costlayers@latest test --email you@example.com
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@latest codex --email you@example.com
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@latest codex --email you@example.com
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@latest codex-profile
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
- const VERSION = "0.8.16";
13
- const INSTALL_SPEC = "costlayers@latest";
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. Uses API invoice mode automatically when OPENAI_API_KEY is set.
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. Use the listed entry points, route hints, and symbol hints to target file reads.
297
- 3. Avoid rereading unchanged large files unless exact code is required.
298
- 4. After major repo changes, ask the user to run \`costlayers scan\`.
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/agent-instructions.md.
313
- 3. Prefer targeted file reads based on the repo pack.
314
- 4. Avoid rereading unchanged large files unless exact code is required.
315
- 5. After major repo changes, run or ask for \`costlayers scan\`.
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
- return { outDir, summary, pack, report };
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
- fs.writeFileSync(path.join(outDir, "connection.json"), JSON.stringify(config, null, 2) + "\n", "utf8");
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 outDir = path.join(repo, ".agentspend");
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 `agentspend connect --engine-url <url>` first.\n");
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: args["api-key"] ? String(args["api-key"]) : saved.api_key || null
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 projection = savingsProjection(report);
516
- const avoided = Number(report.tokens_avoided_per_repeated_task || 0);
517
- process.stdout.write(`\n${avoided > 0 ? "CostLayers found repeated context waste" : "CostLayers built your repo context pack"}\n`);
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 keyEnv = codexProxyApiKeyEnv(next);
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
- ` npx -y ${INSTALL_SPEC} codex --email you@example.com --api`,
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
- fs.writeFileSync(path.join(outDir, "connection.json"), JSON.stringify(connection, null, 2) + "\n", "utf8");
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 outDir = path.join(repo, ".agentspend");
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
- repo_pack_preview: pack.slice(0, 12000),
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 = readJsonIfExists(path.join(outDir, "connection.json"));
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, { preferInvoice: true });
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. Set ${codexProxyApiKeyEnv(nextArgs)} or pass --api for invoice savings.\n`);
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, { preferInvoice: true });
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
- ` source ~/.config/costlayers/env`,
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: less repeated repo context and a usage-stretch meter. This does not reduce a flat ChatGPT subscription invoice.\n`);
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(`\nNext options:\n`);
1050
- process.stdout.write(` Use gateway URL in your model client: ${gatewayBaseUrl}\n`);
1051
- process.stdout.write(` Run Codex: npx -y ${INSTALL_SPEC} codex --email you@example.com\n`);
1052
- process.stdout.write(` Prove API savings: npx -y ${INSTALL_SPEC} test --email you@example.com\n`);
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.16",
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"