spooling 0.2.8__tar.gz → 0.2.9__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.8/spooling.egg-info → spooling-0.2.9}/PKG-INFO +6 -2
- {spooling-0.2.8 → spooling-0.2.9}/README.md +5 -1
- {spooling-0.2.8 → spooling-0.2.9}/pyproject.toml +1 -1
- {spooling-0.2.8 → spooling-0.2.9}/spooling/cli.py +1 -1
- {spooling-0.2.8 → spooling-0.2.9}/spooling/mcp_server.py +1 -1
- {spooling-0.2.8 → spooling-0.2.9}/spooling/parser.py +23 -4
- {spooling-0.2.8 → spooling-0.2.9}/spooling/pricing.py +1 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/__init__.py +2 -0
- spooling-0.2.9/spooling/providers/claude_code.py +65 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/server.py +7 -0
- {spooling-0.2.8 → spooling-0.2.9/spooling.egg-info}/PKG-INFO +6 -2
- {spooling-0.2.8 → spooling-0.2.9}/spooling.egg-info/SOURCES.txt +2 -0
- spooling-0.2.9/tests/test_claude_code_provider.py +229 -0
- {spooling-0.2.8 → spooling-0.2.9}/LICENSE +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/setup.cfg +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/__init__.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/agent.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/classifiers.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/cloud.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/config.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/__init__.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/base.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/factory.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/mysql.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/types.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/db.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/embeddings.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/evals.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/experiments.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/ingest.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/optimize.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/antigravity.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/base.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/codex.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/copilot.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/cursor.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/gemini.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/github.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/gitlab.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/kiro.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/opencode.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/session_file.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/windsurf.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/redact.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/remote_otel.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/schema.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/sdk.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/search.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/sessions.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/stats.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/subscription_pricing.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/tracing.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/tunnel.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/tunnel_auth.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling/watcher.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling.egg-info/requires.txt +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/tests/test_mcp_auth_middleware.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/tests/test_session_ids.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/tests/test_storage_optimizations.py +0 -0
- {spooling-0.2.8 → spooling-0.2.9}/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.9
|
|
4
4
|
Summary: Local session tracker and semantic search for AI coding assistants
|
|
5
5
|
Author: Parsed Analytics, Inc.
|
|
6
6
|
License: MIT
|
|
@@ -38,7 +38,7 @@ Dynamic: license-file
|
|
|
38
38
|
|
|
39
39
|
Local session tracker and semantic search for AI coding assistants.
|
|
40
40
|
|
|
41
|
-
Track your AI coding sessions across **OpenAI Codex CLI**, **GitHub Copilot**, **Cursor**, **Windsurf**, **Kiro**, **Google Antigravity**, and **opencode**, all in one place. Get usage stats, cost estimates, per-provider breakdowns, semantic search, and a built-in AI chat agent to explore your history.
|
|
41
|
+
Track your AI coding sessions across **Claude Code**, **OpenAI Codex CLI**, **GitHub Copilot**, **Cursor**, **Windsurf**, **Kiro**, **Google Antigravity**, and **opencode**, all in one place. Get usage stats, cost estimates, per-provider breakdowns, semantic search, and a built-in AI chat agent to explore your history.
|
|
42
42
|
|
|
43
43
|
**Website:** [spooling.ai](https://spooling.ai)
|
|
44
44
|
|
|
@@ -142,6 +142,7 @@ spooling sync # Full sync with embeddings
|
|
|
142
142
|
spooling sync --no-embed # Skip embeddings (faster initial sync)
|
|
143
143
|
spooling sync -p cursor # Sync only Cursor sessions
|
|
144
144
|
spooling sync -p codex # Sync only Codex CLI sessions
|
|
145
|
+
spooling sync -p claude-code # Sync only Claude Code sessions
|
|
145
146
|
```
|
|
146
147
|
|
|
147
148
|
### `spooling stats`
|
|
@@ -639,6 +640,7 @@ All optional — defaults work out of the box.
|
|
|
639
640
|
|
|
640
641
|
| Provider | Data Location | Format |
|
|
641
642
|
|----------|--------------|--------|
|
|
643
|
+
| **Claude Code** | `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR/projects`) | `<project>/<session-uuid>.jsonl` transcripts with per-message token usage, prompt-cache reads/writes, tool calls and git context |
|
|
642
644
|
| **JSONL Sessions** | `~/.sessions/projects/` | UUID-named JSONL files with conversation history, tool calls, git context |
|
|
643
645
|
| **OpenAI Codex CLI** | `~/.codex/sessions/` | `rollout-*.jsonl` files organized by date |
|
|
644
646
|
| **GitHub Copilot** | `~/Library/Application Support/Code/User/workspaceStorage/` | Chat session JSON from VS Code |
|
|
@@ -650,5 +652,7 @@ All optional — defaults work out of the box.
|
|
|
650
652
|
|
|
651
653
|
Run `spooling init` to see which providers are detected on your system.
|
|
652
654
|
|
|
655
|
+
**Claude Code notes.** Sessions are detected automatically; there is nothing to connect. Cost is computed from each message's real token usage (including prompt-cache reads and writes) at the model's list price, so it reflects API-equivalent spend, not what a subscription plan charges. The project name is the session's working directory. To read transcripts from somewhere else, set `CLAUDE_CONFIG_DIR` or add `{"claude-code": "/path/to/projects"}` to `~/.config/spooling/paths.json`. Subagent transcripts (stored in separate files under `<session>/subagents/` by recent Claude Code versions) are not included yet.
|
|
656
|
+
|
|
653
657
|
Nothing leaves your machine unless you opt in: Spooling Cloud (`spooling push`), a tunnel (`spooling tunnel`), or the Anthropic chat provider. `spooling pricing refresh` downloads public model prices but sends none of your data.
|
|
654
658
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Local session tracker and semantic search for AI coding assistants.
|
|
4
4
|
|
|
5
|
-
Track your AI coding sessions across **OpenAI Codex CLI**, **GitHub Copilot**, **Cursor**, **Windsurf**, **Kiro**, **Google Antigravity**, and **opencode**, all in one place. Get usage stats, cost estimates, per-provider breakdowns, semantic search, and a built-in AI chat agent to explore your history.
|
|
5
|
+
Track your AI coding sessions across **Claude Code**, **OpenAI Codex CLI**, **GitHub Copilot**, **Cursor**, **Windsurf**, **Kiro**, **Google Antigravity**, and **opencode**, all in one place. Get usage stats, cost estimates, per-provider breakdowns, semantic search, and a built-in AI chat agent to explore your history.
|
|
6
6
|
|
|
7
7
|
**Website:** [spooling.ai](https://spooling.ai)
|
|
8
8
|
|
|
@@ -106,6 +106,7 @@ spooling sync # Full sync with embeddings
|
|
|
106
106
|
spooling sync --no-embed # Skip embeddings (faster initial sync)
|
|
107
107
|
spooling sync -p cursor # Sync only Cursor sessions
|
|
108
108
|
spooling sync -p codex # Sync only Codex CLI sessions
|
|
109
|
+
spooling sync -p claude-code # Sync only Claude Code sessions
|
|
109
110
|
```
|
|
110
111
|
|
|
111
112
|
### `spooling stats`
|
|
@@ -603,6 +604,7 @@ All optional — defaults work out of the box.
|
|
|
603
604
|
|
|
604
605
|
| Provider | Data Location | Format |
|
|
605
606
|
|----------|--------------|--------|
|
|
607
|
+
| **Claude Code** | `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR/projects`) | `<project>/<session-uuid>.jsonl` transcripts with per-message token usage, prompt-cache reads/writes, tool calls and git context |
|
|
606
608
|
| **JSONL Sessions** | `~/.sessions/projects/` | UUID-named JSONL files with conversation history, tool calls, git context |
|
|
607
609
|
| **OpenAI Codex CLI** | `~/.codex/sessions/` | `rollout-*.jsonl` files organized by date |
|
|
608
610
|
| **GitHub Copilot** | `~/Library/Application Support/Code/User/workspaceStorage/` | Chat session JSON from VS Code |
|
|
@@ -614,5 +616,7 @@ All optional — defaults work out of the box.
|
|
|
614
616
|
|
|
615
617
|
Run `spooling init` to see which providers are detected on your system.
|
|
616
618
|
|
|
619
|
+
**Claude Code notes.** Sessions are detected automatically; there is nothing to connect. Cost is computed from each message's real token usage (including prompt-cache reads and writes) at the model's list price, so it reflects API-equivalent spend, not what a subscription plan charges. The project name is the session's working directory. To read transcripts from somewhere else, set `CLAUDE_CONFIG_DIR` or add `{"claude-code": "/path/to/projects"}` to `~/.config/spooling/paths.json`. Subagent transcripts (stored in separate files under `<session>/subagents/` by recent Claude Code versions) are not included yet.
|
|
620
|
+
|
|
617
621
|
Nothing leaves your machine unless you opt in: Spooling Cloud (`spooling push`), a tunnel (`spooling tunnel`), or the Anthropic chat provider. `spooling pricing refresh` downloads public model prices but sends none of your data.
|
|
618
622
|
|
|
@@ -64,7 +64,7 @@ def init():
|
|
|
64
64
|
|
|
65
65
|
@cli.command()
|
|
66
66
|
@click.option("--no-embed", is_flag=True, help="Skip embedding (faster sync)")
|
|
67
|
-
@click.option("--provider", "-p", default=None, help="Only sync a specific provider (jsonl-session, codex, cursor, copilot, windsurf)")
|
|
67
|
+
@click.option("--provider", "-p", default=None, help="Only sync a specific provider (claude-code, jsonl-session, codex, cursor, copilot, windsurf)")
|
|
68
68
|
def sync(no_embed, provider):
|
|
69
69
|
"""Sync AI coding sessions to the database."""
|
|
70
70
|
from spooling.ingest import sync as do_sync
|
|
@@ -161,7 +161,7 @@ def list_traces(
|
|
|
161
161
|
|
|
162
162
|
Args:
|
|
163
163
|
limit: Max rows to return (default 25, capped at 200).
|
|
164
|
-
provider: Filter to one provider id (jsonl-session, codex, cursor, copilot, windsurf, kiro, antigravity, gemini, opencode).
|
|
164
|
+
provider: Filter to one provider id (claude-code, jsonl-session, codex, cursor, copilot, windsurf, kiro, antigravity, gemini, opencode).
|
|
165
165
|
project: Filter to sessions whose project name matches exactly.
|
|
166
166
|
"""
|
|
167
167
|
limit = max(1, min(limit, 200))
|
|
@@ -304,8 +304,19 @@ def _tool_result_text(block: dict) -> tuple[str, bool | None]:
|
|
|
304
304
|
return "", is_error
|
|
305
305
|
|
|
306
306
|
|
|
307
|
-
def parse_session_file(
|
|
308
|
-
|
|
307
|
+
def parse_session_file(
|
|
308
|
+
file_path: Path,
|
|
309
|
+
provider_id: str = "jsonl-session",
|
|
310
|
+
project_from_cwd: bool = False,
|
|
311
|
+
) -> ParsedSession | None:
|
|
312
|
+
"""Parse a single session JSONL file into messages + trace.
|
|
313
|
+
|
|
314
|
+
``provider_id`` is stamped on both the session and its trace (the generic
|
|
315
|
+
``jsonl-session`` reader and the ``claude-code`` provider share this parser).
|
|
316
|
+
With ``project_from_cwd`` the project is the working directory's basename,
|
|
317
|
+
matching Codex/Gemini/opencode, instead of the on-disk folder name (which for
|
|
318
|
+
Claude Code is the whole path with slashes replaced by dashes).
|
|
319
|
+
"""
|
|
309
320
|
session_id = file_path.stem
|
|
310
321
|
project = file_path.parent.name
|
|
311
322
|
|
|
@@ -344,9 +355,14 @@ def parse_session_file(file_path: Path) -> ParsedSession | None:
|
|
|
344
355
|
agent_version = agent_version or r.get("version")
|
|
345
356
|
if not model:
|
|
346
357
|
m = (r.get("message") or {}).get("model")
|
|
347
|
-
|
|
358
|
+
# "<synthetic>" marks locally generated placeholder messages
|
|
359
|
+
# (interrupts, API errors), not a real model.
|
|
360
|
+
if m and m != "<synthetic>":
|
|
348
361
|
model = m
|
|
349
362
|
|
|
363
|
+
if project_from_cwd and cwd:
|
|
364
|
+
project = Path(cwd.rstrip("/")).name or project
|
|
365
|
+
|
|
350
366
|
# --- Build ParsedMessage list (legacy path, with tool details) -------
|
|
351
367
|
messages: list[ParsedMessage] = []
|
|
352
368
|
pending_tool_details: list[ToolCallDetail] = []
|
|
@@ -422,6 +438,7 @@ def parse_session_file(file_path: Path) -> ParsedSession | None:
|
|
|
422
438
|
git_branch=git_branch,
|
|
423
439
|
model=model,
|
|
424
440
|
title=title,
|
|
441
|
+
provider_id=provider_id,
|
|
425
442
|
)
|
|
426
443
|
|
|
427
444
|
return ParsedSession(
|
|
@@ -435,6 +452,7 @@ def parse_session_file(file_path: Path) -> ParsedSession | None:
|
|
|
435
452
|
agent_version=agent_version,
|
|
436
453
|
model=model,
|
|
437
454
|
title=title,
|
|
455
|
+
provider_id=provider_id,
|
|
438
456
|
trace=trace,
|
|
439
457
|
)
|
|
440
458
|
|
|
@@ -467,6 +485,7 @@ def _build_trace(
|
|
|
467
485
|
git_branch: str | None,
|
|
468
486
|
model: str | None,
|
|
469
487
|
title: str | None,
|
|
488
|
+
provider_id: str = "jsonl-session",
|
|
470
489
|
) -> Trace:
|
|
471
490
|
"""Build a Trace with span tree from session records.
|
|
472
491
|
|
|
@@ -479,7 +498,7 @@ def _build_trace(
|
|
|
479
498
|
└─ tool ...
|
|
480
499
|
"""
|
|
481
500
|
tb = TraceBuilder(
|
|
482
|
-
provider_id=
|
|
501
|
+
provider_id=provider_id,
|
|
483
502
|
session_id=session_id,
|
|
484
503
|
project=project,
|
|
485
504
|
cwd=cwd,
|
|
@@ -48,6 +48,7 @@ _GEMINI_ZERO_MINOR = re.compile(r"^(gemini-\d+)-0(-)")
|
|
|
48
48
|
# "auto"), substitute the provider's documented default before pricing.
|
|
49
49
|
# Source: each provider's docs, picked as the most-common-actual-model.
|
|
50
50
|
PROVIDER_DEFAULT_MODEL: dict[str, str] = {
|
|
51
|
+
"claude-code": "claude-sonnet-4-5",
|
|
51
52
|
"copilot": "gpt-4o",
|
|
52
53
|
"cursor": "gpt-4o",
|
|
53
54
|
"windsurf": "gpt-4o",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from spooling.providers.base import Provider, RemoteProvider, PROVIDER_REGISTRY
|
|
4
4
|
from spooling.providers.session_file import SessionFileProvider
|
|
5
|
+
from spooling.providers.claude_code import ClaudeCodeProvider
|
|
5
6
|
from spooling.providers.codex import CodexProvider
|
|
6
7
|
from spooling.providers.cursor import CursorProvider
|
|
7
8
|
from spooling.providers.copilot import CopilotProvider
|
|
@@ -19,6 +20,7 @@ __all__ = [
|
|
|
19
20
|
"RemoteProvider",
|
|
20
21
|
"PROVIDER_REGISTRY",
|
|
21
22
|
"SessionFileProvider",
|
|
23
|
+
"ClaudeCodeProvider",
|
|
22
24
|
"CodexProvider",
|
|
23
25
|
"CursorProvider",
|
|
24
26
|
"CopilotProvider",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Claude Code provider: reads session transcripts from ``~/.claude/projects``.
|
|
2
|
+
|
|
3
|
+
Claude Code writes one JSONL transcript per session at
|
|
4
|
+
``<config dir>/projects/<encoded-cwd>/<session-uuid>.jsonl``, where the encoded
|
|
5
|
+
cwd is the working directory with ``/`` replaced by ``-``. Each line is a
|
|
6
|
+
``user`` / ``assistant`` record (plus bookkeeping records the parser ignores)
|
|
7
|
+
carrying per-message token ``usage`` including prompt-cache reads and writes,
|
|
8
|
+
which is what makes cost tracking accurate.
|
|
9
|
+
|
|
10
|
+
The config directory is ``$CLAUDE_CONFIG_DIR`` when set, otherwise ``~/.claude``
|
|
11
|
+
(falling back to ``~/.config/claude`` where newer installs put it). A different
|
|
12
|
+
location can be pinned in ``~/.config/spooling/paths.json`` under the key
|
|
13
|
+
``"claude-code"``.
|
|
14
|
+
|
|
15
|
+
Not yet included: subagent transcripts, which recent Claude Code versions write
|
|
16
|
+
to separate files under ``<session-uuid>/subagents/``. Their spend is not part of
|
|
17
|
+
the parent session's totals.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from spooling.parser import ParsedSession, parse_session_file
|
|
25
|
+
from spooling.providers.base import Provider
|
|
26
|
+
|
|
27
|
+
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _claude_config_dirs() -> list[Path]:
|
|
31
|
+
env = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
|
32
|
+
if env:
|
|
33
|
+
return [Path(env).expanduser()]
|
|
34
|
+
return [Path.home() / ".claude", Path.home() / ".config" / "claude"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ClaudeCodeProvider(Provider):
|
|
38
|
+
type_id = "claude-code"
|
|
39
|
+
name = "Claude Code"
|
|
40
|
+
|
|
41
|
+
def default_data_path(self) -> Path:
|
|
42
|
+
candidates = [d / "projects" for d in _claude_config_dirs()]
|
|
43
|
+
for c in candidates:
|
|
44
|
+
if c.exists():
|
|
45
|
+
return c
|
|
46
|
+
return candidates[0]
|
|
47
|
+
|
|
48
|
+
def discover_session_files(self, data_path: Path | None = None) -> list[Path]:
|
|
49
|
+
base = data_path or self.resolved_data_path()
|
|
50
|
+
if not base.exists():
|
|
51
|
+
return []
|
|
52
|
+
files: list[Path] = []
|
|
53
|
+
for project_dir in base.iterdir():
|
|
54
|
+
if not project_dir.is_dir():
|
|
55
|
+
continue
|
|
56
|
+
# Top level only and UUID-named: skips subagent transcripts and
|
|
57
|
+
# non-session JSONL such as plugin skill-injection logs.
|
|
58
|
+
for f in project_dir.glob("*.jsonl"):
|
|
59
|
+
if _UUID_RE.match(f.stem):
|
|
60
|
+
files.append(f)
|
|
61
|
+
return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
|
|
62
|
+
|
|
63
|
+
def parse_session_file(self, file_path: Path) -> list[ParsedSession]:
|
|
64
|
+
session = parse_session_file(file_path, provider_id=self.type_id, project_from_cwd=True)
|
|
65
|
+
return [session] if session else []
|
|
@@ -108,6 +108,13 @@ async def api_search(
|
|
|
108
108
|
# --- Providers / Connections ---
|
|
109
109
|
|
|
110
110
|
PROVIDER_TEMPLATES = {
|
|
111
|
+
"claude-code": {
|
|
112
|
+
"name": "Claude Code",
|
|
113
|
+
"icon": "claude",
|
|
114
|
+
"default_path": "~/.claude/projects",
|
|
115
|
+
"description": "Anthropic's Claude Code CLI. One JSONL transcript per session, with per-message token and prompt-cache usage.",
|
|
116
|
+
"status_hint": "Auto-detected from ~/.claude/projects/",
|
|
117
|
+
},
|
|
111
118
|
"jsonl-session": {
|
|
112
119
|
"name": "Session Files",
|
|
113
120
|
"icon": "file",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spooling
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: Local session tracker and semantic search for AI coding assistants
|
|
5
5
|
Author: Parsed Analytics, Inc.
|
|
6
6
|
License: MIT
|
|
@@ -38,7 +38,7 @@ Dynamic: license-file
|
|
|
38
38
|
|
|
39
39
|
Local session tracker and semantic search for AI coding assistants.
|
|
40
40
|
|
|
41
|
-
Track your AI coding sessions across **OpenAI Codex CLI**, **GitHub Copilot**, **Cursor**, **Windsurf**, **Kiro**, **Google Antigravity**, and **opencode**, all in one place. Get usage stats, cost estimates, per-provider breakdowns, semantic search, and a built-in AI chat agent to explore your history.
|
|
41
|
+
Track your AI coding sessions across **Claude Code**, **OpenAI Codex CLI**, **GitHub Copilot**, **Cursor**, **Windsurf**, **Kiro**, **Google Antigravity**, and **opencode**, all in one place. Get usage stats, cost estimates, per-provider breakdowns, semantic search, and a built-in AI chat agent to explore your history.
|
|
42
42
|
|
|
43
43
|
**Website:** [spooling.ai](https://spooling.ai)
|
|
44
44
|
|
|
@@ -142,6 +142,7 @@ spooling sync # Full sync with embeddings
|
|
|
142
142
|
spooling sync --no-embed # Skip embeddings (faster initial sync)
|
|
143
143
|
spooling sync -p cursor # Sync only Cursor sessions
|
|
144
144
|
spooling sync -p codex # Sync only Codex CLI sessions
|
|
145
|
+
spooling sync -p claude-code # Sync only Claude Code sessions
|
|
145
146
|
```
|
|
146
147
|
|
|
147
148
|
### `spooling stats`
|
|
@@ -639,6 +640,7 @@ All optional — defaults work out of the box.
|
|
|
639
640
|
|
|
640
641
|
| Provider | Data Location | Format |
|
|
641
642
|
|----------|--------------|--------|
|
|
643
|
+
| **Claude Code** | `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR/projects`) | `<project>/<session-uuid>.jsonl` transcripts with per-message token usage, prompt-cache reads/writes, tool calls and git context |
|
|
642
644
|
| **JSONL Sessions** | `~/.sessions/projects/` | UUID-named JSONL files with conversation history, tool calls, git context |
|
|
643
645
|
| **OpenAI Codex CLI** | `~/.codex/sessions/` | `rollout-*.jsonl` files organized by date |
|
|
644
646
|
| **GitHub Copilot** | `~/Library/Application Support/Code/User/workspaceStorage/` | Chat session JSON from VS Code |
|
|
@@ -650,5 +652,7 @@ All optional — defaults work out of the box.
|
|
|
650
652
|
|
|
651
653
|
Run `spooling init` to see which providers are detected on your system.
|
|
652
654
|
|
|
655
|
+
**Claude Code notes.** Sessions are detected automatically; there is nothing to connect. Cost is computed from each message's real token usage (including prompt-cache reads and writes) at the model's list price, so it reflects API-equivalent spend, not what a subscription plan charges. The project name is the session's working directory. To read transcripts from somewhere else, set `CLAUDE_CONFIG_DIR` or add `{"claude-code": "/path/to/projects"}` to `~/.config/spooling/paths.json`. Subagent transcripts (stored in separate files under `<session>/subagents/` by recent Claude Code versions) are not included yet.
|
|
656
|
+
|
|
653
657
|
Nothing leaves your machine unless you opt in: Spooling Cloud (`spooling push`), a tunnel (`spooling tunnel`), or the Anthropic chat provider. `spooling pricing refresh` downloads public model prices but sends none of your data.
|
|
654
658
|
|
|
@@ -51,6 +51,7 @@ spooling/connectors/rest/shopify_connectors.py
|
|
|
51
51
|
spooling/providers/__init__.py
|
|
52
52
|
spooling/providers/antigravity.py
|
|
53
53
|
spooling/providers/base.py
|
|
54
|
+
spooling/providers/claude_code.py
|
|
54
55
|
spooling/providers/codex.py
|
|
55
56
|
spooling/providers/copilot.py
|
|
56
57
|
spooling/providers/cortex_code.py
|
|
@@ -62,6 +63,7 @@ spooling/providers/kiro.py
|
|
|
62
63
|
spooling/providers/opencode.py
|
|
63
64
|
spooling/providers/session_file.py
|
|
64
65
|
spooling/providers/windsurf.py
|
|
66
|
+
tests/test_claude_code_provider.py
|
|
65
67
|
tests/test_mcp_auth_middleware.py
|
|
66
68
|
tests/test_session_ids.py
|
|
67
69
|
tests/test_storage_optimizations.py
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Tests for the Claude Code provider (~/.claude/projects transcripts)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from spooling import db as db_mod
|
|
11
|
+
from spooling import ingest
|
|
12
|
+
from spooling.parser import parse_session_file
|
|
13
|
+
from spooling.providers import PROVIDER_REGISTRY, get_provider
|
|
14
|
+
from spooling.providers import base as base_mod
|
|
15
|
+
from spooling.providers.claude_code import ClaudeCodeProvider
|
|
16
|
+
|
|
17
|
+
SID1 = "11111111-aaaa-4bbb-8ccc-000000000001"
|
|
18
|
+
SID2 = "22222222-aaaa-4bbb-8ccc-000000000002"
|
|
19
|
+
CWD = "/Users/dev/code/my-app"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _rec(type_, uuid, parent, ts, **kw):
|
|
23
|
+
r = {
|
|
24
|
+
"type": type_, "uuid": uuid, "parentUuid": parent, "isSidechain": False,
|
|
25
|
+
"timestamp": ts, "cwd": CWD, "gitBranch": "main", "version": "2.1.0",
|
|
26
|
+
"sessionId": SID1,
|
|
27
|
+
}
|
|
28
|
+
r.update(kw)
|
|
29
|
+
return r
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _usage(i, o, cr=0, cw=0):
|
|
33
|
+
return {"input_tokens": i, "output_tokens": o,
|
|
34
|
+
"cache_read_input_tokens": cr, "cache_creation_input_tokens": cw}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def transcript(sid: str = SID1) -> list[dict]:
|
|
38
|
+
"""A realistic session: placeholder msg first, then prompt, tool use, answer."""
|
|
39
|
+
recs = [
|
|
40
|
+
{"type": "permission-mode", "permissionMode": "default", "sessionId": SID1},
|
|
41
|
+
{"type": "file-history-snapshot", "messageId": "x", "snapshot": {}},
|
|
42
|
+
# Locally generated placeholder: model "<synthetic>" must not become the session model.
|
|
43
|
+
_rec("assistant", "a0", None, "2026-11-01T10:00:00Z",
|
|
44
|
+
message={"role": "assistant", "model": "<synthetic>",
|
|
45
|
+
"content": [{"type": "text", "text": "No response requested."}],
|
|
46
|
+
"usage": _usage(0, 0)}),
|
|
47
|
+
_rec("user", "u1", "a0", "2026-11-01T10:00:05Z",
|
|
48
|
+
message={"role": "user", "content": "Fix the login bug in auth.ts"}),
|
|
49
|
+
_rec("assistant", "a1", "u1", "2026-11-01T10:00:10Z",
|
|
50
|
+
message={"role": "assistant", "model": "claude-sonnet-4-5",
|
|
51
|
+
"content": [
|
|
52
|
+
{"type": "text", "text": "Let me look at the file."},
|
|
53
|
+
{"type": "tool_use", "id": "toolu_1", "name": "Read",
|
|
54
|
+
"input": {"file_path": "/Users/dev/code/my-app/auth.ts"}}],
|
|
55
|
+
"usage": _usage(100, 50, cr=2000, cw=500)}),
|
|
56
|
+
_rec("user", "u2", "a1", "2026-11-01T10:00:12Z",
|
|
57
|
+
message={"role": "user", "content": [
|
|
58
|
+
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "export function login() {}"}]}),
|
|
59
|
+
_rec("assistant", "a2", "u2", "2026-11-01T10:00:20Z",
|
|
60
|
+
message={"role": "assistant", "model": "claude-sonnet-4-5",
|
|
61
|
+
"content": [{"type": "text", "text": "Fixed."}],
|
|
62
|
+
"usage": _usage(10, 20, cr=2600, cw=0)}),
|
|
63
|
+
{"type": "ai-title", "title": "Fix login", "sessionId": SID1},
|
|
64
|
+
]
|
|
65
|
+
# Message uuids are globally unique in real transcripts; keep the fixture honest.
|
|
66
|
+
for r in recs:
|
|
67
|
+
for k in ("uuid", "parentUuid"):
|
|
68
|
+
if r.get(k):
|
|
69
|
+
r[k] = f"{sid[:8]}-{r[k]}"
|
|
70
|
+
if "sessionId" in r:
|
|
71
|
+
r["sessionId"] = sid
|
|
72
|
+
return recs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def write_session(root, project_dir, session_id, records, mtime=None):
|
|
76
|
+
d = root / "projects" / project_dir
|
|
77
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
f = d / f"{session_id}.jsonl"
|
|
79
|
+
f.write_text("\n".join(json.dumps(r) for r in records) + "\n")
|
|
80
|
+
if mtime:
|
|
81
|
+
os.utime(f, (mtime, mtime))
|
|
82
|
+
return f
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.fixture
|
|
86
|
+
def claude_home(tmp_path, monkeypatch):
|
|
87
|
+
home = tmp_path / "claude"
|
|
88
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home))
|
|
89
|
+
# No user path overrides leaking in from the dev machine.
|
|
90
|
+
monkeypatch.setattr(base_mod, "PATH_OVERRIDES_FILE", tmp_path / "paths.json")
|
|
91
|
+
return home
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_registered():
|
|
95
|
+
assert PROVIDER_REGISTRY["claude-code"] is ClaudeCodeProvider
|
|
96
|
+
assert get_provider("claude-code").name == "Claude Code"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_default_path_follows_claude_config_dir(claude_home):
|
|
100
|
+
assert ClaudeCodeProvider().default_data_path() == claude_home / "projects"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_default_path_is_dot_claude_without_env(tmp_path, monkeypatch):
|
|
104
|
+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
|
105
|
+
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
106
|
+
assert ClaudeCodeProvider().default_data_path() == tmp_path / ".claude" / "projects"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_falls_back_to_xdg_location_when_dot_claude_missing(tmp_path, monkeypatch):
|
|
110
|
+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
|
111
|
+
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
112
|
+
(tmp_path / ".config" / "claude" / "projects").mkdir(parents=True)
|
|
113
|
+
assert ClaudeCodeProvider().default_data_path() == tmp_path / ".config" / "claude" / "projects"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_paths_json_override_wins(claude_home, tmp_path, monkeypatch):
|
|
117
|
+
custom = tmp_path / "elsewhere"
|
|
118
|
+
(tmp_path / "paths.json").write_text(json.dumps({"claude-code": str(custom)}))
|
|
119
|
+
assert ClaudeCodeProvider().resolved_data_path() == custom
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_not_available_when_directory_missing(claude_home):
|
|
123
|
+
assert ClaudeCodeProvider().is_available() is False
|
|
124
|
+
assert ClaudeCodeProvider().discover_session_files() == []
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_discovery_only_top_level_uuid_transcripts(claude_home):
|
|
128
|
+
old = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript(), mtime=time.time() - 1000)
|
|
129
|
+
new = write_session(claude_home, "-Users-dev-code-other", SID2, transcript(SID2))
|
|
130
|
+
proj = claude_home / "projects" / "-Users-dev-code-my-app"
|
|
131
|
+
# Things that must NOT be treated as sessions:
|
|
132
|
+
(proj / SID1 / "subagents").mkdir(parents=True)
|
|
133
|
+
(proj / SID1 / "subagents" / "agent-abc123.jsonl").write_text("{}\n")
|
|
134
|
+
(proj / "skill-injections.jsonl").write_text("{}\n") # non-uuid name
|
|
135
|
+
(claude_home / "projects" / "stray.jsonl").write_text("{}\n") # file directly in projects/
|
|
136
|
+
|
|
137
|
+
files = ClaudeCodeProvider().discover_session_files()
|
|
138
|
+
assert files == [new, old] # newest first, nothing else
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_parse_stamps_provider_on_session_and_trace(claude_home):
|
|
142
|
+
f = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
|
|
143
|
+
[s] = ClaudeCodeProvider().parse_session_file(f)
|
|
144
|
+
assert s.provider_id == "claude-code"
|
|
145
|
+
assert s.trace is not None and s.trace.provider_id == "claude-code"
|
|
146
|
+
assert s.session_id == SID1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_project_is_working_directory_name(claude_home):
|
|
150
|
+
f = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
|
|
151
|
+
[s] = ClaudeCodeProvider().parse_session_file(f)
|
|
152
|
+
assert s.project == "my-app" # not "-Users-dev-code-my-app"
|
|
153
|
+
assert s.cwd == CWD and s.git_branch == "main" and s.agent_version == "2.1.0"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_synthetic_placeholder_is_not_the_session_model(claude_home):
|
|
157
|
+
f = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
|
|
158
|
+
[s] = ClaudeCodeProvider().parse_session_file(f)
|
|
159
|
+
assert s.model == "claude-sonnet-4-5"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_messages_tools_and_title(claude_home):
|
|
163
|
+
f = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
|
|
164
|
+
[s] = ClaudeCodeProvider().parse_session_file(f)
|
|
165
|
+
assert [m.role for m in s.messages] == ["assistant", "user", "assistant", "assistant"]
|
|
166
|
+
assert s.tool_call_count == 1
|
|
167
|
+
read = next(m for m in s.messages if m.tool_details).tool_details[0]
|
|
168
|
+
assert read.name == "Read" and read.input_summary.endswith("auth.ts")
|
|
169
|
+
assert read.result_preview == "export function login() {}" # paired from the tool_result
|
|
170
|
+
assert s.title == "Fix the login bug in auth.ts"
|
|
171
|
+
assert s.started_at == datetime(2026, 11, 1, 10, 0, 0, tzinfo=timezone.utc)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_token_usage_including_cache_flows_into_trace(claude_home):
|
|
175
|
+
f = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
|
|
176
|
+
[s] = ClaudeCodeProvider().parse_session_file(f)
|
|
177
|
+
llm = [sp for sp in s.trace.spans if getattr(sp.kind, "value", sp.kind) == "llm_call"]
|
|
178
|
+
assert len(llm) == 3
|
|
179
|
+
assert sum(x.input_tokens for x in llm) == 110
|
|
180
|
+
assert sum(x.output_tokens for x in llm) == 70
|
|
181
|
+
assert sum(x.cache_read_tokens for x in llm) == 4600
|
|
182
|
+
assert sum(x.cache_write_tokens for x in llm) == 500
|
|
183
|
+
# Cost uses all four components at claude-sonnet-4-5 rates (3 / 15 / 3.75 / 0.30 per Mtok).
|
|
184
|
+
real = [x for x in llm if x.model == "claude-sonnet-4-5"]
|
|
185
|
+
expected = (110 * 3 + 70 * 15 + 500 * 3.75 + 4600 * 0.30) / 1e6
|
|
186
|
+
assert sum(x.cost_usd for x in real) == pytest.approx(expected, rel=0.02)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_empty_or_garbage_files_yield_no_session(claude_home):
|
|
190
|
+
f = write_session(claude_home, "-p", SID2, [{"type": "permission-mode"}])
|
|
191
|
+
assert ClaudeCodeProvider().parse_session_file(f) == []
|
|
192
|
+
g = claude_home / "projects" / "-p" / f"{SID1}.jsonl"
|
|
193
|
+
g.write_text("not json\n\n{broken\n")
|
|
194
|
+
assert ClaudeCodeProvider().parse_session_file(g) == []
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_generic_session_file_parser_is_unchanged(claude_home):
|
|
198
|
+
"""The legacy jsonl-session path keeps its id and folder-name project."""
|
|
199
|
+
f = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
|
|
200
|
+
s = parse_session_file(f)
|
|
201
|
+
assert s.provider_id == "jsonl-session" and s.trace.provider_id == "jsonl-session"
|
|
202
|
+
assert s.project == "-Users-dev-code-my-app"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_sync_ingests_and_is_idempotent(claude_home, tmp_path, monkeypatch):
|
|
206
|
+
monkeypatch.setattr(db_mod, "DB_PATH", tmp_path / "spooling.db")
|
|
207
|
+
write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
|
|
208
|
+
write_session(claude_home, "-Users-dev-code-other", SID2, transcript(SID2))
|
|
209
|
+
|
|
210
|
+
ingest.sync(embed=False, provider_filter="claude-code")
|
|
211
|
+
|
|
212
|
+
conn = db_mod.get_connection()
|
|
213
|
+
rows = conn.execute("SELECT id, provider_id, project, model FROM sessions ORDER BY id").fetchall()
|
|
214
|
+
assert [(r["id"], r["provider_id"], r["project"], r["model"]) for r in rows] == [
|
|
215
|
+
(SID1, "claude-code", "my-app", "claude-sonnet-4-5"),
|
|
216
|
+
(SID2, "claude-code", "my-app", "claude-sonnet-4-5"),
|
|
217
|
+
]
|
|
218
|
+
assert conn.execute("SELECT COUNT(*) AS n FROM messages").fetchone()["n"] == 8
|
|
219
|
+
assert [r["provider_id"] for r in conn.execute("SELECT DISTINCT provider_id FROM traces").fetchall()] == ["claude-code"]
|
|
220
|
+
spans = conn.execute("SELECT COUNT(*) AS n FROM spans WHERE kind = 'llm_call'").fetchone()["n"]
|
|
221
|
+
assert spans == 6
|
|
222
|
+
conn.close()
|
|
223
|
+
|
|
224
|
+
# Second run: nothing changed on disk, nothing re-ingested.
|
|
225
|
+
ingest.sync(embed=False, provider_filter="claude-code")
|
|
226
|
+
conn = db_mod.get_connection()
|
|
227
|
+
assert conn.execute("SELECT COUNT(*) AS n FROM sessions").fetchone()["n"] == 2
|
|
228
|
+
assert conn.execute("SELECT COUNT(*) AS n FROM spans WHERE kind = 'llm_call'").fetchone()["n"] == 6
|
|
229
|
+
conn.close()
|
|
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
|