claude-dev-env 8.90.0 → 8.91.0

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.
@@ -1,40 +1,30 @@
1
1
  #!/usr/bin/env python3
2
- """Score how well the compaction agent hook condenses the user's messages.
2
+ """Score how well the Python trim keeps the user's messages after a compaction.
3
3
 
4
4
  ::
5
5
 
6
- python3 compaction_trim_eval.py --reps 3
7
- deploy_session.jsonl rep 1 facts 13/13 overruled 1/1 ratio 1.6x
6
+ python3 compaction_trim_eval.py
7
+ deploy_session.jsonl facts 13/13 overruled 1/1 ratio 1.6x
8
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 ...
9
+ trim: facts 1.00 overruled 1.00 ratio 1.7x (4 cases)
10
+ oracle (raw messages): facts 1.00 ... null (empty recall): facts 0.00 ...
11
11
 
12
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.
13
+ link, and a decision the user later reversed. The eval trims the fixture's
14
+ typed messages exactly as the SessionStart hook does, then checks that each
15
+ fact survives and that each reversed message's line says ``overruled``.
16
+
17
+ The oracle grades the raw messages and the null grades an empty recall, so a
18
+ broken grader shows before the trim score is trusted. The eval exits 1 when
19
+ the trim loses a fact or leaves a reversal unmarked.
25
20
  """
26
21
 
27
22
  from __future__ import annotations
28
23
 
29
- import argparse
30
24
  import json
31
25
  import re
32
- import shlex
33
- import shutil
34
- import subprocess
35
26
  import sys
36
- import tempfile
37
- from dataclasses import dataclass, field
27
+ from dataclasses import dataclass
38
28
  from pathlib import Path
39
29
 
40
30
  _hooks_directory = str(Path(__file__).resolve().parent.parent)
@@ -44,46 +34,32 @@ _session_directory = str(Path(__file__).resolve().parent)
44
34
  if _session_directory not in sys.path:
45
35
  sys.path.insert(0, _session_directory)
46
36
 
47
- from compaction_user_messages import rendered_extract, typed_user_messages
37
+ from compaction_trim import recall_text
38
+ from compaction_user_messages import typed_user_messages
48
39
  from hooks_constants.compaction_user_messages_constants import (
49
- EVAL_ALLOWED_TOOLS,
40
+ EVAL_CASE_EARLIER_KEY,
41
+ EVAL_CASE_FACTS_KEY,
42
+ EVAL_CASE_OVERRULED_KEY,
43
+ EVAL_CASE_TRANSCRIPT_KEY,
50
44
  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,
45
+ EVAL_FILE_ENCODING,
56
46
  EVAL_FIXTURE_DIRECTORY,
57
- EVAL_HOOK_TYPE,
58
- EVAL_HOOKS_JSON_PATH,
59
- EVAL_JSON_OBJECT_START,
60
47
  EVAL_MESSAGE_LINE_PATTERN_TEMPLATE,
61
48
  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,
49
+ LINE_SEPARATOR,
74
50
  )
75
51
 
76
52
 
77
53
  @dataclass(frozen=True)
78
54
  class CaseScore:
79
- """One graded condensed copy.
55
+ """One graded recall.
80
56
 
81
57
  Attributes:
82
- facts_kept: Planted facts found in the copy.
58
+ facts_kept: Planted facts found in the recall.
83
59
  facts_total: Planted facts in the case.
84
60
  overruled_marked: Reversed messages whose line says overruled.
85
61
  overruled_total: Reversed messages in the case.
86
- compression_ratio: Extract message characters per copy character.
62
+ compression_ratio: Raw message characters per recall character.
87
63
  """
88
64
 
89
65
  facts_kept: int
@@ -92,42 +68,39 @@ class CaseScore:
92
68
  overruled_total: int
93
69
  compression_ratio: float
94
70
 
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)
71
+ @property
72
+ def is_lossless(self) -> bool:
73
+ """True when every fact survives and every reversal is marked."""
74
+ return (
75
+ self.facts_kept == self.facts_total
76
+ and self.overruled_marked == self.overruled_total
77
+ )
105
78
 
106
79
 
107
80
  def _normalized(text: str) -> str:
108
81
  return re.sub(r"\s+", " ", text).casefold()
109
82
 
110
83
 
111
- def _line_for_message(condensed_text: str, position: int) -> str:
84
+ def _line_for_message(recall: str, position: int) -> str:
112
85
  line_pattern = re.compile(
113
86
  EVAL_MESSAGE_LINE_PATTERN_TEMPLATE.format(position=position), re.MULTILINE
114
87
  )
115
- found_line = line_pattern.search(condensed_text)
88
+ found_line = line_pattern.search(recall)
116
89
  return found_line.group(0) if found_line else ""
117
90
 
118
91
 
119
- def _overruled_marked(condensed_text: str, all_reversals: list[dict[str, int]]) -> int:
92
+ def _overruled_marked(recall: str, all_reversals: list[dict[str, int]]) -> int:
120
93
  return sum(
121
94
  EVAL_OVERRULED_MARKER
122
- in _line_for_message(condensed_text, int(each_reversal["earlier"])).casefold()
95
+ in _line_for_message(recall, int(each_reversal[EVAL_CASE_EARLIER_KEY])).casefold()
123
96
  for each_reversal in all_reversals
124
97
  )
125
98
 
126
99
 
127
100
  def score_condensed(
128
- condensed_text: str, all_case_fields: dict[str, object], extract_text: str
101
+ recall: str, all_case_fields: dict[str, object], raw_messages: str
129
102
  ) -> CaseScore:
130
- """Grade one condensed copy against the case's planted facts.
103
+ """Grade one recall against the case's planted facts.
131
104
 
132
105
  ::
133
106
 
@@ -135,257 +108,90 @@ def score_condensed(
135
108
  -> each fact found case-insensitively, the #2 line marked overruled
136
109
 
137
110
  Args:
138
- condensed_text: The text the hook would return as its reason.
111
+ recall: The trimmed lines the hook would return.
139
112
  all_case_fields: One entry from trim_cases.json.
140
- extract_text: The extract the copy was made from, for the ratio.
113
+ raw_messages: The typed messages the recall was made from, for the ratio.
141
114
 
142
115
  Returns:
143
116
  The counts and the compression ratio.
144
117
  """
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])
118
+ all_facts = [str(each_fact) for each_fact in all_case_fields[EVAL_CASE_FACTS_KEY]]
119
+ all_reversals = list(all_case_fields[EVAL_CASE_OVERRULED_KEY])
120
+ normalized_recall = _normalized(recall)
149
121
  return CaseScore(
150
- facts_kept=sum(_normalized(each_fact) in normalized_copy for each_fact in all_facts),
122
+ facts_kept=sum(_normalized(each_fact) in normalized_recall for each_fact in all_facts),
151
123
  facts_total=len(all_facts),
152
- overruled_marked=_overruled_marked(condensed_text, all_reversals),
124
+ overruled_marked=_overruled_marked(recall, all_reversals),
153
125
  overruled_total=len(all_reversals),
154
- compression_ratio=message_characters / max(len(condensed_text), 1),
126
+ compression_ratio=len(raw_messages) / max(len(recall), 1),
155
127
  )
156
128
 
157
129
 
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", ""))
130
+ def _scored_case(
131
+ fixture_directory: Path, all_case_fields: dict[str, object]
132
+ ) -> tuple[str, CaseScore, CaseScore, CaseScore]:
133
+ transcript_name = str(all_case_fields[EVAL_CASE_TRANSCRIPT_KEY])
134
+ transcript_text = (fixture_directory / transcript_name).read_text(encoding=EVAL_FILE_ENCODING)
135
+ all_messages = typed_user_messages(transcript_text.splitlines())
136
+ raw_messages = LINE_SEPARATOR.join(each_message.text for each_message in all_messages)
137
+ recall = recall_text(all_messages) or ""
138
+ return (
139
+ transcript_name,
140
+ score_condensed(recall, all_case_fields, raw_messages),
141
+ score_condensed(raw_messages, all_case_fields, raw_messages),
142
+ score_condensed("", all_case_fields, raw_messages),
167
143
  )
168
144
 
169
145
 
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.
146
+ def scored_cases(fixture_directory: Path) -> list[tuple[str, CaseScore, CaseScore, CaseScore]]:
147
+ """Trim every case's transcript and grade it beside the oracle and the null.
199
148
 
200
149
  ::
201
150
 
202
- "... Hooks: Got structured output: {\"ok\":false,\"reason\":\"#1 deploy\"}"
203
- -> "#1 deploy"
151
+ trim_cases.json with deploy_session.jsonl
152
+ -> [("deploy_session.jsonl", trim score, oracle score, null score)]
204
153
 
205
154
  Args:
206
- debug_text: The ``--debug-file`` log of one headless session.
155
+ fixture_directory: The folder that holds trim_cases.json and its transcripts.
207
156
 
208
157
  Returns:
209
- The text Claude Code handed the main agent, or None when no hook answered.
158
+ One row per case: its transcript name, then the trim, oracle, and null scores.
210
159
  """
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
- }
160
+ all_cases = json.loads(
161
+ (fixture_directory / EVAL_CASES_FILE_NAME).read_text(encoding=EVAL_FILE_ENCODING)
303
162
  )
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
- )
163
+ return [_scored_case(fixture_directory, each_case) for each_case in all_cases]
328
164
 
329
165
 
330
166
  def _summary_line(label: str, all_scores: list[CaseScore]) -> str:
331
- if not all_scores:
332
- return f"{label}: no scored runs"
333
167
  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
168
+ facts_share = sum(each_score.facts_kept for each_score in all_scores) / max(facts_total, 1)
335
169
  overruled_total = sum(each_score.overruled_total for each_score in all_scores)
336
170
  overruled_marked = sum(each_score.overruled_marked for each_score in all_scores)
337
171
  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)
172
+ mean_ratio = sum(each_score.compression_ratio for each_score in all_scores) / max(
173
+ len(all_scores), 1
174
+ )
339
175
  return (
340
176
  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,
177
+ f"ratio {mean_ratio:.1f}x ({len(all_scores)} cases)\n"
362
178
  )
363
179
 
364
180
 
365
181
  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
182
+ """Grade the trim on every fixture case, print the scores, and exit 1 on any loss."""
183
+ all_rows = scored_cases(EVAL_FIXTURE_DIRECTORY)
184
+ for each_name, each_score, _, _ in all_rows:
185
+ sys.stdout.write(
186
+ f"{each_name} facts {each_score.facts_kept}/{each_score.facts_total} "
187
+ f"overruled {each_score.overruled_marked}/{each_score.overruled_total} "
188
+ f"ratio {each_score.compression_ratio:.1f}x\n"
189
+ )
190
+ all_trim_scores = [each_row[1] for each_row in all_rows]
191
+ sys.stdout.write(_summary_line("trim", all_trim_scores))
192
+ sys.stdout.write(_summary_line("oracle (raw messages)", [each_row[2] for each_row in all_rows]))
193
+ sys.stdout.write(_summary_line("null (empty recall)", [each_row[3] for each_row in all_rows]))
194
+ return 0 if all_trim_scores and all(each.is_lossless for each in all_trim_scores) else 1
389
195
 
390
196
 
391
197
  if __name__ == "__main__":