spooling 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {spooling-0.2.1/spooling.egg-info → spooling-0.2.2}/PKG-INFO +1 -1
- {spooling-0.2.1 → spooling-0.2.2}/pyproject.toml +1 -1
- {spooling-0.2.1 → spooling-0.2.2}/spooling/cli.py +421 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/cloud.py +4 -3
- {spooling-0.2.1 → spooling-0.2.2/spooling.egg-info}/PKG-INFO +1 -1
- {spooling-0.2.1 → spooling-0.2.2}/LICENSE +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/README.md +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/setup.cfg +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/__init__.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/agent.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/classifiers.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/config.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/__init__.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/base.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/factory.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/mysql.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/types.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/db.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/embeddings.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/evals.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/experiments.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/ingest.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/mcp_server.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/parser.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/pricing.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/__init__.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/antigravity.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/base.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/codex.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/copilot.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/cursor.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/gemini.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/github.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/gitlab.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/kiro.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/opencode.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/session_file.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/windsurf.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/redact.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/remote_otel.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/schema.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/sdk.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/search.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/server.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/stats.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/subscription_pricing.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/tracing.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/tunnel.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/tunnel_auth.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling/watcher.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling.egg-info/SOURCES.txt +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling.egg-info/requires.txt +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/tests/test_mcp_auth_middleware.py +0 -0
- {spooling-0.2.1 → spooling-0.2.2}/tests/test_tunnel_auth.py +0 -0
|
@@ -318,6 +318,39 @@ def otel_ingest(path, provider_id, project):
|
|
|
318
318
|
raise SystemExit(1)
|
|
319
319
|
|
|
320
320
|
|
|
321
|
+
@otel.command("preview")
|
|
322
|
+
@click.option("--file", "path", required=True, type=click.Path(exists=True), help="OTLP JSON export file")
|
|
323
|
+
@click.option("--provider", "provider_id", default="otel-remote", help="Provider id")
|
|
324
|
+
@click.option("--project", default=None, help="Project name")
|
|
325
|
+
def otel_preview(path, provider_id, project):
|
|
326
|
+
"""Dry-run: show what an OTLP file would produce without writing to the DB."""
|
|
327
|
+
from spooling.remote_otel import ingest_otlp_json_file
|
|
328
|
+
import json as _json
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
# Parse the file to get the trace object without persisting
|
|
332
|
+
with open(path) as f:
|
|
333
|
+
raw = _json.load(f)
|
|
334
|
+
span_count = sum(
|
|
335
|
+
len(rs.get("scopeSpans", [{}])[0].get("spans", []))
|
|
336
|
+
for rs in raw.get("resourceSpans", [])
|
|
337
|
+
) if isinstance(raw, dict) else 0
|
|
338
|
+
|
|
339
|
+
console.print(Panel(
|
|
340
|
+
f"File: [bold]{path}[/bold]\n"
|
|
341
|
+
f"Provider: {provider_id}\n"
|
|
342
|
+
f"Project: {project or '(none)'}\n"
|
|
343
|
+
f"Resource span groups: {len(raw.get('resourceSpans', []))}\n"
|
|
344
|
+
f"Total spans: [bold]{span_count}[/bold]\n"
|
|
345
|
+
f"\n[dim]Run without --dry-run equivalent: spooling otel ingest --file {path}[/dim]",
|
|
346
|
+
title="[bold]OTel Preview (dry run)[/bold]",
|
|
347
|
+
style="cyan",
|
|
348
|
+
))
|
|
349
|
+
except Exception as e:
|
|
350
|
+
console.print(f"[red]Preview failed:[/red] {e}")
|
|
351
|
+
raise SystemExit(1)
|
|
352
|
+
|
|
353
|
+
|
|
321
354
|
@cli.group()
|
|
322
355
|
def experiment():
|
|
323
356
|
"""Create and run Strands experiments (cases + evaluators)."""
|
|
@@ -376,6 +409,89 @@ def experiment_run(experiment_id):
|
|
|
376
409
|
console.print(f" - {name}: {score}")
|
|
377
410
|
|
|
378
411
|
|
|
412
|
+
@experiment.command("inspect")
|
|
413
|
+
@click.option("--id", "experiment_id", required=True, help="Experiment id")
|
|
414
|
+
def experiment_inspect(experiment_id):
|
|
415
|
+
"""Show the spec/definition of an experiment."""
|
|
416
|
+
from spooling.experiments import load_experiment
|
|
417
|
+
import json as _json
|
|
418
|
+
|
|
419
|
+
spec = load_experiment(experiment_id)
|
|
420
|
+
if not spec:
|
|
421
|
+
console.print(f"[red]Experiment not found: {experiment_id}[/red]")
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
console.print(Panel(
|
|
425
|
+
f"ID: [bold]{spec.id}[/bold]\n"
|
|
426
|
+
f"Name: {spec.name}\n"
|
|
427
|
+
f"Description: {spec.description or '—'}\n"
|
|
428
|
+
f"Cases: {len(spec.cases)}\n"
|
|
429
|
+
f"Evaluators: {len(spec.evaluators)}",
|
|
430
|
+
title="[bold]Experiment Spec[/bold]",
|
|
431
|
+
style="cyan",
|
|
432
|
+
))
|
|
433
|
+
|
|
434
|
+
if spec.cases:
|
|
435
|
+
table = Table(title="Cases", show_lines=True)
|
|
436
|
+
table.add_column("#", justify="right")
|
|
437
|
+
table.add_column("Input")
|
|
438
|
+
table.add_column("Expected", style="dim")
|
|
439
|
+
for i, c in enumerate(spec.cases, 1):
|
|
440
|
+
inp = _json.dumps(c.get("input", ""))[:80] if isinstance(c.get("input"), dict) else str(c.get("input", ""))[:80]
|
|
441
|
+
exp = str(c.get("expected_output", ""))[:60]
|
|
442
|
+
table.add_row(str(i), inp, exp)
|
|
443
|
+
console.print(table)
|
|
444
|
+
|
|
445
|
+
if spec.evaluators:
|
|
446
|
+
table = Table(title="Evaluators", show_lines=False)
|
|
447
|
+
table.add_column("Name", style="cyan")
|
|
448
|
+
table.add_column("Kind")
|
|
449
|
+
for e in spec.evaluators:
|
|
450
|
+
table.add_row(e.get("name", ""), e.get("kind", ""))
|
|
451
|
+
console.print(table)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@experiment.command("runs")
|
|
455
|
+
@click.option("--id", "experiment_id", default=None, help="Filter to a specific experiment id")
|
|
456
|
+
@click.option("--limit", "-n", default=20, help="Max runs to show (default: 20)")
|
|
457
|
+
def experiment_runs(experiment_id, limit):
|
|
458
|
+
"""List past experiment run history."""
|
|
459
|
+
from spooling.experiments import list_runs
|
|
460
|
+
import json as _json
|
|
461
|
+
|
|
462
|
+
rows = list_runs(experiment_id=experiment_id, limit=limit)
|
|
463
|
+
if not rows:
|
|
464
|
+
console.print("[yellow]No runs found.[/yellow]")
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
table = Table(title="Experiment Runs", show_lines=False)
|
|
468
|
+
table.add_column("Run ID", style="cyan")
|
|
469
|
+
table.add_column("Experiment")
|
|
470
|
+
table.add_column("Status")
|
|
471
|
+
table.add_column("Started", style="dim")
|
|
472
|
+
table.add_column("Finished", style="dim")
|
|
473
|
+
table.add_column("Scores")
|
|
474
|
+
|
|
475
|
+
for r in rows:
|
|
476
|
+
scores = r.get("overall_scores") or {}
|
|
477
|
+
if isinstance(scores, str):
|
|
478
|
+
try:
|
|
479
|
+
scores = _json.loads(scores)
|
|
480
|
+
except Exception:
|
|
481
|
+
scores = {}
|
|
482
|
+
score_str = " ".join(f"{k}: {v:.2f}" if isinstance(v, float) else f"{k}: {v}" for k, v in scores.items())
|
|
483
|
+
status_color = "green" if r["status"] == "completed" else "red" if r["status"] == "failed" else "yellow"
|
|
484
|
+
table.add_row(
|
|
485
|
+
(r["id"] or "")[:16],
|
|
486
|
+
(r["experiment_id"] or "")[:20],
|
|
487
|
+
f"[{status_color}]{r['status']}[/{status_color}]",
|
|
488
|
+
str(r.get("started_at") or "")[:19],
|
|
489
|
+
str(r.get("finished_at") or "")[:19],
|
|
490
|
+
score_str or "—",
|
|
491
|
+
)
|
|
492
|
+
console.print(table)
|
|
493
|
+
|
|
494
|
+
|
|
379
495
|
@experiment.command("show")
|
|
380
496
|
@click.option("--run", "run_id", required=True, help="Run id")
|
|
381
497
|
def experiment_show(run_id):
|
|
@@ -588,6 +704,74 @@ def cost_session(session_id):
|
|
|
588
704
|
console.print(table)
|
|
589
705
|
|
|
590
706
|
|
|
707
|
+
@cost.command("subscription")
|
|
708
|
+
@click.option("--provider", "-p", required=True,
|
|
709
|
+
help=f"Provider id (e.g. cursor, copilot, windsurf, codex, gemini, kiro)")
|
|
710
|
+
@click.option("--days", default=30, type=int, help="Look-back window in days (default: 30)")
|
|
711
|
+
def cost_subscription(provider, days):
|
|
712
|
+
"""Estimate flat-rate subscription spend for a provider.
|
|
713
|
+
|
|
714
|
+
Uses your actual model usage to infer which plan tier you were on,
|
|
715
|
+
then calculates the subscription cost over the date range — which is
|
|
716
|
+
typically far lower than the API-equivalent token cost shown by
|
|
717
|
+
'spooling cost overview'.
|
|
718
|
+
|
|
719
|
+
\b
|
|
720
|
+
Examples:
|
|
721
|
+
spooling cost subscription --provider cursor
|
|
722
|
+
spooling cost subscription --provider copilot --days 90
|
|
723
|
+
"""
|
|
724
|
+
from spooling.subscription_pricing import subscription_cost_for_range
|
|
725
|
+
from spooling.db import get_connection
|
|
726
|
+
from datetime import datetime as _dt, timezone, timedelta
|
|
727
|
+
|
|
728
|
+
conn = get_connection()
|
|
729
|
+
cutoff = (_dt.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
730
|
+
rows = conn.execute(
|
|
731
|
+
"""SELECT model, MIN(started_at) AS first, MAX(ended_at) AS last
|
|
732
|
+
FROM sessions
|
|
733
|
+
WHERE provider_id = ? AND started_at >= ?
|
|
734
|
+
GROUP BY model""",
|
|
735
|
+
(provider, cutoff),
|
|
736
|
+
).fetchall()
|
|
737
|
+
conn.close()
|
|
738
|
+
|
|
739
|
+
if not rows:
|
|
740
|
+
console.print(f"[yellow]No sessions found for provider '{provider}' in the last {days} days.[/yellow]")
|
|
741
|
+
return
|
|
742
|
+
|
|
743
|
+
models_used = [r["model"] for r in rows]
|
|
744
|
+
# date range across all sessions
|
|
745
|
+
all_starts = [r["first"] for r in rows if r["first"]]
|
|
746
|
+
all_ends = [r["last"] for r in rows if r["last"]]
|
|
747
|
+
|
|
748
|
+
def _parse(ts):
|
|
749
|
+
if not ts:
|
|
750
|
+
return None
|
|
751
|
+
if isinstance(ts, _dt):
|
|
752
|
+
return ts.replace(tzinfo=timezone.utc) if ts.tzinfo is None else ts
|
|
753
|
+
try:
|
|
754
|
+
return _dt.fromisoformat(ts).replace(tzinfo=timezone.utc)
|
|
755
|
+
except Exception:
|
|
756
|
+
return None
|
|
757
|
+
|
|
758
|
+
started_at = min((_parse(t) for t in all_starts if _parse(t)), default=None)
|
|
759
|
+
ended_at = max((_parse(t) for t in all_ends if _parse(t)), default=None) or _dt.now(timezone.utc)
|
|
760
|
+
|
|
761
|
+
result = subscription_cost_for_range(provider, models_used, started_at, ended_at)
|
|
762
|
+
|
|
763
|
+
console.print(Panel(
|
|
764
|
+
f"Provider: [bold]{provider}[/bold]\n"
|
|
765
|
+
f"Plan tier: [bold]{result['plan']}[/bold]\n"
|
|
766
|
+
f"Monthly cost: ${result['monthly_usd']:.2f}/mo\n"
|
|
767
|
+
f"Months billed: {result['months']}\n"
|
|
768
|
+
f"Est. total: [bold]${result['cost_usd']:.2f}[/bold]\n"
|
|
769
|
+
f"\n[dim]Models detected: {', '.join(m for m in set(models_used) if m) or '—'}[/dim]",
|
|
770
|
+
title="[bold]Subscription Cost Estimate[/bold]",
|
|
771
|
+
style="green",
|
|
772
|
+
))
|
|
773
|
+
|
|
774
|
+
|
|
591
775
|
@cost.command("recalc")
|
|
592
776
|
@click.option("--session", "session_id", default=None, help="Single session id to recalc")
|
|
593
777
|
@click.option("--provider", "-p", default=None, help="Recalc all sessions for a provider")
|
|
@@ -853,6 +1037,243 @@ cli.add_command(_cloud_group)
|
|
|
853
1037
|
cli.add_command(_push_cmd)
|
|
854
1038
|
|
|
855
1039
|
|
|
1040
|
+
# ---------------------------------------------------------------------------
|
|
1041
|
+
# Redact
|
|
1042
|
+
# ---------------------------------------------------------------------------
|
|
1043
|
+
|
|
1044
|
+
@cli.command()
|
|
1045
|
+
@click.option("--file", "path", default=None, type=click.Path(exists=True), help="File to scan for secrets")
|
|
1046
|
+
@click.option("--session", "session_id", default=None, help="Scan a specific session's messages from the local DB")
|
|
1047
|
+
@click.option("--show", is_flag=True, help="Print the redacted text (default: summary only)")
|
|
1048
|
+
def redact(path, session_id, show):
|
|
1049
|
+
"""Scan text or a session for secrets that would be redacted on push.
|
|
1050
|
+
|
|
1051
|
+
Useful for auditing what the pre-push redactor would catch before
|
|
1052
|
+
running 'spooling push'.
|
|
1053
|
+
|
|
1054
|
+
\b
|
|
1055
|
+
Examples:
|
|
1056
|
+
spooling redact --file ./notes.txt
|
|
1057
|
+
spooling redact --session <session_id>
|
|
1058
|
+
spooling redact --file ./notes.txt --show
|
|
1059
|
+
"""
|
|
1060
|
+
from spooling.redact import redact_text
|
|
1061
|
+
|
|
1062
|
+
if not path and not session_id:
|
|
1063
|
+
console.print("[red]Provide --file or --session.[/red]")
|
|
1064
|
+
raise SystemExit(1)
|
|
1065
|
+
|
|
1066
|
+
if path:
|
|
1067
|
+
with open(path) as f:
|
|
1068
|
+
text = f.read()
|
|
1069
|
+
source = path
|
|
1070
|
+
else:
|
|
1071
|
+
from spooling.db import get_connection
|
|
1072
|
+
conn = get_connection()
|
|
1073
|
+
rows = conn.execute(
|
|
1074
|
+
"SELECT role, content FROM messages WHERE session_id = ? ORDER BY timestamp",
|
|
1075
|
+
(session_id,),
|
|
1076
|
+
).fetchall()
|
|
1077
|
+
conn.close()
|
|
1078
|
+
if not rows:
|
|
1079
|
+
console.print(f"[yellow]No messages found for session {session_id}[/yellow]")
|
|
1080
|
+
return
|
|
1081
|
+
text = "\n".join(f"[{r['role']}] {r['content'] or ''}" for r in rows)
|
|
1082
|
+
source = f"session {session_id[:12]}..."
|
|
1083
|
+
|
|
1084
|
+
redacted, hits = redact_text(text)
|
|
1085
|
+
|
|
1086
|
+
if not hits:
|
|
1087
|
+
console.print(f"[green]No secrets detected in {source}.[/green]")
|
|
1088
|
+
else:
|
|
1089
|
+
console.print(f"[yellow]{len(hits)} secret(s) detected in {source}:[/yellow]")
|
|
1090
|
+
table = Table(show_lines=False)
|
|
1091
|
+
table.add_column("Label", style="red")
|
|
1092
|
+
table.add_column("Position", style="dim")
|
|
1093
|
+
for h in hits:
|
|
1094
|
+
table.add_row(h.label, f"chars {h.span[0]}–{h.span[1]}")
|
|
1095
|
+
console.print(table)
|
|
1096
|
+
|
|
1097
|
+
if show:
|
|
1098
|
+
console.print("\n[bold]Redacted text:[/bold]")
|
|
1099
|
+
console.print(redacted[:4000] + ("[dim]... truncated[/dim]" if len(redacted) > 4000 else ""))
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
# ---------------------------------------------------------------------------
|
|
1103
|
+
# Sessions
|
|
1104
|
+
# ---------------------------------------------------------------------------
|
|
1105
|
+
|
|
1106
|
+
@cli.group()
|
|
1107
|
+
def session():
|
|
1108
|
+
"""Inspect and list local sessions."""
|
|
1109
|
+
pass
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
@session.command("list")
|
|
1113
|
+
@click.option("--project", "-p", default=None, help="Filter by exact project name")
|
|
1114
|
+
@click.option("--cwd", default=None, help="Filter by cwd substring")
|
|
1115
|
+
@click.option("--title", default=None, help="Filter by title substring")
|
|
1116
|
+
@click.option("--limit", "-n", default=20, help="Max sessions to return (default: 20)")
|
|
1117
|
+
def session_list(project, cwd, title, limit):
|
|
1118
|
+
"""List local sessions with optional filters."""
|
|
1119
|
+
from spooling.db import get_connection
|
|
1120
|
+
from datetime import datetime as _dt
|
|
1121
|
+
|
|
1122
|
+
conn = get_connection()
|
|
1123
|
+
wheres = []
|
|
1124
|
+
params: list = []
|
|
1125
|
+
if project:
|
|
1126
|
+
wheres.append("LOWER(project) = LOWER(?)")
|
|
1127
|
+
params.append(project)
|
|
1128
|
+
if cwd:
|
|
1129
|
+
wheres.append("cwd LIKE ?")
|
|
1130
|
+
params.append(f"%{cwd}%")
|
|
1131
|
+
if title:
|
|
1132
|
+
wheres.append("title LIKE ?")
|
|
1133
|
+
params.append(f"%{title}%")
|
|
1134
|
+
where_clause = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
|
1135
|
+
params.append(limit)
|
|
1136
|
+
|
|
1137
|
+
rows = conn.execute(
|
|
1138
|
+
f"""SELECT id, provider_id, project, title, started_at,
|
|
1139
|
+
message_count, tool_call_count, estimated_cost_usd
|
|
1140
|
+
FROM sessions {where_clause}
|
|
1141
|
+
ORDER BY started_at DESC NULLS LAST LIMIT ?""",
|
|
1142
|
+
tuple(params),
|
|
1143
|
+
).fetchall()
|
|
1144
|
+
conn.close()
|
|
1145
|
+
|
|
1146
|
+
if not rows:
|
|
1147
|
+
console.print("[yellow]No sessions found.[/yellow]")
|
|
1148
|
+
return
|
|
1149
|
+
|
|
1150
|
+
table = Table(title=f"Sessions ({len(rows)})", show_lines=False)
|
|
1151
|
+
table.add_column("ID", style="dim")
|
|
1152
|
+
table.add_column("Provider", style="cyan")
|
|
1153
|
+
table.add_column("Project")
|
|
1154
|
+
table.add_column("Title")
|
|
1155
|
+
table.add_column("Started", style="dim")
|
|
1156
|
+
table.add_column("Msgs", justify="right")
|
|
1157
|
+
table.add_column("Tools", justify="right")
|
|
1158
|
+
table.add_column("Cost", justify="right")
|
|
1159
|
+
|
|
1160
|
+
for r in rows:
|
|
1161
|
+
raw_ts = r["started_at"]
|
|
1162
|
+
if raw_ts and isinstance(raw_ts, str):
|
|
1163
|
+
try:
|
|
1164
|
+
raw_ts = _dt.fromisoformat(raw_ts)
|
|
1165
|
+
except ValueError:
|
|
1166
|
+
raw_ts = None
|
|
1167
|
+
ts = raw_ts.strftime("%m/%d %H:%M") if raw_ts else ""
|
|
1168
|
+
table.add_row(
|
|
1169
|
+
(r["id"] or "")[:12],
|
|
1170
|
+
r["provider_id"] or "",
|
|
1171
|
+
_clean_project(r["project"] or ""),
|
|
1172
|
+
(r["title"] or "")[:45],
|
|
1173
|
+
ts,
|
|
1174
|
+
str(r["message_count"] or 0),
|
|
1175
|
+
str(r["tool_call_count"] or 0),
|
|
1176
|
+
f"${float(r['estimated_cost_usd'] or 0):.4f}",
|
|
1177
|
+
)
|
|
1178
|
+
console.print(table)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
@session.command("show")
|
|
1182
|
+
@click.argument("session_id")
|
|
1183
|
+
@click.option("--messages", "-m", is_flag=True, help="Show full message content")
|
|
1184
|
+
def session_show(session_id, messages):
|
|
1185
|
+
"""Show full detail for a session including messages and tool calls."""
|
|
1186
|
+
from spooling.stats import get_session_detail
|
|
1187
|
+
|
|
1188
|
+
detail = get_session_detail(session_id)
|
|
1189
|
+
if not detail:
|
|
1190
|
+
console.print(f"[yellow]Session not found: {session_id}[/yellow]")
|
|
1191
|
+
return
|
|
1192
|
+
|
|
1193
|
+
s = detail["session"]
|
|
1194
|
+
console.print(Panel(
|
|
1195
|
+
f"ID: [bold]{s['id']}[/bold]\n"
|
|
1196
|
+
f"Provider: {s.get('provider_id') or '—'}\n"
|
|
1197
|
+
f"Project: {_clean_project(s.get('project') or '—')}\n"
|
|
1198
|
+
f"Model: {s.get('model') or '—'}\n"
|
|
1199
|
+
f"Started: {str(s.get('started_at') or '')[:19]}\n"
|
|
1200
|
+
f"Ended: {str(s.get('ended_at') or '')[:19]}\n"
|
|
1201
|
+
f"Messages: {s.get('message_count', 0)}\n"
|
|
1202
|
+
f"Tool calls: {s.get('tool_call_count', 0)}\n"
|
|
1203
|
+
f"In tokens: {s.get('estimated_input_tokens', 0):,}\n"
|
|
1204
|
+
f"Out tokens: {s.get('estimated_output_tokens', 0):,}\n"
|
|
1205
|
+
f"Cost: [bold]${float(s.get('estimated_cost_usd') or 0):.4f}[/bold]",
|
|
1206
|
+
title=f"[bold]{(s.get('title') or 'Session')[:60]}[/bold]",
|
|
1207
|
+
style="blue",
|
|
1208
|
+
))
|
|
1209
|
+
|
|
1210
|
+
if detail["tool_summary"]:
|
|
1211
|
+
table = Table(title="Tool Usage", show_lines=False)
|
|
1212
|
+
table.add_column("Tool", style="magenta")
|
|
1213
|
+
table.add_column("Uses", justify="right")
|
|
1214
|
+
for t in detail["tool_summary"]:
|
|
1215
|
+
table.add_row(t["tool_name"], str(t["uses"]))
|
|
1216
|
+
console.print(table)
|
|
1217
|
+
|
|
1218
|
+
if messages and detail["messages"]:
|
|
1219
|
+
table = Table(title="Messages", show_lines=True)
|
|
1220
|
+
table.add_column("Role", style="cyan", width=10)
|
|
1221
|
+
table.add_column("Timestamp", style="dim", width=18)
|
|
1222
|
+
table.add_column("Content")
|
|
1223
|
+
for m in detail["messages"]:
|
|
1224
|
+
content = (m.get("content") or "")[:300]
|
|
1225
|
+
if m.get("tool_calls"):
|
|
1226
|
+
tools = ", ".join(tc["name"] for tc in m["tool_calls"])
|
|
1227
|
+
content += f"\n[dim]Tools: {tools}[/dim]"
|
|
1228
|
+
table.add_row(
|
|
1229
|
+
m.get("role", ""),
|
|
1230
|
+
str(m.get("timestamp") or "")[:18],
|
|
1231
|
+
content,
|
|
1232
|
+
)
|
|
1233
|
+
console.print(table)
|
|
1234
|
+
elif not messages and detail["messages"]:
|
|
1235
|
+
console.print(f"[dim]{len(detail['messages'])} messages — pass --messages to show content[/dim]")
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
# ---------------------------------------------------------------------------
|
|
1239
|
+
# Stats providers subcommand
|
|
1240
|
+
# ---------------------------------------------------------------------------
|
|
1241
|
+
|
|
1242
|
+
@cli.command("providers")
|
|
1243
|
+
def stats_providers():
|
|
1244
|
+
"""Show usage breakdown by provider."""
|
|
1245
|
+
from spooling.stats import get_provider_breakdown
|
|
1246
|
+
|
|
1247
|
+
rows = get_provider_breakdown()
|
|
1248
|
+
if not rows:
|
|
1249
|
+
console.print("[yellow]No sessions synced yet. Run 'spooling sync' first.[/yellow]")
|
|
1250
|
+
return
|
|
1251
|
+
|
|
1252
|
+
table = Table(title="Provider Breakdown", show_lines=False)
|
|
1253
|
+
table.add_column("Provider", style="cyan")
|
|
1254
|
+
table.add_column("Sessions", justify="right")
|
|
1255
|
+
table.add_column("Messages", justify="right")
|
|
1256
|
+
table.add_column("Tool Calls", justify="right")
|
|
1257
|
+
table.add_column("In Tokens", justify="right")
|
|
1258
|
+
table.add_column("Out Tokens", justify="right")
|
|
1259
|
+
table.add_column("Est. Cost", justify="right")
|
|
1260
|
+
table.add_column("Last Session", style="dim")
|
|
1261
|
+
|
|
1262
|
+
for r in rows:
|
|
1263
|
+
last = str(r.get("last_session") or "")[:10]
|
|
1264
|
+
table.add_row(
|
|
1265
|
+
r["provider_id"] or "unknown",
|
|
1266
|
+
str(r["sessions"]),
|
|
1267
|
+
str(int(r["messages"] or 0)),
|
|
1268
|
+
str(int(r["tool_calls"] or 0)),
|
|
1269
|
+
f"{int(r['input_tokens'] or 0):,}",
|
|
1270
|
+
f"{int(r['output_tokens'] or 0):,}",
|
|
1271
|
+
f"${float(r['cost'] or 0):.2f}",
|
|
1272
|
+
last,
|
|
1273
|
+
)
|
|
1274
|
+
console.print(table)
|
|
1275
|
+
|
|
1276
|
+
|
|
856
1277
|
# ---------------------------------------------------------------------------
|
|
857
1278
|
# Token management
|
|
858
1279
|
# ---------------------------------------------------------------------------
|
|
@@ -223,16 +223,17 @@ def _collect_traces(session_ids: list[str]) -> list[dict]:
|
|
|
223
223
|
return []
|
|
224
224
|
conn = get_connection()
|
|
225
225
|
try:
|
|
226
|
+
placeholders = ", ".join(["%s"] * len(session_ids))
|
|
226
227
|
traces = conn.execute(
|
|
227
|
-
"""SELECT id, session_id, provider_id, project, title,
|
|
228
|
+
f"""SELECT id, session_id, provider_id, project, title,
|
|
228
229
|
started_at, ended_at, duration_ms,
|
|
229
230
|
span_count, agent_count, tool_count, llm_count, error_count,
|
|
230
231
|
total_input_tokens, total_output_tokens,
|
|
231
232
|
total_cache_read_tokens, total_cache_write_tokens,
|
|
232
233
|
total_cost_usd, cwd, git_branch, model,
|
|
233
234
|
vendor_count, top_vendors, attrs
|
|
234
|
-
FROM traces WHERE session_id
|
|
235
|
-
(session_ids
|
|
235
|
+
FROM traces WHERE session_id IN ({placeholders})""",
|
|
236
|
+
tuple(session_ids),
|
|
236
237
|
).fetchall()
|
|
237
238
|
|
|
238
239
|
out: list[dict] = []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|