quirework 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.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- quirework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1213 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a provenance session as a clean, readable transcript.
|
|
3
|
+
|
|
4
|
+
The read-half companion to ``quirework provenance search``: search tells you *which*
|
|
5
|
+
session mentioned X (by short id); show prints that session's human/assistant
|
|
6
|
+
turns in order, with tool calls, tool-result echoes, thinking, and harness
|
|
7
|
+
plumbing stripped, so a session can be *read* without hand-grepping the raw
|
|
8
|
+
JSONL. (Hand-rolling that extraction is exactly what this command exists to
|
|
9
|
+
retire.)
|
|
10
|
+
|
|
11
|
+
quirework provenance show 31cf63ed # cc-session, human + assistant prose
|
|
12
|
+
quirework provenance search kettle --show # find the session, then:
|
|
13
|
+
quirework provenance show 31cf63ed --thinking # include the assistant's thinking
|
|
14
|
+
quirework provenance show 31cf63ed --role human # only the human's turns
|
|
15
|
+
quirework provenance show 31cf63ed --raw # keep harness text, labeled as such
|
|
16
|
+
quirework provenance show 31cf63ed --utc # stamp turns in UTC, not local time
|
|
17
|
+
quirework provenance show 31cf63ed --from 15:47 --to 15:52 # turns in a time window
|
|
18
|
+
quirework provenance show 31cf63ed --turn 14 # the author's turn 14 and its replies
|
|
19
|
+
quirework provenance show c00a7331 # a claude.ai web dialogue (both speakers)
|
|
20
|
+
|
|
21
|
+
The id is the short id search prints (an 8-hex prefix, or the full ``gemini-…``
|
|
22
|
+
name); a full uuid or a path works too. It resolves across the corpus families,
|
|
23
|
+
pending captures included, so a session can be read before it is accepted; the
|
|
24
|
+
source line says when it has not been:
|
|
25
|
+
local CLI sessions (Claude Code, Antigravity, and Codex), claude.ai
|
|
26
|
+
``*.raw.json`` web dialogues, the already-rendered ``gemini-*.md`` reviews,
|
|
27
|
+
where ``provenance.redpen`` declares the archive, red-pen rounds, read as
|
|
28
|
+
the author's marks one turn each and named ``redpen-`` plus the file's name,
|
|
29
|
+
and, where ``provenance.rulings`` declares one, review-sheet rulings, read as
|
|
30
|
+
the author's ruling on each item one turn each, with what the sheet presented
|
|
31
|
+
as the text the turn answered, and named ``rulings-`` plus the file's name.
|
|
32
|
+
|
|
33
|
+
Every turn carries the timestamp the raw log recorded for it (already present in
|
|
34
|
+
the stored transcripts — cc ``timestamp``, agy/web ``created_at`` — so nothing
|
|
35
|
+
needed re-capturing), and the header states the session's span and duration.
|
|
36
|
+
Times render in the machine's local zone by default; ``--utc`` renders them
|
|
37
|
+
in UTC. The already-rendered ``gemini-*.md`` reviews carry no per-turn stamps,
|
|
38
|
+
so those pass through unstamped.
|
|
39
|
+
|
|
40
|
+
``--from`` and ``--to`` bound a time window and keep the turns logged inside
|
|
41
|
+
it, both ends included. A time needs no turn at it: ``--from 00:00`` starts at
|
|
42
|
+
the first turn of the day. Any stamp the transcript prints works as a bound,
|
|
43
|
+
and a bound covers the whole unit it is written to, so ``--to 15:52`` keeps a
|
|
44
|
+
turn at 15:52:59. A bare time takes the session's day; in a session that
|
|
45
|
+
crosses midnight it takes the day the session was running at that time. Only
|
|
46
|
+
a session logged over more than a day can be running at one time on two days;
|
|
47
|
+
there a typed time asks for its date, and the transcript prints the date on
|
|
48
|
+
every turn whose time alone would be ambiguous.
|
|
49
|
+
|
|
50
|
+
A bound is read in the zone the transcript is shown in, so a time copied from
|
|
51
|
+
another machine's transcript can name a different moment here. A bound that
|
|
52
|
+
ends in its own zone cannot: ``--from 16:45:26Z`` and ``--from
|
|
53
|
+
'2026-09-16 13:45:26-03:00'`` mean one moment everywhere, and a log's own
|
|
54
|
+
stamp, such as ``2026-09-16T16:45:26.123Z``, is read as the millisecond it
|
|
55
|
+
names.
|
|
56
|
+
|
|
57
|
+
The author's turns are numbered from 1, as ``quirework provenance search``
|
|
58
|
+
numbers them, so a hit's ``turn 14`` is ``## Human 14`` here and ``--turn 14``
|
|
59
|
+
opens it with the replies that follow, up to the author's next turn. A time
|
|
60
|
+
can be shared by turns logged in the same second and shifts with the zone; the
|
|
61
|
+
number does neither, and --role and --raw leave it alone.
|
|
62
|
+
|
|
63
|
+
(``--thinking`` is wired but usually inert. A thinking block's *text* is only
|
|
64
|
+
persisted when the session ran against a **direct API** (Vertex, or an Anthropic
|
|
65
|
+
API key); **subscription**-authenticated Claude Code sessions record the block
|
|
66
|
+
as a bare signature with no text, and that is most of this corpus. So
|
|
67
|
+
``--thinking`` recovers reasoning only for the handful of direct-API sessions.)
|
|
68
|
+
|
|
69
|
+
PRIVATE: like ``quirework provenance input``, the output is verbatim corpus text (the
|
|
70
|
+
firehose), so it stays inside the private workspace / ~/tmp and is never
|
|
71
|
+
promoted. Curated, short quotes are fine; the whole rendered session is not.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
from __future__ import annotations
|
|
75
|
+
|
|
76
|
+
import argparse
|
|
77
|
+
import json
|
|
78
|
+
import re
|
|
79
|
+
import sys
|
|
80
|
+
from datetime import date, datetime, time, timedelta, timezone
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
|
|
83
|
+
from quirework.project import current_project
|
|
84
|
+
from quirework.provenance import ccfilter as FLT
|
|
85
|
+
from quirework.provenance import rulings as RULINGS
|
|
86
|
+
from quirework.provenance import store
|
|
87
|
+
from quirework.provenance import codexfilter as CODEX_FLT
|
|
88
|
+
from quirework.provenance import catalog as CAT
|
|
89
|
+
from quirework.provenance.metrics import HARNESS
|
|
90
|
+
|
|
91
|
+
ROLE_LABEL = {
|
|
92
|
+
"human": "Human",
|
|
93
|
+
"assistant": "Assistant",
|
|
94
|
+
"agent": "Agent prompt (machine-written)",
|
|
95
|
+
"subagent": "Sub-agent (machine-written)",
|
|
96
|
+
"harness": "Harness context",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# The tool a Claude Code sub-agent hands its final report back through. The
|
|
100
|
+
# report is the sub-agent's own text, so `--raw` shows it with the sub-agent's
|
|
101
|
+
# prose rather than dropping it with the other tool calls.
|
|
102
|
+
_HANDBACK = "SubagentHandback"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _human_slot(text, raw):
|
|
106
|
+
"""``(role, text)`` for a message logged in the human's slot, or ``None``.
|
|
107
|
+
|
|
108
|
+
The harness writes into that slot too: slash-command wrappers, system
|
|
109
|
+
reminders, interrupt notices, and the report a background subagent sends
|
|
110
|
+
back when it finishes. A message with the author's prose in it is
|
|
111
|
+
``human``, with its wrappers stripped. One with none is dropped, or with
|
|
112
|
+
``raw`` kept as ``harness``, so it never reads as the author's words. With
|
|
113
|
+
``raw`` every message keeps its text exactly as logged.
|
|
114
|
+
"""
|
|
115
|
+
text = (text or "").strip()
|
|
116
|
+
cleaned = FLT.strip_wrappers(text)
|
|
117
|
+
if cleaned and not cleaned.startswith(HARNESS):
|
|
118
|
+
return "human", text if raw else cleaned
|
|
119
|
+
if raw and text:
|
|
120
|
+
return "harness", text
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# How an author turn reached a Claude Code log, in the order the reader
|
|
125
|
+
# learned each path. A reader that predates a path did not count its turns,
|
|
126
|
+
# so a turn's origin says which readers saw it: the answer to a question the
|
|
127
|
+
# model asked joined the author's turns first, then a prompt typed while the
|
|
128
|
+
# model worked, then a prompt logged as a list of blocks, which is how one
|
|
129
|
+
# sent with a pasted image is logged.
|
|
130
|
+
ORIGINS = ("typed", "answer", "queued", "listed")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _origin(turn, origin):
|
|
134
|
+
"""``origin`` for a ``_human_slot`` row in the author's role, else ``None``."""
|
|
135
|
+
return origin if turn[0] == "human" else None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _answers(content, asks, raw, ts):
|
|
139
|
+
"""``("human", text, ts)`` for each answer in a tool-result echo.
|
|
140
|
+
|
|
141
|
+
Only the result of a question the model asked, an id in ``asks``, is the
|
|
142
|
+
author's; every other tool result stays dropped as tool output.
|
|
143
|
+
"""
|
|
144
|
+
out = []
|
|
145
|
+
for b in content if isinstance(content, list) else ():
|
|
146
|
+
if not isinstance(b, dict) or b.get("type") != "tool_result":
|
|
147
|
+
continue
|
|
148
|
+
if b.get("tool_use_id") not in asks:
|
|
149
|
+
continue
|
|
150
|
+
text = FLT.answer_text(b, asks[b.get("tool_use_id")], raw=raw)
|
|
151
|
+
if text:
|
|
152
|
+
out.append(("human", text, ts))
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------- #
|
|
157
|
+
# turn extraction -> a flat list of (role, text), in conversation order
|
|
158
|
+
# --------------------------------------------------------------------------- #
|
|
159
|
+
def cc_records(path, *, thinking=False, raw=False, origins=False):
|
|
160
|
+
"""``(role, text, ts)`` turns from a cc-session JSONL.
|
|
161
|
+
|
|
162
|
+
``ts`` is the record's ISO-8601 ``timestamp`` (or ``None`` if absent).
|
|
163
|
+
Assistant messages collapse their content blocks into one turn: ``text``
|
|
164
|
+
blocks as prose, ``thinking`` blocks (only with ``thinking``) prefixed and
|
|
165
|
+
set off, ``tool_use`` blocks dropped. Human turns are the messages the
|
|
166
|
+
author typed: a string, or the text blocks of a prompt sent with a
|
|
167
|
+
pasted image, which the harness logs as a list (``ccfilter.typed_text``).
|
|
168
|
+
A list is otherwise a tool-result echo and is skipped, except the result
|
|
169
|
+
of a question the model asked with ``AskUserQuestion``, which is the
|
|
170
|
+
author's pick and so a human turn, as the capture and the Codex reader
|
|
171
|
+
already treat it, with the option previews the model wrote cut from it
|
|
172
|
+
(``ccfilter.answer_text``). Records the
|
|
173
|
+
harness flags as its own (a meta injection, the summary a compacted
|
|
174
|
+
session resumes from, or the notice of a failed request written in the
|
|
175
|
+
model's slot) and ``isSidechain`` (subagent side-threads) records are
|
|
176
|
+
dropped unless ``raw``, which keeps them under the role of whoever wrote
|
|
177
|
+
them: a flagged record is ``harness``, a side-thread's prompt is
|
|
178
|
+
``agent``, the task the parent handed its subagent, and what the subagent
|
|
179
|
+
wrote back, its prose and the report it hands back, is ``subagent``.
|
|
180
|
+
A prompt the author typed while the model was working is a human turn
|
|
181
|
+
too, though the harness logs it only as a queue operation, at the time
|
|
182
|
+
he typed it (``ccfilter.queued_prompt``). With ``origins`` each row
|
|
183
|
+
carries a fourth item: how a human turn reached the log (``ORIGINS``),
|
|
184
|
+
or ``None`` for any other row.
|
|
185
|
+
"""
|
|
186
|
+
out = []
|
|
187
|
+
asks = {} # each question the model asked the author: id -> its input
|
|
188
|
+
queued = {} # queued text -> when it was enqueued
|
|
189
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
190
|
+
for line in fh:
|
|
191
|
+
line = line.strip()
|
|
192
|
+
if not line:
|
|
193
|
+
continue
|
|
194
|
+
try:
|
|
195
|
+
o = json.loads(line)
|
|
196
|
+
except json.JSONDecodeError:
|
|
197
|
+
continue
|
|
198
|
+
typ = o.get("type")
|
|
199
|
+
prompt = FLT.queued_prompt(o, queued)
|
|
200
|
+
if prompt:
|
|
201
|
+
out.append(("human", prompt[0], prompt[1], "queued"))
|
|
202
|
+
continue
|
|
203
|
+
if typ not in ("user", "assistant"):
|
|
204
|
+
continue
|
|
205
|
+
if not raw and (FLT.harness_flagged(o) or o.get("isSidechain")):
|
|
206
|
+
continue
|
|
207
|
+
ts = o.get("timestamp")
|
|
208
|
+
content = (o.get("message") or {}).get("content")
|
|
209
|
+
if typ == "user":
|
|
210
|
+
if not isinstance(content, str):
|
|
211
|
+
# A tool-result echo, or a prompt sent with a pasted image.
|
|
212
|
+
if not (o.get("isSidechain") or FLT.harness_flagged(o)):
|
|
213
|
+
turn = _human_slot(FLT.typed_text(content, raw), raw)
|
|
214
|
+
if turn:
|
|
215
|
+
out.append((*turn, ts, _origin(turn, "listed")))
|
|
216
|
+
out.extend(
|
|
217
|
+
(*row, "answer") for row in _answers(content, asks, raw, ts)
|
|
218
|
+
)
|
|
219
|
+
continue
|
|
220
|
+
if o.get("isSidechain") or FLT.harness_flagged(o):
|
|
221
|
+
# Reached only with raw; neither was typed by the author.
|
|
222
|
+
# The harness writes into a subagent's slot as it does
|
|
223
|
+
# into the session's, so its flag wins over the thread's.
|
|
224
|
+
role = "harness" if FLT.harness_flagged(o) else "agent"
|
|
225
|
+
if content.strip():
|
|
226
|
+
out.append((role, content.strip(), ts, None))
|
|
227
|
+
continue
|
|
228
|
+
turn = _human_slot(content, raw)
|
|
229
|
+
if turn:
|
|
230
|
+
out.append((*turn, ts, _origin(turn, "typed")))
|
|
231
|
+
continue
|
|
232
|
+
# assistant: merge this message's blocks into one turn
|
|
233
|
+
if isinstance(content, str):
|
|
234
|
+
blocks = [{"type": "text", "text": content}]
|
|
235
|
+
elif isinstance(content, list):
|
|
236
|
+
blocks = content
|
|
237
|
+
else:
|
|
238
|
+
blocks = []
|
|
239
|
+
parts = []
|
|
240
|
+
for b in blocks:
|
|
241
|
+
if not isinstance(b, dict):
|
|
242
|
+
continue
|
|
243
|
+
bt = b.get("type")
|
|
244
|
+
if bt == "text":
|
|
245
|
+
t = (b.get("text") or "").strip()
|
|
246
|
+
if t:
|
|
247
|
+
parts.append(t)
|
|
248
|
+
elif bt == "thinking" and thinking:
|
|
249
|
+
t = (b.get("thinking") or b.get("text") or "").strip()
|
|
250
|
+
if t:
|
|
251
|
+
parts.append("[thinking]\n" + t)
|
|
252
|
+
elif bt == "tool_use" and b.get("name") == FLT.ASK_TOOL:
|
|
253
|
+
asks[b.get("id")] = b.get("input")
|
|
254
|
+
elif (
|
|
255
|
+
bt == "tool_use"
|
|
256
|
+
and b.get("name") == _HANDBACK
|
|
257
|
+
and o.get("isSidechain")
|
|
258
|
+
):
|
|
259
|
+
report = (b.get("input") or {}).get("message")
|
|
260
|
+
if isinstance(report, str) and report.strip():
|
|
261
|
+
parts.append(report.strip())
|
|
262
|
+
# other tool_use / tool_result: dropped
|
|
263
|
+
body = "\n\n".join(parts).strip()
|
|
264
|
+
if body:
|
|
265
|
+
# Reached flagged or side-thread only with raw: an error
|
|
266
|
+
# notice is the harness's, and a side-thread's reply is the
|
|
267
|
+
# subagent's, never the session's own assistant.
|
|
268
|
+
role = (
|
|
269
|
+
"harness"
|
|
270
|
+
if FLT.harness_flagged(o)
|
|
271
|
+
else "subagent"
|
|
272
|
+
if o.get("isSidechain")
|
|
273
|
+
else "assistant"
|
|
274
|
+
)
|
|
275
|
+
out.append((role, body, ts, None))
|
|
276
|
+
return out if origins else [row[:3] for row in out]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def web_records(path, *, thinking=False, raw=False, **_):
|
|
280
|
+
"""``(role, text, ts)`` turns from a claude.ai ``*.raw.json`` dialogue.
|
|
281
|
+
|
|
282
|
+
``ts`` is the message's ISO-8601 ``created_at`` (or ``None`` if absent).
|
|
283
|
+
"""
|
|
284
|
+
o = json.loads(Path(path).read_text(encoding="utf-8", errors="replace"))
|
|
285
|
+
out = []
|
|
286
|
+
for m in o.get("chat_messages", []):
|
|
287
|
+
sender = m.get("sender")
|
|
288
|
+
role = "human" if sender == "human" else "assistant"
|
|
289
|
+
blocks = m.get("content") or []
|
|
290
|
+
txt = " ".join(
|
|
291
|
+
b.get("text", "") for b in blocks if b.get("type") == "text"
|
|
292
|
+
) or m.get("text", "")
|
|
293
|
+
if role == "human":
|
|
294
|
+
turn = _human_slot(txt, raw)
|
|
295
|
+
if turn:
|
|
296
|
+
out.append((*turn, m.get("created_at")))
|
|
297
|
+
continue
|
|
298
|
+
txt = (txt or "").strip()
|
|
299
|
+
# Web captures DO persist thinking text (unlike most cc sessions), so
|
|
300
|
+
# this is the source where --thinking actually recovers content.
|
|
301
|
+
if thinking:
|
|
302
|
+
th = "\n\n".join(
|
|
303
|
+
(b.get("thinking") or b.get("text") or "").strip()
|
|
304
|
+
for b in blocks
|
|
305
|
+
if b.get("type") == "thinking"
|
|
306
|
+
if (b.get("thinking") or b.get("text") or "").strip()
|
|
307
|
+
)
|
|
308
|
+
if th:
|
|
309
|
+
txt = f"[thinking]\n{th}\n\n{txt}".strip()
|
|
310
|
+
if txt:
|
|
311
|
+
out.append((role, txt, m.get("created_at")))
|
|
312
|
+
return out
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def agy_records(path, *, thinking=False, raw=False, **_):
|
|
316
|
+
"""``(role, text, ts)`` turns from an Antigravity session JSONL.
|
|
317
|
+
|
|
318
|
+
``ts`` is the record's ISO-8601 ``created_at`` (or ``None`` if absent).
|
|
319
|
+
"""
|
|
320
|
+
out = []
|
|
321
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
322
|
+
for line in fh:
|
|
323
|
+
line = line.strip()
|
|
324
|
+
if not line:
|
|
325
|
+
continue
|
|
326
|
+
try:
|
|
327
|
+
o = json.loads(line)
|
|
328
|
+
except json.JSONDecodeError:
|
|
329
|
+
continue
|
|
330
|
+
typ = o.get("type")
|
|
331
|
+
ts = o.get("created_at")
|
|
332
|
+
if typ == "USER_INPUT":
|
|
333
|
+
c = o.get("content") or ""
|
|
334
|
+
m = re.search(r"<USER_REQUEST>\s*(.*?)\s*</USER_REQUEST>", c, re.DOTALL)
|
|
335
|
+
turn = _human_slot(m.group(1) if m else c, raw)
|
|
336
|
+
if turn:
|
|
337
|
+
out.append((*turn, ts))
|
|
338
|
+
elif typ == "PLANNER_RESPONSE":
|
|
339
|
+
parts = []
|
|
340
|
+
if thinking and o.get("thinking"):
|
|
341
|
+
parts.append("[thinking]\n" + o["thinking"].strip())
|
|
342
|
+
c = o.get("content")
|
|
343
|
+
if isinstance(c, str) and c.strip():
|
|
344
|
+
parts.append(c.strip())
|
|
345
|
+
body = "\n\n".join(parts).strip()
|
|
346
|
+
if body:
|
|
347
|
+
out.append(("assistant", body, ts))
|
|
348
|
+
return out
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def codex_records(path, *, thinking=False, raw=False, **_):
|
|
352
|
+
"""Dialogue from a Codex rollout, with pseudo-user records relabeled.
|
|
353
|
+
|
|
354
|
+
The injected AGENTS/environment message is omitted (or shown as
|
|
355
|
+
``harness`` under ``--raw``). A subagent's parent-written task is ``agent``,
|
|
356
|
+
never ``human``; that distinction is what keeps search/input evidentiary.
|
|
357
|
+
"""
|
|
358
|
+
return CODEX_FLT.records(path, thinking=thinking, raw=raw)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
_READERS = {
|
|
362
|
+
"cc": cc_records,
|
|
363
|
+
"agy": agy_records,
|
|
364
|
+
"codex": codex_records,
|
|
365
|
+
"web": web_records,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
# --------------------------------------------------------------------------- #
|
|
369
|
+
# red-pen rounds: the author's marks on a rendered copy of the book
|
|
370
|
+
# --------------------------------------------------------------------------- #
|
|
371
|
+
# A round archived by `publish redpen archive` is not a conversation, but every
|
|
372
|
+
# mark in it is the author's own words, typed on the page. It is read as a
|
|
373
|
+
# session of author turns so the tools that follow the author's words (the
|
|
374
|
+
# trail, its backfill) can reach them. The marks channel holds each comment,
|
|
375
|
+
# tracked edit and review-notes edit as a blank-line-separated entry; the
|
|
376
|
+
# verbatim channel after it repeats the review-notes blocks and is not read.
|
|
377
|
+
_REDPEN_MARKS = re.compile(r"^## MARKS\b.*$", re.M)
|
|
378
|
+
_REDPEN_END = re.compile(r"^## (?!MARKS\b)", re.M)
|
|
379
|
+
_REDPEN_DATED = re.compile(r"^marks-dated:\s*(\d{4}-\d{2}-\d{2})", re.M)
|
|
380
|
+
_REDPEN_BYLINE = re.compile(r"^\[[^\]\n]+\]\s+(\d{4}-\d{2}-\d{2})\b")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def is_redpen_round(path):
|
|
384
|
+
"""Whether ``path`` is an archived red-pen round rather than its README."""
|
|
385
|
+
try:
|
|
386
|
+
text = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
387
|
+
except OSError:
|
|
388
|
+
return False
|
|
389
|
+
return bool(_REDPEN_MARKS.search(text))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def redpen_marks(path):
|
|
393
|
+
"""``(text, day)`` for each mark in a red-pen round, in the archive's order.
|
|
394
|
+
|
|
395
|
+
``day`` is the date a comment carries in its byline, or the round's own
|
|
396
|
+
``marks-dated``; the archive records no time of day for any mark.
|
|
397
|
+
"""
|
|
398
|
+
text = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
399
|
+
start = _REDPEN_MARKS.search(text)
|
|
400
|
+
if not start:
|
|
401
|
+
return []
|
|
402
|
+
body = text[start.end() :]
|
|
403
|
+
end = _REDPEN_END.search(body)
|
|
404
|
+
body = body[: end.start()] if end else body
|
|
405
|
+
dated = _REDPEN_DATED.search(text)
|
|
406
|
+
round_day = date.fromisoformat(dated.group(1)) if dated else None
|
|
407
|
+
out = []
|
|
408
|
+
for block in re.split(r"\n\s*\n", body):
|
|
409
|
+
block = block.strip("\n")
|
|
410
|
+
if not block.strip() or block.lstrip().startswith("### "):
|
|
411
|
+
continue
|
|
412
|
+
byline = _REDPEN_BYLINE.match(block)
|
|
413
|
+
day = date.fromisoformat(byline.group(1)) if byline else round_day
|
|
414
|
+
out.append((block, day))
|
|
415
|
+
return out
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _redpen_title(path):
|
|
419
|
+
for line in Path(path).read_text(encoding="utf-8", errors="replace").splitlines():
|
|
420
|
+
if line.startswith("# "):
|
|
421
|
+
return line[2:].strip()
|
|
422
|
+
return Path(path).stem
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def author_turns(kind, path):
|
|
426
|
+
"""``(number, logged, text)`` for each of the author's turns in a session.
|
|
427
|
+
|
|
428
|
+
Numbered as ``render`` numbers them, and so as search does. ``logged`` is
|
|
429
|
+
the aware instant the log recorded, or ``None`` where it recorded none: a
|
|
430
|
+
pre-rendered gemini review never does, and its ``## You`` sections are the
|
|
431
|
+
turns, each counted even when empty.
|
|
432
|
+
"""
|
|
433
|
+
return [(n, logged, text) for n, logged, text, _ in exchanges(kind, path)]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def turn_origins(kind, path):
|
|
437
|
+
"""How each of the author's turns reached the log, in ``author_turns`` order.
|
|
438
|
+
|
|
439
|
+
One of ``ORIGINS``. They are told apart only in a Claude Code log, the
|
|
440
|
+
one source whose reader learned new paths after it was first used; every
|
|
441
|
+
turn of another source is ``typed``, a Codex answer included.
|
|
442
|
+
"""
|
|
443
|
+
if kind != "cc":
|
|
444
|
+
return ["typed"] * len(exchanges(kind, path))
|
|
445
|
+
return [row[3] for row in cc_records(path, origins=True) if row[0] == "human"]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def exchanges(kind, path):
|
|
449
|
+
"""``(number, logged, text, reply)``: each author turn and what it answered.
|
|
450
|
+
|
|
451
|
+
``reply`` is the model's text between the author's previous turn and this
|
|
452
|
+
one, the reply the turn was answering, or empty where there was none. It
|
|
453
|
+
is the context a bare "yes, go" needs before it means anything. The turns
|
|
454
|
+
are exactly ``author_turns``'s, which is built from this.
|
|
455
|
+
"""
|
|
456
|
+
if kind == "redpen":
|
|
457
|
+
return [
|
|
458
|
+
(n, None, text, "") for n, (text, _) in enumerate(redpen_marks(path), 1)
|
|
459
|
+
]
|
|
460
|
+
if kind == "rulings":
|
|
461
|
+
return [
|
|
462
|
+
(n, None, text, presented)
|
|
463
|
+
for n, (text, presented) in enumerate(RULINGS.read(path).turns(), 1)
|
|
464
|
+
]
|
|
465
|
+
if kind == "gemini":
|
|
466
|
+
lines = Path(path).read_text(encoding="utf-8", errors="replace").splitlines()
|
|
467
|
+
starts = [i for i, line in enumerate(lines) if _GEMINI_SPEAKER.match(line)]
|
|
468
|
+
ends = dict(zip(starts, starts[1:] + [len(lines)]))
|
|
469
|
+
out, number, reply = [], 0, []
|
|
470
|
+
for i in starts:
|
|
471
|
+
body = "\n".join(lines[i + 1 : ends[i]]).strip()
|
|
472
|
+
if _GEMINI_SPEAKER.match(lines[i]).group(1) == "You":
|
|
473
|
+
number += 1
|
|
474
|
+
out.append((number, None, body, "\n\n".join(reply)))
|
|
475
|
+
reply = []
|
|
476
|
+
elif body:
|
|
477
|
+
reply.append(body)
|
|
478
|
+
return out
|
|
479
|
+
out, number, reply = [], 0, []
|
|
480
|
+
for role, text, ts in _READERS[kind](path):
|
|
481
|
+
if role == "human":
|
|
482
|
+
number += 1
|
|
483
|
+
out.append((number, _parse_ts(ts), text, "\n\n".join(reply)))
|
|
484
|
+
reply = []
|
|
485
|
+
elif role == "assistant":
|
|
486
|
+
reply.append(text)
|
|
487
|
+
return out
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# --------------------------------------------------------------------------- #
|
|
491
|
+
# resolution: short id / full id / path -> (kind, path)
|
|
492
|
+
# --------------------------------------------------------------------------- #
|
|
493
|
+
def _jsonl_kind(p):
|
|
494
|
+
"""``cc`` / ``agy`` / ``codex`` by the store family holding it, or by shape."""
|
|
495
|
+
family = store.family_of(p)
|
|
496
|
+
if family in (store.AGY, store.CC, store.CODEX):
|
|
497
|
+
return store.KIND_OF[family]
|
|
498
|
+
with p.open(encoding="utf-8", errors="replace") as fh:
|
|
499
|
+
for line in fh:
|
|
500
|
+
line = line.strip()
|
|
501
|
+
if not line:
|
|
502
|
+
continue
|
|
503
|
+
try:
|
|
504
|
+
rec = json.loads(line)
|
|
505
|
+
except json.JSONDecodeError:
|
|
506
|
+
break
|
|
507
|
+
if isinstance(rec, dict) and "step_index" in rec:
|
|
508
|
+
return "agy"
|
|
509
|
+
if (
|
|
510
|
+
isinstance(rec, dict)
|
|
511
|
+
and rec.get("type") == "session_meta"
|
|
512
|
+
and isinstance(rec.get("payload"), dict)
|
|
513
|
+
):
|
|
514
|
+
return "codex"
|
|
515
|
+
return "cc"
|
|
516
|
+
return "cc"
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _candidates(idarg):
|
|
520
|
+
"""All (kind, path) whose id prefix-matches ``idarg``, across the sources."""
|
|
521
|
+
p = Path(idarg)
|
|
522
|
+
if p.is_file():
|
|
523
|
+
name = p.name
|
|
524
|
+
if name.endswith(".jsonl"):
|
|
525
|
+
# Both session corpora use `.jsonl`, so the suffix cannot say which
|
|
526
|
+
# reader to use -- the directory can. Read an agy transcript with the
|
|
527
|
+
# cc reader and it yields no turns at all: an empty transcript that
|
|
528
|
+
# looks like a session in which nothing was said.
|
|
529
|
+
return [(_jsonl_kind(p), p)]
|
|
530
|
+
if name.endswith(".raw.json"):
|
|
531
|
+
return [("web", p)]
|
|
532
|
+
if name.endswith(".md"):
|
|
533
|
+
redpen = _redpen_directory()
|
|
534
|
+
if redpen and p.resolve().parent == redpen.resolve():
|
|
535
|
+
return [("redpen", p)]
|
|
536
|
+
rulings = RULINGS.directory()
|
|
537
|
+
if rulings and p.resolve().parent == rulings.resolve():
|
|
538
|
+
return [("rulings", p)]
|
|
539
|
+
return [("gemini", p)]
|
|
540
|
+
return [
|
|
541
|
+
(kind, path)
|
|
542
|
+
for kind, path in sessions(pending=True)
|
|
543
|
+
if session_id(kind, path).startswith(idarg)
|
|
544
|
+
]
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# Each family's sessions as `show` names them. A web conversation's session is
|
|
548
|
+
# its raw export; a gemini review's is its rendered transcript.
|
|
549
|
+
_FAMILIES = (
|
|
550
|
+
("cc", store.CC, "*.jsonl"),
|
|
551
|
+
("agy", store.AGY, "*.jsonl"),
|
|
552
|
+
("codex", store.CODEX, "*.jsonl"),
|
|
553
|
+
("web", store.WEB, "*.raw.json"),
|
|
554
|
+
("gemini", store.WEB, "gemini-*.md"),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def sessions(pending=False):
|
|
559
|
+
"""Every session as ``(kind, path)``, family by family.
|
|
560
|
+
|
|
561
|
+
The accepted corpus, which is what search, metrics and the catalog read.
|
|
562
|
+
With ``pending``, a session captured since it was accepted is read at its
|
|
563
|
+
pending capture instead, and one never accepted joins: that is the view an
|
|
564
|
+
author reviewing a capture needs, and a trail following a turn made after
|
|
565
|
+
the last acceptance.
|
|
566
|
+
"""
|
|
567
|
+
found = []
|
|
568
|
+
for kind, family, pattern in _FAMILIES:
|
|
569
|
+
chosen = {p.name: p for p in store.files(family, pattern)}
|
|
570
|
+
if pending:
|
|
571
|
+
chosen.update({p.name: p for p in store.pending(family, pattern)})
|
|
572
|
+
found += [(kind, chosen[name]) for name in sorted(chosen)]
|
|
573
|
+
redpen = _redpen_directory()
|
|
574
|
+
if redpen is not None:
|
|
575
|
+
found += [
|
|
576
|
+
("redpen", f) for f in sorted(redpen.glob("*.md")) if is_redpen_round(f)
|
|
577
|
+
]
|
|
578
|
+
found += [("rulings", f) for f in RULINGS.rounds()]
|
|
579
|
+
return found
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _redpen_directory():
|
|
583
|
+
"""The red-pen archive, only where the project declares one.
|
|
584
|
+
|
|
585
|
+
The rounds are private and their author's, so they join the sessions
|
|
586
|
+
only by the project's explicit ``provenance.redpen``, never by default.
|
|
587
|
+
"""
|
|
588
|
+
project = current_project()
|
|
589
|
+
if project.get("provenance.redpen") is None:
|
|
590
|
+
return None
|
|
591
|
+
return project.path("provenance.redpen")
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
REDPEN_PREFIX = "redpen-"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def session_id(kind, path):
|
|
598
|
+
"""The full id a session is known by: its file name without the suffix.
|
|
599
|
+
|
|
600
|
+
A red-pen round is named for its archive file with a ``redpen-`` prefix,
|
|
601
|
+
and a review-sheet ruling with ``rulings-``, as a gemini review's file
|
|
602
|
+
already carries ``gemini-``, so the id says what it names wherever it is
|
|
603
|
+
printed.
|
|
604
|
+
"""
|
|
605
|
+
name = Path(path).name
|
|
606
|
+
if kind == "web":
|
|
607
|
+
return name[: -len(".raw.json")]
|
|
608
|
+
if kind == "redpen":
|
|
609
|
+
return REDPEN_PREFIX + Path(path).stem
|
|
610
|
+
if kind == "rulings":
|
|
611
|
+
return RULINGS.session_id(path)
|
|
612
|
+
return Path(path).stem
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def resolve(idarg):
|
|
616
|
+
"""``(kind, path)`` for an id, or raise ``LookupError`` (none / ambiguous)."""
|
|
617
|
+
found = _candidates(idarg)
|
|
618
|
+
if not found:
|
|
619
|
+
raise LookupError(f"no session matches '{idarg}'")
|
|
620
|
+
if len(found) > 1:
|
|
621
|
+
names = ", ".join(f"{k}:{pp.name}" for k, pp in found)
|
|
622
|
+
raise LookupError(f"'{idarg}' is ambiguous ({len(found)} matches): {names}")
|
|
623
|
+
return found[0]
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
# --------------------------------------------------------------------------- #
|
|
627
|
+
# rendering
|
|
628
|
+
# --------------------------------------------------------------------------- #
|
|
629
|
+
def session_title(kind, path):
|
|
630
|
+
"""A session's title as its transcript header shows it, best-effort."""
|
|
631
|
+
return _header(kind, Path(path))[1]
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _header(kind, path):
|
|
635
|
+
"""``(short_id, title, date, model)`` for the transcript header, best-effort."""
|
|
636
|
+
if kind == "cc":
|
|
637
|
+
meta = CAT._cc_scan(path)
|
|
638
|
+
return CAT._short_id(path.stem), meta["title"], meta["date"], meta["model"]
|
|
639
|
+
if kind == "agy":
|
|
640
|
+
meta = CAT._agy_scan(path)
|
|
641
|
+
return CAT._short_id(path.stem), meta["title"], meta["date"], meta["model"]
|
|
642
|
+
if kind == "codex":
|
|
643
|
+
meta = CODEX_FLT.scan(path)
|
|
644
|
+
return CAT._short_id(path.stem), meta["title"], meta["date"], meta["model"]
|
|
645
|
+
if kind == "web":
|
|
646
|
+
base = path.name[: -len(".raw.json")]
|
|
647
|
+
# Accepted together, the rendered transcript sits beside its export.
|
|
648
|
+
md = path.parent / (base + ".md")
|
|
649
|
+
if not md.exists():
|
|
650
|
+
md = store.newest(store.WEB, base + ".md") or md
|
|
651
|
+
meta = (
|
|
652
|
+
CAT._web_scan(md)
|
|
653
|
+
if md.exists()
|
|
654
|
+
else {"title": "(untitled)", "date": "?", "model": "?"}
|
|
655
|
+
)
|
|
656
|
+
return base, meta["title"], meta["date"], meta.get("model", "?")
|
|
657
|
+
if kind == "redpen":
|
|
658
|
+
day = session_date(kind, path)
|
|
659
|
+
return (
|
|
660
|
+
session_id(kind, path),
|
|
661
|
+
_redpen_title(path),
|
|
662
|
+
day.isoformat() if day else "?",
|
|
663
|
+
"red-pen round",
|
|
664
|
+
)
|
|
665
|
+
if kind == "rulings":
|
|
666
|
+
day = session_date(kind, path)
|
|
667
|
+
return (
|
|
668
|
+
session_id(kind, path),
|
|
669
|
+
RULINGS.read(path).title,
|
|
670
|
+
day.isoformat() if day else "?",
|
|
671
|
+
"review-sheet ruling",
|
|
672
|
+
)
|
|
673
|
+
meta = (
|
|
674
|
+
CAT._web_scan(path)
|
|
675
|
+
if path.exists()
|
|
676
|
+
else {"title": "(untitled)", "date": "?", "model": "?"}
|
|
677
|
+
)
|
|
678
|
+
return path.stem, meta["title"], meta["date"], meta.get("model", "?")
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _parse_ts(ts):
|
|
682
|
+
"""ISO-8601 stamp (``Z`` or offset) -> aware ``datetime``, or ``None``."""
|
|
683
|
+
if not ts:
|
|
684
|
+
return None
|
|
685
|
+
try:
|
|
686
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
687
|
+
except (ValueError, AttributeError):
|
|
688
|
+
return None
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _fmt_dur(seconds):
|
|
692
|
+
"""Human-compact duration: ``42s`` / ``6m21s`` / ``2h05m``."""
|
|
693
|
+
seconds = int(seconds)
|
|
694
|
+
h, rem = divmod(seconds, 3600)
|
|
695
|
+
m, s = divmod(rem, 60)
|
|
696
|
+
if h:
|
|
697
|
+
return f"{h}h{m:02d}m"
|
|
698
|
+
if m:
|
|
699
|
+
return f"{m}m{s:02d}s"
|
|
700
|
+
return f"{s}s"
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _local(dt, tz):
|
|
704
|
+
"""``dt`` in the zone ``tz``, or the machine's local zone when it is ``None``."""
|
|
705
|
+
return dt.astimezone(tz)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def session_start(kind, path):
|
|
709
|
+
"""When a session's first turn was logged, as the transcript header gives it.
|
|
710
|
+
|
|
711
|
+
``None`` for a pre-rendered gemini review, or a log that records no times.
|
|
712
|
+
"""
|
|
713
|
+
if kind not in _READERS:
|
|
714
|
+
return None
|
|
715
|
+
dts = [dt for dt in (_parse_ts(ts) for _, _, ts in _READERS[kind](path)) if dt]
|
|
716
|
+
return min(dts) if dts else None
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def session_date(kind, path):
|
|
720
|
+
"""The UTC day a session began, or ``None`` where nothing records one.
|
|
721
|
+
|
|
722
|
+
A log's first logged time decides it. A pre-rendered gemini review logs no
|
|
723
|
+
times, but its header carries the date the author supplied at capture; a
|
|
724
|
+
red-pen round carries the date of its marks. Those dates are how a
|
|
725
|
+
timeless session takes its place in date order instead of sorting first.
|
|
726
|
+
"""
|
|
727
|
+
start = session_start(kind, path)
|
|
728
|
+
if start is not None:
|
|
729
|
+
return start.astimezone(timezone.utc).date()
|
|
730
|
+
if kind == "redpen":
|
|
731
|
+
days = [day for _, day in redpen_marks(path) if day]
|
|
732
|
+
return min(days) if days else None
|
|
733
|
+
if kind == "rulings":
|
|
734
|
+
return RULINGS.read(path).day()
|
|
735
|
+
if kind == "gemini":
|
|
736
|
+
written = CAT._web_scan(Path(path)).get("date") or ""
|
|
737
|
+
try:
|
|
738
|
+
return date.fromisoformat(written[:10])
|
|
739
|
+
except ValueError:
|
|
740
|
+
return None
|
|
741
|
+
return None
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def shown_date(dt, utc=False):
|
|
745
|
+
"""``dt``'s calendar date in the zone a transcript is shown in."""
|
|
746
|
+
return _local(dt, timezone.utc if utc else None).strftime("%Y-%m-%d")
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _span_line(dts, date, model, nturns, tz):
|
|
750
|
+
"""The header meta line: time span + duration + model + turn count.
|
|
751
|
+
|
|
752
|
+
Falls back to the bare ``(date)`` the catalog scan found when no turn
|
|
753
|
+
carried a parseable timestamp (e.g. a gemini review or a stampless log).
|
|
754
|
+
"""
|
|
755
|
+
tail = f"{model} · {nturns} turns"
|
|
756
|
+
if not dts:
|
|
757
|
+
return f"({date}) · {tail}"
|
|
758
|
+
lo, hi = _local(min(dts), tz), _local(max(dts), tz)
|
|
759
|
+
zone = lo.strftime("%Z") or ("UTC" if tz else "local")
|
|
760
|
+
start = lo.strftime("%Y-%m-%d %H:%M")
|
|
761
|
+
end = hi.strftime("%H:%M" if lo.date() == hi.date() else "%Y-%m-%d %H:%M")
|
|
762
|
+
dur = _fmt_dur((hi - lo).total_seconds())
|
|
763
|
+
return f"{start} → {end} {zone} · {dur} · {tail}"
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _stamp_rows(shown, tz, session):
|
|
767
|
+
"""``(role, text, dt, stamp, number)`` rows from ``(role, text, dt, number)``.
|
|
768
|
+
|
|
769
|
+
The date is printed when it rolls over from the prior turn, and wherever
|
|
770
|
+
the time alone could fall on another day of ``session`` too, which happens
|
|
771
|
+
only in a session logged over more than a day. So every printed stamp read
|
|
772
|
+
back as ``--from`` / ``--to`` names its own turn. Stamps are computed over
|
|
773
|
+
the whole shown list before any time window is applied, so a turn prints
|
|
774
|
+
the same stamp however the window is cut.
|
|
775
|
+
"""
|
|
776
|
+
rows, prev_day = [], None
|
|
777
|
+
for r, text, dt, number in shown:
|
|
778
|
+
stamp = ""
|
|
779
|
+
if dt is not None:
|
|
780
|
+
loc = _local(dt, tz)
|
|
781
|
+
day = loc.strftime("%Y-%m-%d")
|
|
782
|
+
clock = loc.time().replace(microsecond=0)
|
|
783
|
+
bare = (
|
|
784
|
+
day == prev_day and len(_bare_spans(clock, _SECOND, tz, session)) == 1
|
|
785
|
+
)
|
|
786
|
+
stamp = loc.strftime("%H:%M:%S" if bare else "%Y-%m-%d %H:%M:%S")
|
|
787
|
+
prev_day = day
|
|
788
|
+
rows.append((r, text, dt, stamp, number))
|
|
789
|
+
return rows
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
_SECOND = timedelta(seconds=1)
|
|
793
|
+
|
|
794
|
+
# A mark: a date, a time, or a date and a time; a time may name its zone.
|
|
795
|
+
_MARK = re.compile(
|
|
796
|
+
r"(?:(?P<date>\d{4}-\d{2}-\d{2})(?:[ T](?=\d)|$))?"
|
|
797
|
+
r"(?:(?P<hour>\d{1,2}):(?P<minute>\d{2})"
|
|
798
|
+
r"(?::(?P<second>\d{2})(?:\.(?P<fraction>\d{1,6}))?)?"
|
|
799
|
+
r"(?P<zone>Z|[+-]\d{2}:?\d{2})?)?"
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _offset(zone):
|
|
804
|
+
"""The fixed zone a mark names: ``Z`` or ``+HH:MM`` / ``-HHMM``."""
|
|
805
|
+
if zone == "Z":
|
|
806
|
+
return timezone.utc
|
|
807
|
+
sign = -1 if zone[0] == "-" else 1
|
|
808
|
+
digits = zone[1:].replace(":", "")
|
|
809
|
+
return timezone(sign * timedelta(hours=int(digits[:2]), minutes=int(digits[2:])))
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _zoned(naive, tz):
|
|
813
|
+
"""A wall-clock ``naive`` datetime read in ``tz``, or in the machine's zone."""
|
|
814
|
+
return naive.astimezone() if tz is None else naive.replace(tzinfo=tz)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _days(session, tz):
|
|
818
|
+
"""The calendar days, in ``tz``, that ``session`` was logged on."""
|
|
819
|
+
first, last = (_local(dt, tz).date() for dt in session)
|
|
820
|
+
return [first + timedelta(days=n) for n in range((last - first).days + 1)]
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _bare_spans(clock, unit, tz, session):
|
|
824
|
+
"""``(day, start, end)`` for each day a time without a date could name.
|
|
825
|
+
|
|
826
|
+
``session`` is the ``(first, last)`` instants the whole session was logged
|
|
827
|
+
at, and days are counted in ``tz``. A session on one day offers that day.
|
|
828
|
+
Across midnight, a day qualifies only if the session was running at
|
|
829
|
+
``clock`` on it, so more than one qualifies only in a session logged over
|
|
830
|
+
more than a day.
|
|
831
|
+
"""
|
|
832
|
+
spans = []
|
|
833
|
+
for day in _days(session, tz):
|
|
834
|
+
start = datetime.combine(day, clock)
|
|
835
|
+
spans.append((day, _zoned(start, tz), _zoned(start + unit, tz)))
|
|
836
|
+
if len(spans) > 1:
|
|
837
|
+
spans = [(d, s, e) for d, s, e in spans if s <= session[1] and e > session[0]]
|
|
838
|
+
return spans
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _mark_span(flag, mark, tz, session):
|
|
842
|
+
"""``(start, end)``: the aware instants a ``--from`` / ``--to`` mark covers.
|
|
843
|
+
|
|
844
|
+
A mark covers the whole unit it is written to, so ``15:52`` is the minute
|
|
845
|
+
up to ``15:52:59``, ``2026-09-16`` is the day, and a log's own stamp such
|
|
846
|
+
as ``2026-09-16T15:52:10.123Z`` is its millisecond. It is read in ``tz``,
|
|
847
|
+
the zone the stamps are shown in, unless it names its own with ``Z`` or
|
|
848
|
+
``+HH:MM``; then it means the same moment on any machine. A bare time takes
|
|
849
|
+
its date from ``session`` as ``_bare_spans`` describes, counting days in
|
|
850
|
+
the mark's zone. Raises ``LookupError`` when the mark is not a time, or
|
|
851
|
+
when a bare time fits no single day.
|
|
852
|
+
"""
|
|
853
|
+
m = _MARK.fullmatch(mark.strip())
|
|
854
|
+
try:
|
|
855
|
+
if not m or not (m["date"] or m["hour"]):
|
|
856
|
+
raise ValueError
|
|
857
|
+
day = date.fromisoformat(m["date"]) if m["date"] else None
|
|
858
|
+
fraction = m["fraction"] or ""
|
|
859
|
+
clock = time(
|
|
860
|
+
int(m["hour"] or 0),
|
|
861
|
+
int(m["minute"] or 0),
|
|
862
|
+
int(m["second"] or 0),
|
|
863
|
+
int(fraction.ljust(6, "0")),
|
|
864
|
+
)
|
|
865
|
+
if m["zone"]:
|
|
866
|
+
tz = _offset(m["zone"])
|
|
867
|
+
except ValueError:
|
|
868
|
+
raise LookupError(
|
|
869
|
+
f"{flag} {mark!r} is not a time; write HH:MM[:SS], optionally "
|
|
870
|
+
"after a date as 'YYYY-MM-DD HH:MM[:SS]' and before a zone as "
|
|
871
|
+
"Z or +HH:MM"
|
|
872
|
+
) from None
|
|
873
|
+
if fraction:
|
|
874
|
+
unit = timedelta(microseconds=10 ** (6 - len(fraction)))
|
|
875
|
+
elif m["second"]:
|
|
876
|
+
unit = _SECOND
|
|
877
|
+
elif m["hour"]:
|
|
878
|
+
unit = timedelta(minutes=1)
|
|
879
|
+
else:
|
|
880
|
+
unit = timedelta(days=1)
|
|
881
|
+
if day:
|
|
882
|
+
start = datetime.combine(day, clock)
|
|
883
|
+
return _zoned(start, tz), _zoned(start + unit, tz)
|
|
884
|
+
|
|
885
|
+
spans = _bare_spans(clock, unit, tz, session)
|
|
886
|
+
if len(spans) != 1:
|
|
887
|
+
# None qualifies when the session was not running at that time on any
|
|
888
|
+
# of its days; then each of them is as good a guess as the other.
|
|
889
|
+
days = [d for d, _, _ in spans] or _days(session, tz)
|
|
890
|
+
first, last = (_local(dt, tz) for dt in session)
|
|
891
|
+
options = " or ".join(f"'{d:%Y-%m-%d} {mark.strip()}'" for d in days)
|
|
892
|
+
raise LookupError(
|
|
893
|
+
f"{flag} {mark!r} could fall on more than one day of a session "
|
|
894
|
+
f"logged from {first:%Y-%m-%d %H:%M:%S} to "
|
|
895
|
+
f"{last:%Y-%m-%d %H:%M:%S}; put the date in front: {options}"
|
|
896
|
+
)
|
|
897
|
+
return spans[0][1:]
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _window(rows, frm, to, tz, session):
|
|
901
|
+
"""The ``rows`` whose logged time falls between ``--from`` and ``--to``.
|
|
902
|
+
|
|
903
|
+
Both bounds are inclusive. Returns ``(kept, unstamped)``, where
|
|
904
|
+
``unstamped`` counts shown turns left out because no time was logged for
|
|
905
|
+
them. Raises ``LookupError`` if the session has no times at all, if the
|
|
906
|
+
bounds cross, or if no shown turn falls inside them.
|
|
907
|
+
"""
|
|
908
|
+
if session is None:
|
|
909
|
+
raise LookupError("--from/--to need per-turn timestamps; this log has none")
|
|
910
|
+
lo = _mark_span("--from", frm, tz, session)[0] if frm else None
|
|
911
|
+
hi = _mark_span("--to", to, tz, session)[1] if to else None
|
|
912
|
+
if lo is not None and hi is not None and lo >= hi:
|
|
913
|
+
raise LookupError(f"--from {frm!r} falls after --to {to!r} (empty range)")
|
|
914
|
+
kept = [
|
|
915
|
+
row
|
|
916
|
+
for row in rows
|
|
917
|
+
if row[2] is not None
|
|
918
|
+
and (lo is None or row[2] >= lo)
|
|
919
|
+
and (hi is None or row[2] < hi)
|
|
920
|
+
]
|
|
921
|
+
if not kept:
|
|
922
|
+
bounds = (f" from {frm!r}" if frm else "") + (f" to {to!r}" if to else "")
|
|
923
|
+
raise LookupError(f"no shown turn falls in the window{bounds}")
|
|
924
|
+
return kept, sum(1 for row in rows if row[2] is None)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _turn_range(spec, count):
|
|
928
|
+
"""``(first, last)`` author-turn numbers named by ``--turn N`` or ``N-M``.
|
|
929
|
+
|
|
930
|
+
Raises ``LookupError`` unless the numbers are a range within the
|
|
931
|
+
``count`` turns the author took in the session.
|
|
932
|
+
"""
|
|
933
|
+
m = re.fullmatch(r"\s*(\d+)\s*(?:-\s*(\d+)\s*)?", spec)
|
|
934
|
+
if not m:
|
|
935
|
+
raise LookupError(f"--turn {spec!r} is not a turn number; write N or N-M")
|
|
936
|
+
first, last = int(m.group(1)), int(m.group(2) or m.group(1))
|
|
937
|
+
if first < 1 or last < first:
|
|
938
|
+
raise LookupError(f"--turn {spec!r} is not a range of turns counted from 1")
|
|
939
|
+
if last > count:
|
|
940
|
+
took = f"{count} turn{'' if count == 1 else 's'}" if count else "no turns"
|
|
941
|
+
raise LookupError(f"--turn {spec!r}: the author took {took} in this session")
|
|
942
|
+
return first, last
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
# A gemini review's speaker lines; its replies carry `## ` headings of their own.
|
|
946
|
+
_GEMINI_SPEAKER = re.compile(r"^##\s+(You|Gemini)\s+·")
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _gemini_turns(body, spec):
|
|
950
|
+
"""The lines of a pre-rendered gemini review from ``--turn`` ``spec``.
|
|
951
|
+
|
|
952
|
+
The author's turns are its ``## You`` sections, numbered as ``search``
|
|
953
|
+
numbers them: every section counts, even one with nothing left to say.
|
|
954
|
+
"""
|
|
955
|
+
lines = body.splitlines()
|
|
956
|
+
starts = [i for i, line in enumerate(lines) if _GEMINI_SPEAKER.match(line)]
|
|
957
|
+
yours = [i for i in starts if _GEMINI_SPEAKER.match(lines[i]).group(1) == "You"]
|
|
958
|
+
first, last = _turn_range(spec, len(yours))
|
|
959
|
+
end = yours[last] if last < len(yours) else len(lines)
|
|
960
|
+
return "\n".join(lines[yours[first - 1] : end]).rstrip()
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
_GEMINI_ROLE = {"human": "You", "assistant": "Gemini"}
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _gemini_role(body, role):
|
|
967
|
+
"""Only the speaker sections of a gemini review that ``role`` names.
|
|
968
|
+
|
|
969
|
+
The review is pre-rendered, so its sections are its speakers: ``## You``
|
|
970
|
+
is the author and ``## Gemini`` the model. It holds nothing an agent or
|
|
971
|
+
the harness wrote, so those roles leave it empty.
|
|
972
|
+
"""
|
|
973
|
+
speaker = _GEMINI_ROLE.get(role)
|
|
974
|
+
lines = body.splitlines()
|
|
975
|
+
starts = [i for i, line in enumerate(lines) if _GEMINI_SPEAKER.match(line)]
|
|
976
|
+
kept = []
|
|
977
|
+
for i, end in zip(starts, starts[1:] + [len(lines)]):
|
|
978
|
+
if speaker and _GEMINI_SPEAKER.match(lines[i]).group(1) == speaker:
|
|
979
|
+
kept += lines[i:end]
|
|
980
|
+
return "\n".join(kept).rstrip()
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _source(path):
|
|
984
|
+
"""The source line, which says when the capture has not been accepted."""
|
|
985
|
+
pending = store.area(path) == "pending"
|
|
986
|
+
return f"<source: {Path(path).name}{' · pending, not accepted' if pending else ''}>"
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def render(
|
|
990
|
+
kind,
|
|
991
|
+
path,
|
|
992
|
+
*,
|
|
993
|
+
role="all",
|
|
994
|
+
thinking=False,
|
|
995
|
+
raw=False,
|
|
996
|
+
utc=False,
|
|
997
|
+
frm=None,
|
|
998
|
+
to=None,
|
|
999
|
+
turn=None,
|
|
1000
|
+
):
|
|
1001
|
+
"""The full transcript as one string."""
|
|
1002
|
+
if turn and (frm or to):
|
|
1003
|
+
raise LookupError("--turn names turns by number; drop --from/--to")
|
|
1004
|
+
sid, title, date, model = _header(kind, path)
|
|
1005
|
+
scope = f"turn{'s' * ('-' in turn)} {turn.strip()}" if turn else None
|
|
1006
|
+
|
|
1007
|
+
if kind == "gemini":
|
|
1008
|
+
if frm or to:
|
|
1009
|
+
raise LookupError(
|
|
1010
|
+
"--from/--to need per-turn timestamps; the gemini "
|
|
1011
|
+
"reviews are pre-rendered and carry none"
|
|
1012
|
+
)
|
|
1013
|
+
# The gemini .md is already a rendered transcript; pass it through.
|
|
1014
|
+
body = Path(path).read_text(encoding="utf-8", errors="replace").rstrip()
|
|
1015
|
+
head = f"# {sid} — {title} ({date})"
|
|
1016
|
+
if turn:
|
|
1017
|
+
body = _gemini_turns(body, turn)
|
|
1018
|
+
head += f" · {scope}"
|
|
1019
|
+
if role != "all":
|
|
1020
|
+
body = _gemini_role(body, role)
|
|
1021
|
+
return "\n".join([head, _source(path), "", body]) + "\n"
|
|
1022
|
+
|
|
1023
|
+
if kind == "redpen":
|
|
1024
|
+
if frm or to:
|
|
1025
|
+
raise LookupError(
|
|
1026
|
+
"--from/--to need per-turn timestamps; a red-pen round "
|
|
1027
|
+
"records only the day of each mark"
|
|
1028
|
+
)
|
|
1029
|
+
marks = redpen_marks(path)
|
|
1030
|
+
first, last = _turn_range(turn, len(marks)) if turn else (1, len(marks))
|
|
1031
|
+
head = f"# {sid} — {title} ({date})" + (f" · {scope}" if turn else "")
|
|
1032
|
+
lines = [head, _source(path), ""]
|
|
1033
|
+
if role in ("all", "human"):
|
|
1034
|
+
for n, (text, day) in enumerate(marks, 1):
|
|
1035
|
+
if first <= n <= last:
|
|
1036
|
+
stamp = f" · {day.isoformat()}" if day else ""
|
|
1037
|
+
lines += [f"## {ROLE_LABEL['human']} {n}{stamp}", "", text, ""]
|
|
1038
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
1039
|
+
|
|
1040
|
+
if kind == "rulings":
|
|
1041
|
+
if frm or to:
|
|
1042
|
+
raise LookupError(
|
|
1043
|
+
"--from/--to need per-turn timestamps; a review-sheet ruling "
|
|
1044
|
+
"records one time, when it was marked final"
|
|
1045
|
+
)
|
|
1046
|
+
ruling = RULINGS.read(path)
|
|
1047
|
+
rows = exchanges(kind, path)
|
|
1048
|
+
first, last = _turn_range(turn, len(rows)) if turn else (1, len(rows))
|
|
1049
|
+
head = f"# {sid} — {title} ({date})" + (f" · {scope}" if turn else "")
|
|
1050
|
+
moment = ruling.instant()
|
|
1051
|
+
ruled = (
|
|
1052
|
+
f"ruled {_local(moment, timezone.utc if utc else None):%Y-%m-%d %H:%M %Z}"
|
|
1053
|
+
if moment
|
|
1054
|
+
else "ruled at an unrecorded time"
|
|
1055
|
+
)
|
|
1056
|
+
lines = [head, f"{model} · {ruled} · {len(rows)} turns", _source(path), ""]
|
|
1057
|
+
labels = [item.label for item in ruling.items] + ["notes"]
|
|
1058
|
+
for n, _logged, text, presented in rows:
|
|
1059
|
+
if not first <= n <= last:
|
|
1060
|
+
continue
|
|
1061
|
+
if presented and role in ("all", "assistant"):
|
|
1062
|
+
lines += [f"## Sheet · {labels[n - 1]}", "", presented, ""]
|
|
1063
|
+
if role in ("all", "human"):
|
|
1064
|
+
lines += [
|
|
1065
|
+
f"## {ROLE_LABEL['human']} {n} · {labels[n - 1]}",
|
|
1066
|
+
"",
|
|
1067
|
+
text,
|
|
1068
|
+
"",
|
|
1069
|
+
]
|
|
1070
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
1071
|
+
|
|
1072
|
+
# Each turn carries the number of the author's latest turn at or before
|
|
1073
|
+
# it: their own on an author's turn, and theirs on the replies that follow.
|
|
1074
|
+
# Counted over the whole session before --role, it is search's numbering.
|
|
1075
|
+
records, number = [], 0
|
|
1076
|
+
for r, text, ts in _READERS[kind](path, thinking=thinking, raw=raw):
|
|
1077
|
+
number += r == "human"
|
|
1078
|
+
records.append((r, text, _parse_ts(ts), number))
|
|
1079
|
+
shown = [rec for rec in records if role == "all" or rec[0] == role]
|
|
1080
|
+
# A bare time picks its day from the whole session, never from the shown
|
|
1081
|
+
# turns, so --role cannot change what a time means or how it is printed.
|
|
1082
|
+
logged = [rec[2] for rec in records if rec[2]]
|
|
1083
|
+
session = (min(logged), max(logged)) if logged else None
|
|
1084
|
+
tz = timezone.utc if utc else None
|
|
1085
|
+
rows = _stamp_rows(shown, tz, session)
|
|
1086
|
+
if frm or to:
|
|
1087
|
+
rows, unstamped = _window(rows, frm, to, tz, session)
|
|
1088
|
+
if turn:
|
|
1089
|
+
first, last = _turn_range(turn, number)
|
|
1090
|
+
rows = [row for row in rows if first <= row[4] <= last]
|
|
1091
|
+
if not rows:
|
|
1092
|
+
raise LookupError(f"--turn {turn!r} holds no {role} turn")
|
|
1093
|
+
dts = [row[2] for row in rows if row[2]]
|
|
1094
|
+
|
|
1095
|
+
span = _span_line(dts, date, model, len(rows), tz)
|
|
1096
|
+
if frm or to:
|
|
1097
|
+
span += " · slice"
|
|
1098
|
+
if unstamped:
|
|
1099
|
+
noun = "turn" if unstamped == 1 else "turns"
|
|
1100
|
+
span += f" ({unstamped} {noun} with no time left out)"
|
|
1101
|
+
if turn:
|
|
1102
|
+
span += f" · {scope}"
|
|
1103
|
+
lines = [f"# {sid} — {title}", span, _source(path), ""]
|
|
1104
|
+
for r, text, _dt, stamp, n in rows:
|
|
1105
|
+
label = f"{ROLE_LABEL[r]} {n}" if r == "human" else ROLE_LABEL[r]
|
|
1106
|
+
lines.append(f"## {label}" + (f" · {stamp}" if stamp else ""))
|
|
1107
|
+
lines.append("")
|
|
1108
|
+
lines.append(text)
|
|
1109
|
+
lines.append("")
|
|
1110
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def main(argv=None):
|
|
1114
|
+
ap = argparse.ArgumentParser(
|
|
1115
|
+
prog="quirework provenance show",
|
|
1116
|
+
description="Render a provenance session as a clean transcript.",
|
|
1117
|
+
)
|
|
1118
|
+
ap.add_argument("session", help="short id, full id, gemini-… name, or path")
|
|
1119
|
+
ap.add_argument(
|
|
1120
|
+
"--role",
|
|
1121
|
+
choices=["all", "human", "assistant", "agent", "subagent", "harness"],
|
|
1122
|
+
default="all",
|
|
1123
|
+
help="restrict to one writer (default: all): human is the author; "
|
|
1124
|
+
"assistant is the model's reply; agent is a task one model wrote "
|
|
1125
|
+
"for a subagent, and subagent is what the subagent wrote back; "
|
|
1126
|
+
"harness is text the tool injected; agent, subagent and harness are "
|
|
1127
|
+
"shown only with --raw",
|
|
1128
|
+
)
|
|
1129
|
+
ap.add_argument(
|
|
1130
|
+
"--thinking",
|
|
1131
|
+
action="store_true",
|
|
1132
|
+
help="include assistant thinking where the capture holds its "
|
|
1133
|
+
"text: web captures and agy sessions do; cc sessions "
|
|
1134
|
+
"only for direct-API runs (subscription ones persist "
|
|
1135
|
+
"an opaque signature, so there it is usually empty)",
|
|
1136
|
+
)
|
|
1137
|
+
ap.add_argument(
|
|
1138
|
+
"--raw",
|
|
1139
|
+
action="store_true",
|
|
1140
|
+
help="keep the session as logged: harness wrappers stay on human "
|
|
1141
|
+
"turns, and injected text and subagent threads appear under the "
|
|
1142
|
+
"harness and agent roles",
|
|
1143
|
+
)
|
|
1144
|
+
ap.add_argument(
|
|
1145
|
+
"--utc",
|
|
1146
|
+
action="store_true",
|
|
1147
|
+
help="stamp turns in UTC (default: the machine's local zone)",
|
|
1148
|
+
)
|
|
1149
|
+
ap.add_argument(
|
|
1150
|
+
"--from",
|
|
1151
|
+
dest="frm",
|
|
1152
|
+
metavar="TIME",
|
|
1153
|
+
default=None,
|
|
1154
|
+
help="show only turns logged at or after TIME: HH:MM[:SS], or "
|
|
1155
|
+
"'YYYY-MM-DD HH:MM[:SS]' when the session crosses midnight. TIME "
|
|
1156
|
+
"is read in the zone the turns are stamped in, so pair it with "
|
|
1157
|
+
"the same --utc setting, unless it ends in its own zone as Z or "
|
|
1158
|
+
"+HH:MM (as in --from 16:45:26Z); then it means the same moment "
|
|
1159
|
+
"on any machine",
|
|
1160
|
+
)
|
|
1161
|
+
ap.add_argument(
|
|
1162
|
+
"--to",
|
|
1163
|
+
dest="to",
|
|
1164
|
+
metavar="TIME",
|
|
1165
|
+
default=None,
|
|
1166
|
+
help="show only turns logged at or before TIME, which covers its "
|
|
1167
|
+
"whole last unit: --to 15:52 keeps a turn at 15:52:59",
|
|
1168
|
+
)
|
|
1169
|
+
ap.add_argument(
|
|
1170
|
+
"--turn",
|
|
1171
|
+
metavar="N[-M]",
|
|
1172
|
+
default=None,
|
|
1173
|
+
help="show the author's turn N, numbered from 1 as search numbers "
|
|
1174
|
+
"it, with the replies up to their next turn; N-M shows a run of "
|
|
1175
|
+
"them. Not combined with --from/--to",
|
|
1176
|
+
)
|
|
1177
|
+
ap.add_argument(
|
|
1178
|
+
"--out", type=Path, default=None, help="write to a file instead of stdout"
|
|
1179
|
+
)
|
|
1180
|
+
args = ap.parse_args(argv)
|
|
1181
|
+
|
|
1182
|
+
try:
|
|
1183
|
+
kind, path = resolve(args.session)
|
|
1184
|
+
except LookupError as e:
|
|
1185
|
+
print(str(e), file=sys.stderr)
|
|
1186
|
+
return 2
|
|
1187
|
+
|
|
1188
|
+
try:
|
|
1189
|
+
text = render(
|
|
1190
|
+
kind,
|
|
1191
|
+
path,
|
|
1192
|
+
role=args.role,
|
|
1193
|
+
thinking=args.thinking,
|
|
1194
|
+
raw=args.raw,
|
|
1195
|
+
utc=args.utc,
|
|
1196
|
+
frm=args.frm,
|
|
1197
|
+
to=args.to,
|
|
1198
|
+
turn=args.turn,
|
|
1199
|
+
)
|
|
1200
|
+
except LookupError as e:
|
|
1201
|
+
print(str(e), file=sys.stderr)
|
|
1202
|
+
return 2
|
|
1203
|
+
if args.out:
|
|
1204
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
1205
|
+
args.out.write_text(text, encoding="utf-8")
|
|
1206
|
+
print(f"wrote {args.out}", file=sys.stderr)
|
|
1207
|
+
else:
|
|
1208
|
+
sys.stdout.write(text)
|
|
1209
|
+
return 0
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
if __name__ == "__main__":
|
|
1213
|
+
sys.exit(main(sys.argv[1:]))
|