spooling 0.2.6__tar.gz → 0.2.7__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.6/spooling.egg-info → spooling-0.2.7}/PKG-INFO +64 -18
- {spooling-0.2.6 → spooling-0.2.7}/README.md +63 -17
- {spooling-0.2.6 → spooling-0.2.7}/pyproject.toml +1 -1
- {spooling-0.2.6 → spooling-0.2.7}/spooling/cli.py +38 -23
- {spooling-0.2.6 → spooling-0.2.7}/spooling/mcp_server.py +52 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/search.py +34 -1
- spooling-0.2.7/spooling/sessions.py +63 -0
- {spooling-0.2.6 → spooling-0.2.7/spooling.egg-info}/PKG-INFO +64 -18
- {spooling-0.2.6 → spooling-0.2.7}/spooling.egg-info/SOURCES.txt +2 -0
- spooling-0.2.7/tests/test_session_ids.py +135 -0
- {spooling-0.2.6 → spooling-0.2.7}/LICENSE +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/setup.cfg +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/__init__.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/agent.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/classifiers.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/cloud.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/config.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/__init__.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/base.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/factory.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/mysql.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/connectors/types.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/db.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/embeddings.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/evals.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/experiments.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/ingest.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/optimize.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/parser.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/pricing.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/__init__.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/antigravity.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/base.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/codex.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/copilot.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/cursor.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/gemini.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/github.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/gitlab.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/kiro.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/opencode.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/session_file.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/providers/windsurf.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/redact.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/remote_otel.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/schema.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/sdk.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/server.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/stats.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/subscription_pricing.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/tracing.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/tunnel.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/tunnel_auth.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling/watcher.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling.egg-info/requires.txt +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/tests/test_mcp_auth_middleware.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/tests/test_storage_optimizations.py +0 -0
- {spooling-0.2.6 → spooling-0.2.7}/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.7
|
|
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
|
-
**
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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
|
+
`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 #
|
|
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 (
|
|
378
|
-
curl -s "https://api.spooling.ai/
|
|
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
|
-
```
|
|
466
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
23
|
+
**Three steps. Local by default.**
|
|
24
24
|
|
|
25
25
|
### 01 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 `
|
|
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
|
|
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
|
|
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
|
|
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
|
+
`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 #
|
|
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 (
|
|
342
|
-
curl -s "https://api.spooling.ai/
|
|
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
|
-
```
|
|
430
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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()
|
|
@@ -1141,8 +1145,9 @@ def session():
|
|
|
1141
1145
|
@click.option("--project", "-p", default=None, help="Filter by exact project name")
|
|
1142
1146
|
@click.option("--cwd", default=None, help="Filter by cwd substring")
|
|
1143
1147
|
@click.option("--title", default=None, help="Filter by title substring")
|
|
1148
|
+
@click.option("--id", "id_part", default=None, help="Filter by session id substring")
|
|
1144
1149
|
@click.option("--limit", "-n", default=20, help="Max sessions to return (default: 20)")
|
|
1145
|
-
def session_list(project, cwd, title, limit):
|
|
1150
|
+
def session_list(project, cwd, title, id_part, limit):
|
|
1146
1151
|
"""List local sessions with optional filters."""
|
|
1147
1152
|
from spooling.db import get_connection
|
|
1148
1153
|
from datetime import datetime as _dt
|
|
@@ -1159,6 +1164,9 @@ def session_list(project, cwd, title, limit):
|
|
|
1159
1164
|
if title:
|
|
1160
1165
|
wheres.append("title LIKE ?")
|
|
1161
1166
|
params.append(f"%{title}%")
|
|
1167
|
+
if id_part:
|
|
1168
|
+
wheres.append("id LIKE ?")
|
|
1169
|
+
params.append(f"%{id_part}%")
|
|
1162
1170
|
where_clause = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
|
1163
1171
|
params.append(limit)
|
|
1164
1172
|
|
|
@@ -1176,8 +1184,8 @@ def session_list(project, cwd, title, limit):
|
|
|
1176
1184
|
return
|
|
1177
1185
|
|
|
1178
1186
|
table = Table(title=f"Sessions ({len(rows)})", show_lines=False)
|
|
1179
|
-
table.add_column("ID", style="
|
|
1180
|
-
table.add_column("Provider"
|
|
1187
|
+
table.add_column("Session ID", style="cyan", no_wrap=True)
|
|
1188
|
+
table.add_column("Provider")
|
|
1181
1189
|
table.add_column("Project")
|
|
1182
1190
|
table.add_column("Title")
|
|
1183
1191
|
table.add_column("Started", style="dim")
|
|
@@ -1194,7 +1202,7 @@ def session_list(project, cwd, title, limit):
|
|
|
1194
1202
|
raw_ts = None
|
|
1195
1203
|
ts = raw_ts.strftime("%m/%d %H:%M") if raw_ts else ""
|
|
1196
1204
|
table.add_row(
|
|
1197
|
-
|
|
1205
|
+
r["id"] or "",
|
|
1198
1206
|
r["provider_id"] or "",
|
|
1199
1207
|
_clean_project(r["project"] or ""),
|
|
1200
1208
|
(r["title"] or "")[:45],
|
|
@@ -1208,10 +1216,21 @@ def session_list(project, cwd, title, limit):
|
|
|
1208
1216
|
|
|
1209
1217
|
@session.command("show")
|
|
1210
1218
|
@click.argument("session_id")
|
|
1211
|
-
@click.option("--messages", "-m", is_flag=True, help="
|
|
1212
|
-
|
|
1213
|
-
|
|
1219
|
+
@click.option("--messages", "-m", is_flag=True, help="Print the full session text (all messages)")
|
|
1220
|
+
@click.option("--max-chars", default=0, type=int, help="Truncate each message to N chars (0 = no limit)")
|
|
1221
|
+
def session_show(session_id, messages, max_chars):
|
|
1222
|
+
"""Show a session by id (full id or unique prefix), optionally with its full text."""
|
|
1214
1223
|
from spooling.stats import get_session_detail
|
|
1224
|
+
from spooling.sessions import resolve_session_ids
|
|
1225
|
+
|
|
1226
|
+
matches = resolve_session_ids(session_id)
|
|
1227
|
+
if len(matches) > 1:
|
|
1228
|
+
console.print(f"[yellow]'{session_id}' is ambiguous; matches:[/yellow]")
|
|
1229
|
+
for m in matches:
|
|
1230
|
+
console.print(f" {m}")
|
|
1231
|
+
raise SystemExit(1)
|
|
1232
|
+
if matches:
|
|
1233
|
+
session_id = matches[0]
|
|
1215
1234
|
|
|
1216
1235
|
detail = get_session_detail(session_id)
|
|
1217
1236
|
if not detail:
|
|
@@ -1244,23 +1263,19 @@ def session_show(session_id, messages):
|
|
|
1244
1263
|
console.print(table)
|
|
1245
1264
|
|
|
1246
1265
|
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
1266
|
for m in detail["messages"]:
|
|
1252
|
-
|
|
1267
|
+
role = m.get("role") or ""
|
|
1268
|
+
color = "green" if role == "user" else "blue"
|
|
1269
|
+
console.print(f"\n[{color}]{role}[/{color}] [dim]{str(m.get('timestamp') or '')[:19]}[/dim]")
|
|
1270
|
+
content = m.get("content") or ""
|
|
1271
|
+
if max_chars and len(content) > max_chars:
|
|
1272
|
+
content = content[:max_chars] + "..."
|
|
1273
|
+
console.print(content, markup=False, highlight=False)
|
|
1253
1274
|
if m.get("tool_calls"):
|
|
1254
1275
|
tools = ", ".join(tc["name"] for tc in m["tool_calls"])
|
|
1255
|
-
|
|
1256
|
-
table.add_row(
|
|
1257
|
-
m.get("role", ""),
|
|
1258
|
-
str(m.get("timestamp") or "")[:18],
|
|
1259
|
-
content,
|
|
1260
|
-
)
|
|
1261
|
-
console.print(table)
|
|
1276
|
+
console.print(f"Tools: {tools}", style="dim", markup=False)
|
|
1262
1277
|
elif not messages and detail["messages"]:
|
|
1263
|
-
console.print(f"[dim]{len(detail['messages'])} messages — pass --messages to
|
|
1278
|
+
console.print(f"[dim]{len(detail['messages'])} messages — pass --messages to print the session text[/dim]")
|
|
1264
1279
|
|
|
1265
1280
|
|
|
1266
1281
|
# ---------------------------------------------------------------------------
|
|
@@ -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()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Session id helpers: resolve id prefixes and find ids mentioned in a search query."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from spooling.db import get_connection
|
|
6
|
+
|
|
7
|
+
# Tokens that look like (part of) a session id:
|
|
8
|
+
# - opencode style ids: ``ses_...``
|
|
9
|
+
# - UUIDs or hex prefixes of at least 8 chars (dashes allowed)
|
|
10
|
+
# Plain words and short numbers are never treated as ids.
|
|
11
|
+
_ID_TOKEN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_\-]{5,}")
|
|
12
|
+
_HEX_ID = re.compile(r"^(?=.*\d)[0-9a-fA-F][0-9a-fA-F\-]{7,}$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def looks_like_session_id(token: str) -> bool:
|
|
16
|
+
if token.isdigit():
|
|
17
|
+
return False
|
|
18
|
+
return token.lower().startswith("ses_") or bool(_HEX_ID.match(token))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _like_escape(s: str) -> str:
|
|
22
|
+
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_session_ids(ref: str, limit: int = 10) -> list[str]:
|
|
26
|
+
"""Resolve an exact id or id prefix to matching session ids."""
|
|
27
|
+
conn = get_connection()
|
|
28
|
+
try:
|
|
29
|
+
exact = conn.execute("SELECT id FROM sessions WHERE id = ?", (ref,)).fetchone()
|
|
30
|
+
if exact:
|
|
31
|
+
return [exact["id"]]
|
|
32
|
+
rows = conn.execute(
|
|
33
|
+
"SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\\' ORDER BY started_at DESC LIMIT ?",
|
|
34
|
+
(_like_escape(ref) + "%", limit),
|
|
35
|
+
).fetchall()
|
|
36
|
+
return [r["id"] for r in rows]
|
|
37
|
+
finally:
|
|
38
|
+
conn.close()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_sessions_by_id_in_query(query: str, limit: int = 10) -> list[dict]:
|
|
42
|
+
"""Find sessions whose id (or id prefix/substring) appears in a free-text query.
|
|
43
|
+
|
|
44
|
+
Lets semantic search accept a session id (full or partial) alongside or
|
|
45
|
+
instead of natural language.
|
|
46
|
+
"""
|
|
47
|
+
tokens = {t for t in _ID_TOKEN.findall(query) if looks_like_session_id(t)}
|
|
48
|
+
if not tokens:
|
|
49
|
+
return []
|
|
50
|
+
clauses, params = [], []
|
|
51
|
+
for t in tokens:
|
|
52
|
+
clauses.append("id LIKE ? ESCAPE '\\'")
|
|
53
|
+
params.append(f"%{_like_escape(t)}%")
|
|
54
|
+
conn = get_connection()
|
|
55
|
+
try:
|
|
56
|
+
rows = conn.execute(
|
|
57
|
+
f"SELECT id, title, project, cwd, started_at FROM sessions WHERE {' OR '.join(clauses)} "
|
|
58
|
+
"ORDER BY started_at DESC LIMIT ?",
|
|
59
|
+
tuple(params) + (limit,),
|
|
60
|
+
).fetchall()
|
|
61
|
+
return [dict(r) if not isinstance(r, dict) else r for r in rows]
|
|
62
|
+
finally:
|
|
63
|
+
conn.close()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spooling
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
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
|
-
**
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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
|
+
`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 #
|
|
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 (
|
|
378
|
-
curl -s "https://api.spooling.ai/
|
|
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
|
-
```
|
|
466
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -22,6 +22,7 @@ spooling/schema.py
|
|
|
22
22
|
spooling/sdk.py
|
|
23
23
|
spooling/search.py
|
|
24
24
|
spooling/server.py
|
|
25
|
+
spooling/sessions.py
|
|
25
26
|
spooling/stats.py
|
|
26
27
|
spooling/subscription_pricing.py
|
|
27
28
|
spooling/tracing.py
|
|
@@ -62,5 +63,6 @@ spooling/providers/opencode.py
|
|
|
62
63
|
spooling/providers/session_file.py
|
|
63
64
|
spooling/providers/windsurf.py
|
|
64
65
|
tests/test_mcp_auth_middleware.py
|
|
66
|
+
tests/test_session_ids.py
|
|
65
67
|
tests/test_storage_optimizations.py
|
|
66
68
|
tests/test_tunnel_auth.py
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Tests for session-id lookup: id detection, prefix resolution, id-aware search,
|
|
2
|
+
the MCP get_session tool, and the `session` CLI commands."""
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pytest
|
|
8
|
+
from click.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from spooling import db as db_mod
|
|
11
|
+
from spooling import ingest, search as search_mod
|
|
12
|
+
from spooling.parser import ParsedMessage, ParsedSession
|
|
13
|
+
from spooling.sessions import (
|
|
14
|
+
find_sessions_by_id_in_query,
|
|
15
|
+
looks_like_session_id,
|
|
16
|
+
resolve_session_ids,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
NOW = datetime(2026, 11, 2, tzinfo=timezone.utc)
|
|
20
|
+
|
|
21
|
+
OC1 = "ses_e6310bd1fffezQhC7EHnDnrp3R"
|
|
22
|
+
OC2 = "ses_e6310cafefffeAAAAAAAAAAAAA"
|
|
23
|
+
UU1 = "0b104f33-1ffe-4a2b-9c3d-123456789abc"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def conn(tmp_path, monkeypatch):
|
|
28
|
+
monkeypatch.setattr(db_mod, "DB_PATH", tmp_path / "spooling.db")
|
|
29
|
+
c = db_mod.get_connection()
|
|
30
|
+
for sid, title in [(OC1, "login issue"), (OC2, "other work"), (UU1, "uuid session")]:
|
|
31
|
+
ingest._store_session(c, ParsedSession(
|
|
32
|
+
session_id=sid, project="proj", started_at=NOW, ended_at=NOW,
|
|
33
|
+
messages=[
|
|
34
|
+
ParsedMessage(uuid=f"{sid}-1", session_id=sid, role="user", content=f"hello from {title}", timestamp=NOW),
|
|
35
|
+
ParsedMessage(uuid=f"{sid}-2", session_id=sid, role="assistant", content="reply " + "x" * 50, timestamp=NOW),
|
|
36
|
+
],
|
|
37
|
+
))
|
|
38
|
+
c.execute("UPDATE sessions SET title = ? WHERE id = ?", (title, sid))
|
|
39
|
+
c.commit()
|
|
40
|
+
yield c
|
|
41
|
+
c.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize("token,expected", [
|
|
45
|
+
("ses_e6310bd1", True),
|
|
46
|
+
("SES_E6310BD1", True),
|
|
47
|
+
("e6310bd1fffe", True),
|
|
48
|
+
("0b104f33-1ffe-4a2b", True),
|
|
49
|
+
("password", False),
|
|
50
|
+
("authentication", False),
|
|
51
|
+
("deadbeef", False), # hex-looking word, no digit
|
|
52
|
+
("12345678", False), # plain number
|
|
53
|
+
("login-issue", False),
|
|
54
|
+
])
|
|
55
|
+
def test_looks_like_session_id(token, expected):
|
|
56
|
+
assert looks_like_session_id(token) is expected
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_resolve_exact_prefix_ambiguous_and_missing(conn):
|
|
60
|
+
assert resolve_session_ids(OC1) == [OC1]
|
|
61
|
+
assert resolve_session_ids("ses_e6310bd1") == [OC1]
|
|
62
|
+
assert sorted(resolve_session_ids("ses_e6310")) == sorted([OC1, OC2])
|
|
63
|
+
assert resolve_session_ids("nope") == []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_resolve_escapes_like_wildcards(conn):
|
|
67
|
+
assert resolve_session_ids("%") == []
|
|
68
|
+
assert resolve_session_ids("ses_%") == [] # '%' is literal, not a wildcard
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_find_by_id_in_query(conn):
|
|
72
|
+
hits = find_sessions_by_id_in_query("what happened in ses_e6310bd1 yesterday")
|
|
73
|
+
assert [h["id"] for h in hits] == [OC1]
|
|
74
|
+
assert find_sessions_by_id_in_query("how do I fix authentication") == []
|
|
75
|
+
assert [h["id"] for h in find_sessions_by_id_in_query("0b104f33-1ffe")] == [UU1]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_search_ranks_id_match_first(conn, monkeypatch):
|
|
79
|
+
# Stub the semantic side so the test needs no embedding model.
|
|
80
|
+
monkeypatch.setattr(search_mod, "_semantic_search", lambda q, limit, project: [
|
|
81
|
+
{"content": "other", "role": "user", "project": "proj", "timestamp": "t",
|
|
82
|
+
"session_id": OC2, "similarity": 0.9, "title": "other work", "cwd": None},
|
|
83
|
+
{"content": "dup", "role": "user", "project": "proj", "timestamp": "t",
|
|
84
|
+
"session_id": OC1, "similarity": 0.5, "title": "login issue", "cwd": None},
|
|
85
|
+
])
|
|
86
|
+
res = search_mod.search("ses_e6310bd1fffe", limit=5)
|
|
87
|
+
assert res[0]["session_id"] == OC1
|
|
88
|
+
assert res[0]["match"] == "session_id"
|
|
89
|
+
assert res[0]["similarity"] == 1.0
|
|
90
|
+
# no duplicate of the id-matched session from the semantic results
|
|
91
|
+
assert [r["session_id"] for r in res].count(OC1) == 1
|
|
92
|
+
assert len(res) == 2
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_search_id_match_respects_project_and_limit(conn, monkeypatch):
|
|
96
|
+
monkeypatch.setattr(search_mod, "_semantic_search", lambda q, limit, project: [])
|
|
97
|
+
assert search_mod.search("ses_e6310bd1", project="other") == []
|
|
98
|
+
assert len(search_mod.search("ses_e6310", limit=1)) == 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_mcp_get_session(conn):
|
|
102
|
+
from spooling import mcp_server
|
|
103
|
+
|
|
104
|
+
out = mcp_server.get_session("ses_e6310bd1")
|
|
105
|
+
assert out["session"]["id"] == OC1
|
|
106
|
+
assert out["message_count"] == 2
|
|
107
|
+
assert [m["role"] for m in out["messages"]] == ["user", "assistant"]
|
|
108
|
+
|
|
109
|
+
short = mcp_server.get_session(OC1, max_chars=5)
|
|
110
|
+
assert short["messages"][1]["content"].endswith("...")
|
|
111
|
+
|
|
112
|
+
capped = mcp_server.get_session(OC1, max_messages=1)
|
|
113
|
+
assert len(capped["messages"]) == 1 and capped["truncated"] is True
|
|
114
|
+
|
|
115
|
+
assert "messages" not in mcp_server.get_session(OC1, include_messages=False)
|
|
116
|
+
assert "ambiguous" in mcp_server.get_session("ses_e6310")["error"]
|
|
117
|
+
assert "not found" in mcp_server.get_session("zzz")["error"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_cli_session_show_and_list(conn):
|
|
121
|
+
from spooling.cli import cli
|
|
122
|
+
|
|
123
|
+
r = CliRunner()
|
|
124
|
+
res = r.invoke(cli, ["session", "show", "ses_e6310bd1", "-m"])
|
|
125
|
+
assert res.exit_code == 0, res.output
|
|
126
|
+
assert "hello from login issue" in res.output
|
|
127
|
+
|
|
128
|
+
res = r.invoke(cli, ["session", "show", "ses_e6310"])
|
|
129
|
+
assert res.exit_code == 1
|
|
130
|
+
assert OC1 in res.output and OC2 in res.output
|
|
131
|
+
|
|
132
|
+
res = r.invoke(cli, ["session", "list", "--id", "0b104f33"], env={"COLUMNS": "200"})
|
|
133
|
+
assert res.exit_code == 0
|
|
134
|
+
assert UU1 in res.output
|
|
135
|
+
assert OC1 not in res.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|