spooling 0.2.11__tar.gz → 0.2.13__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 (78) hide show
  1. {spooling-0.2.11/spooling.egg-info → spooling-0.2.13}/PKG-INFO +24 -10
  2. {spooling-0.2.11 → spooling-0.2.13}/README.md +23 -9
  3. {spooling-0.2.11 → spooling-0.2.13}/pyproject.toml +1 -1
  4. {spooling-0.2.11 → spooling-0.2.13}/spooling/cli.py +178 -32
  5. {spooling-0.2.11 → spooling-0.2.13}/spooling/ingest.py +51 -4
  6. spooling-0.2.13/spooling/insights.py +427 -0
  7. {spooling-0.2.11 → spooling-0.2.13}/spooling/parser.py +7 -1
  8. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/base.py +5 -0
  9. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/claude_code.py +2 -0
  10. {spooling-0.2.11 → spooling-0.2.13/spooling.egg-info}/PKG-INFO +24 -10
  11. {spooling-0.2.11 → spooling-0.2.13}/spooling.egg-info/SOURCES.txt +1 -0
  12. spooling-0.2.13/tests/test_agent_spans.py +172 -0
  13. spooling-0.2.13/tests/test_insights.py +407 -0
  14. spooling-0.2.11/spooling/insights.py +0 -253
  15. spooling-0.2.11/tests/test_insights.py +0 -218
  16. {spooling-0.2.11 → spooling-0.2.13}/LICENSE +0 -0
  17. {spooling-0.2.11 → spooling-0.2.13}/setup.cfg +0 -0
  18. {spooling-0.2.11 → spooling-0.2.13}/spooling/__init__.py +0 -0
  19. {spooling-0.2.11 → spooling-0.2.13}/spooling/agent.py +0 -0
  20. {spooling-0.2.11 → spooling-0.2.13}/spooling/classifiers.py +0 -0
  21. {spooling-0.2.11 → spooling-0.2.13}/spooling/cloud.py +0 -0
  22. {spooling-0.2.11 → spooling-0.2.13}/spooling/config.py +0 -0
  23. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/__init__.py +0 -0
  24. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/base.py +0 -0
  25. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/bigquery.py +0 -0
  26. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/clickhouse.py +0 -0
  27. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/factory.py +0 -0
  28. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/mongodb.py +0 -0
  29. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/mysql.py +0 -0
  30. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/postgresql.py +0 -0
  31. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/rest/__init__.py +0 -0
  32. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/rest/base.py +0 -0
  33. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/rest/shopify_connectors.py +0 -0
  34. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/snowflake.py +0 -0
  35. {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/types.py +0 -0
  36. {spooling-0.2.11 → spooling-0.2.13}/spooling/db.py +0 -0
  37. {spooling-0.2.11 → spooling-0.2.13}/spooling/embeddings.py +0 -0
  38. {spooling-0.2.11 → spooling-0.2.13}/spooling/evals.py +0 -0
  39. {spooling-0.2.11 → spooling-0.2.13}/spooling/experiments.py +0 -0
  40. {spooling-0.2.11 → spooling-0.2.13}/spooling/mcp_server.py +0 -0
  41. {spooling-0.2.11 → spooling-0.2.13}/spooling/optimize.py +0 -0
  42. {spooling-0.2.11 → spooling-0.2.13}/spooling/pricing.py +0 -0
  43. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/__init__.py +0 -0
  44. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/antigravity.py +0 -0
  45. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/codex.py +0 -0
  46. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/copilot.py +0 -0
  47. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/cortex_code.py +0 -0
  48. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/cursor.py +0 -0
  49. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/gemini.py +0 -0
  50. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/github.py +0 -0
  51. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/gitlab.py +0 -0
  52. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/kiro.py +0 -0
  53. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/opencode.py +0 -0
  54. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/session_file.py +0 -0
  55. {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/windsurf.py +0 -0
  56. {spooling-0.2.11 → spooling-0.2.13}/spooling/redact.py +0 -0
  57. {spooling-0.2.11 → spooling-0.2.13}/spooling/remote_otel.py +0 -0
  58. {spooling-0.2.11 → spooling-0.2.13}/spooling/schema.py +0 -0
  59. {spooling-0.2.11 → spooling-0.2.13}/spooling/sdk.py +0 -0
  60. {spooling-0.2.11 → spooling-0.2.13}/spooling/search.py +0 -0
  61. {spooling-0.2.11 → spooling-0.2.13}/spooling/server.py +0 -0
  62. {spooling-0.2.11 → spooling-0.2.13}/spooling/sessions.py +0 -0
  63. {spooling-0.2.11 → spooling-0.2.13}/spooling/stats.py +0 -0
  64. {spooling-0.2.11 → spooling-0.2.13}/spooling/subscription_pricing.py +0 -0
  65. {spooling-0.2.11 → spooling-0.2.13}/spooling/tracing.py +0 -0
  66. {spooling-0.2.11 → spooling-0.2.13}/spooling/tunnel.py +0 -0
  67. {spooling-0.2.11 → spooling-0.2.13}/spooling/tunnel_auth.py +0 -0
  68. {spooling-0.2.11 → spooling-0.2.13}/spooling/watcher.py +0 -0
  69. {spooling-0.2.11 → spooling-0.2.13}/spooling.egg-info/dependency_links.txt +0 -0
  70. {spooling-0.2.11 → spooling-0.2.13}/spooling.egg-info/entry_points.txt +0 -0
  71. {spooling-0.2.11 → spooling-0.2.13}/spooling.egg-info/requires.txt +0 -0
  72. {spooling-0.2.11 → spooling-0.2.13}/spooling.egg-info/top_level.txt +0 -0
  73. {spooling-0.2.11 → spooling-0.2.13}/tests/test_claude_code_provider.py +0 -0
  74. {spooling-0.2.11 → spooling-0.2.13}/tests/test_cloud_errors.py +0 -0
  75. {spooling-0.2.11 → spooling-0.2.13}/tests/test_mcp_auth_middleware.py +0 -0
  76. {spooling-0.2.11 → spooling-0.2.13}/tests/test_session_ids.py +0 -0
  77. {spooling-0.2.11 → spooling-0.2.13}/tests/test_storage_optimizations.py +0 -0
  78. {spooling-0.2.11 → spooling-0.2.13}/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.11
3
+ Version: 0.2.13
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -155,28 +155,42 @@ 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`
158
+ ### `spooling tools`, `spooling agents`, `spooling mcps`, `spooling traces`
159
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).
160
+ See which tools, MCP servers and agents your sessions use. These read the spans stored by `spooling sync`, and use the same names as the web app (an agent is its subagent type, falling back to its span name; an MCP tool is named `mcp__<server>__<tool>`).
161
161
 
162
162
  ```bash
163
163
  spooling tools # Top tools: uses, traces, errors, avg time
164
+ spooling tools -s read # Tools whose name contains "read"
164
165
  spooling tools --agent explore # Tools called by the "explore" agent
165
- spooling tools --days 7 --provider claude-code
166
+ spooling tools --mcp github # Tools of MCP servers matching "github"
167
+
168
+ spooling mcps # Every MCP server used: tools, calls, traces, errors
169
+ spooling mcps spool # The tools of servers matching "spool"
166
170
 
167
171
  spooling agents # Agents: runs, traces, tokens, cost
172
+ spooling agents -s expl # Agents whose name contains "expl"
168
173
  spooling agents --tool Bash # Agents whose runs called Bash
174
+ spooling agents --mcp github # Agents whose runs used a GitHub MCP tool
169
175
 
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
176
+ spooling traces # Latest traces, with the agents and MCP servers each used
177
+ spooling traces ses_10dd # Search title, project, session id, trace id, agent or MCP server name
178
+ spooling traces --agent explore --tool Bash --mcp spool --days 30
173
179
  ```
174
180
 
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).
181
+ Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n N`, and `--json` for scripting.
182
+
183
+ Matching rules: `--agent` and `--tool` match the exact name, ignoring case, and can be repeated (matches any). `--mcp` and `--search/-s` match part of the name, ignoring case. When you combine different filters on `traces`, a trace must satisfy all of them.
184
+
185
+ `tools --agent` and `agents --tool/--mcp` look inside an agent's own runs. `traces --agent/--tool/--mcp` matches whole traces, like the Traces page.
186
+
187
+ `--cloud` on `tools`, `agents` and `mcps` shows workspace-wide totals from Spooling Cloud. The cloud endpoints only return the 100 most-used tools, so `--search` and `--mcp` filter those client-side, and `--days/--project/--provider/--agent/--tool` can't be combined with `--cloud`.
188
+
189
+ 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. MCP calls are recognised by the `mcp__<server>__<tool>` name Claude Code uses; tools from other clients that name MCP tools differently (for example `server_tool`) appear in `spooling tools` but not in `spooling mcps`.
176
190
 
177
- `tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
191
+ 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`).
178
192
 
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 (for example Claude Code's `Task` tool), so `spooling agents` can be empty for other providers.
193
+ 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.
180
194
 
181
195
  ### `spooling search <query>`
182
196
 
@@ -119,28 +119,42 @@ 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`
122
+ ### `spooling tools`, `spooling agents`, `spooling mcps`, `spooling traces`
123
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).
124
+ See which tools, MCP servers and agents your sessions use. These read the spans stored by `spooling sync`, and use the same names as the web app (an agent is its subagent type, falling back to its span name; an MCP tool is named `mcp__<server>__<tool>`).
125
125
 
126
126
  ```bash
127
127
  spooling tools # Top tools: uses, traces, errors, avg time
128
+ spooling tools -s read # Tools whose name contains "read"
128
129
  spooling tools --agent explore # Tools called by the "explore" agent
129
- spooling tools --days 7 --provider claude-code
130
+ spooling tools --mcp github # Tools of MCP servers matching "github"
131
+
132
+ spooling mcps # Every MCP server used: tools, calls, traces, errors
133
+ spooling mcps spool # The tools of servers matching "spool"
130
134
 
131
135
  spooling agents # Agents: runs, traces, tokens, cost
136
+ spooling agents -s expl # Agents whose name contains "expl"
132
137
  spooling agents --tool Bash # Agents whose runs called Bash
138
+ spooling agents --mcp github # Agents whose runs used a GitHub MCP tool
133
139
 
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
140
+ spooling traces # Latest traces, with the agents and MCP servers each used
141
+ spooling traces ses_10dd # Search title, project, session id, trace id, agent or MCP server name
142
+ spooling traces --agent explore --tool Bash --mcp spool --days 30
137
143
  ```
138
144
 
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).
145
+ Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n N`, and `--json` for scripting.
146
+
147
+ Matching rules: `--agent` and `--tool` match the exact name, ignoring case, and can be repeated (matches any). `--mcp` and `--search/-s` match part of the name, ignoring case. When you combine different filters on `traces`, a trace must satisfy all of them.
148
+
149
+ `tools --agent` and `agents --tool/--mcp` look inside an agent's own runs. `traces --agent/--tool/--mcp` matches whole traces, like the Traces page.
150
+
151
+ `--cloud` on `tools`, `agents` and `mcps` shows workspace-wide totals from Spooling Cloud. The cloud endpoints only return the 100 most-used tools, so `--search` and `--mcp` filter those client-side, and `--days/--project/--provider/--agent/--tool` can't be combined with `--cloud`.
152
+
153
+ 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. MCP calls are recognised by the `mcp__<server>__<tool>` name Claude Code uses; tools from other clients that name MCP tools differently (for example `server_tool`) appear in `spooling tools` but not in `spooling mcps`.
140
154
 
141
- `tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
155
+ 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`).
142
156
 
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 (for example Claude Code's `Task` tool), so `spooling agents` can be empty for other providers.
157
+ 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.
144
158
 
145
159
  ### `spooling search <query>`
146
160
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spooling"
3
- version = "0.2.11"
3
+ version = "0.2.13"
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()
@@ -1372,7 +1373,8 @@ def _reject_cloud_filters(**filters) -> None:
1372
1373
  if used:
1373
1374
  raise click.UsageError(
1374
1375
  f"{', '.join(used)} can't be combined with --cloud "
1375
- "(the cloud endpoint only supports --limit). Drop --cloud to filter local data."
1376
+ "(the cloud endpoint only supports --limit, --search and --mcp). "
1377
+ "Drop --cloud to filter local data."
1376
1378
  )
1377
1379
 
1378
1380
 
@@ -1391,23 +1393,44 @@ def _filter_opts(fn):
1391
1393
  return fn
1392
1394
 
1393
1395
 
1396
+ def _error_cell(n) -> str:
1397
+ return f"[red]{n}[/red]" if n else "0"
1398
+
1399
+
1400
+ def _cloud_tool_rows(limit: int, search: str | None, mcps) -> list[dict]:
1401
+ """Cloud per-tool rows, filtered client-side by name/MCP server (server caps at 100)."""
1402
+ from spooling.cloud import cloud_tools
1403
+ from spooling.insights import mcp_server_of
1404
+ rows = cloud_tools(100)
1405
+ if search:
1406
+ rows = [r for r in rows if search.lower() in r["tool_name"].lower()]
1407
+ if mcps:
1408
+ rows = [r for r in rows
1409
+ if any(m.lower() in (mcp_server_of(r["tool_name"]) or "").lower() for m in mcps)]
1410
+ return rows[:limit]
1411
+
1412
+
1394
1413
  @cli.command("tools")
1395
1414
  @click.option("--agent", "agents", multiple=True, help="Only tools called by this agent (repeatable)")
1415
+ @click.option("--mcp", "mcps", multiple=True, help="Only tools of MCP servers whose name contains this (repeatable)")
1416
+ @click.option("--search", "-s", default=None, help="Only tools whose name contains this text")
1396
1417
  @click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
1397
1418
  @_filter_opts
1398
- def tools_cmd(agents, cloud_mode, days, project, provider, limit, as_json):
1419
+ def tools_cmd(agents, mcps, search, cloud_mode, days, project, provider, limit, as_json):
1399
1420
  """Show which tools are used, how often, and how often they fail.
1400
1421
 
1422
+ MCP tools are named mcp__<server>__<tool>; use --mcp to see one server's tools.
1423
+
1401
1424
  \b
1402
1425
  spooling tools top tools across all traces
1426
+ spooling tools -s read tools whose name contains "read"
1427
+ spooling tools --mcp spool tools from MCP servers matching "spool"
1403
1428
  spooling tools --agent explore tools called by the 'explore' agent
1404
- spooling tools --days 7 --json
1405
- spooling tools --cloud workspace-wide, from Spooling Cloud
1429
+ spooling tools --cloud --mcp github workspace-wide, from Spooling Cloud
1406
1430
  """
1407
1431
  if cloud_mode:
1408
1432
  _reject_cloud_filters(agent=agents, days=days, project=project, provider=provider)
1409
- from spooling.cloud import cloud_tools
1410
- rows = cloud_tools(limit)
1433
+ rows = _cloud_tool_rows(limit, search, mcps)
1411
1434
  if as_json:
1412
1435
  return _emit_json(rows)
1413
1436
  table = Table(title="Tools (cloud)", show_lines=False)
@@ -1416,20 +1439,25 @@ def tools_cmd(agents, cloud_mode, days, project, provider, limit, as_json):
1416
1439
  table.add_column("Sessions", justify="right")
1417
1440
  table.add_column("Errors", justify="right")
1418
1441
  for r in rows:
1419
- table.add_row(r["tool_name"], _fmt_num(r["uses"]), _fmt_num(r["sessions"]),
1420
- f"[red]{r['errors']}[/red]" if r["errors"] else "0")
1421
- console.print(table if rows else "[yellow]No tool data in the cloud workspace yet.[/yellow]")
1442
+ table.add_row(r["tool_name"], _fmt_num(r["uses"]), _fmt_num(r["sessions"]), _error_cell(r["errors"]))
1443
+ console.print(table if rows else "[yellow]No matching tools in the cloud workspace.[/yellow]")
1422
1444
  return
1423
1445
 
1424
1446
  from spooling.insights import tool_stats
1425
- rows = tool_stats(days=days, project=project, provider=provider, agents=list(agents) or None, limit=limit)
1447
+ rows = tool_stats(days=days, project=project, provider=provider, agents=list(agents) or None,
1448
+ mcps=list(mcps) or None, search=search, limit=limit)
1426
1449
  if as_json:
1427
1450
  return _emit_json(rows)
1428
1451
  if not rows:
1429
- console.print("[yellow]No tool data found. Run 'spooling sync' first"
1452
+ console.print("[yellow]No matching tool data found. Run 'spooling sync' first"
1430
1453
  + (" (agent filters need traces that contain agent spans)." if agents else ".") + "[/yellow]")
1431
1454
  return
1432
- table = Table(title="Tools" + (f" called by {', '.join(agents)}" if agents else ""), show_lines=False)
1455
+ title = "Tools"
1456
+ if agents:
1457
+ title += f" called by {', '.join(agents)}"
1458
+ if mcps:
1459
+ title += f" from MCP {', '.join(mcps)}"
1460
+ table = Table(title=title, show_lines=False)
1433
1461
  table.add_column("Tool", style="cyan")
1434
1462
  table.add_column("Uses", justify="right")
1435
1463
  table.add_column("Traces", justify="right")
@@ -1437,27 +1465,130 @@ def tools_cmd(agents, cloud_mode, days, project, provider, limit, as_json):
1437
1465
  table.add_column("Avg time", justify="right", style="dim")
1438
1466
  for r in rows:
1439
1467
  table.add_row(r["name"], _fmt_num(r["uses"]), _fmt_num(r["traces"]),
1440
- f"[red]{r['errors']}[/red]" if r["errors"] else "0", _fmt_ms(r["avg_ms"]))
1468
+ _error_cell(r["errors"]), _fmt_ms(r["avg_ms"]))
1441
1469
  console.print(table)
1442
1470
 
1443
1471
 
1472
+ @cli.command("mcps")
1473
+ @click.argument("server", required=False)
1474
+ @click.option("--search", "-s", default=None, help="Only servers/tools whose name contains this text")
1475
+ @click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
1476
+ @_filter_opts
1477
+ def mcps_cmd(server, search, cloud_mode, days, project, provider, limit, as_json):
1478
+ """Show which MCP servers were used, and which of their tools.
1479
+
1480
+ With no argument, one row per MCP server. Pass a server name (or part of
1481
+ it) to list that server's tools instead.
1482
+
1483
+ \b
1484
+ spooling mcps every MCP server used: tools, calls, traces, errors
1485
+ spooling mcps spool the tools of servers matching "spool"
1486
+ spooling mcps -s search servers/tools with "search" in the name
1487
+ spooling mcps --days 7 --json
1488
+ spooling mcps --cloud workspace-wide, from Spooling Cloud
1489
+ """
1490
+ if cloud_mode:
1491
+ _reject_cloud_filters(days=days, project=project, provider=provider)
1492
+ from spooling.insights import group_mcp_tools
1493
+ rows = _cloud_tool_rows(100, search, [server] if server else None)
1494
+ mcp_rows = [r for r in rows if r["tool_name"].lower().startswith("mcp__")]
1495
+ if server:
1496
+ mcp_rows = mcp_rows[:limit]
1497
+ if as_json:
1498
+ return _emit_json(mcp_rows)
1499
+ table = Table(title=f"MCP tools matching '{server}' (cloud)", show_lines=False)
1500
+ table.add_column("Tool", style="cyan")
1501
+ table.add_column("Uses", justify="right")
1502
+ table.add_column("Sessions", justify="right")
1503
+ table.add_column("Errors", justify="right")
1504
+ for r in mcp_rows:
1505
+ table.add_row(r["tool_name"], _fmt_num(r["uses"]), _fmt_num(r["sessions"]), _error_cell(r["errors"]))
1506
+ console.print(table if mcp_rows else "[yellow]No matching MCP tools in the cloud workspace.[/yellow]")
1507
+ return
1508
+ grouped = group_mcp_tools(mcp_rows)[:limit]
1509
+ if as_json:
1510
+ return _emit_json(grouped)
1511
+ table = Table(title="MCP servers (cloud)", show_lines=False)
1512
+ table.add_column("MCP server", style="cyan")
1513
+ table.add_column("Tools", justify="right")
1514
+ table.add_column("Calls", justify="right")
1515
+ table.add_column("Errors", justify="right")
1516
+ for g in grouped:
1517
+ table.add_row(g["name"], _fmt_num(g["tools"]), _fmt_num(g["calls"]), _error_cell(g["errors"]))
1518
+ console.print(table if grouped else "[yellow]No MCP tool calls in the cloud workspace.[/yellow]")
1519
+ console.print("[dim]Based on the 100 most-used tools in the workspace.[/dim]")
1520
+ return
1521
+
1522
+ from spooling.insights import mcp_stats, tool_stats
1523
+ if server:
1524
+ rows = tool_stats(days=days, project=project, provider=provider, mcps=[server],
1525
+ search=search, limit=limit)
1526
+ if as_json:
1527
+ return _emit_json(rows)
1528
+ if not rows:
1529
+ console.print(f"[yellow]No MCP tools found for a server matching '{server}'. "
1530
+ "Run 'spooling mcps' to see the servers.[/yellow]")
1531
+ return
1532
+ table = Table(title=f"MCP tools matching '{server}'", show_lines=False)
1533
+ table.add_column("Tool", style="cyan")
1534
+ table.add_column("Server", style="dim")
1535
+ table.add_column("Uses", justify="right")
1536
+ table.add_column("Traces", justify="right")
1537
+ table.add_column("Errors", justify="right")
1538
+ table.add_column("Avg time", justify="right", style="dim")
1539
+ for r in rows:
1540
+ table.add_row(r["name"], r["mcp_server"] or "", _fmt_num(r["uses"]), _fmt_num(r["traces"]),
1541
+ _error_cell(r["errors"]), _fmt_ms(r["avg_ms"]))
1542
+ console.print(table)
1543
+ return
1544
+
1545
+ rows = mcp_stats(days=days, project=project, provider=provider, search=search, limit=limit)
1546
+ if as_json:
1547
+ return _emit_json(rows)
1548
+ if not rows:
1549
+ console.print(
1550
+ "[yellow]No MCP tool calls found.[/yellow]\n"
1551
+ "[dim]MCP tools are recorded as mcp__<server>__<tool>. Run 'spooling sync' after using one, "
1552
+ "or try 'spooling mcps --cloud'.[/dim]"
1553
+ )
1554
+ return
1555
+ table = Table(title="MCP servers", show_lines=False)
1556
+ table.add_column("MCP server", style="cyan")
1557
+ table.add_column("Tools", justify="right")
1558
+ table.add_column("Calls", justify="right")
1559
+ table.add_column("Traces", justify="right")
1560
+ table.add_column("Errors", justify="right")
1561
+ table.add_column("Avg time", justify="right", style="dim")
1562
+ for r in rows:
1563
+ table.add_row(r["name"], _fmt_num(r["tools"]), _fmt_num(r["calls"]), _fmt_num(r["traces"]),
1564
+ _error_cell(r["errors"]), _fmt_ms(r["avg_ms"]))
1565
+ console.print(table)
1566
+ console.print("[dim]spooling mcps <server> lists a server's tools.[/dim]")
1567
+
1568
+
1444
1569
  @cli.command("agents")
1445
1570
  @click.option("--tool", "tools", multiple=True, help="Only agents that called this tool (repeatable)")
1571
+ @click.option("--mcp", "mcps", multiple=True, help="Only agents that called a tool of an MCP server matching this (repeatable)")
1572
+ @click.option("--search", "-s", default=None, help="Only agents whose name contains this text")
1446
1573
  @click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
1447
1574
  @_filter_opts
1448
- def agents_cmd(tools, cloud_mode, days, project, provider, limit, as_json):
1575
+ def agents_cmd(tools, mcps, search, cloud_mode, days, project, provider, limit, as_json):
1449
1576
  """Show which agents (subagent types) ran, how often, and what they cost.
1450
1577
 
1451
1578
  \b
1452
1579
  spooling agents agents across all traces
1580
+ spooling agents -s expl agents whose name contains "expl"
1453
1581
  spooling agents --tool Bash agents whose runs called Bash
1454
- spooling agents --days 30 --json
1582
+ spooling agents --mcp github agents whose runs called a GitHub MCP tool
1455
1583
  spooling agents --cloud workspace-wide, from Spooling Cloud
1456
1584
  """
1457
1585
  if cloud_mode:
1458
- _reject_cloud_filters(tool=tools, days=days, project=project, provider=provider)
1586
+ _reject_cloud_filters(tool=tools, mcp=mcps, days=days, project=project, provider=provider)
1459
1587
  from spooling.cloud import cloud_agents
1460
- rows = cloud_agents()[:limit]
1588
+ rows = cloud_agents()
1589
+ if search:
1590
+ rows = [r for r in rows if search.lower() in r["agent_type"].lower()]
1591
+ rows = rows[:limit]
1461
1592
  if as_json:
1462
1593
  return _emit_json(rows)
1463
1594
  table = Table(title="Agents (cloud)", show_lines=False)
@@ -1465,21 +1596,28 @@ def agents_cmd(tools, cloud_mode, days, project, provider, limit, as_json):
1465
1596
  table.add_column("Runs", justify="right")
1466
1597
  for r in rows:
1467
1598
  table.add_row(r["agent_type"], _fmt_num(r["uses"]))
1468
- console.print(table if rows else "[yellow]No agent data in the cloud workspace yet.[/yellow]")
1599
+ console.print(table if rows else "[yellow]No matching agents in the cloud workspace.[/yellow]")
1469
1600
  return
1470
1601
 
1471
1602
  from spooling.insights import agent_stats
1472
- rows = agent_stats(days=days, project=project, provider=provider, tools=list(tools) or None, limit=limit)
1603
+ rows = agent_stats(days=days, project=project, provider=provider, tools=list(tools) or None,
1604
+ mcps=list(mcps) or None, search=search, limit=limit)
1473
1605
  if as_json:
1474
1606
  return _emit_json(rows)
1475
1607
  if not rows:
1476
1608
  console.print(
1477
- "[yellow]No agent spans found locally.[/yellow]\n"
1478
- "[dim]Agent runs are recorded for providers that expose subagents (e.g. Claude Code's Task tool). "
1479
- "Run 'spooling sync' after using one, or try 'spooling agents --cloud'.[/dim]"
1609
+ "[yellow]No matching agent spans found locally.[/yellow]\n"
1610
+ "[dim]Agent runs are recorded for providers that expose subagents (currently Claude Code, via its "
1611
+ "Agent/Task tool). Run 'spooling sync' after using one, or try 'spooling agents --cloud'.\n"
1612
+ "If you use Claude Code and still see this, run 'spooling sync --reparse -p claude-code'.[/dim]"
1480
1613
  )
1481
1614
  return
1482
- table = Table(title="Agents" + (f" that called {', '.join(tools)}" if tools else ""), show_lines=False)
1615
+ title = "Agents"
1616
+ if tools:
1617
+ title += f" that called {', '.join(tools)}"
1618
+ if mcps:
1619
+ title += f" that used MCP {', '.join(mcps)}"
1620
+ table = Table(title=title, show_lines=False)
1483
1621
  table.add_column("Agent", style="cyan")
1484
1622
  table.add_column("Runs", justify="right")
1485
1623
  table.add_column("Traces", justify="right")
@@ -1496,22 +1634,26 @@ def agents_cmd(tools, cloud_mode, days, project, provider, limit, as_json):
1496
1634
  @click.argument("query", required=False)
1497
1635
  @click.option("--agent", "agents", multiple=True, help="Only traces that ran this agent (repeatable)")
1498
1636
  @click.option("--tool", "tools", multiple=True, help="Only traces that used this tool (repeatable)")
1637
+ @click.option("--mcp", "mcps", multiple=True, help="Only traces that used an MCP server matching this (repeatable)")
1499
1638
  @click.option("--session", default=None, help="Session id (or unique prefix)")
1500
1639
  @_filter_opts
1501
- def traces_cmd(query, agents, tools, session, days, project, provider, limit, as_json):
1502
- """List traces, optionally filtered by agent, tool, or free text.
1640
+ def traces_cmd(query, agents, tools, mcps, session, days, project, provider, limit, as_json):
1641
+ """List traces, optionally filtered by agent, tool, MCP server, or free text.
1503
1642
 
1504
- QUERY matches title, project, session id, trace id or an agent name.
1643
+ QUERY matches title, project, session id, trace id, an agent name or an MCP
1644
+ server name. --agent and --tool match the exact name, ignoring case.
1505
1645
 
1506
1646
  \b
1507
1647
  spooling traces latest traces
1508
1648
  spooling traces ses_10dd find by session id
1509
1649
  spooling traces --agent explore traces that ran the explore agent
1650
+ spooling traces --mcp spool traces that used an MCP server matching "spool"
1510
1651
  spooling traces --tool Bash --days 7 traces that used Bash this week
1511
1652
  """
1512
1653
  from spooling.insights import list_traces
1513
1654
  rows = list_traces(query=query, agents=list(agents) or None, tools=list(tools) or None,
1514
- session=session, days=days, project=project, provider=provider, limit=limit)
1655
+ mcps=list(mcps) or None, session=session, days=days, project=project,
1656
+ provider=provider, limit=limit)
1515
1657
  if as_json:
1516
1658
  return _emit_json(rows)
1517
1659
  if not rows:
@@ -1522,17 +1664,21 @@ def traces_cmd(query, agents, tools, session, days, project, provider, limit, as
1522
1664
  table.add_column("Title", style="cyan", max_width=22, overflow="ellipsis", no_wrap=True)
1523
1665
  table.add_column("Session", style="dim", no_wrap=True)
1524
1666
  table.add_column("Agents", max_width=12, overflow="ellipsis", no_wrap=True)
1667
+ table.add_column("MCP", max_width=12, overflow="ellipsis", no_wrap=True)
1525
1668
  table.add_column("Spans", justify="right", no_wrap=True)
1526
1669
  table.add_column("Tools", justify="right", no_wrap=True)
1527
1670
  table.add_column("Cost", justify="right", no_wrap=True)
1671
+
1672
+ def short(names: list[str]) -> str:
1673
+ return ", ".join(names[:1]) + (f" +{len(names) - 1}" if len(names) > 1 else "") if names else "-"
1674
+
1528
1675
  for r in rows:
1529
- ag = r["agents"]
1530
- ag_txt = ", ".join(ag[:1]) + (f" +{len(ag) - 1}" if len(ag) > 1 else "") if ag else "-"
1531
1676
  table.add_row(
1532
1677
  str(r["started_at"] or "")[5:10],
1533
1678
  r["title"] or "(untitled)",
1534
1679
  (r["session_id"] or "")[:10],
1535
- ag_txt,
1680
+ short(r["agents"]),
1681
+ short(r["mcps"]),
1536
1682
  _fmt_num(r["span_count"]),
1537
1683
  _fmt_num(r["tool_count"]),
1538
1684
  f"${float(r['total_cost_usd'] or 0):.2f}",
@@ -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