spooling 0.2.4__tar.gz → 0.2.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.2.4/spooling.egg-info → spooling-0.2.6}/PKG-INFO +1 -1
- {spooling-0.2.4 → spooling-0.2.6}/pyproject.toml +1 -1
- {spooling-0.2.4 → spooling-0.2.6}/spooling/cli.py +28 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/cloud.py +53 -7
- {spooling-0.2.4 → spooling-0.2.6}/spooling/ingest.py +101 -7
- spooling-0.2.6/spooling/optimize.py +159 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/parser.py +7 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/base.py +7 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/opencode.py +36 -1
- {spooling-0.2.4 → spooling-0.2.6}/spooling/schema.py +1 -0
- spooling-0.2.6/spooling/search.py +136 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/watcher.py +11 -2
- {spooling-0.2.4 → spooling-0.2.6/spooling.egg-info}/PKG-INFO +1 -1
- {spooling-0.2.4 → spooling-0.2.6}/spooling.egg-info/SOURCES.txt +2 -0
- spooling-0.2.6/tests/test_storage_optimizations.py +281 -0
- spooling-0.2.4/spooling/search.py +0 -86
- {spooling-0.2.4 → spooling-0.2.6}/LICENSE +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/README.md +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/setup.cfg +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/__init__.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/agent.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/classifiers.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/config.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/__init__.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/base.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/factory.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/mysql.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/types.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/db.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/embeddings.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/evals.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/experiments.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/mcp_server.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/pricing.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/__init__.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/antigravity.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/codex.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/copilot.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/cursor.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/gemini.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/github.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/gitlab.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/kiro.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/session_file.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/windsurf.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/redact.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/remote_otel.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/sdk.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/server.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/stats.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/subscription_pricing.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/tracing.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/tunnel.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling/tunnel_auth.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling.egg-info/requires.txt +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/tests/test_mcp_auth_middleware.py +0 -0
- {spooling-0.2.4 → spooling-0.2.6}/tests/test_tunnel_auth.py +0 -0
|
@@ -71,6 +71,34 @@ def sync(no_embed, provider):
|
|
|
71
71
|
do_sync(embed=not no_embed, provider_filter=provider)
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
@cli.command()
|
|
75
|
+
@click.option("--no-backup", is_flag=True, help="Skip the pre-optimize database backup")
|
|
76
|
+
@click.option("--no-vacuum", is_flag=True, help="Skip VACUUM (faster, but doesn't reclaim disk space)")
|
|
77
|
+
@click.option("--no-embed", is_flag=True, help="Skip embedding messages that are missing chunks")
|
|
78
|
+
def optimize(no_backup, no_vacuum, no_embed):
|
|
79
|
+
"""Deduplicate and compact the local database.
|
|
80
|
+
|
|
81
|
+
Removes duplicated tool-call rows left by older versions, truncates
|
|
82
|
+
oversized previews, embeds any messages missing chunks, and VACUUMs.
|
|
83
|
+
A backup is written next to the database first.
|
|
84
|
+
"""
|
|
85
|
+
from spooling.optimize import optimize as run_optimize
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
s = run_optimize(
|
|
89
|
+
backup=not no_backup, vacuum=not no_vacuum, embed=not no_embed,
|
|
90
|
+
log=lambda m: console.print(m),
|
|
91
|
+
)
|
|
92
|
+
except RuntimeError as e:
|
|
93
|
+
console.print(f"[red]{e}[/red]")
|
|
94
|
+
raise SystemExit(1)
|
|
95
|
+
mb = lambda b: f"{b / 2**20:,.0f} MB"
|
|
96
|
+
console.print(
|
|
97
|
+
f"[green]Done.[/green] {mb(s['before_bytes'])} -> {mb(s['after_bytes'])} "
|
|
98
|
+
f"({mb(s['before_bytes'] - s['after_bytes'])} reclaimed)"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
74
102
|
@cli.command()
|
|
75
103
|
def watch():
|
|
76
104
|
"""Watch for new session data and auto-sync."""
|
|
@@ -229,6 +229,18 @@ def _collect_traces(session_ids: list[str]) -> list[dict]:
|
|
|
229
229
|
if isinstance(v, str):
|
|
230
230
|
return v
|
|
231
231
|
return v.isoformat()
|
|
232
|
+
|
|
233
|
+
def _json(v, default):
|
|
234
|
+
"""Return a parsed object regardless of whether v is already parsed or a JSON string."""
|
|
235
|
+
if v is None:
|
|
236
|
+
return default
|
|
237
|
+
if isinstance(v, str):
|
|
238
|
+
import json as _json_mod
|
|
239
|
+
try:
|
|
240
|
+
return _json_mod.loads(v)
|
|
241
|
+
except Exception:
|
|
242
|
+
return default
|
|
243
|
+
return v
|
|
232
244
|
conn = get_connection()
|
|
233
245
|
try:
|
|
234
246
|
placeholders = ", ".join(["%s"] * len(session_ids))
|
|
@@ -282,19 +294,19 @@ def _collect_traces(session_ids: list[str]) -> list[dict]:
|
|
|
282
294
|
"cost_usd": float(s["cost_usd"] or 0),
|
|
283
295
|
"model": s["model"],
|
|
284
296
|
"tool_name": s["tool_name"],
|
|
285
|
-
"tool_input": s["tool_input"],
|
|
297
|
+
"tool_input": _json(s["tool_input"], None),
|
|
286
298
|
"tool_output": (s["tool_output"] or "")[:200000] if s["tool_output"] else None,
|
|
287
299
|
"tool_is_error": bool(s["tool_is_error"]),
|
|
288
300
|
"agent_type": s["agent_type"],
|
|
289
301
|
"agent_prompt": (s["agent_prompt"] or "")[:200000] if s["agent_prompt"] else None,
|
|
290
302
|
"vendor": s["vendor"],
|
|
291
303
|
"category": s["category"],
|
|
292
|
-
"attrs": s["attrs"]
|
|
304
|
+
"attrs": _json(s["attrs"], {}),
|
|
293
305
|
"events": [
|
|
294
306
|
{
|
|
295
307
|
"name": e["name"],
|
|
296
308
|
"timestamp": _ts(e["timestamp"]),
|
|
297
|
-
"attrs": e["attrs"]
|
|
309
|
+
"attrs": _json(e["attrs"], {}),
|
|
298
310
|
}
|
|
299
311
|
for e in events
|
|
300
312
|
],
|
|
@@ -323,8 +335,8 @@ def _collect_traces(session_ids: list[str]) -> list[dict]:
|
|
|
323
335
|
"git_branch": t["git_branch"],
|
|
324
336
|
"model": t["model"],
|
|
325
337
|
"vendor_count": int(t["vendor_count"] or 0),
|
|
326
|
-
"top_vendors": t["top_vendors"]
|
|
327
|
-
"attrs": t["attrs"]
|
|
338
|
+
"top_vendors": _json(t["top_vendors"], []),
|
|
339
|
+
"attrs": _json(t["attrs"], {}),
|
|
328
340
|
"spans": spans_payload,
|
|
329
341
|
})
|
|
330
342
|
return out
|
|
@@ -442,6 +454,33 @@ def _push_batches(
|
|
|
442
454
|
return total, rejected, None
|
|
443
455
|
|
|
444
456
|
|
|
457
|
+
def _sync_local(log=console.print) -> None:
|
|
458
|
+
"""Ingest new/updated provider sessions into the local DB before pushing.
|
|
459
|
+
|
|
460
|
+
`push` only reads the local DB, so without this step sessions created since
|
|
461
|
+
the last `spooling sync` (or `spooling watch`) would be silently skipped.
|
|
462
|
+
Embeddings are skipped (push doesn't use them and they dominate runtime);
|
|
463
|
+
run `spooling sync` or `spooling watch` for local semantic search.
|
|
464
|
+
A failure here is non-fatal: we warn and push whatever is already local.
|
|
465
|
+
"""
|
|
466
|
+
from spooling.ingest import sync as do_sync
|
|
467
|
+
|
|
468
|
+
log("[dim]Syncing local sessions before push…[/dim]")
|
|
469
|
+
try:
|
|
470
|
+
do_sync(embed=False)
|
|
471
|
+
except Exception as e: # noqa: BLE001
|
|
472
|
+
log(f"[yellow]Local sync failed ({e}); pushing what's already in the local DB.[/yellow]")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
_SYNC_OPTION = click.option(
|
|
476
|
+
"--sync/--no-sync",
|
|
477
|
+
"do_sync",
|
|
478
|
+
default=True,
|
|
479
|
+
show_default=True,
|
|
480
|
+
help="Ingest new provider sessions into the local DB before pushing. Use --no-sync to push only what is already local.",
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
445
484
|
@click.command()
|
|
446
485
|
@click.option("--limit", default=100, help="Max sessions to push per run")
|
|
447
486
|
@click.option("--batch", default=20, help="Sessions per request")
|
|
@@ -484,7 +523,8 @@ def _push_batches(
|
|
|
484
523
|
is_flag=True,
|
|
485
524
|
help="Skip the client-side secret redactor. By default spool scrubs secrets (Snowflake/AWS/GitHub/OpenAI/Anthropic/Stripe tokens, PEM private keys, KEY=VALUE lines whose key looks sensitive) before pushing. Use this only if you're sure the content is clean and you need raw values.",
|
|
486
525
|
)
|
|
487
|
-
|
|
526
|
+
@_SYNC_OPTION
|
|
527
|
+
def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, title_substr: str | None, dry_run: bool, copy: bool, with_spans: bool, no_redact: bool, do_sync: bool):
|
|
488
528
|
"""Push local sessions up to Spooling Cloud.
|
|
489
529
|
|
|
490
530
|
Without filters, this pushes the most recent ``--limit`` sessions from
|
|
@@ -519,6 +559,8 @@ def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
|
|
|
519
559
|
return
|
|
520
560
|
headers = _auth_headers()
|
|
521
561
|
base = _api_base()
|
|
562
|
+
if do_sync:
|
|
563
|
+
_sync_local()
|
|
522
564
|
sessions = _collect_sessions(
|
|
523
565
|
limit=limit,
|
|
524
566
|
since=None,
|
|
@@ -626,7 +668,8 @@ def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
|
|
|
626
668
|
is_flag=True,
|
|
627
669
|
help="Skip the client-side secret redactor (see `spooling push --help`).",
|
|
628
670
|
)
|
|
629
|
-
|
|
671
|
+
@_SYNC_OPTION
|
|
672
|
+
def cloud_watch(interval: int, limit: int, batch: int, lookback: int, no_redact: bool, do_sync: bool):
|
|
630
673
|
"""Continuously push new local sessions to Spooling Cloud (Ctrl+C to stop)."""
|
|
631
674
|
headers = _auth_headers()
|
|
632
675
|
base = _api_base()
|
|
@@ -651,6 +694,9 @@ def cloud_watch(interval: int, limit: int, batch: int, lookback: int, no_redact:
|
|
|
651
694
|
headers = _auth_headers()
|
|
652
695
|
since = (watermark - timedelta(minutes=lookback)) if watermark else None
|
|
653
696
|
|
|
697
|
+
if do_sync:
|
|
698
|
+
_sync_local(log=lambda *_a, **_k: None)
|
|
699
|
+
|
|
654
700
|
try:
|
|
655
701
|
sessions = _collect_sessions(limit=limit, since=since)
|
|
656
702
|
except Exception as e:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Ingestion pipeline - parse AI coding sessions from multiple providers and store in SQLite."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
4
5
|
import struct
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
@@ -59,6 +60,24 @@ def _mark_synced(conn, file_path: str, size: int, provider_id: str = "jsonl-sess
|
|
|
59
60
|
)
|
|
60
61
|
|
|
61
62
|
|
|
63
|
+
# Cap stored tool-result previews; the full output lives in the source tool's
|
|
64
|
+
# own data and isn't searched or displayed beyond a preview.
|
|
65
|
+
MAX_RESULT_PREVIEW = 2000
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _mark_session_synced(conn, session: ParsedSession) -> None:
|
|
69
|
+
"""Record per-session version for incremental providers."""
|
|
70
|
+
if session.source_key and session.source_version is not None:
|
|
71
|
+
_mark_synced(conn, session.source_key, session.source_version, session.provider_id)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_file(provider, file_path, synced: dict[str, int] | None = None) -> list[ParsedSession]:
|
|
75
|
+
"""Parse a provider file, passing per-session sync state when supported."""
|
|
76
|
+
if getattr(provider, "incremental", False):
|
|
77
|
+
return provider.parse_session_file(file_path, synced=synced)
|
|
78
|
+
return provider.parse_session_file(file_path)
|
|
79
|
+
|
|
80
|
+
|
|
62
81
|
def _store_session(conn, session: ParsedSession):
|
|
63
82
|
"""Store a parsed session and its messages."""
|
|
64
83
|
cost = _estimate_cost(
|
|
@@ -109,7 +128,7 @@ def _store_session(conn, session: ParsedSession):
|
|
|
109
128
|
tool_rows_full.append((
|
|
110
129
|
session.session_id, msg.uuid, _scrub(td.name),
|
|
111
130
|
_scrub(td.input_summary),
|
|
112
|
-
_scrub(td.result_preview) or None,
|
|
131
|
+
(_scrub(td.result_preview) or "")[:MAX_RESULT_PREVIEW] or None,
|
|
113
132
|
msg.timestamp,
|
|
114
133
|
))
|
|
115
134
|
else:
|
|
@@ -118,6 +137,10 @@ def _store_session(conn, session: ParsedSession):
|
|
|
118
137
|
session.session_id, msg.uuid, _scrub(tool_name), msg.timestamp,
|
|
119
138
|
))
|
|
120
139
|
|
|
140
|
+
# Re-ingesting a session (file grew, incremental provider changed, ...)
|
|
141
|
+
# must replace its tool calls, not append another copy.
|
|
142
|
+
conn.execute("DELETE FROM tool_calls WHERE session_id = %s", (session.session_id,))
|
|
143
|
+
|
|
121
144
|
if msg_rows:
|
|
122
145
|
conn.executemany(
|
|
123
146
|
"""INSERT INTO messages (id, session_id, role, content, timestamp, tools_used,
|
|
@@ -287,14 +310,28 @@ def _serialize_embedding(vec: list[float]) -> bytes:
|
|
|
287
310
|
return struct.pack(f"{len(vec)}f", *vec)
|
|
288
311
|
|
|
289
312
|
|
|
313
|
+
_TOOL_MARKER_RE = re.compile(r"\[tool: [^\]]*\]")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _chunks_for_content(content: str | None) -> list[str]:
|
|
317
|
+
"""Chunk message text for embedding.
|
|
318
|
+
|
|
319
|
+
Messages that are nothing but ``[tool: X]`` markers carry no searchable
|
|
320
|
+
meaning, so they produce no chunks.
|
|
321
|
+
"""
|
|
322
|
+
if not content or not content.strip():
|
|
323
|
+
return []
|
|
324
|
+
if not _TOOL_MARKER_RE.sub("", content).strip():
|
|
325
|
+
return []
|
|
326
|
+
return chunk_text(content)
|
|
327
|
+
|
|
328
|
+
|
|
290
329
|
def _collect_session_chunks(session: ParsedSession) -> tuple[list[str], list[dict]]:
|
|
291
330
|
"""Collect all text chunks and their metadata for a session (no embedding)."""
|
|
292
331
|
all_chunks: list[str] = []
|
|
293
332
|
chunk_meta: list[dict] = []
|
|
294
333
|
for msg in session.messages:
|
|
295
|
-
|
|
296
|
-
continue
|
|
297
|
-
for chunk in chunk_text(msg.content):
|
|
334
|
+
for chunk in _chunks_for_content(msg.content):
|
|
298
335
|
all_chunks.append(chunk)
|
|
299
336
|
chunk_meta.append({
|
|
300
337
|
"session_id": session.session_id,
|
|
@@ -336,6 +373,45 @@ def _embed_session(conn, session: ParsedSession):
|
|
|
336
373
|
return len(all_chunks)
|
|
337
374
|
|
|
338
375
|
|
|
376
|
+
def backfill_embeddings(conn, batch_size: int = 1000, log=None) -> int:
|
|
377
|
+
"""Embed stored messages that have no chunks yet.
|
|
378
|
+
|
|
379
|
+
Lets ``sync --no-embed`` (and ``push``'s pre-sync) stay fast without
|
|
380
|
+
leaving permanent holes in local semantic search: the next embedding sync
|
|
381
|
+
picks up every message that is still missing chunks. Returns the number of
|
|
382
|
+
chunks written.
|
|
383
|
+
"""
|
|
384
|
+
rows = conn.execute(
|
|
385
|
+
"""SELECT m.id, m.session_id, m.role, m.content, m.timestamp, s.project
|
|
386
|
+
FROM messages m
|
|
387
|
+
JOIN sessions s ON s.id = m.session_id
|
|
388
|
+
WHERE m.content IS NOT NULL AND m.content != ''
|
|
389
|
+
AND NOT EXISTS (SELECT 1 FROM chunks c WHERE c.message_id = m.id)"""
|
|
390
|
+
).fetchall()
|
|
391
|
+
|
|
392
|
+
chunks: list[str] = []
|
|
393
|
+
metas: list[dict] = []
|
|
394
|
+
for r in rows:
|
|
395
|
+
for chunk in _chunks_for_content(r["content"]):
|
|
396
|
+
chunks.append(chunk)
|
|
397
|
+
metas.append({
|
|
398
|
+
"session_id": r["session_id"], "message_id": r["id"],
|
|
399
|
+
"role": r["role"], "project": r["project"], "timestamp": r["timestamp"],
|
|
400
|
+
})
|
|
401
|
+
if not chunks:
|
|
402
|
+
return 0
|
|
403
|
+
|
|
404
|
+
if log:
|
|
405
|
+
log(f"[dim]Embedding {len(chunks)} chunks for messages missing them…[/dim]")
|
|
406
|
+
written = 0
|
|
407
|
+
for i in range(0, len(chunks), batch_size):
|
|
408
|
+
c, m = chunks[i:i + batch_size], metas[i:i + batch_size]
|
|
409
|
+
_write_chunk_rows(conn, c, embed_texts(c), m)
|
|
410
|
+
conn.commit()
|
|
411
|
+
written += len(c)
|
|
412
|
+
return written
|
|
413
|
+
|
|
414
|
+
|
|
339
415
|
def _get_connected_providers(conn) -> list[dict]:
|
|
340
416
|
"""Get all connected providers from the database."""
|
|
341
417
|
rows = conn.execute(
|
|
@@ -457,11 +533,12 @@ def sync(embed: bool = True, provider_filter: str | None = None):
|
|
|
457
533
|
if not files:
|
|
458
534
|
continue
|
|
459
535
|
|
|
460
|
-
# Filter to new or changed files
|
|
536
|
+
# Filter to new or changed files. Incremental providers (many sessions
|
|
537
|
+
# per file) are always scanned; they skip unchanged sessions themselves.
|
|
461
538
|
to_process = []
|
|
462
539
|
for f in files:
|
|
463
540
|
size = f.stat().st_size
|
|
464
|
-
if str(f) not in synced or synced[str(f)] != size:
|
|
541
|
+
if provider.incremental or str(f) not in synced or synced[str(f)] != size:
|
|
465
542
|
to_process.append(f)
|
|
466
543
|
|
|
467
544
|
if not to_process:
|
|
@@ -493,11 +570,12 @@ def sync(embed: bool = True, provider_filter: str | None = None):
|
|
|
493
570
|
batch_sessions: list[tuple] = [] # (file, [sessions])
|
|
494
571
|
|
|
495
572
|
for f in batch_files:
|
|
496
|
-
sessions = provider
|
|
573
|
+
sessions = parse_file(provider, f, synced)
|
|
497
574
|
for session in sessions:
|
|
498
575
|
_store_session(conn, session)
|
|
499
576
|
if session.trace is not None:
|
|
500
577
|
_store_trace(conn, session.trace)
|
|
578
|
+
_mark_session_synced(conn, session)
|
|
501
579
|
total_messages += session.message_count
|
|
502
580
|
total_sessions += 1
|
|
503
581
|
batch_sessions.append((f, sessions))
|
|
@@ -543,10 +621,26 @@ def sync(embed: bool = True, provider_filter: str | None = None):
|
|
|
543
621
|
f"{total_chunks} chunks embedded.[/green]"
|
|
544
622
|
)
|
|
545
623
|
|
|
624
|
+
if embed:
|
|
625
|
+
grand_total_chunks += backfill_embeddings(conn, log=console.print)
|
|
626
|
+
|
|
627
|
+
try:
|
|
628
|
+
stored = conn.execute("SELECT COUNT(*) AS n FROM tool_calls").fetchone()["n"]
|
|
629
|
+
expected = conn.execute("SELECT COALESCE(SUM(tool_call_count), 0) AS n FROM sessions").fetchone()["n"]
|
|
630
|
+
if stored > 2 * expected + 1000:
|
|
631
|
+
console.print(
|
|
632
|
+
f"[yellow]Your database holds {stored:,} tool-call rows but only ~{expected:,} are real "
|
|
633
|
+
f"(duplicates from older versions). Run [bold]spooling optimize[/bold] to reclaim the space.[/yellow]"
|
|
634
|
+
)
|
|
635
|
+
except Exception:
|
|
636
|
+
pass
|
|
637
|
+
|
|
546
638
|
conn.close()
|
|
547
639
|
|
|
548
640
|
if grand_total_sessions == 0:
|
|
549
641
|
console.print("[green]All sessions already synced.[/green]")
|
|
642
|
+
if grand_total_chunks:
|
|
643
|
+
console.print(f"[green]Backfilled {grand_total_chunks} missing chunks.[/green]")
|
|
550
644
|
else:
|
|
551
645
|
console.print(
|
|
552
646
|
f"\n[green]Total: {grand_total_sessions} sessions, "
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""One-shot maintenance for the local SQLite store: `spooling optimize`.
|
|
2
|
+
|
|
3
|
+
Older versions appended a fresh copy of every session's tool calls on each
|
|
4
|
+
re-ingest (the opencode provider re-ingests *every* session on *every* sync),
|
|
5
|
+
so ``tool_calls`` could be ~20x larger than the real data. This module:
|
|
6
|
+
|
|
7
|
+
1. backs the database up,
|
|
8
|
+
2. collapses duplicated ``tool_calls`` blocks,
|
|
9
|
+
3. truncates oversized tool-result previews,
|
|
10
|
+
4. embeds messages that are missing chunks (``sync --no-embed`` / push),
|
|
11
|
+
5. enables incremental auto-vacuum and VACUUMs to give the space back.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import shutil
|
|
17
|
+
import sqlite3
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from spooling.config import DB_PATH
|
|
23
|
+
|
|
24
|
+
MAX_RESULT_PREVIEW = 2000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def db_size(path: Path = DB_PATH) -> int:
|
|
28
|
+
total = 0
|
|
29
|
+
for suffix in ("", "-wal", "-shm"):
|
|
30
|
+
p = Path(str(path) + suffix)
|
|
31
|
+
if p.exists():
|
|
32
|
+
total += p.stat().st_size
|
|
33
|
+
return total
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def duplicate_tool_calls(conn: sqlite3.Connection) -> tuple[int, int]:
|
|
37
|
+
"""Return (stored_rows, expected_rows) for tool_calls.
|
|
38
|
+
|
|
39
|
+
``expected`` is the sum of per-session tool_call_count, which is what a
|
|
40
|
+
clean store holds. Cheap enough to run after every sync as a hint.
|
|
41
|
+
"""
|
|
42
|
+
stored = conn.execute("SELECT COUNT(*) FROM tool_calls").fetchone()[0]
|
|
43
|
+
expected = conn.execute("SELECT COALESCE(SUM(tool_call_count), 0) FROM sessions").fetchone()[0]
|
|
44
|
+
return stored, expected
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def dedupe_tool_calls(conn: sqlite3.Connection) -> int:
|
|
48
|
+
"""Keep only each session's most recent block of tool calls.
|
|
49
|
+
|
|
50
|
+
Every ingest of a session inserted one full block of ``tool_call_count``
|
|
51
|
+
rows, so the newest ``tool_call_count`` rows (by id) are exactly the
|
|
52
|
+
current copy. Returns rows deleted.
|
|
53
|
+
"""
|
|
54
|
+
cur = conn.execute(
|
|
55
|
+
"""DELETE FROM tool_calls WHERE id IN (
|
|
56
|
+
SELECT id FROM (
|
|
57
|
+
SELECT t.id AS id,
|
|
58
|
+
ROW_NUMBER() OVER (PARTITION BY t.session_id ORDER BY t.id DESC) AS rn,
|
|
59
|
+
s.tool_call_count AS n
|
|
60
|
+
FROM tool_calls t
|
|
61
|
+
JOIN sessions s ON s.id = t.session_id
|
|
62
|
+
) WHERE n > 0 AND rn > n
|
|
63
|
+
)"""
|
|
64
|
+
)
|
|
65
|
+
return cur.rowcount
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def truncate_previews(conn: sqlite3.Connection, limit: int = MAX_RESULT_PREVIEW) -> int:
|
|
69
|
+
cur = conn.execute(
|
|
70
|
+
"UPDATE tool_calls SET tool_result_preview = substr(tool_result_preview, 1, ?) "
|
|
71
|
+
"WHERE length(tool_result_preview) > ?",
|
|
72
|
+
(limit, limit),
|
|
73
|
+
)
|
|
74
|
+
return cur.rowcount
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def backup_database(path: Path = DB_PATH) -> Path:
|
|
78
|
+
"""Consistent copy via the SQLite backup API (safe with WAL/open readers)."""
|
|
79
|
+
backups = path.parent / "backups"
|
|
80
|
+
backups.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
dest = backups / f"{path.stem}-{datetime.now():%Y%m%d-%H%M%S}.db"
|
|
82
|
+
free = shutil.disk_usage(path.parent).free
|
|
83
|
+
need = db_size(path) * 2 # backup + headroom for VACUUM's temp copy
|
|
84
|
+
if free < need:
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
f"Not enough free disk space for backup + VACUUM "
|
|
87
|
+
f"(need ~{need // 2**20} MB, have {free // 2**20} MB). "
|
|
88
|
+
f"Free some space or re-run with --no-backup --no-vacuum."
|
|
89
|
+
)
|
|
90
|
+
src = sqlite3.connect(str(path))
|
|
91
|
+
dst = sqlite3.connect(str(dest))
|
|
92
|
+
try:
|
|
93
|
+
src.backup(dst)
|
|
94
|
+
finally:
|
|
95
|
+
dst.close()
|
|
96
|
+
src.close()
|
|
97
|
+
return dest
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def optimize(
|
|
101
|
+
path: Path = DB_PATH,
|
|
102
|
+
*,
|
|
103
|
+
backup: bool = True,
|
|
104
|
+
vacuum: bool = True,
|
|
105
|
+
embed: bool = True,
|
|
106
|
+
log: Callable[[str], None] = print,
|
|
107
|
+
) -> dict:
|
|
108
|
+
"""Run all maintenance steps. Returns a summary dict."""
|
|
109
|
+
summary: dict = {"before_bytes": db_size(path)}
|
|
110
|
+
|
|
111
|
+
if backup:
|
|
112
|
+
dest = backup_database(path)
|
|
113
|
+
summary["backup"] = str(dest)
|
|
114
|
+
log(f"Backup written to {dest}")
|
|
115
|
+
|
|
116
|
+
# isolation_level=None -> autocommit, required for VACUUM.
|
|
117
|
+
conn = sqlite3.connect(str(path), isolation_level=None, timeout=60)
|
|
118
|
+
try:
|
|
119
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
120
|
+
stored, expected = duplicate_tool_calls(conn)
|
|
121
|
+
log(f"tool_calls: {stored:,} stored vs ~{expected:,} expected")
|
|
122
|
+
|
|
123
|
+
conn.execute("BEGIN")
|
|
124
|
+
summary["tool_calls_deleted"] = dedupe_tool_calls(conn)
|
|
125
|
+
summary["previews_truncated"] = truncate_previews(conn)
|
|
126
|
+
conn.execute("COMMIT")
|
|
127
|
+
log(
|
|
128
|
+
f"Removed {summary['tool_calls_deleted']:,} duplicate tool_calls, "
|
|
129
|
+
f"truncated {summary['previews_truncated']:,} previews"
|
|
130
|
+
)
|
|
131
|
+
finally:
|
|
132
|
+
conn.close()
|
|
133
|
+
|
|
134
|
+
if embed:
|
|
135
|
+
from spooling.db import get_connection
|
|
136
|
+
from spooling.ingest import backfill_embeddings
|
|
137
|
+
|
|
138
|
+
c = get_connection()
|
|
139
|
+
try:
|
|
140
|
+
summary["chunks_backfilled"] = backfill_embeddings(c, log=log)
|
|
141
|
+
finally:
|
|
142
|
+
c.close()
|
|
143
|
+
log(f"Backfilled {summary['chunks_backfilled']:,} chunks")
|
|
144
|
+
|
|
145
|
+
conn = sqlite3.connect(str(path), isolation_level=None, timeout=60)
|
|
146
|
+
try:
|
|
147
|
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
148
|
+
if vacuum:
|
|
149
|
+
# auto_vacuum only takes effect on a full VACUUM.
|
|
150
|
+
conn.execute("PRAGMA auto_vacuum=INCREMENTAL")
|
|
151
|
+
log("Running VACUUM (this can take a minute)…")
|
|
152
|
+
conn.execute("VACUUM")
|
|
153
|
+
conn.execute("ANALYZE")
|
|
154
|
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
155
|
+
finally:
|
|
156
|
+
conn.close()
|
|
157
|
+
|
|
158
|
+
summary["after_bytes"] = db_size(path)
|
|
159
|
+
return summary
|
|
@@ -67,6 +67,13 @@ class ParsedSession:
|
|
|
67
67
|
title: str | None = None
|
|
68
68
|
provider_id: str = "jsonl-session"
|
|
69
69
|
trace: Optional[Trace] = None
|
|
70
|
+
# Per-session incremental sync marker. Providers whose "file" holds many
|
|
71
|
+
# sessions (e.g. opencode's single SQLite DB) set these so ingest can skip
|
|
72
|
+
# sessions whose upstream version hasn't changed since the last sync.
|
|
73
|
+
# ``source_key`` is stored in sync_state.file_path, ``source_version`` in
|
|
74
|
+
# sync_state.last_size.
|
|
75
|
+
source_key: str | None = None
|
|
76
|
+
source_version: int | None = None
|
|
70
77
|
|
|
71
78
|
@property
|
|
72
79
|
def message_count(self) -> int:
|
|
@@ -54,6 +54,13 @@ class Provider(ABC):
|
|
|
54
54
|
# the right form.
|
|
55
55
|
requires_credentials: bool = False
|
|
56
56
|
|
|
57
|
+
# True for providers whose single "file" contains many sessions and that
|
|
58
|
+
# track per-session versions themselves (see ``ParsedSession.source_key``).
|
|
59
|
+
# Ingest then always scans the file (the file-size watermark is useless
|
|
60
|
+
# for a constantly-changing DB) and passes ``synced`` to
|
|
61
|
+
# ``parse_session_file`` so unchanged sessions are skipped cheaply.
|
|
62
|
+
incremental: bool = False
|
|
63
|
+
|
|
57
64
|
def __init_subclass__(cls, **kwargs):
|
|
58
65
|
super().__init_subclass__(**kwargs)
|
|
59
66
|
# Don't register the abstract RemoteProvider base.
|
|
@@ -202,6 +202,7 @@ class OpencodeProvider(Provider):
|
|
|
202
202
|
|
|
203
203
|
type_id = "opencode"
|
|
204
204
|
name = "opencode"
|
|
205
|
+
incremental = True
|
|
205
206
|
|
|
206
207
|
def default_data_path(self) -> Path:
|
|
207
208
|
return OPENCODE_DB
|
|
@@ -213,7 +214,34 @@ class OpencodeProvider(Provider):
|
|
|
213
214
|
db = data_path or self.resolved_data_path()
|
|
214
215
|
return [db] if db.exists() else []
|
|
215
216
|
|
|
216
|
-
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _session_versions(conn: sqlite3.Connection) -> dict[str, int]:
|
|
219
|
+
"""Latest activity timestamp (ms) per session across session, message
|
|
220
|
+
and part rows. A session needs re-ingesting iff this changed."""
|
|
221
|
+
versions: dict[str, int] = {}
|
|
222
|
+
for sql in (
|
|
223
|
+
"SELECT id, time_updated FROM session",
|
|
224
|
+
"SELECT session_id, MAX(time_updated) FROM message GROUP BY session_id",
|
|
225
|
+
"SELECT session_id, MAX(time_updated) FROM part GROUP BY session_id",
|
|
226
|
+
):
|
|
227
|
+
for sid, ts in conn.execute(sql):
|
|
228
|
+
if ts is not None and ts > versions.get(sid, 0):
|
|
229
|
+
versions[sid] = int(ts)
|
|
230
|
+
return versions
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def source_key_for(file_path: Path, session_id: str) -> str:
|
|
234
|
+
return f"{file_path}#{session_id}"
|
|
235
|
+
|
|
236
|
+
def parse_session_file(
|
|
237
|
+
self, file_path: Path, synced: dict[str, int] | None = None
|
|
238
|
+
) -> list[ParsedSession]:
|
|
239
|
+
"""Parse opencode.db into sessions.
|
|
240
|
+
|
|
241
|
+
When ``synced`` (sync_state map of key -> version) is given, sessions
|
|
242
|
+
whose version hasn't changed since the last sync are skipped without
|
|
243
|
+
reading their messages/parts.
|
|
244
|
+
"""
|
|
217
245
|
try:
|
|
218
246
|
conn = sqlite3.connect(f"file:{file_path}?mode=ro", uri=True)
|
|
219
247
|
except sqlite3.Error:
|
|
@@ -226,9 +254,14 @@ class OpencodeProvider(Provider):
|
|
|
226
254
|
"SELECT id, directory, title, version, model, agent, "
|
|
227
255
|
"time_created, time_updated FROM session ORDER BY time_created"
|
|
228
256
|
).fetchall()
|
|
257
|
+
versions = self._session_versions(conn)
|
|
229
258
|
|
|
230
259
|
for s in session_rows:
|
|
231
260
|
sid = s["id"]
|
|
261
|
+
key = self.source_key_for(file_path, sid)
|
|
262
|
+
version = versions.get(sid) or s["time_updated"]
|
|
263
|
+
if synced is not None and synced.get(key) == version:
|
|
264
|
+
continue
|
|
232
265
|
message_rows = conn.execute(
|
|
233
266
|
"SELECT id, time_created, data FROM message "
|
|
234
267
|
"WHERE session_id = ? ORDER BY time_created, id",
|
|
@@ -292,6 +325,8 @@ class OpencodeProvider(Provider):
|
|
|
292
325
|
agent_version=s["version"] or None,
|
|
293
326
|
model=model,
|
|
294
327
|
provider_id="opencode",
|
|
328
|
+
source_key=key,
|
|
329
|
+
source_version=int(version) if version is not None else None,
|
|
295
330
|
)
|
|
296
331
|
session.trace = build_flat_trace_from_messages(
|
|
297
332
|
provider_id="opencode",
|
|
@@ -73,6 +73,7 @@ CREATE TABLE IF NOT EXISTS chunks (
|
|
|
73
73
|
|
|
74
74
|
CREATE INDEX IF NOT EXISTS idx_chunks_session ON chunks(session_id);
|
|
75
75
|
CREATE INDEX IF NOT EXISTS idx_chunks_project ON chunks(project);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_message ON chunks(message_id);
|
|
76
77
|
|
|
77
78
|
CREATE TABLE IF NOT EXISTS sync_state (
|
|
78
79
|
file_path TEXT PRIMARY KEY,
|