@ethlete/agent-rules 0.1.0-next.4 → 0.1.0-next.5
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/CHANGELOG.md +16 -0
- package/README.md +27 -10
- package/content/hooks/context-warning.py +232 -98
- package/content/skills/figma-export/SKILL.md +193 -0
- package/content/skills/figma-export/dump-figma-layers.py +83 -0
- package/content/skills/figma-export/dump-figma-svg.py +235 -0
- package/content/skills/figma-export/measure-template.mjs +87 -0
- package/content/skills/query/SKILL.md +6 -0
- package/package.json +1 -1
- package/src/lib/config.d.ts +2 -2
- package/src/lib/owned-paths.js +19 -1
- package/src/lib/owned-paths.js.map +1 -1
- package/src/lib/plan.js +9 -3
- package/src/lib/plan.js.map +1 -1
- package/src/lib/targets/claude-hooks.d.ts +1 -23
- package/src/lib/targets/claude-hooks.js +15 -84
- package/src/lib/targets/claude-hooks.js.map +1 -1
- package/src/lib/targets/codex-hooks.d.ts +12 -0
- package/src/lib/targets/codex-hooks.js +33 -0
- package/src/lib/targets/codex-hooks.js.map +1 -0
- package/src/lib/targets/hooks-shared.d.ts +38 -0
- package/src/lib/targets/hooks-shared.js +95 -0
- package/src/lib/targets/hooks-shared.js.map +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @ethlete/agent-rules
|
|
2
2
|
|
|
3
|
+
## 0.1.0-next.5
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#3048](https://github.com/ethlete-io/ethdk/pull/3048) [`6e19999`](https://github.com/ethlete-io/ethdk/commit/6e199997f51b88aaa1860a56a6b96be057ba1205) Thanks [@github-actions](https://github.com/apps/github-actions)! - The `context-warning` hook now runs under Codex as well as Claude Code, registered in
|
|
8
|
+
`.codex/hooks.json` whenever the `codex` target is on.
|
|
9
|
+
|
|
10
|
+
- [#3049](https://github.com/ethlete-io/ethdk/pull/3049) [`a606dda`](https://github.com/ethlete-io/ethdk/commit/a606dda0695ac8cc4370816bf3ff1c0814436091) Thanks [@TomTomB](https://github.com/TomTomB)! - Add the `figma-export` skill, guiding agents through reconciling a component against a Figma "copy as CSS" export.
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- [#3048](https://github.com/ethlete-io/ethdk/pull/3048) [`fc56189`](https://github.com/ethlete-io/ethdk/commit/fc56189450a45f2b5819d40945a71205a6d67ba0) Thanks [@github-actions](https://github.com/apps/github-actions)! - The `query` skill now says to prefer `withArgs` over passing `args` to `execute()`, and when the imperative form is still right.
|
|
15
|
+
|
|
16
|
+
- [#3048](https://github.com/ethlete-io/ethdk/pull/3048) [`a16cc69`](https://github.com/ethlete-io/ethdk/commit/a16cc698a3cd3f14d0419d8f9710929fb41b4711) Thanks [@github-actions](https://github.com/apps/github-actions)! - Figma export skill: read an SVG frame as well as the CSS dump, with a `dump-figma-svg.py`
|
|
17
|
+
that prints its box tree and measures the auto-layout gaps.
|
|
18
|
+
|
|
3
19
|
## 0.1.0-next.4
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -115,8 +115,8 @@ a repo without `@ethlete/query` never sees the query guide.
|
|
|
115
115
|
|
|
116
116
|
## Hooks (opt-in)
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
Hooks run commands on the developer's machine, so none are emitted by default - opt in
|
|
119
|
+
per hook in the config:
|
|
120
120
|
|
|
121
121
|
```json
|
|
122
122
|
{
|
|
@@ -124,17 +124,31 @@ default - opt in per hook in the config:
|
|
|
124
124
|
}
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
They are emitted for whichever of the `claude` and `codex` targets is enabled:
|
|
128
|
+
|
|
129
|
+
| Target | Script | Registered in |
|
|
130
|
+
| -------- | ------------------------ | ----------------------- |
|
|
131
|
+
| `claude` | `.claude/hooks/ethlete/` | `.claude/settings.json` |
|
|
132
|
+
| `codex` | `.codex/hooks/ethlete/` | `.codex/hooks.json` |
|
|
133
|
+
|
|
134
|
+
Your own entries in those files are left untouched; removing the name from `hooks`
|
|
135
|
+
unregisters and deletes the script again. Codex only loads project-local hooks once the
|
|
136
|
+
`.codex/` layer is trusted, and honours `[features] hooks = false`.
|
|
130
137
|
|
|
131
138
|
Available hooks:
|
|
132
139
|
|
|
133
|
-
- **`context-warning`** - warns once per tier (and instructs
|
|
134
|
-
context crosses 70% / 85% of the token budget, recommending
|
|
135
|
-
capped at the 200k long-context pricing boundary: on 1M-window models every
|
|
136
|
-
past 200k input tokens bills the whole context at a premium rate, so the
|
|
137
|
-
fire at ~140k/~170k instead of deep into the expensive range.
|
|
140
|
+
- **`context-warning`** - warns once per tier (and instructs the agent) when the session
|
|
141
|
+
context crosses 70% / 85% of the token budget, recommending a handoff. Under Claude the
|
|
142
|
+
budget is capped at the 200k long-context pricing boundary: on 1M-window models every
|
|
143
|
+
request past 200k input tokens bills the whole context at a premium rate, so the
|
|
144
|
+
warnings fire at ~140k/~170k instead of deep into the expensive range. Codex has no
|
|
145
|
+
such boundary, so its budget is the model's own reported context window and the
|
|
146
|
+
warnings are pure occupancy.
|
|
147
|
+
|
|
148
|
+
Two things are Claude-only: the separate user-facing line (Codex documents only
|
|
149
|
+
`additionalContext`, so there the warning is folded into the text the model is told to
|
|
150
|
+
relay), and the auto-mode escalation that writes the handoff file unprompted - Codex's
|
|
151
|
+
`permission_mode` values are undocumented, so no value enables it.
|
|
138
152
|
|
|
139
153
|
Hooks can be turned off per machine - see the local config below.
|
|
140
154
|
|
|
@@ -153,6 +167,9 @@ differ per developer, without touching any committed file:
|
|
|
153
167
|
- **`disableHooks`** - `true` disables every generated hook; an array
|
|
154
168
|
(`["context-warning"]`) just the named ones. The hook scripts read the file at
|
|
155
169
|
runtime, so toggling takes effect on the next prompt - no `sync` needed.
|
|
170
|
+
- **`disableAutoHandoffSave`** - keeps the `context-warning` hook's tiered warnings but
|
|
171
|
+
drops the auto-mode escalation: at the critical tier it recommends `/handoff` instead
|
|
172
|
+
of saving the handoff file itself.
|
|
156
173
|
- **`sdkSourcePath`** - a local `ethlete-sdk` checkout. The `sdk-source` and
|
|
157
174
|
`sdk-local-build` skills read it when the agent needs the SDK's own sources, or has to
|
|
158
175
|
build the SDK and install it here through a `file:` dependency. A relative path is
|
|
@@ -2,31 +2,45 @@
|
|
|
2
2
|
"""UserPromptSubmit hook: warn when the context window is getting large.
|
|
3
3
|
|
|
4
4
|
Reads the hook input JSON from stdin, estimates the current context size from
|
|
5
|
-
the
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
the session transcript, and emits a warning (visible to both the user and the
|
|
6
|
+
agent) when it crosses a threshold. Recommends the handoff skill so work can
|
|
7
|
+
continue in a fresh session.
|
|
8
|
+
|
|
9
|
+
Runs under both Claude Code and Codex, selected by `--agent` on the command
|
|
10
|
+
line rather than sniffed from the payload — the generator writes the flag, so
|
|
11
|
+
the two never have to be told apart at runtime. The two differ in three ways
|
|
12
|
+
that matter here, all captured in AGENT_PROFILES:
|
|
13
|
+
|
|
14
|
+
* Transcript format. Claude writes one JSON object per message with
|
|
15
|
+
`message.usage`; Codex writes a rollout stream whose `token_count` events
|
|
16
|
+
carry a TokenUsageInfo. Codex's rollout format is explicitly not a stable
|
|
17
|
+
interface, so the parser searches each line for the usage object instead of
|
|
18
|
+
walking a fixed path.
|
|
19
|
+
* Context budget. Claude's budget is capped at the 200k long-context pricing
|
|
20
|
+
boundary: on models with a larger window, every request past that point
|
|
21
|
+
bills the entire context at the premium rate, which costs far more than
|
|
22
|
+
handing off into a fresh session ever would. Codex has no such boundary, so
|
|
23
|
+
its budget is just the window.
|
|
24
|
+
* How a handoff is invoked. Claude has /handoff and /clear; Codex has neither
|
|
25
|
+
and reads the skill from disk.
|
|
8
26
|
|
|
9
27
|
At the critical tier, if the session's permission_mode is "auto", the
|
|
10
28
|
instruction escalates from "recommend" to "just do it": auto mode already
|
|
11
|
-
means the user wants
|
|
29
|
+
means the user wants the agent acting without stopping to ask, and clearing +
|
|
12
30
|
resuming can't be triggered programmatically (no hook or tool can submit
|
|
13
31
|
input to a running session), so the best available automation is to save the
|
|
14
|
-
handoff file itself immediately rather than waiting for
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
The thresholds are fractions of a token budget, and the budget is capped at the
|
|
18
|
-
200k long-context pricing boundary: on models with a larger window, every
|
|
19
|
-
request past that point bills the entire context at the premium rate, which
|
|
20
|
-
costs far more than handing off into a fresh session ever would.
|
|
32
|
+
handoff file itself immediately rather than waiting for a natural stopping
|
|
33
|
+
point. Codex's permission_mode value set is undocumented, so its profile lists
|
|
34
|
+
no auto modes and the escalation stays off there.
|
|
21
35
|
|
|
22
36
|
Warns once per tier per session (state kept in a temp file); re-arms itself
|
|
23
|
-
if the context shrinks again (e.g. after
|
|
37
|
+
if the context shrinks again (e.g. after a compaction).
|
|
24
38
|
|
|
25
39
|
Can be disabled per machine via a gitignored ethlete-agents.config.local.json
|
|
26
40
|
at the repo root: {"disableHooks": true} or {"disableHooks": ["context-warning"]}.
|
|
27
41
|
To keep the tiered warnings but drop just the auto-mode auto-save escalation,
|
|
28
42
|
use {"disableAutoHandoffSave": true} instead - the critical tier then falls
|
|
29
|
-
back to recommending
|
|
43
|
+
back to recommending a handoff, same as non-auto mode.
|
|
30
44
|
|
|
31
45
|
Fail-safe: any error exits 0 with no output — the hook must never block a prompt.
|
|
32
46
|
"""
|
|
@@ -64,10 +78,87 @@ CONTEXT_WINDOWS = (
|
|
|
64
78
|
)
|
|
65
79
|
DEFAULT_WINDOW = 200_000
|
|
66
80
|
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
AGENT_PROFILES = {
|
|
82
|
+
"claude": {
|
|
83
|
+
"premium_boundary": PREMIUM_BOUNDARY,
|
|
84
|
+
# Claude's transcript reports no window, so it is resolved from the model id.
|
|
85
|
+
"default_window": None,
|
|
86
|
+
"auto_modes": ("auto",),
|
|
87
|
+
"emits_system_message": True,
|
|
88
|
+
"act_now": "Run /handoff now and continue in a fresh session.",
|
|
89
|
+
"act_later": "consider /handoff to continue in a fresh session",
|
|
90
|
+
"recommend": "recommend the user run /handoff to save state and start a fresh session",
|
|
91
|
+
"suggest": "suggest the user run /handoff to save state and start a fresh session",
|
|
92
|
+
"save_now": (
|
|
93
|
+
"run the handoff skill's save mode right now (finish only an in-flight atomic "
|
|
94
|
+
"edit first, nothing new). Then tell the user exactly which handoff file was "
|
|
95
|
+
"written and that they should run /clear, then '/handoff resume <slug>', to "
|
|
96
|
+
"continue — clearing and resuming can't be done programmatically, so this is "
|
|
97
|
+
"the one step still on them."
|
|
98
|
+
),
|
|
99
|
+
},
|
|
100
|
+
"codex": {
|
|
101
|
+
"premium_boundary": None,
|
|
102
|
+
# Only reached if a rollout omits model_context_window; the CONTEXT_WINDOWS table
|
|
103
|
+
# holds Claude model ids and would never match a Codex one.
|
|
104
|
+
"default_window": 272_000,
|
|
105
|
+
# Codex's permission_mode values are undocumented; until one is confirmed to mean
|
|
106
|
+
# "never ask", no value enables the auto-save escalation.
|
|
107
|
+
"auto_modes": (),
|
|
108
|
+
# Only hookSpecificOutput.additionalContext is documented for Codex, so the
|
|
109
|
+
# user-facing line is folded into the model-facing text instead.
|
|
110
|
+
"emits_system_message": False,
|
|
111
|
+
"act_now": "Save a handoff now and continue in a fresh session.",
|
|
112
|
+
"act_later": "consider saving a handoff and continuing in a fresh session",
|
|
113
|
+
"recommend": (
|
|
114
|
+
"tell the user you are near the context limit, then follow "
|
|
115
|
+
".agents/skills/ethlete-handoff/SKILL.md to save a handoff and have them start a "
|
|
116
|
+
"fresh session"
|
|
117
|
+
),
|
|
118
|
+
"suggest": (
|
|
119
|
+
"suggest saving a handoff via .agents/skills/ethlete-handoff/SKILL.md and "
|
|
120
|
+
"continuing in a fresh session"
|
|
121
|
+
),
|
|
122
|
+
"save_now": (
|
|
123
|
+
"follow .agents/skills/ethlete-handoff/SKILL.md and save a handoff right now "
|
|
124
|
+
"(finish only an in-flight atomic edit first, nothing new). Then tell the user "
|
|
125
|
+
"exactly which handoff file was written and that they should start a fresh "
|
|
126
|
+
"codex session and resume from it."
|
|
127
|
+
),
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def agent_name(argv):
|
|
133
|
+
"""The --agent value, defaulting to claude — older registrations pass no flag."""
|
|
134
|
+
for index, arg in enumerate(argv):
|
|
135
|
+
if arg == "--agent" and index + 1 < len(argv):
|
|
136
|
+
return argv[index + 1]
|
|
137
|
+
if arg.startswith("--agent="):
|
|
138
|
+
return arg.split("=", 1)[1]
|
|
139
|
+
return "claude"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def repo_root(data):
|
|
143
|
+
"""Repo root: the env var the agent sets, else derived from this script's own location.
|
|
144
|
+
|
|
145
|
+
The script always lives at <root>/.<agent>/hooks/ethlete/context-warning.py, so walking
|
|
146
|
+
four levels up works for any agent that has no project-dir variable of its own.
|
|
147
|
+
"""
|
|
148
|
+
for variable in ("CLAUDE_PROJECT_DIR", "CODEX_PROJECT_DIR"):
|
|
149
|
+
value = os.environ.get(variable)
|
|
150
|
+
if value:
|
|
151
|
+
return value
|
|
152
|
+
here = os.path.abspath(__file__)
|
|
153
|
+
derived = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here))))
|
|
154
|
+
if os.path.isfile(os.path.join(derived, LOCAL_CONFIG_FILE)):
|
|
155
|
+
return derived
|
|
156
|
+
cwd = data.get("cwd")
|
|
157
|
+
return cwd if isinstance(cwd, str) and cwd else derived
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_local_config(root):
|
|
69
161
|
"""Parsed ethlete-agents.config.local.json at the repo root, or {} if missing/unreadable."""
|
|
70
|
-
root = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
71
162
|
if not root:
|
|
72
163
|
return {}
|
|
73
164
|
try:
|
|
@@ -98,10 +189,11 @@ def window_for(model):
|
|
|
98
189
|
return DEFAULT_WINDOW
|
|
99
190
|
|
|
100
191
|
|
|
101
|
-
def
|
|
102
|
-
"""(tokens, model) from the last main-chain assistant message.
|
|
192
|
+
def claude_context_state(transcript_path):
|
|
193
|
+
"""(tokens, model, window) from the last main-chain assistant message.
|
|
103
194
|
|
|
104
|
-
tokens ≈ its total input tokens (fresh + cache read + cache creation).
|
|
195
|
+
tokens ≈ its total input tokens (fresh + cache read + cache creation). Claude reports no
|
|
196
|
+
window in the transcript, so it is resolved from the model id by the caller.
|
|
105
197
|
"""
|
|
106
198
|
last_usage = None
|
|
107
199
|
last_model = None
|
|
@@ -119,111 +211,151 @@ def context_state(transcript_path):
|
|
|
119
211
|
last_usage = usage
|
|
120
212
|
last_model = message.get("model") or last_model
|
|
121
213
|
if not last_usage:
|
|
122
|
-
return 0, last_model
|
|
214
|
+
return 0, last_model, None
|
|
123
215
|
tokens = (
|
|
124
216
|
last_usage.get("input_tokens", 0)
|
|
125
217
|
+ last_usage.get("cache_read_input_tokens", 0)
|
|
126
218
|
+ last_usage.get("cache_creation_input_tokens", 0)
|
|
127
219
|
)
|
|
128
|
-
return tokens, last_model
|
|
220
|
+
return tokens, last_model, None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def find_token_usage_info(node):
|
|
224
|
+
"""The deepest TokenUsageInfo-shaped dict in a parsed rollout line, or None.
|
|
225
|
+
|
|
226
|
+
Codex documents its rollout format as unstable, so this searches for the shape
|
|
227
|
+
(a dict carrying `last_token_usage`) rather than walking a fixed key path.
|
|
228
|
+
"""
|
|
229
|
+
if isinstance(node, dict):
|
|
230
|
+
if isinstance(node.get("last_token_usage"), dict):
|
|
231
|
+
return node
|
|
232
|
+
for value in node.values():
|
|
233
|
+
found = find_token_usage_info(value)
|
|
234
|
+
if found:
|
|
235
|
+
return found
|
|
236
|
+
elif isinstance(node, list):
|
|
237
|
+
for value in node:
|
|
238
|
+
found = find_token_usage_info(value)
|
|
239
|
+
if found:
|
|
240
|
+
return found
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def codex_context_state(transcript_path):
|
|
245
|
+
"""(tokens, model, window) from the last token_count event in a Codex rollout.
|
|
246
|
+
|
|
247
|
+
tokens is the last request's `total_tokens` — Codex's own `tokens_in_context_window()`
|
|
248
|
+
is exactly that field, and `last_token_usage` is replaced per request while
|
|
249
|
+
`total_token_usage` accumulates across the whole session.
|
|
250
|
+
"""
|
|
251
|
+
last_info = None
|
|
252
|
+
last_model = None
|
|
253
|
+
with open(transcript_path, encoding="utf-8") as f:
|
|
254
|
+
for line in f:
|
|
255
|
+
try:
|
|
256
|
+
obj = json.loads(line)
|
|
257
|
+
except json.JSONDecodeError:
|
|
258
|
+
continue
|
|
259
|
+
model = (obj.get("payload") or {}).get("model") if isinstance(obj.get("payload"), dict) else None
|
|
260
|
+
if isinstance(model, str) and model:
|
|
261
|
+
last_model = model
|
|
262
|
+
info = find_token_usage_info(obj)
|
|
263
|
+
if info:
|
|
264
|
+
last_info = info
|
|
265
|
+
if not last_info:
|
|
266
|
+
return 0, last_model, None
|
|
267
|
+
usage = last_info.get("last_token_usage") or {}
|
|
268
|
+
window = last_info.get("model_context_window")
|
|
269
|
+
return usage.get("total_tokens", 0), last_model, window if isinstance(window, int) else None
|
|
270
|
+
|
|
129
271
|
|
|
272
|
+
CONTEXT_READERS = {"claude": claude_context_state, "codex": codex_context_state}
|
|
130
273
|
|
|
131
|
-
|
|
274
|
+
|
|
275
|
+
def messages(profile, tier, tokens, budget, priced, auto_mode):
|
|
132
276
|
"""(systemMessage, additionalContext) for a tier.
|
|
133
277
|
|
|
134
278
|
priced: the budget is the pricing boundary, not the window — the reason to
|
|
135
279
|
hand off is cost, not an imminent auto-compact.
|
|
136
|
-
auto_mode: at the critical tier, escalates from "recommend
|
|
280
|
+
auto_mode: at the critical tier, escalates from "recommend a handoff" to
|
|
137
281
|
"save it now" — see the module docstring for why.
|
|
138
282
|
"""
|
|
139
283
|
k = f"~{tokens // 1000}k"
|
|
140
284
|
pct = round(tokens / budget * 100)
|
|
141
285
|
budget_k = f"{budget // 1000}k"
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
f"
|
|
148
|
-
"
|
|
149
|
-
"an in-flight atomic edit first, nothing new). Then tell the user exactly which "
|
|
150
|
-
"handoff file was written and that they should run /clear, then "
|
|
151
|
-
"'/handoff resume <slug>', to continue — clearing and resuming can't be done "
|
|
152
|
-
"programmatically, so this is the one step still on them.",
|
|
153
|
-
)
|
|
154
|
-
if tier == 2 and auto_mode:
|
|
155
|
-
return (
|
|
156
|
-
f"🔴 Context is at {k} tokens ({pct}% of the {budget_k} window) — "
|
|
157
|
-
f"auto-compact is imminent. Auto mode is active: saving a handoff now.",
|
|
158
|
-
f"[context-warning hook] The context window is at {k} tokens — {pct}% of "
|
|
159
|
-
f"this model's {budget_k} window (critical, ≥{int(CRITICAL_FRACTION * 100)}%). "
|
|
160
|
-
"Auto mode is active, so don't just recommend a handoff — run the handoff "
|
|
161
|
-
"skill's save mode right now (finish only an in-flight atomic edit first, "
|
|
162
|
-
"nothing new). Then tell the user exactly which handoff file was written and "
|
|
163
|
-
"that they should run /clear, then '/handoff resume <slug>', to continue — "
|
|
164
|
-
"clearing and resuming can't be done programmatically, so this is the one step "
|
|
165
|
-
"still on them.",
|
|
286
|
+
|
|
287
|
+
if priced:
|
|
288
|
+
approach = "about to cross" if tier == 2 else "approaching"
|
|
289
|
+
headline = f"Context is at {k} tokens — {approach} the {budget_k} long-context pricing boundary"
|
|
290
|
+
detail = (
|
|
291
|
+
f"The context is at {k} tokens — {pct}% of the {budget_k} long-context "
|
|
292
|
+
f"pricing boundary."
|
|
166
293
|
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
f"
|
|
171
|
-
f"
|
|
172
|
-
f"[context-warning hook] The context is at {k} tokens — {pct}% of the "
|
|
173
|
-
f"{budget_k} long-context pricing boundary. Past it every request is billed at "
|
|
174
|
-
"the premium rate. Finish only the immediate step, then recommend the user run "
|
|
175
|
-
"/handoff to save state and start a fresh session. Do not start new sub-tasks.",
|
|
294
|
+
else:
|
|
295
|
+
headline = f"Context is at {k} tokens ({pct}% of the {budget_k} window)"
|
|
296
|
+
detail = (
|
|
297
|
+
f"The context window is at {k} tokens — {pct}% of this model's "
|
|
298
|
+
f"{budget_k} window"
|
|
176
299
|
)
|
|
300
|
+
|
|
177
301
|
if tier == 2:
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
"save state and start a fresh session. Do not start new sub-tasks.",
|
|
302
|
+
threshold = f" (critical, ≥{int(CRITICAL_FRACTION * 100)}%)"
|
|
303
|
+
detail = detail if priced else f"{detail}{threshold}."
|
|
304
|
+
pressure = (
|
|
305
|
+
"Past it every request is billed at the premium rate. "
|
|
306
|
+
if priced
|
|
307
|
+
else ""
|
|
185
308
|
)
|
|
186
|
-
|
|
309
|
+
tail = "" if priced else " — auto-compact is imminent"
|
|
310
|
+
if auto_mode:
|
|
311
|
+
return (
|
|
312
|
+
f"🔴 {headline}{tail}. Auto mode is active: saving a handoff now.",
|
|
313
|
+
f"[context-warning hook] {detail} {pressure}Auto mode is active, so don't "
|
|
314
|
+
f"just recommend a handoff — {profile['save_now']}",
|
|
315
|
+
)
|
|
187
316
|
return (
|
|
188
|
-
f"
|
|
189
|
-
f"
|
|
190
|
-
f"
|
|
191
|
-
f"[context-warning hook] The context is at {k} tokens — {pct}% of the "
|
|
192
|
-
f"{budget_k} long-context pricing boundary, past which every request is "
|
|
193
|
-
"billed at the premium rate. When the current task reaches a natural "
|
|
194
|
-
"stopping point, suggest the user run /handoff to save state and start a "
|
|
195
|
-
"fresh session. Keep working normally until then.",
|
|
317
|
+
f"🔴 {headline}{tail}. {profile['act_now']}",
|
|
318
|
+
f"[context-warning hook] {detail} {pressure}Finish only the immediate step, "
|
|
319
|
+
f"then {profile['recommend']}. Do not start new sub-tasks.",
|
|
196
320
|
)
|
|
321
|
+
|
|
322
|
+
threshold = f" (≥{int(WARN_FRACTION * 100)}%)"
|
|
323
|
+
detail = detail if priced else f"{detail}{threshold}."
|
|
324
|
+
pressure = "Past it every request is billed at the premium rate. " if priced else ""
|
|
197
325
|
return (
|
|
198
|
-
f"🟡
|
|
199
|
-
f"
|
|
200
|
-
f"
|
|
201
|
-
f"this model's {budget_k} window (≥{int(WARN_FRACTION * 100)}%). When the "
|
|
202
|
-
"current task reaches a natural stopping point, suggest the user run "
|
|
203
|
-
"/handoff to save state and start a fresh session. Keep working normally until then.",
|
|
326
|
+
f"🟡 {headline}. At the next natural stopping point, {profile['act_later']}.",
|
|
327
|
+
f"[context-warning hook] {detail} {pressure}When the current task reaches a "
|
|
328
|
+
f"natural stopping point, {profile['suggest']}. Keep working normally until then.",
|
|
204
329
|
)
|
|
205
330
|
|
|
206
331
|
|
|
207
332
|
def main():
|
|
208
|
-
|
|
209
|
-
|
|
333
|
+
agent = agent_name(sys.argv[1:])
|
|
334
|
+
profile = AGENT_PROFILES.get(agent)
|
|
335
|
+
if not profile:
|
|
210
336
|
return
|
|
337
|
+
|
|
211
338
|
data = json.load(sys.stdin)
|
|
339
|
+
local_config = load_local_config(repo_root(data))
|
|
340
|
+
if disabled_locally(local_config):
|
|
341
|
+
return
|
|
342
|
+
|
|
212
343
|
transcript_path = data.get("transcript_path")
|
|
213
344
|
session_id = data.get("session_id", "unknown")
|
|
214
|
-
auto_mode = data.get("permission_mode")
|
|
345
|
+
auto_mode = data.get("permission_mode") in profile["auto_modes"] and not auto_handoff_save_disabled(local_config)
|
|
215
346
|
if not transcript_path or not os.path.isfile(transcript_path):
|
|
216
347
|
return
|
|
217
348
|
|
|
218
|
-
tokens, model =
|
|
219
|
-
window = window_for(model)
|
|
220
|
-
|
|
349
|
+
tokens, model, reported_window = CONTEXT_READERS[agent](transcript_path)
|
|
350
|
+
window = reported_window or profile["default_window"] or window_for(model)
|
|
351
|
+
boundary = profile["premium_boundary"]
|
|
352
|
+
budget = min(window, boundary) if boundary else window
|
|
221
353
|
warn_tokens = int(budget * WARN_FRACTION)
|
|
222
354
|
critical_tokens = int(budget * CRITICAL_FRACTION)
|
|
223
355
|
tier = 2 if tokens >= critical_tokens else 1 if tokens >= warn_tokens else 0
|
|
224
356
|
|
|
225
357
|
state_file = os.path.join(
|
|
226
|
-
tempfile.gettempdir(), f"
|
|
358
|
+
tempfile.gettempdir(), f"{agent}-context-warning-{session_id}"
|
|
227
359
|
)
|
|
228
360
|
prev_tier = 0
|
|
229
361
|
try:
|
|
@@ -243,21 +375,23 @@ def main():
|
|
|
243
375
|
return # already warned at this tier (or context shrank — state re-armed above)
|
|
244
376
|
|
|
245
377
|
system_message, additional_context = messages(
|
|
246
|
-
tier, tokens, budget, budget < window, auto_mode
|
|
378
|
+
profile, tier, tokens, budget, budget < window, auto_mode
|
|
247
379
|
)
|
|
248
380
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
381
|
+
if not profile["emits_system_message"]:
|
|
382
|
+
additional_context = f"{additional_context}\n\nTell the user: {system_message}"
|
|
383
|
+
|
|
384
|
+
payload = {
|
|
385
|
+
"hookSpecificOutput": {
|
|
386
|
+
"hookEventName": "UserPromptSubmit",
|
|
387
|
+
"additionalContext": additional_context,
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if profile["emits_system_message"]:
|
|
391
|
+
payload["systemMessage"] = system_message
|
|
392
|
+
payload["suppressOutput"] = True
|
|
393
|
+
|
|
394
|
+
print(json.dumps(payload))
|
|
261
395
|
|
|
262
396
|
|
|
263
397
|
if __name__ == "__main__":
|