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.
Files changed (68) hide show
  1. {spooling-0.1.8/spooling.egg-info → spooling-0.2.0}/PKG-INFO +3 -3
  2. {spooling-0.1.8 → spooling-0.2.0}/pyproject.toml +3 -3
  3. {spooling-0.1.8 → spooling-0.2.0}/spooling/cli.py +124 -12
  4. {spooling-0.1.8 → spooling-0.2.0}/spooling/config.py +20 -13
  5. spooling-0.2.0/spooling/db.py +164 -0
  6. {spooling-0.1.8 → spooling-0.2.0}/spooling/ingest.py +32 -14
  7. {spooling-0.1.8 → spooling-0.2.0}/spooling/mcp_server.py +51 -2
  8. spooling-0.2.0/spooling/schema.py +310 -0
  9. spooling-0.2.0/spooling/search.py +86 -0
  10. {spooling-0.1.8 → spooling-0.2.0}/spooling/server.py +11 -7
  11. {spooling-0.1.8 → spooling-0.2.0}/spooling/stats.py +70 -59
  12. {spooling-0.1.8 → spooling-0.2.0}/spooling/tunnel.py +10 -23
  13. spooling-0.2.0/spooling/tunnel_auth.py +89 -0
  14. {spooling-0.1.8 → spooling-0.2.0/spooling.egg-info}/PKG-INFO +3 -3
  15. {spooling-0.1.8 → spooling-0.2.0}/spooling.egg-info/SOURCES.txt +5 -1
  16. {spooling-0.1.8 → spooling-0.2.0}/spooling.egg-info/requires.txt +2 -2
  17. spooling-0.2.0/tests/test_mcp_auth_middleware.py +119 -0
  18. spooling-0.2.0/tests/test_tunnel_auth.py +177 -0
  19. spooling-0.1.8/spooling/db.py +0 -21
  20. spooling-0.1.8/spooling/search.py +0 -68
  21. {spooling-0.1.8 → spooling-0.2.0}/LICENSE +0 -0
  22. {spooling-0.1.8 → spooling-0.2.0}/README.md +0 -0
  23. {spooling-0.1.8 → spooling-0.2.0}/setup.cfg +0 -0
  24. {spooling-0.1.8 → spooling-0.2.0}/spooling/__init__.py +0 -0
  25. {spooling-0.1.8 → spooling-0.2.0}/spooling/agent.py +0 -0
  26. {spooling-0.1.8 → spooling-0.2.0}/spooling/classifiers.py +0 -0
  27. {spooling-0.1.8 → spooling-0.2.0}/spooling/cloud.py +0 -0
  28. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/__init__.py +0 -0
  29. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/base.py +0 -0
  30. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/bigquery.py +0 -0
  31. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/clickhouse.py +0 -0
  32. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/factory.py +0 -0
  33. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/mongodb.py +0 -0
  34. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/mysql.py +0 -0
  35. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/postgresql.py +0 -0
  36. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/rest/__init__.py +0 -0
  37. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/rest/base.py +0 -0
  38. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/rest/shopify_connectors.py +0 -0
  39. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/snowflake.py +0 -0
  40. {spooling-0.1.8 → spooling-0.2.0}/spooling/connectors/types.py +0 -0
  41. {spooling-0.1.8 → spooling-0.2.0}/spooling/embeddings.py +0 -0
  42. {spooling-0.1.8 → spooling-0.2.0}/spooling/evals.py +0 -0
  43. {spooling-0.1.8 → spooling-0.2.0}/spooling/experiments.py +0 -0
  44. {spooling-0.1.8 → spooling-0.2.0}/spooling/parser.py +0 -0
  45. {spooling-0.1.8 → spooling-0.2.0}/spooling/pricing.py +0 -0
  46. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/__init__.py +0 -0
  47. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/antigravity.py +0 -0
  48. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/base.py +0 -0
  49. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/codex.py +0 -0
  50. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/copilot.py +0 -0
  51. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/cortex_code.py +0 -0
  52. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/cursor.py +0 -0
  53. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/gemini.py +0 -0
  54. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/github.py +0 -0
  55. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/gitlab.py +0 -0
  56. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/kiro.py +0 -0
  57. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/opencode.py +0 -0
  58. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/session_file.py +0 -0
  59. {spooling-0.1.8 → spooling-0.2.0}/spooling/providers/windsurf.py +0 -0
  60. {spooling-0.1.8 → spooling-0.2.0}/spooling/redact.py +0 -0
  61. {spooling-0.1.8 → spooling-0.2.0}/spooling/remote_otel.py +0 -0
  62. {spooling-0.1.8 → spooling-0.2.0}/spooling/sdk.py +0 -0
  63. {spooling-0.1.8 → spooling-0.2.0}/spooling/subscription_pricing.py +0 -0
  64. {spooling-0.1.8 → spooling-0.2.0}/spooling/tracing.py +0 -0
  65. {spooling-0.1.8 → spooling-0.2.0}/spooling/watcher.py +0 -0
  66. {spooling-0.1.8 → spooling-0.2.0}/spooling.egg-info/dependency_links.txt +0 -0
  67. {spooling-0.1.8 → spooling-0.2.0}/spooling.egg-info/entry_points.txt +0 -0
  68. {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.1.8
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>=1.27
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.1.8"
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 connected[/green]")
36
+ console.print(f"[green]Database ready[/green] [dim]({DB_PATH})[/dim]")
37
37
  else:
38
- console.print("[red]Cannot connect to database.[/red]")
39
- console.print(f" URL: {DATABASE_URL}")
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
- ts = r["started_at"].strftime("%m/%d %H:%M") if r["started_at"] else ""
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
- MCP server to the internet. The tunnel URL can be used by any
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
- # Print MCP config snippet for easy copy-paste
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'[bold]{{"mcpServers": {{"custom": {{"type": "http", "url": "{url}/mcp"}}}}}}[/bold]')
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. Sessions live in
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 with session/message/part tables
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
- # Database
23
- DB_HOST = os.getenv("SPOOLING_DB_HOST", "localhost")
24
- DB_PORT = int(os.getenv("SPOOLING_DB_PORT", "5432"))
25
- DB_NAME = os.getenv("SPOOLING_DB_NAME", "spooling")
26
- DB_USER = os.getenv("SPOOLING_DB_USER", "spooling")
27
- DB_PASSWORD = os.getenv("SPOOLING_DB_PASSWORD", "spooling")
28
-
29
- DATABASE_URL = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
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 pgvector."""
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 ss
241
- SET estimated_cost_usd = sub.span_cost
242
- FROM (
243
- SELECT t.session_id, SUM(s.cost_usd)::numeric(10, 4) AS span_cost
244
- FROM spans s JOIN traces t ON s.trace_id = t.id
245
- WHERE t.session_id = %s
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
- GROUP BY t.session_id
252
- ) sub
253
- WHERE ss.id = sub.session_id AND sub.span_cost > 0""",
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 into pgvector."""
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::vector)""",
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"], meta["timestamp"],
293
- str(vec),
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
- mcp.run(transport="streamable-http")
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__":