minimalcode 1.5.2__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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- minimalcode-1.5.2.dist-info/top_level.txt +1 -0
minimalcode/session.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .config import Settings
|
|
17
|
+
|
|
18
|
+
# A compact event carries the whole replacement history, so appending one to a
|
|
19
|
+
# file that already holds that history doubles the file. Past this size the
|
|
20
|
+
# rewrite pays for itself; below it the append-only file is left alone.
|
|
21
|
+
_PRUNE_MIN_BYTES = 64 * 1024
|
|
22
|
+
# Fields that identify the session itself. A later meta event may switch
|
|
23
|
+
# provider/model, but it must not rewrite the session's identity during a prune.
|
|
24
|
+
_HEADER_IDENTITY_KEYS = frozenset(
|
|
25
|
+
{"type", "session_id", "workspace", "created_at", "version"}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _utc_now() -> str:
|
|
30
|
+
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _workspace_key(cwd: Path) -> str:
|
|
34
|
+
name = (
|
|
35
|
+
re.sub(r"[^A-Za-z0-9._-]+", "-", cwd.name or "root").strip("-")[:32] or "root"
|
|
36
|
+
)
|
|
37
|
+
key = str(cwd)
|
|
38
|
+
if os.name == "nt":
|
|
39
|
+
key = key.casefold()
|
|
40
|
+
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:12]
|
|
41
|
+
return f"{name}-{digest}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _compact_json(value: Any) -> str:
|
|
45
|
+
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
46
|
+
try:
|
|
47
|
+
text.encode("utf-8")
|
|
48
|
+
except UnicodeEncodeError:
|
|
49
|
+
# A lone surrogate (an unpaired ``\udXXX`` escape from the provider) has
|
|
50
|
+
# no UTF-8 encoding. Escape it instead of letting the append raise,
|
|
51
|
+
# which would drop the message that produced it.
|
|
52
|
+
text = text.encode("utf-8", "backslashreplace").decode("utf-8")
|
|
53
|
+
return text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class Session:
|
|
58
|
+
session_id: str
|
|
59
|
+
path: Path | None
|
|
60
|
+
cwd: Path
|
|
61
|
+
messages: list[dict[str, Any]] = field(default_factory=list)
|
|
62
|
+
# Provider/model recorded in the session meta. Resuming must not silently
|
|
63
|
+
# send a history recorded for one wire protocol to another provider.
|
|
64
|
+
provider: str = ""
|
|
65
|
+
model: str = ""
|
|
66
|
+
|
|
67
|
+
def append_event(self, event: dict[str, Any]) -> None:
|
|
68
|
+
if self.path is None:
|
|
69
|
+
return
|
|
70
|
+
if self.path.suffix.lower() != ".jsonl":
|
|
71
|
+
# ``--session <path>`` once accepted any existing file, so a typo
|
|
72
|
+
# appended JSONL to README.md. A session is only ever a .jsonl file.
|
|
73
|
+
raise ValueError(f"session file must end in .jsonl: {self.path}")
|
|
74
|
+
parent_created = not self.path.parent.exists()
|
|
75
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
if parent_created:
|
|
77
|
+
# Tighten only a directory this call created: `--session <path>` may
|
|
78
|
+
# point at a user directory that has to keep its own permissions.
|
|
79
|
+
try:
|
|
80
|
+
self.path.parent.chmod(0o700)
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
83
|
+
line = _compact_json(event) + "\n"
|
|
84
|
+
# Create session files with restrictive permissions from the first
|
|
85
|
+
# byte instead of tightening them after conversation content is
|
|
86
|
+
# already on disk.
|
|
87
|
+
fd = os.open(self.path, os.O_RDWR | os.O_CREAT | os.O_APPEND, 0o600)
|
|
88
|
+
needs_newline = False
|
|
89
|
+
if os.fstat(fd).st_size:
|
|
90
|
+
os.lseek(fd, -1, os.SEEK_END)
|
|
91
|
+
needs_newline = os.read(fd, 1) != b"\n"
|
|
92
|
+
with os.fdopen(fd, "a", encoding="utf-8", newline="\n") as handle:
|
|
93
|
+
try:
|
|
94
|
+
os.chmod(self.path, 0o600)
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
if needs_newline:
|
|
98
|
+
# A file whose last line lost its terminator (a torn write, or a
|
|
99
|
+
# file handed to `--session`) would otherwise fuse with this
|
|
100
|
+
# event into one unparseable line and lose both.
|
|
101
|
+
handle.write("\n")
|
|
102
|
+
handle.write(line)
|
|
103
|
+
handle.flush()
|
|
104
|
+
os.fsync(handle.fileno())
|
|
105
|
+
|
|
106
|
+
def append_message(self, message: dict[str, Any]) -> None:
|
|
107
|
+
self.messages.append(message)
|
|
108
|
+
try:
|
|
109
|
+
self.append_event({"type": "message", "message": message})
|
|
110
|
+
except (OSError, TypeError, ValueError):
|
|
111
|
+
self.messages.pop()
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
def replace_messages(
|
|
115
|
+
self,
|
|
116
|
+
messages: Iterable[dict[str, Any]],
|
|
117
|
+
*,
|
|
118
|
+
summary: str,
|
|
119
|
+
dropped_messages: int,
|
|
120
|
+
) -> None:
|
|
121
|
+
previous = self.messages
|
|
122
|
+
self.messages = list(messages)
|
|
123
|
+
state = {
|
|
124
|
+
"type": "compact",
|
|
125
|
+
"summary": summary,
|
|
126
|
+
"dropped_messages": dropped_messages,
|
|
127
|
+
"messages": self.messages,
|
|
128
|
+
"at": _utc_now(),
|
|
129
|
+
}
|
|
130
|
+
size_before = self._size()
|
|
131
|
+
try:
|
|
132
|
+
# The append is what makes the new state durable; the rewrite below
|
|
133
|
+
# only reclaims the history this event just superseded.
|
|
134
|
+
self.append_event(state)
|
|
135
|
+
except (OSError, TypeError, ValueError):
|
|
136
|
+
self.messages = previous
|
|
137
|
+
raise
|
|
138
|
+
self._prune_bloat(state, size_before=size_before)
|
|
139
|
+
|
|
140
|
+
def _size(self) -> int | None:
|
|
141
|
+
if self.path is None:
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
return self.path.stat().st_size
|
|
145
|
+
except OSError:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def _prune_bloat(self, state: dict[str, Any], *, size_before: int | None) -> bool:
|
|
149
|
+
"""Rewrite the file to ``meta + state`` when appending duplicated it.
|
|
150
|
+
|
|
151
|
+
A compact event carries the whole replacement history, so a session that
|
|
152
|
+
compacts twice stores the same messages twice and `--continue` replays
|
|
153
|
+
all of it. The rewrite is atomic (temp file, fsync, ``os.replace``), so
|
|
154
|
+
a crash leaves the append-only file untouched, and it is skipped unless
|
|
155
|
+
the file is clearly larger than the state it would keep. The size delta
|
|
156
|
+
also acts as a weak concurrency check: if another process appended
|
|
157
|
+
between the write and the stat, the file is left alone.
|
|
158
|
+
"""
|
|
159
|
+
if self.path is None or size_before is None:
|
|
160
|
+
return False
|
|
161
|
+
state_bytes = len((_compact_json(state) + "\n").encode("utf-8"))
|
|
162
|
+
size_after = self._size()
|
|
163
|
+
if size_after is None:
|
|
164
|
+
return False
|
|
165
|
+
if size_after - size_before != state_bytes:
|
|
166
|
+
return False
|
|
167
|
+
if size_after < _PRUNE_MIN_BYTES or size_after < 2 * state_bytes:
|
|
168
|
+
return False
|
|
169
|
+
header = self._header_line()
|
|
170
|
+
if header is None:
|
|
171
|
+
# `SessionStore.latest` identifies a session by its first line, so a
|
|
172
|
+
# file without a meta header keeps its original shape.
|
|
173
|
+
return False
|
|
174
|
+
return self._rewrite(header + _compact_json(state) + "\n")
|
|
175
|
+
|
|
176
|
+
def _header_line(self) -> str | None:
|
|
177
|
+
"""Return the session header, with later provider switches applied.
|
|
178
|
+
|
|
179
|
+
``/model`` appends a second meta event. ``SessionStore.load`` lets the
|
|
180
|
+
last one win, so pruning must merge those fields instead of keeping
|
|
181
|
+
only line one, or ``--continue`` resumes on the wrong provider.
|
|
182
|
+
"""
|
|
183
|
+
if self.path is None:
|
|
184
|
+
return None
|
|
185
|
+
header: dict[str, Any] | None = None
|
|
186
|
+
try:
|
|
187
|
+
with self.path.open("r", encoding="utf-8-sig", errors="replace") as handle:
|
|
188
|
+
for line in handle:
|
|
189
|
+
try:
|
|
190
|
+
event = json.loads(line)
|
|
191
|
+
except json.JSONDecodeError:
|
|
192
|
+
continue
|
|
193
|
+
if not isinstance(event, dict) or event.get("type") != "meta":
|
|
194
|
+
continue
|
|
195
|
+
if header is None:
|
|
196
|
+
header = dict(event)
|
|
197
|
+
continue
|
|
198
|
+
for key, value in event.items():
|
|
199
|
+
if key not in _HEADER_IDENTITY_KEYS:
|
|
200
|
+
header[key] = value
|
|
201
|
+
except OSError:
|
|
202
|
+
return None
|
|
203
|
+
if header is None:
|
|
204
|
+
return None
|
|
205
|
+
return _compact_json(header) + "\n"
|
|
206
|
+
|
|
207
|
+
def _rewrite(self, text: str) -> bool:
|
|
208
|
+
"""Atomically replace the session file with ``text``."""
|
|
209
|
+
if self.path is None:
|
|
210
|
+
return False
|
|
211
|
+
try:
|
|
212
|
+
fd, temp_name = tempfile.mkstemp(
|
|
213
|
+
prefix=f".{self.path.name}.", dir=self.path.parent
|
|
214
|
+
)
|
|
215
|
+
except OSError:
|
|
216
|
+
return False
|
|
217
|
+
temp_path = Path(temp_name)
|
|
218
|
+
try:
|
|
219
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
220
|
+
handle.write(text)
|
|
221
|
+
handle.flush()
|
|
222
|
+
os.fsync(handle.fileno())
|
|
223
|
+
try:
|
|
224
|
+
os.chmod(temp_path, 0o600)
|
|
225
|
+
except OSError:
|
|
226
|
+
pass
|
|
227
|
+
os.replace(temp_path, self.path)
|
|
228
|
+
except OSError:
|
|
229
|
+
return False
|
|
230
|
+
finally:
|
|
231
|
+
temp_path.unlink(missing_ok=True)
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
def append_usage(self, usage: dict[str, int]) -> None:
|
|
235
|
+
"""Record token usage without aborting the turn when the write fails.
|
|
236
|
+
|
|
237
|
+
Usage is telemetry: losing one record only affects statistics, while
|
|
238
|
+
raising here would discard a reply the model already produced.
|
|
239
|
+
"""
|
|
240
|
+
try:
|
|
241
|
+
self.append_event({"type": "usage", "usage": usage, "at": _utc_now()})
|
|
242
|
+
except (OSError, TypeError, ValueError):
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class SessionStore:
|
|
247
|
+
def __init__(self, settings: Settings):
|
|
248
|
+
self.settings = settings
|
|
249
|
+
self.root = settings.home / "sessions"
|
|
250
|
+
|
|
251
|
+
def workspace_dir(self, cwd: Path) -> Path:
|
|
252
|
+
return self.root / _workspace_key(cwd.resolve())
|
|
253
|
+
|
|
254
|
+
def create(
|
|
255
|
+
self,
|
|
256
|
+
cwd: Path,
|
|
257
|
+
*,
|
|
258
|
+
provider: str,
|
|
259
|
+
model: str,
|
|
260
|
+
save: bool = True,
|
|
261
|
+
) -> Session:
|
|
262
|
+
session_id = uuid.uuid4().hex
|
|
263
|
+
if not save:
|
|
264
|
+
return Session(
|
|
265
|
+
session_id=session_id,
|
|
266
|
+
path=None,
|
|
267
|
+
cwd=cwd.resolve(),
|
|
268
|
+
provider=provider,
|
|
269
|
+
model=model,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
273
|
+
path = self.workspace_dir(cwd) / f"{stamp}-{session_id[:10]}.jsonl"
|
|
274
|
+
session = Session(
|
|
275
|
+
session_id=session_id,
|
|
276
|
+
path=path,
|
|
277
|
+
cwd=cwd.resolve(),
|
|
278
|
+
provider=provider,
|
|
279
|
+
model=model,
|
|
280
|
+
)
|
|
281
|
+
session.append_event(
|
|
282
|
+
{
|
|
283
|
+
"type": "meta",
|
|
284
|
+
"version": __version__,
|
|
285
|
+
"session_id": session_id,
|
|
286
|
+
"workspace": str(cwd.resolve()),
|
|
287
|
+
"provider": provider,
|
|
288
|
+
"model": model,
|
|
289
|
+
"created_at": _utc_now(),
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
return session
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def _recency_key(path: Path) -> tuple[int, str]:
|
|
296
|
+
try:
|
|
297
|
+
modified = path.stat().st_mtime_ns
|
|
298
|
+
except OSError:
|
|
299
|
+
modified = 0
|
|
300
|
+
return modified, path.name
|
|
301
|
+
|
|
302
|
+
def _candidate_files(self, cwd: Path | None = None) -> list[Path]:
|
|
303
|
+
root = self.workspace_dir(cwd) if cwd is not None else self.root
|
|
304
|
+
if not root.is_dir():
|
|
305
|
+
return []
|
|
306
|
+
return sorted(root.rglob("*.jsonl"), key=self._recency_key, reverse=True)
|
|
307
|
+
|
|
308
|
+
def latest(self, cwd: Path) -> Session | None:
|
|
309
|
+
for path in self._candidate_files(cwd):
|
|
310
|
+
if self._first_event(path) is not None:
|
|
311
|
+
return self.load(path, default_workspace=cwd)
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
def resolve(self, identifier: str, cwd: Path) -> Session:
|
|
315
|
+
try:
|
|
316
|
+
candidate = Path(identifier).expanduser()
|
|
317
|
+
is_file = candidate.is_file()
|
|
318
|
+
except (OSError, RuntimeError, ValueError):
|
|
319
|
+
is_file = False
|
|
320
|
+
candidate = None
|
|
321
|
+
if is_file and candidate is not None:
|
|
322
|
+
return self.load(candidate, default_workspace=cwd)
|
|
323
|
+
|
|
324
|
+
files = self._candidate_files(cwd)
|
|
325
|
+
# A session from another workspace is still addressable by its ID; the
|
|
326
|
+
# sessions listing shows those IDs, so only searching the current
|
|
327
|
+
# workspace made listed sessions fail to resolve here.
|
|
328
|
+
global_files = self._candidate_files()
|
|
329
|
+
if global_files:
|
|
330
|
+
seen_files = set(files)
|
|
331
|
+
files.extend(path for path in global_files if path not in seen_files)
|
|
332
|
+
identities = [(path, self._recorded_session_id(path)) for path in files]
|
|
333
|
+
matches = [
|
|
334
|
+
path
|
|
335
|
+
for path, session_id in identities
|
|
336
|
+
if identifier in session_id
|
|
337
|
+
or identifier in path.stem
|
|
338
|
+
or identifier in path.name
|
|
339
|
+
]
|
|
340
|
+
if not matches:
|
|
341
|
+
raise FileNotFoundError(f"session not found: {identifier}")
|
|
342
|
+
if len(matches) > 1:
|
|
343
|
+
exact = [
|
|
344
|
+
path
|
|
345
|
+
for path, session_id in identities
|
|
346
|
+
if identifier == session_id or identifier == path.stem
|
|
347
|
+
]
|
|
348
|
+
if len(exact) != 1:
|
|
349
|
+
raise ValueError(f"session identifier is ambiguous: {identifier}")
|
|
350
|
+
matches = exact
|
|
351
|
+
return self.load(matches[0], default_workspace=cwd)
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _first_event(path: Path) -> dict[str, Any] | None:
|
|
355
|
+
"""Return the session's meta event, or None when it is unreadable."""
|
|
356
|
+
try:
|
|
357
|
+
with path.open("r", encoding="utf-8-sig", errors="replace") as handle:
|
|
358
|
+
event = json.loads(handle.readline())
|
|
359
|
+
except (OSError, json.JSONDecodeError):
|
|
360
|
+
return None
|
|
361
|
+
if isinstance(event, dict) and event.get("type") == "meta":
|
|
362
|
+
return event
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
@classmethod
|
|
366
|
+
def _recorded_session_id(cls, path: Path) -> str:
|
|
367
|
+
event = cls._first_event(path)
|
|
368
|
+
if event is not None and event.get("type") == "meta":
|
|
369
|
+
session_id = event.get("session_id")
|
|
370
|
+
if isinstance(session_id, str):
|
|
371
|
+
return session_id
|
|
372
|
+
return path.stem
|
|
373
|
+
|
|
374
|
+
def _within_root(self, path: Path) -> bool:
|
|
375
|
+
"""Return True when ``path`` is inside the store's sessions directory."""
|
|
376
|
+
try:
|
|
377
|
+
Path(path).resolve(strict=False).relative_to(
|
|
378
|
+
self.root.resolve(strict=False)
|
|
379
|
+
)
|
|
380
|
+
except (OSError, RuntimeError, ValueError):
|
|
381
|
+
return False
|
|
382
|
+
return True
|
|
383
|
+
|
|
384
|
+
def load(self, path: Path, *, default_workspace: Path | None = None) -> Session:
|
|
385
|
+
messages: list[dict[str, Any]] = []
|
|
386
|
+
session_id = path.stem
|
|
387
|
+
recorded_provider = ""
|
|
388
|
+
recorded_model = ""
|
|
389
|
+
if path.suffix.lower() != ".jsonl":
|
|
390
|
+
raise ValueError(f"not a session file (expected .jsonl): {path}")
|
|
391
|
+
# Never default to the sessions directory: a metadata-less file loaded
|
|
392
|
+
# through --session would otherwise run every tool there.
|
|
393
|
+
workspace = default_workspace or Path.cwd()
|
|
394
|
+
# The recorded workspace is tool-restriction input, so it is trusted
|
|
395
|
+
# only for files the store itself wrote. An arbitrary JSONL handed to
|
|
396
|
+
# --session must not be able to point every tool at C:\ or /.
|
|
397
|
+
trust_recorded_workspace = self._within_root(path)
|
|
398
|
+
try:
|
|
399
|
+
with path.open("r", encoding="utf-8-sig", errors="replace") as handle:
|
|
400
|
+
for line in handle:
|
|
401
|
+
try:
|
|
402
|
+
event = json.loads(line)
|
|
403
|
+
except json.JSONDecodeError:
|
|
404
|
+
continue
|
|
405
|
+
if not isinstance(event, dict):
|
|
406
|
+
continue
|
|
407
|
+
if event.get("type") == "meta":
|
|
408
|
+
recorded_id = event.get("session_id")
|
|
409
|
+
if isinstance(recorded_id, str) and recorded_id:
|
|
410
|
+
session_id = recorded_id
|
|
411
|
+
provider = event.get("provider")
|
|
412
|
+
model = event.get("model")
|
|
413
|
+
if isinstance(provider, str):
|
|
414
|
+
recorded_provider = provider
|
|
415
|
+
if isinstance(model, str):
|
|
416
|
+
recorded_model = model
|
|
417
|
+
recorded_workspace = event.get("workspace")
|
|
418
|
+
if (
|
|
419
|
+
trust_recorded_workspace
|
|
420
|
+
and isinstance(recorded_workspace, str)
|
|
421
|
+
and recorded_workspace
|
|
422
|
+
):
|
|
423
|
+
workspace = Path(recorded_workspace).expanduser()
|
|
424
|
+
elif event.get("type") == "message":
|
|
425
|
+
message = event.get("message")
|
|
426
|
+
if isinstance(message, dict):
|
|
427
|
+
messages.append(message)
|
|
428
|
+
elif event.get("type") == "compact":
|
|
429
|
+
compacted = event.get("messages")
|
|
430
|
+
if isinstance(compacted, list):
|
|
431
|
+
messages = [
|
|
432
|
+
item for item in compacted if isinstance(item, dict)
|
|
433
|
+
]
|
|
434
|
+
except OSError as exc:
|
|
435
|
+
raise FileNotFoundError(f"cannot read session {path}: {exc}") from exc
|
|
436
|
+
try:
|
|
437
|
+
workspace = workspace.resolve()
|
|
438
|
+
except (OSError, RuntimeError):
|
|
439
|
+
pass
|
|
440
|
+
return Session(
|
|
441
|
+
session_id=session_id,
|
|
442
|
+
path=path,
|
|
443
|
+
cwd=workspace,
|
|
444
|
+
messages=messages,
|
|
445
|
+
provider=recorded_provider,
|
|
446
|
+
model=recorded_model,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def list(self) -> list[dict[str, Any]]:
|
|
450
|
+
rows: list[dict[str, Any]] = []
|
|
451
|
+
for path in self._candidate_files():
|
|
452
|
+
first = self._first_event(path)
|
|
453
|
+
if first is None:
|
|
454
|
+
continue
|
|
455
|
+
rows.append(
|
|
456
|
+
{
|
|
457
|
+
"session_id": str(first.get("session_id") or path.stem),
|
|
458
|
+
"workspace": str(first.get("workspace") or ""),
|
|
459
|
+
"provider": str(first.get("provider") or ""),
|
|
460
|
+
"model": str(first.get("model") or ""),
|
|
461
|
+
"created_at": str(first.get("created_at") or ""),
|
|
462
|
+
"path": str(path),
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
return rows
|