claude-dev-env 8.90.1 → 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.
- package/bin/install-constants.mjs +3 -0
- package/bin/install.cursor-rules.test.mjs +11 -1
- package/bin/install.mjs +5 -0
- package/bin/install.settings-defaults.test.mjs +0 -1
- package/hooks/features/session-context.md +5 -4
- package/hooks/hooks.json +0 -13
- package/hooks/hooks_constants/compaction_user_messages_constants.py +141 -47
- package/hooks/session/compaction_recall_prompt.py +29 -18
- package/hooks/session/compaction_trim.py +252 -0
- package/hooks/session/compaction_trim_eval.py +85 -279
- package/hooks/session/compaction_user_messages.py +42 -143
- package/hooks/session/fixtures/compaction_user_messages/codex_rollout.jsonl +22 -0
- package/hooks/session/fixtures/compaction_user_messages/random_9031_1.jsonl +42 -0
- package/hooks/session/fixtures/compaction_user_messages/random_9031_2.jsonl +42 -0
- package/hooks/session/fixtures/compaction_user_messages/trim_cases.json +46 -0
- package/hooks/session/test_compaction_recall_prompt.py +45 -82
- package/hooks/session/test_compaction_trim.py +169 -0
- package/hooks/session/test_compaction_trim_eval.py +32 -85
- package/hooks/session/test_compaction_user_messages.py +37 -95
- package/package.json +1 -1
- package/settings.json +1 -2
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Trim the user's typed messages to their substance, with no model call.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
#1 "Could you please add the retry to the deploy script"
|
|
6
|
+
#2 "thanks"
|
|
7
|
+
#3 "Change of plan, drop the retry from the deploy script"
|
|
8
|
+
-> #1 add retry to deploy script (overruled by #3)
|
|
9
|
+
#3 Change of plan, drop retry from deploy script
|
|
10
|
+
|
|
11
|
+
The trim drops filler phrases, articles, and HTML comments, and drops a
|
|
12
|
+
message that only expresses gratitude, such as ``thanks``. Approvals and
|
|
13
|
+
continuation requests stay in the recall. An artifact
|
|
14
|
+
comment keeps its link, its anchor, and the quoted comment, and loses the
|
|
15
|
+
relay text around them. Text inside backticks and tokens
|
|
16
|
+
that hold a path, a link, or a version stay as typed. A message that reverses
|
|
17
|
+
an earlier one marks the earlier line it shares the most words with.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
from dataclasses import replace
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
27
|
+
if _hooks_directory not in sys.path:
|
|
28
|
+
sys.path.insert(0, _hooks_directory)
|
|
29
|
+
_session_directory = str(Path(__file__).resolve().parent)
|
|
30
|
+
if _session_directory not in sys.path:
|
|
31
|
+
sys.path.insert(0, _session_directory)
|
|
32
|
+
|
|
33
|
+
from compaction_user_messages import TypedMessage
|
|
34
|
+
from hooks_constants.compaction_user_messages_constants import (
|
|
35
|
+
ALL_STOP_WORDS,
|
|
36
|
+
ARTICLE_PATTERN,
|
|
37
|
+
ARTIFACT_COMMENT_MARKER,
|
|
38
|
+
ARTIFACT_COMMENT_TEMPLATE,
|
|
39
|
+
ARTIFACT_LOCATION_PATTERN,
|
|
40
|
+
ARTIFACT_LOCATION_TEMPLATE,
|
|
41
|
+
ARTIFACT_QUOTE_GROUP,
|
|
42
|
+
ARTIFACT_QUOTE_PATTERN,
|
|
43
|
+
ARTIFACT_URL_PATTERN,
|
|
44
|
+
CODE_SPAN_PATTERN,
|
|
45
|
+
CONTENT_WORD_EDGE_PUNCTUATION,
|
|
46
|
+
CONTENT_WORD_PATTERN,
|
|
47
|
+
CUT_MESSAGE_SUFFIX_TEMPLATE,
|
|
48
|
+
DANGLING_COMMA_PATTERN,
|
|
49
|
+
FILLER_PATTERN,
|
|
50
|
+
GRATITUDE_PATTERN,
|
|
51
|
+
HTML_COMMENT_PATTERN,
|
|
52
|
+
INTERRUPT_PATTERN,
|
|
53
|
+
LEFT_OUT_LINES_TEMPLATE,
|
|
54
|
+
LINE_SEPARATOR,
|
|
55
|
+
MAXIMUM_MESSAGE_CHARACTERS,
|
|
56
|
+
MAXIMUM_RECALL_CHARACTERS,
|
|
57
|
+
OVERRULED_SUFFIX_TEMPLATE,
|
|
58
|
+
QUOTE_PREFIX_PATTERN,
|
|
59
|
+
REVERSAL_PATTERN,
|
|
60
|
+
SPACE_BEFORE_PUNCTUATION_PATTERN,
|
|
61
|
+
TRIMMED_EDGE_CHARACTERS,
|
|
62
|
+
TRIMMED_LINE_TEMPLATE,
|
|
63
|
+
WHITESPACE_RUN_PATTERN,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_acknowledgment(text: str) -> bool:
|
|
68
|
+
return bool(
|
|
69
|
+
INTERRUPT_PATTERN.fullmatch(text.strip()) or GRATITUDE_PATTERN.fullmatch(text.strip())
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _artifact_comment(text: str) -> str:
|
|
74
|
+
if ARTIFACT_COMMENT_MARKER not in text:
|
|
75
|
+
return text
|
|
76
|
+
quote = ARTIFACT_QUOTE_PATTERN.search(text)
|
|
77
|
+
url = ARTIFACT_URL_PATTERN.search(text)
|
|
78
|
+
if quote is None or url is None:
|
|
79
|
+
return text
|
|
80
|
+
location = ARTIFACT_LOCATION_PATTERN.search(text)
|
|
81
|
+
return ARTIFACT_COMMENT_TEMPLATE.format(
|
|
82
|
+
url=url.group(1),
|
|
83
|
+
location=(
|
|
84
|
+
ARTIFACT_LOCATION_TEMPLATE.format(location=location.group(1).strip())
|
|
85
|
+
if location
|
|
86
|
+
else ""
|
|
87
|
+
),
|
|
88
|
+
comment=QUOTE_PREFIX_PATTERN.sub("", quote.group(ARTIFACT_QUOTE_GROUP)).strip(),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _substance(text: str) -> str:
|
|
93
|
+
return HTML_COMMENT_PATTERN.sub("", _artifact_comment(text))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _trimmed_prose(prose: str) -> str:
|
|
97
|
+
if CODE_SPAN_PATTERN.fullmatch(prose):
|
|
98
|
+
return prose
|
|
99
|
+
return ARTICLE_PATTERN.sub("", FILLER_PATTERN.sub("", prose))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def caveman_text(text: str) -> str:
|
|
103
|
+
"""Return the text without filler phrases and articles, spacing tidied.
|
|
104
|
+
|
|
105
|
+
::
|
|
106
|
+
|
|
107
|
+
"Could you please add the retry to `the deploy.sh`"
|
|
108
|
+
-> "add retry to `the deploy.sh`"
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
text: One typed message.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The trimmed text; backtick spans and path-like tokens unchanged.
|
|
115
|
+
"""
|
|
116
|
+
trimmed_text = "".join(
|
|
117
|
+
_trimmed_prose(each_part) for each_part in CODE_SPAN_PATTERN.split(text)
|
|
118
|
+
)
|
|
119
|
+
single_spaced = WHITESPACE_RUN_PATTERN.sub(" ", trimmed_text)
|
|
120
|
+
punctuated = SPACE_BEFORE_PUNCTUATION_PATTERN.sub(r"\1", single_spaced)
|
|
121
|
+
return DANGLING_COMMA_PATTERN.sub("", punctuated).strip(TRIMMED_EDGE_CHARACTERS)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _content_words(text: str) -> set[str]:
|
|
125
|
+
all_words = {
|
|
126
|
+
each_word.casefold().rstrip(CONTENT_WORD_EDGE_PUNCTUATION)
|
|
127
|
+
for each_word in CONTENT_WORD_PATTERN.findall(text)
|
|
128
|
+
}
|
|
129
|
+
return all_words - ALL_STOP_WORDS
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _closest_earlier_position(
|
|
133
|
+
all_later_words: set[str], all_earlier_words_by_position: list[tuple[int, set[str]]]
|
|
134
|
+
) -> int | None:
|
|
135
|
+
best_position, best_overlap = None, 0
|
|
136
|
+
for each_position, each_earlier_words in all_earlier_words_by_position:
|
|
137
|
+
overlap = len(all_later_words & each_earlier_words)
|
|
138
|
+
if overlap > best_overlap:
|
|
139
|
+
best_position, best_overlap = each_position, overlap
|
|
140
|
+
return best_position
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _overruled_by(all_kept_messages: list[TypedMessage]) -> dict[int, int]:
|
|
144
|
+
later_position_by_earlier: dict[int, int] = {}
|
|
145
|
+
all_words_by_position = [
|
|
146
|
+
(each_message.position, _content_words(each_message.text))
|
|
147
|
+
for each_message in all_kept_messages
|
|
148
|
+
]
|
|
149
|
+
for each_index, each_message in enumerate(all_kept_messages):
|
|
150
|
+
if not REVERSAL_PATTERN.search(each_message.text):
|
|
151
|
+
continue
|
|
152
|
+
earlier_position = _closest_earlier_position(
|
|
153
|
+
all_words_by_position[each_index][1], all_words_by_position[:each_index]
|
|
154
|
+
)
|
|
155
|
+
if earlier_position is not None:
|
|
156
|
+
later_position_by_earlier[earlier_position] = each_message.position
|
|
157
|
+
return later_position_by_earlier
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _capped_text(text: str) -> str:
|
|
161
|
+
if len(text) <= MAXIMUM_MESSAGE_CHARACTERS:
|
|
162
|
+
return text
|
|
163
|
+
cut_count = len(text) - MAXIMUM_MESSAGE_CHARACTERS
|
|
164
|
+
return text[:MAXIMUM_MESSAGE_CHARACTERS] + CUT_MESSAGE_SUFFIX_TEMPLATE.format(
|
|
165
|
+
cut_count=cut_count
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _messages_with_content(all_messages: list[TypedMessage]) -> list[TypedMessage]:
|
|
170
|
+
all_substance_messages = [
|
|
171
|
+
replace(each_message, text=_substance(each_message.text)) for each_message in all_messages
|
|
172
|
+
]
|
|
173
|
+
return [
|
|
174
|
+
each_message
|
|
175
|
+
for each_message in all_substance_messages
|
|
176
|
+
if each_message.text.strip() and not _is_acknowledgment(each_message.text)
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _trimmed_line(message: TypedMessage, later_position: int | None) -> str:
|
|
181
|
+
line = TRIMMED_LINE_TEMPLATE.format(
|
|
182
|
+
position=message.position, text=_capped_text(caveman_text(message.text))
|
|
183
|
+
)
|
|
184
|
+
if later_position is None:
|
|
185
|
+
return line
|
|
186
|
+
return line + OVERRULED_SUFFIX_TEMPLATE.format(later_position=later_position)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def trimmed_lines(all_messages: list[TypedMessage]) -> list[str]:
|
|
190
|
+
"""Return one trimmed line per message that carries content, oldest first.
|
|
191
|
+
|
|
192
|
+
::
|
|
193
|
+
|
|
194
|
+
[#1 "use Postgres", #2 "thanks", #3 "change of plan, SQLite over Postgres"]
|
|
195
|
+
-> ["#1 use Postgres (overruled by #3)", "#3 change of plan, SQLite over Postgres"]
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
all_messages: Every typed message, oldest first.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Lines that start with the message number; a reversed line ends with its marker.
|
|
202
|
+
"""
|
|
203
|
+
all_kept_messages = _messages_with_content(all_messages)
|
|
204
|
+
later_position_by_earlier = _overruled_by(all_kept_messages)
|
|
205
|
+
return [
|
|
206
|
+
_trimmed_line(each_message, later_position_by_earlier.get(each_message.position))
|
|
207
|
+
for each_message in all_kept_messages
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _newest_lines_within(all_later_lines: list[str], character_budget: int) -> list[str]:
|
|
212
|
+
all_kept_lines: list[str] = []
|
|
213
|
+
remaining_characters = character_budget
|
|
214
|
+
for each_line in reversed(all_later_lines):
|
|
215
|
+
line_size = len(each_line) + len(LINE_SEPARATOR)
|
|
216
|
+
if line_size > remaining_characters:
|
|
217
|
+
break
|
|
218
|
+
remaining_characters -= line_size
|
|
219
|
+
all_kept_lines.append(each_line)
|
|
220
|
+
return list(reversed(all_kept_lines))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def recall_text(all_messages: list[TypedMessage]) -> str | None:
|
|
224
|
+
"""Return the trimmed lines that fit the recall budget, or None when none exist.
|
|
225
|
+
|
|
226
|
+
::
|
|
227
|
+
|
|
228
|
+
5 short messages -> all 5 lines
|
|
229
|
+
#1 plus 400 long messages -> #1, a left-out note, then the newest that fit
|
|
230
|
+
|
|
231
|
+
The first line keeps the session's original ask. The newest lines carry
|
|
232
|
+
the latest corrections, which override older asks.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
all_messages: Every typed message, oldest first.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The lines joined by newlines, at most ``MAXIMUM_RECALL_CHARACTERS`` long
|
|
239
|
+
apart from the left-out note, or None.
|
|
240
|
+
"""
|
|
241
|
+
all_lines = trimmed_lines(all_messages)
|
|
242
|
+
if not all_lines:
|
|
243
|
+
return None
|
|
244
|
+
first_line, *all_later_lines = all_lines
|
|
245
|
+
all_kept_later_lines = _newest_lines_within(
|
|
246
|
+
all_later_lines, MAXIMUM_RECALL_CHARACTERS - len(first_line)
|
|
247
|
+
)
|
|
248
|
+
left_out_count = len(all_later_lines) - len(all_kept_later_lines)
|
|
249
|
+
all_left_out_notes = (
|
|
250
|
+
[LEFT_OUT_LINES_TEMPLATE.format(left_out_count=left_out_count)] if left_out_count else []
|
|
251
|
+
)
|
|
252
|
+
return LINE_SEPARATOR.join([first_line, *all_left_out_notes, *all_kept_later_lines])
|