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.
- package/.agents/skills/efficient-review/tools/config/test_efficient_review_constants.py +23 -0
- package/.agents/skills/efficient-review/tools/test_efficient_review.py +33 -0
- package/.agents/skills/efficient-review/tools/test_efficient_review_plan.py +27 -0
- package/hooks/blocking/test_verify_before_acting.py +1 -1
- package/hooks/features/session-context.md +4 -0
- package/hooks/hooks.json +18 -0
- package/hooks/hooks_constants/compaction_user_messages_constants.py +101 -0
- package/hooks/hooks_constants/test_haiku_spawn_slim_gate_constants.py +18 -0
- package/hooks/session/compaction_recall_prompt.py +90 -0
- package/hooks/session/compaction_trim_eval.py +343 -0
- package/hooks/session/compaction_user_messages.py +306 -0
- package/hooks/session/fixtures/compaction_user_messages/deploy_session.jsonl +17 -0
- package/hooks/session/fixtures/compaction_user_messages/long_session.jsonl +41 -0
- package/hooks/session/fixtures/compaction_user_messages/one_compaction.jsonl +7 -0
- package/hooks/session/fixtures/compaction_user_messages/recall_request_session.jsonl +4 -0
- package/hooks/session/fixtures/compaction_user_messages/trim_cases.json +47 -0
- package/hooks/session/test_compaction_recall_prompt.py +117 -0
- package/hooks/session/test_compaction_trim_eval.py +86 -0
- package/hooks/session/test_compaction_user_messages.py +220 -0
- package/hooks/session/test_deepen_role_gate.py +83 -0
- package/package.json +1 -1
- package/scripts/ci/scratch-home-install.mjs +3 -2
- 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}
|