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.
Files changed (69) hide show
  1. {spooling-0.2.4/spooling.egg-info → spooling-0.2.6}/PKG-INFO +1 -1
  2. {spooling-0.2.4 → spooling-0.2.6}/pyproject.toml +1 -1
  3. {spooling-0.2.4 → spooling-0.2.6}/spooling/cli.py +28 -0
  4. {spooling-0.2.4 → spooling-0.2.6}/spooling/cloud.py +53 -7
  5. {spooling-0.2.4 → spooling-0.2.6}/spooling/ingest.py +101 -7
  6. spooling-0.2.6/spooling/optimize.py +159 -0
  7. {spooling-0.2.4 → spooling-0.2.6}/spooling/parser.py +7 -0
  8. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/base.py +7 -0
  9. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/opencode.py +36 -1
  10. {spooling-0.2.4 → spooling-0.2.6}/spooling/schema.py +1 -0
  11. spooling-0.2.6/spooling/search.py +136 -0
  12. {spooling-0.2.4 → spooling-0.2.6}/spooling/watcher.py +11 -2
  13. {spooling-0.2.4 → spooling-0.2.6/spooling.egg-info}/PKG-INFO +1 -1
  14. {spooling-0.2.4 → spooling-0.2.6}/spooling.egg-info/SOURCES.txt +2 -0
  15. spooling-0.2.6/tests/test_storage_optimizations.py +281 -0
  16. spooling-0.2.4/spooling/search.py +0 -86
  17. {spooling-0.2.4 → spooling-0.2.6}/LICENSE +0 -0
  18. {spooling-0.2.4 → spooling-0.2.6}/README.md +0 -0
  19. {spooling-0.2.4 → spooling-0.2.6}/setup.cfg +0 -0
  20. {spooling-0.2.4 → spooling-0.2.6}/spooling/__init__.py +0 -0
  21. {spooling-0.2.4 → spooling-0.2.6}/spooling/agent.py +0 -0
  22. {spooling-0.2.4 → spooling-0.2.6}/spooling/classifiers.py +0 -0
  23. {spooling-0.2.4 → spooling-0.2.6}/spooling/config.py +0 -0
  24. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/__init__.py +0 -0
  25. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/base.py +0 -0
  26. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/bigquery.py +0 -0
  27. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/clickhouse.py +0 -0
  28. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/factory.py +0 -0
  29. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/mongodb.py +0 -0
  30. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/mysql.py +0 -0
  31. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/postgresql.py +0 -0
  32. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/rest/__init__.py +0 -0
  33. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/rest/base.py +0 -0
  34. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/rest/shopify_connectors.py +0 -0
  35. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/snowflake.py +0 -0
  36. {spooling-0.2.4 → spooling-0.2.6}/spooling/connectors/types.py +0 -0
  37. {spooling-0.2.4 → spooling-0.2.6}/spooling/db.py +0 -0
  38. {spooling-0.2.4 → spooling-0.2.6}/spooling/embeddings.py +0 -0
  39. {spooling-0.2.4 → spooling-0.2.6}/spooling/evals.py +0 -0
  40. {spooling-0.2.4 → spooling-0.2.6}/spooling/experiments.py +0 -0
  41. {spooling-0.2.4 → spooling-0.2.6}/spooling/mcp_server.py +0 -0
  42. {spooling-0.2.4 → spooling-0.2.6}/spooling/pricing.py +0 -0
  43. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/__init__.py +0 -0
  44. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/antigravity.py +0 -0
  45. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/codex.py +0 -0
  46. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/copilot.py +0 -0
  47. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/cortex_code.py +0 -0
  48. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/cursor.py +0 -0
  49. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/gemini.py +0 -0
  50. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/github.py +0 -0
  51. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/gitlab.py +0 -0
  52. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/kiro.py +0 -0
  53. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/session_file.py +0 -0
  54. {spooling-0.2.4 → spooling-0.2.6}/spooling/providers/windsurf.py +0 -0
  55. {spooling-0.2.4 → spooling-0.2.6}/spooling/redact.py +0 -0
  56. {spooling-0.2.4 → spooling-0.2.6}/spooling/remote_otel.py +0 -0
  57. {spooling-0.2.4 → spooling-0.2.6}/spooling/sdk.py +0 -0
  58. {spooling-0.2.4 → spooling-0.2.6}/spooling/server.py +0 -0
  59. {spooling-0.2.4 → spooling-0.2.6}/spooling/stats.py +0 -0
  60. {spooling-0.2.4 → spooling-0.2.6}/spooling/subscription_pricing.py +0 -0
  61. {spooling-0.2.4 → spooling-0.2.6}/spooling/tracing.py +0 -0
  62. {spooling-0.2.4 → spooling-0.2.6}/spooling/tunnel.py +0 -0
  63. {spooling-0.2.4 → spooling-0.2.6}/spooling/tunnel_auth.py +0 -0
  64. {spooling-0.2.4 → spooling-0.2.6}/spooling.egg-info/dependency_links.txt +0 -0
  65. {spooling-0.2.4 → spooling-0.2.6}/spooling.egg-info/entry_points.txt +0 -0
  66. {spooling-0.2.4 → spooling-0.2.6}/spooling.egg-info/requires.txt +0 -0
  67. {spooling-0.2.4 → spooling-0.2.6}/spooling.egg-info/top_level.txt +0 -0
  68. {spooling-0.2.4 → spooling-0.2.6}/tests/test_mcp_auth_middleware.py +0 -0
  69. {spooling-0.2.4 → spooling-0.2.6}/tests/test_tunnel_auth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spooling
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spooling"
3
- version = "0.2.4"
3
+ version = "0.2.6"
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." }]
@@ -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"] or {},
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"] or {},
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"] or [],
327
- "attrs": t["attrs"] or {},
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
- 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):
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
- def cloud_watch(interval: int, limit: int, batch: int, lookback: int, no_redact: bool):
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
- if not msg.content.strip():
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.parse_session_file(f)
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
- def parse_session_file(self, file_path: Path) -> list[ParsedSession]:
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,