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.
Files changed (66) hide show
  1. {spooling-0.2.1/spooling.egg-info → spooling-0.2.2}/PKG-INFO +1 -1
  2. {spooling-0.2.1 → spooling-0.2.2}/pyproject.toml +1 -1
  3. {spooling-0.2.1 → spooling-0.2.2}/spooling/cli.py +421 -0
  4. {spooling-0.2.1 → spooling-0.2.2}/spooling/cloud.py +4 -3
  5. {spooling-0.2.1 → spooling-0.2.2/spooling.egg-info}/PKG-INFO +1 -1
  6. {spooling-0.2.1 → spooling-0.2.2}/LICENSE +0 -0
  7. {spooling-0.2.1 → spooling-0.2.2}/README.md +0 -0
  8. {spooling-0.2.1 → spooling-0.2.2}/setup.cfg +0 -0
  9. {spooling-0.2.1 → spooling-0.2.2}/spooling/__init__.py +0 -0
  10. {spooling-0.2.1 → spooling-0.2.2}/spooling/agent.py +0 -0
  11. {spooling-0.2.1 → spooling-0.2.2}/spooling/classifiers.py +0 -0
  12. {spooling-0.2.1 → spooling-0.2.2}/spooling/config.py +0 -0
  13. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/__init__.py +0 -0
  14. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/base.py +0 -0
  15. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/bigquery.py +0 -0
  16. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/clickhouse.py +0 -0
  17. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/factory.py +0 -0
  18. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/mongodb.py +0 -0
  19. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/mysql.py +0 -0
  20. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/postgresql.py +0 -0
  21. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/rest/__init__.py +0 -0
  22. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/rest/base.py +0 -0
  23. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/rest/shopify_connectors.py +0 -0
  24. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/snowflake.py +0 -0
  25. {spooling-0.2.1 → spooling-0.2.2}/spooling/connectors/types.py +0 -0
  26. {spooling-0.2.1 → spooling-0.2.2}/spooling/db.py +0 -0
  27. {spooling-0.2.1 → spooling-0.2.2}/spooling/embeddings.py +0 -0
  28. {spooling-0.2.1 → spooling-0.2.2}/spooling/evals.py +0 -0
  29. {spooling-0.2.1 → spooling-0.2.2}/spooling/experiments.py +0 -0
  30. {spooling-0.2.1 → spooling-0.2.2}/spooling/ingest.py +0 -0
  31. {spooling-0.2.1 → spooling-0.2.2}/spooling/mcp_server.py +0 -0
  32. {spooling-0.2.1 → spooling-0.2.2}/spooling/parser.py +0 -0
  33. {spooling-0.2.1 → spooling-0.2.2}/spooling/pricing.py +0 -0
  34. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/__init__.py +0 -0
  35. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/antigravity.py +0 -0
  36. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/base.py +0 -0
  37. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/codex.py +0 -0
  38. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/copilot.py +0 -0
  39. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/cortex_code.py +0 -0
  40. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/cursor.py +0 -0
  41. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/gemini.py +0 -0
  42. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/github.py +0 -0
  43. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/gitlab.py +0 -0
  44. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/kiro.py +0 -0
  45. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/opencode.py +0 -0
  46. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/session_file.py +0 -0
  47. {spooling-0.2.1 → spooling-0.2.2}/spooling/providers/windsurf.py +0 -0
  48. {spooling-0.2.1 → spooling-0.2.2}/spooling/redact.py +0 -0
  49. {spooling-0.2.1 → spooling-0.2.2}/spooling/remote_otel.py +0 -0
  50. {spooling-0.2.1 → spooling-0.2.2}/spooling/schema.py +0 -0
  51. {spooling-0.2.1 → spooling-0.2.2}/spooling/sdk.py +0 -0
  52. {spooling-0.2.1 → spooling-0.2.2}/spooling/search.py +0 -0
  53. {spooling-0.2.1 → spooling-0.2.2}/spooling/server.py +0 -0
  54. {spooling-0.2.1 → spooling-0.2.2}/spooling/stats.py +0 -0
  55. {spooling-0.2.1 → spooling-0.2.2}/spooling/subscription_pricing.py +0 -0
  56. {spooling-0.2.1 → spooling-0.2.2}/spooling/tracing.py +0 -0
  57. {spooling-0.2.1 → spooling-0.2.2}/spooling/tunnel.py +0 -0
  58. {spooling-0.2.1 → spooling-0.2.2}/spooling/tunnel_auth.py +0 -0
  59. {spooling-0.2.1 → spooling-0.2.2}/spooling/watcher.py +0 -0
  60. {spooling-0.2.1 → spooling-0.2.2}/spooling.egg-info/SOURCES.txt +0 -0
  61. {spooling-0.2.1 → spooling-0.2.2}/spooling.egg-info/dependency_links.txt +0 -0
  62. {spooling-0.2.1 → spooling-0.2.2}/spooling.egg-info/entry_points.txt +0 -0
  63. {spooling-0.2.1 → spooling-0.2.2}/spooling.egg-info/requires.txt +0 -0
  64. {spooling-0.2.1 → spooling-0.2.2}/spooling.egg-info/top_level.txt +0 -0
  65. {spooling-0.2.1 → spooling-0.2.2}/tests/test_mcp_auth_middleware.py +0 -0
  66. {spooling-0.2.1 → spooling-0.2.2}/tests/test_tunnel_auth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spooling
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spooling"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Local session tracker and semantic search for AI coding assistants"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Parsed Analytics, Inc." }]
@@ -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 = ANY(%s)""",
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] = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spooling
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
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