spooling 0.2.10__tar.gz → 0.2.12__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 (76) hide show
  1. {spooling-0.2.10/spooling.egg-info → spooling-0.2.12}/PKG-INFO +28 -1
  2. {spooling-0.2.10 → spooling-0.2.12}/README.md +27 -0
  3. {spooling-0.2.10 → spooling-0.2.12}/pyproject.toml +1 -1
  4. {spooling-0.2.10 → spooling-0.2.12}/spooling/cli.py +205 -2
  5. {spooling-0.2.10 → spooling-0.2.12}/spooling/cloud.py +22 -0
  6. {spooling-0.2.10 → spooling-0.2.12}/spooling/ingest.py +51 -4
  7. spooling-0.2.12/spooling/insights.py +253 -0
  8. {spooling-0.2.10 → spooling-0.2.12}/spooling/parser.py +7 -1
  9. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/base.py +5 -0
  10. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/claude_code.py +2 -0
  11. {spooling-0.2.10 → spooling-0.2.12/spooling.egg-info}/PKG-INFO +28 -1
  12. {spooling-0.2.10 → spooling-0.2.12}/spooling.egg-info/SOURCES.txt +3 -0
  13. spooling-0.2.12/tests/test_agent_spans.py +172 -0
  14. spooling-0.2.12/tests/test_insights.py +218 -0
  15. {spooling-0.2.10 → spooling-0.2.12}/LICENSE +0 -0
  16. {spooling-0.2.10 → spooling-0.2.12}/setup.cfg +0 -0
  17. {spooling-0.2.10 → spooling-0.2.12}/spooling/__init__.py +0 -0
  18. {spooling-0.2.10 → spooling-0.2.12}/spooling/agent.py +0 -0
  19. {spooling-0.2.10 → spooling-0.2.12}/spooling/classifiers.py +0 -0
  20. {spooling-0.2.10 → spooling-0.2.12}/spooling/config.py +0 -0
  21. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/__init__.py +0 -0
  22. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/base.py +0 -0
  23. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/bigquery.py +0 -0
  24. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/clickhouse.py +0 -0
  25. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/factory.py +0 -0
  26. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/mongodb.py +0 -0
  27. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/mysql.py +0 -0
  28. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/postgresql.py +0 -0
  29. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/rest/__init__.py +0 -0
  30. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/rest/base.py +0 -0
  31. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/rest/shopify_connectors.py +0 -0
  32. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/snowflake.py +0 -0
  33. {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/types.py +0 -0
  34. {spooling-0.2.10 → spooling-0.2.12}/spooling/db.py +0 -0
  35. {spooling-0.2.10 → spooling-0.2.12}/spooling/embeddings.py +0 -0
  36. {spooling-0.2.10 → spooling-0.2.12}/spooling/evals.py +0 -0
  37. {spooling-0.2.10 → spooling-0.2.12}/spooling/experiments.py +0 -0
  38. {spooling-0.2.10 → spooling-0.2.12}/spooling/mcp_server.py +0 -0
  39. {spooling-0.2.10 → spooling-0.2.12}/spooling/optimize.py +0 -0
  40. {spooling-0.2.10 → spooling-0.2.12}/spooling/pricing.py +0 -0
  41. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/__init__.py +0 -0
  42. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/antigravity.py +0 -0
  43. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/codex.py +0 -0
  44. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/copilot.py +0 -0
  45. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/cortex_code.py +0 -0
  46. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/cursor.py +0 -0
  47. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/gemini.py +0 -0
  48. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/github.py +0 -0
  49. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/gitlab.py +0 -0
  50. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/kiro.py +0 -0
  51. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/opencode.py +0 -0
  52. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/session_file.py +0 -0
  53. {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/windsurf.py +0 -0
  54. {spooling-0.2.10 → spooling-0.2.12}/spooling/redact.py +0 -0
  55. {spooling-0.2.10 → spooling-0.2.12}/spooling/remote_otel.py +0 -0
  56. {spooling-0.2.10 → spooling-0.2.12}/spooling/schema.py +0 -0
  57. {spooling-0.2.10 → spooling-0.2.12}/spooling/sdk.py +0 -0
  58. {spooling-0.2.10 → spooling-0.2.12}/spooling/search.py +0 -0
  59. {spooling-0.2.10 → spooling-0.2.12}/spooling/server.py +0 -0
  60. {spooling-0.2.10 → spooling-0.2.12}/spooling/sessions.py +0 -0
  61. {spooling-0.2.10 → spooling-0.2.12}/spooling/stats.py +0 -0
  62. {spooling-0.2.10 → spooling-0.2.12}/spooling/subscription_pricing.py +0 -0
  63. {spooling-0.2.10 → spooling-0.2.12}/spooling/tracing.py +0 -0
  64. {spooling-0.2.10 → spooling-0.2.12}/spooling/tunnel.py +0 -0
  65. {spooling-0.2.10 → spooling-0.2.12}/spooling/tunnel_auth.py +0 -0
  66. {spooling-0.2.10 → spooling-0.2.12}/spooling/watcher.py +0 -0
  67. {spooling-0.2.10 → spooling-0.2.12}/spooling.egg-info/dependency_links.txt +0 -0
  68. {spooling-0.2.10 → spooling-0.2.12}/spooling.egg-info/entry_points.txt +0 -0
  69. {spooling-0.2.10 → spooling-0.2.12}/spooling.egg-info/requires.txt +0 -0
  70. {spooling-0.2.10 → spooling-0.2.12}/spooling.egg-info/top_level.txt +0 -0
  71. {spooling-0.2.10 → spooling-0.2.12}/tests/test_claude_code_provider.py +0 -0
  72. {spooling-0.2.10 → spooling-0.2.12}/tests/test_cloud_errors.py +0 -0
  73. {spooling-0.2.10 → spooling-0.2.12}/tests/test_mcp_auth_middleware.py +0 -0
  74. {spooling-0.2.10 → spooling-0.2.12}/tests/test_session_ids.py +0 -0
  75. {spooling-0.2.10 → spooling-0.2.12}/tests/test_storage_optimizations.py +0 -0
  76. {spooling-0.2.10 → spooling-0.2.12}/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.10
3
+ Version: 0.2.12
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -155,6 +155,33 @@ spooling stats --week # Weekly breakdown
155
155
  spooling stats --days 30 # Last 30 days
156
156
  ```
157
157
 
158
+ ### `spooling tools`, `spooling agents`, `spooling traces`
159
+
160
+ See which tools and agents your sessions use. These read the spans stored by `spooling sync`, and use the same agent/tool names as the web app (an agent is its subagent type, falling back to its span name).
161
+
162
+ ```bash
163
+ spooling tools # Top tools: uses, traces, errors, avg time
164
+ spooling tools --agent explore # Tools called by the "explore" agent
165
+ spooling tools --days 7 --provider claude-code
166
+
167
+ spooling agents # Agents: runs, traces, tokens, cost
168
+ spooling agents --tool Bash # Agents whose runs called Bash
169
+
170
+ spooling traces # Latest traces, with the agents each ran
171
+ spooling traces ses_10dd # Search title, project, session id, trace id or agent name
172
+ spooling traces --agent explore --tool Bash --days 30
173
+ ```
174
+
175
+ Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n N`, and `--json` for scripting. `--agent` and `--tool` can be repeated (matches any).
176
+
177
+ `tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
178
+
179
+ `spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (currently Claude Code, via its `Agent` tool, previously called `Task`), so `spooling agents` is empty for other providers.
180
+
181
+ After upgrading, `spooling sync` re-reads your Claude Code sessions once to pick up agents. To force a re-read of any provider's files at any time, run `spooling sync --reparse` (optionally with `-p claude-code`).
182
+
183
+ Agent runs show duration and the prompt that started them. Their own tokens, cost and tool calls are not attributed yet, because Claude Code writes subagent activity to separate transcript files that Spooling does not read yet.
184
+
158
185
  ### `spooling search <query>`
159
186
 
160
187
  Semantic search across all your session history using natural language. Each result shows the session title **and its session id**, so you can open it with `spooling session show`.
@@ -119,6 +119,33 @@ spooling stats --week # Weekly breakdown
119
119
  spooling stats --days 30 # Last 30 days
120
120
  ```
121
121
 
122
+ ### `spooling tools`, `spooling agents`, `spooling traces`
123
+
124
+ See which tools and agents your sessions use. These read the spans stored by `spooling sync`, and use the same agent/tool names as the web app (an agent is its subagent type, falling back to its span name).
125
+
126
+ ```bash
127
+ spooling tools # Top tools: uses, traces, errors, avg time
128
+ spooling tools --agent explore # Tools called by the "explore" agent
129
+ spooling tools --days 7 --provider claude-code
130
+
131
+ spooling agents # Agents: runs, traces, tokens, cost
132
+ spooling agents --tool Bash # Agents whose runs called Bash
133
+
134
+ spooling traces # Latest traces, with the agents each ran
135
+ spooling traces ses_10dd # Search title, project, session id, trace id or agent name
136
+ spooling traces --agent explore --tool Bash --days 30
137
+ ```
138
+
139
+ Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n N`, and `--json` for scripting. `--agent` and `--tool` can be repeated (matches any).
140
+
141
+ `tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
142
+
143
+ `spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (currently Claude Code, via its `Agent` tool, previously called `Task`), so `spooling agents` is empty for other providers.
144
+
145
+ After upgrading, `spooling sync` re-reads your Claude Code sessions once to pick up agents. To force a re-read of any provider's files at any time, run `spooling sync --reparse` (optionally with `-p claude-code`).
146
+
147
+ Agent runs show duration and the prompt that started them. Their own tokens, cost and tool calls are not attributed yet, because Claude Code writes subagent activity to separate transcript files that Spooling does not read yet.
148
+
122
149
  ### `spooling search <query>`
123
150
 
124
151
  Semantic search across all your session history using natural language. Each result shows the session title **and its session id**, so you can open it with `spooling session show`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spooling"
3
- version = "0.2.10"
3
+ version = "0.2.12"
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." }]
@@ -65,10 +65,11 @@ def init():
65
65
  @cli.command()
66
66
  @click.option("--no-embed", is_flag=True, help="Skip embedding (faster sync)")
67
67
  @click.option("--provider", "-p", default=None, help="Only sync a specific provider (claude-code, jsonl-session, codex, cursor, copilot, windsurf)")
68
- def sync(no_embed, provider):
68
+ @click.option("--reparse", is_flag=True, help="Re-read session files even if they look unchanged (rebuilds traces/spans)")
69
+ def sync(no_embed, provider, reparse):
69
70
  """Sync AI coding sessions to the database."""
70
71
  from spooling.ingest import sync as do_sync
71
- do_sync(embed=not no_embed, provider_filter=provider)
72
+ do_sync(embed=not no_embed, provider_filter=provider, reparse=reparse)
72
73
 
73
74
 
74
75
  @cli.command()
@@ -1339,6 +1340,208 @@ def stats_providers():
1339
1340
  console.print(table)
1340
1341
 
1341
1342
 
1343
+ # ---------------------------------------------------------------------------
1344
+ # Tools / agents / traces insights
1345
+ # ---------------------------------------------------------------------------
1346
+
1347
+ def _fmt_ms(ms) -> str:
1348
+ if ms is None:
1349
+ return "-"
1350
+ if ms < 1000:
1351
+ return f"{ms:.0f}ms"
1352
+ if ms < 60_000:
1353
+ return f"{ms / 1000:.1f}s"
1354
+ return f"{ms / 60_000:.1f}m"
1355
+
1356
+
1357
+ def _fmt_num(n) -> str:
1358
+ n = int(n or 0)
1359
+ if n >= 1_000_000:
1360
+ return f"{n / 1_000_000:.1f}M"
1361
+ if n >= 10_000:
1362
+ return f"{n / 1000:.1f}K"
1363
+ return f"{n:,}"
1364
+
1365
+
1366
+ def _emit_json(data) -> None:
1367
+ import json
1368
+ click.echo(json.dumps(data, indent=2, default=str))
1369
+
1370
+
1371
+ def _reject_cloud_filters(**filters) -> None:
1372
+ used = [f"--{k}" for k, v in filters.items() if v]
1373
+ if used:
1374
+ raise click.UsageError(
1375
+ f"{', '.join(used)} can't be combined with --cloud "
1376
+ "(the cloud endpoint only supports --limit). Drop --cloud to filter local data."
1377
+ )
1378
+
1379
+
1380
+ _FILTER_OPTS = [
1381
+ click.option("--days", type=int, default=None, help="Only the last N days"),
1382
+ click.option("--project", default=None, help="Project name contains this text"),
1383
+ click.option("--provider", "-p", default=None, help="Only this provider (claude-code, opencode, ...)"),
1384
+ click.option("--limit", "-n", type=int, default=20, show_default=True, help="Max rows"),
1385
+ click.option("--json", "as_json", is_flag=True, help="Print JSON instead of a table"),
1386
+ ]
1387
+
1388
+
1389
+ def _filter_opts(fn):
1390
+ for opt in reversed(_FILTER_OPTS):
1391
+ fn = opt(fn)
1392
+ return fn
1393
+
1394
+
1395
+ @cli.command("tools")
1396
+ @click.option("--agent", "agents", multiple=True, help="Only tools called by this agent (repeatable)")
1397
+ @click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
1398
+ @_filter_opts
1399
+ def tools_cmd(agents, cloud_mode, days, project, provider, limit, as_json):
1400
+ """Show which tools are used, how often, and how often they fail.
1401
+
1402
+ \b
1403
+ spooling tools top tools across all traces
1404
+ spooling tools --agent explore tools called by the 'explore' agent
1405
+ spooling tools --days 7 --json
1406
+ spooling tools --cloud workspace-wide, from Spooling Cloud
1407
+ """
1408
+ if cloud_mode:
1409
+ _reject_cloud_filters(agent=agents, days=days, project=project, provider=provider)
1410
+ from spooling.cloud import cloud_tools
1411
+ rows = cloud_tools(limit)
1412
+ if as_json:
1413
+ return _emit_json(rows)
1414
+ table = Table(title="Tools (cloud)", show_lines=False)
1415
+ table.add_column("Tool", style="cyan")
1416
+ table.add_column("Uses", justify="right")
1417
+ table.add_column("Sessions", justify="right")
1418
+ table.add_column("Errors", justify="right")
1419
+ for r in rows:
1420
+ table.add_row(r["tool_name"], _fmt_num(r["uses"]), _fmt_num(r["sessions"]),
1421
+ f"[red]{r['errors']}[/red]" if r["errors"] else "0")
1422
+ console.print(table if rows else "[yellow]No tool data in the cloud workspace yet.[/yellow]")
1423
+ return
1424
+
1425
+ from spooling.insights import tool_stats
1426
+ rows = tool_stats(days=days, project=project, provider=provider, agents=list(agents) or None, limit=limit)
1427
+ if as_json:
1428
+ return _emit_json(rows)
1429
+ if not rows:
1430
+ console.print("[yellow]No tool data found. Run 'spooling sync' first"
1431
+ + (" (agent filters need traces that contain agent spans)." if agents else ".") + "[/yellow]")
1432
+ return
1433
+ table = Table(title="Tools" + (f" called by {', '.join(agents)}" if agents else ""), show_lines=False)
1434
+ table.add_column("Tool", style="cyan")
1435
+ table.add_column("Uses", justify="right")
1436
+ table.add_column("Traces", justify="right")
1437
+ table.add_column("Errors", justify="right")
1438
+ table.add_column("Avg time", justify="right", style="dim")
1439
+ for r in rows:
1440
+ table.add_row(r["name"], _fmt_num(r["uses"]), _fmt_num(r["traces"]),
1441
+ f"[red]{r['errors']}[/red]" if r["errors"] else "0", _fmt_ms(r["avg_ms"]))
1442
+ console.print(table)
1443
+
1444
+
1445
+ @cli.command("agents")
1446
+ @click.option("--tool", "tools", multiple=True, help="Only agents that called this tool (repeatable)")
1447
+ @click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
1448
+ @_filter_opts
1449
+ def agents_cmd(tools, cloud_mode, days, project, provider, limit, as_json):
1450
+ """Show which agents (subagent types) ran, how often, and what they cost.
1451
+
1452
+ \b
1453
+ spooling agents agents across all traces
1454
+ spooling agents --tool Bash agents whose runs called Bash
1455
+ spooling agents --days 30 --json
1456
+ spooling agents --cloud workspace-wide, from Spooling Cloud
1457
+ """
1458
+ if cloud_mode:
1459
+ _reject_cloud_filters(tool=tools, days=days, project=project, provider=provider)
1460
+ from spooling.cloud import cloud_agents
1461
+ rows = cloud_agents()[:limit]
1462
+ if as_json:
1463
+ return _emit_json(rows)
1464
+ table = Table(title="Agents (cloud)", show_lines=False)
1465
+ table.add_column("Agent", style="cyan")
1466
+ table.add_column("Runs", justify="right")
1467
+ for r in rows:
1468
+ table.add_row(r["agent_type"], _fmt_num(r["uses"]))
1469
+ console.print(table if rows else "[yellow]No agent data in the cloud workspace yet.[/yellow]")
1470
+ return
1471
+
1472
+ from spooling.insights import agent_stats
1473
+ rows = agent_stats(days=days, project=project, provider=provider, tools=list(tools) or None, limit=limit)
1474
+ if as_json:
1475
+ return _emit_json(rows)
1476
+ if not rows:
1477
+ console.print(
1478
+ "[yellow]No agent spans found locally.[/yellow]\n"
1479
+ "[dim]Agent runs are recorded for providers that expose subagents (currently Claude Code, via its "
1480
+ "Agent/Task tool). Run 'spooling sync' after using one, or try 'spooling agents --cloud'.\n"
1481
+ "If you use Claude Code and still see this, run 'spooling sync --reparse -p claude-code'.[/dim]"
1482
+ )
1483
+ return
1484
+ table = Table(title="Agents" + (f" that called {', '.join(tools)}" if tools else ""), show_lines=False)
1485
+ table.add_column("Agent", style="cyan")
1486
+ table.add_column("Runs", justify="right")
1487
+ table.add_column("Traces", justify="right")
1488
+ table.add_column("Tokens", justify="right")
1489
+ table.add_column("Est. Cost", justify="right")
1490
+ table.add_column("Avg time", justify="right", style="dim")
1491
+ for r in rows:
1492
+ table.add_row(r["name"], _fmt_num(r["runs"]), _fmt_num(r["traces"]), _fmt_num(r["tokens"]),
1493
+ f"${r['cost']:.2f}", _fmt_ms(r["avg_ms"]))
1494
+ console.print(table)
1495
+
1496
+
1497
+ @cli.command("traces")
1498
+ @click.argument("query", required=False)
1499
+ @click.option("--agent", "agents", multiple=True, help="Only traces that ran this agent (repeatable)")
1500
+ @click.option("--tool", "tools", multiple=True, help="Only traces that used this tool (repeatable)")
1501
+ @click.option("--session", default=None, help="Session id (or unique prefix)")
1502
+ @_filter_opts
1503
+ def traces_cmd(query, agents, tools, session, days, project, provider, limit, as_json):
1504
+ """List traces, optionally filtered by agent, tool, or free text.
1505
+
1506
+ QUERY matches title, project, session id, trace id or an agent name.
1507
+
1508
+ \b
1509
+ spooling traces latest traces
1510
+ spooling traces ses_10dd find by session id
1511
+ spooling traces --agent explore traces that ran the explore agent
1512
+ spooling traces --tool Bash --days 7 traces that used Bash this week
1513
+ """
1514
+ from spooling.insights import list_traces
1515
+ rows = list_traces(query=query, agents=list(agents) or None, tools=list(tools) or None,
1516
+ session=session, days=days, project=project, provider=provider, limit=limit)
1517
+ if as_json:
1518
+ return _emit_json(rows)
1519
+ if not rows:
1520
+ console.print("[yellow]No traces match. Traces are created by 'spooling sync'.[/yellow]")
1521
+ return
1522
+ table = Table(title=f"Traces ({len(rows)})", show_lines=False, pad_edge=False)
1523
+ table.add_column("Date", style="dim", no_wrap=True)
1524
+ table.add_column("Title", style="cyan", max_width=22, overflow="ellipsis", no_wrap=True)
1525
+ table.add_column("Session", style="dim", no_wrap=True)
1526
+ table.add_column("Agents", max_width=12, overflow="ellipsis", no_wrap=True)
1527
+ table.add_column("Spans", justify="right", no_wrap=True)
1528
+ table.add_column("Tools", justify="right", no_wrap=True)
1529
+ table.add_column("Cost", justify="right", no_wrap=True)
1530
+ for r in rows:
1531
+ ag = r["agents"]
1532
+ ag_txt = ", ".join(ag[:1]) + (f" +{len(ag) - 1}" if len(ag) > 1 else "") if ag else "-"
1533
+ table.add_row(
1534
+ str(r["started_at"] or "")[5:10],
1535
+ r["title"] or "(untitled)",
1536
+ (r["session_id"] or "")[:10],
1537
+ ag_txt,
1538
+ _fmt_num(r["span_count"]),
1539
+ _fmt_num(r["tool_count"]),
1540
+ f"${float(r['total_cost_usd'] or 0):.2f}",
1541
+ )
1542
+ console.print(table)
1543
+
1544
+
1342
1545
  # ---------------------------------------------------------------------------
1343
1546
  # Token management
1344
1547
  # ---------------------------------------------------------------------------
@@ -1332,3 +1332,25 @@ def cloud_stats() -> dict | None:
1332
1332
  return r.json()
1333
1333
  except Exception:
1334
1334
  return None
1335
+
1336
+
1337
+ def _cloud_get(path: str, params: dict | None = None):
1338
+ """GET a Spooling Cloud endpoint; on any failure print a plain-English reason and exit 1."""
1339
+ headers = _auth_headers()
1340
+ base = _api_base()
1341
+ try:
1342
+ r = httpx.get(f"{base}{path}", headers=headers, params=params, timeout=15)
1343
+ r.raise_for_status()
1344
+ return r.json()
1345
+ except Exception as e: # noqa: BLE001
1346
+ _fail(_explain_error(e, base))
1347
+
1348
+
1349
+ def cloud_tools(limit: int = 20) -> list[dict]:
1350
+ """Per-tool usage across the cloud workspace (GET /v1/tools)."""
1351
+ return _cloud_get("/v1/tools", {"limit": limit})
1352
+
1353
+
1354
+ def cloud_agents() -> list[dict]:
1355
+ """Per-agent-type usage across the cloud workspace (GET /v1/stats/agent-subtypes)."""
1356
+ return _cloud_get("/v1/stats/agent-subtypes").get("agent_subtypes", [])
@@ -458,8 +458,42 @@ def _sync_remote_provider(conn, provider, prov_info: dict, embed: bool) -> tuple
458
458
  return total_sessions, total_messages, total_chunks
459
459
 
460
460
 
461
- def sync(embed: bool = True, provider_filter: str | None = None):
462
- """Sync sessions from all connected providers to the database."""
461
+ _REVISION_PREFIX = "__parser_revision__:"
462
+
463
+
464
+ def _needs_reread(conn, synced: dict, provider_type: str, revision: int, reparse: bool) -> bool:
465
+ """True if this provider's already-synced files must be re-read this run.
466
+
467
+ Either the user asked (``--reparse``), or a parser fix shipped
468
+ (``provider.parser_revision`` is newer than the one recorded at last sync)
469
+ and there are previously synced files that were parsed the old way.
470
+ """
471
+ if reparse:
472
+ return True
473
+ if not revision:
474
+ return False
475
+ stored = synced.get(_REVISION_PREFIX + provider_type)
476
+ if stored is not None and stored >= revision:
477
+ return False
478
+ row = conn.execute(
479
+ "SELECT COUNT(*) AS n FROM sync_state WHERE provider_id = %s AND file_path NOT LIKE %s",
480
+ (provider_type, _REVISION_PREFIX + "%"),
481
+ ).fetchone()
482
+ return bool(row["n"])
483
+
484
+
485
+ def _record_revision(conn, synced: dict, provider_type: str, revision: int) -> None:
486
+ if revision and synced.get(_REVISION_PREFIX + provider_type) != revision:
487
+ _mark_synced(conn, _REVISION_PREFIX + provider_type, revision, provider_type)
488
+ synced[_REVISION_PREFIX + provider_type] = revision
489
+
490
+
491
+ def sync(embed: bool = True, provider_filter: str | None = None, reparse: bool = False):
492
+ """Sync sessions from all connected providers to the database.
493
+
494
+ ``reparse`` re-reads files even if they look unchanged (also done
495
+ automatically, once, when a provider's parser improves).
496
+ """
463
497
  conn = get_connection()
464
498
  connected = _get_connected_providers(conn)
465
499
 
@@ -533,20 +567,31 @@ def sync(embed: bool = True, provider_filter: str | None = None):
533
567
  if not files:
534
568
  continue
535
569
 
570
+ revision = getattr(provider, "parser_revision", 0)
571
+ reread = _needs_reread(conn, synced, prov_info["type"], revision, reparse)
572
+ # When re-reading, pretend nothing was synced so every file is parsed again.
573
+ prov_synced = {} if reread else synced
574
+
536
575
  # Filter to new or changed files. Incremental providers (many sessions
537
576
  # per file) are always scanned; they skip unchanged sessions themselves.
538
577
  to_process = []
539
578
  for f in files:
540
579
  size = f.stat().st_size
541
- if provider.incremental or str(f) not in synced or synced[str(f)] != size:
580
+ if provider.incremental or str(f) not in prov_synced or prov_synced[str(f)] != size:
542
581
  to_process.append(f)
543
582
 
544
583
  if not to_process:
584
+ _record_revision(conn, synced, prov_info["type"], revision)
585
+ conn.commit()
545
586
  continue
546
587
 
547
588
  console.print(
548
589
  f"[bold]{provider.name}:[/bold] Found {len(to_process)} new/updated session files."
549
590
  )
591
+ if reread and not reparse:
592
+ console.print(
593
+ f" [dim]Re-reading {provider.name} sessions once to pick up improved parsing.[/dim]"
594
+ )
550
595
 
551
596
  total_messages = 0
552
597
  total_chunks = 0
@@ -570,7 +615,7 @@ def sync(embed: bool = True, provider_filter: str | None = None):
570
615
  batch_sessions: list[tuple] = [] # (file, [sessions])
571
616
 
572
617
  for f in batch_files:
573
- sessions = parse_file(provider, f, synced)
618
+ sessions = parse_file(provider, f, prov_synced)
574
619
  for session in sessions:
575
620
  _store_session(conn, session)
576
621
  if session.trace is not None:
@@ -601,6 +646,8 @@ def sync(embed: bool = True, provider_filter: str | None = None):
601
646
  _mark_synced(conn, str(f), f.stat().st_size, prov_info["type"])
602
647
  conn.commit()
603
648
 
649
+ _record_revision(conn, synced, prov_info["type"], revision)
650
+
604
651
  # Update provider stats
605
652
  conn.execute(
606
653
  """UPDATE providers SET