spooling 0.2.10__tar.gz → 0.2.12__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {spooling-0.2.10/spooling.egg-info → spooling-0.2.12}/PKG-INFO +28 -1
- {spooling-0.2.10 → spooling-0.2.12}/README.md +27 -0
- {spooling-0.2.10 → spooling-0.2.12}/pyproject.toml +1 -1
- {spooling-0.2.10 → spooling-0.2.12}/spooling/cli.py +205 -2
- {spooling-0.2.10 → spooling-0.2.12}/spooling/cloud.py +22 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/ingest.py +51 -4
- spooling-0.2.12/spooling/insights.py +253 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/parser.py +7 -1
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/base.py +5 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/claude_code.py +2 -0
- {spooling-0.2.10 → spooling-0.2.12/spooling.egg-info}/PKG-INFO +28 -1
- {spooling-0.2.10 → spooling-0.2.12}/spooling.egg-info/SOURCES.txt +3 -0
- spooling-0.2.12/tests/test_agent_spans.py +172 -0
- spooling-0.2.12/tests/test_insights.py +218 -0
- {spooling-0.2.10 → spooling-0.2.12}/LICENSE +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/setup.cfg +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/__init__.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/agent.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/classifiers.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/config.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/__init__.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/base.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/factory.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/mysql.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/connectors/types.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/db.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/embeddings.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/evals.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/experiments.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/mcp_server.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/optimize.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/pricing.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/__init__.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/antigravity.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/codex.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/copilot.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/cursor.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/gemini.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/github.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/gitlab.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/kiro.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/opencode.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/session_file.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/providers/windsurf.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/redact.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/remote_otel.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/schema.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/sdk.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/search.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/server.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/sessions.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/stats.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/subscription_pricing.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/tracing.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/tunnel.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/tunnel_auth.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling/watcher.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling.egg-info/requires.txt +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/tests/test_claude_code_provider.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/tests/test_cloud_errors.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/tests/test_mcp_auth_middleware.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/tests/test_session_ids.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/tests/test_storage_optimizations.py +0 -0
- {spooling-0.2.10 → spooling-0.2.12}/tests/test_tunnel_auth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spooling
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.12
|
|
4
4
|
Summary: Local session tracker and semantic search for AI coding assistants
|
|
5
5
|
Author: Parsed Analytics, Inc.
|
|
6
6
|
License: MIT
|
|
@@ -155,6 +155,33 @@ spooling stats --week # Weekly breakdown
|
|
|
155
155
|
spooling stats --days 30 # Last 30 days
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
+
### `spooling tools`, `spooling agents`, `spooling traces`
|
|
159
|
+
|
|
160
|
+
See which tools and agents your sessions use. These read the spans stored by `spooling sync`, and use the same agent/tool names as the web app (an agent is its subagent type, falling back to its span name).
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
spooling tools # Top tools: uses, traces, errors, avg time
|
|
164
|
+
spooling tools --agent explore # Tools called by the "explore" agent
|
|
165
|
+
spooling tools --days 7 --provider claude-code
|
|
166
|
+
|
|
167
|
+
spooling agents # Agents: runs, traces, tokens, cost
|
|
168
|
+
spooling agents --tool Bash # Agents whose runs called Bash
|
|
169
|
+
|
|
170
|
+
spooling traces # Latest traces, with the agents each ran
|
|
171
|
+
spooling traces ses_10dd # Search title, project, session id, trace id or agent name
|
|
172
|
+
spooling traces --agent explore --tool Bash --days 30
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n N`, and `--json` for scripting. `--agent` and `--tool` can be repeated (matches any).
|
|
176
|
+
|
|
177
|
+
`tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
|
|
178
|
+
|
|
179
|
+
`spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (currently Claude Code, via its `Agent` tool, previously called `Task`), so `spooling agents` is empty for other providers.
|
|
180
|
+
|
|
181
|
+
After upgrading, `spooling sync` re-reads your Claude Code sessions once to pick up agents. To force a re-read of any provider's files at any time, run `spooling sync --reparse` (optionally with `-p claude-code`).
|
|
182
|
+
|
|
183
|
+
Agent runs show duration and the prompt that started them. Their own tokens, cost and tool calls are not attributed yet, because Claude Code writes subagent activity to separate transcript files that Spooling does not read yet.
|
|
184
|
+
|
|
158
185
|
### `spooling search <query>`
|
|
159
186
|
|
|
160
187
|
Semantic search across all your session history using natural language. Each result shows the session title **and its session id**, so you can open it with `spooling session show`.
|
|
@@ -119,6 +119,33 @@ spooling stats --week # Weekly breakdown
|
|
|
119
119
|
spooling stats --days 30 # Last 30 days
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
### `spooling tools`, `spooling agents`, `spooling traces`
|
|
123
|
+
|
|
124
|
+
See which tools and agents your sessions use. These read the spans stored by `spooling sync`, and use the same agent/tool names as the web app (an agent is its subagent type, falling back to its span name).
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
spooling tools # Top tools: uses, traces, errors, avg time
|
|
128
|
+
spooling tools --agent explore # Tools called by the "explore" agent
|
|
129
|
+
spooling tools --days 7 --provider claude-code
|
|
130
|
+
|
|
131
|
+
spooling agents # Agents: runs, traces, tokens, cost
|
|
132
|
+
spooling agents --tool Bash # Agents whose runs called Bash
|
|
133
|
+
|
|
134
|
+
spooling traces # Latest traces, with the agents each ran
|
|
135
|
+
spooling traces ses_10dd # Search title, project, session id, trace id or agent name
|
|
136
|
+
spooling traces --agent explore --tool Bash --days 30
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n N`, and `--json` for scripting. `--agent` and `--tool` can be repeated (matches any).
|
|
140
|
+
|
|
141
|
+
`tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
|
|
142
|
+
|
|
143
|
+
`spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (currently Claude Code, via its `Agent` tool, previously called `Task`), so `spooling agents` is empty for other providers.
|
|
144
|
+
|
|
145
|
+
After upgrading, `spooling sync` re-reads your Claude Code sessions once to pick up agents. To force a re-read of any provider's files at any time, run `spooling sync --reparse` (optionally with `-p claude-code`).
|
|
146
|
+
|
|
147
|
+
Agent runs show duration and the prompt that started them. Their own tokens, cost and tool calls are not attributed yet, because Claude Code writes subagent activity to separate transcript files that Spooling does not read yet.
|
|
148
|
+
|
|
122
149
|
### `spooling search <query>`
|
|
123
150
|
|
|
124
151
|
Semantic search across all your session history using natural language. Each result shows the session title **and its session id**, so you can open it with `spooling session show`.
|
|
@@ -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()
|
|
@@ -1339,6 +1340,208 @@ def stats_providers():
|
|
|
1339
1340
|
console.print(table)
|
|
1340
1341
|
|
|
1341
1342
|
|
|
1343
|
+
# ---------------------------------------------------------------------------
|
|
1344
|
+
# Tools / agents / traces insights
|
|
1345
|
+
# ---------------------------------------------------------------------------
|
|
1346
|
+
|
|
1347
|
+
def _fmt_ms(ms) -> str:
|
|
1348
|
+
if ms is None:
|
|
1349
|
+
return "-"
|
|
1350
|
+
if ms < 1000:
|
|
1351
|
+
return f"{ms:.0f}ms"
|
|
1352
|
+
if ms < 60_000:
|
|
1353
|
+
return f"{ms / 1000:.1f}s"
|
|
1354
|
+
return f"{ms / 60_000:.1f}m"
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def _fmt_num(n) -> str:
|
|
1358
|
+
n = int(n or 0)
|
|
1359
|
+
if n >= 1_000_000:
|
|
1360
|
+
return f"{n / 1_000_000:.1f}M"
|
|
1361
|
+
if n >= 10_000:
|
|
1362
|
+
return f"{n / 1000:.1f}K"
|
|
1363
|
+
return f"{n:,}"
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _emit_json(data) -> None:
|
|
1367
|
+
import json
|
|
1368
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def _reject_cloud_filters(**filters) -> None:
|
|
1372
|
+
used = [f"--{k}" for k, v in filters.items() if v]
|
|
1373
|
+
if used:
|
|
1374
|
+
raise click.UsageError(
|
|
1375
|
+
f"{', '.join(used)} can't be combined with --cloud "
|
|
1376
|
+
"(the cloud endpoint only supports --limit). Drop --cloud to filter local data."
|
|
1377
|
+
)
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
_FILTER_OPTS = [
|
|
1381
|
+
click.option("--days", type=int, default=None, help="Only the last N days"),
|
|
1382
|
+
click.option("--project", default=None, help="Project name contains this text"),
|
|
1383
|
+
click.option("--provider", "-p", default=None, help="Only this provider (claude-code, opencode, ...)"),
|
|
1384
|
+
click.option("--limit", "-n", type=int, default=20, show_default=True, help="Max rows"),
|
|
1385
|
+
click.option("--json", "as_json", is_flag=True, help="Print JSON instead of a table"),
|
|
1386
|
+
]
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _filter_opts(fn):
|
|
1390
|
+
for opt in reversed(_FILTER_OPTS):
|
|
1391
|
+
fn = opt(fn)
|
|
1392
|
+
return fn
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
@cli.command("tools")
|
|
1396
|
+
@click.option("--agent", "agents", multiple=True, help="Only tools called by this agent (repeatable)")
|
|
1397
|
+
@click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
|
|
1398
|
+
@_filter_opts
|
|
1399
|
+
def tools_cmd(agents, cloud_mode, days, project, provider, limit, as_json):
|
|
1400
|
+
"""Show which tools are used, how often, and how often they fail.
|
|
1401
|
+
|
|
1402
|
+
\b
|
|
1403
|
+
spooling tools top tools across all traces
|
|
1404
|
+
spooling tools --agent explore tools called by the 'explore' agent
|
|
1405
|
+
spooling tools --days 7 --json
|
|
1406
|
+
spooling tools --cloud workspace-wide, from Spooling Cloud
|
|
1407
|
+
"""
|
|
1408
|
+
if cloud_mode:
|
|
1409
|
+
_reject_cloud_filters(agent=agents, days=days, project=project, provider=provider)
|
|
1410
|
+
from spooling.cloud import cloud_tools
|
|
1411
|
+
rows = cloud_tools(limit)
|
|
1412
|
+
if as_json:
|
|
1413
|
+
return _emit_json(rows)
|
|
1414
|
+
table = Table(title="Tools (cloud)", show_lines=False)
|
|
1415
|
+
table.add_column("Tool", style="cyan")
|
|
1416
|
+
table.add_column("Uses", justify="right")
|
|
1417
|
+
table.add_column("Sessions", justify="right")
|
|
1418
|
+
table.add_column("Errors", justify="right")
|
|
1419
|
+
for r in rows:
|
|
1420
|
+
table.add_row(r["tool_name"], _fmt_num(r["uses"]), _fmt_num(r["sessions"]),
|
|
1421
|
+
f"[red]{r['errors']}[/red]" if r["errors"] else "0")
|
|
1422
|
+
console.print(table if rows else "[yellow]No tool data in the cloud workspace yet.[/yellow]")
|
|
1423
|
+
return
|
|
1424
|
+
|
|
1425
|
+
from spooling.insights import tool_stats
|
|
1426
|
+
rows = tool_stats(days=days, project=project, provider=provider, agents=list(agents) or None, limit=limit)
|
|
1427
|
+
if as_json:
|
|
1428
|
+
return _emit_json(rows)
|
|
1429
|
+
if not rows:
|
|
1430
|
+
console.print("[yellow]No tool data found. Run 'spooling sync' first"
|
|
1431
|
+
+ (" (agent filters need traces that contain agent spans)." if agents else ".") + "[/yellow]")
|
|
1432
|
+
return
|
|
1433
|
+
table = Table(title="Tools" + (f" called by {', '.join(agents)}" if agents else ""), show_lines=False)
|
|
1434
|
+
table.add_column("Tool", style="cyan")
|
|
1435
|
+
table.add_column("Uses", justify="right")
|
|
1436
|
+
table.add_column("Traces", justify="right")
|
|
1437
|
+
table.add_column("Errors", justify="right")
|
|
1438
|
+
table.add_column("Avg time", justify="right", style="dim")
|
|
1439
|
+
for r in rows:
|
|
1440
|
+
table.add_row(r["name"], _fmt_num(r["uses"]), _fmt_num(r["traces"]),
|
|
1441
|
+
f"[red]{r['errors']}[/red]" if r["errors"] else "0", _fmt_ms(r["avg_ms"]))
|
|
1442
|
+
console.print(table)
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
@cli.command("agents")
|
|
1446
|
+
@click.option("--tool", "tools", multiple=True, help="Only agents that called this tool (repeatable)")
|
|
1447
|
+
@click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
|
|
1448
|
+
@_filter_opts
|
|
1449
|
+
def agents_cmd(tools, cloud_mode, days, project, provider, limit, as_json):
|
|
1450
|
+
"""Show which agents (subagent types) ran, how often, and what they cost.
|
|
1451
|
+
|
|
1452
|
+
\b
|
|
1453
|
+
spooling agents agents across all traces
|
|
1454
|
+
spooling agents --tool Bash agents whose runs called Bash
|
|
1455
|
+
spooling agents --days 30 --json
|
|
1456
|
+
spooling agents --cloud workspace-wide, from Spooling Cloud
|
|
1457
|
+
"""
|
|
1458
|
+
if cloud_mode:
|
|
1459
|
+
_reject_cloud_filters(tool=tools, days=days, project=project, provider=provider)
|
|
1460
|
+
from spooling.cloud import cloud_agents
|
|
1461
|
+
rows = cloud_agents()[:limit]
|
|
1462
|
+
if as_json:
|
|
1463
|
+
return _emit_json(rows)
|
|
1464
|
+
table = Table(title="Agents (cloud)", show_lines=False)
|
|
1465
|
+
table.add_column("Agent", style="cyan")
|
|
1466
|
+
table.add_column("Runs", justify="right")
|
|
1467
|
+
for r in rows:
|
|
1468
|
+
table.add_row(r["agent_type"], _fmt_num(r["uses"]))
|
|
1469
|
+
console.print(table if rows else "[yellow]No agent data in the cloud workspace yet.[/yellow]")
|
|
1470
|
+
return
|
|
1471
|
+
|
|
1472
|
+
from spooling.insights import agent_stats
|
|
1473
|
+
rows = agent_stats(days=days, project=project, provider=provider, tools=list(tools) or None, limit=limit)
|
|
1474
|
+
if as_json:
|
|
1475
|
+
return _emit_json(rows)
|
|
1476
|
+
if not rows:
|
|
1477
|
+
console.print(
|
|
1478
|
+
"[yellow]No agent spans found locally.[/yellow]\n"
|
|
1479
|
+
"[dim]Agent runs are recorded for providers that expose subagents (currently Claude Code, via its "
|
|
1480
|
+
"Agent/Task tool). Run 'spooling sync' after using one, or try 'spooling agents --cloud'.\n"
|
|
1481
|
+
"If you use Claude Code and still see this, run 'spooling sync --reparse -p claude-code'.[/dim]"
|
|
1482
|
+
)
|
|
1483
|
+
return
|
|
1484
|
+
table = Table(title="Agents" + (f" that called {', '.join(tools)}" if tools else ""), show_lines=False)
|
|
1485
|
+
table.add_column("Agent", style="cyan")
|
|
1486
|
+
table.add_column("Runs", justify="right")
|
|
1487
|
+
table.add_column("Traces", justify="right")
|
|
1488
|
+
table.add_column("Tokens", justify="right")
|
|
1489
|
+
table.add_column("Est. Cost", justify="right")
|
|
1490
|
+
table.add_column("Avg time", justify="right", style="dim")
|
|
1491
|
+
for r in rows:
|
|
1492
|
+
table.add_row(r["name"], _fmt_num(r["runs"]), _fmt_num(r["traces"]), _fmt_num(r["tokens"]),
|
|
1493
|
+
f"${r['cost']:.2f}", _fmt_ms(r["avg_ms"]))
|
|
1494
|
+
console.print(table)
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
@cli.command("traces")
|
|
1498
|
+
@click.argument("query", required=False)
|
|
1499
|
+
@click.option("--agent", "agents", multiple=True, help="Only traces that ran this agent (repeatable)")
|
|
1500
|
+
@click.option("--tool", "tools", multiple=True, help="Only traces that used this tool (repeatable)")
|
|
1501
|
+
@click.option("--session", default=None, help="Session id (or unique prefix)")
|
|
1502
|
+
@_filter_opts
|
|
1503
|
+
def traces_cmd(query, agents, tools, session, days, project, provider, limit, as_json):
|
|
1504
|
+
"""List traces, optionally filtered by agent, tool, or free text.
|
|
1505
|
+
|
|
1506
|
+
QUERY matches title, project, session id, trace id or an agent name.
|
|
1507
|
+
|
|
1508
|
+
\b
|
|
1509
|
+
spooling traces latest traces
|
|
1510
|
+
spooling traces ses_10dd find by session id
|
|
1511
|
+
spooling traces --agent explore traces that ran the explore agent
|
|
1512
|
+
spooling traces --tool Bash --days 7 traces that used Bash this week
|
|
1513
|
+
"""
|
|
1514
|
+
from spooling.insights import list_traces
|
|
1515
|
+
rows = list_traces(query=query, agents=list(agents) or None, tools=list(tools) or None,
|
|
1516
|
+
session=session, days=days, project=project, provider=provider, limit=limit)
|
|
1517
|
+
if as_json:
|
|
1518
|
+
return _emit_json(rows)
|
|
1519
|
+
if not rows:
|
|
1520
|
+
console.print("[yellow]No traces match. Traces are created by 'spooling sync'.[/yellow]")
|
|
1521
|
+
return
|
|
1522
|
+
table = Table(title=f"Traces ({len(rows)})", show_lines=False, pad_edge=False)
|
|
1523
|
+
table.add_column("Date", style="dim", no_wrap=True)
|
|
1524
|
+
table.add_column("Title", style="cyan", max_width=22, overflow="ellipsis", no_wrap=True)
|
|
1525
|
+
table.add_column("Session", style="dim", no_wrap=True)
|
|
1526
|
+
table.add_column("Agents", max_width=12, overflow="ellipsis", no_wrap=True)
|
|
1527
|
+
table.add_column("Spans", justify="right", no_wrap=True)
|
|
1528
|
+
table.add_column("Tools", justify="right", no_wrap=True)
|
|
1529
|
+
table.add_column("Cost", justify="right", no_wrap=True)
|
|
1530
|
+
for r in rows:
|
|
1531
|
+
ag = r["agents"]
|
|
1532
|
+
ag_txt = ", ".join(ag[:1]) + (f" +{len(ag) - 1}" if len(ag) > 1 else "") if ag else "-"
|
|
1533
|
+
table.add_row(
|
|
1534
|
+
str(r["started_at"] or "")[5:10],
|
|
1535
|
+
r["title"] or "(untitled)",
|
|
1536
|
+
(r["session_id"] or "")[:10],
|
|
1537
|
+
ag_txt,
|
|
1538
|
+
_fmt_num(r["span_count"]),
|
|
1539
|
+
_fmt_num(r["tool_count"]),
|
|
1540
|
+
f"${float(r['total_cost_usd'] or 0):.2f}",
|
|
1541
|
+
)
|
|
1542
|
+
console.print(table)
|
|
1543
|
+
|
|
1544
|
+
|
|
1342
1545
|
# ---------------------------------------------------------------------------
|
|
1343
1546
|
# Token management
|
|
1344
1547
|
# ---------------------------------------------------------------------------
|
|
@@ -1332,3 +1332,25 @@ def cloud_stats() -> dict | None:
|
|
|
1332
1332
|
return r.json()
|
|
1333
1333
|
except Exception:
|
|
1334
1334
|
return None
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _cloud_get(path: str, params: dict | None = None):
|
|
1338
|
+
"""GET a Spooling Cloud endpoint; on any failure print a plain-English reason and exit 1."""
|
|
1339
|
+
headers = _auth_headers()
|
|
1340
|
+
base = _api_base()
|
|
1341
|
+
try:
|
|
1342
|
+
r = httpx.get(f"{base}{path}", headers=headers, params=params, timeout=15)
|
|
1343
|
+
r.raise_for_status()
|
|
1344
|
+
return r.json()
|
|
1345
|
+
except Exception as e: # noqa: BLE001
|
|
1346
|
+
_fail(_explain_error(e, base))
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def cloud_tools(limit: int = 20) -> list[dict]:
|
|
1350
|
+
"""Per-tool usage across the cloud workspace (GET /v1/tools)."""
|
|
1351
|
+
return _cloud_get("/v1/tools", {"limit": limit})
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def cloud_agents() -> list[dict]:
|
|
1355
|
+
"""Per-agent-type usage across the cloud workspace (GET /v1/stats/agent-subtypes)."""
|
|
1356
|
+
return _cloud_get("/v1/stats/agent-subtypes").get("agent_subtypes", [])
|
|
@@ -458,8 +458,42 @@ def _sync_remote_provider(conn, provider, prov_info: dict, embed: bool) -> tuple
|
|
|
458
458
|
return total_sessions, total_messages, total_chunks
|
|
459
459
|
|
|
460
460
|
|
|
461
|
-
|
|
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
|