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.
- {spooling-0.2.11/spooling.egg-info → spooling-0.2.13}/PKG-INFO +24 -10
- {spooling-0.2.11 → spooling-0.2.13}/README.md +23 -9
- {spooling-0.2.11 → spooling-0.2.13}/pyproject.toml +1 -1
- {spooling-0.2.11 → spooling-0.2.13}/spooling/cli.py +178 -32
- {spooling-0.2.11 → spooling-0.2.13}/spooling/ingest.py +51 -4
- spooling-0.2.13/spooling/insights.py +427 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/parser.py +7 -1
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/base.py +5 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/claude_code.py +2 -0
- {spooling-0.2.11 → spooling-0.2.13/spooling.egg-info}/PKG-INFO +24 -10
- {spooling-0.2.11 → spooling-0.2.13}/spooling.egg-info/SOURCES.txt +1 -0
- spooling-0.2.13/tests/test_agent_spans.py +172 -0
- spooling-0.2.13/tests/test_insights.py +407 -0
- spooling-0.2.11/spooling/insights.py +0 -253
- spooling-0.2.11/tests/test_insights.py +0 -218
- {spooling-0.2.11 → spooling-0.2.13}/LICENSE +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/setup.cfg +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/__init__.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/agent.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/classifiers.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/cloud.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/config.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/__init__.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/base.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/factory.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/mysql.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/connectors/types.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/db.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/embeddings.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/evals.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/experiments.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/mcp_server.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/optimize.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/pricing.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/__init__.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/antigravity.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/codex.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/copilot.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/cursor.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/gemini.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/github.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/gitlab.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/kiro.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/opencode.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/session_file.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/providers/windsurf.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/redact.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/remote_otel.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/schema.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/sdk.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/search.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/server.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/sessions.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/stats.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/subscription_pricing.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/tracing.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/tunnel.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/tunnel_auth.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling/watcher.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling.egg-info/requires.txt +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/tests/test_claude_code_provider.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/tests/test_cloud_errors.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/tests/test_mcp_auth_middleware.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/tests/test_session_ids.py +0 -0
- {spooling-0.2.11 → spooling-0.2.13}/tests/test_storage_optimizations.py +0 -0
- {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.
|
|
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
|
|
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 --
|
|
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
|
|
171
|
-
spooling traces ses_10dd # Search title, project, session id, trace id or
|
|
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.
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|
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 --
|
|
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
|
|
135
|
-
spooling traces ses_10dd # Search title, project, session id, trace id or
|
|
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.
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
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 --
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 --
|
|
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()
|
|
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
|
|
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,
|
|
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 (
|
|
1479
|
-
"Run 'spooling sync' after using one, or try 'spooling agents --cloud'
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
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
|
|
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,
|
|
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
|