captchakraken 2.3.0 → 2.6.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.
Files changed (54) hide show
  1. package/LICENSE +47 -7
  2. package/README.md +24 -4
  3. package/dist/cli-invocation.d.ts +46 -0
  4. package/dist/cli-invocation.js +68 -0
  5. package/dist/errors.d.ts +45 -0
  6. package/dist/errors.js +74 -0
  7. package/dist/index.d.ts +50 -0
  8. package/dist/index.js +52 -1
  9. package/dist/limits.d.ts +51 -0
  10. package/dist/limits.js +54 -0
  11. package/dist/model-name.d.ts +23 -0
  12. package/dist/model-name.js +70 -0
  13. package/dist/playwright-types.d.ts +28 -0
  14. package/dist/puppeteer-adapter.d.ts +11 -0
  15. package/dist/puppeteer-adapter.js +23 -0
  16. package/dist/python-command.d.ts +50 -0
  17. package/dist/python-command.js +60 -0
  18. package/dist/slide-geometry.d.ts +33 -0
  19. package/dist/slide-geometry.js +64 -0
  20. package/dist/solver.d.ts +214 -12
  21. package/dist/solver.js +1297 -165
  22. package/dist/trajectory.d.ts +31 -0
  23. package/dist/trajectory.js +162 -0
  24. package/dist/types.d.ts +128 -11
  25. package/dist/watcher.d.ts +99 -0
  26. package/dist/watcher.js +129 -0
  27. package/package.json +2 -5
  28. package/python/LICENSE +47 -7
  29. package/python/README.md +25 -8
  30. package/python/examples/README.md +36 -0
  31. package/python/examples/_harness.py +182 -94
  32. package/python/examples/demoHcaptcha.py +2 -1
  33. package/python/examples/demoRecaptcha.py +2 -1
  34. package/python/examples/watch_playwright.py +56 -0
  35. package/python/examples/with_playwright.py +43 -0
  36. package/python/pyproject.toml +4 -3
  37. package/python/src/captchakraken/__init__.py +5 -3
  38. package/python/src/captchakraken/action_types.py +24 -3
  39. package/python/src/captchakraken/cli.py +224 -2
  40. package/python/src/captchakraken/config.py +119 -17
  41. package/python/src/captchakraken/errors.py +195 -0
  42. package/python/src/captchakraken/keyframes.py +690 -0
  43. package/python/src/captchakraken/models.json +120 -0
  44. package/python/src/captchakraken/page_solver.py +1465 -102
  45. package/python/src/captchakraken/pinned_model.json +5 -7
  46. package/python/src/captchakraken/planner.py +328 -73
  47. package/python/src/captchakraken/prompts.py +485 -0
  48. package/python/src/captchakraken/solver.py +182 -11
  49. package/python/src/captchakraken/timing.py +70 -0
  50. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  51. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  52. package/python/src/captchakraken/trajectory.py +2 -7
  53. package/python/src/captchakraken/watcher.py +193 -0
  54. package/python/src/captchakraken/planner_types.py +0 -74
@@ -9,20 +9,18 @@
9
9
  "for the legacy output/PascalCase schema. The model answered in a hybrid,",
10
10
  "the parser dropped it, and EVERY drag puzzle failed as 'unsupported' with",
11
11
  "no test going red. Recording the prompt hashes here means any edit to a",
12
- "serving prompt fails CI until someone consciously re-pins — which forces",
12
+ "serving prompt fails CI until someone consciously re-pins \u2014 which forces",
13
13
  "the question 'does the pinned adapter still expect this prompt?'.",
14
14
  "",
15
15
  "To ship a new adapter: update lora_adapter/lora_revision, re-run the Tier 2",
16
16
  "static-image gate against it, and update prompt_sha256 if the prompts moved."
17
17
  ],
18
-
19
18
  "base_model": "RedHatAI/Qwen3.5-9B-FP8-dynamic",
20
- "lora_adapter": "CaptchaKraken/CaptchaKraken_v1.1",
19
+ "lora_adapter": "CaptchaKraken/CaptchaKraken-Lora-v1.2",
21
20
  "lora_revision": "main",
22
- "lora_name": "captcha",
23
-
21
+ "lora_name": "captcha-v12",
24
22
  "prompt_sha256": {
25
- "PIXEL_ACTION_PROMPT": "15d4833135330aa57ec64eb9132d15739cbf4a10bcda2e6d85d2d0ad92e0216a",
26
- "SELECT_GRID_PROMPT": "f014dbc1cb8c773cc9501ff97aea92ff0281e5687b3d637d1c626ded9212fd3c"
23
+ "PIXEL_ACTION_PROMPT": "21afc1def6faaa706ba4a8afc2e243f949fc6bb8936e624ecf8be0b82baeaae1",
24
+ "SELECT_GRID_PROMPT": "b259d39ac78adc92c4fe4e78aee248d073342aa4a797e9708d37dcea7532367d"
27
25
  }
28
26
  }
@@ -8,7 +8,9 @@ planner with detect/segment/drag-refine; that code is preserved on the
8
8
  """
9
9
 
10
10
  import base64
11
+ import io
11
12
  import json
13
+ import math
12
14
  import os
13
15
  import re
14
16
  import sys
@@ -16,13 +18,80 @@ from mimetypes import guess_type
16
18
  from typing import Any, Dict, List, Optional
17
19
 
18
20
  import requests
21
+ from PIL import Image
19
22
 
20
- from . import config
23
+ from . import config, errors, prompts
21
24
  from .server_manager import ensure_server
22
25
  from .timing import timed
23
26
 
24
27
  DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
25
28
 
29
+ # The pixel floor the adapters are TRAINED at, and therefore the floor an image
30
+ # has to clear before it is worth sending.
31
+ #
32
+ # Training exports `MIN_PIXELS=200704` (448², in the finetune repo's
33
+ # scripts/train_unified.sh), so anything smaller is enlarged before the vision
34
+ # encoder sees it. Serving did none of that: vLLM runs with no
35
+ # `--mm-processor-kwargs` and this client used to re-encode the file unchanged,
36
+ # so a small captcha arrived at a geometry the model was never tuned on. It
37
+ # fails as plausible-but-wrong coordinates, never as an error — measured on real
38
+ # geetest_v3_slide captures (277x285), predictions landed 80-105 px from the
39
+ # hand label at native size and 1-4 px away once upscaled.
40
+ #
41
+ # A FLOOR, NOT A RESIZE. Images already above it are passed through byte-for-byte;
42
+ # re-encoding every screenshot would spend time and fidelity on the types that
43
+ # were never affected.
44
+ #
45
+ # Unconditional as a DEFAULT, because the deployed v1.1 adapter improves under
46
+ # it too (mean error ~40 px native vs ~4 px upscaled). But it is no longer a
47
+ # constant: `models.json` may declare a `pixel_budget` per model, because the
48
+ # band an adapter TRAINED under is a property of that adapter, exactly like its
49
+ # prompt generation. See prompts.pixel_budget.
50
+ MIN_PIXELS = 448 * 448
51
+
52
+
53
+ def _encode_image(path: str,
54
+ budget: "Optional[prompts.PixelBudget]" = None) -> tuple:
55
+ """`(mime, base64)` for one image, fitted into `budget`'s pixel band.
56
+
57
+ Area is clamped, never dimensions: aspect ratio is the geometry of a grid
58
+ puzzle, and squashing it moves every tile centre — fatal when the answer is
59
+ a coordinate on a normalized 0-1000 scale.
60
+
61
+ Anything Pillow cannot open is passed through untouched — this is a
62
+ preprocessing nicety, and it must never be the reason a solve fails.
63
+ """
64
+ budget = budget or prompts.pixel_budget(None)
65
+ with open(path, "rb") as f:
66
+ raw = f.read()
67
+ mime = guess_type(path)[0] or "image/png"
68
+ try:
69
+ im = Image.open(io.BytesIO(raw))
70
+ width, height = im.size
71
+ except Exception: # noqa: BLE001 — unreadable/animated: send it as-is
72
+ return mime, base64.b64encode(raw).decode()
73
+ cap = budget.maximum
74
+ if cap and width * height > cap:
75
+ # floor, not ceil: rounding up here can land back OVER the cap, which
76
+ # is the mirror of the bug the floor branch below guards against.
77
+ scale = math.sqrt(cap / (width * height))
78
+ im = im.convert("RGB").resize(
79
+ (max(1, int(width * scale)), max(1, int(height * scale))),
80
+ Image.BICUBIC)
81
+ buf = io.BytesIO()
82
+ im.save(buf, "PNG")
83
+ return "image/png", base64.b64encode(buf.getvalue()).decode()
84
+ if width * height >= budget.minimum:
85
+ return mime, base64.b64encode(raw).decode()
86
+ # ceil, not round: rounding both sides down lands just under the floor
87
+ # (442x454 = 200,668 for a 277x285 capture) and defeats the whole point.
88
+ scale = math.sqrt(budget.minimum / (width * height))
89
+ im = im.convert("RGB").resize(
90
+ (math.ceil(width * scale), math.ceil(height * scale)), Image.BICUBIC)
91
+ buf = io.BytesIO()
92
+ im.save(buf, "PNG")
93
+ return "image/png", base64.b64encode(buf.getvalue()).decode()
94
+
26
95
  # Header the fleet's haproxy front (on the reverse-proxy EC2) routes on. When a
27
96
  # caller sets CAPTCHA_REQUEST_PRIORITY to a positive int, every request carries
28
97
  # `X-JH-Priority: <n>`; haproxy sends anything above its threshold (5) straight
@@ -55,17 +124,79 @@ _CLIENT_ENV = "CAPTCHA_KRAKEN_CLIENT"
55
124
  _SESSION_HEADER = "X-CK-Session"
56
125
  _SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
57
126
 
127
+ # Arbitrary extra headers, as "Name: value" pairs separated by newlines or
128
+ # commas. Empty and absent by default.
129
+ #
130
+ # WHY THIS EXISTS. Some endpoints sit behind a gate that is not the API key —
131
+ # our own dev gateway (api.dev.captchakraken.com) wants X-CK-Dev-Auth, and a
132
+ # corporate egress proxy may want its own token. Without this the published
133
+ # client simply cannot reach such an endpoint, which meant the dev environment
134
+ # could not exercise the real client path at all: the thing dev exists to
135
+ # rehearse was the one thing it could not do.
136
+ #
137
+ # IT CANNOT OVERWRITE Authorization, Content-Type, OR THE X-CK-* HEADERS. Those
138
+ # carry the credential and the billing attribution. Allowing an env var to
139
+ # rewrite Authorization would turn a stray export into "your key is not the key
140
+ # being charged", and allowing it to rewrite X-CK-Session would let a caller
141
+ # escape the per-attempt billing cap by pinning one session id forever.
142
+ _EXTRA_HEADERS_ENV = "CAPTCHA_KRAKEN_EXTRA_HEADERS"
143
+ _PROTECTED_HEADERS = frozenset(
144
+ {"authorization", "content-type", _CLIENT_HEADER.lower(), _SESSION_HEADER.lower()}
145
+ )
146
+
58
147
  # These values reach the wire verbatim from the environment, so they are
59
148
  # sanitized rather than trusted: a CR/LF would otherwise splice arbitrary
60
149
  # headers into the upstream request.
61
150
  _HEADER_VALUE_MAX = 128
62
151
 
63
152
 
153
+ # RFC 7230 token characters. Anything else is not a header name.
154
+ _VALID_HEADER_NAME = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
155
+
156
+
64
157
  def _clean_header_value(raw: str) -> str:
65
158
  """Printable-ASCII, length-capped header value ("" when nothing survives)."""
66
159
  return "".join(c for c in raw.strip() if 0x20 <= ord(c) < 0x7F)[:_HEADER_VALUE_MAX]
67
160
 
68
161
 
162
+ def _extra_headers(raw: str) -> Dict[str, str]:
163
+ """Parse CAPTCHA_KRAKEN_EXTRA_HEADERS into a header dict.
164
+
165
+ Accepts "Name: value" pairs separated by newlines or commas:
166
+
167
+ CAPTCHA_KRAKEN_EXTRA_HEADERS='X-CK-Dev-Auth: hunter2'
168
+ CAPTCHA_KRAKEN_EXTRA_HEADERS=$'X-One: a\nX-Two: b'
169
+
170
+ Malformed entries are DROPPED, never guessed at. A pair with no colon is
171
+ not a header, and inventing one from it would put an attacker-shaped string
172
+ on the wire. Names are restricted to the RFC 7230 token characters for the
173
+ same reason the values are sanitized: a CR/LF in either would splice extra
174
+ headers into the request.
175
+
176
+ Protected headers are refused, so this cannot be used to replace the
177
+ credential or the billing attribution.
178
+ """
179
+ out: Dict[str, str] = {}
180
+ if not raw.strip():
181
+ return out
182
+
183
+ for entry in raw.replace(",", "\n").splitlines():
184
+ entry = entry.strip()
185
+ if not entry or ":" not in entry:
186
+ continue
187
+ name, _, value = entry.partition(":")
188
+ name = name.strip()
189
+ value = _clean_header_value(value)
190
+ if not name or not value:
191
+ continue
192
+ if not _VALID_HEADER_NAME.match(name):
193
+ continue
194
+ if name.lower() in _PROTECTED_HEADERS:
195
+ continue
196
+ out[name] = value
197
+ return out
198
+
199
+
69
200
  def routing_headers(env=None) -> Dict[str, str]:
70
201
  """Fleet-routing + hosted-API headers derived from the environment.
71
202
 
@@ -93,48 +224,55 @@ def routing_headers(env=None) -> Dict[str, str]:
93
224
  if value:
94
225
  headers[header] = value
95
226
 
227
+ headers.update(_extra_headers(env.get(_EXTRA_HEADERS_ENV) or ""))
228
+
96
229
  return headers
97
230
 
98
231
 
99
- # Matches the training-distribution grid prompts in
100
- # cleanSamples/test/test_solutions.json -> grade.synthesize_instruction.
101
- # Drifting from these costs measurable accuracy.
102
- SELECT_GRID_PROMPT = """Solve the captcha grid by choosing the cell numbers that match the description from the captcha image prompt.
103
-
104
- Grid: {rows}x{cols} ({total} cells)
105
- {grid_hint}
106
-
107
- If no tiles match the description (e.g., they have all been cleared or none were present), return an empty list for target_ids: [].
108
-
109
- Return JSON Array: [list of cell numbers (1-{total})]"""
110
-
111
-
112
- # Non-grid click/drag puzzles. MUST stay byte-identical to
113
- # src/synthetic/reasoning/instructions.py::ACTION_INSTRUCTION in the finetune
114
- # repo — that is the exact prompt the LoRA was trained (and graded) on. Drift
115
- # here silently degrades every click/drag puzzle. Coordinates come back on a
116
- # 0–1000 scale; the solver converts them to 0–1 bboxes.
117
- PIXEL_ACTION_PROMPT = (
118
- "Your task is to solve the captcha. Read the instruction at the top of the image carefully.\n\n"
119
- "Look at the puzzle and decide what action solves it. All coordinates you return must be on a "
120
- "normalized 0–1000 image scale (top-left = (0, 0), bottom-right = (1000, 1000)).\n\n"
121
- "Name WHAT each object is with a short 1–2 word label, then give its position. "
122
- "Choose ONE response:\n\n"
123
- "FOR CLICK PUZZLES:\n"
124
- " Label each thing you click and give its point — subjects[i] names points[i]:\n"
125
- " → \"action\": \"click\", \"subjects\": [\"<label>\", ...], "
126
- "\"points\": [[x1, y1], [x2, y2], ...]\n\n"
127
- "FOR DRAG PUZZLES:\n"
128
- " Drag ONE item at a time. Label the source (the piece you pick up) and the destination "
129
- "(where it belongs), each with a short 1–2 word label, and give both points:\n"
130
- " → \"action\": \"drag\", \"drags\": [{ \"source\": \"<label>\", \"from\": [x, y], "
131
- "\"destination\": \"<label>\", \"to\": [x, y] }, ...]\n\n"
132
- "Respond ONLY with JSON:\n"
133
- "{\n"
134
- " \"action\": \"click\", \"subjects\": [ ... ], \"points\": [ ... ]\n"
135
- " // OR \"action\": \"drag\", \"drags\": [ ... ]\n"
136
- "}"
137
- )
232
+ # ── Prompt constants ────────────────────────────────────────────────────────
233
+ #
234
+ # These are ALIASES for the newest generation's built-ins, kept so that code and
235
+ # tests written against the old module-level constants keep working. They are
236
+ # NOT what a solve sends. The prompt a model gets is resolved per model
237
+ # (`prompts.resolve`), because a model answers in whatever schema its prompt
238
+ # asks for — sending a version-1 model the version-2 prompt does not error, it
239
+ # silently degrades every puzzle. See prompts.py and models.json.
240
+ #
241
+ # The text itself lives in prompts.py and nowhere else. Two copies of a prompt
242
+ # is the drift this module already shipped once (2026-07-18).
243
+ _LATEST = prompts.builtin(prompts.LATEST_PROMPT_VERSION)
244
+
245
+ SELECT_GRID_PROMPT = _LATEST.grid_template
246
+
247
+
248
+ # Non-grid click/drag puzzles, newest generation. Alias — see the note above.
249
+ PIXEL_ACTION_PROMPT = _LATEST.action_prompt
250
+
251
+
252
+ # Animated puzzles: the challenge is recorded, sliced into keyframes
253
+ # (`keyframes.py`) and sent as an ORDINARY MULTI-IMAGE request — one image per
254
+ # keyframe, all in one context so the model can compare them. The answer carries
255
+ # a `frame` naming which keyframe it acted on, and the page driver waits for the
256
+ # live widget to look like that frame before pressing the mouse down.
257
+ #
258
+ # Alias for the newest generation — see the note above. Generation 1 has NO
259
+ # animated prompt (the family did not exist), which is why resolution is per
260
+ # model rather than per client.
261
+ VIDEO_ACTION_PROMPT_TEMPLATE = _LATEST.video_template
262
+
263
+
264
+ def video_action_prompt(n_keyframes: int) -> str:
265
+ """The prompt for a challenge served as `n_keyframes` stills.
266
+
267
+ The count is in the text because the model has no other way to know how many
268
+ images arrived — frame identities live in the prompt, not in the image payload.
269
+ Mirrors `instructions.video_instruction` in the finetune repo exactly.
270
+ """
271
+ n = int(n_keyframes)
272
+ if n < 1:
273
+ raise ValueError(f"a keyframe request needs at least one frame, got {n_keyframes}")
274
+ listing = ", ".join(f"frame {i}" for i in range(1, n + 1))
275
+ return VIDEO_ACTION_PROMPT_TEMPLATE.format(n=n, listing=listing)
138
276
 
139
277
 
140
278
  class ActionPlanner:
@@ -156,6 +294,21 @@ class ActionPlanner:
156
294
  self.model = model or config.lora_name()
157
295
  self.base_url = base_url or config.base_url()
158
296
  self.api_key = api_key or config.api_key()
297
+ # Prompts follow the MODEL, not this client's release. Resolve by the
298
+ # served name first (a hosted endpoint serves `captcha`, not a repo id,
299
+ # and models.json maps the alias back), falling back to the adapter repo
300
+ # id for a self-hosted deploy that serves under its own name.
301
+ #
302
+ # Doing this ONCE per planner rather than per request: resolution can
303
+ # touch the Hub, and a per-request lookup would put a network call in
304
+ # front of every round of every solve.
305
+ _prompt_key = (self.model if prompts.canonical_model_id(self.model)
306
+ else config.lora_adapter())
307
+ self.prompts = prompts.resolve(_prompt_key)
308
+ # Resolution follows the MODEL for the same reason prompts do, and off
309
+ # the same key: an adapter reads a puzzle at the pixel band it trained
310
+ # under. Resolved once per planner — see the note above.
311
+ self.pixel_budget = prompts.pixel_budget(_prompt_key)
159
312
  # Auto-start a local vLLM server on the first request if one isn't up
160
313
  # (no-op for a healthy or remote endpoint). Guarded so we only try once.
161
314
  self._server_ensured = False
@@ -167,11 +320,29 @@ class ActionPlanner:
167
320
  self.debug_callback(f"[Planner] {message}")
168
321
 
169
322
  def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512) -> str:
170
- with open(image_path, "rb") as f:
171
- b64 = base64.b64encode(f.read()).decode()
172
- mime, _ = guess_type(image_path)
173
- if mime is None:
174
- mime = "image/png"
323
+ return self._chat_with_images(prompt, [image_path], max_tokens=max_tokens)
324
+
325
+ def _chat_with_images(
326
+ self, prompt: str, image_paths: List[str], max_tokens: int = 512
327
+ ) -> str:
328
+ """One request carrying N images followed by the prompt.
329
+
330
+ The images go in ONE message, before the text, in the order given. That
331
+ ordering is what makes an animated challenge answerable: the model is told
332
+ "you are given N keyframes, in order: frame 1, frame 2, …" and matches those
333
+ names to the images positionally. Splitting them across requests, or putting
334
+ the text first, would break the correspondence between the numbers in the
335
+ prompt and the pictures — and the frame number is the part the page driver
336
+ acts on.
337
+ """
338
+ if not image_paths:
339
+ raise ValueError("no images to send")
340
+
341
+ parts: List[Dict[str, Any]] = []
342
+ for p in image_paths:
343
+ mime, b64 = _encode_image(p, self.pixel_budget)
344
+ parts.append({"type": "image_url",
345
+ "image_url": {"url": f"data:{mime};base64,{b64}"}})
175
346
 
176
347
  messages = [
177
348
  {
@@ -180,10 +351,7 @@ class ActionPlanner:
180
351
  },
181
352
  {
182
353
  "role": "user",
183
- "content": [
184
- {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
185
- {"type": "text", "text": prompt},
186
- ],
354
+ "content": [*parts, {"type": "text", "text": prompt}],
187
355
  },
188
356
  ]
189
357
 
@@ -219,26 +387,24 @@ class ActionPlanner:
219
387
  self._server_ensured = True
220
388
 
221
389
  url = f"{self.base_url}/chat/completions"
222
- self._log(f"POST {url} model={self.model} max_tokens={max_tokens}")
390
+ self._log(f"POST {url} model={self.model} max_tokens={max_tokens} "
391
+ f"images={len(parts)}")
223
392
 
224
393
  with timed("planner.chat"):
225
394
  resp = requests.post(url, headers=headers, json=payload, timeout=120)
226
395
 
227
- # Surface auth / server errors with the actual body instead of letting
228
- # resp.json() blow up with a cryptic "Expecting value: line 1 column 1"
229
- # on a non-JSON response (e.g. a 401 {"error":"Unauthorized"} or an
230
- # HTML error page). 401/403 almost always means the bearer token
231
- # (CAPTCHA_KRAKEN_API_KEY) didn't reach this process.
396
+ # Surface auth / billing / server errors as something the reader can act
397
+ # on, instead of letting resp.json() blow up with a cryptic "Expecting
398
+ # value: line 1 column 1" on a non-JSON response.
399
+ #
400
+ # `errors.from_response` decides which of the two worlds we are in by
401
+ # looking for the gateway's `error.code`: present means the hosted API
402
+ # refused us and there is a real explanation to give (out of credits,
403
+ # rate limited, attempt abandoned); absent means a local vLLM or a proxy,
404
+ # and the old message — including the bearer-token hint on 401/403 — is
405
+ # reproduced unchanged.
232
406
  if not resp.ok:
233
- body = (resp.text or "")[:300]
234
- hint = ""
235
- if resp.status_code in (401, 403):
236
- hint = (
237
- " — check CAPTCHA_KRAKEN_API_KEY is set and forwarded to the CLI"
238
- )
239
- raise RuntimeError(
240
- f"vLLM {resp.status_code} {resp.reason} at {url}{hint}. Body: {body}"
241
- )
407
+ raise errors.from_response(resp, url)
242
408
 
243
409
  try:
244
410
  data = resp.json()
@@ -353,8 +519,8 @@ class ActionPlanner:
353
519
  else:
354
520
  grid_hint = "Hint: Separate images. Select only clear matches."
355
521
 
356
- prompt = SELECT_GRID_PROMPT.format(
357
- rows=rows, cols=cols, total=total, grid_hint=grid_hint
522
+ prompt = self.prompts.grid_prompt(
523
+ rows=rows, cols=cols, grid_hint=grid_hint
358
524
  )
359
525
  if retry_mode == "missed-tiles":
360
526
  prompt = (
@@ -404,8 +570,8 @@ class ActionPlanner:
404
570
  out.append(iv)
405
571
  return out
406
572
 
407
- def get_pixel_actions(self, image_path: str) -> List[Dict[str, Any]]:
408
- """Solve a non-grid click/drag puzzle.
573
+ def get_pixel_actions(self, image_path: str, text_mode: bool = False) -> List[Dict[str, Any]]:
574
+ """Solve a non-grid click/drag/slide/text puzzle.
409
575
 
410
576
  Sends the trained action prompt + image, parses the model's JSON, and
411
577
  returns a list of normalized actions with all coordinates on a 0–1
@@ -413,16 +579,75 @@ class ActionPlanner:
413
579
 
414
580
  {"kind": "click", "points": [(x, y), ...]}
415
581
  {"kind": "drag", "src": (x, y), "dst": (x, y)}
582
+ {"kind": "slide", "dst": (x, y)} — puzzle-piece slider
583
+ {"kind": "type", "text": "<the code>"} — distorted text
584
+
585
+ `text_mode` swaps in the distorted-text prompt. It is chosen by the
586
+ DRIVER, from the widget's DOM (a visible text box in the challenge),
587
+ because nothing about the picture reliably says "this one is typed" —
588
+ BotDetect's warped letters and hCaptcha's "click the matching letter"
589
+ look alike to a pixel classifier, and the two want opposite answers.
416
590
 
417
591
  Returns [] if the model produced nothing usable. The solver turns these
418
- into ClickAction / DragAction bboxes.
592
+ into ClickAction / DragAction / TypeAction.
419
593
  """
420
- raw = self._chat_with_image(PIXEL_ACTION_PROMPT, image_path, max_tokens=512)
594
+ prompt = self.prompts.text_prompt() if text_mode else self.prompts.action_prompt
595
+ raw = self._chat_with_image(prompt, image_path, max_tokens=512)
421
596
  data = self._parse_json(raw)
422
597
  actions = self._normalize_pixel(data)
423
598
  self._log(f"pixel actions -> {actions}")
424
599
  return actions
425
600
 
601
+ def get_keyframe_actions(self, keyframe_paths: List[str]) -> List[Dict[str, Any]]:
602
+ """Solve an ANIMATED challenge from its keyframes.
603
+
604
+ Same normalized actions as `get_pixel_actions`, each additionally carrying
605
+ `"frame"`: the 1-based keyframe the model chose to act on, or None if it did
606
+ not name a usable one.
607
+
608
+ The frame is not decoration. On these puzzles the target is only there part
609
+ of the time, so the coordinates are only correct while the widget looks like
610
+ the frame they were read off. The page driver holds the mouse until it does.
611
+ An action with `frame=None` is still returned — the caller decides whether to
612
+ click blind or give up, and dropping it here would look identical to "the
613
+ model had nothing to say".
614
+ """
615
+ if not keyframe_paths:
616
+ return []
617
+ # Per model, not per client: generation 1 has no animated prompt at all,
618
+ # so this raises a clear error there instead of sending a v1 model a
619
+ # keyframe request it was never trained to read.
620
+ prompt = self.prompts.video_prompt(len(keyframe_paths))
621
+ raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512)
622
+ data = self._parse_json(raw)
623
+ actions = self._normalize_pixel(data)
624
+ frame = self._normalize_frame(data, len(keyframe_paths))
625
+ for a in actions:
626
+ a["frame"] = frame
627
+ self._log(f"keyframe actions (frame={frame}) -> {actions}")
628
+ return actions
629
+
630
+ @staticmethod
631
+ def _normalize_frame(data: Any, n_keyframes: int) -> Optional[int]:
632
+ """The keyframe number the model chose, or None.
633
+
634
+ Out-of-range is treated as "did not choose" rather than clamped. Clamping a
635
+ 7 to 6 would invent an intent the model never had, and the driver would then
636
+ wait for a picture the answer was not about — a slow failure that looks like
637
+ a flaky page. Tolerates the nested shape the model drifts into
638
+ ({"action": {"frame": n}}), the same way the click/drag parsing does.
639
+ """
640
+ if not isinstance(data, dict):
641
+ return None
642
+ raw = data.get("frame")
643
+ if raw is None and isinstance(data.get("action"), dict):
644
+ raw = data["action"].get("frame")
645
+ try:
646
+ n = int(raw)
647
+ except (TypeError, ValueError):
648
+ return None
649
+ return n if 1 <= n <= n_keyframes else None
650
+
426
651
  @staticmethod
427
652
  def _normalize_pixel(data: Any) -> List[Dict[str, Any]]:
428
653
  """Map the model's 0–1000 click/drag JSON to 0–1 normalized actions.
@@ -489,6 +714,23 @@ class ActionPlanner:
489
714
  if not isinstance(data, dict):
490
715
  return out
491
716
 
717
+ # ---- type (CANONICAL — the schema TEXT_INSTRUCTION asks for on the
718
+ # distorted-text captchas: {"action": "type", "text": "<the code>"}).
719
+ # First, because it is the one answer family with NO coordinate in it:
720
+ # every branch below keys off a number, so a typed answer that fell
721
+ # through reached the end and normalized to nothing.
722
+ # The code is passed through VERBATIM — case and spacing are part of what
723
+ # the model read off the image, and "tidying" them submits a different
724
+ # answer than the one it gave.
725
+ act = data.get("action")
726
+ if isinstance(act, dict) and act.get("action") == "type":
727
+ act = act.get("action") # unwrap {"action": {"action": "type", ...}}
728
+ if act == "type" or (isinstance(data.get("text"), str) and "drags" not in data):
729
+ container = data if isinstance(data.get("text"), str) else data.get("action")
730
+ text = container.get("text") if isinstance(container, dict) else None
731
+ if isinstance(text, str) and text:
732
+ return [{"kind": "type", "text": text}]
733
+
492
734
  # ---- drag (CANONICAL — the schema PIXEL_ACTION_PROMPT asks for and the
493
735
  # LoRA is trained on, see instructions.py::ACTION_INSTRUCTION):
494
736
  # {"action":"drag","drags":[{"source","from":[x,y],"destination","to":[x,y]}]}
@@ -506,11 +748,24 @@ class ActionPlanner:
506
748
  continue
507
749
  snums = flat_numbers(d.get("from"))
508
750
  dnums = flat_numbers(d.get("to"))
509
- if len(snums) >= 2 and len(dnums) >= 2:
751
+ if len(dnums) < 2:
752
+ continue # a piece with nowhere to go is not actionable
753
+ dst = norm_xy(dnums[0], dnums[1])
754
+ if not dst:
755
+ continue
756
+ if len(snums) >= 2:
510
757
  src = norm_xy(snums[0], snums[1])
511
- dst = norm_xy(dnums[0], dnums[1])
512
- if src and dst:
758
+ if src:
513
759
  out.append({"kind": "drag", "src": src, "dst": dst})
760
+ else:
761
+ # SOURCELESS — a puzzle-piece slider. The prompt's "FOR
762
+ # PUZZLE PIECE SLIDER PUZZLES" clause tells the model to
763
+ # leave the source empty precisely because the piece is not
764
+ # what you pick up: the handle is, somewhere else entirely,
765
+ # and how far it has to travel is not knowable from the
766
+ # picture. Requiring both ends here dropped the one answer
767
+ # shape the prompt asks for on every slide puzzle.
768
+ out.append({"kind": "slide", "dst": dst})
514
769
  if out:
515
770
  return out
516
771