samagotchi 0.77.0 → 0.79.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +14 -8
  5. data/docs/internals/anthropic.md +80 -7
  6. data/docs/internals/engine.md +3 -2
  7. data/docs/internals/prompt-caching.md +3 -1
  8. data/docs/sessions.md +1 -0
  9. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/child_move.rb +2 -2
  12. data/lib/samagotchi/child_reports.rb +2 -2
  13. data/lib/samagotchi/children_status.rb +2 -2
  14. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  15. data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
  16. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +195 -0
  17. data/lib/samagotchi/llm/anthropic_messages.rb +74 -17
  18. data/lib/samagotchi/llm/anthropic_wire.rb +29 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +61 -5
  20. data/lib/samagotchi/llm/errors.rb +6 -4
  21. data/lib/samagotchi/llm/http.rb +2 -1
  22. data/lib/samagotchi/llm/model_listing.rb +29 -1
  23. data/lib/samagotchi/llm/openai_chat.rb +12 -2
  24. data/lib/samagotchi/llm/refusal_memo.rb +3 -2
  25. data/lib/samagotchi/note_delivery.rb +2 -2
  26. data/lib/samagotchi/plugin/sessions.rb +2 -2
  27. data/lib/samagotchi/question_desk.rb +10 -3
  28. data/lib/samagotchi/reasoning_history.rb +2 -1
  29. data/lib/samagotchi/reply_wait.rb +2 -3
  30. data/lib/samagotchi/session_manager.rb +0 -1
  31. data/lib/samagotchi/session_metrics/totals.rb +4 -3
  32. data/lib/samagotchi/session_metrics.rb +9 -0
  33. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  34. data/lib/samagotchi/terminal_ui.rb +2 -2
  35. data/lib/samagotchi/tools/delegate.rb +2 -2
  36. data/lib/samagotchi/tools/delegate_result.rb +2 -2
  37. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  38. data/lib/samagotchi/version.rb +1 -1
  39. data/lib/samagotchi/web/public/app.js +61 -53
  40. data/lib/samagotchi/web/public/composer_fold.js +58 -0
  41. data/lib/samagotchi/web/public/composer_grip.js +231 -0
  42. data/lib/samagotchi/web/public/composer_size.js +10 -0
  43. data/lib/samagotchi/web/public/index.html +27 -3
  44. data/lib/samagotchi/web/public/model_picker.js +4 -5
  45. data/lib/samagotchi/web/public/stage_view.js +3 -2
  46. data/lib/samagotchi/web/public/storage.js +23 -0
  47. data/lib/samagotchi/web/public/toasts.js +5 -4
  48. data/lib/samagotchi/worker.rb +1 -0
  49. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 433cf999c039d529cdf878223e798ed00b23efc0a35393c48111c0b1f7b597a7
4
- data.tar.gz: eb13e65818fc0b2efaa04d28315d9693856ff77861d5643d668702fe80cdeca1
3
+ metadata.gz: fc74f7069433e0f6be4252973309183e17c3ecc7ad06800684bf9be6fe40015f
4
+ data.tar.gz: 12a8d7e7c8002a8762d7752f6cae9bd20fae6565cb2d81636cc1ca9a6ff2469d
5
5
  SHA512:
6
- metadata.gz: 58012cc677a1b2c14e358dabed798856f88a8a74f59dff1606f08dca297ae78d5f89d69c392b2254523308996a67a86ff828205e07a7eac7ea0b7adf99961d79
7
- data.tar.gz: 7332bff82c9060f9ad9a77063891ced098572ce28861fbe2a65ab9486076478d40f1731fec13f41ec9dc4a15fc60e14ac023b40255c0356993bfe22969817986
6
+ metadata.gz: 004ef50c618ba8bb488adf9e5623da491691bf37850946870bb94511451d8e56876288232d0bae0212dcfc7d9a1c79544d4297ef0ab8692f5678ff3617fc144c
7
+ data.tar.gz: cb551afd6bdae21bfd61a4ff64ead0306816b41b88f4b5083fbf5e7d2afbb9ef9b9353d5d5750ccbfd1764fe10db8c60f8eb534a2cd4d728b04e3145f84dad4a
data/CHANGELOG.md CHANGED
@@ -8,6 +8,43 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.79.0] - 2026-10-17
12
+
13
+ ### Fixed
14
+
15
+ - Web: a repeated toast cannot unstick a sticky error and make it time out.
16
+ - Anthropic: `api: anthropic` hosts on the Claude API now read their model list, including 1M-token windows and image
17
+ input, so `chi models` and model validation work.
18
+ - Anthropic: on Claude 5.x models, which turn thinking off by `between_tools` and refuse `disabled`, recaps, side
19
+ answers and `--thinking off` sessions now send `thinking: {type: "between_tools"}` after that 400 (a recap used to
20
+ fall back to adaptive thinking at low effort, a `--thinking off` session to the provider's default thinking). Each
21
+ side-answer client learns it once for itself.
22
+ - A host whose model list fails is asked again at most once a minute (it was asked on every context-window lookup).
23
+
24
+ ## [0.78.0] - 2026-10-17
25
+
26
+ ### Added
27
+
28
+ - Web: dragging the message box's top-edge grip down folds the box away for room to read; the info bar stays at the
29
+ bottom, and the grip stays on its top edge as a handle. Pull the handle up or click it, or just type on the page, to
30
+ bring the box back (a draft marks the handle in the accent, a session waiting on you in the attention colour), and
31
+ Stop turn moves into the info bar while it is folded. The choice is remembered per browser, and an archived session
32
+ opens folded.
33
+
34
+ ### Changed
35
+
36
+ - Workers no longer load the terminal UI or Reline, saving about 50–100 ms and 2 MB per worker start; a plugin
37
+ that uses Reline, StringIO, Forwardable or Tempfile must now require it itself.
38
+
39
+ ### Fixed
40
+
41
+ - Claude 5.x on `api: anthropic`: a request whose earlier thinking the API refuses after the system prompt or tools
42
+ changed (a resume or respawn after memory or AGENTS.md edits, `/model`, `/thinking`, `/toolkit`) no longer fails the
43
+ turn. chi asks again with Anthropic's binding controls (or without those thinking blocks), drops that thinking from
44
+ the session, and `/stats` counts it as `thinking dropped`.
45
+
46
+ Update with `chi update` (the system bundle 0.78.0), then restart `chi web`.
47
+
11
48
  ## [0.77.0] - 2026-10-17
12
49
 
13
50
  ### Changed
@@ -3245,7 +3282,9 @@ and long-lived sessions.
3245
3282
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3246
3283
  hotkey panel that send selected text or the clipboard to your sessions.
3247
3284
 
3248
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.77.0...HEAD
3285
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.79.0...HEAD
3286
+ [0.79.0]: https://github.com/dm1try/samagotchi/compare/v0.78.0...v0.79.0
3287
+ [0.78.0]: https://github.com/dm1try/samagotchi/compare/v0.77.0...v0.78.0
3249
3288
  [0.77.0]: https://github.com/dm1try/samagotchi/compare/v0.76.0...v0.77.0
3250
3289
  [0.76.0]: https://github.com/dm1try/samagotchi/compare/v0.75.0...v0.76.0
3251
3290
  [0.75.0]: https://github.com/dm1try/samagotchi/compare/v0.74.0...v0.75.0
data/docs/cli.md CHANGED
@@ -459,7 +459,8 @@ request (with the cached tokens, the cache writes, the re-prefilled tokens and t
459
459
  reports them),
460
460
  the decode speed, generation latency, cancellations, retries (the network's),
461
461
  thinking cuts (generations a plugin such as loop-guard cut) and output cap hits
462
- (generations that ended at the provider's cap), context used and window (the used figure is counted against the smaller of the window and the session's LLM context budget, and says so: `context used: 16000 tokens (25.0% of the 64000 budget)`), the memory indexes the session's prompt holds ([Memory](memory.md#the-indexs-size)), the model notes it carried (`model notes: model_notes_deepseek (system, 612 chars)`, none without any; [Memory](memory.md#model-notes)), the toolkit's prompt text (`kit prompt: coding (~20 tokens)`, only when its kit has one; [Toolkits](configuration.md#toolkits)), the prompt profile and the model the server says it ran, and the
462
+ (generations that ended at the provider's cap), the thinking blocks a Claude 5.x preserved-thinking recovery dropped
463
+ (`thinking dropped:`, only when there were any; [configuration](configuration.md)), context used and window (the used figure is counted against the smaller of the window and the session's LLM context budget, and says so: `context used: 16000 tokens (25.0% of the 64000 budget)`), the memory indexes the session's prompt holds ([Memory](memory.md#the-indexs-size)), the model notes it carried (`model notes: model_notes_deepseek (system, 612 chars)`, none without any; [Memory](memory.md#model-notes)), the toolkit's prompt text (`kit prompt: coding (~20 tokens)`, only when its kit has one; [Toolkits](configuration.md#toolkits)), the prompt profile and the model the server says it ran, and the
463
464
  cost when the provider reports one (OpenRouter's `usage.cost`), and the cost
464
465
  estimated from a price under `hosts.<name>.models` when it reports none or 0,
465
466
  marked `~` and naming the current model's host (`cost: $0.42 reported + ~$0.12 from hosts.gw.models prices (this session only)`;
@@ -343,12 +343,16 @@ hosts:
343
343
 
344
344
  `auth:` works on any host (`bearer` is the default everywhere but `api: anthropic`).
345
345
 
346
- **Known limit (until a later release):** Claude 5.x models (Opus, Sonnet and Haiku 5.5, Fable 5.1) bind each
347
- thinking block to the request it came from. When the history before a block changes (the system prompt rebuilt after a
348
- worker restart or a `/model` switch, a changed tool set, the forget layer's edits), such a model may answer HTTP 400
349
- "… bound to a different conversation" and the turn fails with that error; `/reasoning-history turn` makes it rarer.
350
- A later release sends Anthropic's binding controls and recovers from that 400. Fireworks, Splash and other
351
- non-Claude models are not affected.
346
+ **Claude 5.x preserved thinking:** Claude 5.x models (Opus, Sonnet and Haiku 5.5, Fable 5.1) bind each thinking
347
+ block to everything before it: the system prompt, the tools and the earlier messages. chi keeps the history
348
+ append-only, but the system prompt or the tools can change under a session: a resume or a worker respawn after
349
+ memory or AGENTS.md edits, `/model`, `/thinking`, `/toolkit`, a plugin's tools, or an `llm_context` layer stubbing
350
+ older outputs. Then the API refuses the earlier thinking once (HTTP 400 "… bound to a different conversation"); chi
351
+ asks again with Anthropic's binding controls (or without those blocks), drops that thinking from the session for
352
+ good, and no turn fails. `/stats` counts it (`thinking dropped: N blocks (preserved-thinking recovery)`), and each
353
+ time costs one extra (free) 400 and one cache write. `reasoning_history` on these hosts is `all` by default; `turn`
354
+ drops more reasoning but never needs a recovery at a turn's start. Fireworks, Splash and other non-Claude models
355
+ never see these requests. internals/anthropic.md has the details.
352
356
 
353
357
  The loop, tools, events, hooks and UIs are the chat loop's, as for `api: openai`; only the wire differs: the
354
358
  system prompt goes as the request's `system` (later system notes as user text), tool calls as `tool_use` blocks,
@@ -640,8 +644,10 @@ What each backend gets:
640
644
  | Messages host, `dialect: fireworks` | `thinking: {type: "disabled"}` | `thinking: {type: "adaptive"}` and `output_config: {effort: <level>}` (no `display`) |
641
645
 
642
646
  On a Messages host `default` sends nothing: the provider's own default, which differs by model (some Claude models
643
- think adaptively but show no thinking text, their blocks still going back; others, and Splash, don't think at all). A model that refuses `disabled` (current
644
- Claude models) gets the request again without it, as on any chat host. A model that refuses adaptive thinking
647
+ think adaptively but show no thinking text, their blocks still going back; others, and Splash, don't think at all). A model that refuses `disabled` and names `between_tools` in the 400 (Claude 5.x) gets `thinking: {type:
648
+ "between_tools"}` wherever `off` asked for `disabled`, from then on: the adapter keeps that per model for as long as it
649
+ lives, so a worker's turns keep it, and each side-answer client (recap, `ctx.ask_model`, a broadcast's triage) learns it
650
+ once for itself. One that refuses `disabled` without naming it gets the request again without it, as on any chat host. A model that refuses adaptive thinking
645
651
  (older Claude: Haiku 4.5) is switched, once and for the worker's life, to the budget form: `thinking: {type:
646
652
  "enabled", budget_tokens: B}` with B 2048 / 8192 / 24576 for low / medium / high and no effort; `max_tokens` always
647
653
  stays above the budget (the budget is lowered to leave 1024 for the answer, or `max_tokens` raised to 2048). A
@@ -17,7 +17,10 @@ ChatLoop::Run#generate
17
17
  → POST {base}/v1/messages # LLM::HTTP: retries, cancel, first-token limit
18
18
  → AnthropicAssembly#add per SSE event # → ChatResponse (text, reasoning, tool_calls, usage,
19
19
  # finish_reason, provider_content)
20
+ → a signature 400 (Claude 5.x preserved thinking): AnthropicBindingRecovery#advance, then the same
21
+ request with drop_block or the blocks stripped; ChatResponse#thinking_drop names them
20
22
  → a 400 it can fix: #refusal_fix, then the same request again (once per kind)
23
+ → ChatLoop#settle_dropped_thinking! # the dropped blocks leave the stored model entries
21
24
  ```
22
25
 
23
26
  ## Files
@@ -28,11 +31,12 @@ Paths are under `lib/samagotchi/llm/`.
28
31
  |---|---|
29
32
  | `adapters.rb` | `Adapters::BY_API` (`openai`, `anthropic`), `.for(entry)`, `.build(api:, …)` for side requests (`IdleClient`) |
30
33
  | `anthropic_messages.rb` | `AnthropicMessages`: the request body, `#chat` (streamed or not), the 400 fixes, `#list_models`, `#thinking_fields`, `#replays_content?` |
34
+ | `anthropic_binding_recovery.rb` | `AnthropicBindingRecovery`: the preserved-thinking recovery (`#advance` picks drop_block or strip for a signature 400, `#recovered_body`, `#with_thinking_drop`); one per adapter |
31
35
  | `anthropic_wire.rb` | `AnthropicWire`: ChatLoop's wire messages as Messages' `system` and `messages`, the two breakpoints, tool ids and names |
32
36
  | `anthropic_assembly.rb` | `AnthropicAssembly`: a response from a whole body (`.from_message`) or stream events (`#add`), its usage in the OpenAI shape |
33
37
  | `dialect.rb` | `Dialect` (`STANDARD`, `FIREWORKS`): what a provider's `/v1/messages` does differently |
34
38
  | `prompt_cache.rb` | `PromptCache.control`: the `cache_control` a breakpoint carries (`cache.ttl`) |
35
- | `chat_loop.rb` | `ChatLoop#replay_blocks`, `#reasoning_history`'s floor, `Run#with_thinking` (saves `anthropic_content`) |
39
+ | `chat_loop.rb` | `ChatLoop#replay_blocks`, `#reasoning_history`'s floor, `Run#with_thinking` (saves `anthropic_content`), `#settle_dropped_thinking!` |
36
40
 
37
41
  ## The adapter
38
42
 
@@ -51,7 +55,8 @@ with `tool_choice: {type: "auto"}` (when any), then the loop's options. The opti
51
55
  (`default.max_tokens`), else the model list's `max_tokens` capped at `DEFAULT_MAX_TOKENS` (64000), and never over an
52
56
  output cap a 400 named. `#with_budget_room` keeps `max_tokens` above a budget-form `budget_tokens` plus `MIN_BUDGET`.
53
57
 
54
- `#thinking_fields(level)` is the thinking level on this wire: `off` is `thinking: {type: "disabled"}`; `low`, `medium`
58
+ `#thinking_fields(level)` is the thinking level on this wire: `off` is `thinking: {type: "disabled"}` (sent as `{type:
59
+ "between_tools"}` to a model whose 400 for `disabled` names that form, Claude 5.x: see the 400 fallbacks); `low`, `medium`
55
60
  and `high` are `thinking: {type: "adaptive", display: <dialect's>}` with `output_config: {effort: <level>}`; `default`
56
61
  sends nothing. `#thinking_off_refused_fields` (low effort) is what `IdleClient` sends a side ask whose model refused
57
62
  thinking off.
@@ -112,8 +117,70 @@ text and `tool_use` blocks (a tool loop with thinking needs the last assistant t
112
117
  - `ChatLoop#reasoning_history` floors the level at `turn` on a replaying adapter: `drop` would remove the running
113
118
  run's thinking, which Messages needs. For the same reason a reasoning 400 doesn't ask again without reasoning
114
119
  (`Run#generate`: it would resend the same body).
115
- - A 400 about a thinking block's signature (`BadRequest#thinking_signature?`, a Claude 5.x block bound to a
116
- different conversation) fails the turn for now; configuration.md has the known limit.
120
+ - A 400 about a thinking block's signature (`BadRequest#thinking_signature?`) is recovered in the adapter, and the
121
+ blocks it cost leave the history (next section).
122
+
123
+ ## Preserved thinking (Claude 5.x)
124
+
125
+ Claude 5.x models bind each thinking block to the prefix it was minted after: both `system` blocks (the stable part
126
+ and the per-session tail), the tool set, and every earlier message. Sent back after any of those changed, the block
127
+ gets a 400 `messages.N.content.M: Invalid signature in thinking block. The block is bound to a different
128
+ conversation.`, and its last sentence says what changed ("The `system` prompt differs …"). chi's history is
129
+ append-only within a process, so what changes the prefix is:
130
+
131
+ 1. **A system-prompt rebuild** (`SystemPrompt#build` is cached per process and key; `PromptBuilder#reset!` drops it):
132
+ a new process (`--resume`, a respawn after `session.idle_exit_minutes`, a crash) after a memory index line, an
133
+ AGENTS.md, an appended file, a model note or a bundle memory changed; `/model` (the tail's `Model:` line, its
134
+ overlays and notes); `/thinking` (another key); a toolkit or profile change; a plugin's `tools_changed!`.
135
+ 2. **A tool set change**: the same triggers, plus MCP lines that differ on a respawn.
136
+ 3. **History edits by design**: the `llm_context` `stale`/`forget` stubs, and the image cut moving a batch.
137
+
138
+ A model switch never 400s: the API drops blocks another model can't read silently (`model_binding_mismatch`).
139
+
140
+ **Recover on the 400, then settle.** Normal requests carry no beta header and no `block_binding`, so Fireworks,
141
+ Splash and gateways see the same bytes as before. On a signature 400, before anything streamed,
142
+ `AnthropicBindingRecovery#advance` (one per adapter, `anthropic_binding_recovery.rb`) picks the next step, once each per request:
143
+
144
+ - **drop_block**: the same body with `thinking.block_binding: {prefix_mismatch_behavior: "drop_block"}` (the body's
145
+ adaptive or enabled thinking, `display` as sent; adaptive when the level sends none) and the header
146
+ `anthropic-beta: thinking-binding-controls-2026-08-01`. The answer's `input_transformations` (in `message_start`'s
147
+ message, or the body; `AnthropicAssembly#input_transformations`) list the dropped blocks by path into the body
148
+ sent; the adapter maps the `prefix_binding_mismatch` ones to block identities. Any other entry
149
+ (`model_binding_mismatch`: a switch back reads those blocks again) is counted as kept, never settled.
150
+ - **strip**: the block the 400 names (else the first replayed thinking block) and every thinking or
151
+ redacted_thinking block after it removed from the body (a trailing run, which Messages takes). For a bad
152
+ signature (the 400 without "bound to a different conversation": drop_block doesn't apply), thinking `disabled`
153
+ (block_binding goes only with adaptive or enabled), a model on the budget form with no thinking field, a host
154
+ that refused the controls (`binding_controls`, remembered), or a second signature 400 after drop_block.
155
+
156
+ A third signature 400 fails the turn. The extra requests are 400s, rejected before generation and not billed. The
157
+ step is request state that `#request_body`'s rebuild keeps, so a later `#refusal_fix` (display, cache_control,
158
+ output cap) in the same request keeps the binding or the strip. The response carries `thinking_drop`
159
+ (`LLM::ThinkingDrop`: `identities`, `how`), and the adapter logs `thinking_recovered` (`how`, `dropped`, `kept`,
160
+ `why`: the 400's last sentence).
161
+
162
+ A block's identity (`AnthropicWire.block_identity`, shared by the adapter and ChatLoop) is its `signature`, a
163
+ redacted block's `data`, or, when that is empty (Splash's `signature: ""`), `text:` + the SHA256 of its text.
164
+
165
+ `ChatLoop::Run#generate` hands those identities to `ChatLoop#settle_dropped_thinking!` before the step adds its own
166
+ entry, whatever the finish reason: each model entry holding one is replaced in place (same index) with a copy
167
+ without those blocks (`thinking:` stays; an entry left with none loses `anthropic_content`). That is the edit the
168
+ API made, so the next request is valid with no beta (a pruned history is valid: removing exactly the dropped set
169
+ leaves no gap), and the session file keeps it, since `Engine#kept_messages` and `#failed_messages` take the run's
170
+ conversation whole. It emits `:thinking_dropped` (`count`, `how`) and `thinking_dropped` on `:generation_completed`,
171
+ which `SessionMetrics` sums for `/stats`. A drop that settles nothing (blocks already settled came back, e.g. a
172
+ rolled-back turn's) logs `thinking_recovery_repeated` once per run. A cancelled, cut or dropped generation settles
173
+ nothing; the next request recovers again.
174
+
175
+ Each break costs one free 400, one cache write of the prefix from the edit point, and the reasoning the dropped
176
+ blocks carried. Whether the request after a drop_block one reads the cache that request wrote (it no longer carries
177
+ `block_binding`) is unverified.
178
+
179
+ Why not send drop_block on every request: it needs adaptive or enabled thinking on the body (`default` sends no
180
+ thinking field, `off` sends disabled), it would put a beta header on every request to every host, and it would drop
181
+ the same blocks again on every request until something pruned them. The reactive form does the same work in one
182
+ place, for one extra 400 round trip per break. The `anthropic-thinking-prefix-mismatch` diagnosis header (on the
183
+ 400, undocumented) is kept on the provider error and logged as `diagnosis` on `thinking_recovered`.
117
184
 
118
185
  ## Cache breakpoints
119
186
 
@@ -144,15 +211,21 @@ thinking, nil for none), `cache_breakpoints` and `session_header`.
144
211
 
145
212
  `AnthropicMessages#chat` handles the 400s it can fix itself, once each per request, and remembers the fix for the
146
213
  adapter's life, so ChatLoop's broader paths never see them (`#refusal_fix`, logged under `http`; kept in an
147
- `LLM::RefusalMemo`, locked, since the turn, side-ask and recap threads share the host's adapter):
214
+ `LLM::RefusalMemo`, locked, since the turn thread and the model-list reads share the host's adapter; `IdleClient`,
215
+ whose recap, side answers and triage each build their own adapter (`Adapters.build`), keeps what it learned for that
216
+ client's life only):
148
217
 
149
218
  | 400 about | Fix | Remembered |
150
219
  |---|---|---|
151
220
  | `thinking.display` (`DISPLAY_RE`) | no `display` from now on (`#without_display`), the thinking kept | per host (`thinking_display_refused`) |
152
221
  | adaptive thinking or its effort (`ADAPTIVE_REFUSED_RE`) | the budget form: `thinking: {type: "enabled", budget_tokens: BUDGETS[level]}`, no effort (`#budget_form`) | per model (`thinking_form_switched`) |
222
+ | `thinking: {type: "disabled"}` where the 400 names `between_tools` (`THINKING_OFF_FORM_RE`; Claude 5.x) | `thinking: {type: "between_tools"}` wherever `disabled` was asked (`#thinking_between_tools`), so a recap or side answer (`IdleClient` step 0) and a `--thinking off` session never fall back to adaptive low or to no thinking fields; a model that refuses `between_tools` too raises the 400 as before | per model (`thinking_off_form_switched`) |
153
223
  | `cache_control` | no breakpoints | per host (`cache_control_refused`) |
154
224
  | `max_tokens: N > M` (`OUTPUT_CAP_RE`) | `max_tokens` capped at M | per model (`output_cap`) |
225
+ | `block_binding` or the binding beta, on a drop_block retry (`thinking_binding_unsupported?`) | the strip instead (Preserved thinking) | per host (`binding_controls_refused`) |
155
226
 
156
227
  The display check comes before the adaptive one: its path (`thinking.adaptive.display`) names adaptive too. A context
157
- overflow, a refused sampling field, a signature or a binding-controls 400 is not one of these: it goes on as it is,
158
- to the loop's own handling (ChatLoop's sampling and thinking-fields fallbacks) or to the turn's failure.
228
+ overflow, a refused sampling field, a signature or a binding-controls 400 is not one of these (`#refusal_fix` keeps
229
+ excluding them, so `ADAPTIVE_REFUSED_RE` never reads a binding 400's "adaptive" as a form refusal): a signature 400
230
+ goes to the binding recovery above, the rest on as they are, to the loop's own handling (ChatLoop's sampling and
231
+ thinking-fields fallbacks) or to the turn's failure.
@@ -273,8 +273,8 @@ One iteration with tool calls:
273
273
  → an ask verdict: Gate#settle_ask (asks the user, records waited_ms)
274
274
  → KernelLoop#dispatch_tool_call, or the denial text
275
275
  → :tool_call_completed, then the :after_tool_call hook
276
- → returns {output:, capped_output:, truncated:, activity:, images:, shown_params:,
277
- shown_label:, diff:}
276
+ → returns a ToolRun value object with output, capped_output, truncated, activity, images, shown_params,
277
+ shown_label, refs, diff and output_file; fields a call doesn't produce are nil
278
278
  → :tool_dispatch_completed {iteration, call_count}
279
279
  → native: ToolResponse.joined(runs, ids:), one entry with the capped outputs
280
280
  chat: ToolResponse.single(run, tool_call_id:, ids:) as each call finishes (the block), with the
@@ -473,6 +473,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
473
473
  | `:generation_retrying` | the transport retries the request, or the chat loop asks again for a step whose stream dropped mid-answer (`restarted: true`: what the step streamed is void, and the web's live step, the TUI's activity line, the Bridge's TurnAccumulator and the stream hooks' watch start it over) | `{iteration, attempt, max_retries, next_delay, error_class, error_message, status, restarted?}` |
474
474
  | `:generation_completed` | a generation ended, or was cut | `{iteration, content_length, thinking_chars, served_model, requested_model, finish_reason, ...}`; a cut adds `stopped_by`, `stop_reason`; the Engine adds `speed`, `tokens`, `model_segments` |
475
475
  | `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
476
+ | `:thinking_dropped` | the chat loop settled a preserved-thinking recovery: the adapter dropped replayed thinking blocks and they left the history (`ChatLoop#settle_dropped_thinking!`; the step's `:generation_completed` carries `thinking_dropped`; internals/anthropic.md) | `{iteration, count, how}` (`how`: `:drop_block` or `:strip`) |
476
477
  | `:empty_answer_retry` | an empty, cut or malformed answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, stopped_by?, malformed?}` |
477
478
  | `:steer_cut` | a steer cut the generation | `{iteration, source}` |
478
479
  | `:llm_context_edited` | the apply rule applied a batch of LLM context edits (a request, a forget_outputs call, turn end; never the warm-up) | `{moment, why, freed_tokens, tail_tokens, staged, text, groups}` (`LLMContextNotice`) |
@@ -171,7 +171,9 @@ is prefilled once. The native and the chat loop use the same plan.
171
171
  first reply, on a server that renders it; a stock Qwen template without `preserve_thinking` re-renders from the
172
172
  reply before every user message whatever chi sends). A worker's
173
173
  mid-turn checkpoint (`TurnCheckpoint`) is append-only too (the history plus what the loop appended) and
174
- never changes the request bytes.
174
+ never changes the request bytes. On Messages with Claude 5.x a change to the prefix (the system prompt, the
175
+ tools, an earlier message) also costs the thinking blocks after it: one 400, then chi drops them for good
176
+ (internals/anthropic.md, "Preserved thinking").
175
177
  3. **Deterministic order.** Tools, memory lists and anything else that's rendered from a hash or a set needs a
176
178
  fixed sort. Plugin tools are sorted by (bundle, name) whatever order their plugins finished starting in
177
179
  (R5a).
data/docs/sessions.md CHANGED
@@ -80,6 +80,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
80
80
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
81
81
  - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
82
82
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
83
+ - The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only, and a fold forgets the height the grip set (`chi_composer_floor`): the box reopens at one line, or at its text's height. The start page's grip only resizes.
83
84
 
84
85
  **Test-session hygiene:**
85
86
 
@@ -4,8 +4,8 @@ require "time"
4
4
  require_relative "../session"
5
5
 
6
6
  module Samagotchi
7
- # Loaded on first use: session_manager requires terminal_ui, which
8
- # requires KernelLoop and so the tools (a require cycle otherwise).
7
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
8
+ # files unloaded until the delegate/session path needs them.
9
9
  autoload :SessionManager, File.expand_path("../session_manager", __dir__)
10
10
 
11
11
  module Broadcast
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.77.0
3
+ version: 0.79.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -11,8 +11,8 @@ require_relative "log"
11
11
  require_relative "tools/delegate_cursor"
12
12
 
13
13
  module Samagotchi
14
- # Loaded on first use: session_manager requires terminal_ui, which
15
- # requires KernelLoop and so the tools (a require cycle otherwise).
14
+ # Loaded on first use: session_manager requires this file (a require cycle
15
+ # otherwise).
16
16
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
17
17
  # SessionContinue::OpenChild, the rows a plan refuses: loaded on first use too.
18
18
  autoload :SessionContinue, File.expand_path("session_continue", __dir__)
@@ -12,8 +12,8 @@ require_relative "tools/delegate_wait"
12
12
  require_relative "tools/delegate_cursor"
13
13
 
14
14
  module Samagotchi
15
- # Loaded on first use: session_manager requires terminal_ui, which
16
- # requires KernelLoop and so the tools (a require cycle otherwise).
15
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
16
+ # files unloaded until the delegate/session path needs them.
17
17
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
18
18
 
19
19
  # A delegate child's news reaching its parent by itself (the delegate
@@ -9,8 +9,8 @@ require_relative "tools/delegate"
9
9
  require_relative "tools/delegate_cursor"
10
10
 
11
11
  module Samagotchi
12
- # Loaded on first use: session_manager requires terminal_ui, which
13
- # requires KernelLoop and so the tools (a require cycle otherwise).
12
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
13
+ # files unloaded until the delegate/session path needs them.
14
14
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
15
15
 
16
16
  # A parent's children as `/children` and the glance views show them: one
@@ -46,12 +46,14 @@ module Samagotchi
46
46
  # Every page of the host's model list (MAX_MODEL_PAGES at most): the
47
47
  # block gives a page's URI for the last page's last_id (nil first).
48
48
  # @return [Array<ModelInfo>]
49
- def list_model_pages
49
+ def list_model_pages(headers: {})
50
50
  models = []
51
51
  after = nil
52
52
  MAX_MODEL_PAGES.times do
53
53
  uri = yield(after)
54
- body = parse_json(@http.fetch(uri, Net::HTTP::Get.new(uri), log_fields: { purpose: "models" }).body, "model list")
54
+ request = Net::HTTP::Get.new(uri)
55
+ headers.each { |name, value| request[name] = value }
56
+ body = parse_json(@http.fetch(uri, request, log_fields: { purpose: "models" }).body, "model list")
55
57
  models.concat(model_entries(body).map { |raw| model_info(raw) })
56
58
  break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
57
59
 
@@ -44,14 +44,22 @@ module Samagotchi
44
44
  @stop_reason = nil
45
45
  @stop_details = nil
46
46
  @model = nil
47
+ @input_transformations = []
47
48
  end
48
49
 
50
+ # The message's input_transformations (Claude 5.x: what the API did to
51
+ # the request before generating, e.g. the thinking blocks drop_block
52
+ # dropped), as sent (string keys); [] when it named none. The paths
53
+ # index into the request body, which only the adapter holds.
54
+ attr_reader :input_transformations
55
+
49
56
  # A whole message: its model, usage, stop reason and blocks.
50
57
  def add_message(message)
51
58
  return unless message.is_a?(Hash)
52
59
 
53
60
  @model = served(message["model"]) || @model
54
61
  merge_usage(message["usage"])
62
+ read_transformations(message)
55
63
  @stop_reason = message["stop_reason"] if message["stop_reason"]
56
64
  @stop_details = message["stop_details"] if message["stop_details"].is_a?(Hash)
57
65
  Array(message["content"]).each { |block| @blocks << block.dup if block.is_a?(Hash) }
@@ -133,9 +141,15 @@ module Samagotchi
133
141
 
134
142
  @model = served(message["model"]) || @model
135
143
  merge_usage(message["usage"])
144
+ read_transformations(message)
136
145
  ["", ""]
137
146
  end
138
147
 
148
+ def read_transformations(message)
149
+ entries = message["input_transformations"]
150
+ @input_transformations = entries.select { |entry| entry.is_a?(Hash) } if entries.is_a?(Array)
151
+ end
152
+
139
153
  def start_block(index, block)
140
154
  return ["", ""] unless block.is_a?(Hash)
141
155
 
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../log"
4
+ require_relative "anthropic_wire"
5
+ require_relative "openai_chat"
6
+
7
+ module Samagotchi
8
+ module LLM
9
+ # The preserved-thinking recovery of AnthropicMessages (Claude 5.x: a
10
+ # replayed thinking block is bound to the prefix it was made under, and
11
+ # a prefix that changed gets a signature 400): which step a 400 takes
12
+ # (#advance), the body that step sends (#recovered_body) and the
13
+ # response's ThinkingDrop (#with_thinking_drop). One per adapter; a
14
+ # request's own state is a Recovery.
15
+ class AnthropicBindingRecovery
16
+ # The beta header thinking.block_binding needs, and the behaviour the
17
+ # recovery asks for: the API drops a thinking block whose prefix
18
+ # changed (and every thinking block after it) for that request.
19
+ BINDING_BETA = "thinking-binding-controls-2026-08-01"
20
+ DROP_BLOCK = { prefix_mismatch_behavior: "drop_block" }.freeze
21
+ # A signature 400 that drop_block can settle: the block is valid but
22
+ # bound to another prefix. Without it the signature itself is bad
23
+ # (tampered, unreadable, another provider's): only a strip helps.
24
+ BOUND_ELSEWHERE_RE = /bound to a different conversation/i
25
+ # The block a signature 400 names: an index into the body sent.
26
+ BLOCK_PATH_RE = /messages\.(\d+)\.content\.(\d+)/
27
+ # The only drop the loop settles: a model_binding_mismatch (a /model
28
+ # downgrade) keeps its blocks, a switch back reads them again.
29
+ PREFIX_DROP = "prefix_binding_mismatch"
30
+
31
+ # One request's binding recovery (#advance): the step taken
32
+ # (nil, :drop_block or :strip), the steps tried, where a strip starts
33
+ # ([message, block] in the body), the last signature 400 and the body
34
+ # it answered, and the first 400's last sentence and diagnosis for the log line.
35
+ Recovery = Struct.new(:step, :tried, :strip_from, :signature, :why, :diagnosis)
36
+
37
+ # @param refusals [RefusalMemo] the adapter's (the :binding_controls
38
+ # the host refused is remembered there)
39
+ # @param host_name [String] for the log lines
40
+ # @param budget_form [#call] model -> whether it gets the budget form
41
+ # (AnthropicMessages#budget_form?)
42
+ def initialize(refusals:, host_name:, budget_form:)
43
+ @refusals = refusals
44
+ @host_name = host_name
45
+ @budget_form = budget_form
46
+ end
47
+
48
+ # The step a signature 400 for +body+ takes (set on +recovery+, once
49
+ # each per request), or nil: the error goes on as it is.
50
+ # - drop_block: the same body with thinking.block_binding and the beta
51
+ # header, for a block bound to another prefix, when the host takes
52
+ # the controls and the body's thinking can carry them (adaptive,
53
+ # enabled, or none: then adaptive; not disabled, nor none for a
54
+ # model on the budget form);
55
+ # - strip: the block the 400 names (else the first replayed thinking
56
+ # block) and every thinking block after it removed, for a bad
57
+ # signature, where drop_block can't apply, when the host refused
58
+ # the controls (remembered for the host), or after a second
59
+ # signature 400.
60
+ def advance(error, body, recovery, model)
61
+ return nil if body.nil? || error.status != 400
62
+
63
+ if error.thinking_binding_unsupported? && recovery.step == :drop_block
64
+ unless @refusals.refused?(:binding_controls)
65
+ @refusals.remember(:binding_controls)
66
+ Log.info(:http, "binding_controls_refused", host: @host_name, detail: error.detail.to_s[0, 200])
67
+ end
68
+ return strip_step(recovery)
69
+ end
70
+ return nil unless error.thinking_signature?
71
+
72
+ recovery.diagnosis ||= error.diagnosis
73
+ recovery.signature = [error.detail.to_s, body]
74
+ case recovery.step
75
+ when nil
76
+ return nil if thinking_positions(body).empty?
77
+
78
+ recovery.why = last_sentence(error.detail.to_s)
79
+ return strip_step(recovery) unless drop_block_fits?(error, body, model)
80
+
81
+ recovery.step = :drop_block
82
+ when :drop_block then strip_step(recovery)
83
+ end
84
+ end
85
+
86
+ # +body+ as the recovery step sends it, and the identities a strip
87
+ # removed (nil for none).
88
+ def recovered_body(body, recovery)
89
+ case recovery.step
90
+ when :drop_block then [body.merge(thinking: drop_block_thinking(body[:thinking])), nil]
91
+ when :strip then stripped_body(body, recovery.strip_from)
92
+ else [body, nil]
93
+ end
94
+ end
95
+
96
+ # +response+ with the blocks this request went without
97
+ # (ThinkingDrop), logged: a strip's, or drop_block's
98
+ # prefix_binding_mismatch entries mapped by path into +body+ (the
99
+ # body sent); any other entry is counted as kept, never settled.
100
+ def with_thinking_drop(response, recovery, body, stripped, transformations, model)
101
+ if recovery.step == :strip
102
+ identities = stripped
103
+ kept = 0
104
+ else
105
+ drops = Array(transformations).select { |entry| entry["type"] == "thinking_dropped" }
106
+ prefix, others = drops.partition { |entry| entry["reason"] == PREFIX_DROP }
107
+ identities = prefix.filter_map { |entry| identity_at(body, entry["path"]) }.uniq.freeze
108
+ kept = others.size
109
+ end
110
+ fields = { host: @host_name, model: model.to_s, how: recovery.step.to_s, dropped: identities.size,
111
+ kept: kept, why: recovery.why }
112
+ fields[:diagnosis] = recovery.diagnosis if recovery.diagnosis
113
+ Log.info(:http, "thinking_recovered", **fields)
114
+ return response if identities.empty?
115
+
116
+ response.with(thinking_drop: ThinkingDrop.new(identities: identities, how: recovery.step))
117
+ end
118
+
119
+ private
120
+
121
+ def drop_block_fits?(error, body, model)
122
+ return false unless BOUND_ELSEWHERE_RE.match?(error.detail.to_s) && !@refusals.refused?(:binding_controls)
123
+
124
+ case body[:thinking]
125
+ when nil then !@budget_form.call(model)
126
+ when Hash then %w[adaptive enabled].include?(body[:thinking][:type].to_s)
127
+ else false
128
+ end
129
+ end
130
+
131
+ # The strip, from the block the last signature 400 names in the body
132
+ # it answered (a thinking block there), else the first replayed one.
133
+ def strip_step(recovery)
134
+ return nil if recovery.tried.include?(:strip)
135
+
136
+ detail, body = recovery.signature
137
+ positions = thinking_positions(body)
138
+ named = detail.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
139
+ recovery.strip_from = positions.include?(named) ? named : positions.first
140
+ recovery.step = recovery.strip_from && :strip
141
+ end
142
+
143
+ # [message, block] of every thinking or redacted_thinking block in
144
+ # +body+'s messages.
145
+ def thinking_positions(body)
146
+ Array(body[:messages]).each_with_index.flat_map do |message, n|
147
+ Array(message[:content]).each_with_index.filter_map { |block, m| [n, m] if AnthropicWire.thinking_block?(block) }
148
+ end
149
+ end
150
+
151
+ # The body's thinking with block_binding: drop_block (adaptive
152
+ # without one: the binding controls need adaptive or enabled).
153
+ def drop_block_thinking(thinking)
154
+ thinking = thinking.is_a?(Hash) ? thinking.transform_keys(&:to_sym) : {}
155
+ thinking = { type: "adaptive" } unless %w[adaptive enabled].include?(thinking[:type].to_s)
156
+ thinking.merge(block_binding: DROP_BLOCK)
157
+ end
158
+
159
+ # +body+ without the thinking blocks from +from+ on (a trailing run:
160
+ # what Messages takes; a gap in the middle it refuses), and their
161
+ # identities. A message left empty says the placeholder.
162
+ def stripped_body(body, from)
163
+ first_message, first_block = from
164
+ removed = []
165
+ messages = body[:messages].each_with_index.map do |message, n|
166
+ next message if n < first_message
167
+
168
+ content = message[:content].each_with_index.reject do |block, m|
169
+ drop = (n > first_message || m >= first_block) && AnthropicWire.thinking_block?(block)
170
+ removed << AnthropicWire.block_identity(block) if drop
171
+ drop
172
+ end.map(&:first)
173
+ next message if content.size == message[:content].size
174
+
175
+ message.merge(content: content.empty? ? [{ type: "text", text: AnthropicWire::PLACEHOLDER }] : content)
176
+ end
177
+ [body.merge(messages: messages), removed.compact.uniq.freeze]
178
+ end
179
+
180
+ def identity_at(body, path)
181
+ n, m = path.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
182
+ return nil unless n
183
+
184
+ block = Array(body[:messages])[n]&.then { |message| Array(message[:content])[m] }
185
+ AnthropicWire.thinking_block?(block) ? AnthropicWire.block_identity(block) : nil
186
+ end
187
+
188
+ # The last sentence of a 400 (what it says changed: "The `system`
189
+ # prompt differs …"), at most 200 characters.
190
+ def last_sentence(detail)
191
+ detail.strip.split(/(?<=\.)\s+/).last.to_s[0, 200]
192
+ end
193
+ end
194
+ end
195
+ end