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
agent_trail/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
agent_trail/__main__.py
ADDED
|
@@ -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()
|