spooling 0.1.4__tar.gz → 0.1.6__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.1.4/spooling.egg-info → spooling-0.1.6}/PKG-INFO +24 -1
- {spooling-0.1.4 → spooling-0.1.6}/README.md +17 -0
- {spooling-0.1.4 → spooling-0.1.6}/pyproject.toml +8 -1
- {spooling-0.1.4 → spooling-0.1.6}/spooling/cli.py +40 -10
- {spooling-0.1.4 → spooling-0.1.6}/spooling/cloud.py +359 -0
- spooling-0.1.6/spooling/connectors/__init__.py +0 -0
- spooling-0.1.6/spooling/connectors/base.py +37 -0
- spooling-0.1.6/spooling/connectors/bigquery.py +140 -0
- spooling-0.1.6/spooling/connectors/clickhouse.py +111 -0
- spooling-0.1.6/spooling/connectors/factory.py +68 -0
- spooling-0.1.6/spooling/connectors/mongodb.py +103 -0
- spooling-0.1.6/spooling/connectors/mysql.py +129 -0
- spooling-0.1.6/spooling/connectors/postgresql.py +120 -0
- spooling-0.1.6/spooling/connectors/rest/__init__.py +0 -0
- spooling-0.1.6/spooling/connectors/rest/base.py +206 -0
- spooling-0.1.6/spooling/connectors/rest/shopify_connectors.py +172 -0
- spooling-0.1.6/spooling/connectors/snowflake.py +155 -0
- spooling-0.1.6/spooling/connectors/types.py +87 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/mcp_server.py +266 -8
- {spooling-0.1.4 → spooling-0.1.6}/spooling/server.py +330 -0
- {spooling-0.1.4 → spooling-0.1.6/spooling.egg-info}/PKG-INFO +24 -1
- {spooling-0.1.4 → spooling-0.1.6}/spooling.egg-info/SOURCES.txt +13 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling.egg-info/requires.txt +7 -0
- {spooling-0.1.4 → spooling-0.1.6}/LICENSE +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/setup.cfg +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/__init__.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/agent.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/classifiers.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/config.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/db.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/embeddings.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/evals.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/experiments.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/ingest.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/parser.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/pricing.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/__init__.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/antigravity.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/base.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/codex.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/copilot.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/cursor.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/gemini.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/github.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/gitlab.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/kiro.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/opencode.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/session_file.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/providers/windsurf.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/redact.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/remote_otel.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/sdk.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/search.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/stats.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/subscription_pricing.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/tracing.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling/watcher.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/tests/test_parser.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/tests/test_pricing.py +0 -0
- {spooling-0.1.4 → spooling-0.1.6}/tests/test_redact.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spooling
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Local session tracker and semantic search for AI coding assistants
|
|
5
5
|
Author: Parsed Analytics, Inc.
|
|
6
6
|
License-Expression: MIT
|
|
@@ -26,6 +26,12 @@ Requires-Dist: mcp>=1.27
|
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
28
|
Requires-Dist: pytest-mock>=3.14; extra == "dev"
|
|
29
|
+
Provides-Extra: connectors
|
|
30
|
+
Requires-Dist: snowflake-connector-python>=3.0; extra == "connectors"
|
|
31
|
+
Requires-Dist: google-cloud-bigquery>=3.0; extra == "connectors"
|
|
32
|
+
Requires-Dist: mysql-connector-python>=8.0; extra == "connectors"
|
|
33
|
+
Requires-Dist: clickhouse-connect>=0.5; extra == "connectors"
|
|
34
|
+
Requires-Dist: motor>=3.0; extra == "connectors"
|
|
29
35
|
Dynamic: license-file
|
|
30
36
|
|
|
31
37
|
# Spooling
|
|
@@ -278,6 +284,22 @@ What it does each cycle:
|
|
|
278
284
|
Cycles with no new work are silent. If a push fails the watermark is
|
|
279
285
|
not advanced, so the next cycle retries the same window.
|
|
280
286
|
|
|
287
|
+
### Cloud API endpoints (programmatic access)
|
|
288
|
+
|
|
289
|
+
The cloud API at `api.spooling.ai` uses `/v1/` endpoints. Requests require a Bearer token via the `Authorization` header:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
# Stats
|
|
293
|
+
curl -s "https://api.spooling.ai/v1/stats" \
|
|
294
|
+
-H "Authorization: Bearer $SPOOLING_KEY"
|
|
295
|
+
|
|
296
|
+
# Search (when deployed — falls back to local search on self-hosted)
|
|
297
|
+
curl -s "https://api.spooling.ai/api/search?q=migration&limit=10" \
|
|
298
|
+
-H "Authorization: Bearer $SPOOLING_KEY"
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
**Important**: `app.spooling.ai` is the Next.js frontend. For API access, use `api.spooling.ai` directly or set `API_URL=https://api.spooling.ai` when deploying the frontend so its `/api/*` rewrites reach the backend.
|
|
302
|
+
|
|
281
303
|
### Status / logout
|
|
282
304
|
|
|
283
305
|
```bash
|
|
@@ -485,6 +507,7 @@ All optional - defaults work out of the box for local development.
|
|
|
485
507
|
| `SPOOLING_EMBEDDING_MODEL` | `all-MiniLM-L6-v2` | Sentence transformer model |
|
|
486
508
|
| `SPOOLING_UI_HOST` | `127.0.0.1` | API server host |
|
|
487
509
|
| `ANTHROPIC_API_KEY` | - | Anthropic API key (alternative to setting in UI) |
|
|
510
|
+
| `API_URL` | `http://127.0.0.1:3002` | Backend API URL for Next.js rewrites (`/api/*`→`<API_URL>/api/*`). Set to `https://api.spooling.ai` in production. |
|
|
488
511
|
|
|
489
512
|
---
|
|
490
513
|
|
|
@@ -248,6 +248,22 @@ What it does each cycle:
|
|
|
248
248
|
Cycles with no new work are silent. If a push fails the watermark is
|
|
249
249
|
not advanced, so the next cycle retries the same window.
|
|
250
250
|
|
|
251
|
+
### Cloud API endpoints (programmatic access)
|
|
252
|
+
|
|
253
|
+
The cloud API at `api.spooling.ai` uses `/v1/` endpoints. Requests require a Bearer token via the `Authorization` header:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
# Stats
|
|
257
|
+
curl -s "https://api.spooling.ai/v1/stats" \
|
|
258
|
+
-H "Authorization: Bearer $SPOOLING_KEY"
|
|
259
|
+
|
|
260
|
+
# Search (when deployed — falls back to local search on self-hosted)
|
|
261
|
+
curl -s "https://api.spooling.ai/api/search?q=migration&limit=10" \
|
|
262
|
+
-H "Authorization: Bearer $SPOOLING_KEY"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**Important**: `app.spooling.ai` is the Next.js frontend. For API access, use `api.spooling.ai` directly or set `API_URL=https://api.spooling.ai` when deploying the frontend so its `/api/*` rewrites reach the backend.
|
|
266
|
+
|
|
251
267
|
### Status / logout
|
|
252
268
|
|
|
253
269
|
```bash
|
|
@@ -455,6 +471,7 @@ All optional - defaults work out of the box for local development.
|
|
|
455
471
|
| `SPOOLING_EMBEDDING_MODEL` | `all-MiniLM-L6-v2` | Sentence transformer model |
|
|
456
472
|
| `SPOOLING_UI_HOST` | `127.0.0.1` | API server host |
|
|
457
473
|
| `ANTHROPIC_API_KEY` | - | Anthropic API key (alternative to setting in UI) |
|
|
474
|
+
| `API_URL` | `http://127.0.0.1:3002` | Backend API URL for Next.js rewrites (`/api/*`→`<API_URL>/api/*`). Set to `https://api.spooling.ai` in production. |
|
|
458
475
|
|
|
459
476
|
---
|
|
460
477
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "spooling"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.6"
|
|
4
4
|
description = "Local session tracker and semantic search for AI coding assistants"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -39,6 +39,13 @@ dev = [
|
|
|
39
39
|
"pytest>=8.0",
|
|
40
40
|
"pytest-mock>=3.14",
|
|
41
41
|
]
|
|
42
|
+
connectors = [
|
|
43
|
+
"snowflake-connector-python>=3.0",
|
|
44
|
+
"google-cloud-bigquery>=3.0",
|
|
45
|
+
"mysql-connector-python>=8.0",
|
|
46
|
+
"clickhouse-connect>=0.5",
|
|
47
|
+
"motor>=3.0",
|
|
48
|
+
]
|
|
42
49
|
|
|
43
50
|
[tool.pytest.ini_options]
|
|
44
51
|
testpaths = ["tests"]
|
|
@@ -83,17 +83,22 @@ def watch():
|
|
|
83
83
|
@click.argument("query")
|
|
84
84
|
@click.option("-n", "--limit", default=10, help="Number of results")
|
|
85
85
|
@click.option("-p", "--project", default=None, help="Filter by project")
|
|
86
|
-
|
|
86
|
+
@click.option("--cloud", "cloud_mode", is_flag=True, help="Search cloud workspace instead of local DB")
|
|
87
|
+
def search(query, limit, project, cloud_mode):
|
|
87
88
|
"""Semantic search across session history."""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
if cloud_mode:
|
|
90
|
+
from spooling.cloud import cloud_search
|
|
91
|
+
results = cloud_search(query, limit=limit, project=project)
|
|
92
|
+
else:
|
|
93
|
+
from spooling.search import search as do_search
|
|
94
|
+
results = do_search(query, limit=limit, project=project)
|
|
91
95
|
|
|
92
96
|
if not results:
|
|
93
97
|
console.print("[yellow]No results found.[/yellow]")
|
|
94
98
|
return
|
|
95
99
|
|
|
96
100
|
for i, r in enumerate(results, 1):
|
|
101
|
+
source = r.get("_source", "local")
|
|
97
102
|
similarity = f"{r['similarity']:.1%}"
|
|
98
103
|
project_name = r["project"] or "unknown"
|
|
99
104
|
role = r["role"]
|
|
@@ -104,6 +109,7 @@ def search(query, limit, project):
|
|
|
104
109
|
f"[dim]{project_name}[/dim] "
|
|
105
110
|
f"[{'green' if role == 'user' else 'blue'}]{role}[/{'green' if role == 'user' else 'blue'}] "
|
|
106
111
|
f"[dim]{ts[:19]}[/dim]"
|
|
112
|
+
+ (f" [cyan](cloud)[/cyan]" if source == "cloud" else "")
|
|
107
113
|
)
|
|
108
114
|
if r["title"]:
|
|
109
115
|
console.print(f" [dim]Session:[/dim] {r['title']}")
|
|
@@ -113,8 +119,23 @@ def search(query, limit, project):
|
|
|
113
119
|
@cli.command()
|
|
114
120
|
@click.option("--week", is_flag=True, help="Show weekly breakdown")
|
|
115
121
|
@click.option("--days", default=7, help="Number of days for daily stats")
|
|
116
|
-
|
|
122
|
+
@click.option("--cloud", "cloud_mode", is_flag=True, help="Show stats from cloud workspace")
|
|
123
|
+
def stats(week, days, cloud_mode):
|
|
117
124
|
"""Show usage statistics."""
|
|
125
|
+
if cloud_mode:
|
|
126
|
+
from spooling.cloud import cloud_stats
|
|
127
|
+
data = cloud_stats()
|
|
128
|
+
if not data:
|
|
129
|
+
console.print("[yellow]No cloud stats available. Run 'spooling cloud login' first.[/yellow]")
|
|
130
|
+
return
|
|
131
|
+
console.print(Panel(
|
|
132
|
+
f"Sessions: [bold]{data.get('sessions', 0)}[/bold] | "
|
|
133
|
+
f"Cost: [bold]${float(data.get('cost', 0)):.2f}[/bold] est.",
|
|
134
|
+
title="[bold]Spooling Cloud Overview[/bold]",
|
|
135
|
+
style="cyan",
|
|
136
|
+
))
|
|
137
|
+
return
|
|
138
|
+
|
|
118
139
|
from spooling.stats import get_overview, get_daily_stats
|
|
119
140
|
|
|
120
141
|
overview = get_overview()
|
|
@@ -659,21 +680,30 @@ def pricing_show(model):
|
|
|
659
680
|
|
|
660
681
|
@cli.command()
|
|
661
682
|
@click.option("--stdio", is_flag=True, help="Use stdio transport (default is streamable-HTTP)")
|
|
662
|
-
|
|
683
|
+
@click.option("--local", "local_mode", flag_value="local", default=True, help="Local DB only, no cloud fallback")
|
|
684
|
+
@click.option("--cloud", "local_mode", flag_value="cloud", help="Cloud only, skip local DB")
|
|
685
|
+
@click.option("--hybrid", "local_mode", flag_value="hybrid", help="Local DB with cloud fallback (default)")
|
|
686
|
+
def mcp(stdio, local_mode):
|
|
663
687
|
"""Launch the Spooling MCP server.
|
|
664
688
|
|
|
665
689
|
Defaults to streamable-HTTP at http://127.0.0.1:3004/mcp so any
|
|
666
690
|
MCP-compatible agent (Codex, Cursor, web agents) can
|
|
667
691
|
connect by URL. Pass --stdio for stdio-only clients.
|
|
692
|
+
|
|
693
|
+
Data source modes:
|
|
694
|
+
\b
|
|
695
|
+
--hybrid Local DB with cloud fallback (default)
|
|
696
|
+
--local Local DB only
|
|
697
|
+
--cloud Cloud only
|
|
668
698
|
"""
|
|
699
|
+
from spooling.mcp_server import set_mode, serve_http as _serve_http, serve_stdio as _serve_stdio, MCP_URL
|
|
700
|
+
set_mode(local_mode or "hybrid")
|
|
669
701
|
if stdio:
|
|
670
|
-
from spooling.mcp_server import serve_stdio
|
|
671
702
|
console.print("[bold]Spooling MCP[/bold] over stdio")
|
|
672
|
-
|
|
703
|
+
_serve_stdio()
|
|
673
704
|
else:
|
|
674
|
-
from spooling.mcp_server import serve_http, MCP_URL
|
|
675
705
|
console.print(f"[bold]Spooling MCP[/bold] at {MCP_URL}")
|
|
676
|
-
|
|
706
|
+
_serve_http()
|
|
677
707
|
|
|
678
708
|
|
|
679
709
|
def _check_ollama_preflight() -> None:
|
|
@@ -766,3 +766,362 @@ def cloud_delete(project: str | None, cwd_substr: str | None, session_id: str |
|
|
|
766
766
|
return
|
|
767
767
|
|
|
768
768
|
console.print(f"[green]Deleted[/green] {deleted} session(s) from {base}")
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
# --- Pull (cloud → local) ---------------------------------------------------
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _store_cloud_session_locally(
|
|
775
|
+
session: dict,
|
|
776
|
+
messages: list[dict],
|
|
777
|
+
) -> str | None:
|
|
778
|
+
"""Upsert a cloud session + its messages into the local Spooling DB.
|
|
779
|
+
|
|
780
|
+
Returns the session id on success, ``None`` on DB error.
|
|
781
|
+
"""
|
|
782
|
+
from datetime import datetime as _dt
|
|
783
|
+
|
|
784
|
+
conn = get_connection()
|
|
785
|
+
try:
|
|
786
|
+
sid = session["id"]
|
|
787
|
+
started = _dt.fromisoformat(session["started_at"]) if session.get("started_at") else None
|
|
788
|
+
ended = _dt.fromisoformat(session["ended_at"]) if session.get("ended_at") else None
|
|
789
|
+
|
|
790
|
+
conn.execute(
|
|
791
|
+
"""INSERT INTO sessions (
|
|
792
|
+
id, provider_id, project, title, cwd,
|
|
793
|
+
started_at, ended_at, message_count, tool_call_count,
|
|
794
|
+
estimated_input_tokens, estimated_output_tokens, estimated_cost_usd, model
|
|
795
|
+
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
796
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
797
|
+
provider_id = EXCLUDED.provider_id,
|
|
798
|
+
project = EXCLUDED.project,
|
|
799
|
+
title = EXCLUDED.title,
|
|
800
|
+
cwd = EXCLUDED.cwd,
|
|
801
|
+
started_at = EXCLUDED.started_at,
|
|
802
|
+
ended_at = EXCLUDED.ended_at,
|
|
803
|
+
message_count = EXCLUDED.message_count,
|
|
804
|
+
tool_call_count = EXCLUDED.tool_call_count,
|
|
805
|
+
estimated_input_tokens = EXCLUDED.estimated_input_tokens,
|
|
806
|
+
estimated_output_tokens = EXCLUDED.estimated_output_tokens,
|
|
807
|
+
estimated_cost_usd = EXCLUDED.estimated_cost_usd,
|
|
808
|
+
model = EXCLUDED.model""",
|
|
809
|
+
(
|
|
810
|
+
sid,
|
|
811
|
+
session.get("provider_id", "cloud"),
|
|
812
|
+
session.get("project"),
|
|
813
|
+
session.get("title"),
|
|
814
|
+
session.get("cwd"),
|
|
815
|
+
started,
|
|
816
|
+
ended,
|
|
817
|
+
session.get("message_count", len(messages)),
|
|
818
|
+
session.get("tool_call_count", 0),
|
|
819
|
+
session.get("input_tokens", 0),
|
|
820
|
+
session.get("output_tokens", 0),
|
|
821
|
+
session.get("estimated_cost_usd", 0.0),
|
|
822
|
+
session.get("model"),
|
|
823
|
+
),
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
if messages:
|
|
827
|
+
for m in messages:
|
|
828
|
+
msg_id = f"{sid}-{m.get('sequence', 0)}"
|
|
829
|
+
ts = _dt.fromisoformat(m["timestamp"]) if m.get("timestamp") else None
|
|
830
|
+
conn.execute(
|
|
831
|
+
"""INSERT INTO messages (id, session_id, role, content, timestamp)
|
|
832
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
833
|
+
ON CONFLICT (id) DO NOTHING""",
|
|
834
|
+
(msg_id, sid, m["role"], m.get("content", ""), ts),
|
|
835
|
+
)
|
|
836
|
+
conn.commit()
|
|
837
|
+
return sid
|
|
838
|
+
except Exception as exc:
|
|
839
|
+
console.print(f"[red] Error storing session {session.get('id', '?')}: {exc}[/red]")
|
|
840
|
+
conn.rollback()
|
|
841
|
+
return None
|
|
842
|
+
finally:
|
|
843
|
+
conn.close()
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _pull_sessions(
|
|
847
|
+
limit: int,
|
|
848
|
+
batch: int,
|
|
849
|
+
base: str,
|
|
850
|
+
headers: dict,
|
|
851
|
+
since: datetime | None,
|
|
852
|
+
log,
|
|
853
|
+
) -> tuple[int, int, str | None]:
|
|
854
|
+
"""Fetch sessions from the cloud and store them locally.
|
|
855
|
+
|
|
856
|
+
Returns ``(accepted, skipped, error)``.
|
|
857
|
+
"""
|
|
858
|
+
params = {"limit": min(limit, 200), "offset": 0}
|
|
859
|
+
if since:
|
|
860
|
+
params["since"] = since.isoformat()
|
|
861
|
+
|
|
862
|
+
with httpx.Client(timeout=60) as client:
|
|
863
|
+
try:
|
|
864
|
+
r = client.get(f"{base}/v1/sessions", headers=headers, params=params)
|
|
865
|
+
r.raise_for_status()
|
|
866
|
+
except httpx.HTTPStatusError as e:
|
|
867
|
+
return 0, 0, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
|
|
868
|
+
except Exception as e:
|
|
869
|
+
return 0, 0, str(e)
|
|
870
|
+
|
|
871
|
+
data = r.json()
|
|
872
|
+
cloud_sessions = data.get("sessions", [])
|
|
873
|
+
if not cloud_sessions:
|
|
874
|
+
return 0, 0, None
|
|
875
|
+
|
|
876
|
+
accepted = 0
|
|
877
|
+
skipped = 0
|
|
878
|
+
for s in cloud_sessions:
|
|
879
|
+
try:
|
|
880
|
+
detail_r = client.get(
|
|
881
|
+
f"{base}/v1/sessions/{s['id']}",
|
|
882
|
+
headers=headers,
|
|
883
|
+
timeout=30,
|
|
884
|
+
)
|
|
885
|
+
if not detail_r.is_success:
|
|
886
|
+
skipped += 1
|
|
887
|
+
log(f" [yellow]skipped {s['id'][:12]}… ({detail_r.status_code})[/yellow]")
|
|
888
|
+
continue
|
|
889
|
+
detail = detail_r.json()
|
|
890
|
+
result = _store_cloud_session_locally(
|
|
891
|
+
detail.get("session", s),
|
|
892
|
+
detail.get("messages", []),
|
|
893
|
+
)
|
|
894
|
+
if result:
|
|
895
|
+
accepted += 1
|
|
896
|
+
else:
|
|
897
|
+
skipped += 1
|
|
898
|
+
except Exception as e:
|
|
899
|
+
skipped += 1
|
|
900
|
+
log(f" [yellow]error pulling {s.get('id', '?')[:12]}…: {e}[/yellow]")
|
|
901
|
+
|
|
902
|
+
return accepted, skipped, None
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
@cloud.command("pull")
|
|
906
|
+
@click.option("--limit", default=100, help="Max sessions to pull per run")
|
|
907
|
+
@click.option("--batch", default=10, help="Concurrent session detail fetches")
|
|
908
|
+
@click.option("--dry-run", is_flag=True, help="Show which sessions would be pulled. No writes.")
|
|
909
|
+
def pull(limit: int, batch: int, dry_run: bool):
|
|
910
|
+
"""Pull sessions from Spooling Cloud into the local database.
|
|
911
|
+
|
|
912
|
+
Reads the last-pull watermark from ``~/.config/spooling/cloud.json``
|
|
913
|
+
and fetches sessions synced since then. Combine with ``spooling push``
|
|
914
|
+
for a full bidirectional sync.
|
|
915
|
+
"""
|
|
916
|
+
headers = _auth_headers()
|
|
917
|
+
base = _api_base()
|
|
918
|
+
|
|
919
|
+
cfg = _load_config()
|
|
920
|
+
last = cfg.get("last_pull_at")
|
|
921
|
+
watermark: datetime | None = datetime.fromisoformat(last) if last else None
|
|
922
|
+
|
|
923
|
+
if watermark:
|
|
924
|
+
console.print(f"[dim]Last pull: {watermark.strftime('%Y-%m-%d %H:%M:%S UTC')}[/dim]")
|
|
925
|
+
|
|
926
|
+
# First fetch the list to show the preview.
|
|
927
|
+
params = {"limit": min(limit, 200), "offset": 0}
|
|
928
|
+
if watermark:
|
|
929
|
+
params["since"] = watermark.isoformat()
|
|
930
|
+
|
|
931
|
+
try:
|
|
932
|
+
r = httpx.get(f"{base}/v1/sessions", headers=headers, params=params, timeout=30)
|
|
933
|
+
r.raise_for_status()
|
|
934
|
+
except httpx.HTTPStatusError as e:
|
|
935
|
+
console.print(f"[red]HTTP {e.response.status_code}: {e.response.text[:200]}[/red]")
|
|
936
|
+
return
|
|
937
|
+
except Exception as e:
|
|
938
|
+
console.print(f"[red]{e}[/red]")
|
|
939
|
+
return
|
|
940
|
+
|
|
941
|
+
data = r.json()
|
|
942
|
+
cloud_sessions = data.get("sessions", [])
|
|
943
|
+
if not cloud_sessions:
|
|
944
|
+
console.print("[yellow]No new cloud sessions to pull.[/yellow]")
|
|
945
|
+
return
|
|
946
|
+
|
|
947
|
+
if dry_run:
|
|
948
|
+
console.print(
|
|
949
|
+
f"[dim]Dry run: {len(cloud_sessions)} session(s) would be "
|
|
950
|
+
f"pulled from {base}[/dim]"
|
|
951
|
+
)
|
|
952
|
+
for s in cloud_sessions[:20]:
|
|
953
|
+
title = (s.get("title") or "(untitled)")[:60]
|
|
954
|
+
by = s.get("pushed_by_name") or s.get("pushed_by_email") or "?"
|
|
955
|
+
console.print(
|
|
956
|
+
f" [cyan]{s.get('project') or '-'}[/cyan] {title} "
|
|
957
|
+
f"[dim](by {by})[/dim]"
|
|
958
|
+
)
|
|
959
|
+
if len(cloud_sessions) > 20:
|
|
960
|
+
console.print(f" ... and {len(cloud_sessions) - 20} more")
|
|
961
|
+
return
|
|
962
|
+
|
|
963
|
+
accepted, skipped, err = _pull_sessions(limit, batch, base, headers, watermark, console.print)
|
|
964
|
+
if err:
|
|
965
|
+
console.print(f"[red]{err}[/red]")
|
|
966
|
+
return
|
|
967
|
+
|
|
968
|
+
# Advance watermark
|
|
969
|
+
new_watermark = datetime.now(timezone.utc)
|
|
970
|
+
cfg = _load_config()
|
|
971
|
+
cfg["last_pull_at"] = new_watermark.isoformat()
|
|
972
|
+
_save_config(cfg)
|
|
973
|
+
|
|
974
|
+
console.print(
|
|
975
|
+
f"[green]Done.[/green] {accepted} session(s) pulled from {base}"
|
|
976
|
+
+ (f" ([yellow]{skipped} skipped[/yellow])" if skipped else "")
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
@cloud.command("sync")
|
|
981
|
+
@click.option("--limit", default=100, help="Max sessions per direction")
|
|
982
|
+
@click.option("--batch", default=20, help="Sessions per request")
|
|
983
|
+
@click.option(
|
|
984
|
+
"--project", default=None,
|
|
985
|
+
help="Only sync sessions whose project matches exactly.",
|
|
986
|
+
)
|
|
987
|
+
@click.option(
|
|
988
|
+
"--cwd", "cwd_substr", default=None,
|
|
989
|
+
help="Only sync sessions whose cwd contains this substring.",
|
|
990
|
+
)
|
|
991
|
+
@click.option(
|
|
992
|
+
"--title", "title_substr", default=None,
|
|
993
|
+
help="Only sync sessions whose title contains this substring.",
|
|
994
|
+
)
|
|
995
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be synced. No network writes.")
|
|
996
|
+
@click.option(
|
|
997
|
+
"--no-redact", "no_redact", is_flag=True,
|
|
998
|
+
help="Skip the client-side secret redactor.",
|
|
999
|
+
)
|
|
1000
|
+
def sync(limit: int, batch: int, project: str | None, cwd_substr: str | None, title_substr: str | None, dry_run: bool, no_redact: bool):
|
|
1001
|
+
"""Bidirectional sync: push local sessions up, then pull cloud sessions down.
|
|
1002
|
+
|
|
1003
|
+
Runs ``spooling push`` first, then ``spooling pull``, so after this
|
|
1004
|
+
command completes the local DB and the cloud workspace have the same
|
|
1005
|
+
sessions (subject to filter scoping).
|
|
1006
|
+
"""
|
|
1007
|
+
headers = _auth_headers()
|
|
1008
|
+
base = _api_base()
|
|
1009
|
+
|
|
1010
|
+
# Phase 1: Push local → cloud
|
|
1011
|
+
console.print("[bold]Phase 1: Push local → cloud[/bold]")
|
|
1012
|
+
local_sessions = _collect_sessions(
|
|
1013
|
+
limit=limit,
|
|
1014
|
+
since=None,
|
|
1015
|
+
project=project,
|
|
1016
|
+
cwd_substr=cwd_substr,
|
|
1017
|
+
title_substr=title_substr,
|
|
1018
|
+
)
|
|
1019
|
+
if not local_sessions:
|
|
1020
|
+
console.print(" [yellow]No local sessions to push.[/yellow]")
|
|
1021
|
+
else:
|
|
1022
|
+
if not no_redact:
|
|
1023
|
+
total_redactions = 0
|
|
1024
|
+
for s in local_sessions:
|
|
1025
|
+
_, n = redact_messages(s.get("messages") or [])
|
|
1026
|
+
total_redactions += n
|
|
1027
|
+
if total_redactions:
|
|
1028
|
+
console.print(f" [dim]Redacted {total_redactions} secret(s) before push.[/dim]")
|
|
1029
|
+
|
|
1030
|
+
if dry_run:
|
|
1031
|
+
console.print(f" [dim]Dry run: {len(local_sessions)} session(s) would be pushed[/dim]")
|
|
1032
|
+
else:
|
|
1033
|
+
pushed, rejected, err = _push_batches(local_sessions, batch, base, headers, console.print)
|
|
1034
|
+
if err:
|
|
1035
|
+
console.print(f" [red]{err}[/red]")
|
|
1036
|
+
else:
|
|
1037
|
+
note = f" ([yellow]{rejected} rejected[/yellow])" if rejected else ""
|
|
1038
|
+
console.print(f" [green]Pushed[/green] {pushed} session(s){note}")
|
|
1039
|
+
|
|
1040
|
+
# Phase 2: Pull cloud → local
|
|
1041
|
+
console.print("[bold]Phase 2: Pull cloud → local[/bold]")
|
|
1042
|
+
cfg = _load_config()
|
|
1043
|
+
last = cfg.get("last_pull_at")
|
|
1044
|
+
watermark: datetime | None = datetime.fromisoformat(last) if last else None
|
|
1045
|
+
|
|
1046
|
+
params = {"limit": min(limit, 200), "offset": 0}
|
|
1047
|
+
if watermark:
|
|
1048
|
+
params["since"] = watermark.isoformat()
|
|
1049
|
+
|
|
1050
|
+
try:
|
|
1051
|
+
r = httpx.get(f"{base}/v1/sessions", headers=headers, params=params, timeout=30)
|
|
1052
|
+
r.raise_for_status()
|
|
1053
|
+
except httpx.HTTPStatusError as e:
|
|
1054
|
+
console.print(f" [red]HTTP {e.response.status_code}: {e.response.text[:200]}[/red]")
|
|
1055
|
+
return
|
|
1056
|
+
except Exception as e:
|
|
1057
|
+
console.print(f" [red]{e}[/red]")
|
|
1058
|
+
return
|
|
1059
|
+
|
|
1060
|
+
data = r.json()
|
|
1061
|
+
cloud_sessions = data.get("sessions", [])
|
|
1062
|
+
if not cloud_sessions:
|
|
1063
|
+
console.print(" [yellow]No new cloud sessions to pull.[/yellow]")
|
|
1064
|
+
else:
|
|
1065
|
+
if dry_run:
|
|
1066
|
+
console.print(f" [dim]Dry run: {len(cloud_sessions)} session(s) would be pulled[/dim]")
|
|
1067
|
+
for s in cloud_sessions[:10]:
|
|
1068
|
+
title = (s.get("title") or "(untitled)")[:60]
|
|
1069
|
+
by = s.get("pushed_by_name") or s.get("pushed_by_email") or "?"
|
|
1070
|
+
console.print(f" [cyan]{s.get('project') or '-'}[/cyan] {title} [dim](by {by})[/dim]")
|
|
1071
|
+
else:
|
|
1072
|
+
accepted, skipped, err = _pull_sessions(limit, batch, base, headers, watermark, lambda msg: console.print(f" {msg}"))
|
|
1073
|
+
if err:
|
|
1074
|
+
console.print(f" [red]{err}[/red]")
|
|
1075
|
+
else:
|
|
1076
|
+
new_watermark = datetime.now(timezone.utc)
|
|
1077
|
+
cfg = _load_config()
|
|
1078
|
+
cfg["last_pull_at"] = new_watermark.isoformat()
|
|
1079
|
+
_save_config(cfg)
|
|
1080
|
+
skip_note = f" ([yellow]{skipped} skipped[/yellow])" if skipped else ""
|
|
1081
|
+
console.print(f" [green]Pulled[/green] {accepted} session(s){skip_note}")
|
|
1082
|
+
|
|
1083
|
+
console.print("[bold green]Bidirectional sync complete.[/bold green]")
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
# --- Cloud helpers for --cloud mode on search / stats / mcp ------------------
|
|
1087
|
+
|
|
1088
|
+
def cloud_search(query: str, limit: int = 10, project: str | None = None) -> list[dict]:
|
|
1089
|
+
"""Search Spooling Cloud via ILIKE. Returns list of result dicts."""
|
|
1090
|
+
headers = _auth_headers()
|
|
1091
|
+
base = _api_base()
|
|
1092
|
+
params: dict = {"q": query, "limit": limit}
|
|
1093
|
+
if project:
|
|
1094
|
+
params["project"] = project
|
|
1095
|
+
try:
|
|
1096
|
+
r = httpx.get(f"{base}/v1/search", headers=headers, params=params, timeout=15)
|
|
1097
|
+
r.raise_for_status()
|
|
1098
|
+
data = r.json()
|
|
1099
|
+
except Exception:
|
|
1100
|
+
return []
|
|
1101
|
+
sessions = data.get("sessions", [])
|
|
1102
|
+
return [
|
|
1103
|
+
{
|
|
1104
|
+
"content": f"{s.get('title') or '(untitled)'} — {s.get('project') or '?'}",
|
|
1105
|
+
"role": "system",
|
|
1106
|
+
"project": s.get("project"),
|
|
1107
|
+
"timestamp": s.get("started_at"),
|
|
1108
|
+
"session_id": s["id"],
|
|
1109
|
+
"similarity": 0.0,
|
|
1110
|
+
"title": s.get("title"),
|
|
1111
|
+
"cwd": None,
|
|
1112
|
+
"_source": "cloud",
|
|
1113
|
+
}
|
|
1114
|
+
for s in sessions
|
|
1115
|
+
]
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def cloud_stats() -> dict | None:
|
|
1119
|
+
"""Fetch workspace-level stats from Spooling Cloud. Returns dict or None."""
|
|
1120
|
+
headers = _auth_headers()
|
|
1121
|
+
base = _api_base()
|
|
1122
|
+
try:
|
|
1123
|
+
r = httpx.get(f"{base}/v1/stats", headers=headers, timeout=15)
|
|
1124
|
+
r.raise_for_status()
|
|
1125
|
+
return r.json()
|
|
1126
|
+
except Exception:
|
|
1127
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from .types import (
|
|
6
|
+
ColumnInfo,
|
|
7
|
+
ConnectionConfig,
|
|
8
|
+
QueryResult,
|
|
9
|
+
TableInfo,
|
|
10
|
+
TestConnectionResult,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DataConnector(ABC):
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def connect(self) -> None: ...
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
async def disconnect(self) -> None: ...
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def is_connected(self) -> bool: ...
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def test_connection(self) -> TestConnectionResult: ...
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def query(self, sql: str, params: list | None = None) -> QueryResult: ...
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
async def list_schemas(self) -> list[str]: ...
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def list_tables(self, schema: str | None = None) -> list[TableInfo]: ...
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def get_table_columns(self, schema: str, table: str) -> list[ColumnInfo]: ...
|