spooling 0.1.8__tar.gz → 0.2.0__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.8/spooling.egg-info → spooling-0.2.0}/PKG-INFO +3 -3
- {spooling-0.1.8 → spooling-0.2.0}/pyproject.toml +3 -3
- {spooling-0.1.8 → spooling-0.2.0}/spooling/cli.py +124 -12
- {spooling-0.1.8 → spooling-0.2.0}/spooling/config.py +20 -13
- spooling-0.2.0/spooling/db.py +164 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/ingest.py +32 -14
- {spooling-0.1.8 → spooling-0.2.0}/spooling/mcp_server.py +51 -2
- spooling-0.2.0/spooling/schema.py +310 -0
- spooling-0.2.0/spooling/search.py +86 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/server.py +11 -7
- {spooling-0.1.8 → spooling-0.2.0}/spooling/stats.py +70 -59
- {spooling-0.1.8 → spooling-0.2.0}/spooling/tunnel.py +10 -23
- spooling-0.2.0/spooling/tunnel_auth.py +89 -0
- {spooling-0.1.8 → spooling-0.2.0/spooling.egg-info}/PKG-INFO +3 -3
- {spooling-0.1.8 → spooling-0.2.0}/spooling.egg-info/SOURCES.txt +5 -1
- {spooling-0.1.8 → spooling-0.2.0}/spooling.egg-info/requires.txt +2 -2
- spooling-0.2.0/tests/test_mcp_auth_middleware.py +119 -0
- spooling-0.2.0/tests/test_tunnel_auth.py +177 -0
- spooling-0.1.8/spooling/db.py +0 -21
- spooling-0.1.8/spooling/search.py +0 -68
- {spooling-0.1.8 → spooling-0.2.0}/LICENSE +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/README.md +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/setup.cfg +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/__init__.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/agent.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/classifiers.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/cloud.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/__init__.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/base.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/factory.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/mysql.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/types.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/embeddings.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/evals.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/experiments.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/parser.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/pricing.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/__init__.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/antigravity.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/base.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/codex.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/copilot.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/cursor.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/gemini.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/github.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/gitlab.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/kiro.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/opencode.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/session_file.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/windsurf.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/redact.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/remote_otel.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/sdk.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/subscription_pricing.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/tracing.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling/watcher.py +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.1.8 → spooling-0.2.0}/spooling.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spooling
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Local session tracker and semantic search for AI coding assistants
|
|
5
5
|
Author: Parsed Analytics, Inc.
|
|
6
6
|
License: MIT
|
|
@@ -11,8 +11,8 @@ Requires-Python: >=3.11
|
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Requires-Dist: click>=8.1
|
|
14
|
-
Requires-Dist: psycopg[binary]>=3.1
|
|
15
14
|
Requires-Dist: sentence-transformers>=3.0
|
|
15
|
+
Requires-Dist: numpy>=1.24
|
|
16
16
|
Requires-Dist: fastapi>=0.111
|
|
17
17
|
Requires-Dist: uvicorn[standard]>=0.30
|
|
18
18
|
Requires-Dist: jinja2>=3.1
|
|
@@ -22,7 +22,7 @@ Requires-Dist: httpx>=0.27
|
|
|
22
22
|
Requires-Dist: anthropic>=0.40
|
|
23
23
|
Requires-Dist: strands-agents[ollama]>=1.35
|
|
24
24
|
Requires-Dist: strands-agents-evals>=0.1
|
|
25
|
-
Requires-Dist: mcp
|
|
25
|
+
Requires-Dist: mcp<2,>=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"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "spooling"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
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." }]
|
|
@@ -13,8 +13,8 @@ classifiers = [
|
|
|
13
13
|
]
|
|
14
14
|
dependencies = [
|
|
15
15
|
"click>=8.1",
|
|
16
|
-
"psycopg[binary]>=3.1",
|
|
17
16
|
"sentence-transformers>=3.0",
|
|
17
|
+
"numpy>=1.24",
|
|
18
18
|
"fastapi>=0.111",
|
|
19
19
|
"uvicorn[standard]>=0.30",
|
|
20
20
|
"jinja2>=3.1",
|
|
@@ -24,7 +24,7 @@ dependencies = [
|
|
|
24
24
|
"anthropic>=0.40",
|
|
25
25
|
"strands-agents[ollama]>=1.35",
|
|
26
26
|
"strands-agents-evals>=0.1",
|
|
27
|
-
"mcp>=1.27",
|
|
27
|
+
"mcp>=1.27,<2",
|
|
28
28
|
]
|
|
29
29
|
|
|
30
30
|
[project.optional-dependencies]
|
|
@@ -26,18 +26,17 @@ def cli():
|
|
|
26
26
|
def init():
|
|
27
27
|
"""Check database connection and show provider status."""
|
|
28
28
|
from spooling.db import check_db
|
|
29
|
-
from spooling.config import DATABASE_URL
|
|
30
29
|
from spooling.providers import get_all_providers
|
|
31
30
|
|
|
32
31
|
console.print(Panel("[bold]Spooling[/bold] - Session Tracker", style="blue"))
|
|
33
32
|
|
|
34
33
|
# Check DB
|
|
34
|
+
from spooling.config import DB_PATH
|
|
35
35
|
if check_db():
|
|
36
|
-
console.print("[green]Database
|
|
36
|
+
console.print(f"[green]Database ready[/green] [dim]({DB_PATH})[/dim]")
|
|
37
37
|
else:
|
|
38
|
-
console.print("[red]Cannot
|
|
39
|
-
console.print(
|
|
40
|
-
console.print(" Run: [bold]docker compose up -d[/bold]")
|
|
38
|
+
console.print(f"[red]Cannot open database at {DB_PATH}.[/red]")
|
|
39
|
+
console.print(" Check that the directory is writable.")
|
|
41
40
|
return
|
|
42
41
|
|
|
43
42
|
# Check all providers
|
|
@@ -215,7 +214,14 @@ def stats(week, days, cloud_mode):
|
|
|
215
214
|
table.add_column("Cost", justify="right")
|
|
216
215
|
for r in overview["recent_sessions"]:
|
|
217
216
|
proj = _clean_project(r["project"] or "")
|
|
218
|
-
|
|
217
|
+
raw_ts = r["started_at"]
|
|
218
|
+
if raw_ts and isinstance(raw_ts, str):
|
|
219
|
+
from datetime import datetime as _dt
|
|
220
|
+
try:
|
|
221
|
+
raw_ts = _dt.fromisoformat(raw_ts)
|
|
222
|
+
except ValueError:
|
|
223
|
+
raw_ts = None
|
|
224
|
+
ts = raw_ts.strftime("%m/%d %H:%M") if raw_ts else ""
|
|
219
225
|
title = (r["title"] or "")[:50]
|
|
220
226
|
table.add_row(
|
|
221
227
|
ts, proj, title,
|
|
@@ -684,27 +690,48 @@ def pricing_show(model):
|
|
|
684
690
|
def tunnel(port, name):
|
|
685
691
|
"""Start a Cloudflare tunnel to expose a local MCP server.
|
|
686
692
|
|
|
687
|
-
Creates a quick tunnel (no account required) that exposes your local
|
|
688
|
-
|
|
689
|
-
MCP-compatible client.
|
|
693
|
+
Creates a quick tunnel (no account required) that exposes your local MCP
|
|
694
|
+
server to the internet. The tunnel URL can be used by any
|
|
695
|
+
MCP-compatible client on any device.
|
|
696
|
+
|
|
697
|
+
If a tunnel token has been generated (via [bold]spooling token generate[/bold]),
|
|
698
|
+
the printed config snippet includes the Bearer auth header automatically.
|
|
690
699
|
|
|
691
700
|
Examples:
|
|
692
701
|
\b
|
|
693
702
|
spooling tunnel # Tunnel Spooling MCP (port 3004)
|
|
694
703
|
spooling tunnel --port 8090 # Tunnel a custom MCP server
|
|
695
|
-
spooling tunnel -p 3000 -n my-api # Named tunnel
|
|
696
704
|
"""
|
|
705
|
+
import json as _json
|
|
697
706
|
from spooling.tunnel import start_tunnel
|
|
707
|
+
from spooling.tunnel_auth import load_token
|
|
708
|
+
|
|
709
|
+
tok = load_token()
|
|
710
|
+
|
|
711
|
+
if tok:
|
|
712
|
+
console.print()
|
|
713
|
+
console.print(f"[bold cyan]Tunnel token:[/bold cyan] [bold]{tok}[/bold]")
|
|
714
|
+
console.print("[dim]The MCP server will require this token on every request.[/dim]")
|
|
715
|
+
console.print()
|
|
716
|
+
else:
|
|
717
|
+
console.print()
|
|
718
|
+
console.print("[yellow]No tunnel token configured — the endpoint will be unauthenticated.[/yellow]")
|
|
719
|
+
console.print("[dim]Run [bold]spooling token generate[/bold] then restart [bold]spooling mcp[/bold] to secure it.[/dim]")
|
|
720
|
+
console.print()
|
|
698
721
|
|
|
699
722
|
display_name = name or f"port {port}"
|
|
700
723
|
console.print(f"[bold]Starting tunnel for {display_name}...[/bold]")
|
|
701
724
|
|
|
702
725
|
url = start_tunnel(port=port, name=name)
|
|
703
726
|
if url:
|
|
704
|
-
#
|
|
727
|
+
# Build MCP config snippet — include auth header when a token is set.
|
|
728
|
+
mcp_server_config: dict = {"type": "http", "url": f"{url}/mcp"}
|
|
729
|
+
if tok:
|
|
730
|
+
mcp_server_config["headers"] = {"Authorization": f"Bearer {tok}"}
|
|
731
|
+
snippet = _json.dumps({"mcpServers": {"spooling": mcp_server_config}}, indent=2)
|
|
705
732
|
console.print()
|
|
706
733
|
console.print("[dim]Add this to your MCP client config:[/dim]")
|
|
707
|
-
console.print(f
|
|
734
|
+
console.print(f"[bold]{snippet}[/bold]")
|
|
708
735
|
console.print()
|
|
709
736
|
|
|
710
737
|
|
|
@@ -826,5 +853,90 @@ cli.add_command(_cloud_group)
|
|
|
826
853
|
cli.add_command(_push_cmd)
|
|
827
854
|
|
|
828
855
|
|
|
856
|
+
# ---------------------------------------------------------------------------
|
|
857
|
+
# Token management
|
|
858
|
+
# ---------------------------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
@cli.group()
|
|
861
|
+
def token():
|
|
862
|
+
"""Manage the MCP tunnel authentication token.
|
|
863
|
+
|
|
864
|
+
The token secures the public tunnel endpoint so only authorised clients
|
|
865
|
+
can query your sessions. Generate it once, then restart the MCP server
|
|
866
|
+
to enforce it.
|
|
867
|
+
|
|
868
|
+
\b
|
|
869
|
+
Quick-start:
|
|
870
|
+
spooling token generate # create & save a token
|
|
871
|
+
spooling mcp # MCP server now requires the token
|
|
872
|
+
spooling tunnel # shows the config snippet with the token
|
|
873
|
+
"""
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
@token.command("generate")
|
|
877
|
+
@click.option("--force", "-f", is_flag=True, help="Replace an existing token without prompting.")
|
|
878
|
+
def token_generate(force):
|
|
879
|
+
"""Generate and save a new tunnel token.
|
|
880
|
+
|
|
881
|
+
Replaces any existing token. Restart the MCP server afterwards so it
|
|
882
|
+
picks up the new value.
|
|
883
|
+
"""
|
|
884
|
+
from spooling.tunnel_auth import generate_token, load_token, save_token
|
|
885
|
+
|
|
886
|
+
existing = load_token()
|
|
887
|
+
if existing and not force:
|
|
888
|
+
console.print("[yellow]A token already exists.[/yellow] Use [bold]--force[/bold] to replace it, or [bold]spooling token show[/bold] to view it.")
|
|
889
|
+
return
|
|
890
|
+
|
|
891
|
+
tok = generate_token()
|
|
892
|
+
save_token(tok)
|
|
893
|
+
console.print()
|
|
894
|
+
console.print("[green]Token generated and saved.[/green]")
|
|
895
|
+
console.print()
|
|
896
|
+
console.print(f" [bold cyan]Token:[/bold cyan] [bold]{tok}[/bold]")
|
|
897
|
+
console.print()
|
|
898
|
+
console.print("[dim]Next steps:[/dim]")
|
|
899
|
+
console.print(" 1. Restart [bold]spooling mcp[/bold] so it enforces the token.")
|
|
900
|
+
console.print(" 2. Run [bold]spooling tunnel[/bold] to get a ready-to-paste MCP config snippet.")
|
|
901
|
+
console.print()
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
@token.command("show")
|
|
905
|
+
def token_show():
|
|
906
|
+
"""Print the current tunnel token."""
|
|
907
|
+
from spooling.tunnel_auth import load_token, TOKEN_ENV_VAR, _TOKEN_FILE
|
|
908
|
+
|
|
909
|
+
tok = load_token()
|
|
910
|
+
if not tok:
|
|
911
|
+
console.print("[yellow]No token configured.[/yellow] Run [bold]spooling token generate[/bold] to create one.")
|
|
912
|
+
return
|
|
913
|
+
|
|
914
|
+
source = f"env var {TOKEN_ENV_VAR}" if __import__("os").environ.get(TOKEN_ENV_VAR) else str(_TOKEN_FILE)
|
|
915
|
+
console.print()
|
|
916
|
+
console.print(f" [bold cyan]Token:[/bold cyan] [bold]{tok}[/bold]")
|
|
917
|
+
console.print(f" [dim]Source: {source}[/dim]")
|
|
918
|
+
console.print()
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
@token.command("rotate")
|
|
922
|
+
def token_rotate():
|
|
923
|
+
"""Generate a new token, replacing the current one.
|
|
924
|
+
|
|
925
|
+
Equivalent to [bold]spooling token generate --force[/bold].
|
|
926
|
+
Restart the MCP server afterwards.
|
|
927
|
+
"""
|
|
928
|
+
from spooling.tunnel_auth import generate_token, save_token
|
|
929
|
+
|
|
930
|
+
tok = generate_token()
|
|
931
|
+
save_token(tok)
|
|
932
|
+
console.print()
|
|
933
|
+
console.print("[green]Token rotated.[/green]")
|
|
934
|
+
console.print()
|
|
935
|
+
console.print(f" [bold cyan]New token:[/bold cyan] [bold]{tok}[/bold]")
|
|
936
|
+
console.print()
|
|
937
|
+
console.print("[dim]Restart [bold]spooling mcp[/bold] to enforce the new token.[/dim]")
|
|
938
|
+
console.print()
|
|
939
|
+
|
|
940
|
+
|
|
829
941
|
if __name__ == "__main__":
|
|
830
942
|
cli()
|
|
@@ -1,32 +1,39 @@
|
|
|
1
1
|
"""Configuration for Spooling."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import sys
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
# Legacy session data directory (JSONL-format sessions)
|
|
7
8
|
SESSIONS_DIR = Path.home() / ".sessions"
|
|
8
9
|
SESSIONS_PROJECTS_DIR = SESSIONS_DIR / "projects"
|
|
9
10
|
|
|
10
|
-
# Snowflake Cortex Code data directory.
|
|
11
|
-
# ~/.snowflake/cortex/conversations/<uuid>.history.jsonl with a sidecar
|
|
12
|
-
# <uuid>.json carrying title, working_directory, git info, and timestamps.
|
|
11
|
+
# Snowflake Cortex Code data directory.
|
|
13
12
|
CORTEX_DIR = Path.home() / ".snowflake" / "cortex"
|
|
14
13
|
CORTEX_CONVERSATIONS_DIR = CORTEX_DIR / "conversations"
|
|
15
14
|
|
|
16
15
|
# opencode (sst/opencode) data directory. Single SQLite DB at
|
|
17
|
-
# ~/.local/share/opencode/opencode.db
|
|
18
|
-
# (Drizzle-managed). Parts carry the Vercel AI SDK UIMessage payload.
|
|
16
|
+
# ~/.local/share/opencode/opencode.db
|
|
19
17
|
OPENCODE_DIR = Path.home() / ".local" / "share" / "opencode"
|
|
20
18
|
OPENCODE_DB = OPENCODE_DIR / "opencode.db"
|
|
21
19
|
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# SQLite database path
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# XDG-style on Linux/macOS: ~/.local/share/spooling/spooling.db
|
|
24
|
+
# Windows: %APPDATA%\spooling\spooling.db
|
|
25
|
+
# Override with SPOOLING_DB env var.
|
|
26
|
+
|
|
27
|
+
def _default_db_path() -> Path:
|
|
28
|
+
if env := os.getenv("SPOOLING_DB"):
|
|
29
|
+
return Path(env)
|
|
30
|
+
if sys.platform == "win32":
|
|
31
|
+
base = Path(os.environ.get("APPDATA", Path.home()))
|
|
32
|
+
else:
|
|
33
|
+
base = Path.home() / ".local" / "share"
|
|
34
|
+
return base / "spooling" / "spooling.db"
|
|
35
|
+
|
|
36
|
+
DB_PATH: Path = _default_db_path()
|
|
30
37
|
|
|
31
38
|
# Embeddings
|
|
32
39
|
EMBEDDING_MODEL = os.getenv("SPOOLING_EMBEDDING_MODEL", "all-MiniLM-L6-v2")
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""SQLite database connection with PostgreSQL compatibility shim.
|
|
2
|
+
|
|
3
|
+
Replaces the old psycopg/PostgreSQL backend so `pip install spooling`
|
|
4
|
+
works without Docker or any system dependencies.
|
|
5
|
+
|
|
6
|
+
The shim translates the subset of PostgreSQL SQL idioms used in this
|
|
7
|
+
codebase to their SQLite equivalents at query time:
|
|
8
|
+
|
|
9
|
+
%s → ? (parameter placeholder)
|
|
10
|
+
now() → datetime('now')
|
|
11
|
+
::typename → (stripped) (%s::vector, %s::jsonb, etc.)
|
|
12
|
+
ILIKE → LIKE (SQLite LIKE is case-insensitive for ASCII)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import sqlite3
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from spooling.config import DB_PATH
|
|
20
|
+
from spooling.schema import SCHEMA_SQL
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# SQL compatibility translation
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
_PLACEHOLDER_RE = re.compile(r"%s")
|
|
28
|
+
_CAST_RE = re.compile(r"::\w+")
|
|
29
|
+
_ILIKE_RE = re.compile(r"\bILIKE\b", re.IGNORECASE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _adapt_sql(sql: str) -> str:
|
|
33
|
+
"""Translate PostgreSQL SQL idioms → SQLite equivalents."""
|
|
34
|
+
sql = _PLACEHOLDER_RE.sub("?", sql)
|
|
35
|
+
sql = sql.replace("now()", "datetime('now')")
|
|
36
|
+
sql = _CAST_RE.sub("", sql)
|
|
37
|
+
sql = _ILIKE_RE.sub("LIKE", sql)
|
|
38
|
+
return sql
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Row and cursor wrappers
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
class _Row(dict):
|
|
46
|
+
"""dict subclass that also supports attribute access and .get()."""
|
|
47
|
+
|
|
48
|
+
def __getattr__(self, key: str):
|
|
49
|
+
try:
|
|
50
|
+
return self[key]
|
|
51
|
+
except KeyError:
|
|
52
|
+
raise AttributeError(key) from None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _row_factory(cursor: sqlite3.Cursor, row: tuple) -> _Row:
|
|
56
|
+
return _Row(zip((col[0] for col in cursor.description), row))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Cursor:
|
|
60
|
+
"""Thin wrapper around sqlite3.Cursor with psycopg-style execute()."""
|
|
61
|
+
|
|
62
|
+
__slots__ = ("_cur",)
|
|
63
|
+
|
|
64
|
+
def __init__(self, cur: sqlite3.Cursor) -> None:
|
|
65
|
+
self._cur = cur
|
|
66
|
+
|
|
67
|
+
def execute(self, sql: str, params=()) -> "_Cursor":
|
|
68
|
+
self._cur.execute(_adapt_sql(sql), params)
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def executemany(self, sql: str, seq) -> "_Cursor":
|
|
72
|
+
self._cur.executemany(_adapt_sql(sql), seq)
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def fetchone(self) -> _Row | None:
|
|
76
|
+
return self._cur.fetchone()
|
|
77
|
+
|
|
78
|
+
def fetchall(self) -> list[_Row]:
|
|
79
|
+
return self._cur.fetchall()
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def lastrowid(self) -> int | None:
|
|
83
|
+
return self._cur.lastrowid
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def rowcount(self) -> int:
|
|
87
|
+
return self._cur.rowcount
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SpoolingConnection:
|
|
91
|
+
"""Wraps sqlite3.Connection with a psycopg-compatible interface."""
|
|
92
|
+
|
|
93
|
+
__slots__ = ("_conn",)
|
|
94
|
+
|
|
95
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
96
|
+
conn.row_factory = _row_factory
|
|
97
|
+
self._conn = conn
|
|
98
|
+
|
|
99
|
+
# ---- core methods ----
|
|
100
|
+
|
|
101
|
+
def execute(self, sql: str, params=()) -> _Cursor:
|
|
102
|
+
return _Cursor(self._conn.execute(_adapt_sql(sql), params))
|
|
103
|
+
|
|
104
|
+
def executemany(self, sql: str, seq) -> _Cursor:
|
|
105
|
+
return _Cursor(self._conn.executemany(_adapt_sql(sql), seq))
|
|
106
|
+
|
|
107
|
+
def cursor(self) -> _Cursor:
|
|
108
|
+
return _Cursor(self._conn.cursor())
|
|
109
|
+
|
|
110
|
+
def commit(self) -> None:
|
|
111
|
+
self._conn.commit()
|
|
112
|
+
|
|
113
|
+
def rollback(self) -> None:
|
|
114
|
+
self._conn.rollback()
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
self._conn.close()
|
|
118
|
+
|
|
119
|
+
# ---- context manager ----
|
|
120
|
+
|
|
121
|
+
def __enter__(self) -> "SpoolingConnection":
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
125
|
+
if exc_type is None:
|
|
126
|
+
self._conn.commit()
|
|
127
|
+
else:
|
|
128
|
+
self._conn.rollback()
|
|
129
|
+
self._conn.close()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Schema bootstrap
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|
137
|
+
"""Create all tables/indexes if they don't exist yet."""
|
|
138
|
+
conn.executescript(SCHEMA_SQL)
|
|
139
|
+
conn.commit()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Public API
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def get_connection() -> SpoolingConnection:
|
|
147
|
+
"""Open (and auto-create if needed) the local SQLite database."""
|
|
148
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
raw = sqlite3.connect(str(DB_PATH), check_same_thread=False)
|
|
150
|
+
raw.execute("PRAGMA journal_mode=WAL")
|
|
151
|
+
raw.execute("PRAGMA foreign_keys=ON")
|
|
152
|
+
_ensure_schema(raw)
|
|
153
|
+
return SpoolingConnection(raw)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def check_db() -> bool:
|
|
157
|
+
"""Return True if the database is reachable (always True for SQLite)."""
|
|
158
|
+
try:
|
|
159
|
+
conn = get_connection()
|
|
160
|
+
conn.execute("SELECT 1")
|
|
161
|
+
conn.close()
|
|
162
|
+
return True
|
|
163
|
+
except Exception:
|
|
164
|
+
return False
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
"""Ingestion pipeline - parse AI coding sessions from multiple providers and store in
|
|
1
|
+
"""Ingestion pipeline - parse AI coding sessions from multiple providers and store in SQLite."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import struct
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from rich.console import Console
|
|
@@ -236,27 +237,43 @@ def _store_trace(conn, trace: Trace):
|
|
|
236
237
|
# still benefit, but sessions with zero llm_call cost keep their
|
|
237
238
|
# existing chars/4 estimate untouched.
|
|
238
239
|
if trace.session_id:
|
|
240
|
+
# SQLite-compatible correlated UPDATE: compute the span cost in a
|
|
241
|
+
# subquery and write it back only when it is positive.
|
|
239
242
|
conn.execute(
|
|
240
|
-
"""UPDATE sessions
|
|
241
|
-
SET estimated_cost_usd =
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
WHERE t.session_id =
|
|
243
|
+
"""UPDATE sessions
|
|
244
|
+
SET estimated_cost_usd = (
|
|
245
|
+
SELECT SUM(s.cost_usd)
|
|
246
|
+
FROM spans s
|
|
247
|
+
JOIN traces t ON s.trace_id = t.id
|
|
248
|
+
WHERE t.session_id = sessions.id
|
|
246
249
|
AND s.kind = 'llm_call'
|
|
247
250
|
AND s.cost_usd IS NOT NULL
|
|
248
251
|
AND s.cost_usd > 0
|
|
249
252
|
AND s.model IS NOT NULL
|
|
250
253
|
AND s.model <> '<synthetic>'
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
+
)
|
|
255
|
+
WHERE id = %s
|
|
256
|
+
AND (
|
|
257
|
+
SELECT COALESCE(SUM(s.cost_usd), 0)
|
|
258
|
+
FROM spans s
|
|
259
|
+
JOIN traces t ON s.trace_id = t.id
|
|
260
|
+
WHERE t.session_id = sessions.id
|
|
261
|
+
AND s.kind = 'llm_call'
|
|
262
|
+
AND s.cost_usd > 0
|
|
263
|
+
AND s.model IS NOT NULL
|
|
264
|
+
AND s.model <> '<synthetic>'
|
|
265
|
+
) > 0""",
|
|
254
266
|
(trace.session_id,),
|
|
255
267
|
)
|
|
256
268
|
|
|
257
269
|
|
|
270
|
+
def _serialize_embedding(vec: list[float]) -> bytes:
|
|
271
|
+
"""Pack a float list to a compact float32 BLOB for SQLite storage."""
|
|
272
|
+
return struct.pack(f"{len(vec)}f", *vec)
|
|
273
|
+
|
|
274
|
+
|
|
258
275
|
def _embed_session(conn, session: ParsedSession):
|
|
259
|
-
"""Chunk and embed session messages
|
|
276
|
+
"""Chunk and embed session messages, storing vectors as BLOBs."""
|
|
260
277
|
# Delete existing chunks for this session (re-embed on update)
|
|
261
278
|
conn.execute("DELETE FROM chunks WHERE session_id = %s", (session.session_id,))
|
|
262
279
|
|
|
@@ -286,11 +303,12 @@ def _embed_session(conn, session: ParsedSession):
|
|
|
286
303
|
for chunk, vec, meta in zip(all_chunks, vectors, chunk_meta):
|
|
287
304
|
conn.execute(
|
|
288
305
|
"""INSERT INTO chunks (session_id, message_id, content, role, project, timestamp, embedding)
|
|
289
|
-
VALUES (%s, %s, %s, %s, %s, %s, %s
|
|
306
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
|
|
290
307
|
(
|
|
291
308
|
meta["session_id"], meta["message_id"], chunk,
|
|
292
|
-
meta["role"], meta["project"],
|
|
293
|
-
|
|
309
|
+
meta["role"], meta["project"],
|
|
310
|
+
meta["timestamp"].isoformat() if hasattr(meta["timestamp"], "isoformat") else meta["timestamp"],
|
|
311
|
+
_serialize_embedding(vec),
|
|
294
312
|
),
|
|
295
313
|
)
|
|
296
314
|
|
|
@@ -29,6 +29,10 @@ from typing import Any, Optional
|
|
|
29
29
|
|
|
30
30
|
import httpx
|
|
31
31
|
from mcp.server.fastmcp import FastMCP
|
|
32
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
33
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
34
|
+
from starlette.requests import Request
|
|
35
|
+
from starlette.responses import Response
|
|
32
36
|
|
|
33
37
|
from spooling.db import get_connection
|
|
34
38
|
|
|
@@ -127,6 +131,9 @@ mcp = FastMCP(
|
|
|
127
131
|
host=MCP_HOST,
|
|
128
132
|
port=MCP_PORT,
|
|
129
133
|
stateless_http=True,
|
|
134
|
+
transport_security=TransportSecuritySettings(
|
|
135
|
+
enable_dns_rebinding_protection=False,
|
|
136
|
+
),
|
|
130
137
|
)
|
|
131
138
|
|
|
132
139
|
|
|
@@ -554,6 +561,32 @@ def run_eval(rubric_id: str, trace_id: str) -> dict:
|
|
|
554
561
|
return {"status": "ok", "rubric_id": rubric_id, "trace_id": trace_id, "result": _row(row)}
|
|
555
562
|
|
|
556
563
|
|
|
564
|
+
# --- auth middleware --------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
class _BearerTokenMiddleware(BaseHTTPMiddleware):
|
|
567
|
+
"""Enforce Bearer-token authentication on every MCP request.
|
|
568
|
+
|
|
569
|
+
Only active when a tunnel token has been configured via
|
|
570
|
+
``spooling token generate`` or the ``SPOOLING_MCP_TOKEN`` env var.
|
|
571
|
+
Requests without a valid ``Authorization: Bearer <token>`` header receive
|
|
572
|
+
a 401 response and are never forwarded to the MCP handler.
|
|
573
|
+
"""
|
|
574
|
+
|
|
575
|
+
def __init__(self, app, token: str) -> None:
|
|
576
|
+
super().__init__(app)
|
|
577
|
+
self._token = token
|
|
578
|
+
|
|
579
|
+
async def dispatch(self, request: Request, call_next): # type: ignore[override]
|
|
580
|
+
auth = request.headers.get("Authorization", "")
|
|
581
|
+
if not (auth.startswith("Bearer ") and auth[7:].rstrip() == self._token):
|
|
582
|
+
return Response(
|
|
583
|
+
"Unauthorized",
|
|
584
|
+
status_code=401,
|
|
585
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
586
|
+
)
|
|
587
|
+
return await call_next(request)
|
|
588
|
+
|
|
589
|
+
|
|
557
590
|
# --- entrypoint ------------------------------------------------------------
|
|
558
591
|
|
|
559
592
|
def serve_stdio() -> None:
|
|
@@ -562,8 +595,24 @@ def serve_stdio() -> None:
|
|
|
562
595
|
|
|
563
596
|
|
|
564
597
|
def serve_http() -> None:
|
|
565
|
-
"""Run the MCP server over streamable-HTTP at MCP_URL.
|
|
566
|
-
|
|
598
|
+
"""Run the MCP server over streamable-HTTP at MCP_URL.
|
|
599
|
+
|
|
600
|
+
If a tunnel token is configured (via ``spooling token generate`` or the
|
|
601
|
+
``SPOOLING_MCP_TOKEN`` env var), every inbound request must carry a valid
|
|
602
|
+
``Authorization: Bearer <token>`` header. This keeps the public tunnel
|
|
603
|
+
endpoint private even though it's accessible over the internet.
|
|
604
|
+
"""
|
|
605
|
+
from spooling.tunnel_auth import load_token
|
|
606
|
+
|
|
607
|
+
token = load_token()
|
|
608
|
+
if token:
|
|
609
|
+
import uvicorn
|
|
610
|
+
|
|
611
|
+
app = mcp.streamable_http_app()
|
|
612
|
+
app.add_middleware(_BearerTokenMiddleware, token=token)
|
|
613
|
+
uvicorn.run(app, host=MCP_HOST, port=MCP_PORT, log_level="warning")
|
|
614
|
+
else:
|
|
615
|
+
mcp.run(transport="streamable-http")
|
|
567
616
|
|
|
568
617
|
|
|
569
618
|
if __name__ == "__main__":
|