samagotchi 0.77.0 → 0.78.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +10 -6
  5. data/docs/internals/anthropic.md +73 -5
  6. data/docs/internals/engine.md +1 -0
  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/anthropic_assembly.rb +14 -0
  15. data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
  16. data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
  17. data/lib/samagotchi/llm/chat_loop.rb +49 -2
  18. data/lib/samagotchi/llm/errors.rb +6 -4
  19. data/lib/samagotchi/llm/http.rb +2 -1
  20. data/lib/samagotchi/llm/openai_chat.rb +12 -2
  21. data/lib/samagotchi/note_delivery.rb +2 -2
  22. data/lib/samagotchi/plugin/sessions.rb +2 -2
  23. data/lib/samagotchi/reply_wait.rb +2 -3
  24. data/lib/samagotchi/session_manager.rb +0 -1
  25. data/lib/samagotchi/session_metrics/totals.rb +4 -3
  26. data/lib/samagotchi/session_metrics.rb +9 -0
  27. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  28. data/lib/samagotchi/terminal_ui.rb +2 -2
  29. data/lib/samagotchi/tools/delegate.rb +2 -2
  30. data/lib/samagotchi/tools/delegate_result.rb +2 -2
  31. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  32. data/lib/samagotchi/version.rb +1 -1
  33. data/lib/samagotchi/web/public/app.js +217 -19
  34. data/lib/samagotchi/web/public/composer_fold.js +58 -0
  35. data/lib/samagotchi/web/public/composer_size.js +10 -0
  36. data/lib/samagotchi/web/public/index.html +27 -3
  37. data/lib/samagotchi/worker.rb +1 -0
  38. metadata +2 -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: 1b19c3433cc4f46956563a8e4e6532d8b60061084b8050b534b9230b3c608c54
4
+ data.tar.gz: 97b8e078c43e1c9901139f1f937d16e43b57b6147cd8298fbb9b77abb37c0b96
5
5
  SHA512:
6
- metadata.gz: 58012cc677a1b2c14e358dabed798856f88a8a74f59dff1606f08dca297ae78d5f89d69c392b2254523308996a67a86ff828205e07a7eac7ea0b7adf99961d79
7
- data.tar.gz: 7332bff82c9060f9ad9a77063891ced098572ce28861fbe2a65ab9486076478d40f1731fec13f41ec9dc4a15fc60e14ac023b40255c0356993bfe22969817986
6
+ metadata.gz: '0022706491e62a2d7f603a5290fd4c5525b17c6712af6d31dc5ce163b36c4e4414074d7b4da609f890ec5e243ea89b33a5ba0621528d8face6c5226108d0a443'
7
+ data.tar.gz: da23fef030284a3401d5ec17017ef2a7d1165d30909f4a96e095823315bc38bbbe1eaf1bdfd76eefe604af19cd160c5f68fd19cb3c4ccb478b28bd9ea1de57eb
data/CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.78.0] - 2026-10-17
12
+
13
+ ### Added
14
+
15
+ - Web: dragging the message box's top-edge grip down folds the box away for room to read; the info bar stays at the
16
+ 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
17
+ bring the box back (a draft marks the handle in the accent, a session waiting on you in the attention colour), and
18
+ Stop turn moves into the info bar while it is folded. The choice is remembered per browser, and an archived session
19
+ opens folded.
20
+
21
+ ### Changed
22
+
23
+ - Workers no longer load the terminal UI or Reline, saving about 50–100 ms and 2 MB per worker start; a plugin
24
+ that uses Reline, StringIO, Forwardable or Tempfile must now require it itself.
25
+
26
+ ### Fixed
27
+
28
+ - Claude 5.x on `api: anthropic`: a request whose earlier thinking the API refuses after the system prompt or tools
29
+ changed (a resume or respawn after memory or AGENTS.md edits, `/model`, `/thinking`, `/toolkit`) no longer fails the
30
+ turn. chi asks again with Anthropic's binding controls (or without those thinking blocks), drops that thinking from
31
+ the session, and `/stats` counts it as `thinking dropped`.
32
+
33
+ Update with `chi update` (the system bundle 0.78.0), then restart `chi web`.
34
+
11
35
  ## [0.77.0] - 2026-10-17
12
36
 
13
37
  ### Changed
@@ -3245,7 +3269,8 @@ and long-lived sessions.
3245
3269
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3246
3270
  hotkey panel that send selected text or the clipboard to your sessions.
3247
3271
 
3248
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.77.0...HEAD
3272
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.78.0...HEAD
3273
+ [0.78.0]: https://github.com/dm1try/samagotchi/compare/v0.77.0...v0.78.0
3249
3274
  [0.77.0]: https://github.com/dm1try/samagotchi/compare/v0.76.0...v0.77.0
3250
3275
  [0.76.0]: https://github.com/dm1try/samagotchi/compare/v0.75.0...v0.76.0
3251
3276
  [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,
@@ -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): #binding_recovery, 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
@@ -32,7 +35,7 @@ Paths are under `lib/samagotchi/llm/`.
32
35
  | `anthropic_assembly.rb` | `AnthropicAssembly`: a response from a whole body (`.from_message`) or stream events (`#add`), its usage in the OpenAI shape |
33
36
  | `dialect.rb` | `Dialect` (`STANDARD`, `FIREWORKS`): what a provider's `/v1/messages` does differently |
34
37
  | `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`) |
38
+ | `chat_loop.rb` | `ChatLoop#replay_blocks`, `#reasoning_history`'s floor, `Run#with_thinking` (saves `anthropic_content`), `#settle_dropped_thinking!` |
36
39
 
37
40
  ## The adapter
38
41
 
@@ -112,8 +115,70 @@ text and `tool_use` blocks (a tool loop with thinking needs the last assistant t
112
115
  - `ChatLoop#reasoning_history` floors the level at `turn` on a replaying adapter: `drop` would remove the running
113
116
  run's thinking, which Messages needs. For the same reason a reasoning 400 doesn't ask again without reasoning
114
117
  (`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.
118
+ - A 400 about a thinking block's signature (`BadRequest#thinking_signature?`) is recovered in the adapter, and the
119
+ blocks it cost leave the history (next section).
120
+
121
+ ## Preserved thinking (Claude 5.x)
122
+
123
+ Claude 5.x models bind each thinking block to the prefix it was minted after: both `system` blocks (the stable part
124
+ and the per-session tail), the tool set, and every earlier message. Sent back after any of those changed, the block
125
+ gets a 400 `messages.N.content.M: Invalid signature in thinking block. The block is bound to a different
126
+ conversation.`, and its last sentence says what changed ("The `system` prompt differs …"). chi's history is
127
+ append-only within a process, so what changes the prefix is:
128
+
129
+ 1. **A system-prompt rebuild** (`SystemPrompt#build` is cached per process and key; `PromptBuilder#reset!` drops it):
130
+ a new process (`--resume`, a respawn after `session.idle_exit_minutes`, a crash) after a memory index line, an
131
+ AGENTS.md, an appended file, a model note or a bundle memory changed; `/model` (the tail's `Model:` line, its
132
+ overlays and notes); `/thinking` (another key); a toolkit or profile change; a plugin's `tools_changed!`.
133
+ 2. **A tool set change**: the same triggers, plus MCP lines that differ on a respawn.
134
+ 3. **History edits by design**: the `llm_context` `stale`/`forget` stubs, and the image cut moving a batch.
135
+
136
+ A model switch never 400s: the API drops blocks another model can't read silently (`model_binding_mismatch`).
137
+
138
+ **Recover on the 400, then settle.** Normal requests carry no beta header and no `block_binding`, so Fireworks,
139
+ Splash and gateways see the same bytes as before. On a signature 400, before anything streamed,
140
+ `AnthropicMessages#binding_recovery` picks the next step, once each per request:
141
+
142
+ - **drop_block**: the same body with `thinking.block_binding: {prefix_mismatch_behavior: "drop_block"}` (the body's
143
+ adaptive or enabled thinking, `display` as sent; adaptive when the level sends none) and the header
144
+ `anthropic-beta: thinking-binding-controls-2026-08-01`. The answer's `input_transformations` (in `message_start`'s
145
+ message, or the body; `AnthropicAssembly#input_transformations`) list the dropped blocks by path into the body
146
+ sent; the adapter maps the `prefix_binding_mismatch` ones to block identities. Any other entry
147
+ (`model_binding_mismatch`: a switch back reads those blocks again) is counted as kept, never settled.
148
+ - **strip**: the block the 400 names (else the first replayed thinking block) and every thinking or
149
+ redacted_thinking block after it removed from the body (a trailing run, which Messages takes). For a bad
150
+ signature (the 400 without "bound to a different conversation": drop_block doesn't apply), thinking `disabled`
151
+ (block_binding goes only with adaptive or enabled), a model on the budget form with no thinking field, a host
152
+ that refused the controls (`binding_controls`, remembered), or a second signature 400 after drop_block.
153
+
154
+ A third signature 400 fails the turn. The extra requests are 400s, rejected before generation and not billed. The
155
+ step is request state that `#request_body`'s rebuild keeps, so a later `#refusal_fix` (display, cache_control,
156
+ output cap) in the same request keeps the binding or the strip. The response carries `thinking_drop`
157
+ (`LLM::ThinkingDrop`: `identities`, `how`), and the adapter logs `thinking_recovered` (`how`, `dropped`, `kept`,
158
+ `why`: the 400's last sentence).
159
+
160
+ A block's identity (`AnthropicWire.block_identity`, shared by the adapter and ChatLoop) is its `signature`, a
161
+ redacted block's `data`, or, when that is empty (Splash's `signature: ""`), `text:` + the SHA256 of its text.
162
+
163
+ `ChatLoop::Run#generate` hands those identities to `ChatLoop#settle_dropped_thinking!` before the step adds its own
164
+ entry, whatever the finish reason: each model entry holding one is replaced in place (same index) with a copy
165
+ without those blocks (`thinking:` stays; an entry left with none loses `anthropic_content`). That is the edit the
166
+ API made, so the next request is valid with no beta (a pruned history is valid: removing exactly the dropped set
167
+ leaves no gap), and the session file keeps it, since `Engine#kept_messages` and `#failed_messages` take the run's
168
+ conversation whole. It emits `:thinking_dropped` (`count`, `how`) and `thinking_dropped` on `:generation_completed`,
169
+ which `SessionMetrics` sums for `/stats`. A drop that settles nothing (blocks already settled came back, e.g. a
170
+ rolled-back turn's) logs `thinking_recovery_repeated` once per run. A cancelled, cut or dropped generation settles
171
+ nothing; the next request recovers again.
172
+
173
+ Each break costs one free 400, one cache write of the prefix from the edit point, and the reasoning the dropped
174
+ blocks carried. Whether the request after a drop_block one reads the cache that request wrote (it no longer carries
175
+ `block_binding`) is unverified.
176
+
177
+ Why not send drop_block on every request: it needs adaptive or enabled thinking on the body (`default` sends no
178
+ thinking field, `off` sends disabled), it would put a beta header on every request to every host, and it would drop
179
+ the same blocks again on every request until something pruned them. The reactive form does the same work in one
180
+ place, for one extra 400 round trip per break. The `anthropic-thinking-prefix-mismatch` diagnosis header (on the
181
+ 400, undocumented) is kept on the provider error and logged as `diagnosis` on `thinking_recovered`.
117
182
 
118
183
  ## Cache breakpoints
119
184
 
@@ -152,7 +217,10 @@ adapter's life, so ChatLoop's broader paths never see them (`#refusal_fix`, logg
152
217
  | 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`) |
153
218
  | `cache_control` | no breakpoints | per host (`cache_control_refused`) |
154
219
  | `max_tokens: N > M` (`OUTPUT_CAP_RE`) | `max_tokens` capped at M | per model (`output_cap`) |
220
+ | `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
221
 
156
222
  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.
223
+ overflow, a refused sampling field, a signature or a binding-controls 400 is not one of these (`#refusal_fix` keeps
224
+ excluding them, so `ADAPTIVE_REFUSED_RE` never reads a binding 400's "adaptive" as a form refusal): a signature 400
225
+ goes to the binding recovery above, the rest on as they are, to the loop's own handling (ChatLoop's sampling and
226
+ thinking-fields fallbacks) or to the turn's failure.
@@ -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.78.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
@@ -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
 
@@ -43,6 +43,11 @@ module Samagotchi
43
43
  # breakpoints for the host;
44
44
  # - max_tokens over the model's output limit: the limit the message
45
45
  # names, per model.
46
+ # A signature 400 (Claude 5.x preserved thinking: a replayed block bound
47
+ # to a prefix that changed) is recovered here too (#binding_recovery):
48
+ # asked again with drop_block, or with the blocks stripped, and the
49
+ # dropped blocks handed to the loop (ChatResponse#thinking_drop), which
50
+ # removes them from the history.
46
51
  class AnthropicMessages
47
52
  include ModelListing
48
53
  include AdapterCommon
@@ -71,6 +76,27 @@ module Samagotchi
71
76
  # (configured sampling passes as written).
72
77
  DROPPED_OPTIONS = %i[reasoning_effort chat_template_kwargs stream_options prompt_cache_key logprobs top_logprobs
73
78
  max_completion_tokens].freeze
79
+ # The beta header thinking.block_binding needs, and the behaviour the
80
+ # recovery asks for: the API drops a thinking block whose prefix
81
+ # changed (and every thinking block after it) for that request.
82
+ BINDING_BETA = "thinking-binding-controls-2026-08-01"
83
+ DROP_BLOCK = { prefix_mismatch_behavior: "drop_block" }.freeze
84
+ # A signature 400 that drop_block can settle: the block is valid but
85
+ # bound to another prefix. Without it the signature itself is bad
86
+ # (tampered, unreadable, another provider's): only a strip helps.
87
+ BOUND_ELSEWHERE_RE = /bound to a different conversation/i
88
+ # The block a signature 400 names: an index into the body sent.
89
+ BLOCK_PATH_RE = /messages\.(\d+)\.content\.(\d+)/
90
+ THINKING_TYPES = %w[thinking redacted_thinking].freeze
91
+ # The only drop the loop settles: a model_binding_mismatch (a /model
92
+ # downgrade) keeps its blocks, a switch back reads them again.
93
+ PREFIX_DROP = "prefix_binding_mismatch"
94
+
95
+ # One request's binding recovery (#binding_recovery): the step taken
96
+ # (nil, :drop_block or :strip), the steps tried, where a strip starts
97
+ # ([message, block] in the body), the last signature 400 and the body
98
+ # it answered, and the first 400's last sentence and diagnosis for the log line.
99
+ Recovery = Struct.new(:step, :tried, :strip_from, :signature, :why, :diagnosis)
74
100
 
75
101
  attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout, :dialect
76
102
 
@@ -111,14 +137,29 @@ module Samagotchi
111
137
  def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
112
138
  session_id: nil)
113
139
  fixed = []
140
+ recovery = Recovery.new(tried: [])
141
+ shown = false
114
142
  begin
115
- body = request_body(messages, tools, model, options)
116
- request = post_request("#{@base_url}/v1/messages", body, session_id: session_id)
143
+ # A rebuilt body (a refusal fix's retry) keeps the recovery.
144
+ body, stripped = recovered_body(request_body(messages, tools, model, options), recovery)
145
+ headers = recovery.step == :drop_block ? { "anthropic-beta" => BINDING_BETA } : {}
146
+ request = post_request("#{@base_url}/v1/messages", body, session_id: session_id, extra_headers: headers)
117
147
  log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body), cache: cache_summary(body) }
118
- return chat_once(request, cancel_controller, log_fields) unless @stream
119
-
120
- chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields)
148
+ response, transformations =
149
+ if @stream
150
+ chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields, -> { shown = true })
151
+ else
152
+ chat_once(request, cancel_controller, log_fields)
153
+ end
154
+ recovery.step ? with_thinking_drop(response, recovery, body, stripped, transformations, model) : response
121
155
  rescue BadRequest => e
156
+ # Only before anything was shown: a mid-stream error must not
157
+ # repeat its deltas.
158
+ if !shown && binding_recovery(e, body, recovery, model)
159
+ recovery.tried << recovery.step
160
+ retry
161
+ end
162
+
122
163
  fix = refusal_fix(e, body, model)
123
164
  raise if fix.nil? || fixed.include?(fix)
124
165
 
@@ -249,6 +290,152 @@ module Samagotchi
249
290
  body.merge(max_tokens: max_tokens, thinking: thinking.merge(budget_tokens: budget))
250
291
  end
251
292
 
293
+ # The step a signature 400 for +body+ takes (set on +recovery+, once
294
+ # each per request), or nil: the error goes on as it is.
295
+ # - drop_block: the same body with thinking.block_binding and the beta
296
+ # header, for a block bound to another prefix, when the host takes
297
+ # the controls and the body's thinking can carry them (adaptive,
298
+ # enabled, or none: then adaptive; not disabled, nor none for a
299
+ # model on the budget form);
300
+ # - strip: the block the 400 names (else the first replayed thinking
301
+ # block) and every thinking block after it removed, for a bad
302
+ # signature, where drop_block can't apply, when the host refused
303
+ # the controls (remembered for the host), or after a second
304
+ # signature 400.
305
+ def binding_recovery(error, body, recovery, model)
306
+ return nil if body.nil? || error.status != 400
307
+
308
+ if error.thinking_binding_unsupported? && recovery.step == :drop_block
309
+ unless @refusals.refused?(:binding_controls)
310
+ @refusals.remember(:binding_controls)
311
+ Log.info(:http, "binding_controls_refused", host: @host_name, detail: error.detail.to_s[0, 200])
312
+ end
313
+ return strip_step(recovery)
314
+ end
315
+ return nil unless error.thinking_signature?
316
+
317
+ recovery.diagnosis ||= error.diagnosis
318
+ recovery.signature = [error.detail.to_s, body]
319
+ case recovery.step
320
+ when nil
321
+ return nil if thinking_positions(body).empty?
322
+
323
+ recovery.why = last_sentence(error.detail.to_s)
324
+ return strip_step(recovery) unless drop_block_fits?(error, body, model)
325
+
326
+ recovery.step = :drop_block
327
+ when :drop_block then strip_step(recovery)
328
+ end
329
+ end
330
+
331
+ def drop_block_fits?(error, body, model)
332
+ return false unless BOUND_ELSEWHERE_RE.match?(error.detail.to_s) && !@refusals.refused?(:binding_controls)
333
+
334
+ case body[:thinking]
335
+ when nil then !budget_form?(model)
336
+ when Hash then %w[adaptive enabled].include?(body[:thinking][:type].to_s)
337
+ else false
338
+ end
339
+ end
340
+
341
+ # The strip, from the block the last signature 400 names in the body
342
+ # it answered (a thinking block there), else the first replayed one.
343
+ def strip_step(recovery)
344
+ return nil if recovery.tried.include?(:strip)
345
+
346
+ detail, body = recovery.signature
347
+ positions = thinking_positions(body)
348
+ named = detail.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
349
+ recovery.strip_from = positions.include?(named) ? named : positions.first
350
+ recovery.step = recovery.strip_from && :strip
351
+ end
352
+
353
+ # [message, block] of every thinking or redacted_thinking block in
354
+ # +body+'s messages.
355
+ def thinking_positions(body)
356
+ Array(body[:messages]).each_with_index.flat_map do |message, n|
357
+ Array(message[:content]).each_with_index.filter_map { |block, m| [n, m] if thinking_block?(block) }
358
+ end
359
+ end
360
+
361
+ def thinking_block?(block) = block.is_a?(Hash) && THINKING_TYPES.include?((block["type"] || block[:type]).to_s)
362
+
363
+ # +body+ as the recovery step sends it, and the identities a strip
364
+ # removed (nil for none).
365
+ def recovered_body(body, recovery)
366
+ case recovery.step
367
+ when :drop_block then [body.merge(thinking: drop_block_thinking(body[:thinking])), nil]
368
+ when :strip then stripped_body(body, recovery.strip_from)
369
+ else [body, nil]
370
+ end
371
+ end
372
+
373
+ # The body's thinking with block_binding: drop_block (adaptive
374
+ # without one: the binding controls need adaptive or enabled).
375
+ def drop_block_thinking(thinking)
376
+ thinking = thinking.is_a?(Hash) ? thinking.transform_keys(&:to_sym) : {}
377
+ thinking = { type: "adaptive" } unless %w[adaptive enabled].include?(thinking[:type].to_s)
378
+ thinking.merge(block_binding: DROP_BLOCK)
379
+ end
380
+
381
+ # +body+ without the thinking blocks from +from+ on (a trailing run:
382
+ # what Messages takes; a gap in the middle it refuses), and their
383
+ # identities. A message left empty says the placeholder.
384
+ def stripped_body(body, from)
385
+ first_message, first_block = from
386
+ removed = []
387
+ messages = body[:messages].each_with_index.map do |message, n|
388
+ next message if n < first_message
389
+
390
+ content = message[:content].each_with_index.reject do |block, m|
391
+ drop = (n > first_message || m >= first_block) && thinking_block?(block)
392
+ removed << AnthropicWire.block_identity(block) if drop
393
+ drop
394
+ end.map(&:first)
395
+ next message if content.size == message[:content].size
396
+
397
+ message.merge(content: content.empty? ? [{ type: "text", text: AnthropicWire::PLACEHOLDER }] : content)
398
+ end
399
+ [body.merge(messages: messages), removed.compact.uniq.freeze]
400
+ end
401
+
402
+ # +response+ with the blocks this request went without
403
+ # (ThinkingDrop), logged: a strip's, or drop_block's
404
+ # prefix_binding_mismatch entries mapped by path into +body+ (the
405
+ # body sent); any other entry is counted as kept, never settled.
406
+ def with_thinking_drop(response, recovery, body, stripped, transformations, model)
407
+ if recovery.step == :strip
408
+ identities = stripped
409
+ kept = 0
410
+ else
411
+ drops = Array(transformations).select { |entry| entry["type"] == "thinking_dropped" }
412
+ prefix, others = drops.partition { |entry| entry["reason"] == PREFIX_DROP }
413
+ identities = prefix.filter_map { |entry| identity_at(body, entry["path"]) }.uniq.freeze
414
+ kept = others.size
415
+ end
416
+ fields = { host: @host_name, model: model.to_s, how: recovery.step.to_s, dropped: identities.size,
417
+ kept: kept, why: recovery.why }
418
+ fields[:diagnosis] = recovery.diagnosis if recovery.diagnosis
419
+ Log.info(:http, "thinking_recovered", **fields)
420
+ return response if identities.empty?
421
+
422
+ response.with(thinking_drop: ThinkingDrop.new(identities: identities, how: recovery.step))
423
+ end
424
+
425
+ def identity_at(body, path)
426
+ n, m = path.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
427
+ return nil unless n
428
+
429
+ block = Array(body[:messages])[n]&.then { |message| Array(message[:content])[m] }
430
+ thinking_block?(block) ? AnthropicWire.block_identity(block) : nil
431
+ end
432
+
433
+ # The last sentence of a 400 (what it says changed: "The `system`
434
+ # prompt differs …"), at most 200 characters.
435
+ def last_sentence(detail)
436
+ detail.strip.split(/(?<=\.)\s+/).last.to_s[0, 200]
437
+ end
438
+
252
439
  # What a 400 for +body+ changes (remembered), or nil: the error goes
253
440
  # on as it is.
254
441
  def refusal_fix(error, body, model)
@@ -309,30 +496,40 @@ module Samagotchi
309
496
  raise error if error
310
497
  raise ProtocolError.new("#{@host_name}: no content in the Messages response", host: @host_name) unless message?(body)
311
498
 
312
- AnthropicAssembly.from_message(body).response
499
+ assembly = AnthropicAssembly.from_message(body)
500
+ [assembly.response, assembly.input_transformations]
313
501
  end
314
502
 
315
503
  def message?(body) = body.is_a?(Hash) && body["content"].is_a?(Array)
316
504
 
317
505
  # The SSE stream: each `data:` event goes through the assembly (the
318
506
  # `event:` lines are not needed, so a gateway that drops them works);
319
- # on_delta gets every event but pings, with its text and reasoning.
320
- def chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields)
321
- stream_assembled(request, cancel_controller, on_retry, log_fields, assembly_class: AnthropicAssembly,
322
- what: "stream event") do |assembly, event, shown|
507
+ # on_delta gets every event but pings, with its text and reasoning;
508
+ # +on_shown+ hears each event that shows something. Returns the
509
+ # response and the message's input_transformations.
510
+ def chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields, on_shown = nil)
511
+ last = nil
512
+ response = stream_assembled(request, cancel_controller, on_retry, log_fields, assembly_class: AnthropicAssembly,
513
+ what: "stream event") do |assembly, event, shown|
514
+ last = assembly
323
515
  content, reasoning = assembly.add(event)
324
- shown.call if AnthropicAssembly.shows?(event)
516
+ if AnthropicAssembly.shows?(event)
517
+ shown.call
518
+ on_shown&.call
519
+ end
325
520
  on_delta&.call(content: content, reasoning: reasoning, payload: assembly.with_usage(event)) unless event["type"] == "ping"
326
521
  end
522
+ [response, last&.input_transformations || []]
327
523
  end
328
524
 
329
- def post_request(url, body, session_id: nil)
525
+ # +extra_headers+ go after the defaults (the binding beta).
526
+ def post_request(url, body, session_id: nil, extra_headers: {})
330
527
  headers = { "anthropic-version" => API_VERSION }
331
528
  unless session_id.to_s.empty?
332
529
  headers["Session-Id"] = session_id.to_s
333
530
  headers[@dialect.session_header] = session_id.to_s if @dialect.session_header
334
531
  end
335
- json_post(url, body, headers)
532
+ json_post(url, body, headers.merge(extra_headers))
336
533
  end
337
534
  end
338
535
  end