samagotchi 0.64.0 → 0.65.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/docs/architecture.md +20 -3
  4. data/docs/cli.md +25 -1
  5. data/docs/configuration.md +128 -3
  6. data/docs/internals/engine.md +15 -3
  7. data/docs/sessions.md +3 -2
  8. data/lib/samagotchi/bridge.rb +11 -1
  9. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  11. data/lib/samagotchi/client.rb +4 -2
  12. data/lib/samagotchi/config.rb +5 -4
  13. data/lib/samagotchi/engine.rb +39 -7
  14. data/lib/samagotchi/generation_phase.rb +17 -0
  15. data/lib/samagotchi/host_config.rb +27 -6
  16. data/lib/samagotchi/host_registry.rb +34 -11
  17. data/lib/samagotchi/idle_client.rb +62 -25
  18. data/lib/samagotchi/idle_recap.rb +5 -2
  19. data/lib/samagotchi/idle_target.rb +30 -7
  20. data/lib/samagotchi/llm/adapters.rb +41 -0
  21. data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
  22. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  23. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  24. data/lib/samagotchi/llm/api_key.rb +26 -9
  25. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  26. data/lib/samagotchi/llm/dialect.rb +57 -0
  27. data/lib/samagotchi/llm/errors.rb +81 -12
  28. data/lib/samagotchi/llm/model_listing.rb +111 -0
  29. data/lib/samagotchi/llm/model_result.rb +9 -2
  30. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  31. data/lib/samagotchi/model_profile.rb +1 -1
  32. data/lib/samagotchi/reasoning_history.rb +14 -4
  33. data/lib/samagotchi/recap_setup.rb +2 -1
  34. data/lib/samagotchi/self_report.rb +18 -28
  35. data/lib/samagotchi/session.rb +3 -2
  36. data/lib/samagotchi/session_metrics.rb +6 -11
  37. data/lib/samagotchi/steer_cut.rb +4 -0
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
  39. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  40. data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
  41. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  42. data/lib/samagotchi/turn_note.rb +22 -5
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +2 -1
  45. data/lib/samagotchi/web/public/activity.js +3 -0
  46. data/lib/samagotchi/web/public/app.js +90 -13
  47. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  48. data/lib/samagotchi/web/public/index.html +29 -0
  49. data/lib/samagotchi/web/public/live_turn.js +8 -0
  50. data/lib/samagotchi/web/public/stage_model.js +27 -7
  51. data/lib/samagotchi/web/public/stage_view.js +32 -10
  52. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  53. data/lib/samagotchi/web/public/turn_events.js +22 -8
  54. data/lib/samagotchi/web/public/turn_html.js +20 -4
  55. data/lib/samagotchi/web/public/turn_model.js +16 -9
  56. data/lib/samagotchi/web/public/turn_view.js +6 -4
  57. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61245b8998baef2bbb4c29cabc648314b4cc124cf9233099948cb7cf66dc890e
4
- data.tar.gz: ac4e8fa165b5cc59d683b1541a3758e03f60a1d14b7fd9cded087770fda162de
3
+ metadata.gz: 43045b7872d6b50ba24cc2ef1678ac6708346aac9236a5764593660e4e81fc6f
4
+ data.tar.gz: dad96a9922329ea3294d7bf04159b3c829796bdfda03d42eef724bbee4a3e4e7
5
5
  SHA512:
6
- metadata.gz: 717ca2421c97d4f93493891e4f695528a0b8495ab11a2b47c348b29c726004abb32e638ab21b3e6af62676acfc4cc31c45527bdb9dfe72a9a270b3f7bfda3e02
7
- data.tar.gz: 4916e876cf70f3b1b03e8c7554a127019e08e3b6f2e991305c8fe63ce1f6cf399155c43167ff36382c09eb2774792a4783ccbe39109702df6bd4efbe67a1ff53
6
+ metadata.gz: 4a35a44e9905f8c3411a656383c8a7f2493318732b959d526cdcbdc5dba90e71e874fe678057d3b060878dc5e4560398683790dade299258bd43cf541e0b62cf
7
+ data.tar.gz: 9dc56d9cd85b5c5f323315f99bc7c2f4e7580927966f41723a833db561dd5d6c0b3a8720c92ea32e1fafbb3b75e9eb0e93e57e9c4784e1a9983f95e7ac6f17c6
data/CHANGELOG.md CHANGED
@@ -8,6 +8,45 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.65.0] - 2026-10-13
12
+
13
+ ### Added
14
+
15
+ - **Anthropic Messages hosts** (`api: anthropic`): streamed text and thinking, native tool use, images, usage with
16
+ cache reads and writes, thinking blocks replayed verbatim (also after `--resume`; `reasoning_history` defaults to
17
+ `all` there). A new host key `auth: bearer|x-api-key` (any host) picks the header the key goes in. Thinking levels
18
+ map to Messages' thinking (`off` disabled, `low`/`medium`/`high` adaptive at that effort, `default` nothing), with
19
+ fallbacks when a host refuses a field. Recaps, `ctx.ask_model` and broadcast triage work there too. See
20
+ docs/configuration.md, "Anthropic Messages hosts". **Known limit until the next release:** Claude 5.x models
21
+ (Opus, Sonnet and Haiku 5.5, Fable 5.1) may stop a turn with a 400 "… bound to a different conversation" after the
22
+ history changed under their thinking blocks; the next release adds the recovery.
23
+ - `dialect: fireworks` on an `api: anthropic` host, for Fireworks directly or behind a gateway: Bearer auth, no
24
+ thinking `display`, no `cache_control`, and `x-session-affinity` for cache routing. `chi self` names it.
25
+ - A provider's refusal (Messages `stop_reason: refusal`, or an empty `content_filter` stop) ends the turn with
26
+ `no answer: refused by the provider (category)` in every UI and isn't retried. A `content_filter` stop after some
27
+ text keeps the text, marked `[cut off by the provider's content filter]`.
28
+ - Web composer: a row above the text field shows the keys while idle and, while a turn runs, where a message sent now
29
+ goes in (after this thinking step, tool call or answer, with its clock), with `cut in now` and `queue for after the
30
+ turn` links. Alt+Enter (Option+Enter) queues. See docs/cli.md, "Typing during a turn".
31
+ - Terminal: while a turn runs, the status row ends in `· typing goes in at the next step (/cut, /queue)`.
32
+
33
+ ### Changed
34
+
35
+ - Web: a message queued for after the running turn sits below the running turn's card, not among its rows, also
36
+ after a reload.
37
+ - A chat host that refuses a sampling field (`temperature`, `top_p`, `top_k`) gets the step again without it instead
38
+ of the turn failing; on `api: openai` hosts such a 400 no longer turns reasoning off.
39
+ - A Splash or Fireworks model's vision comes from its model list instead of reading "unknown".
40
+
41
+ ### Fixed
42
+
43
+ - A server error without a numeric code is classified by its type: an invalid request fails at once instead of being
44
+ retried, an unknown model (404, `NOT_FOUND`) is no longer retried, a `timeout_error` is a 504.
45
+ - Anthropic's context-overflow 400s ("prompt is too long", "input length and `max_tokens` exceed context limit")
46
+ roll the turn back and report it as too long for the context window, also through a gateway on the OpenAI path.
47
+
48
+ Update with `chi update` (the system bundle moves with the gem).
49
+
11
50
  ## [0.64.0] - 2026-10-13
12
51
 
13
52
  ### Added
@@ -2794,7 +2833,8 @@ and long-lived sessions.
2794
2833
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2795
2834
  hotkey panel that send selected text or the clipboard to your sessions.
2796
2835
 
2797
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.64.0...HEAD
2836
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.65.0...HEAD
2837
+ [0.65.0]: https://github.com/dm1try/samagotchi/compare/v0.64.0...v0.65.0
2798
2838
  [0.64.0]: https://github.com/dm1try/samagotchi/compare/v0.63.0...v0.64.0
2799
2839
  [0.63.0]: https://github.com/dm1try/samagotchi/compare/v0.62.0...v0.63.0
2800
2840
  [0.62.0]: https://github.com/dm1try/samagotchi/compare/v0.61.0...v0.62.0
data/docs/architecture.md CHANGED
@@ -100,7 +100,7 @@ terminal renders the events it gets over the Bridge.
100
100
  | Core | `Samagotchi::Engine` | System prompt, memory injection, tool declarations, session lifecycle, model↔tool loop (`run_turn`). No terminal coupling. |
101
101
  | UI | `Samagotchi::TerminalUI` | REPL (Reline), rendering, REPL commands. Delegates all core work to an `Engine`. |
102
102
  | Model loops | `KernelLoop` (via `LLM::NativeBackend`), `LLM::ChatLoop` | The model↔tool loop: raw prompt or OpenAI chat API, chosen per host (see below). |
103
- | Adapters | `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::HTTP` | Raw-prompt servers, the OpenAI chat API, and the HTTP both share. |
103
+ | Adapters | `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::AnthropicMessages`, `LLM::HTTP` | Raw-prompt servers, the OpenAI chat API, Anthropic's Messages API, and the HTTP they share. |
104
104
  | Tools | `lib/samagotchi/tools/*` | Execute, read, edit, write, memory, task_*, web_fetch, delegate/delegate_result, ask_user_question, reminders, send_note, list_sessions, context_read, forget_outputs, plus runtime/output-guardrails. |
105
105
  | Bundles | `lib/samagotchi/memory_bundle/*`, `BundleCommand` | `chi bundle`: install/upgrade (3-way merge), build, uninstall; the install record under `.bundles/<name>/` that the hook, guardrail and plugin loaders read ([internals/bundles.md](internals/bundles.md)). |
106
106
  | Background | `Samagotchi::Worker`, `Samagotchi::SessionManager` | `SessionManager` forks/spawns one worker process per session; its `Worker` builds the `Engine` and `Bridge` (no terminal rendering) and runs queued turns. The spawned process (own process group) forks the worker, which `setsid`s and is reparented to init once its starter exits (it waits until the worker holds `owner.lock`, so the spawner's `Process.detach` waiter tells a starting worker): nothing that walks the spawner's process tree reaches it (`SAMAGOTCHI_WORKER_DETACH=1`, `detach_from_spawner`). The lock's pid is the worker's; `spawn`'s `child_pid` is its starter's. |
@@ -398,6 +398,7 @@ Engine picks the loop from the effective model's host (`HostRegistry#resolve`):
398
398
  |---|---|---|---|
399
399
  | Raw prompt | `KernelLoop`, wrapped by `LLM::NativeBackend` | no `api:`, or `llama_cpp`/`mlx`/`omlx` | `Client` (`/completion` or `/v1/completions`), chi's own Gemma/Qwen prompt and tool-call parsing |
400
400
  | Chat | `LLM::ChatLoop` | `api: openai` | `LLM::OpenAIChat` (`/v1/chat/completions`, streamed, native tool calls) |
401
+ | Chat | `LLM::ChatLoop` | `api: anthropic` | `LLM::AnthropicMessages` (`/v1/messages`, streamed SSE, native `tool_use`) |
401
402
 
402
403
  Both return an `LLM::ModelResult` and emit the same stream events; tool calls in
403
404
  both go through `ToolRunner` (events, hooks, veto, output cap) and
@@ -438,12 +439,28 @@ threshold puts the model's own `[CONTEXT: …]` line on the tail as a system
438
439
  message (docs/internals/context-telemetry.md).
439
440
 
440
441
  **Adapters.** `Client` (raw-prompt servers) and `LLM::OpenAIChat` (one per host,
441
- `HostRegistry#adapter_for`) share `LLM::HTTP`: timeouts, TLS for https, a line
442
- reader for streamed bodies, the retry loop (`retry.*`; network errors, 429 and
442
+ `HostRegistry#adapter_for`, built by `LLM::Adapters` from the host's api) share
443
+ `LLM::HTTP`: timeouts, TLS for https, a line reader for streamed bodies, the retry loop (`retry.*`; network errors, 429 and
443
444
  500/502/503/504/529, honouring `Retry-After`; never after a stream has produced
444
445
  output) and cancel. Cancel closes the in-flight socket from the
445
446
  `CancellationController` listener, so it works on any thread.
446
447
 
448
+ `LLM::AnthropicMessages` talks Anthropic's Messages API (`POST {root}/v1/messages`) for the same chat loop:
449
+ ChatLoop builds the same OpenAI-shaped wire messages for every chat host, and the adapter translates them
450
+ (`LLM::AnthropicWire`: the head system message becomes the top-level `system` blocks, tool calls `tool_use` blocks,
451
+ tool messages `tool_result` blocks in one user message, image parts `image` blocks, later system messages user
452
+ text; empty text blocks are never sent). Its answer (`LLM::AnthropicAssembly`) is a `ChatResponse` whose
453
+ `provider_content` holds the content blocks as received, which ChatLoop saves on the model entry as
454
+ `anthropic_content` (string keys, so a resumed session sends the same bytes) and sends back verbatim when the
455
+ adapter `replays_content?` (only for entries whose calls are all paired, never for an interrupted one; earlier
456
+ entries' thinking blocks left out under `reasoning_history: turn`) (thinking blocks
457
+ need their signature and their order among text and `tool_use`). Usage is normalized to the OpenAI shape the rest
458
+ of chi reads (the prompt is input + cache reads + cache writes), stop reasons to the loop's finish reasons
459
+ (`max_tokens` → `length`, `tool_use` → `tool_calls`, `end_turn` → `stop`). A stream is read by content-block index
460
+ from the `data:` lines alone (`event:` lines are not needed); `message_start` / `message_delta` events reach
461
+ `on_delta` with that normalized `usage`, pings not at all, and a block's start or delta counts as shown for the
462
+ first-token limit. Both adapters share their model-list cache (`LLM::ModelListing`).
463
+
447
464
  **Errors.** A failed request raises an `LLM::ProviderError` of one kind:
448
465
  `ConnectionError` (`RetryExhausted`), `RateLimited` (`CreditsHeld`: a 402 about
449
466
  credit held by in-flight requests, retried after 20 s), `OutOfCredits` (any other
data/docs/cli.md CHANGED
@@ -555,6 +555,26 @@ REPL alike:
555
555
  sent after it still go in at the next step.
556
556
  At the open prompt (no turn running) `/cut TEXT` and `/queue TEXT` run TEXT
557
557
  as a normal prompt.
558
+ - **The web composer** has a row above the text field in a session (always
559
+ there, so the composer never changes height): the keys while idle, and while
560
+ the turn runs the delivery line, where a message sent now goes in (`↳ goes in after this
561
+ thinking step · 0:42 · 3k chars`, `… after this tool call (execute · 0:12)`,
562
+ `… after this answer; the turn may go on`, `… once the card above is
563
+ answered`), then two links. **Enter** (or Send) is the next step;
564
+ **Alt+Enter** (Option+Enter) or `queue for after the turn` queues it, like
565
+ `/queue TEXT`, and the bubble is dashed (`after this turn`), below the
566
+ running turn's card with the commands queued for its end, until its turn
567
+ starts; `cut in now` cuts, like `/cut TEXT` (by click only: it throws the
568
+ thinking away). Cut is enabled only while the step has streamed nothing but
569
+ thinking and cutting is on for the model (the line says `cuts at about 0:25
570
+ if you choose cut in now` until the thinking passes `steer_cut_after`, and
571
+ `cutting is off for <model>` with no cut link when it is 0); the queue link
572
+ is gone for a worker too old to queue (Alt+Enter then sends nothing, keeps the
573
+ text and says to restart the worker). A link clicked with an empty composer
574
+ arms that choice (`cut in now ✓ · Enter sends`); a second click or Esc lets
575
+ go of it. The echo says what it waits for: `waits for the next step` or
576
+ `cutting in…`, then `steered`. A phone shows the short form (`↳ after this
577
+ thinking · 0:42`) with `cut in` and `queue` chips.
558
578
  - Commands answer, wait or are refused, by what they do:
559
579
  - At once, beside the turn: `/stats`, `/recap`, `/help`, `/context`, a
560
580
  plugin's anytime command (`/btw`), and the commands that only show
@@ -708,7 +728,9 @@ approval, a hook's question and the step limit don't): two muted lines
708
728
  under its `question → answer` line, the whole text when you open the card,
709
729
  in both views and after a reload. The chip at the stage's bottom edge, `N steps · M tool
710
730
  calls` with one tick per call, opens the turn view's block right above it (newest step first
711
- while it runs; a tick opens its step). `▾` folds the stage to its status
731
+ while it runs; a tick opens its step). Below the chip, past a dashed rule, wait the
732
+ messages queued for after the turn and the commands queued for its end; they go into
733
+ the history after the turn's answer. `▾` folds the stage to its status
712
734
  row, remembered in this browser. The stage keeps its height while a turn
713
735
  runs: it grows, with a short ease, when a card or the answer needs room and gives the room back
714
736
  only when the turn moves into the history. When the turn ends the answer shows in
@@ -953,6 +975,8 @@ status> model=Qwen3.6-35B | ⑂ 2 running · 1 waiting | ctx=8.1% (under20) | me
953
975
  report none, `api: openai`, at the turn's end).
954
976
  - `mem:`: the memories the session read, with its `--memory` list; `muted:` its `--mute` list.
955
977
  Up to 8 names each, then `+N`.
978
+ - While a turn runs it ends in `· typing goes in at the next step (/cut, /queue)`; the hint is the first thing dropped
979
+ on a narrow terminal.
956
980
  - The row is cut to the terminal's width. Without a live region (output or input not a terminal,
957
981
  `TERM=dumb`) it prints as a line when it changes.
958
982
  - `SAMAGOTCHI_STATUS_LINE` / `status.line` (default `on`): `off`, `false` or `0` hides it.
@@ -231,7 +231,8 @@ hosts:
231
231
 
232
232
  `chi self` shows the variable and whether it is set (`api key FIREWORKS_API_KEY (set)`).
233
233
 
234
- Every host with `api_key_env:` sends `Authorization: Bearer <key>` on each request,
234
+ Every host with `api_key_env:` sends `Authorization: Bearer <key>` on each request (`api: anthropic`:
235
+ `x-api-key: <key>`, see [Anthropic Messages hosts](#anthropic-messages-hosts)),
235
236
  chat or raw-prompt alike, so a llama.cpp started with `--api-key` works as a native
236
237
  host too (`chi bootstrap --key-env VAR` writes such an entry). An unset variable
237
238
  fails the turn before any request (`set VAR`); a 401/403 names the variable to
@@ -292,6 +293,90 @@ workers, and resumed sessions all use the loop of the model's host. Hosts withou
292
293
  `api: openai` keep using the native `/completion`, `/v1/completions`, or oMLX
293
294
  transport path.
294
295
 
296
+ ### Anthropic Messages hosts
297
+
298
+ `api: anthropic` makes a host talk Anthropic's Messages API (`POST {url}/v1/messages`) through the same chat loop:
299
+ Anthropic's own API, a gateway that serves `/v1/messages`, or a local server that does (Splash). The url is the API
300
+ root **without** `/v1`, as `ANTHROPIC_BASE_URL` takes it (a url that ends in `/v1` is accepted: no second `/v1` is
301
+ added); an entry with neither `url:` nor `host:` talks to `https://api.anthropic.com`. The key goes in the
302
+ `x-api-key` header; `auth: bearer` sends it as `Authorization: Bearer` instead (a gateway or provider that wants
303
+ that), `auth: x-api-key` is the default spelled out. Exactly one of the two headers is sent.
304
+
305
+ ```yaml
306
+ hosts:
307
+ claude:
308
+ api: anthropic
309
+ api_key_env: ANTHROPIC_API_KEY
310
+ gateway:
311
+ url: https://gateway.example.com
312
+ api: anthropic
313
+ api_key_env: GATEWAY_KEY
314
+ auth: bearer
315
+ splash-msg:
316
+ url: http://192.0.2.10:8000
317
+ api: anthropic
318
+ ```
319
+
320
+ `auth:` works on any host (`bearer` is the default everywhere but `api: anthropic`).
321
+
322
+ **Known limit (until the next release):** Claude 5.x models (Opus, Sonnet and Haiku 5.5, Fable 5.1) bind each
323
+ thinking block to the request it came from. When the history before a block changes (the system prompt rebuilt after a
324
+ worker restart or a `/model` switch, a changed tool set, the forget layer's edits), such a model may answer HTTP 400
325
+ "… bound to a different conversation" and the turn fails with that error; `/reasoning-history turn` makes it rarer.
326
+ The next release sends Anthropic's binding controls and recovers from that 400. Fireworks, Splash and other
327
+ non-Claude models are not affected.
328
+
329
+ The loop, tools, events, hooks and UIs are the chat loop's, as for `api: openai`; only the wire differs: the
330
+ system prompt goes as the request's `system` (later system notes as user text), tool calls as `tool_use` blocks,
331
+ results as `tool_result` blocks, images as `image` blocks; thinking streams into the thinking lane; usage counts
332
+ cache reads and writes into the prompt (so `/stats` shows them). Without `default.max_tokens`, `max_tokens` is the
333
+ model list's `max_tokens` capped at 64000, else 64000 (Messages requires one). chi's OpenAI-only request fields are
334
+ never sent there; `sampling:` passes as written.
335
+
336
+ Its model list is `GET {url}/v1/models` (every page): Anthropic's shape (the window from `max_input_tokens`, images
337
+ from `capabilities.image_input`) or the OpenAI shape a gateway, Splash or Fireworks serves there (images from
338
+ `input_modalities`, `vision` or `supports_image_input`). A gateway whose list lacks an
339
+ id (a high-availability alias) takes it from the host's declared `models:`, as on any host.
340
+
341
+ **Dialects.** A provider whose `/v1/messages` departs from Anthropic's gets `dialect:` on its host (only on
342
+ `api: anthropic`; an unknown value, or a dialect on another api, warns once and is ignored). `chi self` names it on
343
+ its `loop` line. `dialect: standard` is the default written out. There is one other dialect today, `fireworks` (Fireworks' Anthropic-compatible API, directly or behind a gateway):
344
+
345
+ - the key goes as `Authorization: Bearer` (an explicit `auth:` still wins);
346
+ - thinking at `low`/`medium`/`high` is `{type: "adaptive"}` with `output_config.effort`, without `display` (Fireworks'
347
+ schema has none and refuses unknown fields; it returns thinking by default); a model switched to the budget form gets
348
+ none either;
349
+ - no `cache_control` breakpoints (Fireworks caches prompt prefixes by itself);
350
+ - `x-session-affinity: <session id>` beside `Session-Id`, so a session stays on one replica, where its cache is.
351
+
352
+ A host without `dialect:` is standard Anthropic (Claude, Splash). Give each upstream kind its own host entry: when a
353
+ gateway serves both Fireworks models and Claude ids on one url, the Claude ids go on a second entry with the same url
354
+ and no `dialect:` (Claude wants `display: summarized` and the breakpoints).
355
+
356
+ ```yaml
357
+ hosts:
358
+ work-msg: # the work gateway's Messages route, Fireworks behind it
359
+ url: https://gateway.example.com # the API root, no /v1 (chi adds /v1/messages)
360
+ api: anthropic
361
+ dialect: fireworks # Bearer auth, no thinking display, no cache_control, x-session-affinity
362
+ api_key_env: WORK_API_KEY
363
+ # auth: x-api-key # only if the gateway itself wants that header
364
+ thinking: medium # low|medium|high → output_config.effort; off → thinking disabled
365
+ models:
366
+ accounts/fireworks/models/kimi-k2p5:
367
+ price: {input: 0.6, cache_read: 0.1, output: 2.5} # example rates: put the gateway's (no cache writes on Fireworks)
368
+ work-claude: # the same gateway's Claude ids: standard Messages
369
+ url: https://gateway.example.com
370
+ api: anthropic
371
+ api_key_env: WORK_API_KEY
372
+ auth: bearer # if the gateway wants Bearer for Claude ids too
373
+ fireworks: # Fireworks directly
374
+ url: https://api.fireworks.ai/inference
375
+ api: anthropic
376
+ dialect: fireworks
377
+ api_key_env: FIREWORKS_API_KEY
378
+ ```
379
+
295
380
  To manually verify a live chat-loop tool round trip, run the gated integration
296
381
  spec. It requires the model to call `execute` and return the current UTC date:
297
382
 
@@ -512,7 +597,13 @@ models:
512
597
  - `/model` shows the level and where it came from (`thinking: off (models: qwen3.6-35b-a3b)`, `thinking: low
513
598
  (session)`), `/stats` on its `thinking:` line, `chi self` too (a session's own level first when run from its
514
599
  commands).
515
- - The idle recap and plugins' side questions always ask with thinking off, whatever the level.
600
+ - The idle recap, plugins' side questions and a broadcast's triage always ask with thinking off, whatever the
601
+ level. A model that refuses that gets no thinking fields on an `api: openai` host (its default); on an
602
+ `api: anthropic` host, where such a model always thinks, it gets adaptive thinking at `low` effort and a limit of
603
+ at least 4096 tokens, so the thinking leaves room for the answer; a model that refuses that too (one that takes no
604
+ thinking fields at all) gets none, at the normal limit. What a host refused is kept for the next recap, the next
605
+ `ctx.ask_model` on the same model, and the rest of a broadcast. A recap and `ctx.ask_model` send the session's id
606
+ as the turns do (`Session-Id`, and `x-session-affinity` under `dialect: fireworks`); a triage sends none.
516
607
 
517
608
  What each backend gets:
518
609
 
@@ -521,6 +612,20 @@ What each backend gets:
521
612
  | native (`/completion`), `qwen36` | an empty thought after the assistant cue, and no turn preamble | no knob: thinking stays as the model has it, one notice |
522
613
  | native, `gemma4` | no `<\|think\|>` token at the start of the system prompt | no knob, one notice |
523
614
  | chat host (`api: openai`) | `chat_template_kwargs: {enable_thinking: false}` and `reasoning_effort: "none"` | `reasoning_effort: <level>` |
615
+ | Messages host (`api: anthropic`) | `thinking: {type: "disabled"}` | `thinking: {type: "adaptive", display: "summarized"}` and `output_config: {effort: <level>}` |
616
+ | Messages host, `dialect: fireworks` | `thinking: {type: "disabled"}` | `thinking: {type: "adaptive"}` and `output_config: {effort: <level>}` (no `display`) |
617
+
618
+ On a Messages host `default` sends nothing: the provider's own default, which differs by model (some Claude models
619
+ 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
620
+ Claude models) gets the request again without it, as on any chat host. A model that refuses adaptive thinking
621
+ (older Claude: Haiku 4.5) is switched, once and for the worker's life, to the budget form: `thinking: {type:
622
+ "enabled", budget_tokens: B}` with B 2048 / 8192 / 24576 for low / medium / high and no effort; `max_tokens` always
623
+ stays above the budget (the budget is lowered to leave 1024 for the answer, or `max_tokens` raised to 2048). A
624
+ host that refuses the thinking `display` field gets thinking without it from then on (the level still applies); one
625
+ that refuses `cache_control` gets no cache breakpoints from then on, and a `max_tokens` over the model's output
626
+ limit is sent again with the limit the host names. On any chat host, a model that refuses a sampling field
627
+ (`temperature`, `top_p`, `top_k`; Claude refuses the empty-answer retry's temperature while thinking) gets the
628
+ request again without them, and none of them after.
524
629
 
525
630
  On chat hosts: llama.cpp honours both off switches but ignores the effort (when its `/props` says
526
631
  `chat_template_caps.supports_reasoning_effort: false`, chi says so once per session and host, from the `/props`
@@ -593,10 +698,17 @@ models:
593
698
  turns included, and answers HTTP 400 without it: under `turn` every turn after one that thought fails. Set
594
699
  `reasoning_history: all` there (the example above). DeepSeek through OpenRouter takes `turn`.
595
700
  - `drop` sends none (what chi did before this setting).
701
+ - **On an `api: anthropic` host** the reasoning goes back as the answer's own content blocks, verbatim (thinking
702
+ blocks with their signatures, in their order among text and `tool_use`), not as `reasoning_content`. The default
703
+ there is `all`: Claude keeps earlier turns' thinking in context, and leaving it out would rewrite the cached prefix
704
+ each turn (a Qwen template on Splash drops earlier turns' reasoning itself, so `all` costs nothing there). `turn`
705
+ leaves earlier turns' thinking blocks out (their text and calls stay), and `drop` counts as `turn`: the running
706
+ turn's thinking blocks always go back (a tool loop with thinking needs the last reply's). An interrupted reply, or
707
+ one whose tool calls never got their results, goes back as plain text without its blocks.
596
708
  - `all` sends every reply's reasoning, earlier turns too. It sends only the field: a local template that leaves out
597
709
  earlier turns' reasoning (Qwen's) needs `preserve_thinking` as well (above).
598
710
  - Order, first set wins: the session's own level, `SAMAGOTCHI_REASONING_HISTORY` (the whole process; for A/B runs),
599
- the `models:` entry (found the way `profile:` is), the `hosts:` entry, then `turn`. Anything else than a level warns
711
+ the `models:` entry (found the way `profile:` is), the `hosts:` entry, then `turn` (`all` on `api: anthropic`). Anything else than a level warns
600
712
  once and counts as unset. A host's level reaches a worker when it starts, as its `sampling:` does.
601
713
  - **A session's own level** (`drop`, `turn` or `all`; an explicit `turn` counts as its own) is saved in the session
602
714
  (its file's `"reasoning_history"`), so a `--resume`, a respawned worker, a continue and `/model` keep it; a plugin's
@@ -891,6 +1003,12 @@ many times per turn; `0` ends the turn at the empty answer as before. An answer
891
1003
  (90 % or more) is not retried. When the retries run out the turn ends with no answer: every UI shows one muted line,
892
1004
  `no answer: the model returned nothing (after 1 retry)`, and the model is told on its next turn with a hidden note;
893
1005
  no made-up answer is saved.
1006
+ A provider that refuses the answer (a Messages host's `stop_reason: refusal`, or an OpenAI-compatible host's
1007
+ `finish_reason: content_filter` with no text) is not asked again: the turn ends with no answer (text streamed before
1008
+ a Messages refusal is not kept), every UI shows `no answer: refused by the provider (cyber)` (the category from
1009
+ `stop_details`, when there is one), and the hidden note says the answer was refused. A `content_filter` stop after
1010
+ some text keeps that text as the answer, followed by `[cut off by the provider's content filter]`, and is not asked
1011
+ again either. Tool calls that come with either are not run.
894
1012
  A generation a plugin cuts while it streams (loop-guard's thinking watch, or any `stop_generation`,
895
1013
  [hooks.md](hooks.md#watching-the-stream)) uses the same budget: `↻ cut by loop-guard, asking again (1/1)`, and with
896
1014
  none left the turn ends as cancelled (hook).
@@ -959,6 +1077,13 @@ message (before, a failed llama.cpp `/completion` ended the turn as
959
1077
  | bad request | other 4xx; a prompt larger than the context window, whatever the status | no |
960
1078
  | protocol | a body the API doesn't promise | no |
961
1079
 
1080
+ An error event (or an error body inside a 200) has no HTTP status of its own: its numeric `error.code` stands for
1081
+ one, or a Google-style code name (Fireworks: `INVALID_ARGUMENT` 400, `UNAUTHENTICATED` 401, `PERMISSION_DENIED` 403,
1082
+ `NOT_FOUND` 404, `RESOURCE_EXHAUSTED` 429), else its `error.type` (Anthropic's names; some OpenAI-compatible servers,
1083
+ OpenAI and Ollama, use some of them: `invalid_request_error` 400, `authentication_error` 401, `billing_error` 402,
1084
+ `permission_error` 403, `not_found_error` 404, `request_too_large` 413, `rate_limit_error` 429, `api_error` 500,
1085
+ `timeout_error` 504, `overloaded_error` 529); with neither it counts as a 500.
1086
+
962
1087
  The turn's prompt and its completed tool calls stay in the session.
963
1088
 
964
1089
  ## Debug Log File
@@ -129,8 +129,8 @@ The Engine does not talk to models directly. `Engine#backend` picks the loop the
129
129
 
130
130
  ```
131
131
  Engine#backend → backend_for(@host_registry.resolve(@effective_model_name))
132
- → host entry chat? (api: openai): LLM::ChatLoop (one per Engine, built on first use)
133
- → else: LLM::NativeBackend wrapping the Engine's KernelLoop
132
+ → host entry chat? (an api in LLM::Adapters::BY_API: openai, anthropic): LLM::ChatLoop (one per Engine, built on first use)
133
+ → else: LLM::NativeBackend wrapping the Engine's KernelLoop
134
134
  ```
135
135
 
136
136
  Both implement the `LLM::ModelBackend` contract (`#complete(messages:, max_iterations:, on_stream_event:,
@@ -210,7 +210,10 @@ The differences from the native loop:
210
210
  - A 400 about reasoning (`BadRequest#reasoning_refused?`) for a request that sent reasoning back asks the step again
211
211
  without it; when that goes through, `#reasoning_history_refused!` marks the model (worker lifetime) and
212
212
  `:reasoning_history_refused` goes out. When it is refused too, the thinking-fields fallback (`#thinking_refused!`,
213
- `:thinking_refused`) runs on it, still without reasoning. Up to three requests per step.
213
+ `:thinking_refused`) runs on it, still without reasoning. Up to three requests per step. A 400 about a thinking
214
+ block's signature (`BadRequest#thinking_signature?`) or a refused binding control
215
+ (`#thinking_binding_unsupported?`) is not one: `ProviderErrors` leaves `reasoning_refused?` false for them, so one
216
+ bad block never turns reasoning off for the model.
214
217
 
215
218
  ### Shared: ToolResponse (per batch) and ToolRunner (per call)
216
219
 
@@ -343,6 +346,10 @@ What the running generation streams, fed by the Engine's stream handler: whether
343
346
  Bridge acks it as `cut`.
344
347
  A generation is cuttable while it has streamed only thinking, is still thinking, and has thought for at least
345
348
  the model's cut wait (`SteerCutAfter.resolve`: `steer_cut_after` per models:/hosts: entry, else `steer.cut_after`) seconds (`cuttable?(min_age)`). It has `started!`, `chunk!`, `retrying!` and `finished!`.
349
+ `snapshot` is the running generation as `GenerationPhase::Snapshot` `{thinking_only, age}` (nil with none running;
350
+ `age`: its thinking's seconds, nil before any): `SteerCut#generation_snapshot` → `Engine#generation_snapshot`, which
351
+ `Bridge#snapshot` merges into `current_turn.generation`, so a web page joining mid-generation can clock its delivery
352
+ line and tell whether "cut in now" can land.
346
353
 
347
354
  ### WaitingSteer
348
355
 
@@ -520,6 +527,11 @@ Both loops:
520
527
  The retry request runs at temperature 0.6 when the configured sampling sets no temperature; a configured one
521
528
  is kept. A length stop with the context at least 90% full is not retried. When the retries run out, the
522
529
  Engine keeps a `TurnNote.empty` note as the turn's end.
530
+ - **A refusal** (chat loop only): a finish reason `refusal` (Messages), or `content_filter` (OpenAI) with no text, is
531
+ an empty answer never retried; text streamed before a refusal is not kept, and tool calls with it are not run. A
532
+ `content_filter` with text keeps it, marked `ChatLoop::FILTERED_MARK`, also never retried. `ModelResult#refusal` is `{category:}` (from the response's
533
+ `stop_details`) or `{}`, and goes on the `TurnNote.empty` marker and `turn_summary.empty_answer` as `refusal`, so
534
+ every UI words the notice `no answer: refused by the provider (cyber)`.
523
535
  - **A cut generation** (`after_cut`): a generation a plugin cut (`stop_generation`) spends the same retry budget
524
536
  with its own nudge; when it is used up, the turn ends cancelled (`:hook`). A steer's cut asks again with the
525
537
  steer and spends nothing.
data/docs/sessions.md CHANGED
@@ -76,7 +76,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
76
76
  - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
77
77
  - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
78
78
  - 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).
79
- - 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 `day N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue), `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), `format.js` (pure formatters such as `previewOf`), `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), `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), `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), `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` (`node --test spec/web/public/*.test.js`).
79
+ - 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 `day N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue), `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), `format.js` (pure formatters such as `previewOf`), `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), `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), `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` (`node --test spec/web/public/*.test.js`).
80
80
  - 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).
81
81
 
82
82
  **Test-session hygiene:**
@@ -135,7 +135,8 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
135
135
  - cancelled (Ctrl-C, the web's stop): `[SYSTEM: the previous turn was cancelled (ctrl-c) after 12s; the reply
136
136
  above ends where it was cut off.]` (an answer or only narration had streamed), or `…; no answer had been shown.]` when only thinking had streamed;
137
137
  - ended with nothing visible: `[SYSTEM: the previous turn ended with no visible answer (thinking only, or
138
- nothing). The user's last message is still unanswered.]`.
138
+ nothing). The user's last message is still unanswered.]`, or `[SYSTEM: the previous turn's answer was refused by
139
+ the provider (cyber). …]` when the provider refused it.
139
140
 
140
141
  When the context window fills past 40% (then 60%, 80%), the native loop leaves a similar line (`kind: context`):
141
142
  `[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted
@@ -254,7 +254,7 @@ module Samagotchi
254
254
  seq = @engine.event_count
255
255
  {
256
256
  messages: @engine.messages_checkpoint,
257
- current_turn: @accumulator.current_turn,
257
+ current_turn: current_turn_with_generation,
258
258
  queued: @accumulator.queued,
259
259
  queued_commands: @accumulator.queued_commands,
260
260
  recap: @accumulator.recap,
@@ -316,6 +316,16 @@ module Samagotchi
316
316
 
317
317
  private
318
318
 
319
+ # The turn in progress (TurnAccumulator#current_turn) with the running
320
+ # generation's phase as generation: {thinking_only:, age:}
321
+ # (Engine#generation_snapshot), so a page joining mid-generation can
322
+ # clock its delivery line and say whether a cut can land.
323
+ def current_turn_with_generation
324
+ turn = @accumulator.current_turn
325
+ gen = turn && @engine.generation_snapshot
326
+ gen ? turn.merge(generation: gen.to_h) : turn
327
+ end
328
+
319
329
  # The Engine's commands: the built-ins and its plugins'.
320
330
  def command_registry
321
331
  @engine.command_registry
@@ -57,7 +57,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
57
57
  **Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
58
58
 
59
59
  - `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
60
- - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; a worker reads `hosts:` from the file as it starts (`SAMAGOTCHI_HOSTS_JSON` replaces it for a process it is set on; `SessionManager.spawn_options` unsets it for workers).
60
+ - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, auth, dialect, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `auth:` (`bearer|x-api-key`) is the header the key goes in (default `x-api-key` on `api: anthropic`, Bearer elsewhere); `dialect:` (`fireworks`, or `standard` for the default; `api: anthropic` only) adjusts the Messages wire for that provider (Bearer by default, no thinking `display`, no `cache_control`, `x-session-affinity`; `LLM::Dialect`, docs/configuration.md "Anthropic Messages hosts"); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; a worker reads `hosts:` from the file as it starts (`SAMAGOTCHI_HOSTS_JSON` replaces it for a process it is set on; `SessionManager.spawn_options` unsets it for workers).
61
61
  - `models:` map of model id or alias → `{profile, vision, sampling, thinking, reasoning_history}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `reasoning_history:` (here or on a host) is `drop|turn|all`, which of its reasoning a chat host's model gets back. Precedence: the session's own level (`/reasoning-history LEVEL`, saved in the session) > `SAMAGOTCHI_REASONING_HISTORY` > `models:` > `hosts.<name>.reasoning_history` > `turn`; `docs/configuration.md` "Reasoning history". `/thinking` and `/reasoning-history` ask before a change that restarts the prompt cache; `--yes` applies it (`chi send` commands apply directly). `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
62
62
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
63
63
  - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
@@ -128,7 +128,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
128
128
  ## Validations
129
129
 
130
130
  - **Model name** (`default.model` / `SAMAGOTCHI_DEFAULT_MODEL`): `ModelProfile.required_model_name` — non-empty string, otherwise harness fails fast at startup. Via `Config.get("default.model")`.
131
- - **Host api** (`hosts.<name>.api`): `llama_cpp|mlx|omlx` (raw-prompt loop; also the transport) or `openai` (chat loop at `http://HOST:PORT/v1`). Absent: raw-prompt loop. It replaces the removed `backend` setting.
131
+ - **Host api** (`hosts.<name>.api`): `llama_cpp|mlx|omlx` (raw-prompt loop; also the transport), `openai` (chat loop at `http://HOST:PORT/v1`) or `anthropic` (chat loop over the Messages API at `{url}/v1/messages`; without `url:`/`host:` it is `https://api.anthropic.com`). Absent: raw-prompt loop. It replaces the removed `backend` setting.
132
132
  - **Transport** (`server.transport`): enum `llama_cpp|mlx|omlx`.
133
133
  - **Profile** (`models.<id>.profile`, `hosts.<name>.profile`, `SAMAGOTCHI_MODEL_PROFILE`): enum `qwen36|gemma4`; an unknown one warns and is ignored.
134
134
  - **Alias name** (`write_model_alias!`):
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.64.0
3
+ version: 0.65.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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
- config_modification_protocol.md: sha256:99ad8a596cc0f5129caf82cc00314d597f0ec309be96e2d48ca4fa936b4ae343
9
+ config_modification_protocol.md: sha256:301895777916dd998d8946aaa2e8f3a6e7f2b18eaf9362eef11471ae602d54ab
10
10
  memory_guide.md: sha256:b0a1ba42c5334d4117b5733c8c26d7477364baca9169b0c0a711de6ac7fc7f3c
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -195,9 +195,11 @@ module Samagotchi
195
195
  # @param api_key_env [String, nil] the variable holding the host's API
196
196
  # key (llama.cpp's --api-key), sent as a bearer token on every request;
197
197
  # nil sends no Authorization header
198
+ # @param auth [Symbol, nil] the header the key goes in (LLM::ApiKey::SCHEMES;
199
+ # HostEntry#auth_scheme), nil: Bearer
198
200
  # @param env [Hash] where the key variable is read
199
201
  def initialize(host: nil, port: nil, open_timeout: nil, read_timeout: nil, transport: nil, sleeper: nil, scheme: nil,
200
- first_token_timeout: nil, name: nil, api_key_env: nil, env: ENV)
202
+ first_token_timeout: nil, name: nil, api_key_env: nil, auth: nil, env: ENV)
201
203
  # Unified config precedence: CLI > ENV > file > default (via Samagotchi::Config)
202
204
  @host = host || Samagotchi::Config.get("server.host")
203
205
  @port = (port || Samagotchi::Config.get("server.port")).to_i
@@ -217,7 +219,7 @@ module Samagotchi
217
219
  @label = name.to_s.empty? ? @transport.label : name.to_s
218
220
  @http = LLM::HTTP.new(label: @label, open_timeout: @open_timeout, read_timeout: @read_timeout,
219
221
  sleeper: sleeper, first_token_timeout: first_token_timeout,
220
- api_key: LLM::ApiKey.for(api_key_env, host: @label, env: env))
222
+ api_key: LLM::ApiKey.for(api_key_env, host: @label, env: env, scheme: auth))
221
223
  end
222
224
 
223
225
  # Seconds a completion may take to stream its first text, or nil.
@@ -279,7 +279,7 @@ module Samagotchi
279
279
  # Maps of named entries and the keys an entry may hold
280
280
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
281
281
  MAP_ENTRY_KEYS = {
282
- "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking
282
+ "hosts" => %w[host port url transport api api_key_env auth dialect profile first_token_timeout vision sampling thinking
283
283
  reasoning_history enabled remote window_tokens steer_cut_after llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
284
284
  "models" => %w[profile vision sampling thinking reasoning_history window_tokens steer_cut_after llm_context_strategy llm_context_apply
285
285
  llm_context_budget_tokens].freeze
@@ -851,9 +851,10 @@ module Samagotchi
851
851
 
852
852
  HOSTS_KEY = "hosts"
853
853
  VALID_TRANSPORTS_FOR_CONFIG = %w[llama_cpp mlx omlx].freeze
854
- # How chi talks to a host: a raw-prompt api (also the Client transport) or
855
- # openai, the chat loop against <host>/v1.
856
- VALID_APIS_FOR_CONFIG = (VALID_TRANSPORTS_FOR_CONFIG + %w[openai]).freeze
854
+ # How chi talks to a host: a raw-prompt api (also the Client transport),
855
+ # or a chat api the chat loop talks (LLM::Adapters): openai (Chat
856
+ # Completions against <host>/v1) or anthropic (Messages).
857
+ VALID_APIS_FOR_CONFIG = (VALID_TRANSPORTS_FOR_CONFIG + %w[openai anthropic]).freeze
857
858
  HOST_NAME_RE = /\A[a-z0-9][a-z0-9._-]*\z/i
858
859
  ENV_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
859
860