claude-dev-env 8.88.5 → 8.88.7

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 (24) 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/bin/install.settings-defaults.test.mjs +5 -1
  5. package/hooks/blocking/test_verify_before_acting.py +1 -1
  6. package/hooks/features/session-context.md +4 -0
  7. package/hooks/hooks.json +18 -0
  8. package/hooks/hooks_constants/compaction_user_messages_constants.py +93 -0
  9. package/hooks/hooks_constants/test_haiku_spawn_slim_gate_constants.py +18 -0
  10. package/hooks/session/compaction_recall_prompt.py +90 -0
  11. package/hooks/session/compaction_trim_eval.py +392 -0
  12. package/hooks/session/compaction_user_messages.py +307 -0
  13. package/hooks/session/fixtures/compaction_user_messages/deploy_session.jsonl +17 -0
  14. package/hooks/session/fixtures/compaction_user_messages/long_session.jsonl +41 -0
  15. package/hooks/session/fixtures/compaction_user_messages/one_compaction.jsonl +7 -0
  16. package/hooks/session/fixtures/compaction_user_messages/recall_request_session.jsonl +4 -0
  17. package/hooks/session/fixtures/compaction_user_messages/trim_cases.json +47 -0
  18. package/hooks/session/test_compaction_recall_prompt.py +144 -0
  19. package/hooks/session/test_compaction_trim_eval.py +115 -0
  20. package/hooks/session/test_compaction_user_messages.py +228 -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
  24. package/settings.json +2 -1
@@ -0,0 +1,392 @@
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.
14
+
15
+ Each run starts a headless ``claude -p`` session whose settings hold the
16
+ condensing hook from ``hooks.json`` and the Read allow rules from the
17
+ package ``settings.json``. The session runs the extractor on the fixture, so
18
+ Claude Code fires the hook as it would after a compaction. The runner reads
19
+ the hook's returned reason from the session's debug log. The grader checks
20
+ that each fact survives and that the reversed line says ``overruled``.
21
+
22
+ The oracle grades the raw extract and the null grades an empty reply, so a
23
+ broken grader shows before any model score is trusted. A failed call goes
24
+ to the errors list, never to a zero score.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import re
32
+ import shlex
33
+ import shutil
34
+ import subprocess
35
+ import sys
36
+ import tempfile
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+
40
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
41
+ if _hooks_directory not in sys.path:
42
+ sys.path.insert(0, _hooks_directory)
43
+ _session_directory = str(Path(__file__).resolve().parent)
44
+ if _session_directory not in sys.path:
45
+ sys.path.insert(0, _session_directory)
46
+
47
+ from compaction_user_messages import rendered_extract, typed_user_messages
48
+ from hooks_constants.compaction_user_messages_constants import (
49
+ EVAL_ALLOWED_TOOLS,
50
+ EVAL_CASES_FILE_NAME,
51
+ EVAL_CLAUDE_EXECUTABLE,
52
+ EVAL_DEBUG_FILE_NAME,
53
+ EVAL_DECISION_SEPARATOR,
54
+ EVAL_DEFAULT_MODEL,
55
+ EVAL_EMPTY_MCP_CONFIG,
56
+ EVAL_FIXTURE_DIRECTORY,
57
+ EVAL_HOOK_TYPE,
58
+ EVAL_HOOKS_JSON_PATH,
59
+ EVAL_JSON_OBJECT_START,
60
+ EVAL_MESSAGE_LINE_PATTERN_TEMPLATE,
61
+ EVAL_OVERRULED_MARKER,
62
+ EVAL_PACKAGE_SETTINGS_PATH,
63
+ EVAL_RESULTS_FILE_NAME,
64
+ EVAL_RESULTS_JSON_INDENT,
65
+ EVAL_RUN_PROMPT_TEMPLATE,
66
+ EVAL_RUN_SETTINGS_FILE_NAME,
67
+ EVAL_SERVED_MODEL_SEPARATOR,
68
+ EVAL_SESSION_DIRECTORY,
69
+ EVAL_STRUCTURED_OUTPUT_MARKER,
70
+ EVAL_TIMEOUT_SECONDS,
71
+ EXTRACTOR_SCRIPT_NAME,
72
+ MESSAGES_HEADING,
73
+ OUTPUT_ENCODING,
74
+ )
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class CaseScore:
79
+ """One graded condensed copy.
80
+
81
+ Attributes:
82
+ facts_kept: Planted facts found in the copy.
83
+ facts_total: Planted facts in the case.
84
+ overruled_marked: Reversed messages whose line says overruled.
85
+ overruled_total: Reversed messages in the case.
86
+ compression_ratio: Extract message characters per copy character.
87
+ """
88
+
89
+ facts_kept: int
90
+ facts_total: int
91
+ overruled_marked: int
92
+ overruled_total: int
93
+ compression_ratio: float
94
+
95
+
96
+ @dataclass
97
+ class EvalTally:
98
+ """Scores, errors, and per-run records gathered across every case."""
99
+
100
+ all_model_scores: list[CaseScore] = field(default_factory=list)
101
+ all_oracle_scores: list[CaseScore] = field(default_factory=list)
102
+ all_null_scores: list[CaseScore] = field(default_factory=list)
103
+ all_errors: list[str] = field(default_factory=list)
104
+ all_records: list[dict[str, object]] = field(default_factory=list)
105
+
106
+
107
+ def _normalized(text: str) -> str:
108
+ return re.sub(r"\s+", " ", text).casefold()
109
+
110
+
111
+ def _line_for_message(condensed_text: str, position: int) -> str:
112
+ line_pattern = re.compile(
113
+ EVAL_MESSAGE_LINE_PATTERN_TEMPLATE.format(position=position), re.MULTILINE
114
+ )
115
+ found_line = line_pattern.search(condensed_text)
116
+ return found_line.group(0) if found_line else ""
117
+
118
+
119
+ def _overruled_marked(condensed_text: str, all_reversals: list[dict[str, int]]) -> int:
120
+ return sum(
121
+ EVAL_OVERRULED_MARKER
122
+ in _line_for_message(condensed_text, int(each_reversal["earlier"])).casefold()
123
+ for each_reversal in all_reversals
124
+ )
125
+
126
+
127
+ def score_condensed(
128
+ condensed_text: str, all_case_fields: dict[str, object], extract_text: str
129
+ ) -> CaseScore:
130
+ """Grade one condensed copy against the case's planted facts.
131
+
132
+ ::
133
+
134
+ "#2 keep Postgres (overruled by #6)" and "#6 switch SQLite"
135
+ -> each fact found case-insensitively, the #2 line marked overruled
136
+
137
+ Args:
138
+ condensed_text: The text the hook would return as its reason.
139
+ all_case_fields: One entry from trim_cases.json.
140
+ extract_text: The extract the copy was made from, for the ratio.
141
+
142
+ Returns:
143
+ The counts and the compression ratio.
144
+ """
145
+ all_facts = [str(each_fact) for each_fact in all_case_fields["facts"]]
146
+ all_reversals = list(all_case_fields[EVAL_OVERRULED_MARKER])
147
+ normalized_copy = _normalized(condensed_text)
148
+ message_characters = len(extract_text.split(MESSAGES_HEADING, 1)[-1])
149
+ return CaseScore(
150
+ facts_kept=sum(_normalized(each_fact) in normalized_copy for each_fact in all_facts),
151
+ facts_total=len(all_facts),
152
+ overruled_marked=_overruled_marked(condensed_text, all_reversals),
153
+ overruled_total=len(all_reversals),
154
+ compression_ratio=message_characters / max(len(condensed_text), 1),
155
+ )
156
+
157
+
158
+ def condensing_hook_group(hooks_json_text: str) -> dict[str, object]:
159
+ """Return the PostToolUse group of ``hooks.json`` cut down to the condensing agent hook."""
160
+ all_registrations = json.loads(hooks_json_text)["hooks"]["PostToolUse"]
161
+ return next(
162
+ {"matcher": each_group["matcher"], "hooks": [each_hook]}
163
+ for each_group in all_registrations
164
+ for each_hook in each_group["hooks"]
165
+ if each_hook.get("type") == EVAL_HOOK_TYPE
166
+ and EXTRACTOR_SCRIPT_NAME in str(each_hook.get("if", ""))
167
+ )
168
+
169
+
170
+ def run_settings(
171
+ all_group_fields: dict[str, object], all_allow_rules: list[str], model_name: str
172
+ ) -> dict[str, object]:
173
+ """Return headless session settings that register the hook on the given model."""
174
+ all_hooks = [{**each_hook, "model": model_name} for each_hook in all_group_fields["hooks"]]
175
+ return {
176
+ "permissions": {"allow": all_allow_rules},
177
+ "hooks": {"PostToolUse": [{**all_group_fields, "hooks": all_hooks}]},
178
+ }
179
+
180
+
181
+ def hook_reason(model_reply: str) -> str | None:
182
+ """Return the ``reason`` of the last JSON decision in the reply, or None when there is none."""
183
+ decoder = json.JSONDecoder()
184
+ found_reason = None
185
+ for each_index, each_character in enumerate(model_reply):
186
+ if each_character != EVAL_JSON_OBJECT_START:
187
+ continue
188
+ try:
189
+ decision, _ = decoder.raw_decode(model_reply, each_index)
190
+ except json.JSONDecodeError:
191
+ continue
192
+ if isinstance(decision, dict) and isinstance(decision.get("reason"), str):
193
+ found_reason = decision["reason"]
194
+ return found_reason
195
+
196
+
197
+ def debug_log_reason(debug_text: str) -> str | None:
198
+ """Return the reason of the last hook decision a debug log records, or None.
199
+
200
+ ::
201
+
202
+ "... Hooks: Got structured output: {\"ok\":false,\"reason\":\"#1 deploy\"}"
203
+ -> "#1 deploy"
204
+
205
+ Args:
206
+ debug_text: The ``--debug-file`` log of one headless session.
207
+
208
+ Returns:
209
+ The text Claude Code handed the main agent, or None when no hook answered.
210
+ """
211
+ all_decisions = [
212
+ each_line.split(EVAL_STRUCTURED_OUTPUT_MARKER, 1)[1]
213
+ for each_line in debug_text.splitlines()
214
+ if EVAL_STRUCTURED_OUTPUT_MARKER in each_line
215
+ ]
216
+ return hook_reason(EVAL_DECISION_SEPARATOR.join(all_decisions))
217
+
218
+
219
+ def _session_command(
220
+ transcript_path: Path, settings_path: Path, model_name: str, debug_path: Path
221
+ ) -> list[str]:
222
+ extractor_command = shlex.join(
223
+ ["python3", str(EVAL_SESSION_DIRECTORY / EXTRACTOR_SCRIPT_NAME), str(transcript_path)]
224
+ )
225
+ return [
226
+ shutil.which(EVAL_CLAUDE_EXECUTABLE) or EVAL_CLAUDE_EXECUTABLE,
227
+ "-p",
228
+ "--model",
229
+ model_name,
230
+ "--setting-sources",
231
+ "",
232
+ "--settings",
233
+ str(settings_path),
234
+ "--strict-mcp-config",
235
+ "--mcp-config",
236
+ EVAL_EMPTY_MCP_CONFIG,
237
+ "--allowedTools",
238
+ EVAL_ALLOWED_TOOLS,
239
+ "--no-session-persistence",
240
+ "--debug-file",
241
+ str(debug_path),
242
+ "--output-format",
243
+ "json",
244
+ EVAL_RUN_PROMPT_TEMPLATE.format(command=extractor_command),
245
+ ]
246
+
247
+
248
+ def _run_hook_live(
249
+ transcript_path: Path, settings_path: Path, model_name: str
250
+ ) -> tuple[str | None, dict[str, object]]:
251
+ run_directory = Path(tempfile.mkdtemp(dir=settings_path.parent))
252
+ debug_path = run_directory / EVAL_DEBUG_FILE_NAME
253
+ completed = subprocess.run(
254
+ _session_command(transcript_path, settings_path, model_name, debug_path),
255
+ stdin=subprocess.DEVNULL,
256
+ capture_output=True,
257
+ text=True,
258
+ timeout=EVAL_TIMEOUT_SECONDS,
259
+ cwd=run_directory,
260
+ check=False,
261
+ )
262
+ session_output = json.loads(completed.stdout)
263
+ debug_text = debug_path.read_text(encoding=OUTPUT_ENCODING, errors="replace")
264
+ return debug_log_reason(debug_text), session_output
265
+
266
+
267
+ def _run_line(run_label: str, case_score: CaseScore, all_served_models: list[str]) -> str:
268
+ return (
269
+ f"{run_label} facts {case_score.facts_kept}/{case_score.facts_total} "
270
+ f"overruled {case_score.overruled_marked}/{case_score.overruled_total} "
271
+ f"ratio {case_score.compression_ratio:.1f}x "
272
+ f"served {EVAL_SERVED_MODEL_SEPARATOR.join(all_served_models)}\n"
273
+ )
274
+
275
+
276
+ def _run_once(
277
+ run_label: str,
278
+ transcript_path: Path,
279
+ settings_path: Path,
280
+ model_name: str,
281
+ all_case_fields: dict[str, object],
282
+ extract_text: str,
283
+ tally: EvalTally,
284
+ ) -> None:
285
+ try:
286
+ condensed_text, model_output = _run_hook_live(transcript_path, settings_path, model_name)
287
+ except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError) as call_error:
288
+ tally.all_errors.append(f"{run_label}: {type(call_error).__name__}")
289
+ return
290
+ if condensed_text is None:
291
+ tally.all_errors.append(f"{run_label}: no hook reason in the debug log")
292
+ return
293
+ case_score = score_condensed(condensed_text, all_case_fields, extract_text)
294
+ all_served_models = sorted(dict(model_output.get("modelUsage") or {}))
295
+ tally.all_model_scores.append(case_score)
296
+ tally.all_records.append(
297
+ {
298
+ "run": run_label,
299
+ "served_models": all_served_models,
300
+ "score": case_score.__dict__,
301
+ "condensed": condensed_text,
302
+ }
303
+ )
304
+ sys.stdout.write(_run_line(run_label, case_score, all_served_models))
305
+
306
+
307
+ def _run_case(
308
+ all_case_fields: dict[str, object],
309
+ settings_path: Path,
310
+ parsed_arguments: argparse.Namespace,
311
+ tally: EvalTally,
312
+ ) -> None:
313
+ transcript_path = EVAL_FIXTURE_DIRECTORY / str(all_case_fields["transcript"])
314
+ all_lines = transcript_path.read_text(encoding=OUTPUT_ENCODING).splitlines()
315
+ extract_text = rendered_extract(typed_user_messages(all_lines))
316
+ tally.all_oracle_scores.append(score_condensed(extract_text, all_case_fields, extract_text))
317
+ tally.all_null_scores.append(score_condensed("", all_case_fields, extract_text))
318
+ for each_rep in range(1, parsed_arguments.reps + 1):
319
+ _run_once(
320
+ f"{transcript_path.name} rep {each_rep}",
321
+ transcript_path,
322
+ settings_path,
323
+ parsed_arguments.model,
324
+ all_case_fields,
325
+ extract_text,
326
+ tally,
327
+ )
328
+
329
+
330
+ def _summary_line(label: str, all_scores: list[CaseScore]) -> str:
331
+ if not all_scores:
332
+ return f"{label}: no scored runs"
333
+ facts_total = sum(each_score.facts_total for each_score in all_scores)
334
+ facts_share = sum(each_score.facts_kept for each_score in all_scores) / facts_total
335
+ overruled_total = sum(each_score.overruled_total for each_score in all_scores)
336
+ overruled_marked = sum(each_score.overruled_marked for each_score in all_scores)
337
+ overruled_share = overruled_marked / max(overruled_total, 1)
338
+ mean_ratio = sum(each_score.compression_ratio for each_score in all_scores) / len(all_scores)
339
+ return (
340
+ f"{label}: facts {facts_share:.2f} overruled {overruled_share:.2f} "
341
+ f"ratio {mean_ratio:.1f}x ({len(all_scores)} runs)"
342
+ )
343
+
344
+
345
+ def _write_report(tally: EvalTally, output_directory: Path | None) -> None:
346
+ sys.stdout.write(
347
+ _summary_line("model", tally.all_model_scores) + f" errors {len(tally.all_errors)}\n"
348
+ )
349
+ sys.stdout.write(_summary_line("oracle (raw extract)", tally.all_oracle_scores) + "\n")
350
+ sys.stdout.write(_summary_line("null (empty reply)", tally.all_null_scores) + "\n")
351
+ for each_error in tally.all_errors:
352
+ sys.stdout.write(f"error: {each_error}\n")
353
+ if output_directory is None:
354
+ return
355
+ output_directory.mkdir(parents=True, exist_ok=True)
356
+ (output_directory / EVAL_RESULTS_FILE_NAME).write_text(
357
+ json.dumps(
358
+ {"records": tally.all_records, "errors": tally.all_errors},
359
+ indent=EVAL_RESULTS_JSON_INDENT,
360
+ ),
361
+ encoding=OUTPUT_ENCODING,
362
+ )
363
+
364
+
365
+ def main() -> int:
366
+ """Run every case through the live hook, grade it, and print the scores."""
367
+ argument_parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
368
+ argument_parser.add_argument("--reps", type=int, default=1)
369
+ argument_parser.add_argument("--model", default=EVAL_DEFAULT_MODEL)
370
+ argument_parser.add_argument("--output-dir", type=Path, default=None)
371
+ parsed_arguments = argument_parser.parse_args()
372
+ all_cases = json.loads(
373
+ (EVAL_FIXTURE_DIRECTORY / EVAL_CASES_FILE_NAME).read_text(encoding=OUTPUT_ENCODING)
374
+ )
375
+ hook_group = condensing_hook_group(EVAL_HOOKS_JSON_PATH.read_text(encoding=OUTPUT_ENCODING))
376
+ all_allow_rules = json.loads(EVAL_PACKAGE_SETTINGS_PATH.read_text(encoding=OUTPUT_ENCODING))[
377
+ "permissions"
378
+ ]["allow"]
379
+ settings_path = Path(tempfile.mkdtemp()) / EVAL_RUN_SETTINGS_FILE_NAME
380
+ settings_path.write_text(
381
+ json.dumps(run_settings(hook_group, all_allow_rules, parsed_arguments.model)),
382
+ encoding=OUTPUT_ENCODING,
383
+ )
384
+ tally = EvalTally()
385
+ for each_case in all_cases:
386
+ _run_case(each_case, settings_path, parsed_arguments, tally)
387
+ _write_report(tally, parsed_arguments.output_dir)
388
+ return 0 if tally.all_model_scores and not tally.all_errors else 1
389
+
390
+
391
+ if __name__ == "__main__":
392
+ sys.exit(main())
@@ -0,0 +1,307 @@
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
+ -> ~/.claude/compaction-recall/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 each typed message with its number and timestamp, oldest
14
+ first. The trim rules live in the hook prompt. The file sits under
15
+ ``~/.claude`` so the hook's Read permission rule can name its folder.
16
+ The whole transcript is read each time, so a second compaction yields
17
+ the same messages plus the newer ones.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import sys
24
+ from collections.abc import Iterable, Iterator
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
29
+ if _hooks_directory not in sys.path:
30
+ sys.path.insert(0, _hooks_directory)
31
+
32
+ from hooks_constants.compaction_user_messages_constants import (
33
+ ALL_RECALL_DIRECTORY_PARTS,
34
+ ALL_SKIPPED_ENTRY_FLAGS,
35
+ ASSISTANT_ENTRY_TYPE,
36
+ ATTACHMENT_ENTRY_TYPE,
37
+ ATTACHMENT_KEY,
38
+ BLOCK_SEPARATOR,
39
+ BLOCK_TYPE_KEY,
40
+ CONTENT_KEY,
41
+ CUT_MESSAGE_SUFFIX_TEMPLATE,
42
+ ENTRY_TYPE_KEY,
43
+ HARNESS_TAG_OPENING_PATTERN,
44
+ HUMAN_ORIGIN_KIND,
45
+ IMAGE_BLOCK_TYPE,
46
+ IMAGE_PLACEHOLDER,
47
+ LEFT_OUT_MESSAGES_TEMPLATE,
48
+ MAXIMUM_EXTRACT_CHARACTERS,
49
+ MAXIMUM_MESSAGE_CHARACTERS,
50
+ MESSAGE_HEADING_TEMPLATE,
51
+ MESSAGE_KEY,
52
+ MESSAGES_HEADING,
53
+ NO_MESSAGES_TEXT,
54
+ ORIGIN_KEY,
55
+ ORIGIN_KIND_KEY,
56
+ OUTPUT_ENCODING,
57
+ OUTPUT_FILE_SUFFIX,
58
+ QUEUED_COMMAND_ATTACHMENT_TYPE,
59
+ QUEUED_PROMPT_KEY,
60
+ SLASH_COMMAND_ARGS_PATTERN,
61
+ SLASH_COMMAND_NAME_PATTERN,
62
+ TEXT_BLOCK_TYPE,
63
+ TEXT_KEY,
64
+ TIMESTAMP_KEY,
65
+ TRANSCRIPT_ARGUMENT_COUNT,
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: a heading, then the kept messages.
245
+
246
+ ::
247
+
248
+ [#1 "fix login"] -> "# User messages, oldest first" + "## 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 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 = [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 recall file for one transcript, named after its session id.
270
+
271
+ ::
272
+
273
+ ~/.claude/projects/p/abc-123.jsonl -> ~/.claude/compaction-recall/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"{session_file_id(transcript_path.stem)}{OUTPUT_FILE_SUFFIX}"
282
+ return Path.home().joinpath(*ALL_RECALL_DIRECTORY_PARTS, 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.parent.mkdir(parents=True, exist_ok=True)
301
+ output_path.write_text(rendered_extract(all_messages), encoding=OUTPUT_ENCODING)
302
+ sys.stdout.write(f"{output_path}\n")
303
+ return 0
304
+
305
+
306
+ if __name__ == "__main__":
307
+ sys.exit(main())