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.
- chsum-1.0.0.dist-info/METADATA +199 -0
- chsum-1.0.0.dist-info/RECORD +5 -0
- chsum-1.0.0.dist-info/WHEEL +4 -0
- chsum-1.0.0.dist-info/entry_points.txt +2 -0
- chsum.py +1103 -0
|
@@ -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,,
|
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())
|