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.
- agent_trail/__init__.py +1 -0
- agent_trail/__main__.py +3 -0
- agent_trail/adapters/__init__.py +26 -0
- agent_trail/adapters/base.py +39 -0
- agent_trail/adapters/claude_code.py +40 -0
- agent_trail/adapters/codebuddy.py +93 -0
- agent_trail/adapters/codex.py +165 -0
- agent_trail/adapters/deepseek_harness.py +179 -0
- agent_trail/adapters/jsonl_utils.py +53 -0
- agent_trail/adapters/kimi_code.py +112 -0
- agent_trail/adapters/minimax_code.py +152 -0
- agent_trail/adapters/opencode.py +169 -0
- agent_trail/adapters/pi.py +114 -0
- agent_trail/adapters/sqlite_utils.py +15 -0
- agent_trail/adapters/zcode.py +161 -0
- agent_trail/cli.py +134 -0
- agent_trail/launcher.py +114 -0
- agent_trail/models.py +86 -0
- agent_trail/scanner.py +103 -0
- agent_trail/server.py +260 -0
- agent_trail/static/app.js +525 -0
- agent_trail/static/i18n.js +248 -0
- agent_trail/static/index.html +134 -0
- agent_trail/static/styles.css +307 -0
- agent_trail/store.py +197 -0
- agent_trail/tray/__init__.py +6 -0
- agent_trail/tray/app.py +582 -0
- agent_trail/tray/desktop.template +10 -0
- agent_trail/tray/icon.png +0 -0
- agent_trail/tray/sni.py +384 -0
- agent_trail/watcher.py +307 -0
- session_trail-0.2.0.dist-info/METADATA +155 -0
- session_trail-0.2.0.dist-info/RECORD +37 -0
- session_trail-0.2.0.dist-info/WHEEL +5 -0
- session_trail-0.2.0.dist-info/entry_points.txt +2 -0
- session_trail-0.2.0.dist-info/licenses/LICENSE +21 -0
- session_trail-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|