chsum 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.4
2
+ Name: chsum
3
+ Version: 1.0.0
4
+ Summary: Work logs and reload-ready context from Claude Code conversations. Deterministic: no model, nothing invented.
5
+ Author: Joshua
6
+ License: MIT
7
+ Keywords: claude,claude-code,context,transcripts,work-log
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+
11
+ # chsum
12
+
13
+ Work logs and reload-ready context from your Claude Code conversations.
14
+
15
+ **Nothing here is generated by a model.** Every line of output is either copied
16
+ verbatim from a transcript or computed from it, so nothing can be invented. That
17
+ matters because the output is designed to be pasted back into a future Claude
18
+ session, where a plausible-but-wrong sentence would become ground truth.
19
+
20
+ ## The idea
21
+
22
+ Your own prompts already are a faithful record of what you were trying to do.
23
+ Extracted in order they read as the story of the session — most of what a summary
24
+ would have said, without the risk:
25
+
26
+ ```markdown
27
+ **m1**
28
+ > can you open a chrome page to the site, it is running on 3000
29
+
30
+ **m7**
31
+ > sherpa-onnx-tts.worker.js:267 [Sherpa Worker] Initialization failed…
32
+
33
+ **m137**
34
+ > when I speed up the text to speech, it ends up sounding like a chipmunk
35
+
36
+ **m153**
37
+ > The toolbar is no longer working to slow it down or speed it up live
38
+ ```
39
+
40
+ Blockquoting is functional, not cosmetic: a quoted reply containing `## Summary`
41
+ would otherwise forge a section of the digest. Everything else — dates, duration,
42
+ branch, files, commands — is parsed straight out of the transcript.
43
+
44
+ ## Requirements
45
+
46
+ - [`claude-history`](https://github.com/) on your `PATH`
47
+ - Python 3.10+. No third-party packages, no model, no network.
48
+
49
+ ## Install
50
+
51
+ ```sh
52
+ pipx install chsum # from a checkout: pipx install .
53
+ ```
54
+
55
+ Or as a Claude Code plugin, which brings the skill with it:
56
+
57
+ ```
58
+ /plugin marketplace add InDate/indate-tools
59
+ /plugin install chsum@indate-tools
60
+ ```
61
+
62
+ The plugin carries the skill; the `chsum` command still comes from pipx.
63
+
64
+ pipx, not `pip install --user`: chsum is an application, so it gets its own venv
65
+ and one symlink on `PATH`. `pipx install --editable .` while working on it.
66
+
67
+ A real command rather than a shell alias, because an alias doesn't exist for
68
+ scripts, hooks, or agents.
69
+
70
+ ## Usage
71
+
72
+ ```sh
73
+ chsum # every session in this project, one line each
74
+ chsum -n 5 # just the five most recent
75
+ chsum --since 7d # only the last week
76
+ chsum --all # across every project
77
+ chsum last # most recent real session, as context
78
+ chsum last -n 2 # the one before that
79
+ chsum find "text to speech playback speed" # locate a conversation
80
+ chsum digest <ch_ref> # write a digest file
81
+ chsum digest <ch_ref> --stdout # print it instead
82
+ chsum digest --file path/to/session.jsonl # address by file
83
+ chsum context <ch_ref> # reload artifact, for pasting into Claude
84
+ chsum context <ch_ref>/<agent-id> # one subagent's own digest
85
+ chsum journal --since 7d # work log for this project
86
+ chsum journal --since 2w --all # across every project
87
+ ```
88
+
89
+ Bare `chsum` lists the project's sessions, newest activity first:
90
+
91
+ ```
92
+ ref date dur prompts files agents title
93
+ ch_c120431a267b202aebf0b38f6c3c1b69 2026-08-06 5h38m 78 14 - Plan 3D house model…
94
+ ch_da4e99d42e5efab11ebdedc22fb65145 2026-08-05 3h03m 30 12 5 Set up cdp-tools server
95
+ ch_b99f11b7c257dafc8b93f53480ba3804 2026-08-05 6s 1 0 - empty (untitled)
96
+ ```
97
+
98
+ Listing is the default because picking is the common case, and "most recent" is
99
+ often a session you abandoned after one prompt. Those are flagged `empty` rather
100
+ than hidden — knowing a session was a dead end is the answer to "where did that
101
+ work go". Activity means a file edited, a notable command, an agent spawned, or a
102
+ second prompt.
103
+
104
+ `chsum last` is `chsum context` on the most recent session with activity, ordered
105
+ by last activity so one you resumed yesterday beats one you started last week.
106
+ Run from inside Claude Code, the session doing the running is excluded.
107
+
108
+ Everything scopes to the current project; `--all` widens. Digests land in
109
+ `~/.claude/chsum/digests/<uuid>.md` (`--out` to change).
110
+
111
+ ### Subagents
112
+
113
+ A subagent's edits and commands fold into its parent's totals — otherwise a
114
+ session that delegated everything reads as no activity. Files no parent turn
115
+ touched are marked `(agent)`. Each agent gets a line in **Delegated**, and an
116
+ address:
117
+
118
+ ```sh
119
+ chsum context ch_da4e99d42e5efab11ebdedc22fb65145/a728cd49179f1a356
120
+ ```
121
+
122
+ Its task, files, commands, and last message. Everything past the one-line summary
123
+ is fetched on demand, so a heavily-delegated session doesn't produce a digest
124
+ nobody wants to read.
125
+
126
+ `<parent-ref>/<agent-id>` resolves to `<uuid>/subagents/agent-<id>.jsonl`. chsum's
127
+ own scheme, not claude-history's — see *Notes on correctness*.
128
+
129
+ ### Search modes
130
+
131
+ `--hybrid` (default) and `--semantic` are best for conceptual recall but are slow:
132
+ tens of seconds warm, and **several minutes on the very first run** while the
133
+ embedding index builds. Use `--lexical` (sub-second) for identifiers, filenames,
134
+ and error strings, or `--exact` for exact tokens.
135
+
136
+ ## What a digest contains
137
+
138
+ | Section | Source |
139
+ |---|---|
140
+ | Frontmatter — ref, title, project, branch, start, duration, counts | computed |
141
+ | **What I asked for** — your prompts, verbatim, in order | copied |
142
+ | **Files changed** / **Commands run** | parsed from tool calls |
143
+ | **Delegated** — one line per subagent, with its address | parsed from sidecars |
144
+ | **Where I left off** — last prompt and last reply, verbatim | copied |
145
+ | — *found by two separate backward scans, so they may be far apart and are not a Q&A pair* | |
146
+ | **Drill down** — `mN → ma_…` anchor map | computed |
147
+
148
+ An agent digest has the same shape minus the intent trail — an agent gets one
149
+ instruction, so **Task** is a single block — and no anchor map (see below).
150
+
151
+ Output is budgeted, because it lands in a future context window: quotes clip,
152
+ lists cap. Every truncation is marked (`[+N chars, read the anchor]`, `…and N
153
+ more`) so you always know when you're seeing a fragment.
154
+
155
+ ## Notes on correctness
156
+
157
+ Several things here are non-obvious and were established by measuring, not assuming:
158
+
159
+ - **Duration excludes idle time.** Sessions get resumed hours or days later, so
160
+ first-record-to-last-record wildly overstates effort — one session in the corpus
161
+ reads as 92 hours. Gaps over 30 minutes are treated as "walked away".
162
+ - **Anchors are content-addressed, so they can collide.** Two messages with
163
+ byte-identical text (`[Request interrupted by user]`, say) share one anchor, and
164
+ `read --anchor` then fails with `ambiguous-ref`. Ambiguous anchors are detected
165
+ and never published — every anchor a digest prints resolves to exactly one message.
166
+ - **Most "user" records aren't from you.** They're tool results, interrupts, and
167
+ harness scaffolding. Those are filtered out; `prompts:` counts what you typed.
168
+ - **`outline` has two output shapes** — segment ranges for long conversations,
169
+ per-message lines for short ones. Both are handled.
170
+ - **Subagent transcripts** aren't conversations in their own right and never appear
171
+ in the listing, matching `claude-history`'s discovery rules.
172
+ - **`claude-history` has no per-agent ref.** `--subagents` inlines agent messages
173
+ into the parent read untagged, so they can't be sliced apart. Sidecars are
174
+ parsed directly, which is why agent digests carry no `ma_` anchors — those are
175
+ claude-history's to mint, and a fabricated one is worse than none.
176
+ - **An agent's last message isn't necessarily its conclusion**, so the section is
177
+ *Last thing it said*. An interrupted agent ends mid-thought.
178
+ - **Agent counts take the larger of two sources** — `Agent`/`Task` calls in the
179
+ parent, and sidecars on disk. Sidecars go missing; an agent that spawns its own
180
+ outnumbers the visible calls.
181
+ - **Scratch paths** (`/tmp`, scratchpads, plan files) are excluded from "files
182
+ changed" so the work log shows real project changes.
183
+
184
+ ## Adding prose later
185
+
186
+ There is a deliberately unimplemented `Summariser` seam at the bottom of
187
+ `chsum.py`. A TL;DR is the one thing extraction can't produce; the intended order
188
+ is Haiku first to set a quality bar and a price, then a local MLX backend measured
189
+ against it.
190
+
191
+ The rule for any backend: it gets the already-extracted material, and its output is
192
+ **additive** — layered on top of the verbatim record so a wrong sentence can always
193
+ be checked against the quotes beneath it.
194
+
195
+ If you do go local, note that the model in `mlx-community/DeepSeek-R1-Distill-Qwen-14B-MLX`
196
+ is **139 GB** of unquantised weights. The 4-bit build is `…-14B-4bit` at 8.32 GB. On a
197
+ 16 GB machine the binding constraint is KV cache, not context length: this architecture
198
+ costs 192 KB/token at fp16 (96 KB with `kv_bits=8`), so after 8.32 GB of weights you get
199
+ roughly 18k–36k tokens of usable input, not the 131k the config advertises.
@@ -0,0 +1,5 @@
1
+ chsum.py,sha256=LPb898wqgwBhL-aOO1aAVD964pbqJd5iy-qinr1WXpA,42404
2
+ chsum-1.0.0.dist-info/METADATA,sha256=CoqLy32whkQeLqDSoHXcZPI-kcRjmaVdBg0NvwNPAZs,9250
3
+ chsum-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
4
+ chsum-1.0.0.dist-info/entry_points.txt,sha256=gj4x6CDTsnpwwSXrGQGoWeCbdMoFaiHF9rYgJkF8Igw,37
5
+ chsum-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chsum = chsum:main
chsum.py ADDED
@@ -0,0 +1,1103 @@
1
+ #!/usr/bin/env python3
2
+ """chsum — Claude Code conversations as work logs and reload-ready context.
3
+
4
+ DETERMINISTIC: every line of output is copied verbatim from a transcript or
5
+ computed from it. Digests feed back into future sessions, where an invented
6
+ claim would become ground truth. Prose generation waits behind the `Summariser`
7
+ seam at the bottom.
8
+
9
+ Commands: sessions (default), last, find, digest, context, journal.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import pathlib
18
+ import re
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ from collections import defaultdict
24
+ from dataclasses import dataclass, field
25
+ from datetime import datetime, timezone
26
+
27
+ PROJECTS_ROOT = pathlib.Path(
28
+ os.environ.get("CLAUDE_CONFIG_DIR", pathlib.Path.home() / ".claude")
29
+ ) / "projects"
30
+ DIGEST_DIR = pathlib.Path.home() / ".claude" / "chsum" / "digests"
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # ch_ ref derivation
34
+ # ---------------------------------------------------------------------------
35
+ # Reimplements claude-history's AgentConversationRef::from_parts
36
+ # (src/agent/refs.rs:31-54): length-prefixed 128-bit FNV-1a over
37
+ # ["agent-v1", project_dir_name, session_filename]. Their versioned internal, so
38
+ # anything derived is verified against the uuid they report before it's trusted.
39
+
40
+ _FNV_OFFSET = 0x6C62272E07BB014262B821756295C58D
41
+ _FNV_PRIME = 0x0000000001000000000000000000013B
42
+ _MASK = (1 << 128) - 1
43
+
44
+
45
+ def _digest_parts(parts) -> int:
46
+ h = _FNV_OFFSET
47
+ for part in parts:
48
+ b = part.encode()
49
+ for byte in len(b).to_bytes(8, "little"):
50
+ h = ((h ^ byte) * _FNV_PRIME) & _MASK
51
+ for byte in b:
52
+ h = ((h ^ byte) * _FNV_PRIME) & _MASK
53
+ return h
54
+
55
+
56
+ def ch_ref_for_path(path: pathlib.Path) -> str:
57
+ """Full 32-hex ref. Always the full digest: the 12-hex form claude-history
58
+ emits is corpus-dependent and gets extended to stay unambiguous."""
59
+ return f"ch_{_digest_parts(['agent-v1', path.parent.name, path.name]):032x}"
60
+
61
+
62
+ def project_dir_name(cwd: pathlib.Path) -> str:
63
+ """claude-history's convert_path_to_project_dir_name (src/history/path.rs:10-21)."""
64
+ return re.sub(r"[^A-Za-z0-9-]", "-", str(cwd))
65
+
66
+
67
+ def transcripts(local: bool = False) -> list[pathlib.Path]:
68
+ """Addressable conversations only: two levels, no agent-* sidecars.
69
+
70
+ Mirrors claude-history's discover_agent_keys (src/agent/service.rs:477-486).
71
+ """
72
+ if not PROJECTS_ROOT.is_dir():
73
+ return []
74
+ out = [p for p in PROJECTS_ROOT.glob("*/*.jsonl") if not p.name.startswith("agent-")]
75
+ if local:
76
+ want = project_dir_name(pathlib.Path.cwd())
77
+ out = [p for p in out if p.parent.name == want]
78
+ return sorted(out)
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # claude-history
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ class HistoryError(RuntimeError):
87
+ pass
88
+
89
+
90
+ def _history(*args: str, timeout: int = 600) -> str:
91
+ exe = shutil.which("claude-history")
92
+ if not exe:
93
+ raise HistoryError("claude-history not found on PATH")
94
+ proc = subprocess.run([exe, *args], capture_output=True, text=True, timeout=timeout)
95
+ if "agent-error" in proc.stdout:
96
+ raise HistoryError(
97
+ f"claude-history rejected {' '.join(args)}: "
98
+ f"{proc.stdout.strip().splitlines()[0]}"
99
+ )
100
+ if proc.returncode != 0:
101
+ raise HistoryError(proc.stderr.strip() or proc.stdout.strip() or "unknown failure")
102
+ return proc.stdout
103
+
104
+
105
+ def _fields(line: str) -> dict:
106
+ """Parse `key=value` tokens from a protocol line, ignoring any trailing `| text`."""
107
+ head = line.split(" | ", 1)[0]
108
+ return dict(t.split("=", 1) for t in head.split() if "=" in t)
109
+
110
+
111
+ @dataclass
112
+ class Hit:
113
+ ref: str
114
+ uuid: str
115
+ title: str
116
+
117
+
118
+ def search(query: str, *, local: bool, mode: str, top: int) -> list[Hit]:
119
+ args = ["agent", "search", query, "--top", str(top), f"--{mode}",
120
+ "--local" if local else "--all"]
121
+ hits = []
122
+ for line in _history(*args).splitlines():
123
+ if line.startswith("conversation "):
124
+ f = _fields(line)
125
+ hits.append(Hit(
126
+ ref=f.get("ref", ""), uuid=f.get("uuid", ""),
127
+ title=line.split(" | ", 1)[1].strip() if " | " in line else "(untitled)",
128
+ ))
129
+ return hits
130
+
131
+
132
+ def last_message_number(ref: str) -> int:
133
+ """Highest message ordinal — the upper bound for a full read.
134
+
135
+ outline has two shapes: `seg m1..m38` for long conversations, bare `m1 role=…`
136
+ lines for short ones. Handle both, or short ones silently read as empty.
137
+ """
138
+ end = 0
139
+ for line in _history("agent", "outline", ref, "--no-budget").splitlines():
140
+ if line.startswith("seg "):
141
+ m = re.search(r"m(\d+)\.\.m(\d+)", line)
142
+ if m:
143
+ end = max(end, int(m.group(2)))
144
+ elif m := re.match(r"m(\d+)\s", line):
145
+ end = max(end, int(m.group(1)))
146
+ return end
147
+
148
+
149
+ def uuid_for_ref(ref: str) -> str:
150
+ for line in _history("agent", "outline", ref, "--no-budget").splitlines():
151
+ if line.startswith("conversation "):
152
+ return _fields(line).get("uuid", "")
153
+ return ""
154
+
155
+
156
+ @dataclass
157
+ class Message:
158
+ n: int
159
+ role: str
160
+ anchor: str
161
+ text: str
162
+
163
+
164
+ def read_messages(ref: str, start: int = 1, end: int | None = None) -> list[Message]:
165
+ """Parse `agent read` output into messages with their mN and ma_ anchor.
166
+
167
+ claude-history is the text source because it has already stripped tool sludge.
168
+ """
169
+ end = end or last_message_number(ref)
170
+ if not end:
171
+ return []
172
+ raw = _history("agent", "read", f"{ref}:m{start}..m{end}", "--no-budget")
173
+ msgs: list[Message] = []
174
+ cur: Message | None = None
175
+ body: list[str] = []
176
+ for line in raw.splitlines():
177
+ if line.startswith("message "):
178
+ if cur:
179
+ cur.text = "\n".join(body).strip()
180
+ msgs.append(cur)
181
+ f = _fields(line)
182
+ # The ordinal is a bare positional token ("message m17 role=..."),
183
+ # not a key=value pair, so it has to be read off directly.
184
+ m = re.match(r"message\s+m(\d+)", line)
185
+ cur = Message(
186
+ n=int(m.group(1)) if m else 0,
187
+ role=f.get("role", "?"),
188
+ anchor=f.get("anchor", ""),
189
+ text="",
190
+ )
191
+ body = []
192
+ elif line.startswith("| ") or line == "|":
193
+ body.append(line[2:] if len(line) > 1 else "")
194
+ if cur:
195
+ cur.text = "\n".join(body).strip()
196
+ msgs.append(cur)
197
+ return msgs
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Noise filtering
202
+ # ---------------------------------------------------------------------------
203
+ # Harness scaffolding that appears in the user role but isn't something the user
204
+ # typed. Measured across the corpus: interrupts and task-notifications dominate.
205
+
206
+ _NOISE_MARKERS = (
207
+ "[Request interrupted",
208
+ "<system-reminder",
209
+ "<task-notification",
210
+ "<command-name",
211
+ "<command-message",
212
+ "<local-command-stdout",
213
+ "<local-command-caveat",
214
+ "Caveat: The messages below were generated",
215
+ "[SYSTEM NOTIFICATION",
216
+ )
217
+
218
+
219
+ def is_real_prompt(text: str) -> bool:
220
+ t = text.strip()
221
+ if len(t) < 2:
222
+ return False
223
+ return not any(m in t for m in _NOISE_MARKERS)
224
+
225
+
226
+ # Steering turns that carry no standalone meaning. Measured on the corpus: ~31% of
227
+ # prompts in conversational sessions are these, and listed in a trail they read as
228
+ # noise ("yes", "ok, do that"). They're counted rather than shown.
229
+ _ACK_RE = re.compile(
230
+ r"^(y(es|ep|eah|up)?|no(pe)?|ok(ay)?|sure|thanks|ta|cool|nice|good|great|perfect|"
231
+ r"do (it|that)|go ahead|carry on|continue|next|yes please|please do|"
232
+ r"correct|right|exactly|agreed|fine|stop|wait|hmm+)"
233
+ r"[\s.,!?*)]*$",
234
+ re.IGNORECASE,
235
+ )
236
+
237
+
238
+ def is_substantive(text: str) -> bool:
239
+ """Does this prompt say anything on its own?
240
+
241
+ "yes" read cold months later carries nothing — its meaning lived in the message
242
+ it answered. Filtered from the trail, but counted, so it isn't silently erased.
243
+ """
244
+ t = text.strip()
245
+ return len(t) >= 12 and not _ACK_RE.match(t)
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Deterministic metadata, straight from the transcript
250
+ # ---------------------------------------------------------------------------
251
+
252
+ _FILE_TOOLS = {"Edit", "Write", "NotebookEdit", "MultiEdit"}
253
+ _READ_TOOLS = {"Read"}
254
+ _AGENT_TOOLS = {"Agent", "Task"} # Task is the older name for the same thing
255
+
256
+
257
+ @dataclass
258
+ class AgentRun:
259
+ """One subagent, from its sidecar transcript. Kept per-agent as well as merged
260
+ into the parent, so a digest can name each in one line and address the rest."""
261
+ id: str = "" # sidecar stem minus the agent- prefix; the address
262
+ agent_type: str = ""
263
+ model: str = ""
264
+ description: str = "" # the task line from the Agent call
265
+ spawn_depth: int = 1
266
+ duration: str = ""
267
+ edited: list[str] = field(default_factory=list)
268
+ commands: list[str] = field(default_factory=list)
269
+ path: pathlib.Path | None = None
270
+
271
+
272
+ @dataclass
273
+ class Meta:
274
+ uuid: str = ""
275
+ title: str = "(untitled)"
276
+ project: str = ""
277
+ branch: str = ""
278
+ started: str = ""
279
+ ended: str = ""
280
+ duration: str = ""
281
+ prompts: int = 0 # things you actually typed
282
+ records: int = 0 # raw user/assistant records, mostly tool traffic
283
+ active: int = 0 # seconds of work, excluding idle gaps
284
+ resumed: bool = False # spans a long break, so `date` alone understates it
285
+ agents: list[AgentRun] = field(default_factory=list)
286
+ spawned: int = 0 # Agent tool calls seen in the parent, sidecar or not
287
+ edited: list[str] = field(default_factory=list)
288
+ read: list[str] = field(default_factory=list)
289
+ commands: list[str] = field(default_factory=list)
290
+ agent_only: set[str] = field(default_factory=set) # files no parent turn touched
291
+ path: pathlib.Path | None = None
292
+
293
+ @property
294
+ def agent_count(self) -> int:
295
+ """Sidecars can be missing (older sessions, pruned) or outnumber the
296
+ visible Agent calls (an agent that spawned its own), so trust whichever
297
+ is larger."""
298
+ return max(self.spawned, len(self.agents))
299
+
300
+ @property
301
+ def date(self) -> str:
302
+ return self.started[:10]
303
+
304
+ @property
305
+ def project_name(self) -> str:
306
+ return pathlib.Path(self.project).name if self.project else "?"
307
+
308
+
309
+ IDLE_GAP_SECONDS = 30 * 60
310
+
311
+
312
+ def _parse_ts(s: str) -> datetime | None:
313
+ try:
314
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
315
+ except ValueError:
316
+ return None
317
+
318
+
319
+ def active_seconds(stamps: list[str]) -> int:
320
+ """Time worked, not wall-clock. Sessions resume days later, so first→last
321
+ overstates badly (one reads as 92h). Gaps over IDLE_GAP_SECONDS are excluded."""
322
+ times = sorted(t for t in (_parse_ts(s) for s in stamps) if t)
323
+ total = 0
324
+ for a, b in zip(times, times[1:]):
325
+ gap = (b - a).total_seconds()
326
+ if 0 <= gap <= IDLE_GAP_SECONDS:
327
+ total += gap
328
+ return int(total)
329
+
330
+
331
+ def _fmt_secs(secs: int) -> str:
332
+ if secs < 60:
333
+ return f"{secs}s"
334
+ h, m = divmod(secs // 60, 60)
335
+ return f"{h}h{m:02d}m" if h else f"{m}m"
336
+
337
+
338
+ def _records(path: pathlib.Path):
339
+ for line in path.open(errors="replace"):
340
+ line = line.strip()
341
+ if not line:
342
+ continue
343
+ try:
344
+ rec = json.loads(line)
345
+ except json.JSONDecodeError:
346
+ continue
347
+ if isinstance(rec, dict):
348
+ yield rec
349
+
350
+
351
+ def subagent_transcripts(path: pathlib.Path) -> list[pathlib.Path]:
352
+ """Sidecars for a session: <project>/<session-uuid>/subagents/agent-*.jsonl."""
353
+ d = path.parent / path.stem / "subagents"
354
+ return sorted(d.glob("agent-*.jsonl")) if d.is_dir() else []
355
+
356
+
357
+ def extract_agent(side: pathlib.Path) -> AgentRun:
358
+ """One sidecar's own totals, plus the task line from its .meta.json sibling."""
359
+ run = AgentRun(id=side.stem.removeprefix("agent-"), path=side)
360
+ sidemeta = side.with_suffix(".meta.json")
361
+ if sidemeta.exists():
362
+ try:
363
+ d = json.loads(sidemeta.read_text(errors="replace"))
364
+ except json.JSONDecodeError:
365
+ d = {}
366
+ if isinstance(d, dict):
367
+ run.agent_type = str(d.get("agentType") or "")
368
+ run.model = str(d.get("model") or "")
369
+ run.description = str(d.get("description") or "")
370
+ run.spawn_depth = int(d.get("spawnDepth") or 1)
371
+ stamps, edited, read, cmds = [], [], [], []
372
+ for rec in _records(side):
373
+ if rec.get("timestamp"):
374
+ stamps.append(rec["timestamp"])
375
+ if rec.get("type") in ("user", "assistant"):
376
+ _collect_tools(rec, edited, read, cmds)
377
+ if stamps:
378
+ stamps.sort()
379
+ run.duration = _fmt_secs(active_seconds(stamps))
380
+ run.edited, run.commands = edited, _dedupe(cmds)
381
+ return run
382
+
383
+
384
+ def extract_meta(path: pathlib.Path) -> Meta:
385
+ meta = Meta(uuid=path.stem, path=path)
386
+ stamps, edited, read, cmds = [], [], [], []
387
+ for rec in _records(path):
388
+ if rec.get("timestamp"):
389
+ stamps.append(rec["timestamp"])
390
+ if rec.get("type") == "ai-title" and rec.get("aiTitle"):
391
+ meta.title = rec["aiTitle"] # refined over the session; last wins
392
+ if not meta.project and rec.get("cwd"):
393
+ meta.project = rec["cwd"]
394
+ if rec.get("gitBranch"):
395
+ meta.branch = rec["gitBranch"]
396
+ if rec.get("type") in ("user", "assistant"):
397
+ meta.records += 1
398
+ meta.spawned += _collect_tools(rec, edited, read, cmds)
399
+ if rec.get("type") == "user" and _is_typed_prompt(rec):
400
+ meta.prompts += 1
401
+ own_edits = set(edited)
402
+
403
+ # Fold subagent tool use into the parent: a session that delegated everything
404
+ # would otherwise read as no activity. Prompts stay parent-only.
405
+ meta.agents = [extract_agent(s) for s in subagent_transcripts(path)]
406
+ for run in meta.agents:
407
+ edited.extend(run.edited)
408
+ cmds.extend(run.commands)
409
+
410
+ if stamps:
411
+ stamps.sort()
412
+ meta.started, meta.ended = stamps[0], stamps[-1]
413
+ meta.active = active_seconds(stamps)
414
+ meta.duration = _fmt_secs(meta.active)
415
+ meta.resumed = (
416
+ _parse_ts(stamps[-1]) - _parse_ts(stamps[0])
417
+ ).total_seconds() > meta.active + IDLE_GAP_SECONDS if _parse_ts(stamps[0]) else False
418
+ keep = lambda fs: _dedupe(_relpath(f, meta.project) for f in fs if _is_project_file(f))
419
+ meta.edited = keep(edited)
420
+ meta.read = keep(read)
421
+ meta.commands = _dedupe(cmds)
422
+ # Project-relative only once cwd is known, hence here not in extract_agent.
423
+ for run in meta.agents:
424
+ run.edited = keep(run.edited)
425
+ meta.agent_only = set(meta.edited) - set(keep(own_edits))
426
+ return meta
427
+
428
+
429
+ # Real work, but not project changes — they crowd out the files that matter.
430
+ _NON_PROJECT_PREFIXES = ("/tmp/", "/private/tmp/", "/var/folders/")
431
+ _NON_PROJECT_PARTS = ("/scratchpad/", "/.claude/plans/", "/.claude/projects/")
432
+
433
+
434
+ # Look-only commands. One session logged 118, nearly all greps — they bury the
435
+ # few that did something.
436
+ _INSPECTION_CMDS = {
437
+ "ls", "cat", "head", "tail", "grep", "rg", "find", "echo", "wc", "which",
438
+ "pwd", "cd", "file", "stat", "du", "df", "tree", "sed", "awk", "jq", "sort",
439
+ "uniq", "diff", "less", "more", "printf", "env", "date", "man", "type",
440
+ }
441
+
442
+
443
+ def _is_notable_command(cmd: str) -> bool:
444
+ """Did this command change something, build, or test?"""
445
+ first = cmd.split()[0] if cmd.split() else ""
446
+ first = first.rsplit("/", 1)[-1]
447
+ if first in ("sudo", "time", "nohup"):
448
+ parts = cmd.split()
449
+ first = parts[1].rsplit("/", 1)[-1] if len(parts) > 1 else first
450
+ return first not in _INSPECTION_CMDS
451
+
452
+
453
+ def _is_project_file(path: str) -> bool:
454
+ return not (path.startswith(_NON_PROJECT_PREFIXES)
455
+ or any(p in path for p in _NON_PROJECT_PARTS))
456
+
457
+
458
+ def _relpath(path: str, project: str) -> str:
459
+ if project and path.startswith(project + "/"):
460
+ return path[len(project) + 1:]
461
+ home = str(pathlib.Path.home())
462
+ return "~" + path[len(home):] if path.startswith(home + "/") else path
463
+
464
+
465
+ def _is_typed_prompt(rec: dict) -> bool:
466
+ """A user record carrying text the human actually wrote. Most user-role records
467
+ are tool_results; the rest is harness scaffolding (interrupts, notifications)."""
468
+ content = (rec.get("message") or {}).get("content")
469
+ if isinstance(content, str):
470
+ texts = [content]
471
+ elif isinstance(content, list):
472
+ texts = [p.get("text", "") for p in content
473
+ if isinstance(p, dict) and p.get("type") == "text"]
474
+ else:
475
+ return False
476
+ return any(is_real_prompt(t) for t in texts)
477
+
478
+
479
+ def _dedupe(items) -> list[str]:
480
+ seen, out = set(), []
481
+ for i in items:
482
+ if i not in seen:
483
+ seen.add(i)
484
+ out.append(i)
485
+ return out
486
+
487
+
488
+ def _collect_tools(rec: dict, edited: list, read: list, cmds: list) -> int:
489
+ """Append this record's tool use to the accumulators; return agents spawned."""
490
+ content = (rec.get("message") or {}).get("content")
491
+ if not isinstance(content, list):
492
+ return 0
493
+ spawned = 0
494
+ for part in content:
495
+ if not isinstance(part, dict) or part.get("type") != "tool_use":
496
+ continue
497
+ name, inp = part.get("name"), part.get("input") or {}
498
+ if name in _AGENT_TOOLS:
499
+ spawned += 1
500
+ elif name in _FILE_TOOLS and isinstance(inp.get("file_path"), str):
501
+ edited.append(inp["file_path"])
502
+ elif name in _READ_TOOLS and isinstance(inp.get("file_path"), str):
503
+ read.append(inp["file_path"])
504
+ elif name == "Bash" and isinstance(inp.get("command"), str):
505
+ cmd = inp["command"].strip().splitlines()[0]
506
+ if _is_notable_command(cmd):
507
+ cmds.append(cmd[:120])
508
+ return spawned
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Rendering
513
+ # ---------------------------------------------------------------------------
514
+
515
+
516
+ def _yaml(v: str) -> str:
517
+ s = str(v)
518
+ return json.dumps(s) if (":" in s or s.startswith(("[", "{", "#", "*", "&"))) else s
519
+
520
+
521
+ def _plural(n: int, word: str) -> str:
522
+ return f"{n} {word}" + ("" if n == 1 else "s")
523
+
524
+
525
+ def _bullets(items: list[str], limit: int) -> list[str]:
526
+ out = [f"- `{i}`" for i in items[:limit]]
527
+ if len(items) > limit:
528
+ out.append(f"- …and {len(items) - limit} more")
529
+ return out
530
+
531
+
532
+ def _clip(text: str, limit: int) -> str:
533
+ text = text.strip()
534
+ if len(text) <= limit:
535
+ return text
536
+ return text[:limit].rstrip() + f"\n… [+{len(text) - limit} chars, read the anchor]"
537
+
538
+
539
+ def _quote(text: str) -> str:
540
+ """Blockquote transcript text. Functional, not decorative: a quoted message
541
+ containing "## Summary" would otherwise forge a section of this document."""
542
+ return "\n".join(f"> {line}" if line.strip() else ">"
543
+ for line in text.strip().splitlines())
544
+
545
+
546
+ def frontmatter(meta: Meta, ref: str) -> str:
547
+ lines = ["---", f"ref: {ref}", f"uuid: {meta.uuid}", f"title: {_yaml(meta.title)}"]
548
+ if meta.project:
549
+ lines.append(f"project: {_yaml(meta.project)}")
550
+ if meta.branch:
551
+ lines.append(f"branch: {_yaml(meta.branch)}")
552
+ if meta.started:
553
+ lines.append(f"started: {_yaml(meta.started)}")
554
+ if meta.duration:
555
+ lines.append(f"duration: {meta.duration}")
556
+ lines += [f"prompts: {meta.prompts}", f"files_edited: {len(meta.edited)}"]
557
+ if meta.agent_count:
558
+ lines.append(f"subagents: {meta.agent_count} # their edits are counted above")
559
+ lines += ["generated_by: chsum (deterministic extraction, no model)", "---"]
560
+ return "\n".join(lines)
561
+
562
+
563
+ def messages_from_jsonl(path: pathlib.Path) -> list[Message]:
564
+ """Text messages straight from a transcript file. Sidecars only.
565
+
566
+ claude-history has no per-agent ref — `--subagents` inlines agent messages into
567
+ the parent read untagged — so these are parsed here. Anchors stay empty: `ma_`
568
+ values are claude-history's to mint, and a fabricated one is worse than none.
569
+ """
570
+ msgs: list[Message] = []
571
+ for rec in _records(path):
572
+ role = rec.get("type")
573
+ if role not in ("user", "assistant"):
574
+ continue
575
+ content = (rec.get("message") or {}).get("content")
576
+ if isinstance(content, str):
577
+ texts = [content]
578
+ elif isinstance(content, list):
579
+ texts = [p.get("text", "") for p in content
580
+ if isinstance(p, dict) and p.get("type") == "text"]
581
+ else:
582
+ continue
583
+ text = "\n".join(t for t in texts if t.strip()).strip()
584
+ if text:
585
+ msgs.append(Message(n=len(msgs) + 1, role=role, anchor="", text=text))
586
+ return msgs
587
+
588
+
589
+ def render_agent_digest(meta: Meta, parent_ref: str, run: AgentRun) -> str:
590
+ """One subagent's work. Same shape as a session digest, minus the intent trail:
591
+ an agent gets one instruction, so 'what I asked for' is a single block."""
592
+ msgs = messages_from_jsonl(run.path) if run.path else []
593
+ lines = ["---", f"ref: {parent_ref}/{run.id}", f"parent: {parent_ref}",
594
+ f"agent: {run.agent_type or 'agent'}"]
595
+ if run.model:
596
+ lines.append(f"model: {run.model}")
597
+ if run.duration:
598
+ lines.append(f"duration: {run.duration}")
599
+ lines += [f"files_edited: {len(run.edited)}", f"commands: {len(run.commands)}",
600
+ "generated_by: chsum (deterministic extraction, no model)", "---"]
601
+ parts = ["\n".join(lines), ""]
602
+
603
+ parts.append(f"# {run.description or 'subagent ' + run.id}\n")
604
+ parts.append(f"*{run.agent_type or 'agent'}"
605
+ + (f"/{run.model}" if run.model else "")
606
+ + (f" · {run.duration}" if run.duration else "")
607
+ + f" · spawned by `{parent_ref}`*\n")
608
+
609
+ parts.append("## Task\n")
610
+ task = next((m.text for m in msgs if m.role == "user"), "")
611
+ parts.append(_quote(_clip(task, 500)) + "\n" if task
612
+ else "*No instruction recorded.*\n")
613
+
614
+ if run.edited:
615
+ parts.append("## Files changed\n")
616
+ parts += _bullets(run.edited, 20) + [""]
617
+
618
+ if run.commands:
619
+ parts.append("## Commands run\n")
620
+ parts += _bullets(run.commands, 10) + [""]
621
+
622
+ # Not "final report": an interrupted agent ends mid-thought, and the transcript
623
+ # can't tell you which happened.
624
+ parts.append("## Last thing it said\n")
625
+ final = next((m.text for m in reversed(msgs) if m.role == "assistant"), "")
626
+ parts.append(_quote(_clip(final, 900)) + "\n" if final
627
+ else "*Nothing recorded.*\n")
628
+
629
+ parts.append("## Drill down\n")
630
+ parts.append(f"Full sidecar: `{run.path}`\n")
631
+ parts.append("Inlined into the parent read (untagged, all agents at once): "
632
+ f"`claude-history agent read {parent_ref}:mN..mN --subagents --no-budget`\n")
633
+ return "\n".join(parts).rstrip() + "\n"
634
+
635
+
636
+ def render_digest(meta: Meta, ref: str, msgs: list[Message], *,
637
+ prompt_clip: int = 300, max_prompts: int = 25) -> str:
638
+ typed = [m for m in msgs if m.role == "user" and is_real_prompt(m.text)]
639
+ prompts = [m for m in typed if is_substantive(m.text)]
640
+ steering = len(typed) - len(prompts)
641
+ parts = [frontmatter(meta, ref), ""]
642
+
643
+ parts.append(f"# {meta.title}\n")
644
+ when = f"{meta.date} · {meta.duration}" if meta.duration else meta.date
645
+ parts.append(f"*{when} · {meta.project_name}"
646
+ + (f" · `{meta.branch}`" if meta.branch else "") + "*\n")
647
+
648
+ # The intent trail: verbatim, in order. This is the summary, uninvented.
649
+ parts.append("## What I asked for\n")
650
+ if not prompts:
651
+ parts.append("*No user prompts recorded.*\n")
652
+ else:
653
+ shown = prompts[:max_prompts]
654
+ for m in shown:
655
+ parts.append(f"**m{m.n}**\n")
656
+ parts.append(_quote(_clip(m.text, prompt_clip)) + "\n")
657
+ trailer = []
658
+ if len(prompts) > len(shown):
659
+ trailer.append(f"{len(prompts) - len(shown)} more prompts")
660
+ if steering:
661
+ trailer.append(f"{steering} short steering replies not shown "
662
+ f"(“yes”, “ok, do that”)")
663
+ if trailer:
664
+ parts.append(f"*…and {', '.join(trailer)}.*\n")
665
+
666
+ if meta.edited:
667
+ parts.append("## Files changed\n")
668
+ # Marked, not separated: one session's work either way, but worth knowing
669
+ # before you hunt for the turn where you supposedly changed it.
670
+ shown_files = meta.edited[:20]
671
+ parts += [f"- `{f}`" + (" (agent)" if f in meta.agent_only else "")
672
+ for f in shown_files]
673
+ if len(meta.edited) > len(shown_files):
674
+ parts.append(f"- …and {len(meta.edited) - len(shown_files)} more")
675
+ parts.append("")
676
+
677
+ if meta.agents:
678
+ parts.append("## Delegated\n")
679
+ for run in meta.agents[:8]:
680
+ bits = [b for b in (f"{run.agent_type or 'agent'}"
681
+ + (f"/{run.model}" if run.model else ""),
682
+ run.duration,
683
+ f"{_plural(len(run.edited), 'file')}, "
684
+ f"{_plural(len(run.commands), 'command')}",
685
+ f"depth {run.spawn_depth}" if run.spawn_depth > 1 else "") if b]
686
+ parts.append(f"- `{run.id}` {' · '.join(bits)}")
687
+ if run.description:
688
+ parts.append(f" {run.description}")
689
+ if len(meta.agents) > 8:
690
+ parts.append(f"- …and {len(meta.agents) - 8} more")
691
+ parts.append("")
692
+ parts.append(f"One agent's own digest: `chsum context {ref}/<id>`\n")
693
+
694
+ if meta.commands:
695
+ parts.append("## Commands run\n")
696
+ parts += _bullets(meta.commands, 10) + [""]
697
+
698
+ parts.append("## Where I left off\n")
699
+ tail = _last_exchange(msgs)
700
+ if tail:
701
+ # Not labelled an exchange: two separate backward scans, so the reply
702
+ # usually isn't answering the prompt above it.
703
+ for m in tail:
704
+ label = ("Last thing I asked" if m.role == "user"
705
+ else "Last thing Claude said")
706
+ parts.append(f"**{label}** (m{m.n})\n")
707
+ parts.append(_quote(_clip(m.text, 600)) + "\n")
708
+ else:
709
+ parts.append("*Nothing recorded.*\n")
710
+
711
+ parts.append("## Drill down\n")
712
+ parts.append(f"Read any message: `claude-history agent read {ref}:mN..mN --no-budget`\n")
713
+ cited = _citable_anchors(msgs, prompts[:max_prompts] + (tail or []))
714
+ if cited:
715
+ parts.append("Durable anchors (survive renumbering if the transcript changes):\n")
716
+ parts += [f"- m{n} → `{a}`" for n, a in cited[:12]] + [""]
717
+ return "\n".join(parts).rstrip() + "\n"
718
+
719
+
720
+ def _citable_anchors(all_msgs: list[Message], cited: list[Message]) -> list[tuple[int, str]]:
721
+ """Anchors safe to publish: present, unique, one per message.
722
+
723
+ They are content-addressed, so byte-identical messages share one and
724
+ `read --anchor` fails with ambiguous-ref (measured: "[Request interrupted by
725
+ user]" collides). Dropped rather than emitted — mN alone still works.
726
+ """
727
+ counts: dict[str, int] = {}
728
+ for m in all_msgs:
729
+ if m.anchor:
730
+ counts[m.anchor] = counts.get(m.anchor, 0) + 1
731
+ out, seen = [], set()
732
+ for m in cited:
733
+ if m.anchor and counts.get(m.anchor) == 1 and m.n not in seen:
734
+ seen.add(m.n)
735
+ out.append((m.n, m.anchor))
736
+ return sorted(out)
737
+
738
+
739
+ def _last_exchange(msgs: list[Message]) -> list[Message]:
740
+ """Final real user prompt and the final assistant reply — the 'where was I' signal."""
741
+ out = []
742
+ for m in reversed(msgs):
743
+ if m.role == "user" and is_real_prompt(m.text):
744
+ out.append(m)
745
+ break
746
+ for m in reversed(msgs):
747
+ if m.role == "assistant" and m.text.strip():
748
+ out.append(m)
749
+ break
750
+ return sorted(out, key=lambda m: m.n)
751
+
752
+
753
+ # ---------------------------------------------------------------------------
754
+ # Commands
755
+ # ---------------------------------------------------------------------------
756
+
757
+
758
+ def _path_for_uuid(uuid: str) -> pathlib.Path | None:
759
+ return next((p for p in transcripts() if p.stem == uuid), None)
760
+
761
+
762
+ def resolve_ref(args) -> str:
763
+ if getattr(args, "file", None):
764
+ path = pathlib.Path(args.file).expanduser().resolve()
765
+ if not path.exists():
766
+ raise SystemExit(f"no such transcript: {path}")
767
+ ref = ch_ref_for_path(path)
768
+ got = uuid_for_ref(ref)
769
+ if got != path.stem:
770
+ raise SystemExit(
771
+ f"derived ref resolved to {got or '(nothing)'}, expected {path.stem}.\n"
772
+ "claude-history's ref scheme has probably changed — use `chsum find` instead."
773
+ )
774
+ return ref
775
+ return args.ref
776
+
777
+
778
+ def cmd_find(args) -> int:
779
+ if args.mode in ("hybrid", "semantic"):
780
+ # Embedding search is tens of seconds warm, and several minutes the very
781
+ # first time while the index builds. Say so rather than looking hung.
782
+ print(f"searching ({args.mode}; --lexical is much faster for exact terms)…",
783
+ file=sys.stderr)
784
+ hits = search(args.query, local=not args.all, mode=args.mode, top=args.top)
785
+ if not hits:
786
+ print("no matches", file=sys.stderr)
787
+ return 1
788
+ for h in hits:
789
+ path = _path_for_uuid(h.uuid)
790
+ meta = extract_meta(path) if path else Meta()
791
+ print(f"{h.ref} {meta.date or '??????????'} "
792
+ f"{meta.project_name[:22]:<22} {h.title}")
793
+ return 0
794
+
795
+
796
+ def _split_agent_ref(ref: str) -> tuple[str, str]:
797
+ """`ch_…/a38bb53…` → (parent ref, agent id). No agent part → ("", ref)."""
798
+ parent, sep, agent = ref.partition("/")
799
+ return (parent, agent.removeprefix("agent-")) if sep else (ref, "")
800
+
801
+
802
+ def _parent_path(ref: str) -> pathlib.Path:
803
+ uuid = uuid_for_ref(ref)
804
+ if not uuid:
805
+ raise HistoryError(f"{ref} did not resolve to a conversation")
806
+ path = _path_for_uuid(uuid)
807
+ if not path:
808
+ raise HistoryError(f"no transcript on disk for {uuid}")
809
+ return path
810
+
811
+
812
+ def _digest_for(ref: str) -> tuple[Meta, str]:
813
+ parent_ref, agent_id = _split_agent_ref(ref)
814
+ path = _parent_path(parent_ref)
815
+ meta = extract_meta(path)
816
+ if agent_id:
817
+ run = next((a for a in meta.agents if a.id == agent_id), None)
818
+ if not run:
819
+ known = ", ".join(a.id for a in meta.agents) or "none"
820
+ raise HistoryError(f"no subagent {agent_id} in {parent_ref} (has: {known})")
821
+ return meta, render_agent_digest(meta, parent_ref, run)
822
+ return meta, render_digest(meta, ref, read_messages(ref))
823
+
824
+
825
+ def cmd_digest(args) -> int:
826
+ ref = resolve_ref(args)
827
+ meta, md = _digest_for(ref)
828
+ if args.stdout:
829
+ sys.stdout.write(md)
830
+ return 0
831
+ args.out.mkdir(parents=True, exist_ok=True)
832
+ _, agent_id = _split_agent_ref(ref)
833
+ dest = args.out / (f"{meta.uuid}-agent-{agent_id}.md" if agent_id else f"{meta.uuid}.md")
834
+ dest.write_text(md)
835
+ print(f"wrote {dest}")
836
+ return 0
837
+
838
+
839
+ def cmd_context(args) -> int:
840
+ """Reload artifact. Same content as the digest, with a provenance header so a
841
+ future reader knows exactly how much to trust it (answer: it's verbatim)."""
842
+ ref = resolve_ref(args)
843
+ meta, md = _digest_for(ref)
844
+ parent_ref, agent_id = _split_agent_ref(ref)
845
+ print("<!-- Extracted verbatim from the transcript by chsum. No model wrote this;")
846
+ print(" nothing here is paraphrased. Quotes may be clipped — full text is in")
847
+ if agent_id:
848
+ # chsum's own address, not a claude-history one — `agent read` would fail.
849
+ print(" the sidecar named under Drill down. -->")
850
+ else:
851
+ print(f" the transcript: claude-history agent read {ref}:mN..mN --no-budget -->")
852
+ print()
853
+ sys.stdout.write(md)
854
+ return 0
855
+
856
+
857
+ def latest_transcript(local: bool = True, nth: int = 1) -> pathlib.Path:
858
+ """Nth-most-recent conversation with activity, by last activity not filename.
859
+
860
+ Sessions that went nowhere are skipped (`chsum sessions` lists those), as is
861
+ the session doing the running when invoked from inside Claude Code.
862
+ """
863
+ live = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
864
+ cands = [p for p in transcripts(local=local) if p.stem != live]
865
+ cands.sort(key=lambda p: p.stat().st_mtime, reverse=True)
866
+ seen = 0
867
+ for p in cands:
868
+ if not _has_activity(extract_meta(p)):
869
+ continue
870
+ seen += 1
871
+ if seen == nth:
872
+ return p
873
+ where = "this project" if local else "any project"
874
+ raise SystemExit(f"no conversation #{nth} in {where}"
875
+ if seen else f"no conversations found in {where}")
876
+
877
+
878
+ def cmd_last(args) -> int:
879
+ args.file = str(latest_transcript(local=not args.all, nth=args.nth))
880
+ args.ref = None
881
+ return cmd_context(args)
882
+
883
+
884
+ def cmd_sessions(args) -> int:
885
+ """One line per conversation, newest first. Triage: which were real work.
886
+
887
+ Empty ones are listed, not hidden — knowing a session was a dead end is the
888
+ answer to "where did that work go".
889
+ """
890
+ cutoff = _parse_since(args.since) if args.since else None
891
+ live = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
892
+ metas = []
893
+ for p in transcripts(local=not args.all):
894
+ if p.stem == live:
895
+ continue
896
+ if cutoff is not None and p.stat().st_mtime < cutoff:
897
+ continue
898
+ meta = extract_meta(p)
899
+ if cutoff is not None:
900
+ end = _parse_ts(meta.ended)
901
+ if end and end.timestamp() < cutoff:
902
+ continue
903
+ metas.append(meta)
904
+ if not metas:
905
+ where = "any project" if args.all else "this project"
906
+ print(f"no conversations in {where}", file=sys.stderr)
907
+ return 1
908
+
909
+ # Last activity, not start: a resumed session is as recent as you left it.
910
+ metas.sort(key=lambda m: m.ended or m.started or "", reverse=True)
911
+ shown = metas if args.limit <= 0 else metas[:args.limit]
912
+
913
+ scope = "all projects" if args.all else project_dir_name(pathlib.Path.cwd())
914
+ window = f" · last {args.since}" if args.since else ""
915
+ empty = sum(1 for m in shown if not _has_activity(m))
916
+ print(f"# Sessions — {scope}{window}")
917
+ print(f"*{len(shown)} of {len(metas)} shown · {empty} with no activity*\n")
918
+
919
+ rows = []
920
+ for m in shown:
921
+ rows.append((
922
+ ch_ref_for_path(m.path),
923
+ (m.ended or m.started or "")[:10] or "??????????",
924
+ m.duration or "-",
925
+ str(m.prompts),
926
+ str(len(m.edited)),
927
+ str(m.agent_count) if m.agent_count else "-",
928
+ "" if _has_activity(m) else "empty",
929
+ m.title,
930
+ ))
931
+ heads = ("ref", "date", "dur", "prompts", "files", "agents", "", "title")
932
+ widths = [max(len(r[i]) for r in (*rows, heads)) for i in range(len(heads) - 1)]
933
+ fmt = lambda r: " ".join(
934
+ [f"{c:<{w}}" for c, w in zip(r, widths)] + [r[-1]]
935
+ ).rstrip()
936
+ print(fmt(heads))
937
+ for r in rows:
938
+ print(fmt(r))
939
+ print("\nRead one: `chsum context <ref>` Most recent real session: `chsum last`")
940
+ return 0
941
+
942
+
943
+ def _has_activity(m: Meta) -> bool:
944
+ """Did the session go anywhere? One prompt answered with "what do you mean?"
945
+ is exactly what this exists to skip. Delegated work counts."""
946
+ return bool(m.edited) or bool(m.commands) or m.agent_count > 0 or m.prompts >= 2
947
+
948
+
949
+ def _parse_since(spec: str) -> float:
950
+ m = re.fullmatch(r"(\d+)\s*([hdw])", spec.strip())
951
+ if not m:
952
+ raise SystemExit(f"--since expects forms like 7d, 24h, 2w (got {spec!r})")
953
+ mult = {"h": 3600, "d": 86400, "w": 604800}[m.group(2)]
954
+ return time.time() - int(m.group(1)) * mult
955
+
956
+
957
+ def cmd_journal(args) -> int:
958
+ """Chronological work log. Pure JSONL — no claude-history calls, so it stays
959
+ fast across the whole corpus."""
960
+ cutoff = _parse_since(args.since)
961
+ metas = []
962
+ for p in transcripts(local=not args.all):
963
+ # mtime is a cheap superset filter; a resumed old session has a recent one.
964
+ if p.stat().st_mtime < cutoff:
965
+ continue
966
+ meta = extract_meta(p)
967
+ if not _has_activity(meta): # aborted, tool-only, or went nowhere
968
+ continue
969
+ end = _parse_ts(meta.ended)
970
+ if end and end.timestamp() < cutoff:
971
+ continue
972
+ metas.append(meta)
973
+ if not metas:
974
+ print("no conversations in that window", file=sys.stderr)
975
+ return 1
976
+
977
+ # By last activity: a resumed session belongs to the day you last worked on it.
978
+ metas.sort(key=lambda m: m.ended or "")
979
+ by_day: dict[str, list[Meta]] = defaultdict(list)
980
+ for m in metas:
981
+ by_day[(m.ended or "")[:10] or "undated"].append(m)
982
+
983
+ total_files = len({f for m in metas for f in m.edited})
984
+ span = f"last {args.since}" + ("" if args.all else " · this project")
985
+ print(f"# Work log — {span}\n")
986
+ print(f"*{_plural(len(metas), 'session')} · {_plural(len(by_day), 'day')} · "
987
+ f"{_plural(total_files, 'file')} changed*\n")
988
+
989
+ for day, sessions in by_day.items():
990
+ pretty = day
991
+ try:
992
+ pretty = datetime.strptime(day, "%Y-%m-%d").strftime("%a %d %b %Y")
993
+ except ValueError:
994
+ pass
995
+ print(f"## {pretty}\n")
996
+ for m in sessions:
997
+ bits = [b for b in (m.duration, m.project_name,
998
+ f"`{m.branch}`" if m.branch else "",
999
+ f"resumed from {m.date}"
1000
+ if m.resumed and m.date != (m.ended or "")[:10] else "") if b]
1001
+ print(f"### {m.title}")
1002
+ print(f"*{' · '.join(bits)}*\n")
1003
+ if m.edited:
1004
+ print(f"Changed {len(m.edited)} file(s): "
1005
+ + ", ".join(f"`{f}`" for f in m.edited[:6])
1006
+ + (f" +{len(m.edited) - 6} more" if len(m.edited) > 6 else ""))
1007
+ print()
1008
+ print(f"`chsum context {ch_ref_for_path(m.path)}`\n")
1009
+ return 0
1010
+
1011
+
1012
+ # ---------------------------------------------------------------------------
1013
+ # Summariser seam — deliberately empty for now
1014
+ # ---------------------------------------------------------------------------
1015
+
1016
+
1017
+ class Summariser:
1018
+ """Where prose generation plugs in. Nothing above needs a model, and nothing
1019
+ above should change when one arrives.
1020
+
1021
+ A backend gets the extracted material (intent trail, last exchange), never the
1022
+ raw transcript, and its output is additive — layered on top of the verbatim
1023
+ record so a wrong sentence can be checked against the quotes beneath it.
1024
+ """
1025
+
1026
+ def summarise(self, meta: Meta, msgs: list[Message]) -> str:
1027
+ raise NotImplementedError("no summariser backend configured")
1028
+
1029
+
1030
+ # ---------------------------------------------------------------------------
1031
+ # CLI
1032
+ # ---------------------------------------------------------------------------
1033
+
1034
+
1035
+ def main(argv=None) -> int:
1036
+ ap = argparse.ArgumentParser(
1037
+ prog="chsum",
1038
+ description="Work logs and reload-ready context from Claude Code conversations. "
1039
+ "Fully deterministic: no model, nothing invented.",
1040
+ )
1041
+ ap.add_argument("--out", type=pathlib.Path, default=DIGEST_DIR,
1042
+ help=f"digest directory (default: {DIGEST_DIR})")
1043
+ sub = ap.add_subparsers(dest="cmd")
1044
+
1045
+ p = sub.add_parser("sessions", help="one line per conversation in this project (the default)")
1046
+ p.add_argument("-n", "--limit", type=int, default=25, metavar="N",
1047
+ help="how many to list, 0 for all (default: 25)")
1048
+ p.add_argument("--since", default=None, help="window, e.g. 7d, 24h, 2w (default: all time)")
1049
+ p.add_argument("--all", action="store_true", help="all projects (default: this one)")
1050
+ p.set_defaults(func=cmd_sessions)
1051
+
1052
+ p = sub.add_parser("last", help="context for your most recent conversation")
1053
+ p.add_argument("-n", "--nth", type=int, default=1, metavar="N",
1054
+ help="Nth most recent instead of the last (default: 1)")
1055
+ p.add_argument("--all", action="store_true", help="all projects (default: this one)")
1056
+ p.set_defaults(func=cmd_last)
1057
+
1058
+ p = sub.add_parser("find", help="search conversations")
1059
+ p.add_argument("query")
1060
+ p.add_argument("--all", action="store_true", help="all workspaces (default: this one)")
1061
+ p.add_argument("--top", type=int, default=8)
1062
+ for mode in ("hybrid", "semantic", "lexical", "exact"):
1063
+ p.add_argument(f"--{mode}", dest="mode", action="store_const", const=mode)
1064
+ p.set_defaults(mode="hybrid", func=cmd_find)
1065
+
1066
+ p = sub.add_parser("digest", help="deterministic digest of one conversation")
1067
+ p.add_argument("ref", nargs="?", help="ch_... ref from `chsum find`")
1068
+ p.add_argument("--file", help="transcript path (derives the ref)")
1069
+ p.add_argument("--stdout", action="store_true", help="print instead of writing a file")
1070
+ p.set_defaults(func=cmd_digest)
1071
+
1072
+ p = sub.add_parser("context", help="reload artifact for pasting back into Claude")
1073
+ p.add_argument("ref", nargs="?")
1074
+ p.add_argument("--file")
1075
+ p.set_defaults(func=cmd_context)
1076
+
1077
+ p = sub.add_parser("journal", help="chronological work log")
1078
+ p.add_argument("--since", default="7d", help="window, e.g. 7d, 24h, 2w")
1079
+ p.add_argument("--all", action="store_true", help="all projects (default: this one)")
1080
+ p.set_defaults(func=cmd_journal)
1081
+
1082
+ # Bare `chsum` lists sessions: you usually want to pick one, and "most recent"
1083
+ # is often a dud. Anything naming a subcommand or asking for help is left alone.
1084
+ raw = list(argv) if argv is not None else sys.argv[1:]
1085
+ if not any(tok in sub.choices or tok in ("-h", "--help") for tok in raw):
1086
+ raw = ["sessions"] + raw
1087
+ args = ap.parse_args(raw)
1088
+ if args.cmd in ("digest", "context") and not args.ref and not args.file:
1089
+ ap.error(f"{args.cmd}: need a ch_... ref or --file")
1090
+
1091
+ try:
1092
+ return args.func(args)
1093
+ except HistoryError as e:
1094
+ print(f"error: {e}", file=sys.stderr)
1095
+ return 2
1096
+ except BrokenPipeError:
1097
+ return 0
1098
+ except KeyboardInterrupt:
1099
+ return 130
1100
+
1101
+
1102
+ if __name__ == "__main__":
1103
+ sys.exit(main())