session-trail 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+
3
+ from .base import BaseAdapter
4
+ from .claude_code import ClaudeCodeAdapter
5
+ from .codebuddy import CodeBuddyAdapter
6
+ from .codex import CodexAdapter
7
+ from .deepseek_harness import DeepSeekHarnessAdapter
8
+ from .kimi_code import KimiCodeAdapter
9
+ from .minimax_code import MiniMaxCodeAdapter
10
+ from .opencode import OpenCodeAdapter
11
+ from .pi import PiAdapter
12
+ from .zcode import ZCodeAdapter
13
+
14
+
15
+ def default_adapters(home: Path) -> list[BaseAdapter]:
16
+ return [
17
+ KimiCodeAdapter(home),
18
+ CodeBuddyAdapter(home),
19
+ PiAdapter(home),
20
+ DeepSeekHarnessAdapter(home),
21
+ MiniMaxCodeAdapter(home),
22
+ ZCodeAdapter(home),
23
+ OpenCodeAdapter(home),
24
+ CodexAdapter(home),
25
+ ClaudeCodeAdapter(home),
26
+ ]
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from ..models import ActivitySession, AdapterStatus, ResumePlan, ScanResult
8
+
9
+
10
+ class BaseAdapter(ABC):
11
+ source: str
12
+ adapter_version: int = 1
13
+
14
+ def __init__(self, home: Path) -> None:
15
+ self.home = home
16
+ self._status = AdapterStatus(source=self.source, kind="unavailable")
17
+
18
+ @abstractmethod
19
+ def discover(self) -> AdapterStatus:
20
+ raise NotImplementedError
21
+
22
+ def status(self) -> AdapterStatus:
23
+ return self._status
24
+
25
+ def watch_roots(self) -> list[tuple[Path, bool]]:
26
+ """Directories to watch for change notifications as (path, recursive).
27
+
28
+ Defaults to no watching; adapters that read files should return the
29
+ directories that contain their sources so misses degrade to polling.
30
+ """
31
+ return []
32
+
33
+ @abstractmethod
34
+ def scan(self, previous_fingerprint: str | None, now: datetime) -> ScanResult:
35
+ raise NotImplementedError
36
+
37
+ @abstractmethod
38
+ def resume_plan(self, session: ActivitySession) -> ResumePlan:
39
+ raise NotImplementedError
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from ..models import ActivitySession, AdapterStatus, ResumePlan, ScanResult
7
+ from .base import BaseAdapter
8
+ from .jsonl_utils import fingerprint_paths
9
+
10
+
11
+ class ClaudeCodeAdapter(BaseAdapter):
12
+ source = "claude-code"
13
+ adapter_version = 1
14
+
15
+ @property
16
+ def root(self) -> Path:
17
+ return self.home / ".claude" / "projects"
18
+
19
+ def discover(self) -> AdapterStatus:
20
+ if not self.root.is_dir():
21
+ return AdapterStatus(source=self.source, kind="unavailable")
22
+ return AdapterStatus(
23
+ source=self.source,
24
+ kind="experimental",
25
+ message="session metadata format has not been verified",
26
+ )
27
+
28
+ def scan(self, previous_fingerprint: str | None, now: datetime) -> ScanResult:
29
+ discovery = self.discover()
30
+ if discovery.kind == "unavailable":
31
+ return ScanResult(discovery, None, [], True)
32
+ fingerprint = fingerprint_paths([], self.adapter_version)
33
+ return ScanResult(discovery, fingerprint, [], fingerprint != previous_fingerprint)
34
+
35
+ def resume_plan(self, session: ActivitySession) -> ResumePlan:
36
+ return ResumePlan(
37
+ kind="none",
38
+ workdir=session.workdir,
39
+ reason="Claude Code resume has not been verified",
40
+ )
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from ..models import ActivitySession, AdapterStatus, ResumePlan, ScanResult
7
+ from .base import BaseAdapter
8
+ from .jsonl_utils import fingerprint_paths, read_jsonl_metadata, to_utc_iso
9
+
10
+
11
+ class CodeBuddyAdapter(BaseAdapter):
12
+ source = "codebuddy"
13
+ adapter_version = 1
14
+
15
+ @property
16
+ def projects_path(self) -> Path:
17
+ return self.home / ".codebuddy" / "projects"
18
+
19
+ def watch_roots(self) -> list[tuple[Path, bool]]:
20
+ return [(self.projects_path, True)]
21
+
22
+ def discover(self) -> AdapterStatus:
23
+ return AdapterStatus(
24
+ source=self.source,
25
+ kind="ok" if self.projects_path.is_dir() else "unavailable",
26
+ )
27
+
28
+ def scan(self, previous_fingerprint: str | None, now: datetime) -> ScanResult:
29
+ discovery = self.discover()
30
+ if discovery.kind == "unavailable":
31
+ return ScanResult(discovery, None, [], True)
32
+ paths = list(self.projects_path.glob("**/*.jsonl"))
33
+ fingerprint = fingerprint_paths(paths, self.adapter_version)
34
+ if fingerprint == previous_fingerprint:
35
+ return ScanResult(
36
+ AdapterStatus(source=self.source, kind="ok", record_count=len(paths)),
37
+ fingerprint,
38
+ [],
39
+ False,
40
+ )
41
+ sessions: list[ActivitySession] = []
42
+ error_count = 0
43
+ for path in paths:
44
+ result = read_jsonl_metadata(path, {"sessionId", "cwd", "timestamp"})
45
+ error_count += result.error_count
46
+ records = [item for item in result.records if item.get("timestamp") is not None]
47
+ if not records:
48
+ error_count += 1
49
+ continue
50
+ workdir = next((str(item["cwd"]) for item in reversed(result.records) if item.get("cwd")), "")
51
+ if not workdir:
52
+ error_count += 1
53
+ continue
54
+ session_id = str(records[0].get("sessionId") or path.stem)
55
+ timestamps = [float(item["timestamp"]) for item in records]
56
+ sessions.append(
57
+ ActivitySession(
58
+ source=self.source,
59
+ session_id=session_id,
60
+ workdir=workdir,
61
+ title=None,
62
+ title_source="fallback",
63
+ created_at=to_utc_iso(min(timestamps)),
64
+ updated_at=to_utc_iso(max(timestamps)),
65
+ source_locator=str(path),
66
+ resume_kind="cli",
67
+ resume_ref=session_id,
68
+ adapter_version=self.adapter_version,
69
+ source_fingerprint=fingerprint,
70
+ last_scanned_at=now.isoformat(),
71
+ )
72
+ )
73
+ return ScanResult(
74
+ AdapterStatus(
75
+ source=self.source,
76
+ kind="ok",
77
+ record_count=len(sessions),
78
+ error_count=error_count,
79
+ ),
80
+ fingerprint,
81
+ sessions,
82
+ True,
83
+ )
84
+
85
+ def resume_plan(self, session: ActivitySession) -> ResumePlan:
86
+ if session.source_missing or session.resume_kind != "cli" or not session.resume_ref:
87
+ return ResumePlan(kind="none", workdir=session.workdir, reason="source unavailable")
88
+ return ResumePlan(
89
+ kind="cli",
90
+ executable="codebuddy",
91
+ args=("--resume", session.resume_ref),
92
+ workdir=session.workdir,
93
+ )
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from contextlib import closing
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ from ..models import ActivitySession, AdapterStatus, ResumePlan, ScanResult
10
+ from .base import BaseAdapter
11
+ from .jsonl_utils import fingerprint_paths, to_utc_iso
12
+ from .sqlite_utils import connect_readonly
13
+
14
+
15
+ class CodexAdapter(BaseAdapter):
16
+ source = "codex"
17
+ adapter_version = 1
18
+
19
+ @property
20
+ def root(self) -> Path:
21
+ return self.home / ".codex"
22
+
23
+ @property
24
+ def database_path(self) -> Path:
25
+ return self.root / "state_5.sqlite"
26
+
27
+ @property
28
+ def sessions_path(self) -> Path:
29
+ return self.root / "sessions"
30
+
31
+ def watch_roots(self) -> list[tuple[Path, bool]]:
32
+ return [(self.database_path.parent, False), (self.sessions_path, True)]
33
+
34
+ def discover(self) -> AdapterStatus:
35
+ available = self.database_path.is_file() or self.sessions_path.is_dir()
36
+ return AdapterStatus(source=self.source, kind="ok" if available else "unavailable")
37
+
38
+ def scan(self, previous_fingerprint: str | None, now: datetime) -> ScanResult:
39
+ discovery = self.discover()
40
+ if discovery.kind == "unavailable":
41
+ return ScanResult(discovery, None, [], True)
42
+ rollout_paths = list(self.sessions_path.glob("**/*.jsonl")) if self.sessions_path.is_dir() else []
43
+ database_paths = [self.database_path, Path(f"{self.database_path}-wal")]
44
+ fingerprint = fingerprint_paths(
45
+ [path for path in database_paths if path.is_file()] + rollout_paths,
46
+ self.adapter_version,
47
+ )
48
+ if fingerprint == previous_fingerprint:
49
+ return ScanResult(
50
+ AdapterStatus(source=self.source, kind="ok"), fingerprint, [], False
51
+ )
52
+ database_records = {}
53
+ if self.database_path.is_file():
54
+ try:
55
+ with closing(connect_readonly(self.database_path)) as connection:
56
+ rows = connection.execute(
57
+ "SELECT id, title, cwd, created_at, updated_at, rollout_path, archived "
58
+ "FROM threads"
59
+ ).fetchall()
60
+ for row in rows:
61
+ database_records[str(row[0])] = row[1:]
62
+ except sqlite3.Error as error:
63
+ return ScanResult(self._error_status(error), None, [], True)
64
+ sessions: list[ActivitySession] = []
65
+ error_count = 0
66
+ for session_id, row in database_records.items():
67
+ try:
68
+ title, workdir, created, updated, rollout_path, archived = row
69
+ if not workdir:
70
+ error_count += 1
71
+ continue
72
+ sessions.append(
73
+ ActivitySession(
74
+ source=self.source,
75
+ session_id=session_id,
76
+ workdir=str(workdir),
77
+ title=str(title) if title else None,
78
+ title_source="native" if title else "fallback",
79
+ created_at=to_utc_iso(created),
80
+ updated_at=to_utc_iso(updated),
81
+ source_locator=str(rollout_path),
82
+ resume_kind="none",
83
+ resume_ref=None,
84
+ archived=bool(archived),
85
+ adapter_version=self.adapter_version,
86
+ source_fingerprint=fingerprint,
87
+ last_scanned_at=now.isoformat(),
88
+ )
89
+ )
90
+ except (TypeError, ValueError, OverflowError, OSError):
91
+ error_count += 1
92
+ for path in rollout_paths:
93
+ try:
94
+ metadata = self._rollout_metadata(path)
95
+ if metadata is None:
96
+ error_count += 1
97
+ continue
98
+ session_id = str(metadata.get("id") or "")
99
+ if not session_id or session_id in database_records or not metadata.get("cwd"):
100
+ continue
101
+ created = metadata.get("timestamp")
102
+ sessions.append(
103
+ ActivitySession(
104
+ source=self.source,
105
+ session_id=session_id,
106
+ workdir=str(metadata["cwd"]),
107
+ title=None,
108
+ title_source="fallback",
109
+ created_at=to_utc_iso(created) if created is not None else None,
110
+ updated_at=to_utc_iso(path.stat().st_mtime),
111
+ source_locator=str(path),
112
+ resume_kind="none",
113
+ resume_ref=None,
114
+ adapter_version=self.adapter_version,
115
+ source_fingerprint=fingerprint,
116
+ last_scanned_at=now.isoformat(),
117
+ )
118
+ )
119
+ except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError, OverflowError):
120
+ error_count += 1
121
+ return ScanResult(
122
+ AdapterStatus(
123
+ source=self.source,
124
+ kind="ok",
125
+ record_count=len(sessions),
126
+ error_count=error_count,
127
+ ),
128
+ fingerprint,
129
+ sessions,
130
+ True,
131
+ )
132
+
133
+ @staticmethod
134
+ def _rollout_metadata(path: Path) -> dict | None:
135
+ with path.open(encoding="utf-8") as handle:
136
+ for line in handle:
137
+ if not line.strip():
138
+ continue
139
+ value = json.loads(line)
140
+ if not isinstance(value, dict) or value.get("type") != "session_meta":
141
+ continue
142
+ payload = value.get("payload")
143
+ if not isinstance(payload, dict):
144
+ return None
145
+ return {
146
+ key: payload[key] for key in ("id", "timestamp", "cwd") if key in payload
147
+ }
148
+ return None
149
+
150
+ def _error_status(self, error: sqlite3.Error) -> AdapterStatus:
151
+ temporary = isinstance(error, sqlite3.OperationalError) and any(
152
+ word in str(error).lower() for word in ("locked", "busy")
153
+ )
154
+ return AdapterStatus(
155
+ source=self.source,
156
+ kind="error" if temporary else "incompatible",
157
+ message="database temporarily unavailable" if temporary else "database schema incompatible",
158
+ )
159
+
160
+ def resume_plan(self, session: ActivitySession) -> ResumePlan:
161
+ return ResumePlan(
162
+ kind="none",
163
+ workdir=session.workdir,
164
+ reason="Codex requires an unverified directory-trust decision",
165
+ )
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..models import ActivitySession, AdapterStatus, ResumePlan, ScanResult
10
+ from .base import BaseAdapter
11
+ from .jsonl_utils import fingerprint_paths, to_utc_iso
12
+
13
+
14
+ class DeepSeekHarnessAdapter(BaseAdapter):
15
+ source = "deepseek-harness"
16
+ adapter_version = 1
17
+
18
+ @property
19
+ def index_path(self) -> Path:
20
+ return self.home / ".dsh-tui" / "session-index.json"
21
+
22
+ @property
23
+ def database_path(self) -> Path:
24
+ return self.home / ".dsh-tui" / "sessions.sqlite"
25
+
26
+ @property
27
+ def cache_path(self) -> Path:
28
+ return self.home / ".dsh" / "storages" / "session_projcache" / "sessions"
29
+
30
+ def watch_roots(self) -> list[tuple[Path, bool]]:
31
+ return [(self.index_path.parent, False), (self.cache_path, False)]
32
+
33
+ def discover(self) -> AdapterStatus:
34
+ available = (
35
+ self.index_path.is_file()
36
+ or self.database_path.is_file()
37
+ or self.cache_path.is_dir()
38
+ )
39
+ return AdapterStatus(source=self.source, kind="ok" if available else "unavailable")
40
+
41
+ def scan(self, previous_fingerprint: str | None, now: datetime) -> ScanResult:
42
+ discovery = self.discover()
43
+ if discovery.kind == "unavailable":
44
+ return ScanResult(discovery, None, [], True)
45
+ cache_paths = list(self.cache_path.glob("*.json")) if self.cache_path.is_dir() else []
46
+ paths = [
47
+ path
48
+ for path in (self.index_path, self.database_path)
49
+ if path.is_file()
50
+ ]
51
+ fingerprint = fingerprint_paths([*paths, *cache_paths], self.adapter_version)
52
+ if fingerprint == previous_fingerprint:
53
+ return ScanResult(
54
+ AdapterStatus(source=self.source, kind="ok", record_count=len(cache_paths)),
55
+ fingerprint,
56
+ [],
57
+ False,
58
+ )
59
+ indexed: dict[str, dict[str, Any]] = {}
60
+ error_count = 0
61
+ try:
62
+ raw_index = json.loads(self.index_path.read_text(encoding="utf-8"))
63
+ raw_entries = raw_index.get("entries", {})
64
+ entries = (
65
+ list(raw_entries.items())
66
+ if isinstance(raw_entries, dict)
67
+ else [(str(item.get("id") or item.get("sessionId") or ""), item) for item in raw_entries]
68
+ )
69
+ for session_id, entry in entries:
70
+ if not session_id or not isinstance(entry, dict):
71
+ error_count += 1
72
+ continue
73
+ derived = entry.get("derived", {})
74
+ if not isinstance(derived, dict):
75
+ error_count += 1
76
+ continue
77
+ indexed[str(session_id)] = {
78
+ "title": derived.get("title"),
79
+ "identity": derived.get("identity"),
80
+ }
81
+ except FileNotFoundError:
82
+ pass
83
+ except (OSError, UnicodeError, json.JSONDecodeError, TypeError, AttributeError):
84
+ error_count += 1
85
+ database_records: dict[str, dict[str, Any]] = {}
86
+ if self.database_path.is_file():
87
+ try:
88
+ connection = sqlite3.connect(
89
+ f"{self.database_path.as_uri()}?mode=ro", uri=True
90
+ )
91
+ try:
92
+ rows = connection.execute("SELECT id, created_at, cwd FROM sessions")
93
+ database_records = {
94
+ str(row[0]): {"created_at": row[1], "cwd": row[2]} for row in rows
95
+ }
96
+ finally:
97
+ connection.close()
98
+ except sqlite3.Error:
99
+ error_count += 1
100
+ cache_records: dict[str, dict[str, Any]] = {}
101
+ cache_files: dict[str, Path] = {}
102
+ for path in cache_paths:
103
+ try:
104
+ raw = json.loads(path.read_text(encoding="utf-8"))
105
+ identity = raw.get("record", {}).get("identity", {})
106
+ if not isinstance(identity, dict):
107
+ error_count += 1
108
+ continue
109
+ cache_id = path.stem
110
+ normalized_id = cache_id.removeprefix("session-")
111
+ if normalized_id in indexed or normalized_id in database_records:
112
+ cache_id = normalized_id
113
+ cache_records[cache_id] = {
114
+ key: identity[key] for key in ("createdAt", "cwd") if key in identity
115
+ }
116
+ cache_files[cache_id] = path
117
+ except (OSError, UnicodeError, json.JSONDecodeError, TypeError, AttributeError):
118
+ error_count += 1
119
+ sessions: list[ActivitySession] = []
120
+ session_ids = set(indexed) | set(database_records) | set(cache_records)
121
+ for session_id in sorted(session_ids):
122
+ index = indexed.get(session_id, {})
123
+ cache_id = session_id
124
+ database = database_records.get(session_id, {})
125
+ cache = cache_records.get(cache_id, {})
126
+ workdir = database.get("cwd") or cache.get("cwd")
127
+ if not workdir:
128
+ error_count += 1
129
+ continue
130
+ try:
131
+ raw_created = database.get("created_at") or cache.get("createdAt")
132
+ created_at = to_utc_iso(raw_created) if raw_created is not None else None
133
+ source_path = cache_files.get(cache_id) or self.index_path
134
+ updated_at = (
135
+ to_utc_iso(source_path.stat().st_mtime)
136
+ if source_path.is_file()
137
+ else created_at
138
+ )
139
+ if updated_at is None:
140
+ error_count += 1
141
+ continue
142
+ title = index.get("title")
143
+ sessions.append(
144
+ ActivitySession(
145
+ source=self.source,
146
+ session_id=session_id,
147
+ workdir=str(workdir),
148
+ title=str(title) if title else None,
149
+ title_source="native" if title else "fallback",
150
+ created_at=created_at,
151
+ updated_at=updated_at,
152
+ source_locator=str(source_path),
153
+ resume_kind="none",
154
+ resume_ref=None,
155
+ adapter_version=self.adapter_version,
156
+ source_fingerprint=fingerprint,
157
+ last_scanned_at=now.isoformat(),
158
+ )
159
+ )
160
+ except (OSError, TypeError, ValueError, OverflowError):
161
+ error_count += 1
162
+ return ScanResult(
163
+ AdapterStatus(
164
+ source=self.source,
165
+ kind="ok",
166
+ record_count=len(sessions),
167
+ error_count=error_count,
168
+ ),
169
+ fingerprint,
170
+ sessions,
171
+ True,
172
+ )
173
+
174
+ def resume_plan(self, session: ActivitySession) -> ResumePlan:
175
+ return ResumePlan(
176
+ kind="none",
177
+ workdir=session.workdir,
178
+ reason="DeepSeek Harness runtime and plugins are incompatible",
179
+ )
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class JsonlReadResult:
13
+ records: list[dict[str, Any]]
14
+ error_count: int
15
+
16
+
17
+ def read_jsonl_metadata(path: Path, allowed_fields: set[str]) -> JsonlReadResult:
18
+ records: list[dict[str, Any]] = []
19
+ error_count = 0
20
+ with path.open(encoding="utf-8") as handle:
21
+ for line in handle:
22
+ if not line.strip():
23
+ continue
24
+ try:
25
+ value = json.loads(line)
26
+ except (json.JSONDecodeError, UnicodeDecodeError):
27
+ error_count += 1
28
+ continue
29
+ if not isinstance(value, dict):
30
+ error_count += 1
31
+ continue
32
+ records.append({key: value[key] for key in allowed_fields if key in value})
33
+ return JsonlReadResult(records=records, error_count=error_count)
34
+
35
+
36
+ def to_utc_iso(value: Any) -> str:
37
+ if isinstance(value, str):
38
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
39
+ if parsed.tzinfo is None:
40
+ parsed = parsed.replace(tzinfo=timezone.utc)
41
+ return parsed.astimezone(timezone.utc).isoformat()
42
+ numeric = float(value)
43
+ if abs(numeric) >= 100_000_000_000:
44
+ numeric /= 1000
45
+ return datetime.fromtimestamp(numeric, timezone.utc).isoformat()
46
+
47
+
48
+ def fingerprint_paths(paths: list[Path], adapter_version: int) -> str:
49
+ digest = hashlib.sha256(f"adapter:{adapter_version}\n".encode())
50
+ for path in sorted(paths, key=lambda item: str(item)):
51
+ stat = path.stat()
52
+ digest.update(f"{path}\0{stat.st_size}\0{stat.st_mtime_ns}\n".encode())
53
+ return digest.hexdigest()