claude-dev-env 8.88.4 → 8.88.6

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.
Files changed (23) hide show
  1. package/.agents/skills/efficient-review/tools/config/test_efficient_review_constants.py +23 -0
  2. package/.agents/skills/efficient-review/tools/test_efficient_review.py +33 -0
  3. package/.agents/skills/efficient-review/tools/test_efficient_review_plan.py +27 -0
  4. package/hooks/blocking/test_verify_before_acting.py +1 -1
  5. package/hooks/features/session-context.md +4 -0
  6. package/hooks/hooks.json +18 -0
  7. package/hooks/hooks_constants/compaction_user_messages_constants.py +101 -0
  8. package/hooks/hooks_constants/test_haiku_spawn_slim_gate_constants.py +18 -0
  9. package/hooks/session/compaction_recall_prompt.py +90 -0
  10. package/hooks/session/compaction_trim_eval.py +343 -0
  11. package/hooks/session/compaction_user_messages.py +306 -0
  12. package/hooks/session/fixtures/compaction_user_messages/deploy_session.jsonl +17 -0
  13. package/hooks/session/fixtures/compaction_user_messages/long_session.jsonl +41 -0
  14. package/hooks/session/fixtures/compaction_user_messages/one_compaction.jsonl +7 -0
  15. package/hooks/session/fixtures/compaction_user_messages/recall_request_session.jsonl +4 -0
  16. package/hooks/session/fixtures/compaction_user_messages/trim_cases.json +47 -0
  17. package/hooks/session/test_compaction_recall_prompt.py +117 -0
  18. package/hooks/session/test_compaction_trim_eval.py +86 -0
  19. package/hooks/session/test_compaction_user_messages.py +220 -0
  20. package/hooks/session/test_deepen_role_gate.py +83 -0
  21. package/package.json +1 -1
  22. package/scripts/ci/scratch-home-install.mjs +3 -2
  23. package/scripts/ci/scratch-home-install.test.mjs +23 -1
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env python3
2
+ """Score how well the compaction agent hook condenses the user's messages.
3
+
4
+ ::
5
+
6
+ python3 compaction_trim_eval.py --reps 3
7
+ deploy_session.jsonl rep 1 facts 13/13 overruled 1/1 ratio 1.6x
8
+ ...
9
+ model: facts 1.00 overruled 1.00 ratio 1.7x (9 runs) errors 0
10
+ oracle (raw extract): facts 1.00 ... null (empty reply): facts 0.00 ...
11
+
12
+ Each case is a fixture transcript with planted facts: paths, numbers, a
13
+ link, and a decision the user later reversed. The runner writes the
14
+ extract with ``compaction_user_messages.py``, then sends the agent hook's
15
+ own prompt from ``hooks.json`` to ``claude -p`` with the Read tool, the way
16
+ Claude Code runs the hook. The grader checks that each fact survives and
17
+ that the reversed line says ``overruled``.
18
+
19
+ The oracle grades the raw extract and the null grades an empty reply, so a
20
+ broken grader shows before any model score is trusted. A failed call goes
21
+ to the errors list, never to a zero score.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import re
29
+ import shutil
30
+ import subprocess
31
+ import sys
32
+ import tempfile
33
+ import uuid
34
+ from dataclasses import dataclass, field
35
+ from pathlib import Path
36
+
37
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
38
+ if _hooks_directory not in sys.path:
39
+ sys.path.insert(0, _hooks_directory)
40
+ _session_directory = str(Path(__file__).resolve().parent)
41
+ if _session_directory not in sys.path:
42
+ sys.path.insert(0, _session_directory)
43
+
44
+ from compaction_user_messages import output_path_for, rendered_extract, typed_user_messages
45
+ from hooks_constants.compaction_user_messages_constants import (
46
+ EVAL_ARGUMENTS_PLACEHOLDER,
47
+ EVAL_CASES_FILE_NAME,
48
+ EVAL_CLAUDE_EXECUTABLE,
49
+ EVAL_DEFAULT_MODEL,
50
+ EVAL_FIXTURE_DIRECTORY,
51
+ EVAL_HOOK_TYPE,
52
+ EVAL_HOOKS_JSON_PATH,
53
+ EVAL_JSON_OBJECT_START,
54
+ EVAL_MESSAGE_LINE_PATTERN_TEMPLATE,
55
+ EVAL_OVERRULED_MARKER,
56
+ EVAL_RESULTS_FILE_NAME,
57
+ EVAL_RESULTS_JSON_INDENT,
58
+ EVAL_SERVED_MODEL_SEPARATOR,
59
+ EVAL_SESSION_DIRECTORY,
60
+ EVAL_TIMEOUT_SECONDS,
61
+ EXTRACTOR_SCRIPT_NAME,
62
+ MESSAGES_HEADING,
63
+ OUTPUT_ENCODING,
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class CaseScore:
69
+ """One graded condensed copy.
70
+
71
+ Attributes:
72
+ facts_kept: Planted facts found in the copy.
73
+ facts_total: Planted facts in the case.
74
+ overruled_marked: Reversed messages whose line says overruled.
75
+ overruled_total: Reversed messages in the case.
76
+ compression_ratio: Extract message characters per copy character.
77
+ """
78
+
79
+ facts_kept: int
80
+ facts_total: int
81
+ overruled_marked: int
82
+ overruled_total: int
83
+ compression_ratio: float
84
+
85
+
86
+ @dataclass
87
+ class EvalTally:
88
+ """Scores, errors, and per-run records gathered across every case."""
89
+
90
+ all_model_scores: list[CaseScore] = field(default_factory=list)
91
+ all_oracle_scores: list[CaseScore] = field(default_factory=list)
92
+ all_null_scores: list[CaseScore] = field(default_factory=list)
93
+ all_errors: list[str] = field(default_factory=list)
94
+ all_records: list[dict[str, object]] = field(default_factory=list)
95
+
96
+
97
+ def _normalized(text: str) -> str:
98
+ return re.sub(r"\s+", " ", text).casefold()
99
+
100
+
101
+ def _line_for_message(condensed_text: str, position: int) -> str:
102
+ line_pattern = re.compile(
103
+ EVAL_MESSAGE_LINE_PATTERN_TEMPLATE.format(position=position), re.MULTILINE
104
+ )
105
+ found_line = line_pattern.search(condensed_text)
106
+ return found_line.group(0) if found_line else ""
107
+
108
+
109
+ def _overruled_marked(condensed_text: str, all_reversals: list[dict[str, int]]) -> int:
110
+ return sum(
111
+ EVAL_OVERRULED_MARKER
112
+ in _line_for_message(condensed_text, int(each_reversal["earlier"])).casefold()
113
+ for each_reversal in all_reversals
114
+ )
115
+
116
+
117
+ def score_condensed(
118
+ condensed_text: str, all_case_fields: dict[str, object], extract_text: str
119
+ ) -> CaseScore:
120
+ """Grade one condensed copy against the case's planted facts.
121
+
122
+ ::
123
+
124
+ "#2 keep Postgres (overruled by #6)" and "#6 switch SQLite"
125
+ -> each fact found case-insensitively, the #2 line marked overruled
126
+
127
+ Args:
128
+ condensed_text: The text the hook would return as its reason.
129
+ all_case_fields: One entry from trim_cases.json.
130
+ extract_text: The extract the copy was made from, for the ratio.
131
+
132
+ Returns:
133
+ The counts and the compression ratio.
134
+ """
135
+ all_facts = [str(each_fact) for each_fact in all_case_fields["facts"]]
136
+ all_reversals = list(all_case_fields[EVAL_OVERRULED_MARKER])
137
+ normalized_copy = _normalized(condensed_text)
138
+ message_characters = len(extract_text.split(MESSAGES_HEADING, 1)[-1])
139
+ return CaseScore(
140
+ facts_kept=sum(_normalized(each_fact) in normalized_copy for each_fact in all_facts),
141
+ facts_total=len(all_facts),
142
+ overruled_marked=_overruled_marked(condensed_text, all_reversals),
143
+ overruled_total=len(all_reversals),
144
+ compression_ratio=message_characters / max(len(condensed_text), 1),
145
+ )
146
+
147
+
148
+ def condensing_hook_prompt(hooks_json_text: str) -> str:
149
+ """Return the prompt of the PostToolUse agent hook that condenses the messages."""
150
+ all_registrations = json.loads(hooks_json_text)["hooks"]["PostToolUse"]
151
+ return next(
152
+ str(each_hook["prompt"])
153
+ for each_group in all_registrations
154
+ for each_hook in each_group["hooks"]
155
+ if each_hook.get("type") == EVAL_HOOK_TYPE
156
+ and EXTRACTOR_SCRIPT_NAME in str(each_hook.get("if", ""))
157
+ )
158
+
159
+
160
+ def hook_reason(model_reply: str) -> str | None:
161
+ """Return the ``reason`` of the last JSON decision in the reply, or None when there is none."""
162
+ decoder = json.JSONDecoder()
163
+ found_reason = None
164
+ for each_index, each_character in enumerate(model_reply):
165
+ if each_character != EVAL_JSON_OBJECT_START:
166
+ continue
167
+ try:
168
+ decision, _ = decoder.raw_decode(model_reply, each_index)
169
+ except json.JSONDecodeError:
170
+ continue
171
+ if isinstance(decision, dict) and isinstance(decision.get("reason"), str):
172
+ found_reason = decision["reason"]
173
+ return found_reason
174
+
175
+
176
+ def _write_extract(transcript_path: Path) -> tuple[Path, str]:
177
+ all_lines = transcript_path.read_text(encoding=OUTPUT_ENCODING).splitlines()
178
+ extract_text = rendered_extract(typed_user_messages(all_lines))
179
+ extract_path = output_path_for(transcript_path)
180
+ extract_path.write_text(extract_text, encoding=OUTPUT_ENCODING)
181
+ return extract_path, extract_text
182
+
183
+
184
+ def _hook_input(transcript_path: Path, extract_path: Path) -> str:
185
+ command = f"python3 {EVAL_SESSION_DIRECTORY / EXTRACTOR_SCRIPT_NAME} {transcript_path}"
186
+ return json.dumps(
187
+ {
188
+ "hook_event_name": "PostToolUse",
189
+ "tool_name": "Bash",
190
+ "tool_input": {"command": command},
191
+ "tool_response": {"stdout": f"{extract_path}\n", "stderr": "", "interrupted": False},
192
+ }
193
+ )
194
+
195
+
196
+ def _ask_model(prompt_text: str, model_name: str) -> dict[str, object]:
197
+ command = [
198
+ shutil.which(EVAL_CLAUDE_EXECUTABLE) or EVAL_CLAUDE_EXECUTABLE,
199
+ "-p",
200
+ "--model",
201
+ model_name,
202
+ "--setting-sources",
203
+ "",
204
+ "--tools",
205
+ "Read",
206
+ "--allowedTools=Read",
207
+ "--no-session-persistence",
208
+ "--session-id",
209
+ str(uuid.uuid4()),
210
+ "--output-format",
211
+ "json",
212
+ ]
213
+ completed = subprocess.run(
214
+ command,
215
+ input=prompt_text,
216
+ capture_output=True,
217
+ text=True,
218
+ timeout=EVAL_TIMEOUT_SECONDS,
219
+ cwd=tempfile.gettempdir(),
220
+ check=False,
221
+ )
222
+ return json.loads(completed.stdout)
223
+
224
+
225
+ def _run_line(run_label: str, case_score: CaseScore, all_served_models: list[str]) -> str:
226
+ return (
227
+ f"{run_label} facts {case_score.facts_kept}/{case_score.facts_total} "
228
+ f"overruled {case_score.overruled_marked}/{case_score.overruled_total} "
229
+ f"ratio {case_score.compression_ratio:.1f}x "
230
+ f"served {EVAL_SERVED_MODEL_SEPARATOR.join(all_served_models)}\n"
231
+ )
232
+
233
+
234
+ def _run_once(
235
+ run_label: str,
236
+ prompt_text: str,
237
+ model_name: str,
238
+ all_case_fields: dict[str, object],
239
+ extract_text: str,
240
+ tally: EvalTally,
241
+ ) -> None:
242
+ try:
243
+ model_output = _ask_model(prompt_text, model_name)
244
+ except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError) as call_error:
245
+ tally.all_errors.append(f"{run_label}: {type(call_error).__name__}")
246
+ return
247
+ condensed_text = hook_reason(str(model_output.get("result", "")))
248
+ if model_output.get("is_error") or condensed_text is None:
249
+ tally.all_errors.append(f"{run_label}: no reason in reply")
250
+ return
251
+ case_score = score_condensed(condensed_text, all_case_fields, extract_text)
252
+ all_served_models = sorted(dict(model_output.get("modelUsage") or {}))
253
+ tally.all_model_scores.append(case_score)
254
+ tally.all_records.append(
255
+ {
256
+ "run": run_label,
257
+ "served_models": all_served_models,
258
+ "score": case_score.__dict__,
259
+ "condensed": condensed_text,
260
+ }
261
+ )
262
+ sys.stdout.write(_run_line(run_label, case_score, all_served_models))
263
+
264
+
265
+ def _run_case(
266
+ all_case_fields: dict[str, object],
267
+ hook_prompt: str,
268
+ parsed_arguments: argparse.Namespace,
269
+ tally: EvalTally,
270
+ ) -> None:
271
+ transcript_path = EVAL_FIXTURE_DIRECTORY / str(all_case_fields["transcript"])
272
+ extract_path, extract_text = _write_extract(transcript_path)
273
+ tally.all_oracle_scores.append(score_condensed(extract_text, all_case_fields, extract_text))
274
+ tally.all_null_scores.append(score_condensed("", all_case_fields, extract_text))
275
+ prompt_text = hook_prompt.replace(
276
+ EVAL_ARGUMENTS_PLACEHOLDER, _hook_input(transcript_path, extract_path)
277
+ )
278
+ for each_rep in range(1, parsed_arguments.reps + 1):
279
+ _run_once(
280
+ f"{transcript_path.name} rep {each_rep}",
281
+ prompt_text,
282
+ parsed_arguments.model,
283
+ all_case_fields,
284
+ extract_text,
285
+ tally,
286
+ )
287
+
288
+
289
+ def _summary_line(label: str, all_scores: list[CaseScore]) -> str:
290
+ if not all_scores:
291
+ return f"{label}: no scored runs"
292
+ facts_total = sum(each_score.facts_total for each_score in all_scores)
293
+ facts_share = sum(each_score.facts_kept for each_score in all_scores) / facts_total
294
+ overruled_total = sum(each_score.overruled_total for each_score in all_scores)
295
+ overruled_marked = sum(each_score.overruled_marked for each_score in all_scores)
296
+ overruled_share = overruled_marked / max(overruled_total, 1)
297
+ mean_ratio = sum(each_score.compression_ratio for each_score in all_scores) / len(all_scores)
298
+ return (
299
+ f"{label}: facts {facts_share:.2f} overruled {overruled_share:.2f} "
300
+ f"ratio {mean_ratio:.1f}x ({len(all_scores)} runs)"
301
+ )
302
+
303
+
304
+ def _write_report(tally: EvalTally, output_directory: Path | None) -> None:
305
+ sys.stdout.write(
306
+ _summary_line("model", tally.all_model_scores) + f" errors {len(tally.all_errors)}\n"
307
+ )
308
+ sys.stdout.write(_summary_line("oracle (raw extract)", tally.all_oracle_scores) + "\n")
309
+ sys.stdout.write(_summary_line("null (empty reply)", tally.all_null_scores) + "\n")
310
+ for each_error in tally.all_errors:
311
+ sys.stdout.write(f"error: {each_error}\n")
312
+ if output_directory is None:
313
+ return
314
+ output_directory.mkdir(parents=True, exist_ok=True)
315
+ (output_directory / EVAL_RESULTS_FILE_NAME).write_text(
316
+ json.dumps(
317
+ {"records": tally.all_records, "errors": tally.all_errors},
318
+ indent=EVAL_RESULTS_JSON_INDENT,
319
+ ),
320
+ encoding=OUTPUT_ENCODING,
321
+ )
322
+
323
+
324
+ def main() -> int:
325
+ """Run every case through the hook prompt, grade it, and print the scores."""
326
+ argument_parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
327
+ argument_parser.add_argument("--reps", type=int, default=1)
328
+ argument_parser.add_argument("--model", default=EVAL_DEFAULT_MODEL)
329
+ argument_parser.add_argument("--output-dir", type=Path, default=None)
330
+ parsed_arguments = argument_parser.parse_args()
331
+ all_cases = json.loads(
332
+ (EVAL_FIXTURE_DIRECTORY / EVAL_CASES_FILE_NAME).read_text(encoding=OUTPUT_ENCODING)
333
+ )
334
+ hook_prompt = condensing_hook_prompt(EVAL_HOOKS_JSON_PATH.read_text(encoding=OUTPUT_ENCODING))
335
+ tally = EvalTally()
336
+ for each_case in all_cases:
337
+ _run_case(each_case, hook_prompt, parsed_arguments, tally)
338
+ _write_report(tally, parsed_arguments.output_dir)
339
+ return 0 if tally.all_model_scores and not tally.all_errors else 1
340
+
341
+
342
+ if __name__ == "__main__":
343
+ sys.exit(main())
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env python3
2
+ """Write every message the user typed in a session to a file, and print its path.
3
+
4
+ ::
5
+
6
+ python3 compaction_user_messages.py ~/.claude/projects/p/abc.jsonl
7
+ -> /tmp/compaction-user-messages-abc.md
8
+
9
+ The agent runs this after a compaction. Only the path reaches its context.
10
+ A PostToolUse agent hook reads the file, condenses it, and returns the
11
+ condensed messages to the agent.
12
+
13
+ The file holds the trim rules, then each typed message with its number and
14
+ timestamp, oldest first. The whole transcript is read each time, so a second
15
+ compaction yields the same messages plus the newer ones.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import sys
22
+ import tempfile
23
+ from collections.abc import Iterable, Iterator
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
28
+ if _hooks_directory not in sys.path:
29
+ sys.path.insert(0, _hooks_directory)
30
+
31
+ from hooks_constants.compaction_user_messages_constants import (
32
+ ALL_SKIPPED_ENTRY_FLAGS,
33
+ ASSISTANT_ENTRY_TYPE,
34
+ ATTACHMENT_ENTRY_TYPE,
35
+ ATTACHMENT_KEY,
36
+ BLOCK_SEPARATOR,
37
+ BLOCK_TYPE_KEY,
38
+ CONTENT_KEY,
39
+ CUT_MESSAGE_SUFFIX_TEMPLATE,
40
+ ENTRY_TYPE_KEY,
41
+ HARNESS_TAG_OPENING_PATTERN,
42
+ HUMAN_ORIGIN_KIND,
43
+ IMAGE_BLOCK_TYPE,
44
+ IMAGE_PLACEHOLDER,
45
+ LEFT_OUT_MESSAGES_TEMPLATE,
46
+ MAXIMUM_EXTRACT_CHARACTERS,
47
+ MAXIMUM_MESSAGE_CHARACTERS,
48
+ MESSAGE_HEADING_TEMPLATE,
49
+ MESSAGE_KEY,
50
+ MESSAGES_HEADING,
51
+ NO_MESSAGES_TEXT,
52
+ ORIGIN_KEY,
53
+ ORIGIN_KIND_KEY,
54
+ OUTPUT_ENCODING,
55
+ OUTPUT_FILE_PREFIX,
56
+ OUTPUT_FILE_SUFFIX,
57
+ QUEUED_COMMAND_ATTACHMENT_TYPE,
58
+ QUEUED_PROMPT_KEY,
59
+ SLASH_COMMAND_ARGS_PATTERN,
60
+ SLASH_COMMAND_NAME_PATTERN,
61
+ TEXT_BLOCK_TYPE,
62
+ TEXT_KEY,
63
+ TIMESTAMP_KEY,
64
+ TRANSCRIPT_ARGUMENT_COUNT,
65
+ TRIM_INSTRUCTIONS,
66
+ USAGE_EXIT_CODE,
67
+ USAGE_TEXT,
68
+ USER_ENTRY_TYPE,
69
+ )
70
+ from hooks_constants.session_file_id import session_file_id
71
+ from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class TypedMessage:
76
+ """One message the user typed, numbered by its place among all typed messages.
77
+
78
+ Attributes:
79
+ position: 1 for the first typed message, counting up.
80
+ timestamp: The transcript timestamp, or an empty string.
81
+ text: The message text, with a slash command shown as ``/name args``.
82
+ """
83
+
84
+ position: int
85
+ timestamp: str
86
+ text: str
87
+
88
+
89
+ def _parsed_entry(transcript_line: str) -> dict[str, object]:
90
+ try:
91
+ parsed_entry = json.loads(transcript_line)
92
+ except json.JSONDecodeError:
93
+ return {}
94
+ return parsed_entry if isinstance(parsed_entry, dict) else {}
95
+
96
+
97
+ def _block_text(block: object) -> str | None:
98
+ if not isinstance(block, dict):
99
+ return None
100
+ if block.get(BLOCK_TYPE_KEY) == TEXT_BLOCK_TYPE:
101
+ return str(block.get(TEXT_KEY, ""))
102
+ if block.get(BLOCK_TYPE_KEY) == IMAGE_BLOCK_TYPE:
103
+ return IMAGE_PLACEHOLDER
104
+ return None
105
+
106
+
107
+ def _content_text(content: object) -> str:
108
+ if isinstance(content, str):
109
+ return content
110
+ all_blocks = content if isinstance(content, list) else []
111
+ all_parts = [_block_text(each_block) for each_block in all_blocks]
112
+ return BLOCK_SEPARATOR.join(each_part for each_part in all_parts if each_part is not None)
113
+
114
+
115
+ def _typed_text(raw_text: str) -> str | None:
116
+ stripped_text = raw_text.strip()
117
+ command_name = SLASH_COMMAND_NAME_PATTERN.search(stripped_text)
118
+ if command_name:
119
+ command_args = SLASH_COMMAND_ARGS_PATTERN.search(stripped_text)
120
+ typed_args = command_args.group(1).strip() if command_args else ""
121
+ return f"{command_name.group(1)} {typed_args}".strip()
122
+ if not stripped_text or HARNESS_TAG_OPENING_PATTERN.match(stripped_text):
123
+ return None
124
+ return stripped_text
125
+
126
+
127
+ def _is_typed_by_a_person(all_entry_fields: dict[str, object]) -> bool:
128
+ if any(all_entry_fields.get(each_flag) for each_flag in ALL_SKIPPED_ENTRY_FLAGS):
129
+ return False
130
+ origin = all_entry_fields.get(ORIGIN_KEY)
131
+ return not isinstance(origin, dict) or origin.get(ORIGIN_KIND_KEY) == HUMAN_ORIGIN_KIND
132
+
133
+
134
+ def _entry_text(all_entry_fields: dict[str, object]) -> str | None:
135
+ entry_type = all_entry_fields.get(ENTRY_TYPE_KEY)
136
+ if entry_type == USER_ENTRY_TYPE and _is_typed_by_a_person(all_entry_fields):
137
+ message = all_entry_fields.get(MESSAGE_KEY)
138
+ content = message.get(CONTENT_KEY) if isinstance(message, dict) else None
139
+ return _typed_text(_content_text(content))
140
+ if entry_type != ATTACHMENT_ENTRY_TYPE:
141
+ return None
142
+ attachment = all_entry_fields.get(ATTACHMENT_KEY)
143
+ if not isinstance(attachment, dict):
144
+ return None
145
+ if attachment.get(ENTRY_TYPE_KEY) != QUEUED_COMMAND_ATTACHMENT_TYPE:
146
+ return None
147
+ queued_prompt = attachment.get(QUEUED_PROMPT_KEY)
148
+ return _typed_text(queued_prompt) if isinstance(queued_prompt, str) else None
149
+
150
+
151
+ def _typed_texts(all_transcript_lines: Iterable[str]) -> Iterator[tuple[str, str]]:
152
+ previous_text = None
153
+ for each_line in all_transcript_lines:
154
+ all_entry_fields = _parsed_entry(each_line)
155
+ if all_entry_fields.get(ENTRY_TYPE_KEY) == ASSISTANT_ENTRY_TYPE:
156
+ previous_text = None
157
+ continue
158
+ entry_text = _entry_text(all_entry_fields)
159
+ if entry_text is None or entry_text == previous_text:
160
+ continue
161
+ previous_text = entry_text
162
+ yield str(all_entry_fields.get(TIMESTAMP_KEY, "")), entry_text
163
+
164
+
165
+ def typed_user_messages(all_transcript_lines: Iterable[str]) -> list[TypedMessage]:
166
+ """Return the messages a person typed, in order, numbered from 1.
167
+
168
+ ::
169
+
170
+ user "fix the login bug" -> kept
171
+ user "<command-name>/deepen</command-name>
172
+ <command-args>speed it up</command-args>" -> "/deepen speed it up"
173
+ user tool_result, isMeta skill body,
174
+ isCompactSummary, isSidechain, origin peer -> left out
175
+ user "<system-reminder>...", "<local-command-stdout>" -> left out
176
+ attachment queued_command "also check logs" -> kept
177
+
178
+ A message repeated right after itself, such as a queued prompt that later
179
+ lands as a user entry, counts once.
180
+
181
+ Args:
182
+ all_transcript_lines: The session transcript, one JSON entry per line.
183
+
184
+ Returns:
185
+ The typed messages, oldest first.
186
+ """
187
+ return [
188
+ TypedMessage(position=each_position, timestamp=each_timestamp, text=each_text)
189
+ for each_position, (each_timestamp, each_text) in enumerate(
190
+ _typed_texts(all_transcript_lines), start=1
191
+ )
192
+ ]
193
+
194
+
195
+ def _capped_text(text: str) -> str:
196
+ if len(text) <= MAXIMUM_MESSAGE_CHARACTERS:
197
+ return text
198
+ cut_count = len(text) - MAXIMUM_MESSAGE_CHARACTERS
199
+ return text[:MAXIMUM_MESSAGE_CHARACTERS] + CUT_MESSAGE_SUFFIX_TEMPLATE.format(
200
+ cut_count=cut_count
201
+ )
202
+
203
+
204
+ def _rendered_message(message: TypedMessage) -> str:
205
+ return MESSAGE_HEADING_TEMPLATE.format(
206
+ position=message.position,
207
+ timestamp=message.timestamp,
208
+ text=_capped_text(message.text),
209
+ )
210
+
211
+
212
+ def bounded_messages(all_messages: list[TypedMessage]) -> list[TypedMessage]:
213
+ """Return the messages that fit the extract budget: the first, then the newest.
214
+
215
+ ::
216
+
217
+ 5 short messages -> all 5
218
+ #1 plus 400 long messages -> #1, then the newest that fit
219
+
220
+ The first message keeps the session's original ask. The newest carry the
221
+ latest corrections, which override older asks.
222
+
223
+ Args:
224
+ all_messages: Every typed message, oldest first.
225
+
226
+ Returns:
227
+ The kept messages, oldest first.
228
+ """
229
+ if not all_messages:
230
+ return []
231
+ first_message, *all_later_messages = all_messages
232
+ remaining_characters = MAXIMUM_EXTRACT_CHARACTERS - len(_rendered_message(first_message))
233
+ all_kept_later_messages: list[TypedMessage] = []
234
+ for each_message in reversed(all_later_messages):
235
+ message_size = len(_rendered_message(each_message))
236
+ if message_size > remaining_characters:
237
+ break
238
+ remaining_characters -= message_size
239
+ all_kept_later_messages.append(each_message)
240
+ return [first_message, *reversed(all_kept_later_messages)]
241
+
242
+
243
+ def rendered_extract(all_messages: list[TypedMessage]) -> str:
244
+ """Return the file text: the trim rules, then the kept messages.
245
+
246
+ ::
247
+
248
+ [#1 "fix login"] -> TRIM_INSTRUCTIONS + "# User messages..." + "## 1 | <ts>\\nfix login"
249
+
250
+ Args:
251
+ all_messages: Every typed message, oldest first.
252
+
253
+ Returns:
254
+ The text the extractor writes to its output file.
255
+ """
256
+ if not all_messages:
257
+ return TRIM_INSTRUCTIONS + MESSAGES_HEADING + NO_MESSAGES_TEXT
258
+ all_kept_messages = bounded_messages(all_messages)
259
+ left_out_count = len(all_messages) - len(all_kept_messages)
260
+ rendered_parts = [TRIM_INSTRUCTIONS, MESSAGES_HEADING]
261
+ for each_index, each_message in enumerate(all_kept_messages):
262
+ if each_index == 1 and left_out_count:
263
+ rendered_parts.append(LEFT_OUT_MESSAGES_TEMPLATE.format(left_out_count=left_out_count))
264
+ rendered_parts.append(_rendered_message(each_message))
265
+ return "".join(rendered_parts)
266
+
267
+
268
+ def output_path_for(transcript_path: Path) -> Path:
269
+ """Return the temp file for one transcript, named after its session id.
270
+
271
+ ::
272
+
273
+ ~/.claude/projects/p/abc-123.jsonl -> /tmp/compaction-user-messages-abc-123.md
274
+
275
+ Args:
276
+ transcript_path: The session transcript.
277
+
278
+ Returns:
279
+ The path the extractor writes; a rerun overwrites it.
280
+ """
281
+ file_name = f"{OUTPUT_FILE_PREFIX}{session_file_id(transcript_path.stem)}{OUTPUT_FILE_SUFFIX}"
282
+ return Path(tempfile.gettempdir()) / file_name
283
+
284
+
285
+ def main() -> int:
286
+ """Write the extract for the transcript named on the command line and print its path."""
287
+ if len(sys.argv) != TRANSCRIPT_ARGUMENT_COUNT:
288
+ sys.stderr.write(USAGE_TEXT)
289
+ return USAGE_EXIT_CODE
290
+ transcript_path = Path(sys.argv[1])
291
+ try:
292
+ with transcript_path.open(
293
+ encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY
294
+ ) as transcript:
295
+ all_messages = typed_user_messages(transcript)
296
+ except OSError as read_error:
297
+ sys.stderr.write(f"cannot read {transcript_path}: {read_error}\n")
298
+ return USAGE_EXIT_CODE
299
+ output_path = output_path_for(transcript_path)
300
+ output_path.write_text(rendered_extract(all_messages), encoding=OUTPUT_ENCODING)
301
+ sys.stdout.write(f"{output_path}\n")
302
+ return 0
303
+
304
+
305
+ if __name__ == "__main__":
306
+ sys.exit(main())
@@ -0,0 +1,17 @@
1
+ {"type": "user", "timestamp": "2026-10-19T10:00:01Z", "message": {"role": "user", "content": "<command-message>deepen</command-message>\n<command-name>/deepen</command-name>\n<command-args>migrate the billing service off the old cron box. the cron box is batch-07.internal and the job runs at 02:15 UTC</command-args>"}, "origin": {"kind": "human"}}
2
+ {"type": "user", "timestamp": "2026-10-19T10:00:02Z", "message": {"role": "user", "content": [{"type": "text", "text": "Base directory for this skill: ~/.claude/skills/deepen\n\n# deepen ..."}]}, "isMeta": true}
3
+ {"type": "assistant", "timestamp": "2026-10-19T10:00:03Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "I will look at the cron config."}]}}
4
+ {"type": "user", "timestamp": "2026-10-19T10:00:04Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "15 2 * * * /opt/billing/run.sh"}]}}
5
+ {"type": "user", "timestamp": "2026-10-19T10:00:05Z", "message": {"role": "user", "content": "we store invoices in Postgres, keep using Postgres for the new service. the table is billing.invoices_v3"}, "origin": {"kind": "human"}}
6
+ {"type": "user", "timestamp": "2026-10-19T10:00:06Z", "message": {"role": "user", "content": "<system-reminder>Task tools available.</system-reminder>"}}
7
+ {"type": "assistant", "timestamp": "2026-10-19T10:00:07Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Understood."}]}}
8
+ {"type": "user", "timestamp": "2026-10-19T10:00:08Z", "message": {"role": "user", "content": "ok"}, "origin": {"kind": "human"}}
9
+ {"type": "user", "timestamp": "2026-10-19T10:00:09Z", "message": {"role": "user", "content": "dont touch packages/billing/legacy_export.py, finance still reads its CSV every Monday"}, "origin": {"kind": "human"}}
10
+ {"type": "attachment", "timestamp": "2026-10-19T10:00:10Z", "attachment": {"type": "queued_command", "prompt": "<agent-message from=\"a1\">[Subagent hand-back] I suggest MySQL.</agent-message>"}}
11
+ {"type": "user", "timestamp": "2026-10-19T10:00:11Z", "message": {"role": "user", "content": [{"type": "text", "text": "[Request interrupted by user]"}]}, "origin": {"kind": "human"}}
12
+ {"type": "user", "timestamp": "2026-10-19T10:00:12Z", "message": {"role": "user", "content": "change of plan: switch the new service to SQLite, not Postgres. ops wont give us a db until Q3"}, "origin": {"kind": "human"}}
13
+ {"type": "user", "timestamp": "2026-10-19T10:00:13Z", "message": {"role": "user", "content": [{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": ""}}, {"type": "text", "text": "this is the error from the staging run, exit code 137"}]}, "origin": {"kind": "human"}}
14
+ {"type": "user", "timestamp": "2026-10-19T10:00:14Z", "message": {"role": "user", "content": "retry budget is 4 attempts with 30s backoff. the PR goes to https://github.com/acme/billing/pull/812 and needs Dana's review"}, "origin": {"kind": "human"}}
15
+ {"type": "user", "timestamp": "2026-10-19T10:00:15Z", "message": {"role": "user", "content": "thanks"}, "origin": {"kind": "human"}}
16
+ {"type": "system", "subtype": "compact_boundary", "timestamp": "2026-10-19T10:00:16Z", "content": "Conversation compacted"}
17
+ {"type": "user", "timestamp": "2026-10-19T10:00:17Z", "message": {"role": "user", "content": "This session is being continued from a previous conversation..."}, "isCompactSummary": true}