collossal 0.1.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,349 @@
1
+ """Past sessions, uploaded from the transcripts already on this machine.
2
+
3
+ Claude Code writes every session to ``~/.claude/projects/<folder>/<id>.jsonl``
4
+ and keeps them for weeks. The live stream only sees a session from the moment a
5
+ hook attaches to it, so on a machine that has just installed Collossal all of
6
+ that history is invisible — the dashboard shows a teammate with two sessions
7
+ when they have run eighty. This uploads it: every session, whatever it ran in.
8
+
9
+ Three rules keep it honest:
10
+
11
+ * **Nothing twice.** Before any transcript is read in full, the server is asked
12
+ which of them it wants. One the live stream already carried is left alone
13
+ rather than duplicated, and one imported before only sends what it gained.
14
+ * **Real time.** Every event keeps the moment it happened, so an imported
15
+ session sits in the history where it happened, not where it arrived.
16
+ * **Same redaction.** Events are rendered by the tailer's own function, so
17
+ nothing leaves the machine by this route that the live stream would have
18
+ scrubbed.
19
+
20
+ Run by ``collossal install`` and ``collossal import`` over everything, and by the
21
+ SessionEnd hook over the one session that just ended — which is what completes
22
+ a session that began before the hooks were installed.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import json
29
+ import sys
30
+ import time
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Any, Callable, Iterator
34
+
35
+ from ..agents import claude_dir
36
+ from ..client import CollossalClient, CollossalError
37
+ from ..config import ConfigError, load_config
38
+ from ..git_context import read_git_context
39
+ from .checkouts import Tally
40
+ from .hook import _log
41
+ from .tailer import render_event
42
+ from .transcript import SessionMeta, read_events
43
+
44
+ #: A transcript that moved more recently than this may still be running — the
45
+ #: tailer's own idle horizon, so the two never race over one session. It is
46
+ #: picked up when it ends.
47
+ ACTIVE_SECONDS = 15 * 60
48
+
49
+ #: Per request: well inside the server's limits (500 events, 8 MB), and small
50
+ #: enough that one slow chunk is not a stall.
51
+ CHUNK_EVENTS = 200
52
+ CHUNK_CHARS = 1_500_000
53
+
54
+ #: The server plans this many sessions per call.
55
+ PLAN_BATCH = 1000
56
+
57
+ #: A rate-limited request is retried this many times before the session is
58
+ #: given up on (and left for the next run).
59
+ RETRIES = 6
60
+
61
+
62
+ @dataclass
63
+ class Transcript:
64
+ path: Path
65
+ session_id: str
66
+ #: The directory the session ran in — which is what says which repository.
67
+ cwd: str | None
68
+ modified: float
69
+
70
+
71
+ def _first_cwd(path: Path) -> str | None:
72
+ """A session's working directory, from its opening records."""
73
+ try:
74
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
75
+ for count, line in enumerate(fh):
76
+ if count > 200:
77
+ break
78
+ try:
79
+ record = json.loads(line)
80
+ except json.JSONDecodeError:
81
+ continue
82
+ cwd = record.get("cwd") if isinstance(record, dict) else None
83
+ if isinstance(cwd, str) and cwd:
84
+ return cwd
85
+ except OSError:
86
+ return None
87
+ return None
88
+
89
+
90
+ def find_transcripts(root: Path | None = None) -> list[Transcript]:
91
+ """Every session transcript on this machine, newest first.
92
+
93
+ The top level of each project folder only: the folders beside the
94
+ transcripts hold subagents' runs, which belong to their parent session and
95
+ are not sessions of their own.
96
+ """
97
+ projects = (root or claude_dir()) / "projects"
98
+ try:
99
+ folders = [p for p in projects.iterdir() if p.is_dir()]
100
+ except OSError:
101
+ return []
102
+
103
+ found: list[Transcript] = []
104
+ for folder in folders:
105
+ for path in folder.glob("*.jsonl"):
106
+ try:
107
+ modified = path.stat().st_mtime
108
+ except OSError:
109
+ continue
110
+ found.append(Transcript(path, path.stem, _first_cwd(path), modified))
111
+ return sorted(found, key=lambda t: t.modified, reverse=True)
112
+
113
+
114
+ def touched_checkouts(transcript: Transcript) -> list[Path]:
115
+ """The checkouts a finished session worked in, most-worked first."""
116
+ parsed, _ = read_events(transcript.path, start_line=0)
117
+ tally = Tally(Path(transcript.cwd) if transcript.cwd else None)
118
+ tally.add(parsed)
119
+ return tally.ranked()
120
+
121
+
122
+ def build(transcript: Transcript, strip_dir: Path | None) -> dict[str, Any] | None:
123
+ """A transcript as the import endpoint takes it; None when it holds nothing.
124
+
125
+ `strip_dir` is taken off the front of every path the session quotes: its
126
+ repository's root, or — for a session that began in a folder — that
127
+ folder, so a path still says which repository it is in.
128
+ """
129
+ meta = SessionMeta()
130
+ parsed, _ = read_events(transcript.path, start_line=0, meta=meta)
131
+
132
+ events: list[dict[str, Any]] = []
133
+ last_at = meta.first_at
134
+ for e in parsed:
135
+ item = render_event(e, strip_dir)
136
+ if item is None:
137
+ continue
138
+ # A record without a time of its own happened no earlier than the one
139
+ # before it.
140
+ at = e.timestamp or last_at
141
+ if at is None:
142
+ continue
143
+ last_at = at
144
+ events.append({"seq": len(events) + 1, **item, "at": at})
145
+
146
+ if not events or not meta.first_at:
147
+ return None
148
+
149
+ payload: dict[str, Any] = {
150
+ "startedAt": meta.first_at,
151
+ "endedAt": meta.last_at or meta.first_at,
152
+ "events": events,
153
+ }
154
+ if meta.title:
155
+ payload["title"] = meta.title[:200]
156
+ if meta.model:
157
+ payload["model"] = meta.model[:100]
158
+ if meta.branch:
159
+ payload["branch"] = meta.branch[:200]
160
+ return payload
161
+
162
+
163
+ def _chunks(events: list[dict[str, Any]]) -> Iterator[list[dict[str, Any]]]:
164
+ chunk: list[dict[str, Any]] = []
165
+ size = 0
166
+ for event in events:
167
+ n = len(event["body"])
168
+ if chunk and (len(chunk) >= CHUNK_EVENTS or size + n > CHUNK_CHARS):
169
+ yield chunk
170
+ chunk, size = [], 0
171
+ chunk.append(event)
172
+ size += n
173
+ if chunk:
174
+ yield chunk
175
+
176
+
177
+ async def _send(client: CollossalClient, payload: dict[str, Any]) -> dict[str, Any]:
178
+ """One upload, waiting out a rate limit rather than losing the session to it."""
179
+ for attempt in range(RETRIES):
180
+ try:
181
+ return await client.import_session(payload)
182
+ except CollossalError as exc:
183
+ if exc.status != 429 or attempt == RETRIES - 1:
184
+ raise
185
+ await asyncio.sleep(min(max(exc.retry_after or 10.0, 1.0), 60.0))
186
+ raise AssertionError("unreachable")
187
+
188
+
189
+ @dataclass
190
+ class Summary:
191
+ found: int = 0
192
+ imported: int = 0
193
+ events: int = 0
194
+ streamed: int = 0
195
+ up_to_date: int = 0
196
+ active: int = 0
197
+ by_activity: int = 0
198
+ folder_only: int = 0
199
+ empty: int = 0
200
+ failed: int = 0
201
+
202
+
203
+ #: Called as each session starts uploading: (index, total, title).
204
+ Progress = Callable[[int, int, str], None]
205
+
206
+
207
+ @dataclass
208
+ class _Candidate:
209
+ transcript: Transcript
210
+ #: What the session is labelled with — every part optional.
211
+ place: dict[str, str]
212
+ #: Taken off the front of the paths it quotes.
213
+ strip_dir: Path | None
214
+ #: How far the server already has it.
215
+ reached: int = 0
216
+
217
+
218
+ async def import_history(
219
+ client: CollossalClient,
220
+ transcripts: list[Transcript],
221
+ *,
222
+ progress: Progress | None = None,
223
+ include_active: bool = False,
224
+ ) -> Summary:
225
+ summary = Summary(found=len(transcripts))
226
+ now = time.time()
227
+
228
+ # Where each one ran — asked of git once per directory. Every session is
229
+ # uploaded; this only decides what it is labelled with.
230
+ contexts: dict[Path, Any] = {}
231
+
232
+ def context(directory: Path):
233
+ if directory not in contexts:
234
+ contexts[directory] = read_git_context(directory)
235
+ return contexts[directory]
236
+
237
+ candidates: list[_Candidate] = []
238
+ for t in transcripts:
239
+ if not include_active and now - t.modified < ACTIVE_SECONDS:
240
+ summary.active += 1
241
+ continue
242
+ cwd = Path(t.cwd) if t.cwd else None
243
+ place: dict[str, str] = {"folder": cwd.name} if cwd and cwd.name else {}
244
+
245
+ git = context(cwd) if cwd and cwd.is_dir() else None
246
+ if git and git.usable:
247
+ place["remoteUrl"] = git.remote_url
248
+ candidates.append(_Candidate(t, place, git.root))
249
+ continue
250
+
251
+ # Began in a folder that is not a checkout — most often one holding
252
+ # several. Labelled by the repository it worked in most, if any.
253
+ chosen = next(
254
+ (ctx for root in touched_checkouts(t) if (ctx := context(root)).usable),
255
+ None,
256
+ )
257
+ if chosen:
258
+ place["remoteUrl"] = chosen.remote_url
259
+ summary.by_activity += 1
260
+ else:
261
+ summary.folder_only += 1
262
+ candidates.append(_Candidate(t, place, cwd))
263
+
264
+ wanted: list[_Candidate] = []
265
+ for start in range(0, len(candidates), PLAN_BATCH):
266
+ batch = candidates[start : start + PLAN_BATCH]
267
+ plan = await client.import_plan([c.transcript.session_id for c in batch])
268
+ # The server answers in the order it was asked.
269
+ for candidate, verdict in zip(batch, plan.get("sessions") or []):
270
+ if verdict.get("action") == "import":
271
+ candidate.reached = int(verdict.get("lastSeq") or 0)
272
+ wanted.append(candidate)
273
+ else:
274
+ summary.streamed += 1
275
+
276
+ for index, c in enumerate(wanted, start=1):
277
+ t = c.transcript
278
+ payload = build(t, c.strip_dir)
279
+ if payload is None:
280
+ summary.empty += 1
281
+ continue
282
+ # Events are numbered by their place in the transcript, so what the
283
+ # server already holds is exactly a prefix: send only what follows it.
284
+ fresh = [e for e in payload["events"] if e["seq"] > c.reached]
285
+ if not fresh:
286
+ summary.up_to_date += 1
287
+ continue
288
+ if progress:
289
+ progress(index, len(wanted), payload.get("title") or t.session_id[:8])
290
+
291
+ head = {k: v for k, v in payload.items() if k != "events"}
292
+ try:
293
+ accepted = 0
294
+ status = "imported"
295
+ for chunk in _chunks(fresh):
296
+ result = await _send(
297
+ client,
298
+ {"externalId": t.session_id, **c.place, **head, "events": chunk},
299
+ )
300
+ status = result.get("status", status)
301
+ if status == "streamed":
302
+ # It began streaming between the plan and now.
303
+ break
304
+ accepted += int(result.get("accepted") or 0)
305
+ except CollossalError as exc:
306
+ _log(f"history: {t.session_id[:8]} failed ({exc})")
307
+ summary.failed += 1
308
+ continue
309
+
310
+ if status == "streamed":
311
+ summary.streamed += 1
312
+ else:
313
+ summary.imported += 1
314
+ summary.events += accepted
315
+
316
+ return summary
317
+
318
+
319
+ def main() -> None:
320
+ """The SessionEnd hook's detached worker: the session that just ended.
321
+
322
+ A session that began before the hooks were installed was never streamed,
323
+ and this is what completes it. One the live stream carried costs a single
324
+ small request, which the plan answers with "streamed".
325
+ """
326
+ try:
327
+ raw = sys.stdin.read()
328
+ event = json.loads(raw) if raw.strip() else {}
329
+ path = Path(event.get("transcript_path") or "")
330
+ session_id = event.get("session_id")
331
+ if not session_id or not path.is_file():
332
+ return
333
+ transcript = Transcript(
334
+ path,
335
+ session_id,
336
+ event.get("cwd") or _first_cwd(path),
337
+ path.stat().st_mtime,
338
+ )
339
+ client = CollossalClient(load_config())
340
+ asyncio.run(import_history(client, [transcript], include_active=True))
341
+ except (ConfigError, CollossalError, OSError, ValueError) as exc:
342
+ _log(f"history: {exc}")
343
+ except Exception as exc: # noqa: BLE001 - must never surface to the session
344
+ _log(f"history error: {exc}")
345
+ sys.exit(0)
346
+
347
+
348
+ if __name__ == "__main__":
349
+ main()