spooling 0.2.10__tar.gz → 0.2.11__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.10/spooling.egg-info → spooling-0.2.11}/PKG-INFO +24 -1
- {spooling-0.2.10 → spooling-0.2.11}/README.md +23 -0
- {spooling-0.2.10 → spooling-0.2.11}/pyproject.toml +1 -1
- {spooling-0.2.10 → spooling-0.2.11}/spooling/cli.py +201 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/cloud.py +22 -0
- spooling-0.2.11/spooling/insights.py +253 -0
- {spooling-0.2.10 → spooling-0.2.11/spooling.egg-info}/PKG-INFO +24 -1
- {spooling-0.2.10 → spooling-0.2.11}/spooling.egg-info/SOURCES.txt +2 -0
- spooling-0.2.11/tests/test_insights.py +218 -0
- {spooling-0.2.10 → spooling-0.2.11}/LICENSE +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/setup.cfg +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/__init__.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/agent.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/classifiers.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/config.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/__init__.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/base.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/factory.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/mysql.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/types.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/db.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/embeddings.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/evals.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/experiments.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/ingest.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/mcp_server.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/optimize.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/parser.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/pricing.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/__init__.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/antigravity.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/base.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/claude_code.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/codex.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/copilot.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/cursor.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/gemini.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/github.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/gitlab.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/kiro.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/opencode.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/session_file.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/windsurf.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/redact.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/remote_otel.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/schema.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/sdk.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/search.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/server.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/sessions.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/stats.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/subscription_pricing.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/tracing.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/tunnel.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/tunnel_auth.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling/watcher.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling.egg-info/requires.txt +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/tests/test_claude_code_provider.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/tests/test_cloud_errors.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/tests/test_mcp_auth_middleware.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/tests/test_session_ids.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/tests/test_storage_optimizations.py +0 -0
- {spooling-0.2.10 → spooling-0.2.11}/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.11
|
|
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,29 @@ 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 (for example Claude Code's `Task` tool), so `spooling agents` can be empty for other providers.
|
|
180
|
+
|
|
158
181
|
### `spooling search <query>`
|
|
159
182
|
|
|
160
183
|
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,29 @@ 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 (for example Claude Code's `Task` tool), so `spooling agents` can be empty for other providers.
|
|
144
|
+
|
|
122
145
|
### `spooling search <query>`
|
|
123
146
|
|
|
124
147
|
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`.
|
|
@@ -1339,6 +1339,207 @@ def stats_providers():
|
|
|
1339
1339
|
console.print(table)
|
|
1340
1340
|
|
|
1341
1341
|
|
|
1342
|
+
# ---------------------------------------------------------------------------
|
|
1343
|
+
# Tools / agents / traces insights
|
|
1344
|
+
# ---------------------------------------------------------------------------
|
|
1345
|
+
|
|
1346
|
+
def _fmt_ms(ms) -> str:
|
|
1347
|
+
if ms is None:
|
|
1348
|
+
return "-"
|
|
1349
|
+
if ms < 1000:
|
|
1350
|
+
return f"{ms:.0f}ms"
|
|
1351
|
+
if ms < 60_000:
|
|
1352
|
+
return f"{ms / 1000:.1f}s"
|
|
1353
|
+
return f"{ms / 60_000:.1f}m"
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def _fmt_num(n) -> str:
|
|
1357
|
+
n = int(n or 0)
|
|
1358
|
+
if n >= 1_000_000:
|
|
1359
|
+
return f"{n / 1_000_000:.1f}M"
|
|
1360
|
+
if n >= 10_000:
|
|
1361
|
+
return f"{n / 1000:.1f}K"
|
|
1362
|
+
return f"{n:,}"
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _emit_json(data) -> None:
|
|
1366
|
+
import json
|
|
1367
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _reject_cloud_filters(**filters) -> None:
|
|
1371
|
+
used = [f"--{k}" for k, v in filters.items() if v]
|
|
1372
|
+
if used:
|
|
1373
|
+
raise click.UsageError(
|
|
1374
|
+
f"{', '.join(used)} can't be combined with --cloud "
|
|
1375
|
+
"(the cloud endpoint only supports --limit). Drop --cloud to filter local data."
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
_FILTER_OPTS = [
|
|
1380
|
+
click.option("--days", type=int, default=None, help="Only the last N days"),
|
|
1381
|
+
click.option("--project", default=None, help="Project name contains this text"),
|
|
1382
|
+
click.option("--provider", "-p", default=None, help="Only this provider (claude-code, opencode, ...)"),
|
|
1383
|
+
click.option("--limit", "-n", type=int, default=20, show_default=True, help="Max rows"),
|
|
1384
|
+
click.option("--json", "as_json", is_flag=True, help="Print JSON instead of a table"),
|
|
1385
|
+
]
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _filter_opts(fn):
|
|
1389
|
+
for opt in reversed(_FILTER_OPTS):
|
|
1390
|
+
fn = opt(fn)
|
|
1391
|
+
return fn
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
@cli.command("tools")
|
|
1395
|
+
@click.option("--agent", "agents", multiple=True, help="Only tools called by this agent (repeatable)")
|
|
1396
|
+
@click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
|
|
1397
|
+
@_filter_opts
|
|
1398
|
+
def tools_cmd(agents, cloud_mode, days, project, provider, limit, as_json):
|
|
1399
|
+
"""Show which tools are used, how often, and how often they fail.
|
|
1400
|
+
|
|
1401
|
+
\b
|
|
1402
|
+
spooling tools top tools across all traces
|
|
1403
|
+
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
|
|
1406
|
+
"""
|
|
1407
|
+
if cloud_mode:
|
|
1408
|
+
_reject_cloud_filters(agent=agents, days=days, project=project, provider=provider)
|
|
1409
|
+
from spooling.cloud import cloud_tools
|
|
1410
|
+
rows = cloud_tools(limit)
|
|
1411
|
+
if as_json:
|
|
1412
|
+
return _emit_json(rows)
|
|
1413
|
+
table = Table(title="Tools (cloud)", show_lines=False)
|
|
1414
|
+
table.add_column("Tool", style="cyan")
|
|
1415
|
+
table.add_column("Uses", justify="right")
|
|
1416
|
+
table.add_column("Sessions", justify="right")
|
|
1417
|
+
table.add_column("Errors", justify="right")
|
|
1418
|
+
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]")
|
|
1422
|
+
return
|
|
1423
|
+
|
|
1424
|
+
from spooling.insights import tool_stats
|
|
1425
|
+
rows = tool_stats(days=days, project=project, provider=provider, agents=list(agents) or None, limit=limit)
|
|
1426
|
+
if as_json:
|
|
1427
|
+
return _emit_json(rows)
|
|
1428
|
+
if not rows:
|
|
1429
|
+
console.print("[yellow]No tool data found. Run 'spooling sync' first"
|
|
1430
|
+
+ (" (agent filters need traces that contain agent spans)." if agents else ".") + "[/yellow]")
|
|
1431
|
+
return
|
|
1432
|
+
table = Table(title="Tools" + (f" called by {', '.join(agents)}" if agents else ""), show_lines=False)
|
|
1433
|
+
table.add_column("Tool", style="cyan")
|
|
1434
|
+
table.add_column("Uses", justify="right")
|
|
1435
|
+
table.add_column("Traces", justify="right")
|
|
1436
|
+
table.add_column("Errors", justify="right")
|
|
1437
|
+
table.add_column("Avg time", justify="right", style="dim")
|
|
1438
|
+
for r in rows:
|
|
1439
|
+
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"]))
|
|
1441
|
+
console.print(table)
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
@cli.command("agents")
|
|
1445
|
+
@click.option("--tool", "tools", multiple=True, help="Only agents that called this tool (repeatable)")
|
|
1446
|
+
@click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
|
|
1447
|
+
@_filter_opts
|
|
1448
|
+
def agents_cmd(tools, cloud_mode, days, project, provider, limit, as_json):
|
|
1449
|
+
"""Show which agents (subagent types) ran, how often, and what they cost.
|
|
1450
|
+
|
|
1451
|
+
\b
|
|
1452
|
+
spooling agents agents across all traces
|
|
1453
|
+
spooling agents --tool Bash agents whose runs called Bash
|
|
1454
|
+
spooling agents --days 30 --json
|
|
1455
|
+
spooling agents --cloud workspace-wide, from Spooling Cloud
|
|
1456
|
+
"""
|
|
1457
|
+
if cloud_mode:
|
|
1458
|
+
_reject_cloud_filters(tool=tools, days=days, project=project, provider=provider)
|
|
1459
|
+
from spooling.cloud import cloud_agents
|
|
1460
|
+
rows = cloud_agents()[:limit]
|
|
1461
|
+
if as_json:
|
|
1462
|
+
return _emit_json(rows)
|
|
1463
|
+
table = Table(title="Agents (cloud)", show_lines=False)
|
|
1464
|
+
table.add_column("Agent", style="cyan")
|
|
1465
|
+
table.add_column("Runs", justify="right")
|
|
1466
|
+
for r in rows:
|
|
1467
|
+
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]")
|
|
1469
|
+
return
|
|
1470
|
+
|
|
1471
|
+
from spooling.insights import agent_stats
|
|
1472
|
+
rows = agent_stats(days=days, project=project, provider=provider, tools=list(tools) or None, limit=limit)
|
|
1473
|
+
if as_json:
|
|
1474
|
+
return _emit_json(rows)
|
|
1475
|
+
if not rows:
|
|
1476
|
+
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]"
|
|
1480
|
+
)
|
|
1481
|
+
return
|
|
1482
|
+
table = Table(title="Agents" + (f" that called {', '.join(tools)}" if tools else ""), show_lines=False)
|
|
1483
|
+
table.add_column("Agent", style="cyan")
|
|
1484
|
+
table.add_column("Runs", justify="right")
|
|
1485
|
+
table.add_column("Traces", justify="right")
|
|
1486
|
+
table.add_column("Tokens", justify="right")
|
|
1487
|
+
table.add_column("Est. Cost", justify="right")
|
|
1488
|
+
table.add_column("Avg time", justify="right", style="dim")
|
|
1489
|
+
for r in rows:
|
|
1490
|
+
table.add_row(r["name"], _fmt_num(r["runs"]), _fmt_num(r["traces"]), _fmt_num(r["tokens"]),
|
|
1491
|
+
f"${r['cost']:.2f}", _fmt_ms(r["avg_ms"]))
|
|
1492
|
+
console.print(table)
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
@cli.command("traces")
|
|
1496
|
+
@click.argument("query", required=False)
|
|
1497
|
+
@click.option("--agent", "agents", multiple=True, help="Only traces that ran this agent (repeatable)")
|
|
1498
|
+
@click.option("--tool", "tools", multiple=True, help="Only traces that used this tool (repeatable)")
|
|
1499
|
+
@click.option("--session", default=None, help="Session id (or unique prefix)")
|
|
1500
|
+
@_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.
|
|
1503
|
+
|
|
1504
|
+
QUERY matches title, project, session id, trace id or an agent name.
|
|
1505
|
+
|
|
1506
|
+
\b
|
|
1507
|
+
spooling traces latest traces
|
|
1508
|
+
spooling traces ses_10dd find by session id
|
|
1509
|
+
spooling traces --agent explore traces that ran the explore agent
|
|
1510
|
+
spooling traces --tool Bash --days 7 traces that used Bash this week
|
|
1511
|
+
"""
|
|
1512
|
+
from spooling.insights import list_traces
|
|
1513
|
+
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)
|
|
1515
|
+
if as_json:
|
|
1516
|
+
return _emit_json(rows)
|
|
1517
|
+
if not rows:
|
|
1518
|
+
console.print("[yellow]No traces match. Traces are created by 'spooling sync'.[/yellow]")
|
|
1519
|
+
return
|
|
1520
|
+
table = Table(title=f"Traces ({len(rows)})", show_lines=False, pad_edge=False)
|
|
1521
|
+
table.add_column("Date", style="dim", no_wrap=True)
|
|
1522
|
+
table.add_column("Title", style="cyan", max_width=22, overflow="ellipsis", no_wrap=True)
|
|
1523
|
+
table.add_column("Session", style="dim", no_wrap=True)
|
|
1524
|
+
table.add_column("Agents", max_width=12, overflow="ellipsis", no_wrap=True)
|
|
1525
|
+
table.add_column("Spans", justify="right", no_wrap=True)
|
|
1526
|
+
table.add_column("Tools", justify="right", no_wrap=True)
|
|
1527
|
+
table.add_column("Cost", justify="right", no_wrap=True)
|
|
1528
|
+
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
|
+
table.add_row(
|
|
1532
|
+
str(r["started_at"] or "")[5:10],
|
|
1533
|
+
r["title"] or "(untitled)",
|
|
1534
|
+
(r["session_id"] or "")[:10],
|
|
1535
|
+
ag_txt,
|
|
1536
|
+
_fmt_num(r["span_count"]),
|
|
1537
|
+
_fmt_num(r["tool_count"]),
|
|
1538
|
+
f"${float(r['total_cost_usd'] or 0):.2f}",
|
|
1539
|
+
)
|
|
1540
|
+
console.print(table)
|
|
1541
|
+
|
|
1542
|
+
|
|
1342
1543
|
# ---------------------------------------------------------------------------
|
|
1343
1544
|
# Token management
|
|
1344
1545
|
# ---------------------------------------------------------------------------
|
|
@@ -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", [])
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Agent / tool / trace insights over the local span data.
|
|
2
|
+
|
|
3
|
+
These power ``spooling tools``, ``spooling agents`` and ``spooling traces``.
|
|
4
|
+
|
|
5
|
+
Naming matches the Spooling web app so numbers line up between the two:
|
|
6
|
+
|
|
7
|
+
* an agent is its ``agent_type`` (the subagent type) when set, else the span name
|
|
8
|
+
* a tool is its ``tool_name`` when set, else the span name
|
|
9
|
+
|
|
10
|
+
Filter semantics:
|
|
11
|
+
|
|
12
|
+
* ``tools --agent A`` tools called *by* agent A (inside its subtree)
|
|
13
|
+
* ``agents --tool T`` agents whose runs called tool T (inside their subtree)
|
|
14
|
+
* ``traces`` trace-level, like the Traces page: a trace matches an
|
|
15
|
+
agent / tool filter if it contains any such span
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from spooling.db import get_connection
|
|
21
|
+
from spooling.stats import _days_ago
|
|
22
|
+
|
|
23
|
+
AGENT_LABEL = "COALESCE(NULLIF({a}.agent_type, ''), {a}.name)"
|
|
24
|
+
TOOL_LABEL = "COALESCE(NULLIF({a}.tool_name, ''), {a}.name)"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _agent(alias: str = "sp") -> str:
|
|
28
|
+
return AGENT_LABEL.format(a=alias)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _tool(alias: str = "sp") -> str:
|
|
32
|
+
return TOOL_LABEL.format(a=alias)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _marks(n: int) -> str:
|
|
36
|
+
return ",".join("?" * n)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _trace_filters(
|
|
40
|
+
*,
|
|
41
|
+
days: int | None,
|
|
42
|
+
project: str | None,
|
|
43
|
+
provider: str | None,
|
|
44
|
+
alias: str = "t",
|
|
45
|
+
) -> tuple[list[str], list]:
|
|
46
|
+
"""WHERE fragments restricting the ``traces`` table (alias ``t``)."""
|
|
47
|
+
where: list[str] = []
|
|
48
|
+
params: list = []
|
|
49
|
+
if days:
|
|
50
|
+
where.append(f"{alias}.started_at >= ?")
|
|
51
|
+
params.append(_days_ago(days))
|
|
52
|
+
if project:
|
|
53
|
+
where.append(f"{alias}.project LIKE ?")
|
|
54
|
+
params.append(f"%{project}%")
|
|
55
|
+
if provider:
|
|
56
|
+
where.append(f"{alias}.provider_id = ?")
|
|
57
|
+
params.append(provider)
|
|
58
|
+
return where, params
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# tools
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def tool_stats(
|
|
66
|
+
*,
|
|
67
|
+
days: int | None = None,
|
|
68
|
+
project: str | None = None,
|
|
69
|
+
provider: str | None = None,
|
|
70
|
+
agents: list[str] | None = None,
|
|
71
|
+
limit: int = 20,
|
|
72
|
+
) -> list[dict]:
|
|
73
|
+
"""Per-tool usage: calls, traces it appeared in, errors, average duration."""
|
|
74
|
+
where, params = _trace_filters(days=days, project=project, provider=provider)
|
|
75
|
+
where.insert(0, "sp.kind = 'tool'")
|
|
76
|
+
|
|
77
|
+
cte = ""
|
|
78
|
+
if agents:
|
|
79
|
+
# restrict to tool spans that live under one of the named agents
|
|
80
|
+
cte = f"""WITH RECURSIVE sub(id) AS (
|
|
81
|
+
SELECT sp.id FROM spans sp WHERE sp.kind = 'agent' AND {_agent()} IN ({_marks(len(agents))})
|
|
82
|
+
UNION
|
|
83
|
+
SELECT c.id FROM spans c JOIN sub ON c.parent_id = sub.id
|
|
84
|
+
)"""
|
|
85
|
+
where.append("sp.id IN (SELECT id FROM sub)")
|
|
86
|
+
params = list(agents) + params
|
|
87
|
+
|
|
88
|
+
sql = f"""{cte}
|
|
89
|
+
SELECT {_tool()} AS name,
|
|
90
|
+
COUNT(*) AS uses,
|
|
91
|
+
COUNT(DISTINCT sp.trace_id) AS traces,
|
|
92
|
+
COALESCE(SUM(sp.tool_is_error), 0) AS errors,
|
|
93
|
+
AVG(NULLIF(sp.duration_ms, 0)) AS avg_ms
|
|
94
|
+
FROM spans sp
|
|
95
|
+
JOIN traces t ON t.id = sp.trace_id
|
|
96
|
+
WHERE {' AND '.join(where)}
|
|
97
|
+
GROUP BY 1
|
|
98
|
+
ORDER BY uses DESC, name ASC
|
|
99
|
+
LIMIT ?"""
|
|
100
|
+
conn = get_connection()
|
|
101
|
+
try:
|
|
102
|
+
rows = conn.execute(sql, params + [limit]).fetchall()
|
|
103
|
+
finally:
|
|
104
|
+
conn.close()
|
|
105
|
+
return [
|
|
106
|
+
{
|
|
107
|
+
"name": r["name"],
|
|
108
|
+
"uses": int(r["uses"]),
|
|
109
|
+
"traces": int(r["traces"]),
|
|
110
|
+
"errors": int(r["errors"] or 0),
|
|
111
|
+
"avg_ms": float(r["avg_ms"]) if r["avg_ms"] is not None else None,
|
|
112
|
+
}
|
|
113
|
+
for r in rows
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# agents
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def agent_stats(
|
|
122
|
+
*,
|
|
123
|
+
days: int | None = None,
|
|
124
|
+
project: str | None = None,
|
|
125
|
+
provider: str | None = None,
|
|
126
|
+
tools: list[str] | None = None,
|
|
127
|
+
limit: int = 20,
|
|
128
|
+
) -> list[dict]:
|
|
129
|
+
"""Per-agent usage: runs, traces it appeared in, tokens, cost."""
|
|
130
|
+
where, params = _trace_filters(days=days, project=project, provider=provider)
|
|
131
|
+
where.insert(0, "sp.kind = 'agent'")
|
|
132
|
+
|
|
133
|
+
cte = ""
|
|
134
|
+
if tools:
|
|
135
|
+
# restrict to agent spans that have one of the named tools somewhere beneath them
|
|
136
|
+
cte = f"""WITH RECURSIVE up(id) AS (
|
|
137
|
+
SELECT sp.parent_id FROM spans sp
|
|
138
|
+
WHERE sp.kind = 'tool' AND {_tool()} IN ({_marks(len(tools))}) AND sp.parent_id IS NOT NULL
|
|
139
|
+
UNION
|
|
140
|
+
SELECT p.parent_id FROM spans p JOIN up ON p.id = up.id WHERE p.parent_id IS NOT NULL
|
|
141
|
+
)"""
|
|
142
|
+
where.append("sp.id IN (SELECT id FROM up)")
|
|
143
|
+
params = list(tools) + params
|
|
144
|
+
|
|
145
|
+
sql = f"""{cte}
|
|
146
|
+
SELECT {_agent()} AS name,
|
|
147
|
+
COUNT(*) AS runs,
|
|
148
|
+
COUNT(DISTINCT sp.trace_id) AS traces,
|
|
149
|
+
COALESCE(SUM(sp.input_tokens), 0) + COALESCE(SUM(sp.output_tokens), 0) AS tokens,
|
|
150
|
+
COALESCE(SUM(sp.cost_usd), 0) AS cost,
|
|
151
|
+
AVG(NULLIF(sp.duration_ms, 0)) AS avg_ms
|
|
152
|
+
FROM spans sp
|
|
153
|
+
JOIN traces t ON t.id = sp.trace_id
|
|
154
|
+
WHERE {' AND '.join(where)}
|
|
155
|
+
GROUP BY 1
|
|
156
|
+
ORDER BY runs DESC, name ASC
|
|
157
|
+
LIMIT ?"""
|
|
158
|
+
conn = get_connection()
|
|
159
|
+
try:
|
|
160
|
+
rows = conn.execute(sql, params + [limit]).fetchall()
|
|
161
|
+
finally:
|
|
162
|
+
conn.close()
|
|
163
|
+
return [
|
|
164
|
+
{
|
|
165
|
+
"name": r["name"],
|
|
166
|
+
"runs": int(r["runs"]),
|
|
167
|
+
"traces": int(r["traces"]),
|
|
168
|
+
"tokens": int(r["tokens"] or 0),
|
|
169
|
+
"cost": float(r["cost"] or 0),
|
|
170
|
+
"avg_ms": float(r["avg_ms"]) if r["avg_ms"] is not None else None,
|
|
171
|
+
}
|
|
172
|
+
for r in rows
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# traces
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def list_traces(
|
|
181
|
+
*,
|
|
182
|
+
query: str | None = None,
|
|
183
|
+
agents: list[str] | None = None,
|
|
184
|
+
tools: list[str] | None = None,
|
|
185
|
+
session: str | None = None,
|
|
186
|
+
days: int | None = None,
|
|
187
|
+
project: str | None = None,
|
|
188
|
+
provider: str | None = None,
|
|
189
|
+
limit: int = 20,
|
|
190
|
+
) -> list[dict]:
|
|
191
|
+
"""Traces, newest first, each with its distinct agents.
|
|
192
|
+
|
|
193
|
+
``query`` matches title, project, session id, trace id or an agent name
|
|
194
|
+
(the same fields the Traces page searches).
|
|
195
|
+
"""
|
|
196
|
+
where, params = _trace_filters(days=days, project=project, provider=provider)
|
|
197
|
+
|
|
198
|
+
if session:
|
|
199
|
+
where.append("t.session_id LIKE ?")
|
|
200
|
+
params.append(f"{session}%")
|
|
201
|
+
if query and query.strip():
|
|
202
|
+
like = f"%{query.strip()}%"
|
|
203
|
+
where.append(
|
|
204
|
+
f"""(COALESCE(t.title, s.title) LIKE ? OR COALESCE(t.project, s.project) LIKE ?
|
|
205
|
+
OR t.session_id LIKE ? OR t.id LIKE ?
|
|
206
|
+
OR EXISTS (SELECT 1 FROM spans sp WHERE sp.trace_id = t.id AND sp.kind = 'agent'
|
|
207
|
+
AND {_agent()} LIKE ?))"""
|
|
208
|
+
)
|
|
209
|
+
params.extend([like] * 5)
|
|
210
|
+
if agents:
|
|
211
|
+
where.append(
|
|
212
|
+
f"""EXISTS (SELECT 1 FROM spans sp WHERE sp.trace_id = t.id AND sp.kind = 'agent'
|
|
213
|
+
AND {_agent()} IN ({_marks(len(agents))}))"""
|
|
214
|
+
)
|
|
215
|
+
params.extend(agents)
|
|
216
|
+
if tools:
|
|
217
|
+
where.append(
|
|
218
|
+
f"""EXISTS (SELECT 1 FROM spans sp WHERE sp.trace_id = t.id AND sp.kind = 'tool'
|
|
219
|
+
AND {_tool()} IN ({_marks(len(tools))}))"""
|
|
220
|
+
)
|
|
221
|
+
params.extend(tools)
|
|
222
|
+
|
|
223
|
+
sql = f"""
|
|
224
|
+
SELECT t.id, t.session_id, t.provider_id,
|
|
225
|
+
COALESCE(t.project, s.project) AS project,
|
|
226
|
+
COALESCE(t.title, s.title) AS title,
|
|
227
|
+
t.started_at, t.duration_ms, t.span_count, t.agent_count, t.tool_count,
|
|
228
|
+
t.error_count, t.total_input_tokens, t.total_output_tokens,
|
|
229
|
+
t.total_cost_usd, t.model
|
|
230
|
+
FROM traces t
|
|
231
|
+
LEFT JOIN sessions s ON s.id = t.session_id
|
|
232
|
+
{('WHERE ' + ' AND '.join(where)) if where else ''}
|
|
233
|
+
ORDER BY t.started_at DESC
|
|
234
|
+
LIMIT ?"""
|
|
235
|
+
conn = get_connection()
|
|
236
|
+
try:
|
|
237
|
+
rows = [dict(r) for r in conn.execute(sql, params + [limit]).fetchall()]
|
|
238
|
+
if rows:
|
|
239
|
+
ids = [r["id"] for r in rows]
|
|
240
|
+
by_trace: dict[str, list[str]] = {i: [] for i in ids}
|
|
241
|
+
for a in conn.execute(
|
|
242
|
+
f"""SELECT DISTINCT sp.trace_id AS trace_id, {_agent()} AS name
|
|
243
|
+
FROM spans sp
|
|
244
|
+
WHERE sp.kind = 'agent' AND sp.trace_id IN ({_marks(len(ids))})
|
|
245
|
+
ORDER BY name""",
|
|
246
|
+
ids,
|
|
247
|
+
).fetchall():
|
|
248
|
+
by_trace[a["trace_id"]].append(a["name"])
|
|
249
|
+
for r in rows:
|
|
250
|
+
r["agents"] = by_trace[r["id"]]
|
|
251
|
+
finally:
|
|
252
|
+
conn.close()
|
|
253
|
+
return rows
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spooling
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.11
|
|
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,29 @@ 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 (for example Claude Code's `Task` tool), so `spooling agents` can be empty for other providers.
|
|
180
|
+
|
|
158
181
|
### `spooling search <query>`
|
|
159
182
|
|
|
160
183
|
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`.
|
|
@@ -12,6 +12,7 @@ spooling/embeddings.py
|
|
|
12
12
|
spooling/evals.py
|
|
13
13
|
spooling/experiments.py
|
|
14
14
|
spooling/ingest.py
|
|
15
|
+
spooling/insights.py
|
|
15
16
|
spooling/mcp_server.py
|
|
16
17
|
spooling/optimize.py
|
|
17
18
|
spooling/parser.py
|
|
@@ -65,6 +66,7 @@ spooling/providers/session_file.py
|
|
|
65
66
|
spooling/providers/windsurf.py
|
|
66
67
|
tests/test_claude_code_provider.py
|
|
67
68
|
tests/test_cloud_errors.py
|
|
69
|
+
tests/test_insights.py
|
|
68
70
|
tests/test_mcp_auth_middleware.py
|
|
69
71
|
tests/test_session_ids.py
|
|
70
72
|
tests/test_storage_optimizations.py
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Tests for `spooling tools`, `spooling agents` and `spooling traces`
|
|
2
|
+
(spooling.insights + the CLI commands)."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from click.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from spooling import db as db_mod
|
|
11
|
+
from spooling import insights
|
|
12
|
+
from spooling.cli import cli
|
|
13
|
+
|
|
14
|
+
NOW = datetime.now(timezone.utc)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def conn(tmp_path, monkeypatch):
|
|
19
|
+
monkeypatch.setattr(db_mod, "DB_PATH", tmp_path / "spooling.db")
|
|
20
|
+
c = db_mod.get_connection()
|
|
21
|
+
yield c
|
|
22
|
+
c.close()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def add_trace(conn, tid, sid, *, title, days_ago=0, project="proj", provider="claude-code"):
|
|
26
|
+
started = (NOW - timedelta(days=days_ago)).isoformat()
|
|
27
|
+
conn.execute(
|
|
28
|
+
"INSERT INTO sessions (id, provider_id, project, title, started_at) VALUES (?,?,?,?,?)",
|
|
29
|
+
(sid, provider, project, title, started),
|
|
30
|
+
)
|
|
31
|
+
conn.execute(
|
|
32
|
+
"INSERT INTO traces (id, session_id, provider_id, project, title, started_at) VALUES (?,?,?,?,?,?)",
|
|
33
|
+
(tid, sid, provider, project, title, started),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def add_span(conn, sid, tid, kind, *, parent=None, name=None, tool=None, agent=None,
|
|
38
|
+
err=0, ms=100, tokens=0, cost=0.0):
|
|
39
|
+
conn.execute(
|
|
40
|
+
"""INSERT INTO spans (id, trace_id, parent_id, kind, name, tool_name, agent_type,
|
|
41
|
+
tool_is_error, duration_ms, input_tokens, output_tokens, cost_usd)
|
|
42
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,0,?)""",
|
|
43
|
+
(sid, tid, parent, kind, name or tool or agent or kind, tool, agent, err, ms, tokens, cost),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def seeded(conn):
|
|
49
|
+
# trace A: root -> explore agent (Read x2, Grep) ; root -> Bash (top level, one error)
|
|
50
|
+
add_trace(conn, "tA", "ses_aaaa1111", title="Fix login bug", days_ago=1)
|
|
51
|
+
add_span(conn, "A-root", "tA", "session")
|
|
52
|
+
add_span(conn, "A-ex", "tA", "agent", parent="A-root", name="agent:explore", agent="explore", tokens=100, cost=0.5)
|
|
53
|
+
add_span(conn, "A-r1", "tA", "tool", parent="A-ex", tool="Read", ms=100)
|
|
54
|
+
add_span(conn, "A-r2", "tA", "tool", parent="A-ex", tool="Read", ms=300)
|
|
55
|
+
add_span(conn, "A-g1", "tA", "tool", parent="A-ex", tool="Grep")
|
|
56
|
+
add_span(conn, "A-b1", "tA", "tool", parent="A-root", tool="Bash", err=1)
|
|
57
|
+
# trace B (older): root -> build agent -> Bash ; agent has no agent_type, falls back to name
|
|
58
|
+
add_trace(conn, "tB", "ses_bbbb2222", title="Refactor billing", days_ago=40, project="other", provider="opencode")
|
|
59
|
+
add_span(conn, "B-root", "tB", "session")
|
|
60
|
+
add_span(conn, "B-bd", "tB", "agent", parent="B-root", name="build", agent=None, tokens=50, cost=0.25)
|
|
61
|
+
add_span(conn, "B-b1", "tB", "tool", parent="B-bd", tool="Bash")
|
|
62
|
+
# trace C: no agents at all
|
|
63
|
+
add_trace(conn, "tC", "ses_cccc3333", title="Quick question", days_ago=2)
|
|
64
|
+
add_span(conn, "C-root", "tC", "session")
|
|
65
|
+
add_span(conn, "C-r1", "tC", "tool", parent="C-root", tool="Read")
|
|
66
|
+
conn.commit()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def by_name(rows):
|
|
70
|
+
return {r["name"]: r for r in rows}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestToolStats:
|
|
74
|
+
def test_counts_errors_and_traces(self, seeded):
|
|
75
|
+
t = by_name(insights.tool_stats())
|
|
76
|
+
assert t["Read"]["uses"] == 3 and t["Read"]["traces"] == 2
|
|
77
|
+
assert t["Bash"]["uses"] == 2 and t["Bash"]["errors"] == 1
|
|
78
|
+
assert t["Read"]["avg_ms"] == pytest.approx(166.67, abs=0.1)
|
|
79
|
+
|
|
80
|
+
def test_sorted_by_uses_and_limited(self, seeded):
|
|
81
|
+
rows = insights.tool_stats(limit=1)
|
|
82
|
+
assert [r["name"] for r in rows] == ["Read"]
|
|
83
|
+
|
|
84
|
+
def test_agent_filter_is_subtree_only(self, seeded):
|
|
85
|
+
t = by_name(insights.tool_stats(agents=["explore"]))
|
|
86
|
+
assert set(t) == {"Read", "Grep"} # top-level Bash and trace C's Read excluded
|
|
87
|
+
assert t["Read"]["uses"] == 2
|
|
88
|
+
|
|
89
|
+
def test_agent_filter_falls_back_to_span_name(self, seeded):
|
|
90
|
+
assert set(by_name(insights.tool_stats(agents=["build"]))) == {"Bash"}
|
|
91
|
+
|
|
92
|
+
def test_days_project_provider_filters(self, seeded):
|
|
93
|
+
assert by_name(insights.tool_stats(days=7))["Bash"]["uses"] == 1 # trace B is 40 days old
|
|
94
|
+
other = by_name(insights.tool_stats(project="other"))
|
|
95
|
+
assert set(other) == {"Bash"} and other["Bash"]["uses"] == 1
|
|
96
|
+
assert by_name(insights.tool_stats(provider="opencode"))["Bash"]["uses"] == 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestAgentStats:
|
|
100
|
+
def test_runs_tokens_cost(self, seeded):
|
|
101
|
+
a = by_name(insights.agent_stats())
|
|
102
|
+
assert set(a) == {"explore", "build"}
|
|
103
|
+
assert a["explore"]["runs"] == 1 and a["explore"]["tokens"] == 100
|
|
104
|
+
assert a["explore"]["cost"] == pytest.approx(0.5)
|
|
105
|
+
|
|
106
|
+
def test_tool_filter_is_subtree_only(self, seeded):
|
|
107
|
+
assert set(by_name(insights.agent_stats(tools=["Grep"]))) == {"explore"}
|
|
108
|
+
assert set(by_name(insights.agent_stats(tools=["Bash"]))) == {"build"} # explore's Bash is not under it
|
|
109
|
+
|
|
110
|
+
def test_days_filter(self, seeded):
|
|
111
|
+
assert set(by_name(insights.agent_stats(days=7))) == {"explore"}
|
|
112
|
+
|
|
113
|
+
def test_none_when_no_agents(self, conn):
|
|
114
|
+
add_trace(conn, "t1", "s1", title="x")
|
|
115
|
+
add_span(conn, "r", "t1", "session")
|
|
116
|
+
conn.commit()
|
|
117
|
+
assert insights.agent_stats() == []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestListTraces:
|
|
121
|
+
def test_newest_first_with_agents(self, seeded):
|
|
122
|
+
rows = insights.list_traces()
|
|
123
|
+
assert [r["id"] for r in rows] == ["tA", "tC", "tB"]
|
|
124
|
+
assert by_name([{"name": r["id"], **r} for r in rows])["tA"]["agents"] == ["explore"]
|
|
125
|
+
assert rows[1]["agents"] == []
|
|
126
|
+
|
|
127
|
+
def test_session_id_search_full_prefix_and_substring(self, seeded):
|
|
128
|
+
assert [r["id"] for r in insights.list_traces(query="ses_bbbb2222")] == ["tB"]
|
|
129
|
+
assert [r["id"] for r in insights.list_traces(query="bbbb")] == ["tB"]
|
|
130
|
+
assert [r["id"] for r in insights.list_traces(session="ses_aaaa")] == ["tA"]
|
|
131
|
+
|
|
132
|
+
def test_query_matches_title_and_agent_name(self, seeded):
|
|
133
|
+
assert [r["id"] for r in insights.list_traces(query="billing")] == ["tB"]
|
|
134
|
+
assert [r["id"] for r in insights.list_traces(query="explore")] == ["tA"]
|
|
135
|
+
|
|
136
|
+
def test_agent_and_tool_filters_are_trace_level(self, seeded):
|
|
137
|
+
assert [r["id"] for r in insights.list_traces(agents=["build"])] == ["tB"]
|
|
138
|
+
assert [r["id"] for r in insights.list_traces(agents=["build", "explore"])] == ["tA", "tB"]
|
|
139
|
+
assert [r["id"] for r in insights.list_traces(tools=["Grep"])] == ["tA"]
|
|
140
|
+
assert [r["id"] for r in insights.list_traces(agents=["explore"], tools=["Bash"])] == ["tA"]
|
|
141
|
+
assert insights.list_traces(agents=["explore"], tools=["Nope"]) == []
|
|
142
|
+
|
|
143
|
+
def test_other_filters_and_limit(self, seeded):
|
|
144
|
+
assert [r["id"] for r in insights.list_traces(provider="opencode")] == ["tB"]
|
|
145
|
+
assert [r["id"] for r in insights.list_traces(days=7)] == ["tA", "tC"]
|
|
146
|
+
assert len(insights.list_traces(limit=1)) == 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TestCli:
|
|
150
|
+
def run(self, *args):
|
|
151
|
+
return CliRunner().invoke(cli, list(args))
|
|
152
|
+
|
|
153
|
+
def test_tools_table_and_json(self, seeded):
|
|
154
|
+
r = self.run("tools")
|
|
155
|
+
assert r.exit_code == 0 and "Read" in r.output and "Bash" in r.output
|
|
156
|
+
data = json.loads(self.run("tools", "--agent", "explore", "--json").output)
|
|
157
|
+
assert {d["name"] for d in data} == {"Read", "Grep"}
|
|
158
|
+
|
|
159
|
+
def test_agents_table_and_json(self, seeded):
|
|
160
|
+
assert "explore" in self.run("agents").output
|
|
161
|
+
data = json.loads(self.run("agents", "--tool", "Grep", "--json").output)
|
|
162
|
+
assert [d["name"] for d in data] == ["explore"]
|
|
163
|
+
|
|
164
|
+
def test_agents_empty_explains_why(self, conn):
|
|
165
|
+
r = self.run("agents")
|
|
166
|
+
assert r.exit_code == 0 and "No agent spans" in r.output
|
|
167
|
+
|
|
168
|
+
def test_traces_filters(self, seeded):
|
|
169
|
+
data = json.loads(self.run("traces", "--agent", "build", "--json").output)
|
|
170
|
+
assert [d["id"] for d in data] == ["tB"]
|
|
171
|
+
data = json.loads(self.run("traces", "ses_cccc", "--json").output)
|
|
172
|
+
assert [d["id"] for d in data] == ["tC"]
|
|
173
|
+
data = json.loads(self.run("traces", "--tool", "Grep", "--days", "7", "--json").output)
|
|
174
|
+
assert [d["id"] for d in data] == ["tA"]
|
|
175
|
+
|
|
176
|
+
def test_cloud_rejects_local_only_filters(self, seeded):
|
|
177
|
+
r = self.run("tools", "--cloud", "--days", "7")
|
|
178
|
+
assert r.exit_code == 2 and "can't be combined with --cloud" in r.output
|
|
179
|
+
r = self.run("agents", "--cloud", "--tool", "Bash")
|
|
180
|
+
assert r.exit_code == 2
|
|
181
|
+
|
|
182
|
+
def test_cloud_mode_renders_server_data(self, seeded, monkeypatch):
|
|
183
|
+
from spooling import cloud
|
|
184
|
+
monkeypatch.setattr(cloud, "cloud_tools", lambda limit=20: [
|
|
185
|
+
{"tool_name": "Bash", "uses": 12, "sessions": 3, "errors": 1}])
|
|
186
|
+
monkeypatch.setattr(cloud, "cloud_agents", lambda: [{"agent_type": "explore", "uses": 7}])
|
|
187
|
+
assert "Bash" in self.run("tools", "--cloud").output
|
|
188
|
+
assert json.loads(self.run("agents", "--cloud", "--json").output) == [{"agent_type": "explore", "uses": 7}]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TestCloudErrors:
|
|
192
|
+
def run(self, *args):
|
|
193
|
+
return CliRunner().invoke(cli, list(args))
|
|
194
|
+
|
|
195
|
+
def test_unreachable_server_exits_nonzero_with_reason(self, conn, monkeypatch):
|
|
196
|
+
import httpx
|
|
197
|
+
from spooling import cloud
|
|
198
|
+
monkeypatch.setattr(cloud, "_auth_headers", lambda: {"Authorization": "Bearer sk_x"})
|
|
199
|
+
|
|
200
|
+
def boom(*a, **k):
|
|
201
|
+
raise httpx.ConnectError("connection refused")
|
|
202
|
+
monkeypatch.setattr(cloud.httpx, "get", boom)
|
|
203
|
+
for cmd in (["tools", "--cloud"], ["agents", "--cloud"]):
|
|
204
|
+
r = self.run(*cmd)
|
|
205
|
+
assert r.exit_code == 1
|
|
206
|
+
assert "log in" not in r.output.lower() # a network error must not read as "log in"
|
|
207
|
+
|
|
208
|
+
def test_rejected_key_explained(self, conn, monkeypatch):
|
|
209
|
+
import httpx
|
|
210
|
+
from spooling import cloud
|
|
211
|
+
monkeypatch.setattr(cloud, "_auth_headers", lambda: {"Authorization": "Bearer sk_x"})
|
|
212
|
+
req = httpx.Request("GET", "https://api.example/v1/tools")
|
|
213
|
+
|
|
214
|
+
def reject(*a, **k):
|
|
215
|
+
return httpx.Response(401, text="bad key", request=req)
|
|
216
|
+
monkeypatch.setattr(cloud.httpx, "get", reject)
|
|
217
|
+
r = self.run("tools", "--cloud")
|
|
218
|
+
assert r.exit_code == 1 and "401" in r.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|