spooling 0.2.6__tar.gz → 0.2.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {spooling-0.2.6/spooling.egg-info → spooling-0.2.8}/PKG-INFO +64 -18
  2. {spooling-0.2.6 → spooling-0.2.8}/README.md +63 -17
  3. {spooling-0.2.6 → spooling-0.2.8}/pyproject.toml +1 -1
  4. {spooling-0.2.6 → spooling-0.2.8}/spooling/cli.py +64 -27
  5. {spooling-0.2.6 → spooling-0.2.8}/spooling/mcp_server.py +52 -0
  6. {spooling-0.2.6 → spooling-0.2.8}/spooling/search.py +34 -1
  7. spooling-0.2.8/spooling/sessions.py +63 -0
  8. {spooling-0.2.6 → spooling-0.2.8/spooling.egg-info}/PKG-INFO +64 -18
  9. {spooling-0.2.6 → spooling-0.2.8}/spooling.egg-info/SOURCES.txt +2 -0
  10. spooling-0.2.8/tests/test_session_ids.py +154 -0
  11. {spooling-0.2.6 → spooling-0.2.8}/LICENSE +0 -0
  12. {spooling-0.2.6 → spooling-0.2.8}/setup.cfg +0 -0
  13. {spooling-0.2.6 → spooling-0.2.8}/spooling/__init__.py +0 -0
  14. {spooling-0.2.6 → spooling-0.2.8}/spooling/agent.py +0 -0
  15. {spooling-0.2.6 → spooling-0.2.8}/spooling/classifiers.py +0 -0
  16. {spooling-0.2.6 → spooling-0.2.8}/spooling/cloud.py +0 -0
  17. {spooling-0.2.6 → spooling-0.2.8}/spooling/config.py +0 -0
  18. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/__init__.py +0 -0
  19. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/base.py +0 -0
  20. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/bigquery.py +0 -0
  21. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/clickhouse.py +0 -0
  22. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/factory.py +0 -0
  23. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/mongodb.py +0 -0
  24. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/mysql.py +0 -0
  25. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/postgresql.py +0 -0
  26. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/rest/__init__.py +0 -0
  27. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/rest/base.py +0 -0
  28. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/rest/shopify_connectors.py +0 -0
  29. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/snowflake.py +0 -0
  30. {spooling-0.2.6 → spooling-0.2.8}/spooling/connectors/types.py +0 -0
  31. {spooling-0.2.6 → spooling-0.2.8}/spooling/db.py +0 -0
  32. {spooling-0.2.6 → spooling-0.2.8}/spooling/embeddings.py +0 -0
  33. {spooling-0.2.6 → spooling-0.2.8}/spooling/evals.py +0 -0
  34. {spooling-0.2.6 → spooling-0.2.8}/spooling/experiments.py +0 -0
  35. {spooling-0.2.6 → spooling-0.2.8}/spooling/ingest.py +0 -0
  36. {spooling-0.2.6 → spooling-0.2.8}/spooling/optimize.py +0 -0
  37. {spooling-0.2.6 → spooling-0.2.8}/spooling/parser.py +0 -0
  38. {spooling-0.2.6 → spooling-0.2.8}/spooling/pricing.py +0 -0
  39. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/__init__.py +0 -0
  40. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/antigravity.py +0 -0
  41. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/base.py +0 -0
  42. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/codex.py +0 -0
  43. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/copilot.py +0 -0
  44. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/cortex_code.py +0 -0
  45. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/cursor.py +0 -0
  46. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/gemini.py +0 -0
  47. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/github.py +0 -0
  48. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/gitlab.py +0 -0
  49. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/kiro.py +0 -0
  50. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/opencode.py +0 -0
  51. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/session_file.py +0 -0
  52. {spooling-0.2.6 → spooling-0.2.8}/spooling/providers/windsurf.py +0 -0
  53. {spooling-0.2.6 → spooling-0.2.8}/spooling/redact.py +0 -0
  54. {spooling-0.2.6 → spooling-0.2.8}/spooling/remote_otel.py +0 -0
  55. {spooling-0.2.6 → spooling-0.2.8}/spooling/schema.py +0 -0
  56. {spooling-0.2.6 → spooling-0.2.8}/spooling/sdk.py +0 -0
  57. {spooling-0.2.6 → spooling-0.2.8}/spooling/server.py +0 -0
  58. {spooling-0.2.6 → spooling-0.2.8}/spooling/stats.py +0 -0
  59. {spooling-0.2.6 → spooling-0.2.8}/spooling/subscription_pricing.py +0 -0
  60. {spooling-0.2.6 → spooling-0.2.8}/spooling/tracing.py +0 -0
  61. {spooling-0.2.6 → spooling-0.2.8}/spooling/tunnel.py +0 -0
  62. {spooling-0.2.6 → spooling-0.2.8}/spooling/tunnel_auth.py +0 -0
  63. {spooling-0.2.6 → spooling-0.2.8}/spooling/watcher.py +0 -0
  64. {spooling-0.2.6 → spooling-0.2.8}/spooling.egg-info/dependency_links.txt +0 -0
  65. {spooling-0.2.6 → spooling-0.2.8}/spooling.egg-info/entry_points.txt +0 -0
  66. {spooling-0.2.6 → spooling-0.2.8}/spooling.egg-info/requires.txt +0 -0
  67. {spooling-0.2.6 → spooling-0.2.8}/spooling.egg-info/top_level.txt +0 -0
  68. {spooling-0.2.6 → spooling-0.2.8}/tests/test_mcp_auth_middleware.py +0 -0
  69. {spooling-0.2.6 → spooling-0.2.8}/tests/test_storage_optimizations.py +0 -0
  70. {spooling-0.2.6 → spooling-0.2.8}/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.6
3
+ Version: 0.2.8
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -56,7 +56,7 @@ No Docker, no PostgreSQL, no Node.js required.
56
56
 
57
57
  ## How it works
58
58
 
59
- **Four commands. Zero cloud.**
59
+ **Three steps. Local by default.**
60
60
 
61
61
  ### 01   Install
62
62
 
@@ -82,6 +82,8 @@ spooling sync # embed every session for semantic search
82
82
 
83
83
  ```bash
84
84
  spooling search "that redis race condition"
85
+ spooling session list # find a session and its id
86
+ spooling session show ses_e6310bd1 -m # read that session's full text
85
87
  spooling stats
86
88
  spooling mcp # MCP server at http://127.0.0.1:3004/mcp
87
89
  ```
@@ -90,7 +92,7 @@ spooling mcp # MCP server at http://127.0.0.1:3004/mcp
90
92
 
91
93
  ## Connect Spooling to your AI coding agent
92
94
 
93
- `spooling mcp` exposes an MCP server at `http://127.0.0.1:3004/mcp` (HTTP streamable transport). Any MCP-speaking agent can connect to it and pull context from your local KB mid-conversation. The agent gets tools like `spooling_search`, `spooling_recent_sessions`, `spooling_get_session`, `spooling_workspace_stats`, and `spooling_top_projects`.
95
+ `spooling mcp` exposes an MCP server at `http://127.0.0.1:3004/mcp` (HTTP streamable transport). Any MCP-speaking agent can connect to it and pull context from your local KB mid-conversation. The agent gets tools like `search_sessions` (find a session), `get_session` (read its full conversation by id), `get_stats`, and `list_traces`. See [Tools exposed](#tools-exposed) for the full list.
94
96
 
95
97
  ### Cursor / Windsurf / Codex / Antigravity
96
98
 
@@ -117,7 +119,7 @@ curl -s http://127.0.0.1:3004/mcp \
117
119
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq
118
120
  ```
119
121
 
120
- Should return the five `spool_*` tools. If you get "connection refused", `spooling ui` is not running (or its ports got squatted, run `lsof -ti :3004 | xargs kill -9` and retry).
122
+ Should return the tools listed under [Tools exposed](#tools-exposed). If you get "connection refused", `spooling mcp` (or `spooling ui`) is not running, or something else is using port 3004. Find it with `lsof -i :3004`.
121
123
 
122
124
  ---
123
125
 
@@ -125,7 +127,7 @@ Should return the five `spool_*` tools. If you get "connection refused", `spooli
125
127
 
126
128
  ### `spooling init`
127
129
 
128
- Check database connection and show which AI coding tool providers are detected on your system. This scans default paths for Codex CLI, GitHub Copilot, Cursor, and Windsurf session data.
130
+ Check that the local database opens and show which AI coding tool providers are detected on your system. This scans the default paths for every supported provider (see [Supported Providers](#supported-providers)).
129
131
 
130
132
  ```bash
131
133
  spooling init
@@ -154,17 +156,48 @@ spooling stats --days 30 # Last 30 days
154
156
 
155
157
  ### `spooling search <query>`
156
158
 
157
- Semantic search across all your session history using natural language.
159
+ 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`.
158
160
 
159
161
  ```bash
160
162
  spooling search "snowflake connector"
161
163
  spooling search "authentication bug" -n 5
162
- spooling search "database migration" -p ~/myproject
164
+ spooling search "database migration" -p myproject
165
+ spooling search ses_e6310bd1 # a session id (or prefix) matches that session first
166
+ spooling search "login issue" --cloud # search your cloud workspace instead
163
167
  ```
164
168
 
169
+ Session ids work in the query: a full id, a prefix such as `ses_e6310bd1`, or a UUID/hex prefix of 8+ characters. The matching session is ranked first and labelled `(id match)`. `--cloud` search is matched by the server, so ids are shown but not ranked first.
170
+
165
171
  Options:
166
172
  - `-n, --limit` - Number of results (default: 10)
167
- - `-p, --project` - Filter by project name
173
+ - `-p, --project` - Filter by exact project name (see `spooling session list`)
174
+ - `--cloud` - Search the cloud workspace instead of the local database
175
+
176
+ ### `spooling session`
177
+
178
+ List sessions and read one back by id.
179
+
180
+ ```bash
181
+ spooling session list # newest first, with full session ids
182
+ spooling session list --title login -n 10 # filter by title substring
183
+ spooling session list --id e6310bd1 # filter by id substring
184
+ spooling session list --project myproject --cwd ~/code
185
+
186
+ spooling session show ses_e6310bd1 # summary: model, tokens, cost, tools used
187
+ spooling session show ses_e6310bd1 -m # plus the full conversation text
188
+ spooling session show ses_e6310bd1 -m --max-chars 300 # truncate each message
189
+ ```
190
+
191
+ `spooling session <id> -m` is shorthand for `spooling session show <id> -m`. `session show` accepts a full session id or any unique prefix. If a prefix matches several sessions, it lists them and exits so you can pick one.
192
+
193
+ ### `spooling optimize`
194
+
195
+ Compact the local database. Removes duplicated tool-call rows left by older versions (before 0.2.6), truncates oversized previews, embeds any messages missing from search, and runs `VACUUM`. A backup is written next to the database first, and it is safe to re-run. `spooling sync` prints a hint when it finds duplicates.
196
+
197
+ ```bash
198
+ spooling optimize
199
+ spooling optimize --no-backup --no-vacuum --no-embed # skip individual steps
200
+ ```
168
201
 
169
202
  ### `spooling watch`
170
203
 
@@ -336,11 +369,15 @@ the CLI ships with a `spooling cloud` subcommand.
336
369
  Send the most recent local sessions up to the cloud:
337
370
 
338
371
  ```bash
339
- spooling push # 100 sessions, batches of 20
372
+ spooling push # syncs local sessions first, then pushes 100 in batches of 20
340
373
  spooling push --limit 500 # bigger backfill
374
+ spooling push --no-sync # push only what is already in the local database
375
+ spooling push --dry-run # show what would be pushed
341
376
  ```
342
377
 
343
- The server upserts by session id, so re-running is safe.
378
+ `push` ingests new provider sessions into the local database before uploading, so you don't need to run `spooling sync` first. The server upserts by session id, so re-running is safe.
379
+
380
+ To bring sessions from the cloud back down, use `spooling cloud pull`, or `spooling cloud sync` for both directions.
344
381
 
345
382
  ### Watch (continuous push)
346
383
 
@@ -374,8 +411,8 @@ The cloud API at `api.spooling.ai` uses `/v1/` endpoints. Requests require a Bea
374
411
  curl -s "https://api.spooling.ai/v1/stats" \
375
412
  -H "Authorization: Bearer $SPOOLING_KEY"
376
413
 
377
- # Search (when deployed — falls back to local search on self-hosted)
378
- curl -s "https://api.spooling.ai/api/search?q=migration&limit=10" \
414
+ # Search (matches session titles and ids)
415
+ curl -s "https://api.spooling.ai/v1/search?q=migration&limit=10" \
379
416
  -H "Authorization: Bearer $SPOOLING_KEY"
380
417
  ```
381
418
 
@@ -450,7 +487,8 @@ the full config snippet so you don't have to remember it.
450
487
  |------|---------|
451
488
  | `list_traces` | Recent Spooling traces, filterable by provider/project |
452
489
  | `get_trace` | Full detail for one trace: header, spans, eval scores |
453
- | `search_sessions` | Semantic search over embedded session chunks |
490
+ | `search_sessions` | Semantic search over embedded session chunks. Results include `session_id`; a session id in the query matches that session first |
491
+ | `get_session` | Full detail for one session by id or unique prefix, including the conversation text (`include_messages`, `max_messages`, `max_chars`) |
454
492
  | `get_stats` | Top-line stats: traces, tokens, cost, errors |
455
493
  | `get_top_vendors` | Most-used external vendors by tool-call count |
456
494
  | `list_evals` | Recent eval runs, optionally filtered by rubric |
@@ -462,8 +500,15 @@ the full config snippet so you don't have to remember it.
462
500
  For MCP clients that only speak stdio, run `spooling mcp --stdio` and register
463
501
  it with the command-based form:
464
502
 
465
- ```bash
466
- # MCP config for stdio transport
503
+ ```json
504
+ {
505
+ "mcpServers": {
506
+ "spooling": {
507
+ "command": "spooling",
508
+ "args": ["mcp", "--stdio"]
509
+ }
510
+ }
511
+ }
467
512
  ```
468
513
 
469
514
  ---
@@ -539,7 +584,9 @@ spooling/
539
584
  │ ├── parser.py # Session JSONL parser
540
585
  │ ├── embeddings.py # sentence-transformers (all-MiniLM-L6-v2)
541
586
  │ ├── ingest.py # Sync pipeline
542
- │ ├── search.py # Numpy cosine similarity search
587
+ │ ├── search.py # Numpy cosine similarity search (+ session-id matching)
588
+ │ ├── sessions.py # Session id lookup helpers
589
+ │ ├── optimize.py # Database dedupe / compaction
543
590
  │ ├── stats.py # Usage statistics
544
591
  │ ├── watcher.py # File watcher (watchdog)
545
592
  │ ├── agent.py # Chat agent (Ollama + Anthropic)
@@ -583,7 +630,6 @@ All optional — defaults work out of the box.
583
630
  | `SPOOLING_DB` | `~/.local/share/spooling/spooling.db` | Path to the SQLite database file (`%APPDATA%\spooling\spooling.db` on Windows) |
584
631
  | `SPOOLING_EMBEDDING_MODEL` | `all-MiniLM-L6-v2` | Sentence transformer model |
585
632
  | `SPOOLING_UI_HOST` | `127.0.0.1` | API server host |
586
- | `SPOOLING_UI_PORT` | `3001` | API server port (used by `spooling serve`) |
587
633
  | `SPOOLING_UI_DIR` | *(auto-detected)* | Path to the Next.js `ui/` directory (required for `spooling ui`) |
588
634
  | `ANTHROPIC_API_KEY` | - | Anthropic API key (alternative to setting in the GUI) |
589
635
 
@@ -604,5 +650,5 @@ All optional — defaults work out of the box.
604
650
 
605
651
  Run `spooling init` to see which providers are detected on your system.
606
652
 
607
- No data is sent to external servers. Everything runs locally.
653
+ 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.
608
654
 
@@ -20,7 +20,7 @@ No Docker, no PostgreSQL, no Node.js required.
20
20
 
21
21
  ## How it works
22
22
 
23
- **Four commands. Zero cloud.**
23
+ **Three steps. Local by default.**
24
24
 
25
25
  ### 01 &nbsp; Install
26
26
 
@@ -46,6 +46,8 @@ spooling sync # embed every session for semantic search
46
46
 
47
47
  ```bash
48
48
  spooling search "that redis race condition"
49
+ spooling session list # find a session and its id
50
+ spooling session show ses_e6310bd1 -m # read that session's full text
49
51
  spooling stats
50
52
  spooling mcp # MCP server at http://127.0.0.1:3004/mcp
51
53
  ```
@@ -54,7 +56,7 @@ spooling mcp # MCP server at http://127.0.0.1:3004/mcp
54
56
 
55
57
  ## Connect Spooling to your AI coding agent
56
58
 
57
- `spooling mcp` exposes an MCP server at `http://127.0.0.1:3004/mcp` (HTTP streamable transport). Any MCP-speaking agent can connect to it and pull context from your local KB mid-conversation. The agent gets tools like `spooling_search`, `spooling_recent_sessions`, `spooling_get_session`, `spooling_workspace_stats`, and `spooling_top_projects`.
59
+ `spooling mcp` exposes an MCP server at `http://127.0.0.1:3004/mcp` (HTTP streamable transport). Any MCP-speaking agent can connect to it and pull context from your local KB mid-conversation. The agent gets tools like `search_sessions` (find a session), `get_session` (read its full conversation by id), `get_stats`, and `list_traces`. See [Tools exposed](#tools-exposed) for the full list.
58
60
 
59
61
  ### Cursor / Windsurf / Codex / Antigravity
60
62
 
@@ -81,7 +83,7 @@ curl -s http://127.0.0.1:3004/mcp \
81
83
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq
82
84
  ```
83
85
 
84
- Should return the five `spool_*` tools. If you get "connection refused", `spooling ui` is not running (or its ports got squatted, run `lsof -ti :3004 | xargs kill -9` and retry).
86
+ Should return the tools listed under [Tools exposed](#tools-exposed). If you get "connection refused", `spooling mcp` (or `spooling ui`) is not running, or something else is using port 3004. Find it with `lsof -i :3004`.
85
87
 
86
88
  ---
87
89
 
@@ -89,7 +91,7 @@ Should return the five `spool_*` tools. If you get "connection refused", `spooli
89
91
 
90
92
  ### `spooling init`
91
93
 
92
- Check database connection and show which AI coding tool providers are detected on your system. This scans default paths for Codex CLI, GitHub Copilot, Cursor, and Windsurf session data.
94
+ Check that the local database opens and show which AI coding tool providers are detected on your system. This scans the default paths for every supported provider (see [Supported Providers](#supported-providers)).
93
95
 
94
96
  ```bash
95
97
  spooling init
@@ -118,17 +120,48 @@ spooling stats --days 30 # Last 30 days
118
120
 
119
121
  ### `spooling search <query>`
120
122
 
121
- Semantic search across all your session history using natural language.
123
+ 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`.
122
124
 
123
125
  ```bash
124
126
  spooling search "snowflake connector"
125
127
  spooling search "authentication bug" -n 5
126
- spooling search "database migration" -p ~/myproject
128
+ spooling search "database migration" -p myproject
129
+ spooling search ses_e6310bd1 # a session id (or prefix) matches that session first
130
+ spooling search "login issue" --cloud # search your cloud workspace instead
127
131
  ```
128
132
 
133
+ Session ids work in the query: a full id, a prefix such as `ses_e6310bd1`, or a UUID/hex prefix of 8+ characters. The matching session is ranked first and labelled `(id match)`. `--cloud` search is matched by the server, so ids are shown but not ranked first.
134
+
129
135
  Options:
130
136
  - `-n, --limit` - Number of results (default: 10)
131
- - `-p, --project` - Filter by project name
137
+ - `-p, --project` - Filter by exact project name (see `spooling session list`)
138
+ - `--cloud` - Search the cloud workspace instead of the local database
139
+
140
+ ### `spooling session`
141
+
142
+ List sessions and read one back by id.
143
+
144
+ ```bash
145
+ spooling session list # newest first, with full session ids
146
+ spooling session list --title login -n 10 # filter by title substring
147
+ spooling session list --id e6310bd1 # filter by id substring
148
+ spooling session list --project myproject --cwd ~/code
149
+
150
+ spooling session show ses_e6310bd1 # summary: model, tokens, cost, tools used
151
+ spooling session show ses_e6310bd1 -m # plus the full conversation text
152
+ spooling session show ses_e6310bd1 -m --max-chars 300 # truncate each message
153
+ ```
154
+
155
+ `spooling session <id> -m` is shorthand for `spooling session show <id> -m`. `session show` accepts a full session id or any unique prefix. If a prefix matches several sessions, it lists them and exits so you can pick one.
156
+
157
+ ### `spooling optimize`
158
+
159
+ Compact the local database. Removes duplicated tool-call rows left by older versions (before 0.2.6), truncates oversized previews, embeds any messages missing from search, and runs `VACUUM`. A backup is written next to the database first, and it is safe to re-run. `spooling sync` prints a hint when it finds duplicates.
160
+
161
+ ```bash
162
+ spooling optimize
163
+ spooling optimize --no-backup --no-vacuum --no-embed # skip individual steps
164
+ ```
132
165
 
133
166
  ### `spooling watch`
134
167
 
@@ -300,11 +333,15 @@ the CLI ships with a `spooling cloud` subcommand.
300
333
  Send the most recent local sessions up to the cloud:
301
334
 
302
335
  ```bash
303
- spooling push # 100 sessions, batches of 20
336
+ spooling push # syncs local sessions first, then pushes 100 in batches of 20
304
337
  spooling push --limit 500 # bigger backfill
338
+ spooling push --no-sync # push only what is already in the local database
339
+ spooling push --dry-run # show what would be pushed
305
340
  ```
306
341
 
307
- The server upserts by session id, so re-running is safe.
342
+ `push` ingests new provider sessions into the local database before uploading, so you don't need to run `spooling sync` first. The server upserts by session id, so re-running is safe.
343
+
344
+ To bring sessions from the cloud back down, use `spooling cloud pull`, or `spooling cloud sync` for both directions.
308
345
 
309
346
  ### Watch (continuous push)
310
347
 
@@ -338,8 +375,8 @@ The cloud API at `api.spooling.ai` uses `/v1/` endpoints. Requests require a Bea
338
375
  curl -s "https://api.spooling.ai/v1/stats" \
339
376
  -H "Authorization: Bearer $SPOOLING_KEY"
340
377
 
341
- # Search (when deployed — falls back to local search on self-hosted)
342
- curl -s "https://api.spooling.ai/api/search?q=migration&limit=10" \
378
+ # Search (matches session titles and ids)
379
+ curl -s "https://api.spooling.ai/v1/search?q=migration&limit=10" \
343
380
  -H "Authorization: Bearer $SPOOLING_KEY"
344
381
  ```
345
382
 
@@ -414,7 +451,8 @@ the full config snippet so you don't have to remember it.
414
451
  |------|---------|
415
452
  | `list_traces` | Recent Spooling traces, filterable by provider/project |
416
453
  | `get_trace` | Full detail for one trace: header, spans, eval scores |
417
- | `search_sessions` | Semantic search over embedded session chunks |
454
+ | `search_sessions` | Semantic search over embedded session chunks. Results include `session_id`; a session id in the query matches that session first |
455
+ | `get_session` | Full detail for one session by id or unique prefix, including the conversation text (`include_messages`, `max_messages`, `max_chars`) |
418
456
  | `get_stats` | Top-line stats: traces, tokens, cost, errors |
419
457
  | `get_top_vendors` | Most-used external vendors by tool-call count |
420
458
  | `list_evals` | Recent eval runs, optionally filtered by rubric |
@@ -426,8 +464,15 @@ the full config snippet so you don't have to remember it.
426
464
  For MCP clients that only speak stdio, run `spooling mcp --stdio` and register
427
465
  it with the command-based form:
428
466
 
429
- ```bash
430
- # MCP config for stdio transport
467
+ ```json
468
+ {
469
+ "mcpServers": {
470
+ "spooling": {
471
+ "command": "spooling",
472
+ "args": ["mcp", "--stdio"]
473
+ }
474
+ }
475
+ }
431
476
  ```
432
477
 
433
478
  ---
@@ -503,7 +548,9 @@ spooling/
503
548
  │ ├── parser.py # Session JSONL parser
504
549
  │ ├── embeddings.py # sentence-transformers (all-MiniLM-L6-v2)
505
550
  │ ├── ingest.py # Sync pipeline
506
- │ ├── search.py # Numpy cosine similarity search
551
+ │ ├── search.py # Numpy cosine similarity search (+ session-id matching)
552
+ │ ├── sessions.py # Session id lookup helpers
553
+ │ ├── optimize.py # Database dedupe / compaction
507
554
  │ ├── stats.py # Usage statistics
508
555
  │ ├── watcher.py # File watcher (watchdog)
509
556
  │ ├── agent.py # Chat agent (Ollama + Anthropic)
@@ -547,7 +594,6 @@ All optional — defaults work out of the box.
547
594
  | `SPOOLING_DB` | `~/.local/share/spooling/spooling.db` | Path to the SQLite database file (`%APPDATA%\spooling\spooling.db` on Windows) |
548
595
  | `SPOOLING_EMBEDDING_MODEL` | `all-MiniLM-L6-v2` | Sentence transformer model |
549
596
  | `SPOOLING_UI_HOST` | `127.0.0.1` | API server host |
550
- | `SPOOLING_UI_PORT` | `3001` | API server port (used by `spooling serve`) |
551
597
  | `SPOOLING_UI_DIR` | *(auto-detected)* | Path to the Next.js `ui/` directory (required for `spooling ui`) |
552
598
  | `ANTHROPIC_API_KEY` | - | Anthropic API key (alternative to setting in the GUI) |
553
599
 
@@ -568,5 +614,5 @@ All optional — defaults work out of the box.
568
614
 
569
615
  Run `spooling init` to see which providers are detected on your system.
570
616
 
571
- No data is sent to external servers. Everything runs locally.
617
+ 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.
572
618
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spooling"
3
- version = "0.2.6"
3
+ version = "0.2.8"
4
4
  description = "Local session tracker and semantic search for AI coding assistants"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Parsed Analytics, Inc." }]
@@ -138,9 +138,13 @@ def search(query, limit, project, cloud_mode):
138
138
  f"[dim]{ts[:19]}[/dim]"
139
139
  + (f" [cyan](cloud)[/cyan]" if source == "cloud" else "")
140
140
  )
141
- if r["title"]:
142
- console.print(f" [dim]Session:[/dim] {r['title']}")
143
- console.print(f" {r['content']}")
141
+ console.print(
142
+ f" [dim]Session:[/dim] {r['title'] or '(untitled)'} "
143
+ f"[cyan]{r['session_id']}[/cyan]"
144
+ + (" [yellow](id match)[/yellow]" if r.get("match") == "session_id" else "")
145
+ )
146
+ if r.get("match") != "session_id":
147
+ console.print(f" {r['content']}")
144
148
 
145
149
 
146
150
  @cli.command()
@@ -1131,9 +1135,27 @@ def redact(path, session_id, show):
1131
1135
  # Sessions
1132
1136
  # ---------------------------------------------------------------------------
1133
1137
 
1134
- @cli.group()
1138
+ class _SessionGroup(click.Group):
1139
+ """`spooling session <id>` is shorthand for `spooling session show <id>`."""
1140
+
1141
+ def resolve_command(self, ctx, args):
1142
+ try:
1143
+ return super().resolve_command(ctx, args)
1144
+ except click.UsageError:
1145
+ if args and not args[0].startswith("-") and "show" in self.commands:
1146
+ return "show", self.commands["show"], args
1147
+ raise
1148
+
1149
+
1150
+ @cli.group(cls=_SessionGroup)
1135
1151
  def session():
1136
- """Inspect and list local sessions."""
1152
+ """Inspect and list local sessions.
1153
+
1154
+ \b
1155
+ spooling session list list sessions with ids
1156
+ spooling session show ID -m read a session's full text
1157
+ spooling session ID -m same as 'show ID'
1158
+ """
1137
1159
  pass
1138
1160
 
1139
1161
 
@@ -1141,8 +1163,9 @@ def session():
1141
1163
  @click.option("--project", "-p", default=None, help="Filter by exact project name")
1142
1164
  @click.option("--cwd", default=None, help="Filter by cwd substring")
1143
1165
  @click.option("--title", default=None, help="Filter by title substring")
1166
+ @click.option("--id", "id_part", default=None, help="Filter by session id substring")
1144
1167
  @click.option("--limit", "-n", default=20, help="Max sessions to return (default: 20)")
1145
- def session_list(project, cwd, title, limit):
1168
+ def session_list(project, cwd, title, id_part, limit):
1146
1169
  """List local sessions with optional filters."""
1147
1170
  from spooling.db import get_connection
1148
1171
  from datetime import datetime as _dt
@@ -1159,6 +1182,9 @@ def session_list(project, cwd, title, limit):
1159
1182
  if title:
1160
1183
  wheres.append("title LIKE ?")
1161
1184
  params.append(f"%{title}%")
1185
+ if id_part:
1186
+ wheres.append("id LIKE ?")
1187
+ params.append(f"%{id_part}%")
1162
1188
  where_clause = ("WHERE " + " AND ".join(wheres)) if wheres else ""
1163
1189
  params.append(limit)
1164
1190
 
@@ -1176,8 +1202,8 @@ def session_list(project, cwd, title, limit):
1176
1202
  return
1177
1203
 
1178
1204
  table = Table(title=f"Sessions ({len(rows)})", show_lines=False)
1179
- table.add_column("ID", style="dim")
1180
- table.add_column("Provider", style="cyan")
1205
+ table.add_column("Session ID", style="cyan", no_wrap=True)
1206
+ table.add_column("Provider")
1181
1207
  table.add_column("Project")
1182
1208
  table.add_column("Title")
1183
1209
  table.add_column("Started", style="dim")
@@ -1194,7 +1220,7 @@ def session_list(project, cwd, title, limit):
1194
1220
  raw_ts = None
1195
1221
  ts = raw_ts.strftime("%m/%d %H:%M") if raw_ts else ""
1196
1222
  table.add_row(
1197
- (r["id"] or "")[:12],
1223
+ r["id"] or "",
1198
1224
  r["provider_id"] or "",
1199
1225
  _clean_project(r["project"] or ""),
1200
1226
  (r["title"] or "")[:45],
@@ -1208,15 +1234,30 @@ def session_list(project, cwd, title, limit):
1208
1234
 
1209
1235
  @session.command("show")
1210
1236
  @click.argument("session_id")
1211
- @click.option("--messages", "-m", is_flag=True, help="Show full message content")
1212
- def session_show(session_id, messages):
1213
- """Show full detail for a session including messages and tool calls."""
1237
+ @click.option("--messages", "-m", is_flag=True, help="Print the full session text (all messages)")
1238
+ @click.option("--max-chars", default=0, type=int, help="Truncate each message to N chars (0 = no limit)")
1239
+ def session_show(session_id, messages, max_chars):
1240
+ """Show a session by id (full id or unique prefix), optionally with its full text."""
1214
1241
  from spooling.stats import get_session_detail
1242
+ from spooling.sessions import resolve_session_ids
1243
+
1244
+ matches = resolve_session_ids(session_id)
1245
+ if len(matches) > 1:
1246
+ console.print(f"[yellow]'{session_id}' is ambiguous; matches:[/yellow]")
1247
+ for m in matches:
1248
+ console.print(f" {m}")
1249
+ raise SystemExit(1)
1250
+ if matches:
1251
+ session_id = matches[0]
1215
1252
 
1216
1253
  detail = get_session_detail(session_id)
1217
1254
  if not detail:
1218
- console.print(f"[yellow]Session not found: {session_id}[/yellow]")
1219
- return
1255
+ console.print(
1256
+ f"[yellow]Session not found: {session_id}[/yellow]\n"
1257
+ "[dim]Find ids with 'spooling session list --id PART' or 'spooling search QUERY'. "
1258
+ "If the session is recent, run 'spooling sync' first.[/dim]"
1259
+ )
1260
+ raise SystemExit(1)
1220
1261
 
1221
1262
  s = detail["session"]
1222
1263
  console.print(Panel(
@@ -1244,23 +1285,19 @@ def session_show(session_id, messages):
1244
1285
  console.print(table)
1245
1286
 
1246
1287
  if messages and detail["messages"]:
1247
- table = Table(title="Messages", show_lines=True)
1248
- table.add_column("Role", style="cyan", width=10)
1249
- table.add_column("Timestamp", style="dim", width=18)
1250
- table.add_column("Content")
1251
1288
  for m in detail["messages"]:
1252
- content = (m.get("content") or "")[:300]
1289
+ role = m.get("role") or ""
1290
+ color = "green" if role == "user" else "blue"
1291
+ console.print(f"\n[{color}]{role}[/{color}] [dim]{str(m.get('timestamp') or '')[:19]}[/dim]")
1292
+ content = m.get("content") or ""
1293
+ if max_chars and len(content) > max_chars:
1294
+ content = content[:max_chars] + "..."
1295
+ console.print(content, markup=False, highlight=False)
1253
1296
  if m.get("tool_calls"):
1254
1297
  tools = ", ".join(tc["name"] for tc in m["tool_calls"])
1255
- content += f"\n[dim]Tools: {tools}[/dim]"
1256
- table.add_row(
1257
- m.get("role", ""),
1258
- str(m.get("timestamp") or "")[:18],
1259
- content,
1260
- )
1261
- console.print(table)
1298
+ console.print(f"Tools: {tools}", style="dim", markup=False)
1262
1299
  elif not messages and detail["messages"]:
1263
- console.print(f"[dim]{len(detail['messages'])} messages — pass --messages to show content[/dim]")
1300
+ console.print(f"[dim]{len(detail['messages'])} messages — pass --messages to print the session text[/dim]")
1264
1301
 
1265
1302
 
1266
1303
  # ---------------------------------------------------------------------------
@@ -10,6 +10,7 @@ Tools exposed:
10
10
  - list_traces(limit, provider, project)
11
11
  - get_trace(trace_id)
12
12
  - search_sessions(query, limit, project)
13
+ - get_session(session_id, include_messages)
13
14
  - get_stats()
14
15
  - get_top_vendors()
15
16
  - list_evals(rubric_id, limit)
@@ -306,6 +307,10 @@ def search_sessions(
306
307
  ) -> list[dict]:
307
308
  """Semantic search over Spooling's embedded session chunks. Returns ranked matches.
308
309
 
310
+ Each result includes ``session_id`` and ``title``. A session id (or prefix,
311
+ e.g. ``ses_e6310bd1``) in the query matches that session directly and ranks
312
+ first. Use ``get_session`` to read a result's full conversation.
313
+
309
314
  Falls back to Spooling Cloud ILIKE search when the local DB has no
310
315
  embedded results and a cloud API key is configured. Cloud results
311
316
  include a ``_source: "cloud"`` field.
@@ -374,6 +379,53 @@ def search_sessions(
374
379
  ]
375
380
 
376
381
 
382
+ @mcp.tool()
383
+ def get_session(
384
+ session_id: str,
385
+ include_messages: bool = True,
386
+ max_messages: int = 200,
387
+ max_chars: int = 4000,
388
+ ) -> dict:
389
+ """Fetch one session by id, including its conversation text.
390
+
391
+ Use after ``search_sessions`` (or ``list_traces``) to read what was said.
392
+ Accepts a full session id or a unique prefix (e.g. ``ses_e6310bd1``).
393
+
394
+ Args:
395
+ session_id: Full session id or unique prefix.
396
+ include_messages: Include the message text (default True).
397
+ max_messages: Max messages to return, oldest first (default 200).
398
+ max_chars: Truncate each message to this many characters (0 = no limit).
399
+ """
400
+ from spooling.sessions import resolve_session_ids
401
+ from spooling.stats import get_session_detail
402
+
403
+ matches = resolve_session_ids(session_id)
404
+ if not matches:
405
+ return {"error": f"session not found: {session_id}"}
406
+ if len(matches) > 1:
407
+ return {"error": f"ambiguous session id: {session_id}", "matches": matches}
408
+
409
+ detail = get_session_detail(matches[0])
410
+ if not detail:
411
+ return {"error": f"session not found: {session_id}"}
412
+
413
+ out: dict = {
414
+ "session": detail["session"],
415
+ "tool_summary": detail["tool_summary"],
416
+ "message_count": len(detail["messages"]),
417
+ }
418
+ if include_messages:
419
+ msgs = detail["messages"][: max(0, max_messages)]
420
+ for m in msgs:
421
+ content = m.get("content") or ""
422
+ if max_chars and len(content) > max_chars:
423
+ m["content"] = content[:max_chars] + "..."
424
+ out["messages"] = msgs
425
+ out["truncated"] = len(detail["messages"]) > len(msgs)
426
+ return out
427
+
428
+
377
429
  @mcp.tool()
378
430
  def get_stats() -> dict:
379
431
  """Top-line Spooling stats: total traces, spans, tools, llm calls, cost, errors.
@@ -73,7 +73,40 @@ def _load_index(conn, dim: int) -> _Index:
73
73
 
74
74
 
75
75
  def search(query: str, limit: int = 10, project: str | None = None) -> list[dict]:
76
- """Search session chunks by semantic similarity."""
76
+ """Search session chunks by semantic similarity.
77
+
78
+ Session ids (full or partial) found in the query are matched directly and
79
+ ranked first, so ids are searchable alongside titles and content.
80
+ """
81
+ id_hits = _id_matches(query, project)
82
+ results = _semantic_search(query, limit, project)
83
+ seen = {r["session_id"] for r in id_hits}
84
+ merged = id_hits + [r for r in results if r["session_id"] not in seen]
85
+ return merged[:limit]
86
+
87
+
88
+ def _id_matches(query: str, project: str | None) -> list[dict]:
89
+ from spooling.sessions import find_sessions_by_id_in_query
90
+
91
+ out = []
92
+ for s in find_sessions_by_id_in_query(query):
93
+ if project and s["project"] != project:
94
+ continue
95
+ out.append({
96
+ "content": s["title"] or "(untitled)",
97
+ "role": "session",
98
+ "project": s["project"],
99
+ "timestamp": s["started_at"],
100
+ "session_id": s["id"],
101
+ "similarity": 1.0,
102
+ "title": s["title"],
103
+ "cwd": s["cwd"],
104
+ "match": "session_id",
105
+ })
106
+ return out
107
+
108
+
109
+ def _semantic_search(query: str, limit: int = 10, project: str | None = None) -> list[dict]:
77
110
  query_arr = np.array(embed_text(query), dtype=np.float32)
78
111
 
79
112
  conn = get_connection()