crowsnest 0.0.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.
crowsnest/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """One session that watches the others.
2
+
3
+ A machine running many Claude Code sessions has a question nobody answers: *what are they
4
+ all doing, and which of them needs me?* Each session knows only itself, the terminal tabs
5
+ are silent until you click them, and the answer lives in forty scrollbacks.
6
+
7
+ ``crowsnest`` reads what Claude Code already writes -- the registry it keeps for every
8
+ running session, and the transcript each session appends to -- and answers in three
9
+ tiers, cheapest first:
10
+
11
+ 1. **The roster** (:func:`crowsnest.tools.roster`): who is alive, busy, idle or waiting,
12
+ where, since when. Instant; no transcript is read.
13
+ 2. **The activity** (:func:`crowsnest.tools.show`, :func:`crowsnest.tools.turns`): what a
14
+ session was last asked, what it last said, the tool it is running now, the question
15
+ it is waiting on -- read from the tail of its transcript, which costs the watched
16
+ session nothing and never interrupts it. ``turns`` pages further back when the tail is
17
+ not enough.
18
+ 3. **The ask**: a running session can be *messaged* and will answer from its own
19
+ context. That is a Claude Code feature, not a Python one, so it lives in the shipped
20
+ skill (``crowsnest/data/skills/crowsnest/SKILL.md``) rather than here -- with the rule
21
+ that says when it is worth a turn of someone else's context and when it is not.
22
+
23
+ And one stream: :func:`crowsnest.watch.events` yields a line every time a session starts,
24
+ exits, finishes a turn, or starts waiting on its human, so a monitor is told rather than
25
+ made to poll.
26
+
27
+ Everything here is read-only. Nothing sends, spawns, kills, or writes into another
28
+ session; the one write in the package is the skill installer, and it writes symlinks.
29
+
30
+ >>> from crowsnest import live_sessions, roster
31
+ >>> live_sessions(home='/nonexistent-dir-for-doctest')
32
+ []
33
+ """
34
+
35
+ from crowsnest.activity import Activity, Turn, read_activity, read_turns
36
+ from crowsnest.registry import LiveSession, live_sessions
37
+ from crowsnest.tools import resolve, roster, show, turns
38
+ from crowsnest.watch import events
39
+
40
+ __all__ = [
41
+ "Activity",
42
+ "LiveSession",
43
+ "Turn",
44
+ "events",
45
+ "live_sessions",
46
+ "read_activity",
47
+ "read_turns",
48
+ "resolve",
49
+ "roster",
50
+ "show",
51
+ "turns",
52
+ ]
crowsnest/__main__.py ADDED
@@ -0,0 +1,234 @@
1
+ """The ``crowsnest`` command: the one surface v0 builds.
2
+
3
+ Every verb is a thin renderer over a function in :mod:`crowsnest.tools`, the single list
4
+ all surfaces dispatch from. The core prints nothing and exits nothing; the formatting is
5
+ here so that a later MCP or HTTP adapter needs no change to the core.
6
+
7
+ Bare ``crowsnest`` prints the roster, because the fewest keystrokes have to produce the
8
+ useful thing.
9
+ """
10
+
11
+ # PYTHON_ARGCOMPLETE_OK
12
+
13
+ from __future__ import annotations
14
+
15
+ import json as _json
16
+ import sys
17
+ from datetime import datetime, timezone
18
+
19
+ from crowsnest import skills as _skills
20
+ from crowsnest import tools
21
+ from crowsnest import watch as _watch
22
+
23
+ __all__ = ["main"]
24
+
25
+ DEFAULT_COMMAND = "roster"
26
+
27
+
28
+ def _age(epoch: float | None) -> str:
29
+ if not epoch:
30
+ return "?"
31
+ seconds = max(0.0, datetime.now(timezone.utc).timestamp() - epoch)
32
+ for size, unit in ((86400, "d"), (3600, "h"), (60, "m")):
33
+ if seconds >= size:
34
+ return f"{seconds / size:.0f}{unit}"
35
+ return f"{seconds:.0f}s"
36
+
37
+
38
+ def _one_line(text: str, limit: int) -> str:
39
+ text = " ".join((text or "").split())
40
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
41
+
42
+
43
+ def _local(stamp: str) -> str:
44
+ """An ISO timestamp as local ``HH:MM``, or the raw value when unparseable."""
45
+ try:
46
+ return (
47
+ datetime.fromisoformat(stamp.replace("Z", "+00:00"))
48
+ .astimezone()
49
+ .strftime("%H:%M")
50
+ )
51
+ except ValueError:
52
+ return stamp
53
+
54
+
55
+ def _row_detail(row: dict, limit: int) -> str:
56
+ act = row.get("activity") or {}
57
+ status = row["status"]
58
+ if status == "waiting":
59
+ cause = act.get("pending_question") or "; ".join(act.get("in_flight") or ())
60
+ parts = [
61
+ row.get("waiting_for") or "waiting",
62
+ cause or act.get("last_assistant_text", ""),
63
+ ]
64
+ return _one_line(" · ".join(p for p in parts if p), limit)
65
+ if status == "busy":
66
+ running = "; ".join(act.get("in_flight") or ())
67
+ if running:
68
+ return _one_line("→ " + running, limit)
69
+ return _one_line("asked: " + act.get("last_user_prompt", ""), limit)
70
+ said = act.get("last_assistant_text", "")
71
+ mark = "⚠ " if act.get("errored") else ""
72
+ return _one_line(f'{mark}"{said}"' if said else "", limit)
73
+
74
+
75
+ def roster(*, home: str | None = None, brief: bool = False, width: int = 110):
76
+ """Who is alive, most urgent first: waiting on you, then busy, then idle.
77
+
78
+ `--brief` answers from the registry alone, without reading any transcript.
79
+ """
80
+ result = tools.roster(home=home, activity=not brief)
81
+ lines = []
82
+ for row in result["sessions"]:
83
+ head = f"{row['status']:<8}{_age(row['status_since']):>4} {row['label'][:26]:<27}{row['project'][:16]:<17}"
84
+ detail = "" if brief else _row_detail(row, max(20, width - len(head)))
85
+ lines.append((head + detail).rstrip())
86
+ counts = result["counts"]
87
+ summary = ", ".join(f"{n} {k}" for k, n in counts.items() if n)
88
+ lines.append(f"-- {len(result['sessions'])} live: {summary or 'none'}")
89
+ return "\n".join(lines)
90
+
91
+
92
+ def show(session: str, *, home: str | None = None, recent: int = 8, json: bool = False):
93
+ """One session in full: what it was asked, what it said, what it is running now.
94
+
95
+ `session` is a registry name, a unique prefix of one, a session-id prefix, or a pid.
96
+ """
97
+ result = tools.show(session, home=home, recent=recent)
98
+ if json:
99
+ return _json.dumps(result, indent=2)
100
+ s, act = result["session"], result["activity"]
101
+ since = _age(s["status_since"])
102
+ out = [
103
+ f"# {s['label']} ({s['status']} for {since}"
104
+ + (f", {s['waiting_for']}" if s["waiting_for"] else "")
105
+ + ")"
106
+ ]
107
+ out.append(
108
+ f"pid {s['pid']} · session {s['session_id'][:8]} · {s['cwd']}"
109
+ + (f" · branch {act['git_branch']}" if act["git_branch"] else "")
110
+ + (" · remote control on" if s["remote_control"] else "")
111
+ )
112
+ if act["pending_question"]:
113
+ out += ["", "## Waiting on you", act["pending_question"]]
114
+ if act["in_flight"]:
115
+ out += ["", "## In flight", *[f"- {t}" for t in act["in_flight"]]]
116
+ out += [
117
+ "",
118
+ f"## Last asked ({_local(act['last_prompt_at'])})",
119
+ act["last_user_prompt"] or "(none in the tail)",
120
+ ]
121
+ out += [
122
+ "",
123
+ f"## Last said ({_local(act['last_text_at'])})",
124
+ act["last_assistant_text"] or "(none in the tail)",
125
+ ]
126
+ if act["recent_tools"]:
127
+ out += ["", "## Recent tools", *[f"- {t}" for t in act["recent_tools"]]]
128
+ flags = [k for k in ("turn_open", "errored") if act[k]]
129
+ if flags or not act["tail_complete"]:
130
+ out += [
131
+ "",
132
+ "flags: "
133
+ + ", ".join(flags + ([] if act["tail_complete"] else ["tail only"])),
134
+ ]
135
+ return "\n".join(out)
136
+
137
+
138
+ def turns(
139
+ session: str,
140
+ *,
141
+ last: int = 5,
142
+ before: int | None = None,
143
+ home: str | None = None,
144
+ json: bool = False,
145
+ ):
146
+ """The last few turns of a session, oldest first. `--before N` pages back from turn N."""
147
+ result = tools.turns(session, last=last, before=before, home=home)
148
+ if json:
149
+ return _json.dumps(result, indent=2)
150
+ out = [f"# {result['session']['label']} — turns"]
151
+ for t in result["turns"]:
152
+ out += [
153
+ "",
154
+ f"## turn {t['index']} ({_local(t['prompt_at'])})",
155
+ f"> {t['prompt']}",
156
+ ]
157
+ if t["tools"]:
158
+ out.append(
159
+ f"tools ({len(t['tools'])}): "
160
+ + "; ".join(t["tools"][:8])
161
+ + (" …" if len(t["tools"]) > 8 else "")
162
+ )
163
+ out.append(t["reply"] or "(no final text)")
164
+ if not result["turns"]:
165
+ out.append("(no turns)")
166
+ return "\n".join(out)
167
+
168
+
169
+ def watch(
170
+ *, interval: float = _watch.DFLT_INTERVAL, home: str | None = None, json: bool = False
171
+ ):
172
+ """Print one line per change, forever: started, exited, idle, busy, waiting, error.
173
+
174
+ Built for Claude Code's `Monitor` tool: each line becomes a notification in the
175
+ watching session. Stop with Ctrl-C.
176
+ """
177
+ try:
178
+ for event in _watch.events(interval=interval, home=home):
179
+ if json:
180
+ line = _json.dumps(event)
181
+ else:
182
+ when = _local(event["at"])
183
+ line = f"{when} {event['kind']:<8} {event['name']} ({event['project']})"
184
+ if event["detail"]:
185
+ line += f" — {event['detail']}"
186
+ print(line, flush=True)
187
+ except KeyboardInterrupt:
188
+ pass
189
+
190
+
191
+ def install_skills(
192
+ *,
193
+ target: str | None = None,
194
+ only: str | None = None,
195
+ force: bool = False,
196
+ dry_run: bool = False,
197
+ ):
198
+ """Link the bundled skill and subagent into ~/.claude (or `--target`). Idempotent."""
199
+ names = [n for n in (only or "").split(",") if n.strip()] or None
200
+ plan = _skills.install_skills(target=target, only=names, force=force, dry_run=dry_run)
201
+ lines = [f"{'would install' if dry_run else 'installed'} into {plan['target']}"]
202
+ for row in plan["actions"]:
203
+ how = f" ({row['method']})" if row["method"] else ""
204
+ lines.append(
205
+ f"{row['action']:<9}{row['kind']:<7}{row['name']:<18}{row['reason']}{how}"
206
+ )
207
+ return "\n".join(lines)
208
+
209
+
210
+ _commands = [roster, show, turns, watch, install_skills]
211
+
212
+
213
+ def main(argv: list[str] | None = None) -> None:
214
+ """Dispatch the ``crowsnest`` command. Bare ``crowsnest`` runs :func:`roster`."""
215
+ import cw
216
+
217
+ argv = list(sys.argv[1:] if argv is None else argv)
218
+ if not argv or argv[0].startswith("-") and argv[0] not in ("-h", "--help"):
219
+ argv = [DEFAULT_COMMAND, *argv]
220
+ parser = cw.mk_parser(
221
+ _commands, prog="crowsnest", description=__doc__.splitlines()[0]
222
+ )
223
+ try:
224
+ code = cw.run(parser, argv)
225
+ except (ValueError, KeyError) as exc:
226
+ message = exc.args[0] if exc.args else str(exc)
227
+ print(f"crowsnest: {message}", file=sys.stderr)
228
+ sys.exit(2)
229
+ if code:
230
+ raise SystemExit(code)
231
+
232
+
233
+ if __name__ == "__main__":
234
+ main()
crowsnest/activity.py ADDED
@@ -0,0 +1,353 @@
1
+ """What a session is doing right now, read from the tail of its transcript.
2
+
3
+ A transcript is append-only and can run to megabytes; the part that says what a session
4
+ is doing *now* is its last few kilobytes. So :func:`read_activity` reads from the end,
5
+ widening the window only until it has seen one human prompt, and reports what it found:
6
+ what the session was last asked, what it last said, the tools it ran most recently, the
7
+ tool call that has not returned yet, and -- the case a monitor exists for -- a question it
8
+ has put to its human that nobody has answered.
9
+
10
+ What the transcript's *content* means is :mod:`openloops.transcripts`'s business -- which
11
+ ``user`` line is a person speaking and which is tooling, what the assistant's last words
12
+ were, whether the turn ended -- and :func:`openloops.transcripts.parse_session` is called
13
+ on the tail rather than that logic being written a second time. What this module adds is
14
+ the tool-level view that a dated digest has no use for and a live monitor cannot do
15
+ without.
16
+
17
+ :func:`read_turns` is the deep path. It reads the whole file and pages backwards through
18
+ turns, for when the tail did not carry enough context and the alternative is spending a
19
+ turn of the watched session's own context asking it.
20
+
21
+ >>> read_activity('/nonexistent-file-for-doctest').last_user_prompt
22
+ ''
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from collections.abc import Callable
29
+ from dataclasses import asdict, dataclass
30
+ from pathlib import Path
31
+
32
+ from openloops.transcripts import parse_session
33
+
34
+ __all__ = [
35
+ "QUESTION_TOOL",
36
+ "RECENT_TOOLS",
37
+ "TAIL_BYTES",
38
+ "Activity",
39
+ "Turn",
40
+ "describe_tool",
41
+ "load_records",
42
+ "read_activity",
43
+ "read_turns",
44
+ "tail_records",
45
+ ]
46
+
47
+ #: How much of the file's end is read first. A turn of tool calls is a few kilobytes; a
48
+ #: quarter megabyte covers the last several turns of nearly every session and costs a
49
+ #: few milliseconds. The window doubles until it holds a human prompt.
50
+ TAIL_BYTES = 256 * 1024
51
+
52
+ #: How many recent tool calls an :class:`Activity` carries.
53
+ RECENT_TOOLS = 6
54
+
55
+ #: The tool Claude Code uses to put a structured question to its human. A call to it with
56
+ #: no result yet is a session waiting on a person.
57
+ QUESTION_TOOL = "AskUserQuestion"
58
+
59
+ #: Which input field best says what a tool call was about, first match wins. A
60
+ #: ``description`` is written for a human; a path or a target is the next best thing.
61
+ _ARG_KEYS = (
62
+ "description",
63
+ "file_path",
64
+ "path",
65
+ "pattern",
66
+ "to",
67
+ "query",
68
+ "url",
69
+ "prompt",
70
+ "command",
71
+ "message",
72
+ )
73
+ _PATH_KEYS = ("file_path", "path")
74
+ _ARG_LIMIT = 80
75
+
76
+
77
+ def _parse_lines(data: bytes) -> list[dict]:
78
+ records = []
79
+ for line in data.splitlines():
80
+ line = line.strip()
81
+ if not line:
82
+ continue
83
+ try:
84
+ rec = json.loads(line)
85
+ except ValueError:
86
+ continue
87
+ if isinstance(rec, dict):
88
+ records.append(rec)
89
+ return records
90
+
91
+
92
+ def load_records(path: str | Path) -> list[dict]:
93
+ """Every record in a transcript, tolerating blank and malformed lines."""
94
+ try:
95
+ return _parse_lines(Path(path).read_bytes())
96
+ except OSError:
97
+ return []
98
+
99
+
100
+ def _has_prompt(records: list[dict]) -> bool:
101
+ return bool(parse_session(records).last_user_prompt)
102
+
103
+
104
+ def tail_records(
105
+ path: str | Path,
106
+ *,
107
+ tail_bytes: int = TAIL_BYTES,
108
+ enough: Callable[[list[dict]], bool] = _has_prompt,
109
+ ) -> tuple[list[dict], bool]:
110
+ """The records in the last ``tail_bytes`` of a transcript, widening until ``enough``.
111
+
112
+ Returns the records and whether the window reached the start of the file. The first
113
+ line of a window that starts mid-file is a fragment and is dropped.
114
+
115
+ >>> tail_records('/nonexistent-file-for-doctest')
116
+ ([], True)
117
+ """
118
+ path = Path(path)
119
+ try:
120
+ size = path.stat().st_size
121
+ except OSError:
122
+ return [], True
123
+ window = max(1, tail_bytes)
124
+ while True:
125
+ start = max(0, size - window)
126
+ try:
127
+ with path.open("rb") as f:
128
+ f.seek(start)
129
+ data = f.read()
130
+ except OSError:
131
+ return [], True
132
+ if start > 0:
133
+ data = data.partition(b"\n")[2]
134
+ records = _parse_lines(data)
135
+ if start == 0 or enough(records):
136
+ return records, start == 0
137
+ window *= 2
138
+
139
+
140
+ def _blocks(record: dict) -> list[dict]:
141
+ content = (record.get("message") or {}).get("content")
142
+ if isinstance(content, list):
143
+ return [b for b in content if isinstance(b, dict)]
144
+ return []
145
+
146
+
147
+ def _stamp(record: dict) -> str:
148
+ return str(record.get("timestamp") or "")
149
+
150
+
151
+ def _main_thread(records: list[dict]) -> list[dict]:
152
+ return sorted((r for r in records if not r.get("isSidechain")), key=_stamp)
153
+
154
+
155
+ def describe_tool(name: str, inputs: dict | None, *, limit: int = _ARG_LIMIT) -> str:
156
+ """One line naming a tool call: the tool, and the argument that says what it did.
157
+
158
+ >>> describe_tool('Bash', {'command': 'ls -la', 'description': 'List files'})
159
+ 'Bash: List files'
160
+ >>> describe_tool('Read', {'file_path': '/a/b/c.py'})
161
+ 'Read: c.py'
162
+ >>> describe_tool('AskUserQuestion', {'questions': [{'question': 'Ship it?'}]})
163
+ 'AskUserQuestion: Ship it?'
164
+ >>> describe_tool('ListAgents', {})
165
+ 'ListAgents'
166
+ """
167
+ inputs = inputs if isinstance(inputs, dict) else {}
168
+ arg = ""
169
+ if name == QUESTION_TOOL:
170
+ questions = inputs.get("questions") or []
171
+ first = questions[0] if questions and isinstance(questions[0], dict) else {}
172
+ arg = str(first.get("question") or "")
173
+ else:
174
+ for key in _ARG_KEYS:
175
+ value = inputs.get(key)
176
+ if isinstance(value, str) and value.strip():
177
+ arg = Path(value).name if key in _PATH_KEYS else value
178
+ break
179
+ arg = " ".join(arg.split())
180
+ if len(arg) > limit:
181
+ arg = arg[: limit - 1].rstrip() + "…"
182
+ return f"{name}: {arg}" if arg else name
183
+
184
+
185
+ def _tool_calls(records: list[dict]) -> tuple[list[tuple[str, str, dict]], set[str]]:
186
+ """Every tool call on the main thread in order, and the ids that got a result."""
187
+ calls: list[tuple[str, str, dict]] = []
188
+ answered: set[str] = set()
189
+ for rec in records:
190
+ if rec.get("type") == "assistant":
191
+ for b in _blocks(rec):
192
+ if b.get("type") == "tool_use":
193
+ calls.append(
194
+ (
195
+ str(b.get("id") or ""),
196
+ str(b.get("name") or ""),
197
+ b.get("input") or {},
198
+ )
199
+ )
200
+ elif rec.get("type") == "user":
201
+ for b in _blocks(rec):
202
+ if b.get("type") == "tool_result" and b.get("tool_use_id"):
203
+ answered.add(str(b["tool_use_id"]))
204
+ return calls, answered
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class Activity:
209
+ """What one session is doing, as its transcript tail reads. Flat and JSON-shaped.
210
+
211
+ ``in_flight`` lists tool calls with no result yet, oldest first: normally one, several
212
+ when calls were issued in parallel. ``pending_question`` is the first question of an
213
+ in-flight :data:`QUESTION_TOOL` call -- a session waiting on a person.
214
+ ``tail_complete`` says whether the window reached the start of the file, which is what
215
+ makes the difference between "no prompt in the tail" and "no prompt at all".
216
+ """
217
+
218
+ session_id: str = ""
219
+ last_event_at: str = ""
220
+ last_user_prompt: str = ""
221
+ last_prompt_at: str = ""
222
+ last_assistant_text: str = ""
223
+ last_text_at: str = ""
224
+ recent_tools: tuple[str, ...] = ()
225
+ in_flight: tuple[str, ...] = ()
226
+ pending_question: str = ""
227
+ turn_open: bool = False
228
+ errored: bool = False
229
+ git_branch: str = ""
230
+ tail_complete: bool = True
231
+
232
+ def as_dict(self) -> dict:
233
+ return asdict(self)
234
+
235
+
236
+ def read_activity(
237
+ path: str | Path,
238
+ *,
239
+ session_id: str = "",
240
+ tail_bytes: int = TAIL_BYTES,
241
+ recent: int = RECENT_TOOLS,
242
+ ) -> Activity:
243
+ """Read the tail of a transcript into an :class:`Activity`.
244
+
245
+ Costs the watched session nothing: the file is opened read-only and the session is
246
+ never signalled, messaged or otherwise made aware.
247
+ """
248
+ records, complete = tail_records(path, tail_bytes=tail_bytes)
249
+ main = _main_thread(records)
250
+ session = parse_session(main, key=session_id)
251
+ calls, answered = _tool_calls(main)
252
+ in_flight = [
253
+ (name, inputs) for call_id, name, inputs in calls if call_id not in answered
254
+ ]
255
+ question = next((describe_tool(n, i) for n, i in in_flight if n == QUESTION_TOOL), "")
256
+ stamps = [_stamp(r) for r in main if _stamp(r)]
257
+ return Activity(
258
+ session_id=session.key or session_id,
259
+ last_event_at=max(stamps) if stamps else "",
260
+ last_user_prompt=session.last_user_prompt,
261
+ last_prompt_at=session.last_prompt_at,
262
+ last_assistant_text=session.last_assistant_text,
263
+ last_text_at=session.last_turn_at,
264
+ recent_tools=tuple(describe_tool(n, i) for _, n, i in calls[-recent:])
265
+ if recent
266
+ else (),
267
+ in_flight=tuple(describe_tool(n, i) for n, i in in_flight),
268
+ pending_question=question.partition(": ")[2] if question else "",
269
+ turn_open=session.ended_mid_turn,
270
+ errored=session.ended_with_error,
271
+ git_branch=session.git_branch,
272
+ tail_complete=complete,
273
+ )
274
+
275
+
276
+ @dataclass(frozen=True)
277
+ class Turn:
278
+ """One exchange: a human prompt, what the assistant did, and its last words."""
279
+
280
+ index: int
281
+ prompt: str
282
+ prompt_at: str
283
+ reply: str
284
+ reply_at: str
285
+ tools: tuple[str, ...]
286
+
287
+ def as_dict(self) -> dict:
288
+ return asdict(self)
289
+
290
+
291
+ def _opens_a_turn(record: dict) -> bool:
292
+ """A ``user`` record that could be a human prompt: not a tool result, not injected."""
293
+ if (
294
+ record.get("type") != "user"
295
+ or record.get("isMeta")
296
+ or record.get("isCompactSummary")
297
+ ):
298
+ return False
299
+ return not any(b.get("type") == "tool_result" for b in _blocks(record))
300
+
301
+
302
+ def _turn_chunks(records: list[dict]) -> list[list[dict]]:
303
+ """Split main-thread records at each human prompt.
304
+
305
+ A ``user`` record that opens a chunk but carries no human text once its tooling
306
+ wrappers are stripped -- a system reminder, a task notification -- was not a turn, and
307
+ its chunk is folded into the turn before it. That judgement is
308
+ :func:`openloops.transcripts.parse_session`'s, made on the chunk, not repeated here.
309
+ """
310
+ chunks: list[list[dict]] = [[]]
311
+ for rec in records:
312
+ if _opens_a_turn(rec):
313
+ chunks.append([rec])
314
+ else:
315
+ chunks[-1].append(rec)
316
+ turns: list[list[dict]] = []
317
+ for chunk in chunks:
318
+ if chunk and parse_session(chunk).last_user_prompt:
319
+ turns.append(chunk)
320
+ elif turns:
321
+ turns[-1].extend(chunk)
322
+ return turns
323
+
324
+
325
+ def read_turns(
326
+ path: str | Path,
327
+ *,
328
+ last: int = 5,
329
+ before: int | None = None,
330
+ ) -> list[Turn]:
331
+ """The last ``last`` turns of a transcript, oldest first; ``before=N`` pages back.
332
+
333
+ Reads the whole file. This is the deep path, taken on request when the tail did not
334
+ carry enough context -- still cheaper than a turn of the watched session's own.
335
+ """
336
+ chunks = _turn_chunks(_main_thread(load_records(path)))
337
+ turns = []
338
+ for index, chunk in enumerate(chunks, start=1):
339
+ session = parse_session(chunk)
340
+ calls, _ = _tool_calls(chunk)
341
+ turns.append(
342
+ Turn(
343
+ index=index,
344
+ prompt=session.last_user_prompt,
345
+ prompt_at=session.last_prompt_at,
346
+ reply=session.last_assistant_text,
347
+ reply_at=session.last_turn_at,
348
+ tools=tuple(describe_tool(n, i) for _, n, i in calls),
349
+ )
350
+ )
351
+ if before is not None:
352
+ turns = [t for t in turns if t.index < before]
353
+ return turns[-last:] if last > 0 else turns