sede 0.2.2__tar.gz → 0.2.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sede
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Session deleter for coding assistants
5
5
  Author-email: Ilya Popov <ilypopv@gmail.com>
6
6
  License: MIT
@@ -146,7 +146,7 @@ When no sessions are found for a provider, the same screen layout is shown inste
146
146
  - Claude: deletes selected `.jsonl` session file, then removes parent project dir only if empty.
147
147
  - Copilot: deletes the selected session directory recursively.
148
148
  - Antigravity: deletes the selected conversation directory recursively.
149
- - OpenCode: deletes selected session rows (`part`/`message`/`session` with `PRAGMA foreign_keys=ON`) from the shared `opencode.db`; DB file and other sessions are untouched.
149
+ - OpenCode: deletes selected session rows from the shared `opencode.db` (`part`/`message`/`session` for v1, `session_v2` plus related rows for v2, with `PRAGMA foreign_keys=ON`); v2 child sessions are removed too, while the DB file and other sessions are untouched.
150
150
  - Always review selected entries before confirming.
151
151
 
152
152
  ## Session Sources
@@ -154,4 +154,4 @@ When no sessions are found for a provider, the same screen layout is shown inste
154
154
  - Claude: `~/.claude/projects/*/*.jsonl`
155
155
  - Copilot: `~/.copilot/session-state/<session-id>/`
156
156
  - Antigravity: `~/.gemini/antigravity-cli/brain/<session-id>/` and `~/.gemini/antigravity/brain/<session-id>/`
157
- - OpenCode: `~/.local/share/opencode/opencode.db` (fallback `~/Library/Application Support/opencode/opencode.db`, `XDG_DATA_HOME` honoured; same XDG path for brew/non-brew) — only chat sessions are surfaced, caches (`~/.cache/opencode`, `~/.local/state/opencode`, snapshot/log) are excluded
157
+ - OpenCode: `~/.local/share/opencode/opencode.db` (fallback `~/Library/Application Support/opencode/opencode.db`, `XDG_DATA_HOME` honoured; same XDG path for brew/non-brew) — reads both v1 (`session`/`message`/`part`) and v2 (`session_v2`/`session_message`/`session_part`) tables; only chat sessions are surfaced, caches (`~/.cache/opencode`, `~/.local/state/opencode`, snapshot/log) are excluded
@@ -125,7 +125,7 @@ When no sessions are found for a provider, the same screen layout is shown inste
125
125
  - Claude: deletes selected `.jsonl` session file, then removes parent project dir only if empty.
126
126
  - Copilot: deletes the selected session directory recursively.
127
127
  - Antigravity: deletes the selected conversation directory recursively.
128
- - OpenCode: deletes selected session rows (`part`/`message`/`session` with `PRAGMA foreign_keys=ON`) from the shared `opencode.db`; DB file and other sessions are untouched.
128
+ - OpenCode: deletes selected session rows from the shared `opencode.db` (`part`/`message`/`session` for v1, `session_v2` plus related rows for v2, with `PRAGMA foreign_keys=ON`); v2 child sessions are removed too, while the DB file and other sessions are untouched.
129
129
  - Always review selected entries before confirming.
130
130
 
131
131
  ## Session Sources
@@ -133,4 +133,4 @@ When no sessions are found for a provider, the same screen layout is shown inste
133
133
  - Claude: `~/.claude/projects/*/*.jsonl`
134
134
  - Copilot: `~/.copilot/session-state/<session-id>/`
135
135
  - Antigravity: `~/.gemini/antigravity-cli/brain/<session-id>/` and `~/.gemini/antigravity/brain/<session-id>/`
136
- - OpenCode: `~/.local/share/opencode/opencode.db` (fallback `~/Library/Application Support/opencode/opencode.db`, `XDG_DATA_HOME` honoured; same XDG path for brew/non-brew) — only chat sessions are surfaced, caches (`~/.cache/opencode`, `~/.local/state/opencode`, snapshot/log) are excluded
136
+ - OpenCode: `~/.local/share/opencode/opencode.db` (fallback `~/Library/Application Support/opencode/opencode.db`, `XDG_DATA_HOME` honoured; same XDG path for brew/non-brew) — reads both v1 (`session`/`message`/`part`) and v2 (`session_v2`/`session_message`/`session_part`) tables; only chat sessions are surfaced, caches (`~/.cache/opencode`, `~/.local/state/opencode`, snapshot/log) are excluded
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sede"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Session deleter for coding assistants"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = { text = "MIT" }
@@ -359,6 +359,71 @@ def _discover_antigravity_sessions() -> list[SessionRecord]:
359
359
  return sorted(sessions, key=lambda s: s.updated_at, reverse=True)
360
360
 
361
361
 
362
+ _OPENCODE_SESSION_SCHEMAS = (
363
+ ("session_v2", "session_message", "session_part"),
364
+ ("session", "message", "part"),
365
+ )
366
+
367
+ _OPENCODE_V2_SESSION_TABLES = (
368
+ "blob_ref",
369
+ "instruction_entry",
370
+ "instruction_state",
371
+ "schedule",
372
+ "session_inbox",
373
+ "session_part",
374
+ "session_message",
375
+ )
376
+
377
+
378
+ def _opencode_table_names(cursor: sqlite3.Cursor) -> set[str]:
379
+ """Returns the table names present in an OpenCode SQLite database.
380
+
381
+ Args:
382
+ cursor: Cursor connected to the OpenCode database.
383
+
384
+ Returns:
385
+ The set of table names available in the database.
386
+ """
387
+ return {
388
+ row[0]
389
+ for row in cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
390
+ }
391
+
392
+
393
+ def _opencode_payload_size(
394
+ cursor: sqlite3.Cursor,
395
+ session_id: str,
396
+ message_table: str,
397
+ part_table: str,
398
+ fallback_size: int,
399
+ ) -> int:
400
+ """Estimates an OpenCode session size from stored payload columns.
401
+
402
+ Args:
403
+ cursor: Cursor connected to the OpenCode database.
404
+ session_id: Session identifier to measure.
405
+ message_table: Table holding message payloads for this schema.
406
+ part_table: Table holding message part payloads for this schema.
407
+ fallback_size: Size to use when no payload rows are found.
408
+
409
+ Returns:
410
+ The combined payload size in bytes, or ``fallback_size`` when both
411
+ payload tables contain no data for the session.
412
+ """
413
+ size_bytes = 0
414
+ try:
415
+ for table in (message_table, part_table):
416
+ cursor.execute(
417
+ f"SELECT COALESCE(SUM(LENGTH(data)), 0) FROM {table} "
418
+ "WHERE session_id = ?",
419
+ (session_id,),
420
+ )
421
+ size_bytes += int(cursor.fetchone()[0] or 0)
422
+ except sqlite3.Error:
423
+ return 0
424
+ return size_bytes or fallback_size
425
+
426
+
362
427
  def _opencode_db_paths() -> list[Path]:
363
428
  """Returns candidate paths for the OpenCode SQLite database.
364
429
 
@@ -416,10 +481,12 @@ def _opencode_cache_paths() -> list[tuple[str, Path, str]]:
416
481
  def _discover_opencode_sessions() -> list[SessionRecord]:
417
482
  """Discovers OpenCode sessions from the SQLite database.
418
483
 
419
- Sessions are read from ``~/.local/share/opencode/opencode.db`` (and
420
- fallbacks). Only chat sessions are surfaced — local caches such as
421
- ``~/.cache/opencode`` or ``~/.local/share/opencode/snapshot`` are
422
- intentionally excluded.
484
+ Reads both the legacy v1 ``session``/``message``/``part`` tables and the
485
+ v2 ``session_v2``/``session_message``/``session_part`` tables, so a single
486
+ database can surface sessions stored by either version. Sessions are read
487
+ from ``~/.local/share/opencode/opencode.db`` (and fallbacks). Only chat
488
+ sessions are surfaced — local caches such as ``~/.cache/opencode`` or
489
+ ``~/.local/share/opencode/snapshot`` are intentionally excluded.
423
490
 
424
491
  Returns:
425
492
  Discovered OpenCode sessions sorted by last update time, newest
@@ -435,73 +502,66 @@ def _discover_opencode_sessions() -> list[SessionRecord]:
435
502
  uri = f"file:{db_path}?mode=ro"
436
503
  conn = sqlite3.connect(uri, uri=True)
437
504
  cursor = conn.cursor()
438
- # Verify required tables exist.
439
- cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session'")
440
- if cursor.fetchone() is None:
441
- conn.close()
442
- continue
505
+ tables = _opencode_table_names(cursor)
443
506
 
444
- # Left join project to resolve worktree when session.directory is empty.
445
- cursor.execute(
446
- "SELECT s.id, s.project_id, s.directory, s.title, "
447
- "s.time_created, s.time_updated, p.worktree "
448
- "FROM session s LEFT JOIN project p ON s.project_id = p.id"
449
- )
450
- rows = cursor.fetchall()
451
-
452
- # Pre-compute sizes per session when possible (message + part payloads).
453
- for row in rows:
454
- sid, pid, directory, title, t_created, t_updated, worktree = row
455
- if sid in seen_ids:
507
+ for session_table, message_table, part_table in _OPENCODE_SESSION_SCHEMAS:
508
+ if session_table not in tables:
456
509
  continue
457
- seen_ids.add(sid)
458
- project_path = directory or worktree or "Unknown project"
459
- title_val = title or sid
460
- # time columns are milliseconds since epoch
461
- try:
462
- ms = int(t_updated if t_updated is not None else t_created)
463
- updated_at = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
464
- except Exception:
465
- try:
466
- stat = db_path.stat()
467
- updated_at = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
468
- except OSError:
469
- updated_at = datetime.now(timezone.utc)
470
510
 
471
- # Estimate size via sum of message/part data lengths.
472
- size_bytes = 0
511
+ # Left join project to resolve worktree when directory is empty.
512
+ cursor.execute(
513
+ "SELECT s.id, s.directory, s.title, "
514
+ "s.time_created, s.time_updated, p.worktree "
515
+ f"FROM {session_table} s "
516
+ "LEFT JOIN project p ON s.project_id = p.id"
517
+ )
518
+ rows = cursor.fetchall()
519
+ # Fallback to DB file size if a session has no payload rows.
473
520
  try:
474
- cursor.execute(
475
- "SELECT COALESCE(SUM(LENGTH(data)), 0) FROM message WHERE session_id = ?",
476
- (sid,),
477
- )
478
- msg_size = cursor.fetchone()[0] or 0
479
- cursor.execute(
480
- "SELECT COALESCE(SUM(LENGTH(data)), 0) FROM part WHERE session_id = ?",
481
- (sid,),
482
- )
483
- part_size = cursor.fetchone()[0] or 0
484
- size_bytes = int(msg_size) + int(part_size)
485
- # Fallback to DB file size if both are zero but session exists.
486
- if size_bytes == 0:
521
+ fallback_size = db_path.stat().st_size // max(1, len(rows))
522
+ except OSError:
523
+ fallback_size = 0
524
+
525
+ for row in rows:
526
+ sid, directory, title, t_created, t_updated, worktree = row
527
+ if sid in seen_ids:
528
+ continue
529
+ seen_ids.add(sid)
530
+ project_path = directory or worktree or "Unknown project"
531
+ title_val = title or sid
532
+ # time columns are milliseconds since epoch
533
+ try:
534
+ ms = int(t_updated if t_updated is not None else t_created)
535
+ updated_at = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
536
+ except Exception:
487
537
  try:
488
- size_bytes = db_path.stat().st_size // max(1, len(rows))
538
+ stat = db_path.stat()
539
+ updated_at = datetime.fromtimestamp(
540
+ stat.st_mtime, tz=timezone.utc
541
+ )
489
542
  except OSError:
490
- size_bytes = 0
491
- except sqlite3.Error:
492
- size_bytes = 0
493
-
494
- sessions.append(
495
- SessionRecord(
496
- provider="opencode",
497
- session_id=sid,
498
- title=_shorten(title_val, 70),
499
- project_path=project_path,
500
- size_bytes=size_bytes,
501
- updated_at=updated_at,
502
- storage_path=db_path,
543
+ updated_at = datetime.now(timezone.utc)
544
+
545
+ # Estimate size via sum of message/part data lengths.
546
+ size_bytes = _opencode_payload_size(
547
+ cursor,
548
+ sid,
549
+ message_table,
550
+ part_table,
551
+ fallback_size,
552
+ )
553
+
554
+ sessions.append(
555
+ SessionRecord(
556
+ provider="opencode",
557
+ session_id=sid,
558
+ title=_shorten(title_val, 70),
559
+ project_path=project_path,
560
+ size_bytes=size_bytes,
561
+ updated_at=updated_at,
562
+ storage_path=db_path,
563
+ )
503
564
  )
504
- )
505
565
  conn.close()
506
566
  except sqlite3.Error:
507
567
  try:
@@ -513,6 +573,64 @@ def _discover_opencode_sessions() -> list[SessionRecord]:
513
573
  return sorted(sessions, key=lambda s: s.updated_at, reverse=True)
514
574
 
515
575
 
576
+ def _delete_opencode_v2_session(
577
+ cursor: sqlite3.Cursor, session_id: str, tables: set[str]
578
+ ) -> None:
579
+ """Deletes an OpenCode v2 session, its child sessions, and related rows.
580
+
581
+ Mirrors OpenCode's own session deletion: child sessions are removed
582
+ recursively, durable events and jobs for the session are dropped, and
583
+ session-scoped rows are deleted explicitly before the ``session_v2`` row
584
+ itself. Optional tables are skipped when absent so databases from older
585
+ or newer schema versions keep working.
586
+
587
+ Args:
588
+ cursor: Cursor connected to the OpenCode database.
589
+ session_id: Identifier of the session to delete.
590
+ tables: Table names present in the database.
591
+ """
592
+ if "session_v2" not in tables:
593
+ return
594
+
595
+ pending = [session_id]
596
+ seen: set[str] = set()
597
+
598
+ while pending:
599
+ current = pending.pop()
600
+ if current in seen:
601
+ continue
602
+ seen.add(current)
603
+
604
+ cursor.execute("SELECT id FROM session_v2 WHERE parent_id = ?", (current,))
605
+ pending.extend(row[0] for row in cursor.fetchall())
606
+
607
+ if "job" in tables:
608
+ cursor.execute(
609
+ "DELETE FROM job WHERE session_id = ? OR child_session_id = ?",
610
+ (current, current),
611
+ )
612
+ if "event" in tables:
613
+ cursor.execute("DELETE FROM event WHERE aggregate_id = ?", (current,))
614
+ if "event_sequence" in tables:
615
+ cursor.execute(
616
+ "DELETE FROM event_sequence WHERE aggregate_id = ?", (current,)
617
+ )
618
+ if "blob" in tables and "blob_ref" in tables:
619
+ cursor.execute(
620
+ "DELETE FROM blob WHERE hash IN ("
621
+ "SELECT hash FROM blob_ref WHERE session_id = ?"
622
+ ") AND NOT EXISTS ("
623
+ "SELECT 1 FROM blob_ref AS ref WHERE ref.hash = blob.hash "
624
+ "AND ref.session_id != ?"
625
+ ")",
626
+ (current, current),
627
+ )
628
+ for table in _OPENCODE_V2_SESSION_TABLES:
629
+ if table in tables:
630
+ cursor.execute(f"DELETE FROM {table} WHERE session_id = ?", (current,))
631
+ cursor.execute("DELETE FROM session_v2 WHERE id = ?", (current,))
632
+
633
+
516
634
  def _delete_opencode_session(session_path: Path, session_id: str) -> None:
517
635
  """Deletes an OpenCode session.
518
636
 
@@ -520,10 +638,11 @@ def _delete_opencode_session(session_path: Path, session_id: str) -> None:
520
638
  session_path: Path to the OpenCode database (``opencode.db``). For
521
639
  legacy cache entries this may be a cache directory.
522
640
  session_id: Session identifier. Cache entries prefixed with
523
- ``cache:`` are removed via filesystem deletion; all other values
524
- are treated as DB session ids and removed safely via
525
- ``DELETE`` with explicit cleanup of ``message``/``part`` rows
526
- so that a shared ``opencode.db`` file is never removed and
641
+ ``cache:`` are removed via filesystem deletion. Legacy v1
642
+ sessions are removed with explicit ``DELETE`` calls on
643
+ ``part``/``message``/``session``; v2 sessions use the
644
+ ``session_v2`` schema and also delete child sessions and their
645
+ related rows. A shared ``opencode.db`` file is never removed and
527
646
  other sessions remain untouched.
528
647
  """
529
648
  if session_id.startswith("cache:"):
@@ -538,9 +657,16 @@ def _delete_opencode_session(session_path: Path, session_id: str) -> None:
538
657
  try:
539
658
  conn.execute("PRAGMA foreign_keys = ON")
540
659
  cursor = conn.cursor()
541
- cursor.execute("DELETE FROM part WHERE session_id = ?", (session_id,))
542
- cursor.execute("DELETE FROM message WHERE session_id = ?", (session_id,))
543
- cursor.execute("DELETE FROM session WHERE id = ?", (session_id,))
660
+ tables = _opencode_table_names(cursor)
661
+
662
+ if "session" in tables:
663
+ cursor.execute("DELETE FROM part WHERE session_id = ?", (session_id,))
664
+ cursor.execute("DELETE FROM message WHERE session_id = ?", (session_id,))
665
+ cursor.execute("DELETE FROM session WHERE id = ?", (session_id,))
666
+
667
+ if "session_v2" in tables:
668
+ _delete_opencode_v2_session(cursor, session_id, tables)
669
+
544
670
  conn.commit()
545
671
  finally:
546
672
  conn.close()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sede
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Session deleter for coding assistants
5
5
  Author-email: Ilya Popov <ilypopv@gmail.com>
6
6
  License: MIT
@@ -146,7 +146,7 @@ When no sessions are found for a provider, the same screen layout is shown inste
146
146
  - Claude: deletes selected `.jsonl` session file, then removes parent project dir only if empty.
147
147
  - Copilot: deletes the selected session directory recursively.
148
148
  - Antigravity: deletes the selected conversation directory recursively.
149
- - OpenCode: deletes selected session rows (`part`/`message`/`session` with `PRAGMA foreign_keys=ON`) from the shared `opencode.db`; DB file and other sessions are untouched.
149
+ - OpenCode: deletes selected session rows from the shared `opencode.db` (`part`/`message`/`session` for v1, `session_v2` plus related rows for v2, with `PRAGMA foreign_keys=ON`); v2 child sessions are removed too, while the DB file and other sessions are untouched.
150
150
  - Always review selected entries before confirming.
151
151
 
152
152
  ## Session Sources
@@ -154,4 +154,4 @@ When no sessions are found for a provider, the same screen layout is shown inste
154
154
  - Claude: `~/.claude/projects/*/*.jsonl`
155
155
  - Copilot: `~/.copilot/session-state/<session-id>/`
156
156
  - Antigravity: `~/.gemini/antigravity-cli/brain/<session-id>/` and `~/.gemini/antigravity/brain/<session-id>/`
157
- - OpenCode: `~/.local/share/opencode/opencode.db` (fallback `~/Library/Application Support/opencode/opencode.db`, `XDG_DATA_HOME` honoured; same XDG path for brew/non-brew) — only chat sessions are surfaced, caches (`~/.cache/opencode`, `~/.local/state/opencode`, snapshot/log) are excluded
157
+ - OpenCode: `~/.local/share/opencode/opencode.db` (fallback `~/Library/Application Support/opencode/opencode.db`, `XDG_DATA_HOME` honoured; same XDG path for brew/non-brew) — reads both v1 (`session`/`message`/`part`) and v2 (`session_v2`/`session_message`/`session_part`) tables; only chat sessions are surfaced, caches (`~/.cache/opencode`, `~/.local/state/opencode`, snapshot/log) are excluded
@@ -204,8 +204,12 @@ def test_delete_antigravity_session_cleans_db_and_summaries(tmp_path: Path) -> N
204
204
 
205
205
  summaries_db = app_dir / "conversation_summaries.db"
206
206
  conn = sqlite3.connect(str(summaries_db))
207
- conn.execute("CREATE TABLE conversation_summaries (conversation_id TEXT PRIMARY KEY, title TEXT)")
208
- conn.execute("INSERT INTO conversation_summaries VALUES ('session-123', 'My Session')")
207
+ conn.execute(
208
+ "CREATE TABLE conversation_summaries (conversation_id TEXT PRIMARY KEY, title TEXT)"
209
+ )
210
+ conn.execute(
211
+ "INSERT INTO conversation_summaries VALUES ('session-123', 'My Session')"
212
+ )
209
213
  conn.commit()
210
214
  conn.close()
211
215
 
@@ -217,7 +221,9 @@ def test_delete_antigravity_session_cleans_db_and_summaries(tmp_path: Path) -> N
217
221
 
218
222
  conn = sqlite3.connect(str(summaries_db))
219
223
  cursor = conn.cursor()
220
- cursor.execute("SELECT * FROM conversation_summaries WHERE conversation_id = 'session-123'")
224
+ cursor.execute(
225
+ "SELECT * FROM conversation_summaries WHERE conversation_id = 'session-123'"
226
+ )
221
227
  assert cursor.fetchone() is None
222
228
  conn.close()
223
229
 
@@ -329,12 +335,16 @@ def test_read_antigravity_metadata_extracts_tool_calls_cwd(tmp_path: Path) -> No
329
335
  assert metadata.get("prompt") == "Build feature"
330
336
 
331
337
 
332
- def test_discover_antigravity_sessions_handles_empty_root(tmp_path: Path, monkeypatch) -> None:
338
+ def test_discover_antigravity_sessions_handles_empty_root(
339
+ tmp_path: Path, monkeypatch
340
+ ) -> None:
333
341
  monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
334
342
  assert discovery._discover_antigravity_sessions() == []
335
343
 
336
344
 
337
- def test_find_profile_targets_matches_direct_marker(tmp_path: Path, monkeypatch) -> None:
345
+ def test_find_profile_targets_matches_direct_marker(
346
+ tmp_path: Path, monkeypatch
347
+ ) -> None:
338
348
  monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
339
349
  target = tmp_path / ".copilot" / "session-state"
340
350
  target.mkdir(parents=True)
@@ -344,7 +354,9 @@ def test_find_profile_targets_matches_direct_marker(tmp_path: Path, monkeypatch)
344
354
  assert found == [target]
345
355
 
346
356
 
347
- def test_find_profile_targets_finds_profile_subdirs(tmp_path: Path, monkeypatch) -> None:
357
+ def test_find_profile_targets_finds_profile_subdirs(
358
+ tmp_path: Path, monkeypatch
359
+ ) -> None:
348
360
  monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
349
361
 
350
362
  direct = tmp_path / ".copilot-profiles" / "az" / "session-state"
@@ -502,14 +514,18 @@ def test_delete_session_rejects_unknown_provider(tmp_path: Path) -> None:
502
514
  discovery.delete_session(record)
503
515
 
504
516
 
505
- def _create_opencode_db(db_path: Path, sessions: list[tuple[str, str, str, int]]) -> None:
517
+ def _create_opencode_db(
518
+ db_path: Path, sessions: list[tuple[str, str, str, int]]
519
+ ) -> None:
506
520
  db_path.parent.mkdir(parents=True, exist_ok=True)
507
521
  conn = __import__("sqlite3").connect(str(db_path))
508
522
  conn.execute("CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT)")
509
523
  conn.execute(
510
524
  "CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER)"
511
525
  )
512
- conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT)")
526
+ conn.execute(
527
+ "CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT)"
528
+ )
513
529
  conn.execute(
514
530
  "CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT)"
515
531
  )
@@ -519,7 +535,9 @@ def _create_opencode_db(db_path: Path, sessions: list[tuple[str, str, str, int]]
519
535
  "INSERT INTO session VALUES (?, ?, ?, ?, ?, ?)",
520
536
  (sid, proj, f"/tmp/{proj}", title, ms - 1000, ms),
521
537
  )
522
- conn.execute("INSERT INTO message VALUES (?, ?, ?)", (f"msg-{sid}", sid, "msgdata"))
538
+ conn.execute(
539
+ "INSERT INTO message VALUES (?, ?, ?)", (f"msg-{sid}", sid, "msgdata")
540
+ )
523
541
  conn.execute(
524
542
  "INSERT INTO part VALUES (?, ?, ?, ?)",
525
543
  (f"prt-{sid}", f"msg-{sid}", sid, "partdata"),
@@ -528,6 +546,44 @@ def _create_opencode_db(db_path: Path, sessions: list[tuple[str, str, str, int]]
528
546
  conn.close()
529
547
 
530
548
 
549
+ def _create_opencode_v2_db(
550
+ db_path: Path,
551
+ sessions: list[tuple[str, str, str, int]],
552
+ parents: dict[str, str] | None = None,
553
+ ) -> None:
554
+ db_path.parent.mkdir(parents=True, exist_ok=True)
555
+ conn = __import__("sqlite3").connect(str(db_path))
556
+ conn.execute("CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT)")
557
+ conn.execute(
558
+ "CREATE TABLE session_v2 (id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER)"
559
+ )
560
+ conn.execute(
561
+ "CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT)"
562
+ )
563
+ conn.execute(
564
+ "CREATE TABLE session_part (message_id TEXT, session_id TEXT, ordinal INTEGER, data TEXT)"
565
+ )
566
+ parent_ids = parents or {}
567
+ for sid, proj, title, ms in sessions:
568
+ conn.execute(
569
+ "INSERT OR IGNORE INTO project VALUES (?, ?)", (proj, f"/tmp/{proj}")
570
+ )
571
+ conn.execute(
572
+ "INSERT INTO session_v2 VALUES (?, ?, ?, ?, ?, ?, ?)",
573
+ (sid, proj, parent_ids.get(sid), f"/tmp/{proj}", title, ms - 1000, ms),
574
+ )
575
+ conn.execute(
576
+ "INSERT INTO session_message VALUES (?, ?, ?)",
577
+ (f"msg-{sid}", sid, "msgdata"),
578
+ )
579
+ conn.execute(
580
+ "INSERT INTO session_part VALUES (?, ?, ?, ?)",
581
+ (f"msg-{sid}", sid, 0, "partdata"),
582
+ )
583
+ conn.commit()
584
+ conn.close()
585
+
586
+
531
587
  def test_opencode_db_paths_respects_home(monkeypatch, tmp_path: Path) -> None:
532
588
  monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
533
589
  paths = discovery._opencode_db_paths()
@@ -630,12 +686,16 @@ def test_delete_opencode_cache_removes_file(tmp_path: Path) -> None:
630
686
  assert not cache_file.exists()
631
687
 
632
688
 
633
- def test_discover_opencode_sessions_handles_missing_db(monkeypatch, tmp_path: Path) -> None:
689
+ def test_discover_opencode_sessions_handles_missing_db(
690
+ monkeypatch, tmp_path: Path
691
+ ) -> None:
634
692
  monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
635
693
  assert discovery._discover_opencode_sessions() == []
636
694
 
637
695
 
638
- def test_discover_opencode_sessions_handles_corrupt_db(monkeypatch, tmp_path: Path) -> None:
696
+ def test_discover_opencode_sessions_handles_corrupt_db(
697
+ monkeypatch, tmp_path: Path
698
+ ) -> None:
639
699
  monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
640
700
  db_path = tmp_path / ".local" / "share" / "opencode" / "opencode.db"
641
701
  db_path.parent.mkdir(parents=True)
@@ -677,7 +737,9 @@ def test_discover_opencode_sessions_handles_empty_title_and_missing_project(
677
737
  conn.execute(
678
738
  "CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER)"
679
739
  )
680
- conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT)")
740
+ conn.execute(
741
+ "CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT)"
742
+ )
681
743
  conn.execute(
682
744
  "CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT)"
683
745
  )
@@ -735,3 +797,281 @@ def test_delete_opencode_session_missing_db_raises(tmp_path: Path) -> None:
735
797
  )
736
798
  with pytest.raises(FileNotFoundError):
737
799
  discovery.delete_session(record)
800
+
801
+
802
+ def test_discover_opencode_sessions_reads_v1_and_v2_tables(
803
+ monkeypatch, tmp_path: Path
804
+ ) -> None:
805
+ import sqlite3
806
+
807
+ monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
808
+ db_path = tmp_path / ".local" / "share" / "opencode" / "opencode.db"
809
+ _create_opencode_v2_db(
810
+ db_path,
811
+ [("ses_v2", "proj_v2", "V2 Session", 1788600000000)],
812
+ )
813
+
814
+ conn = sqlite3.connect(str(db_path))
815
+ conn.execute(
816
+ "CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER)"
817
+ )
818
+ conn.execute(
819
+ "CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT)"
820
+ )
821
+ conn.execute(
822
+ "CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT)"
823
+ )
824
+ conn.execute("INSERT INTO project VALUES ('proj_v1', '/tmp/proj_v1')")
825
+ conn.execute(
826
+ "INSERT INTO session VALUES ('ses_v1', 'proj_v1', '/tmp/proj_v1', 'V1 Session', ?, ?)",
827
+ (1788590000000, 1788590000000),
828
+ )
829
+ conn.execute("INSERT INTO message VALUES ('msg_v1', 'ses_v1', 'v1data')")
830
+ conn.execute("INSERT INTO part VALUES ('prt_v1', 'msg_v1', 'ses_v1', 'v1part')")
831
+ conn.commit()
832
+ conn.close()
833
+
834
+ sessions = discovery._discover_opencode_sessions()
835
+
836
+ assert [s.session_id for s in sessions] == ["ses_v2", "ses_v1"]
837
+ assert all(s.provider == "opencode" for s in sessions)
838
+ assert sessions[0].title == "V2 Session"
839
+ assert sessions[0].project_path == "/tmp/proj_v2"
840
+ assert sessions[0].size_bytes > 0
841
+ assert sessions[1].title == "V1 Session"
842
+
843
+
844
+ def test_discover_opencode_sessions_v2_uses_fallbacks(
845
+ monkeypatch, tmp_path: Path
846
+ ) -> None:
847
+ import sqlite3
848
+
849
+ monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
850
+ db_path = tmp_path / ".local" / "share" / "opencode" / "opencode.db"
851
+ _create_opencode_v2_db(
852
+ db_path,
853
+ [("ses_v2", "proj_v2", "V2 Session", 1788600000000)],
854
+ )
855
+
856
+ conn = sqlite3.connect(str(db_path))
857
+ conn.execute(
858
+ "INSERT INTO session_v2 VALUES ('ses_empty', 'missing', NULL, '', '', ?, ?)",
859
+ (1788590000000, 1788590000000),
860
+ )
861
+ conn.commit()
862
+ conn.close()
863
+
864
+ sessions = discovery._discover_opencode_sessions()
865
+ empty = next(s for s in sessions if s.session_id == "ses_empty")
866
+
867
+ assert empty.title == "ses_empty"
868
+ assert empty.project_path == "Unknown project"
869
+ assert empty.size_bytes > 0
870
+
871
+
872
+ def test_discover_opencode_sessions_v2_handles_missing_payload_tables(
873
+ monkeypatch, tmp_path: Path
874
+ ) -> None:
875
+ import sqlite3
876
+
877
+ monkeypatch.setattr(discovery.Path, "home", staticmethod(lambda: tmp_path))
878
+ db_path = tmp_path / ".local" / "share" / "opencode" / "opencode.db"
879
+ db_path.parent.mkdir(parents=True)
880
+ conn = sqlite3.connect(str(db_path))
881
+ conn.execute("CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT)")
882
+ conn.execute(
883
+ "CREATE TABLE session_v2 (id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER)"
884
+ )
885
+ conn.execute(
886
+ "INSERT INTO session_v2 VALUES ('ses_v2', 'proj_v2', NULL, '/tmp/proj_v2', 'V2 Session', 1788590000000, 1788590000000)"
887
+ )
888
+ conn.commit()
889
+ conn.close()
890
+
891
+ sessions = discovery._discover_opencode_sessions()
892
+
893
+ assert len(sessions) == 1
894
+ assert sessions[0].session_id == "ses_v2"
895
+ assert sessions[0].size_bytes == 0
896
+
897
+
898
+ def test_delete_opencode_v2_session_removes_children_and_related_rows(
899
+ tmp_path: Path,
900
+ ) -> None:
901
+ import sqlite3
902
+
903
+ db_path = tmp_path / "opencode.db"
904
+ _create_opencode_v2_db(
905
+ db_path,
906
+ [
907
+ ("ses_parent", "proj", "Parent", 1788590000000),
908
+ ("ses_child", "proj", "Child", 1788590001000),
909
+ ("ses_other", "proj", "Other", 1788590002000),
910
+ ],
911
+ parents={"ses_child": "ses_parent"},
912
+ )
913
+
914
+ conn = sqlite3.connect(str(db_path))
915
+ conn.execute("CREATE TABLE instruction_state (session_id TEXT PRIMARY KEY)")
916
+ conn.execute("CREATE TABLE instruction_entry (session_id TEXT, key TEXT)")
917
+ conn.execute("CREATE TABLE blob_ref (session_id TEXT, message_id TEXT, hash TEXT)")
918
+ conn.execute("CREATE TABLE schedule (id TEXT PRIMARY KEY, session_id TEXT)")
919
+ conn.execute("CREATE TABLE session_inbox (id TEXT PRIMARY KEY, session_id TEXT)")
920
+ conn.execute(
921
+ "CREATE TABLE job (notification_id TEXT PRIMARY KEY, session_id TEXT, child_session_id TEXT)"
922
+ )
923
+ conn.execute(
924
+ "CREATE TABLE event_sequence (aggregate_id TEXT PRIMARY KEY, seq INTEGER)"
925
+ )
926
+ conn.execute("CREATE TABLE event (id TEXT PRIMARY KEY, aggregate_id TEXT)")
927
+ for sid in ("ses_parent", "ses_child", "ses_other"):
928
+ conn.execute("INSERT INTO instruction_state VALUES (?)", (sid,))
929
+ conn.execute("INSERT INTO instruction_entry VALUES (?, 'key')", (sid,))
930
+ conn.execute(
931
+ "INSERT INTO blob_ref VALUES (?, ?, ?)", (sid, f"msg-{sid}", "hash")
932
+ )
933
+ conn.execute("INSERT INTO schedule VALUES (?, ?)", (f"sch-{sid}", sid))
934
+ conn.execute("INSERT INTO session_inbox VALUES (?, ?)", (f"inbox-{sid}", sid))
935
+ conn.execute("INSERT INTO event_sequence VALUES (?, 1)", (sid,))
936
+ conn.execute("INSERT INTO event VALUES (?, ?)", (f"evt-{sid}", sid))
937
+ conn.execute("INSERT INTO job VALUES ('n1', 'ses_parent', NULL)")
938
+ conn.execute("INSERT INTO job VALUES ('n2', 'ses_other', 'ses_child')")
939
+ conn.execute("INSERT INTO job VALUES ('n3', 'ses_other', NULL)")
940
+ conn.commit()
941
+ conn.close()
942
+
943
+ record = SessionRecord(
944
+ provider="opencode",
945
+ session_id="ses_parent",
946
+ title="Parent",
947
+ project_path="/tmp/proj",
948
+ size_bytes=10,
949
+ updated_at=datetime.now(timezone.utc),
950
+ storage_path=db_path,
951
+ )
952
+ discovery.delete_session(record)
953
+
954
+ conn = sqlite3.connect(str(db_path))
955
+ cur = conn.cursor()
956
+
957
+ checks = {
958
+ "session_v2": "id",
959
+ "session_message": "session_id",
960
+ "session_part": "session_id",
961
+ "instruction_state": "session_id",
962
+ "instruction_entry": "session_id",
963
+ "blob_ref": "session_id",
964
+ "schedule": "session_id",
965
+ "session_inbox": "session_id",
966
+ "event_sequence": "aggregate_id",
967
+ "event": "aggregate_id",
968
+ }
969
+ for table, column in checks.items():
970
+ assert (
971
+ cur.execute(
972
+ f"SELECT COUNT(*) FROM {table} WHERE {column} IN (?, ?)",
973
+ ("ses_parent", "ses_child"),
974
+ ).fetchone()[0]
975
+ == 0
976
+ )
977
+
978
+ assert cur.execute("SELECT COUNT(*) FROM session_v2").fetchone()[0] == 1
979
+ assert cur.execute("SELECT id FROM session_v2").fetchone()[0] == "ses_other"
980
+ assert cur.execute("SELECT COUNT(*) FROM session_message").fetchone()[0] == 1
981
+ assert cur.execute("SELECT COUNT(*) FROM session_part").fetchone()[0] == 1
982
+ assert cur.execute("SELECT COUNT(*) FROM instruction_state").fetchone()[0] == 1
983
+ assert cur.execute("SELECT COUNT(*) FROM blob_ref").fetchone()[0] == 1
984
+ assert cur.execute("SELECT COUNT(*) FROM schedule").fetchone()[0] == 1
985
+ assert cur.execute("SELECT COUNT(*) FROM session_inbox").fetchone()[0] == 1
986
+ assert cur.execute("SELECT COUNT(*) FROM event_sequence").fetchone()[0] == 1
987
+ assert cur.execute("SELECT COUNT(*) FROM event").fetchone()[0] == 1
988
+ assert cur.execute("SELECT notification_id FROM job").fetchall() == [("n3",)]
989
+ conn.close()
990
+
991
+
992
+ def test_delete_opencode_v2_session_releases_unreferenced_blobs(
993
+ tmp_path: Path,
994
+ ) -> None:
995
+ import sqlite3
996
+
997
+ db_path = tmp_path / "opencode.db"
998
+ _create_opencode_v2_db(
999
+ db_path,
1000
+ [
1001
+ ("ses_parent", "proj", "Parent", 1788590000000),
1002
+ ("ses_child", "proj", "Child", 1788590001000),
1003
+ ("ses_other", "proj", "Other", 1788590002000),
1004
+ ],
1005
+ parents={"ses_child": "ses_parent"},
1006
+ )
1007
+
1008
+ conn = sqlite3.connect(str(db_path))
1009
+ conn.execute("CREATE TABLE blob (hash TEXT PRIMARY KEY, data TEXT)")
1010
+ conn.execute("CREATE TABLE blob_ref (session_id TEXT, message_id TEXT, hash TEXT)")
1011
+ for hash_value in ("b_parent", "b_shared", "b_child_other", "b_other"):
1012
+ conn.execute("INSERT INTO blob VALUES (?, 'data')", (hash_value,))
1013
+ for sid, hash_value in (
1014
+ ("ses_parent", "b_parent"),
1015
+ ("ses_parent", "b_shared"),
1016
+ ("ses_child", "b_shared"),
1017
+ ("ses_child", "b_child_other"),
1018
+ ("ses_other", "b_child_other"),
1019
+ ("ses_other", "b_other"),
1020
+ ):
1021
+ conn.execute(
1022
+ "INSERT INTO blob_ref VALUES (?, ?, ?)", (sid, f"msg-{sid}", hash_value)
1023
+ )
1024
+ conn.commit()
1025
+ conn.close()
1026
+
1027
+ record = SessionRecord(
1028
+ provider="opencode",
1029
+ session_id="ses_parent",
1030
+ title="Parent",
1031
+ project_path="/tmp/proj",
1032
+ size_bytes=10,
1033
+ updated_at=datetime.now(timezone.utc),
1034
+ storage_path=db_path,
1035
+ )
1036
+ discovery.delete_session(record)
1037
+
1038
+ conn = sqlite3.connect(str(db_path))
1039
+ cur = conn.cursor()
1040
+ assert cur.execute("SELECT hash FROM blob ORDER BY hash").fetchall() == [
1041
+ ("b_child_other",),
1042
+ ("b_other",),
1043
+ ]
1044
+ assert cur.execute(
1045
+ "SELECT DISTINCT hash FROM blob_ref ORDER BY hash"
1046
+ ).fetchall() == [
1047
+ ("b_child_other",),
1048
+ ("b_other",),
1049
+ ]
1050
+ conn.close()
1051
+
1052
+
1053
+ def test_delete_opencode_v2_session_without_optional_tables(tmp_path: Path) -> None:
1054
+ import sqlite3
1055
+
1056
+ db_path = tmp_path / "opencode.db"
1057
+ _create_opencode_v2_db(
1058
+ db_path,
1059
+ [("ses_v2", "proj", "V2 Session", 1788590000000)],
1060
+ )
1061
+
1062
+ record = SessionRecord(
1063
+ provider="opencode",
1064
+ session_id="ses_v2",
1065
+ title="V2 Session",
1066
+ project_path="/tmp/proj",
1067
+ size_bytes=10,
1068
+ updated_at=datetime.now(timezone.utc),
1069
+ storage_path=db_path,
1070
+ )
1071
+ discovery.delete_session(record)
1072
+
1073
+ conn = sqlite3.connect(str(db_path))
1074
+ cur = conn.cursor()
1075
+ assert cur.execute("SELECT COUNT(*) FROM session_v2").fetchone()[0] == 0
1076
+ assert cur.execute("SELECT COUNT(*) FROM session_message").fetchone()[0] == 0
1077
+ conn.close()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes