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,112 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from ..models import ActivitySession, AdapterStatus, ResumePlan, ScanResult
8
+ from .base import BaseAdapter
9
+ from .jsonl_utils import fingerprint_paths, read_jsonl_metadata, to_utc_iso
10
+
11
+
12
+ class KimiCodeAdapter(BaseAdapter):
13
+ source = "kimi-code"
14
+ adapter_version = 1
15
+
16
+ @property
17
+ def root(self) -> Path:
18
+ return self.home / ".kimi-code"
19
+
20
+ @property
21
+ def index_path(self) -> Path:
22
+ return self.root / "session_index.jsonl"
23
+
24
+ def watch_roots(self) -> list[tuple[Path, bool]]:
25
+ return [(self.root, True)]
26
+
27
+ def discover(self) -> AdapterStatus:
28
+ return AdapterStatus(
29
+ source=self.source,
30
+ kind="ok" if self.index_path.is_file() else "unavailable",
31
+ )
32
+
33
+ def scan(self, previous_fingerprint: str | None, now: datetime) -> ScanResult:
34
+ discovery = self.discover()
35
+ if discovery.kind == "unavailable":
36
+ return ScanResult(discovery, None, [], True)
37
+ index = read_jsonl_metadata(
38
+ self.index_path, {"sessionId", "sessionDir", "workDir"}
39
+ )
40
+ entries = [item for item in index.records if item.get("sessionId")]
41
+ state_paths = [self._state_path(item) for item in entries]
42
+ fingerprint = fingerprint_paths([self.index_path, *state_paths], self.adapter_version)
43
+ if fingerprint == previous_fingerprint:
44
+ return ScanResult(
45
+ AdapterStatus(
46
+ source=self.source,
47
+ kind="ok",
48
+ record_count=len(entries),
49
+ error_count=index.error_count,
50
+ ),
51
+ fingerprint,
52
+ [],
53
+ False,
54
+ )
55
+ sessions: list[ActivitySession] = []
56
+ error_count = index.error_count
57
+ for entry in entries:
58
+ state_path = self._state_path(entry)
59
+ try:
60
+ raw = json.loads(state_path.read_text(encoding="utf-8"))
61
+ state = {key: raw[key] for key in ("id", "cwd", "title", "createdAt", "updatedAt", "archived") if key in raw}
62
+ session_id = str(entry["sessionId"])
63
+ workdir = str(state.get("cwd") or entry.get("workDir") or "")
64
+ if not workdir:
65
+ error_count += 1
66
+ continue
67
+ title = state.get("title")
68
+ updated = state.get("updatedAt") or state_path.stat().st_mtime
69
+ created = state.get("createdAt")
70
+ sessions.append(
71
+ ActivitySession(
72
+ source=self.source,
73
+ session_id=session_id,
74
+ workdir=workdir,
75
+ title=str(title) if title else None,
76
+ title_source="native" if title else "fallback",
77
+ created_at=to_utc_iso(created) if created is not None else None,
78
+ updated_at=to_utc_iso(updated),
79
+ source_locator=str(state_path),
80
+ resume_kind="cli",
81
+ resume_ref=session_id,
82
+ archived=bool(state.get("archived", False)),
83
+ adapter_version=self.adapter_version,
84
+ source_fingerprint=fingerprint,
85
+ last_scanned_at=now.isoformat(),
86
+ )
87
+ )
88
+ except (OSError, json.JSONDecodeError, TypeError, ValueError):
89
+ error_count += 1
90
+ status = AdapterStatus(
91
+ source=self.source,
92
+ kind="ok",
93
+ record_count=len(sessions),
94
+ error_count=error_count,
95
+ )
96
+ return ScanResult(status, fingerprint, sessions, True)
97
+
98
+ def _state_path(self, entry: dict) -> Path:
99
+ session_dir = Path(str(entry.get("sessionDir", "")))
100
+ if not session_dir.is_absolute():
101
+ session_dir = self.root / session_dir
102
+ return session_dir / "state.json"
103
+
104
+ def resume_plan(self, session: ActivitySession) -> ResumePlan:
105
+ if session.source_missing or session.resume_kind != "cli" or not session.resume_ref:
106
+ return ResumePlan(kind="none", workdir=session.workdir, reason="source unavailable")
107
+ return ResumePlan(
108
+ kind="cli",
109
+ executable="kimi",
110
+ args=("--session", session.resume_ref),
111
+ workdir=session.workdir,
112
+ )
@@ -0,0 +1,152 @@
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 MiniMaxCodeAdapter(BaseAdapter):
16
+ source = "minimax-code"
17
+ adapter_version = 1
18
+
19
+ @property
20
+ def database_path(self) -> Path:
21
+ return self.home / ".minimax" / "v2" / "sqlite" / "runtime-state.sqlite"
22
+
23
+ def watch_roots(self) -> list[tuple[Path, bool]]:
24
+ return [(self.database_path.parent, False)]
25
+
26
+ def discover(self) -> AdapterStatus:
27
+ return AdapterStatus(
28
+ source=self.source,
29
+ kind="ok" if self.database_path.is_file() else "unavailable",
30
+ )
31
+
32
+ def scan(self, previous_fingerprint: str | None, now: datetime) -> ScanResult:
33
+ discovery = self.discover()
34
+ if discovery.kind == "unavailable":
35
+ return ScanResult(discovery, None, [], True)
36
+ wal_path = Path(f"{self.database_path}-wal")
37
+ paths = [self.database_path, *( [wal_path] if wal_path.is_file() else [] )]
38
+ fingerprint = fingerprint_paths(paths, self.adapter_version)
39
+ if fingerprint == previous_fingerprint:
40
+ return ScanResult(
41
+ AdapterStatus(source=self.source, kind="ok"), fingerprint, [], False
42
+ )
43
+ try:
44
+ with closing(connect_readonly(self.database_path)) as connection:
45
+ rows = connection.execute(
46
+ "SELECT session_id, workspace_dir, project_workspace_dir, title, "
47
+ "created_at_ms, updated_at_ms, archived, project_id, parent_session_id "
48
+ "FROM local_runtime_sessions"
49
+ ).fetchall()
50
+ project_paths = dict(
51
+ connection.execute(
52
+ "SELECT project_id, workspace_dir FROM local_runtime_projects"
53
+ ).fetchall()
54
+ )
55
+ statistics = self._statistics(connection)
56
+ except sqlite3.Error as error:
57
+ return ScanResult(self._error_status(error), None, [], True)
58
+ sessions: list[ActivitySession] = []
59
+ error_count = 0
60
+ for row in rows:
61
+ try:
62
+ session_id, workdir, project_workdir, title, created, updated, archived, project_id, parent_session_id = row
63
+ workdir = workdir or project_workdir or project_paths.get(project_id)
64
+ if not session_id or not workdir:
65
+ error_count += 1
66
+ continue
67
+ stats = statistics.get(str(session_id), {})
68
+ sessions.append(
69
+ ActivitySession(
70
+ source=self.source,
71
+ session_id=str(session_id),
72
+ workdir=str(workdir),
73
+ title=str(title) if title else None,
74
+ title_source="native" if title else "fallback",
75
+ created_at=to_utc_iso(created) if created is not None else None,
76
+ updated_at=to_utc_iso(updated),
77
+ additions=stats.get("additions"),
78
+ deletions=stats.get("deletions"),
79
+ files_changed=stats.get("files_changed"),
80
+ source_locator=str(self.database_path),
81
+ resume_kind="none" if parent_session_id else "cli",
82
+ resume_ref=None if parent_session_id else str(session_id),
83
+ archived=bool(archived),
84
+ adapter_version=self.adapter_version,
85
+ source_fingerprint=fingerprint,
86
+ last_scanned_at=now.isoformat(),
87
+ )
88
+ )
89
+ except (TypeError, ValueError, OverflowError, OSError):
90
+ error_count += 1
91
+ return ScanResult(
92
+ AdapterStatus(
93
+ source=self.source,
94
+ kind="ok",
95
+ record_count=len(sessions),
96
+ error_count=error_count,
97
+ ),
98
+ fingerprint,
99
+ sessions,
100
+ True,
101
+ )
102
+
103
+ @staticmethod
104
+ def _statistics(connection: sqlite3.Connection) -> dict[str, dict[str, int]]:
105
+ exists = connection.execute(
106
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='local_runtime_turn_diffs'"
107
+ ).fetchone()
108
+ if not exists:
109
+ return {}
110
+ statistics: dict[str, dict[str, int]] = {}
111
+ rows = connection.execute(
112
+ "SELECT session_id, file_changes_json FROM local_runtime_turn_diffs"
113
+ )
114
+ for session_id, raw in rows:
115
+ try:
116
+ changes = json.loads(raw)
117
+ if not isinstance(changes, list):
118
+ continue
119
+ totals = statistics.setdefault(
120
+ str(session_id), {"additions": 0, "deletions": 0, "files_changed": 0}
121
+ )
122
+ for change in changes:
123
+ if not isinstance(change, dict):
124
+ continue
125
+ for field in ("additions", "deletions"):
126
+ value = change.get(field)
127
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
128
+ totals[field] += int(value)
129
+ totals["files_changed"] += 1
130
+ except (json.JSONDecodeError, TypeError):
131
+ continue
132
+ return statistics
133
+
134
+ def _error_status(self, error: sqlite3.Error) -> AdapterStatus:
135
+ temporary = isinstance(error, sqlite3.OperationalError) and any(
136
+ word in str(error).lower() for word in ("locked", "busy")
137
+ )
138
+ return AdapterStatus(
139
+ source=self.source,
140
+ kind="error" if temporary else "incompatible",
141
+ message="database temporarily unavailable" if temporary else "database schema incompatible",
142
+ )
143
+
144
+ def resume_plan(self, session: ActivitySession) -> ResumePlan:
145
+ if session.source_missing or session.resume_kind != "cli" or not session.resume_ref:
146
+ return ResumePlan(kind="none", workdir=session.workdir, reason="source unavailable")
147
+ return ResumePlan(
148
+ kind="cli",
149
+ executable="mcode",
150
+ args=("--session", session.resume_ref),
151
+ workdir=session.workdir,
152
+ )
@@ -0,0 +1,169 @@
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
+ from typing import Any
9
+
10
+ from ..models import ActivitySession, AdapterStatus, ResumePlan, ScanResult
11
+ from .base import BaseAdapter
12
+ from .jsonl_utils import fingerprint_paths, to_utc_iso
13
+ from .sqlite_utils import connect_readonly
14
+
15
+
16
+ class OpenCodeAdapter(BaseAdapter):
17
+ source = "opencode"
18
+ adapter_version = 1
19
+
20
+ @property
21
+ def root(self) -> Path:
22
+ return self.home / ".local" / "share" / "opencode"
23
+
24
+ @property
25
+ def database_path(self) -> Path:
26
+ return self.root / "opencode.db"
27
+
28
+ @property
29
+ def storage_path(self) -> Path:
30
+ return self.root / "storage" / "session"
31
+
32
+ def watch_roots(self) -> list[tuple[Path, bool]]:
33
+ return [(self.database_path.parent, False), (self.storage_path, True)]
34
+
35
+ def discover(self) -> AdapterStatus:
36
+ available = self.database_path.is_file() or self.storage_path.is_dir()
37
+ return AdapterStatus(source=self.source, kind="ok" if available else "unavailable")
38
+
39
+ def scan(self, previous_fingerprint: str | None, now: datetime) -> ScanResult:
40
+ discovery = self.discover()
41
+ if discovery.kind == "unavailable":
42
+ return ScanResult(discovery, None, [], True)
43
+ legacy_paths = list(self.storage_path.glob("**/*.json")) if self.storage_path.is_dir() else []
44
+ database_paths = [self.database_path, Path(f"{self.database_path}-wal")]
45
+ fingerprint = fingerprint_paths(
46
+ [path for path in database_paths if path.is_file()] + legacy_paths,
47
+ self.adapter_version,
48
+ )
49
+ if fingerprint == previous_fingerprint:
50
+ return ScanResult(
51
+ AdapterStatus(source=self.source, kind="ok"), fingerprint, [], False
52
+ )
53
+ database_records: dict[str, dict[str, Any]] = {}
54
+ if self.database_path.is_file():
55
+ try:
56
+ with closing(connect_readonly(self.database_path)) as connection:
57
+ rows = connection.execute(
58
+ "SELECT id, directory, title, time_created, time_updated, "
59
+ "summary_additions, summary_deletions, summary_files, time_archived "
60
+ "FROM session"
61
+ ).fetchall()
62
+ for row in rows:
63
+ database_records[str(row[0])] = {
64
+ "workdir": row[1],
65
+ "title": row[2],
66
+ "created_at": row[3],
67
+ "updated_at": row[4],
68
+ "additions": row[5],
69
+ "deletions": row[6],
70
+ "files_changed": row[7],
71
+ "archived": row[8] is not None,
72
+ "locator": self.database_path,
73
+ }
74
+ except sqlite3.Error as error:
75
+ return ScanResult(self._error_status(error), None, [], True)
76
+ sessions: list[ActivitySession] = []
77
+ error_count = 0
78
+ for session_id, record in database_records.items():
79
+ try:
80
+ sessions.append(self._record(session_id, record, fingerprint, now))
81
+ except (TypeError, ValueError, OverflowError, OSError):
82
+ error_count += 1
83
+ database_ids = set(database_records)
84
+ for path in legacy_paths:
85
+ try:
86
+ raw = json.loads(path.read_text(encoding="utf-8"))
87
+ session_id = str(raw.get("id") or "")
88
+ if not session_id or session_id in database_ids:
89
+ continue
90
+ times = raw.get("time", {})
91
+ summary = raw.get("summary", {})
92
+ if not isinstance(times, dict) or not isinstance(summary, dict):
93
+ error_count += 1
94
+ continue
95
+ record = {
96
+ "workdir": raw.get("directory"),
97
+ "title": raw.get("title"),
98
+ "created_at": times.get("created"),
99
+ "updated_at": times.get("updated"),
100
+ "additions": summary.get("additions"),
101
+ "deletions": summary.get("deletions"),
102
+ "files_changed": summary.get("files"),
103
+ "archived": bool(raw.get("archived", False)),
104
+ "locator": path,
105
+ }
106
+ sessions.append(self._record(session_id, record, fingerprint, now))
107
+ except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError, OverflowError):
108
+ error_count += 1
109
+ return ScanResult(
110
+ AdapterStatus(
111
+ source=self.source,
112
+ kind="ok",
113
+ record_count=len(sessions),
114
+ error_count=error_count,
115
+ ),
116
+ fingerprint,
117
+ sessions,
118
+ True,
119
+ )
120
+
121
+ def _record(
122
+ self,
123
+ session_id: str,
124
+ record: dict[str, Any],
125
+ fingerprint: str,
126
+ now: datetime,
127
+ ) -> ActivitySession:
128
+ if not record.get("workdir") or record.get("updated_at") is None:
129
+ raise ValueError("missing session metadata")
130
+ title = record.get("title")
131
+ return ActivitySession(
132
+ source=self.source,
133
+ session_id=session_id,
134
+ workdir=str(record["workdir"]),
135
+ title=str(title) if title else None,
136
+ title_source="native" if title else "fallback",
137
+ created_at=to_utc_iso(record["created_at"]) if record.get("created_at") is not None else None,
138
+ updated_at=to_utc_iso(record["updated_at"]),
139
+ additions=record.get("additions"),
140
+ deletions=record.get("deletions"),
141
+ files_changed=record.get("files_changed"),
142
+ source_locator=str(record["locator"]),
143
+ resume_kind="cli",
144
+ resume_ref=session_id,
145
+ archived=bool(record.get("archived")),
146
+ adapter_version=self.adapter_version,
147
+ source_fingerprint=fingerprint,
148
+ last_scanned_at=now.isoformat(),
149
+ )
150
+
151
+ def _error_status(self, error: sqlite3.Error) -> AdapterStatus:
152
+ temporary = isinstance(error, sqlite3.OperationalError) and any(
153
+ word in str(error).lower() for word in ("locked", "busy")
154
+ )
155
+ return AdapterStatus(
156
+ source=self.source,
157
+ kind="error" if temporary else "incompatible",
158
+ message="database temporarily unavailable" if temporary else "database schema incompatible",
159
+ )
160
+
161
+ def resume_plan(self, session: ActivitySession) -> ResumePlan:
162
+ if session.source_missing or session.resume_kind != "cli" or not session.resume_ref:
163
+ return ResumePlan(kind="none", workdir=session.workdir, reason="source unavailable")
164
+ return ResumePlan(
165
+ kind="cli",
166
+ executable="opencode",
167
+ args=("--session", session.resume_ref),
168
+ workdir=session.workdir,
169
+ )
@@ -0,0 +1,114 @@
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 PiAdapter(BaseAdapter):
12
+ source = "pi"
13
+ adapter_version = 1
14
+
15
+ @property
16
+ def root(self) -> Path:
17
+ return self.home / ".pi" / "agent" / "sessions"
18
+
19
+ def watch_roots(self) -> list[tuple[Path, bool]]:
20
+ return [(self.root, True)]
21
+
22
+ def discover(self) -> AdapterStatus:
23
+ return AdapterStatus(
24
+ source=self.source,
25
+ kind="ok" if self.root.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.root.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
+ try:
45
+ result = read_jsonl_metadata(
46
+ path, {"id", "timestamp", "cwd", "type", "name", "title"}
47
+ )
48
+ error_count += result.error_count
49
+ header = next(
50
+ (
51
+ record
52
+ for record in result.records
53
+ if record.get("type") == "session" and record.get("id")
54
+ ),
55
+ None,
56
+ )
57
+ if header is None or not header.get("cwd"):
58
+ error_count += 1
59
+ continue
60
+ timestamps = []
61
+ for record in result.records:
62
+ if record.get("timestamp") is not None:
63
+ try:
64
+ timestamps.append(to_utc_iso(record["timestamp"]))
65
+ except (TypeError, ValueError, OverflowError, OSError):
66
+ error_count += 1
67
+ title = next(
68
+ (
69
+ str(record.get("name") or record.get("title"))
70
+ for record in reversed(result.records)
71
+ if record.get("type")
72
+ in {"session_name", "session_title", "name", "title"}
73
+ and (record.get("name") or record.get("title"))
74
+ ),
75
+ None,
76
+ )
77
+ updated_at = max(timestamps) if timestamps else to_utc_iso(path.stat().st_mtime)
78
+ sessions.append(
79
+ ActivitySession(
80
+ source=self.source,
81
+ session_id=str(header["id"]),
82
+ workdir=str(header["cwd"]),
83
+ title=title,
84
+ title_source="native" if title else "fallback",
85
+ created_at=min(timestamps) if timestamps else None,
86
+ updated_at=updated_at,
87
+ source_locator=str(path),
88
+ resume_kind="none",
89
+ resume_ref=None,
90
+ adapter_version=self.adapter_version,
91
+ source_fingerprint=fingerprint,
92
+ last_scanned_at=now.isoformat(),
93
+ )
94
+ )
95
+ except (OSError, UnicodeError, TypeError, ValueError, OverflowError):
96
+ error_count += 1
97
+ return ScanResult(
98
+ AdapterStatus(
99
+ source=self.source,
100
+ kind="ok",
101
+ record_count=len(sessions),
102
+ error_count=error_count,
103
+ ),
104
+ fingerprint,
105
+ sessions,
106
+ True,
107
+ )
108
+
109
+ def resume_plan(self, session: ActivitySession) -> ResumePlan:
110
+ return ResumePlan(
111
+ kind="none",
112
+ workdir=session.workdir,
113
+ reason="Pi session restoration was not verified",
114
+ )
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+
6
+
7
+ def connect_readonly(path: Path) -> sqlite3.Connection:
8
+ connection = sqlite3.connect(
9
+ f"{path.as_uri()}?mode=ro",
10
+ uri=True,
11
+ timeout=0.25,
12
+ )
13
+ connection.execute("PRAGMA busy_timeout=250")
14
+ connection.execute("PRAGMA query_only=ON")
15
+ return connection