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.
- collossal-0.1.0.dist-info/METADATA +218 -0
- collossal-0.1.0.dist-info/RECORD +26 -0
- collossal-0.1.0.dist-info/WHEEL +4 -0
- collossal-0.1.0.dist-info/entry_points.txt +5 -0
- main_mcp/__init__.py +16 -0
- main_mcp/agents.py +278 -0
- main_mcp/capture/__init__.py +1 -0
- main_mcp/capture/checkouts.py +83 -0
- main_mcp/capture/history.py +349 -0
- main_mcp/capture/hook.py +386 -0
- main_mcp/capture/redact.py +200 -0
- main_mcp/capture/shell_edits.py +305 -0
- main_mcp/capture/ship.py +135 -0
- main_mcp/capture/tailer.py +430 -0
- main_mcp/capture/transcript.py +259 -0
- main_mcp/capture/turn_worker.py +124 -0
- main_mcp/capture/turns.py +148 -0
- main_mcp/capture/windows.py +509 -0
- main_mcp/capture/worker.py +130 -0
- main_mcp/cli.py +517 -0
- main_mcp/client.py +249 -0
- main_mcp/config.py +114 -0
- main_mcp/format.py +64 -0
- main_mcp/git_context.py +122 -0
- main_mcp/room.py +747 -0
- main_mcp/server.py +319 -0
|
@@ -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()
|