ctxoptimizer 0.4.1__py3-none-any.whl

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 (83) hide show
  1. ctxoptimizer/__init__.py +5 -0
  2. ctxoptimizer/__main__.py +14 -0
  3. ctxoptimizer/admin_security.py +130 -0
  4. ctxoptimizer/analysis/__init__.py +1 -0
  5. ctxoptimizer/analysis/baseline.py +69 -0
  6. ctxoptimizer/analysis/break_even.py +49 -0
  7. ctxoptimizer/analysis/drift.py +151 -0
  8. ctxoptimizer/analysis/edits.py +148 -0
  9. ctxoptimizer/analysis/markers.py +34 -0
  10. ctxoptimizer/analysis/recommend.py +114 -0
  11. ctxoptimizer/analysis/segments.py +57 -0
  12. ctxoptimizer/analysis/session.py +62 -0
  13. ctxoptimizer/analysis/state.py +239 -0
  14. ctxoptimizer/analysis/tokens.py +62 -0
  15. ctxoptimizer/analysis/waste.py +322 -0
  16. ctxoptimizer/cli.py +873 -0
  17. ctxoptimizer/config.py +512 -0
  18. ctxoptimizer/dashboard/__init__.py +1 -0
  19. ctxoptimizer/dashboard/api.py +537 -0
  20. ctxoptimizer/dashboard/service.py +157 -0
  21. ctxoptimizer/dashboard/static/.gitkeep +1 -0
  22. ctxoptimizer/dashboard/templates/.gitkeep +1 -0
  23. ctxoptimizer/dashboard/templates/doctor.html +46 -0
  24. ctxoptimizer/dashboard/templates/live.html +91 -0
  25. ctxoptimizer/dashboard/templates/overview.html +172 -0
  26. ctxoptimizer/dashboard/templates/pricing.html +115 -0
  27. ctxoptimizer/dashboard/templates/request.html +111 -0
  28. ctxoptimizer/dashboard/templates/session.html +67 -0
  29. ctxoptimizer/dashboard/templates/settings.html +79 -0
  30. ctxoptimizer/dashboard/templates/table.html +56 -0
  31. ctxoptimizer/dashboard/templates/transforms.html +118 -0
  32. ctxoptimizer/dashboard/templates/waste.html +120 -0
  33. ctxoptimizer/dashboard/views.py +429 -0
  34. ctxoptimizer/data/cache_limits.json +23 -0
  35. ctxoptimizer/data/default_config.toml +185 -0
  36. ctxoptimizer/data/pricing.json +421 -0
  37. ctxoptimizer/doctor.py +129 -0
  38. ctxoptimizer/guard.py +139 -0
  39. ctxoptimizer/ids.py +73 -0
  40. ctxoptimizer/log.py +73 -0
  41. ctxoptimizer/mcp.py +124 -0
  42. ctxoptimizer/models.py +145 -0
  43. ctxoptimizer/pipeline.py +126 -0
  44. ctxoptimizer/profiles.py +152 -0
  45. ctxoptimizer/providers/__init__.py +37 -0
  46. ctxoptimizer/providers/anthropic.py +105 -0
  47. ctxoptimizer/providers/base.py +191 -0
  48. ctxoptimizer/providers/gemini.py +143 -0
  49. ctxoptimizer/providers/ollama.py +133 -0
  50. ctxoptimizer/providers/openai_chat.py +135 -0
  51. ctxoptimizer/providers/openai_responses.py +121 -0
  52. ctxoptimizer/proxy.py +665 -0
  53. ctxoptimizer/routing.py +259 -0
  54. ctxoptimizer/server.py +319 -0
  55. ctxoptimizer/service.py +150 -0
  56. ctxoptimizer/telemetry/__init__.py +1 -0
  57. ctxoptimizer/telemetry/blobs.py +57 -0
  58. ctxoptimizer/telemetry/expand.py +62 -0
  59. ctxoptimizer/telemetry/pricing.py +168 -0
  60. ctxoptimizer/telemetry/redact.py +53 -0
  61. ctxoptimizer/telemetry/sse.py +145 -0
  62. ctxoptimizer/telemetry/store.py +829 -0
  63. ctxoptimizer/telemetry/usage.py +435 -0
  64. ctxoptimizer/transforms/__init__.py +87 -0
  65. ctxoptimizer/transforms/ansi.py +21 -0
  66. ctxoptimizer/transforms/anthropic_cache.py +100 -0
  67. ctxoptimizer/transforms/backref.py +74 -0
  68. ctxoptimizer/transforms/base.py +93 -0
  69. ctxoptimizer/transforms/cache_serve.py +146 -0
  70. ctxoptimizer/transforms/dedupe_lines.py +69 -0
  71. ctxoptimizer/transforms/eligibility.py +24 -0
  72. ctxoptimizer/transforms/openai_hints.py +84 -0
  73. ctxoptimizer/transforms/progress.py +52 -0
  74. ctxoptimizer/transforms/stale_result_elision.py +109 -0
  75. ctxoptimizer/transforms/truncate.py +100 -0
  76. ctxoptimizer/transforms/volatile.py +177 -0
  77. ctxoptimizer/transforms/whitespace.py +25 -0
  78. ctxoptimizer-0.4.1.dist-info/METADATA +196 -0
  79. ctxoptimizer-0.4.1.dist-info/RECORD +83 -0
  80. ctxoptimizer-0.4.1.dist-info/WHEEL +4 -0
  81. ctxoptimizer-0.4.1.dist-info/entry_points.txt +3 -0
  82. ctxoptimizer-0.4.1.dist-info/licenses/LICENSE +21 -0
  83. ctxoptimizer-0.4.1.dist-info/licenses/NOTICE +13 -0
ctxoptimizer/cli.py ADDED
@@ -0,0 +1,873 @@
1
+ """Command line interface (SPEC section 15).
2
+
3
+ P1 commands: run, status, env, doctor, stats, export, purge.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import csv
9
+ import io
10
+ import json
11
+ import shlex
12
+ import socket
13
+ import sqlite3
14
+ import time
15
+ from datetime import UTC, date, datetime
16
+ from pathlib import Path
17
+ from typing import Annotated, Any
18
+
19
+ import httpx
20
+ import typer
21
+ import uvicorn
22
+
23
+ from ctxoptimizer import __version__
24
+ from ctxoptimizer.admin_security import load_tokens, save_tokens
25
+ from ctxoptimizer.analysis.recommend import recommendations
26
+ from ctxoptimizer.config import (
27
+ LOOPBACK_HOSTS,
28
+ Config,
29
+ data_dir,
30
+ default_config_path,
31
+ ensure_config_file,
32
+ load_config,
33
+ )
34
+ from ctxoptimizer.dashboard.service import findings_from_rows
35
+ from ctxoptimizer.models import Endpoint, Mode, Provider, RequestContext
36
+ from ctxoptimizer.pipeline import run_pipeline
37
+ from ctxoptimizer.providers import adapter_for
38
+ from ctxoptimizer.server import create_app
39
+ from ctxoptimizer.service import install_service, uninstall_service
40
+ from ctxoptimizer.telemetry.blobs import BlobStore
41
+ from ctxoptimizer.telemetry.pricing import load_pricing
42
+ from ctxoptimizer.telemetry.redact import redact_text
43
+ from ctxoptimizer.telemetry.store import default_db_path
44
+ from ctxoptimizer.transforms import build_transforms
45
+
46
+ app = typer.Typer(
47
+ add_completion=False,
48
+ no_args_is_help=True,
49
+ help="Local, deterministic cost optimizer for AI coding agents.",
50
+ )
51
+
52
+ _SHELLS = ("bash", "zsh", "fish", "powershell")
53
+ _Request = dict[str, Any]
54
+
55
+
56
+ def _version_callback(value: bool) -> None:
57
+ if value:
58
+ typer.echo(__version__)
59
+ raise typer.Exit(code=0)
60
+
61
+
62
+ @app.callback()
63
+ def main(
64
+ version: Annotated[
65
+ bool,
66
+ typer.Option(
67
+ "--version",
68
+ is_eager=True,
69
+ callback=_version_callback,
70
+ help="Show the ctxoptimizer version and exit.",
71
+ ),
72
+ ] = False,
73
+ ) -> None:
74
+ """ctxoptimizer command line."""
75
+
76
+
77
+ @app.command()
78
+ def run(
79
+ host: Annotated[str | None, typer.Option("--host")] = None,
80
+ port: Annotated[int | None, typer.Option("--port")] = None,
81
+ mode: Annotated[str | None, typer.Option("--mode")] = None,
82
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
83
+ ) -> None:
84
+ """Start the proxy in the foreground."""
85
+ if config_path is None:
86
+ ensure_config_file(default_config_path())
87
+ overrides: dict[str, object] = {}
88
+ if host:
89
+ overrides["server.host"] = host
90
+ if port:
91
+ overrides["server.port"] = port
92
+ if mode:
93
+ overrides["mode.default"] = mode
94
+ config = _load(config_path, overrides or None)
95
+ typer.echo(
96
+ f"ctxoptimizer {__version__} listening on "
97
+ f"http://{config.server.host}:{config.server.port} ({config.mode.default})"
98
+ )
99
+ if config.server.allow_remote:
100
+ typer.echo("warning: remote admin access is enabled; keep the token private")
101
+ app = create_app(config, config_path=config_path)
102
+ save_tokens(app.state.admin_token, app.state.csrf_token)
103
+ typer.echo(
104
+ "dashboard: "
105
+ f"http://{config.server.host}:{config.server.port}/_ctxoptimizer/"
106
+ f"?token={app.state.admin_token}"
107
+ )
108
+ uvicorn.run(
109
+ app,
110
+ host=config.server.host,
111
+ port=config.server.port,
112
+ log_level=config.server.log_level,
113
+ access_log=False,
114
+ )
115
+
116
+
117
+ @app.command()
118
+ def status(
119
+ url: Annotated[str | None, typer.Option("--url")] = None,
120
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
121
+ ) -> None:
122
+ """Query a running proxy's health endpoint."""
123
+ config = _load(config_path)
124
+ target = url or f"http://{config.server.host}:{config.server.port}"
125
+ try:
126
+ response = httpx.get(f"{target}/_ctxoptimizer/health", timeout=2.0)
127
+ response.raise_for_status()
128
+ except httpx.HTTPError as exc:
129
+ typer.echo(f"error: cannot reach {target}: {exc}", err=True)
130
+ raise typer.Exit(code=1) from exc
131
+ data = response.json()
132
+ for key in (
133
+ "status",
134
+ "version",
135
+ "mode",
136
+ "uptime_s",
137
+ "requests",
138
+ "completed",
139
+ "client_disconnect",
140
+ "upstream_errors",
141
+ "dropped_telemetry",
142
+ "stored_requests",
143
+ "last_model",
144
+ "last_cost_usd",
145
+ "last_usage_source",
146
+ ):
147
+ typer.echo(f"{key}: {data.get(key)}")
148
+
149
+
150
+ @app.command()
151
+ def mode(
152
+ value: Annotated[str, typer.Argument(help="passthrough | shadow | active")],
153
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
154
+ ) -> None:
155
+ """Switch mode at runtime via the admin API."""
156
+ if value not in {"passthrough", "shadow", "active"}:
157
+ raise typer.BadParameter("mode must be passthrough, shadow or active")
158
+ config = _load(config_path)
159
+ try:
160
+ data = _admin_request(config, "POST", "/_ctxoptimizer/api/mode", {"mode": value})
161
+ except Exception as exc:
162
+ typer.echo(f"error: {exc}", err=True)
163
+ raise typer.Exit(code=1) from exc
164
+ typer.echo(f"mode: {data.get('mode')}")
165
+
166
+
167
+ @app.command()
168
+ def env(
169
+ shell: Annotated[str, typer.Option("--shell")] = "bash",
170
+ host: Annotated[str | None, typer.Option("--host")] = None,
171
+ port: Annotated[int | None, typer.Option("--port")] = None,
172
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
173
+ ) -> None:
174
+ """Print client base-URL exports for the current shell."""
175
+ if shell not in _SHELLS:
176
+ raise typer.BadParameter(f"shell must be one of {', '.join(_SHELLS)}")
177
+ config = _load(config_path)
178
+ actual_host = host or config.server.host
179
+ actual_port = port or config.server.port
180
+ base = f"http://{actual_host}:{actual_port}"
181
+ values: dict[str, str] = {
182
+ "ANTHROPIC_BASE_URL": base,
183
+ "OPENAI_BASE_URL": f"{base}/v1",
184
+ }
185
+ for name in sorted(config.upstreams):
186
+ if name in {"anthropic", "openai", "gemini", "ollama"}:
187
+ continue
188
+ values[f"CTXOPTIMIZER_{name.upper()}_BASE_URL"] = f"{base}/u/{name}"
189
+ for name, value in values.items():
190
+ typer.echo(_export_line(shell, name, value))
191
+
192
+
193
+ @app.command()
194
+ def doctor(
195
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
196
+ offline: Annotated[bool, typer.Option("--offline")] = False,
197
+ ) -> None:
198
+ """Check the local configuration, storage and upstream reachability."""
199
+ config = _load(config_path)
200
+ failures = 0
201
+
202
+ def report(ok: bool, label: str, detail: str = "") -> None:
203
+ nonlocal failures
204
+ if not ok:
205
+ failures += 1
206
+ suffix = f": {detail}" if detail else ""
207
+ typer.echo(f"[{'ok' if ok else 'fail'}] {label}{suffix}")
208
+
209
+ def note(label: str, detail: str) -> None:
210
+ typer.echo(f"[warn] {label}: {detail}")
211
+
212
+ report(
213
+ config.server.host in LOOPBACK_HOSTS or config.server.allow_remote,
214
+ "bind host",
215
+ config.server.host,
216
+ )
217
+
218
+ db_path = _db_path(config)
219
+ try:
220
+ with sqlite3.connect(db_path) as conn:
221
+ conn.execute("PRAGMA user_version").fetchone()
222
+ report(True, "database writable", str(db_path))
223
+ except Exception as exc:
224
+ report(False, "database writable", str(exc))
225
+
226
+ try:
227
+ table = load_pricing(Path(config.pricing.file) if config.pricing.file else None)
228
+ verified = sum(1 for price in table.models if price.verified)
229
+ typer.echo(f"[ok] pricing: version {table.version}, {verified} verified entries")
230
+ try:
231
+ age_days = (date.today() - date.fromisoformat(table.version)).days
232
+ if age_days > 60:
233
+ note("pricing age", f"{age_days} days; check provider pages")
234
+ except ValueError:
235
+ note("pricing age", f"unparseable version {table.version!r}")
236
+ except Exception as exc:
237
+ report(False, "pricing", str(exc))
238
+
239
+ try:
240
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
241
+ sock.bind((config.server.host, config.server.port))
242
+ typer.echo(f"[ok] port free: {config.server.port}")
243
+ except OSError:
244
+ note("port", f"{config.server.port} is already in use (is ctxoptimizer running?)")
245
+
246
+ try:
247
+ import importlib.util
248
+
249
+ if importlib.util.find_spec("tiktoken") is not None:
250
+ typer.echo("[ok] tiktoken available")
251
+ else:
252
+ note("tiktoken", "not installed; using the fallback estimator")
253
+ except Exception as exc:
254
+ note("tiktoken", str(exc))
255
+
256
+ if not offline:
257
+ for name, entry in sorted(config.upstreams.items()):
258
+ base = entry if isinstance(entry, str) else entry.url
259
+ try:
260
+ response = httpx.head(base, timeout=2.0, follow_redirects=False)
261
+ reachable = response.status_code < 500
262
+ except httpx.HTTPError as exc:
263
+ reachable = False
264
+ note(f"upstream {name}", str(exc))
265
+ else:
266
+ typer.echo(
267
+ f"[{'ok' if reachable else 'warn'}] upstream {name}: "
268
+ f"HTTP {response.status_code}"
269
+ )
270
+
271
+ typer.echo("clients: claude-code, opencode, codex, aider, cline")
272
+ typer.echo("hint: `ctxoptimizer env --shell powershell` prints base-URL exports")
273
+ if failures:
274
+ raise typer.Exit(code=1)
275
+
276
+
277
+ @app.command()
278
+ def stats(
279
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
280
+ since: Annotated[str | None, typer.Option("--since")] = None,
281
+ project: Annotated[str | None, typer.Option("--project")] = None,
282
+ json_output: Annotated[bool, typer.Option("--json")] = False,
283
+ ) -> None:
284
+ """Print a terminal summary of recorded requests and cost."""
285
+ config = _load(config_path)
286
+ rows = _read_rows(_db_path(config), _parse_since(since), 100_000)
287
+ if project:
288
+ rows = [row for row in rows if row.get("project") == project]
289
+ requests = len(rows)
290
+ cost = sum(float(row.get("cost_actual_usd") or 0.0) for row in rows)
291
+ baseline = sum(float(row.get("cost_baseline_sim_usd") or 0.0) for row in rows)
292
+ fresh = sum(int(row.get("fresh_input") or 0) for row in rows)
293
+ read = sum(int(row.get("cache_read") or 0) for row in rows)
294
+ output = sum(int(row.get("output_tokens") or 0) for row in rows)
295
+ ratio = (read / (read + fresh)) if (read + fresh) else 0.0
296
+ data = {
297
+ "requests": requests,
298
+ "actual_cost_usd": cost,
299
+ "baseline_sim_usd": baseline,
300
+ "savings_sim_usd": max(baseline - cost, 0.0),
301
+ "fresh_input": fresh,
302
+ "cache_read": read,
303
+ "cache_hit_ratio": ratio,
304
+ "output_tokens": output,
305
+ }
306
+ if json_output:
307
+ typer.echo(json.dumps(data, indent=2))
308
+ return
309
+ typer.echo(f"requests: {requests}")
310
+ typer.echo(f"actual cost: ${cost:.6f}")
311
+ typer.echo(f"baseline cost (sim.): ${baseline:.6f}")
312
+ typer.echo(f"fresh input: {fresh}")
313
+ typer.echo(f"cache read: {read}")
314
+ typer.echo(f"cache hit ratio: {ratio:.1%}")
315
+ typer.echo(f"output tokens: {output}")
316
+
317
+
318
+ @app.command("export")
319
+ def export_command(
320
+ format: Annotated[str, typer.Option("--format")] = "json",
321
+ since: Annotated[str | None, typer.Option("--since")] = None,
322
+ limit: Annotated[int, typer.Option("--limit")] = 1000,
323
+ out: Annotated[Path | None, typer.Option("--out")] = None,
324
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
325
+ ) -> None:
326
+ """Export recorded requests as JSON or CSV."""
327
+ config = _load(config_path)
328
+ rows = _read_rows(_db_path(config), _parse_since(since), limit)
329
+ if format == "json":
330
+ reports = _read_reports(_db_path(config), _parse_since(since), limit)
331
+ text = json.dumps({"requests": rows, "transform_reports": reports}, indent=2, default=str)
332
+ elif format == "csv":
333
+ text = _to_csv(rows)
334
+ else:
335
+ raise typer.BadParameter("format must be json or csv")
336
+ if out is None:
337
+ typer.echo(text)
338
+ else:
339
+ out.parent.mkdir(parents=True, exist_ok=True)
340
+ out.write_text(text, encoding="utf-8")
341
+ typer.echo(f"wrote {len(rows)} rows to {out}")
342
+
343
+
344
+ guard_app = typer.Typer(help="Inspect or override per-session transform quarantine.")
345
+ app.add_typer(guard_app, name="guard")
346
+
347
+
348
+ @guard_app.command("status")
349
+ def guard_show(
350
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
351
+ ) -> None:
352
+ """Show quarantined transforms."""
353
+ config = _load(config_path)
354
+ try:
355
+ data = _admin_request(config, "GET", "/_ctxoptimizer/api/guard")
356
+ except Exception as exc:
357
+ typer.echo(f"error: {exc}", err=True)
358
+ raise typer.Exit(code=1) from exc
359
+ for item in data.get("quarantined", []):
360
+ typer.echo(f"{item['transform_id']}\t{item['session_id']}\t{item['reason']}")
361
+
362
+
363
+ def _guard_action(
364
+ action: str,
365
+ transform: str,
366
+ session: str | None,
367
+ config_path: Path | None,
368
+ ) -> None:
369
+ config = _load(config_path)
370
+ body = {"action": action, "transform_id": transform}
371
+ if session:
372
+ body["session_id"] = session
373
+ try:
374
+ data = _admin_request(config, "POST", "/_ctxoptimizer/api/guard", body)
375
+ except Exception as exc:
376
+ typer.echo(f"error: {exc}", err=True)
377
+ raise typer.Exit(code=1) from exc
378
+ typer.echo(f"{action}: {data.get('transform_id')} ({len(data.get('sessions', []))} session(s))")
379
+
380
+
381
+ @guard_app.command("enable")
382
+ def guard_enable(
383
+ transform: Annotated[str, typer.Argument()],
384
+ session: Annotated[str | None, typer.Option("--session")] = None,
385
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
386
+ ) -> None:
387
+ """Re-enable a transform (optionally for one session)."""
388
+ _guard_action("enable", transform, session, config_path)
389
+
390
+
391
+ @guard_app.command("disable")
392
+ def guard_disable(
393
+ transform: Annotated[str, typer.Argument()],
394
+ session: Annotated[str | None, typer.Option("--session")] = None,
395
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
396
+ ) -> None:
397
+ """Quarantine a transform (optionally for one session)."""
398
+ _guard_action("disable", transform, session, config_path)
399
+
400
+
401
+ @app.command()
402
+ def waste(
403
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
404
+ project: Annotated[str | None, typer.Option("--project")] = None,
405
+ since: Annotated[str | None, typer.Option("--since")] = None,
406
+ detector: Annotated[str | None, typer.Option("--detector")] = None,
407
+ summary: Annotated[bool, typer.Option("--summary")] = False,
408
+ json_output: Annotated[bool, typer.Option("--json")] = False,
409
+ ) -> None:
410
+ """List open waste findings ranked by estimated cost."""
411
+ config = _load(config_path)
412
+ rows = _read_findings(_db_path(config), _parse_since(since))
413
+ if project:
414
+ rows = [row for row in rows if row.get("project") == project]
415
+ if detector:
416
+ rows = [row for row in rows if row.get("detector") == detector]
417
+ if summary:
418
+ rollup: dict[str, dict[str, float]] = {}
419
+ for row in rows:
420
+ item = rollup.setdefault(
421
+ str(row.get("detector")), {"findings": 0, "tokens": 0.0, "cost": 0.0}
422
+ )
423
+ item["findings"] += 1
424
+ item["tokens"] += float(row.get("est_tokens_wasted") or 0)
425
+ item["cost"] += float(row.get("est_cost_usd") or 0)
426
+ if json_output:
427
+ typer.echo(json.dumps(rollup, indent=2))
428
+ return
429
+ for name, item in sorted(rollup.items(), key=lambda pair: -pair[1]["cost"]):
430
+ typer.echo(
431
+ f"{name}\tfindings={int(item['findings'])} "
432
+ f"est_tokens={int(item['tokens'])} est_usd={item['cost']:.6f}"
433
+ )
434
+ return
435
+ if json_output:
436
+ typer.echo(json.dumps(rows, indent=2))
437
+ return
438
+ for row in rows:
439
+ typer.echo(
440
+ f"{row.get('detector')}\t{row.get('session_id')}\t"
441
+ f"est_tokens={row.get('est_tokens_wasted')}\t"
442
+ f"est_usd={row.get('est_cost_usd')}\tstatus={row.get('status')}"
443
+ )
444
+
445
+
446
+ @app.command()
447
+ def recommend(
448
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
449
+ json_output: Annotated[bool, typer.Option("--json")] = False,
450
+ ) -> None:
451
+ """Print ranked recommendations with evidence."""
452
+ config = _load(config_path)
453
+ findings = findings_from_rows(_read_findings(_db_path(config), None))
454
+ items = recommendations(
455
+ findings,
456
+ cache_serve_enabled=config.transforms.cache_serve.enabled,
457
+ volatile_normalizer_enabled=config.transforms.volatile_normalizer.enabled,
458
+ transforms_enabled=True,
459
+ )
460
+ if json_output:
461
+ typer.echo(json.dumps(items, indent=2))
462
+ return
463
+ for item in items:
464
+ typer.echo(f"{item['title']} [{item['label']}]")
465
+ typer.echo(f" {item['evidence']}")
466
+ typer.echo(f" action: {item['action']}")
467
+
468
+
469
+ @app.command()
470
+ def replay(
471
+ path: Annotated[Path, typer.Argument(help="captured request JSON")],
472
+ provider: Annotated[str, typer.Option("--provider")] = "anthropic",
473
+ endpoint: Annotated[str, typer.Option("--endpoint")] = "messages",
474
+ mode: Annotated[str, typer.Option("--mode")] = "active",
475
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
476
+ ) -> None:
477
+ """Run the pipeline on one request offline; print reports and changed blocks."""
478
+ config = _load(config_path, {"mode.default": mode} if mode else None)
479
+ try:
480
+ payload = json.loads(path.read_text(encoding="utf-8"))
481
+ provider_enum = Provider(provider)
482
+ endpoint_enum = Endpoint(endpoint)
483
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
484
+ typer.echo(f"error: {exc}", err=True)
485
+ raise typer.Exit(code=1) from exc
486
+ if not isinstance(payload, dict):
487
+ typer.echo("error: request JSON must be an object", err=True)
488
+ raise typer.Exit(code=1)
489
+ adapter = adapter_for(provider_enum, endpoint_enum)
490
+ ctx = RequestContext(
491
+ request_id="replay",
492
+ provider=provider_enum,
493
+ endpoint=endpoint_enum,
494
+ upstream_name=provider_enum.value,
495
+ model=adapter.model_of(payload),
496
+ stream=adapter.is_stream(payload),
497
+ mode=Mode(mode),
498
+ session_id="replay",
499
+ project=None,
500
+ client_name="replay",
501
+ config=config,
502
+ config_hash=config.config_hash,
503
+ started_monotonic=time.monotonic(),
504
+ )
505
+ result = run_pipeline(ctx, payload, adapter, build_transforms(config), config)
506
+ for report in result.reports:
507
+ typer.echo(
508
+ f"{report.transform_id}\tchanged={report.changed}\t"
509
+ f"tokens={report.est_tokens_before}->{report.est_tokens_after}\t"
510
+ f"{report.discarded_reason or ''}"
511
+ )
512
+ typer.echo(f"total: {result.orig_est_tokens} -> {result.final_est_tokens} est. tokens")
513
+ for index, (before, after) in enumerate(result.changed_blocks, start=1):
514
+ typer.echo(f"--- block {index} before ---")
515
+ typer.echo(before)
516
+ typer.echo(f"--- block {index} after ---")
517
+ typer.echo(after)
518
+
519
+
520
+ @app.command()
521
+ def bench(
522
+ rounds: Annotated[int, typer.Option("--rounds")] = 25,
523
+ ) -> None:
524
+ """Run a small synthetic pipeline benchmark."""
525
+ config = _load(None, {"mode.default": "active"})
526
+ adapter = adapter_for(Provider.ANTHROPIC, Endpoint.MESSAGES)
527
+ samples: list[float] = []
528
+ for _ in range(max(1, rounds)):
529
+ messages: list[dict[str, Any]] = [{"role": "user", "content": "bench"}]
530
+ for index in range(20):
531
+ messages.extend(
532
+ [
533
+ {
534
+ "role": "assistant",
535
+ "content": [
536
+ {
537
+ "type": "tool_use",
538
+ "id": f"t{index}",
539
+ "name": "Bash",
540
+ "input": {"command": "pytest -q"},
541
+ }
542
+ ],
543
+ },
544
+ {
545
+ "role": "user",
546
+ "content": [
547
+ {
548
+ "type": "tool_result",
549
+ "tool_use_id": f"t{index}",
550
+ "content": "\x1b[31mFAILED\x1b[0m\n" * 200,
551
+ }
552
+ ],
553
+ },
554
+ ]
555
+ )
556
+ payload = {"model": "claude-sonnet-4-5", "messages": messages}
557
+ ctx = RequestContext(
558
+ request_id="bench",
559
+ provider=Provider.ANTHROPIC,
560
+ endpoint=Endpoint.MESSAGES,
561
+ upstream_name="anthropic",
562
+ model="claude-sonnet-4-5",
563
+ stream=False,
564
+ mode=Mode.ACTIVE,
565
+ session_id="bench",
566
+ project=None,
567
+ client_name="bench",
568
+ config=config,
569
+ config_hash=config.config_hash,
570
+ started_monotonic=time.monotonic(),
571
+ )
572
+ started = time.perf_counter()
573
+ run_pipeline(ctx, payload, adapter, build_transforms(config), config)
574
+ samples.append((time.perf_counter() - started) * 1000)
575
+ samples.sort()
576
+ p50 = samples[len(samples) // 2]
577
+ p95 = samples[max(0, int(len(samples) * 0.95) - 1)]
578
+ typer.echo(f"rounds={len(samples)} p50_ms={p50:.2f} p95_ms={p95:.2f}")
579
+
580
+
581
+ @app.command()
582
+ def capture(
583
+ out: Annotated[Path, typer.Option("--out")],
584
+ request_id: Annotated[str | None, typer.Option("--request")] = None,
585
+ sanitize: Annotated[bool, typer.Option("--sanitize")] = False,
586
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
587
+ ) -> None:
588
+ """Export stored request metadata and changed blocks as review fixtures."""
589
+ config = _load(config_path)
590
+ db_path = _db_path(config)
591
+ rows = _read_rows(db_path, None, 1000)
592
+ if request_id:
593
+ rows = [row for row in rows if row["request_id"] == request_id]
594
+ blobs = BlobStore(data_dir() / "blobs")
595
+ written = 0
596
+ for row in rows:
597
+ target = out / str(row["request_id"])
598
+ target.mkdir(parents=True, exist_ok=True)
599
+ meta = {key: value for key, value in row.items() if not key.startswith("_")}
600
+ (target / "meta.json").write_text(json.dumps(meta, indent=2, default=str), encoding="utf-8")
601
+ blocks = _capture_blocks(db_path, str(row["request_id"]), blobs, sanitize)
602
+ (target / "blocks.json").write_text(json.dumps(blocks, indent=2), encoding="utf-8")
603
+ for block in blocks:
604
+ location = block.get("location")
605
+ text = block.get("before") or ""
606
+ if location == "request":
607
+ (target / "request.json").write_text(text, encoding="utf-8")
608
+ elif location == "final_request":
609
+ (target / "request.final.json").write_text(text, encoding="utf-8")
610
+ elif location == "response":
611
+ suffix = "sse" if _looks_sse(text) else "json"
612
+ (target / f"response.{suffix}").write_text(text, encoding="utf-8")
613
+ written += 1
614
+ typer.echo(f"wrote {written} fixture(s) to {out}")
615
+
616
+
617
+ @app.command()
618
+ def purge(
619
+ all_data: Annotated[bool, typer.Option("--all")] = False,
620
+ days: Annotated[int, typer.Option("--days")] = 14,
621
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
622
+ ) -> None:
623
+ """Delete recorded telemetry, either everything or rows older than N days."""
624
+ config = _load(config_path)
625
+ db_path = _db_path(config)
626
+ if not db_path.exists():
627
+ typer.echo("nothing to purge")
628
+ return
629
+ with sqlite3.connect(db_path) as conn:
630
+ if all_data:
631
+ for statement in (
632
+ "DELETE FROM captures",
633
+ "DELETE FROM blobs",
634
+ "DELETE FROM guard_events",
635
+ "DELETE FROM waste_findings",
636
+ "DELETE FROM drift_events",
637
+ "DELETE FROM transform_reports",
638
+ "DELETE FROM requests",
639
+ "DELETE FROM sessions",
640
+ ):
641
+ conn.execute(statement)
642
+ conn.commit()
643
+ typer.echo("purged all telemetry")
644
+ else:
645
+ cutoff = time.time() - days * 86_400
646
+ deleted = conn.execute("DELETE FROM requests WHERE ts < ?", (cutoff,)).rowcount
647
+ conn.commit()
648
+ typer.echo(f"purged {deleted} request(s) older than {days} day(s)")
649
+
650
+
651
+ def _load(path: Path | None, overrides: dict[str, object] | None = None) -> Config:
652
+ try:
653
+ return load_config(path=path, overrides=overrides)
654
+ except Exception as exc:
655
+ typer.echo(f"error: {exc}", err=True)
656
+ raise typer.Exit(code=1) from exc
657
+
658
+
659
+ def _db_path(config: Config) -> Path:
660
+ return Path(config.telemetry.db_path) if config.telemetry.db_path else default_db_path()
661
+
662
+
663
+ def _export_line(shell: str, name: str, value: str) -> str:
664
+ if shell == "powershell":
665
+ return f'$env:{name} = "{value}"'
666
+ if shell == "fish":
667
+ return f"set -gx {name} {shlex.quote(value)}"
668
+ return f'export {name}="{value}"'
669
+
670
+
671
+ def _parse_since(value: str | None) -> float | None:
672
+ if not value:
673
+ return None
674
+ now = time.time()
675
+ for suffix, seconds in (("m", 60), ("h", 3600), ("d", 86_400)):
676
+ if value.endswith(suffix) and value[:-1].isdigit():
677
+ return now - int(value[:-1]) * seconds
678
+ try:
679
+ parsed = datetime.fromisoformat(value)
680
+ except ValueError as exc:
681
+ raise typer.BadParameter("--since must be 30m, 24h, 7d or an ISO timestamp") from exc
682
+ if parsed.tzinfo is None:
683
+ parsed = parsed.replace(tzinfo=UTC)
684
+ return parsed.timestamp()
685
+
686
+
687
+ def _capture_blocks(
688
+ db_path: Path, request_id: str, blobs: BlobStore, sanitize: bool
689
+ ) -> list[dict[str, Any]]:
690
+ if not db_path.exists():
691
+ return []
692
+ with sqlite3.connect(db_path) as conn:
693
+ conn.row_factory = sqlite3.Row
694
+ rows = conn.execute(
695
+ "SELECT * FROM captures WHERE request_id = ? ORDER BY rowid",
696
+ (request_id,),
697
+ ).fetchall()
698
+ blocks: list[dict[str, Any]] = []
699
+ for row in rows:
700
+ before = blobs.get(row["before_hash"] or "")
701
+ after = blobs.get(row["after_hash"] or "")
702
+ blocks.append(
703
+ {
704
+ "location": row["location"],
705
+ "before": _fixture_text(before, sanitize),
706
+ "after": _fixture_text(after, sanitize),
707
+ }
708
+ )
709
+ return blocks
710
+
711
+
712
+ def _fixture_text(data: bytes | None, sanitize: bool) -> str | None:
713
+ if data is None:
714
+ return None
715
+ text = redact_text(data.decode("utf-8", "replace"))
716
+ if sanitize:
717
+ import re
718
+
719
+ text = re.sub(r"[A-Za-z]:\\Users\\[^\\\s]+", "<home>", text)
720
+ text = re.sub(r"/(?:home|Users)/[^/\s]+", "<home>", text)
721
+ text = re.sub(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "user@example.com", text)
722
+ return text
723
+
724
+
725
+ def _read_rows(db_path: Path, since_ts: float | None, limit: int) -> list[_Request]:
726
+ if not db_path.exists():
727
+ return []
728
+ with sqlite3.connect(db_path) as conn:
729
+ conn.row_factory = sqlite3.Row
730
+ query = "SELECT * FROM requests"
731
+ params: list[Any] = []
732
+ if since_ts is not None:
733
+ query += " WHERE ts >= ?"
734
+ params.append(since_ts)
735
+ query += " ORDER BY ts DESC LIMIT ?"
736
+ params.append(limit)
737
+ return [dict(row) for row in conn.execute(query, params).fetchall()]
738
+
739
+
740
+ def _looks_sse(text: str) -> bool:
741
+ return "data:" in text and "\n\n" in text
742
+
743
+
744
+ def _read_reports(db_path: Path, since_ts: float | None, limit: int) -> list[_Request]:
745
+ if not db_path.exists():
746
+ return []
747
+ with sqlite3.connect(db_path) as conn:
748
+ conn.row_factory = sqlite3.Row
749
+ query = (
750
+ "SELECT transform_reports.*, requests.ts FROM transform_reports "
751
+ "JOIN requests USING(request_id)"
752
+ )
753
+ params: list[Any] = []
754
+ if since_ts is not None:
755
+ query += " WHERE requests.ts >= ?"
756
+ params.append(since_ts)
757
+ query += " ORDER BY requests.ts DESC LIMIT ?"
758
+ params.append(limit)
759
+ return [dict(row) for row in conn.execute(query, params).fetchall()]
760
+
761
+
762
+ def _read_findings(db_path: Path, since_ts: float | None) -> list[_Request]:
763
+ if not db_path.exists():
764
+ return []
765
+ with sqlite3.connect(db_path) as conn:
766
+ conn.row_factory = sqlite3.Row
767
+ query = "SELECT * FROM waste_findings WHERE status IN ('new', 'ack')"
768
+ params: list[Any] = []
769
+ if since_ts is not None:
770
+ query += " AND first_seen_ts >= ?"
771
+ params.append(since_ts)
772
+ query += " ORDER BY est_cost_usd DESC, ts DESC"
773
+ return [dict(row) for row in conn.execute(query, params).fetchall()]
774
+
775
+
776
+ def _admin_request(
777
+ config: Config,
778
+ method: str,
779
+ path: str,
780
+ body: dict[str, Any] | None = None,
781
+ ) -> dict[str, Any]:
782
+ admin_token, csrf_token = load_tokens()
783
+ admin_token = admin_token or config.server.admin_token
784
+ if not admin_token:
785
+ raise RuntimeError("no admin token found; start `ctxoptimizer run` first")
786
+ headers = {"x-ctxoptimizer-admin-token": admin_token}
787
+ if method.upper() not in {"GET", "HEAD", "OPTIONS"}:
788
+ if not csrf_token:
789
+ raise RuntimeError("no CSRF token found; start `ctxoptimizer run` first")
790
+ headers["x-ctxoptimizer-csrf"] = csrf_token
791
+ base = f"http://{config.server.host}:{config.server.port}"
792
+ response = httpx.request(
793
+ method,
794
+ f"{base}{path}",
795
+ json=body,
796
+ headers=headers,
797
+ timeout=5.0,
798
+ )
799
+ if response.status_code >= 400:
800
+ raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
801
+ data = response.json()
802
+ return data if isinstance(data, dict) else {"data": data}
803
+
804
+
805
+ def _to_csv(rows: list[_Request]) -> str:
806
+ if not rows:
807
+ return ""
808
+ buffer = io.StringIO()
809
+ writer = csv.DictWriter(buffer, fieldnames=list(rows[0].keys()))
810
+ writer.writeheader()
811
+ writer.writerows(rows)
812
+ return buffer.getvalue()
813
+
814
+
815
+ mcp_app = typer.Typer(help="MCP integration for ctxoptimizer_expand.")
816
+ app.add_typer(mcp_app, name="mcp")
817
+
818
+ service_app = typer.Typer(help="Install or remove the per-user autostart service.")
819
+ app.add_typer(service_app, name="service")
820
+
821
+
822
+ @service_app.command("install")
823
+ def service_install(
824
+ config_path: Annotated[Path | None, typer.Option("--config")] = None,
825
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
826
+ ) -> None:
827
+ """Install launchd/systemd/scheduled-task autostart."""
828
+ plan = install_service(config_path=str(config_path) if config_path else None, dry_run=dry_run)
829
+ typer.echo(f"platform: {plan.platform}")
830
+ typer.echo(f"unit: {plan.path}")
831
+ for command in plan.commands:
832
+ typer.echo("run: " + " ".join(command))
833
+ if dry_run:
834
+ typer.echo("(dry run; nothing written or executed)")
835
+
836
+
837
+ @service_app.command("uninstall")
838
+ def service_uninstall(
839
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
840
+ ) -> None:
841
+ """Remove the autostart service."""
842
+ plan = uninstall_service(dry_run=dry_run)
843
+ for command in plan.remove_commands:
844
+ typer.echo("run: " + " ".join(command))
845
+ typer.echo(f"removed: {plan.path}")
846
+
847
+
848
+ @mcp_app.command("run")
849
+ def mcp_run() -> None:
850
+ """Serve the expand tool over stdio."""
851
+ from ctxoptimizer.mcp import run_stdio
852
+
853
+ run_stdio()
854
+
855
+
856
+ @mcp_app.command("snippet")
857
+ def mcp_snippet(
858
+ client: Annotated[str, typer.Option("--client")] = "claude-code",
859
+ ) -> None:
860
+ """Print a registration snippet for an MCP client."""
861
+ typer.echo(
862
+ json.dumps(
863
+ {
864
+ "mcpServers": {"ctxoptimizer": {"command": "ctxoptimizer", "args": ["mcp", "run"]}},
865
+ "client": client,
866
+ },
867
+ indent=2,
868
+ )
869
+ )
870
+
871
+
872
+ if __name__ == "__main__":
873
+ app()