samagotchi 0.67.0 → 0.68.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/docs/architecture.md +6 -3
  4. data/docs/internals/engine.md +58 -9
  5. data/docs/sessions.md +1 -1
  6. data/docs/testing.md +1 -1
  7. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  8. data/lib/samagotchi/effective_model.rb +283 -0
  9. data/lib/samagotchi/engine.rb +211 -358
  10. data/lib/samagotchi/level_setting_command.rb +99 -0
  11. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  12. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  13. data/lib/samagotchi/session.rb +22 -8
  14. data/lib/samagotchi/session_setting.rb +86 -0
  15. data/lib/samagotchi/thinking_command.rb +24 -76
  16. data/lib/samagotchi/thinking_notices.rb +169 -0
  17. data/lib/samagotchi/version.rb +1 -1
  18. data/lib/samagotchi/web/app.rb +55 -84
  19. data/lib/samagotchi/web/public/app.js +74 -505
  20. data/lib/samagotchi/web/public/bubbles.js +291 -0
  21. data/lib/samagotchi/web/public/cards_place.js +184 -0
  22. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  23. data/lib/samagotchi/web/public/command_list.js +12 -10
  24. data/lib/samagotchi/web/public/index.html +15 -7
  25. data/lib/samagotchi/web/public/info_bar.js +3 -3
  26. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  27. data/lib/samagotchi/web/public/init_row.js +21 -10
  28. data/lib/samagotchi/web/public/level_chip.js +29 -80
  29. data/lib/samagotchi/web/public/lists_view.js +64 -9
  30. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  31. data/lib/samagotchi/web/public/model_picker.js +26 -22
  32. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  33. data/lib/samagotchi/web/public/refs.js +13 -0
  34. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  35. data/lib/samagotchi/web/public/stage_model.js +33 -0
  36. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  37. data/lib/samagotchi/web/public/stage_view.js +56 -335
  38. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  39. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  40. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  41. data/lib/samagotchi/worker.rb +11 -16
  42. metadata +14 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58f42be965113cdd1f5736f77b9a5be8e9fbd77088b4a2013f9b7e86ae7ceb12
4
- data.tar.gz: a093f0921d62e84c9e6c34ac10482e57e9e041a5a9d21eda41a88052e819cda1
3
+ metadata.gz: 66b4e4cd47fc55a5678ee1f1b799c029a52959bcc374b04337819164235a3e24
4
+ data.tar.gz: 150202d4f1e2c0d526736da39dec4eaf142a62cc365581deb200d2ef66f4555f
5
5
  SHA512:
6
- metadata.gz: d02e8644b724765182cabb9a74eafcfaec94eb13781680e29cc33bfb1ee2bc850f55a7df4117b61d8a2889c7d48bf4792b88ba316676a1fb6be5632cc98d1a3f
7
- data.tar.gz: a4397b9a00729a4eb230415e265c834f85f42b8b6ded242dc3c981ad6f3e543f367d7a9df00ed6d516aef12c2e2266a765b0f66c7025f575975c83fd4b556b86
6
+ metadata.gz: 649aeccb314f2e39cbd63b879c20a1006598dcb24dab76787c4906d5ce0c38aa86d3ca04e77dc57f7d04229f75132ee5cafbe71a3f5722328b204eacbaf4e550
7
+ data.tar.gz: 235f6ba63f7a59125698dab591ff4800e7b8abd0d6ddf49f519fdbf1a1ca449fe6d9319a76f7f01c92e9507ebae662cb99a6ff00cf57c0825c41fee3b8d935ea
data/CHANGELOG.md CHANGED
@@ -8,6 +8,25 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.68.0] - 2026-10-14
12
+
13
+ ### Changed
14
+
15
+ - Web: the settings popovers' selects (thinking level, LLM context strategy and apply) look the same in Safari, Chrome
16
+ and Firefox: chi's own box and caret instead of each browser's native control.
17
+ - Web: the thinking chip comes before the LLM context chip, on the session bar (think, reasoning, llm ctx, tools) and
18
+ beside the start page's model picker.
19
+
20
+ ### Fixed
21
+
22
+ - `/model` while the web or another terminal read `/stats` no longer leaves the new model on the old model's prompt
23
+ profile (its turns formatted for the wrong family until the next `/model`).
24
+ - Web: the sessions strip no longer blinks when a session updates: a card fades in only when it is new to the list,
25
+ and a running or waiting card's dot keeps its pulse through the redraw. All sessions no longer re-fades on every
26
+ update or search keystroke either.
27
+
28
+ Update with `chi update` (the system bundle 0.68.0), then restart `chi web`.
29
+
11
30
  ## [0.67.0] - 2026-10-14
12
31
 
13
32
  ### Added
@@ -2937,7 +2956,8 @@ and long-lived sessions.
2937
2956
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2938
2957
  hotkey panel that send selected text or the clipboard to your sessions.
2939
2958
 
2940
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.67.0...HEAD
2959
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.68.0...HEAD
2960
+ [0.68.0]: https://github.com/dm1try/samagotchi/compare/v0.67.0...v0.68.0
2941
2961
  [0.67.0]: https://github.com/dm1try/samagotchi/compare/v0.66.0...v0.67.0
2942
2962
  [0.66.0]: https://github.com/dm1try/samagotchi/compare/v0.65.0...v0.66.0
2943
2963
  [0.65.0]: https://github.com/dm1try/samagotchi/compare/v0.64.0...v0.65.0
data/docs/architecture.md CHANGED
@@ -400,7 +400,9 @@ by one pure resolver, `ModelRef.parse` (host prefix, alias), given the configure
400
400
  hosts and aliases; `HostRegistry` routes a name that names no host (its model
401
401
  index after `/models`) and builds the `ModelTarget`.
402
402
 
403
- Engine picks the loop from the effective model's host (`HostRegistry#resolve`):
403
+ Engine picks the loop from the effective model's host: `EffectiveModel.of` resolves it (`HostRegistry#resolve`)
404
+ on every read, never kept, since config.yml and a model listing can move it without a `/model`
405
+ ([internals/engine.md](internals/engine.md), "EffectiveModel"):
404
406
 
405
407
  | Loop | Class | Host | Talks through |
406
408
  |---|---|---|---|
@@ -496,7 +498,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
496
498
 
497
499
  | Component | Main file(s) | What it is |
498
500
  |---|---|---|
499
- | Worker | `worker.rb` (`worker_idle_exit.rb`, `worker_inbound.rb`, `worker_signals.rb`) | A session's background process: builds the `Engine` and `Bridge`, runs queued turns, exits when idle. A spawned one traps TERM, HUP and INT (`WorkerSignals`, installed by `run_session_loop` when `SAMAGOTCHI_WORKER_PROCESS=1`): the running turn stops as a Stop with reason `signal`, is saved, and the worker leaves (`:signal`, exit 0; a new worker only for input already queued). A running turn is checkpointed at its iteration boundaries (`TurnCheckpoint`, the `interrupted_turn` mark), and the next worker settles a turn its predecessor died in (`TurnCheckpoint.settle`). |
501
+ | Worker | `worker.rb` (`worker_idle_exit.rb`, `worker_inbound.rb`, `worker_signals.rb`) | A session's background process: builds the `Engine` and `Bridge`, runs queued turns, exits when idle. A spawned one traps TERM, HUP and INT (`WorkerSignals`, installed by `run_session_loop` when `SAMAGOTCHI_WORKER_PROCESS=1`): the running turn stops as a Stop with reason `signal`, is saved, and the worker leaves (`:signal`, exit 0; a new worker only for input already queued). A running turn is checkpointed at its iteration boundaries (`TurnCheckpoint`, the `interrupted_turn` mark), and the next worker settles a turn its predecessor died in (`TurnCheckpoint.settle`). The session is saved from the turn's thread, Bridge threads and a signal's thread; `Session#save` takes a per-session lock around its snapshot and write, so an older snapshot never lands over a newer one. |
500
502
  | Config | `config.rb` | Every setting as an `Entry`: env, config.yml and CLI layers ([configuration.md](configuration.md)). |
501
503
  | MemoryBundle | `memory_bundle.rb`, `memory_bundle/{installer,builder,installed_bundle,plugin_ref,bundle_hook}.rb` (`Installer`, `Builder`, `InstalledBundle`, `PluginRef`, `BundleHook`) | Installing, building and reading bundles of memories, hooks and plugins. |
502
504
  | Plugin API | `plugin/api.rb` (`Plugin::Api`), `plugin/tool_spec.rb` (`Plugin::ToolSpec`: a checked tool declaration, how it is registered), `plugin/loader.rb` | What a bundle's plugin registers: tools, commands, annotation actions, hooks, services, toolkits, peer sources ([plugins.md](plugins.md)). |
@@ -515,7 +517,8 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
515
517
  | Toolkits | `toolkit.rb` (`Toolkit`), `toolkit_binding.rb` (`ToolkitBinding`, the Engine's seam), `toolkit_command.rb` (`/toolkit`), `tools/registry.rb` (`Registry#toolkit`, `#left_out_names`), `web/public/toolkit_chip.js` | Which registered tools a session's prompts declare: saved in the session, resolved at a turn's start (`Engine#apply_toolkit!`), named in the prompt's not-loaded line; `chi.toolkit` and `ctx.use_toolkit` for plugins ([configuration.md](configuration.md#toolkits)). |
516
518
  | Steer delivery | `steer.rb` (`Steer`), `delivery.rb` (`Delivery`), `steer_cut.rb` (`SteerCut`), `generation_phase.rb`, `waiting_steer.rb`, `steer_cut_after.rb` (`SteerCutAfter`), `cut_policy.rb` (`CutPolicy`) | A message for a running turn: at the next boundary, cutting a thinking-only generation, or queued for the turn's end ([internals/engine.md](internals/engine.md)). |
517
519
  | Prompt files | `prompt_files.rb` (`PromptFiles`) | The AGENTS.md chain (root → cwd) and the appended files the system prompt carries. |
518
- | Model settings | `model_profile.rb` (`ModelProfile`), `model_settings.rb`, `sampling_settings.rb`, `model_catalog.rb` (`ModelCatalog`), `model_notes.rb` (`ModelNotes`) | A model's prompt format, its `models:` entry, its request parameters, the pickable model rows (`chi models`, `/api/models`), the notes its prompt loads. |
520
+ | Model settings | `effective_model.rb` (`EffectiveModel`, `ModelChoice`), `model_profile.rb` (`ModelProfile`), `model_settings.rb`, `sampling_settings.rb`, `model_catalog.rb` (`ModelCatalog`), `model_notes.rb` (`ModelNotes`) | The effective model's settings as one snapshot per read, a model's prompt format, its `models:` entry, its request parameters, the pickable model rows (`chi models`, `/api/models`), the notes its prompt loads. |
521
+ | Session settings | `session_setting.rb` (`SessionSetting`), `level_setting_command.rb` (`/thinking`, `/reasoning-history`), `llm_context_command.rb`, `toolkit_command.rb`, `thinking_notices.rb` (`ThinkingNotices`) | A session's own llm_context, thinking, reasoning_history and toolkit: their chips (session state, `/stats`, `:command_ran`, the web), the commands that set them, the thinking level's notices. |
519
522
  | Dialects | `llm/dialect.rb` (`LLM::Dialect`) | What an `api: anthropic` provider's `/v1/messages` does differently (`dialect: fireworks`) ([internals/anthropic.md](internals/anthropic.md)). |
520
523
  | Logging | `log.rb` (`Log`), `log_subscriber.rb` (`LogSubscriber`), `log_path.rb` (`LogPath`), `log_line.rb` | The debug log: tagged records, a session's event trail, where the file goes ([configuration.md](configuration.md#debug-log-file)). |
521
524
  | Stores | `reminder_store.rb` (`ReminderStore`), `recap_store.rb` (`RecapStore`) | A session's reminders, and its idle recap (`recap.json`). |
@@ -34,7 +34,8 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
34
34
  │ # resumed: true (only on a step-limit Continue: resumed: given),
35
35
  │ # images (only when there are any), cut_after (the model's cut wait, s)}
36
36
  │ # refresh_profile! (may ask the server's /props once)
37
- │ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name is set in generate)
37
+ │ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name is set in generate),
38
+ │ # from one EffectiveModel; ThinkingNotices#turn_started keeps the level and says one the host can't do
38
39
  │ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools,
39
40
  │ # then the session's toolkit (apply_toolkit!: Registry#toolkit, a prompt rebuild when it changed)
40
41
  │ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
@@ -43,11 +44,12 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
43
44
  │ # Returns false when a Stop came during the probes or the init wait, else true
44
45
  │
45
46
  ├── generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
46
- │ # sync_kernel_client!, then @kernel.turn_settings = turn.settings with model_name, window_setting,
47
- │ # llm_context (turn_llm_context: the session's, the model's/host's, else llm_context.strategy)
48
- │ # and price (turn_price: hosts.<name>.models.<id>.price), all read per turn
47
+ │ # one EffectiveModel (after the hooks, which may switch the model): sync_kernel_client!, then
48
+ │ # @kernel.turn_settings = turn.settings with model_name, window_setting, llm_context (the
49
+ │ # session's, the model's/host's, else llm_context.strategy) and price
50
+ │ # (hosts.<name>.models.<id>.price), all read per turn
49
51
  │ # turn.limit = max_iterations or IterationLimit.for
50
- │ # backend.complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input))
52
+ │ # backend(model).complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input))
51
53
  │ # Returns LLM::ModelResult (text, conversation, canceled?, exhausted?, tool_activity,
52
54
  │ # context_status, empty_steps, empty_retries)
53
55
  │
@@ -118,6 +120,16 @@ A worker's TERM, HUP or INT (WorkerSignals, trapped by a spawned worker only):
118
120
  loop left brings only that hard exit, and run_session_loop restores the default handlers
119
121
  → run_session_loop: exit 0, no discard; input queued behind the stopped turn wakes a new worker
120
122
 
123
+ The effective model doesn't resolve (a broken host config, HostRegistry#resolve raising):
124
+ → the rescued readers give their fallback (Engine#model_setting) and log effective_model_unresolved;
125
+ a setting that fails on a resolved model gives its fallback and logs <setting>_unresolved
126
+ (sampling, thinking, reasoning_history, steer_cut_after, llm_context, window, price, served,
127
+ vision_adapter); served_probe_failed when asking the server for its served model fails;
128
+ alias_note_failed, thinking_notice_failed likewise
129
+ → WARN once per process for an (event, model, error class), DEBUG after (EffectiveModel::Unresolved)
130
+ → session_state_snapshot never raises (its model fields are nil); a turn, stats_snapshot,
131
+ chat_model? and system_prompt raise, as before
132
+
121
133
  Stopped before the model was asked (prepare_turn returned false):
122
134
  → stopped_before_model(turn) → end_turn(turn, "canceled")
123
135
  → nothing kept, :turn_canceled {cancellation_reason, cancelled_by, duration_ms}
@@ -129,7 +141,7 @@ Stopped before the model was asked (prepare_turn returned false):
129
141
  The Engine does not talk to models directly. `Engine#backend` picks the loop the effective model's host speaks:
130
142
 
131
143
  ```
132
- Engine#backend → backend_for(@host_registry.resolve(@effective_model_name))
144
+ Engine#backend → backend_for(effective_model) # EffectiveModel.of(@host_registry, @model_choice)
133
145
  → host entry chat? (an api in LLM::Adapters::BY_API: openai, anthropic): LLM::ChatLoop (one per Engine, built on first use)
134
146
  → else: LLM::NativeBackend wrapping the Engine's KernelLoop
135
147
  ```
@@ -326,6 +338,38 @@ TurnState
326
338
 
327
339
  The lock is a leaf: nothing is called out while it is held.
328
340
 
341
+ ### EffectiveModel (a snapshot per read)
342
+
343
+ `EffectiveModel.of(registry, choice)` resolves the model once (`HostRegistry#resolve`: host, client, the id
344
+ sent) with its lookup names, and reads each setting from them (sampling, thinking, reasoning history, steer cut,
345
+ LLM context, window, price, served model, vision, profile). It is built per read and never kept: config.yml is
346
+ read live, a model listing can move a bare id to another host, and another process's saved model lists change
347
+ what a resolve gives, all without a `/model`. Frozen, so any thread may hold one.
348
+
349
+ - The Engine's readers take one (`Engine#effective_model`); those whose fallback always covered the resolve go
350
+ through `#model_setting`. A turn takes one in `turn_settings` (before the hooks) and one in `generate` (after
351
+ them); `system_prompt`, `steer_cut_after` (at `:turn_started`) and the turn-end warm-up take their own;
352
+ `stats_snapshot` and `session_state_snapshot` take one each (the latter nil-tolerant).
353
+ - Never resolved (they hold on a broken resolve): `effective_model_ref`, `bare_model_name`,
354
+ `guardrail_model_name`, `sync_model_key!`, `served_model(probe: false)` (`HostRegistry#model_ref`/`#bare_name`).
355
+ - Readers on other threads: the Bridge (`/state`, `/stats`, the SSE reset, a steer's cut wait), the worker's
356
+ command thread, the recap thread and plugin threads (`session_model_recap_target`), the REPL.
357
+ - `ModelChoice` (`@model_choice`) holds the name and the typed name as one value, so a reader on another thread
358
+ never pairs one model's name with another's typed name.
359
+ - `EffectiveModel.of_session` builds a saved session's (no worker): `Web::App`, `LLMContextStrategy.for_session`.
360
+
361
+ `SessionSetting` names the four per-session settings (llm_context, thinking, reasoning_history, toolkit): the
362
+ session's own value, the commands that move each chip, its chip from an EffectiveModel or from the Engine's
363
+ readers, a saved session's chip, and how the web takes a worker snapshot's. The session state, `/stats` (no
364
+ toolkit), a `:command_ran` and the web's session JSON list them from it.
365
+
366
+ ### ThinkingNotices
367
+
368
+ What a turn's thinking level leads to, said once per session and host (`thinking_notices.rb`): an effort the
369
+ host can't do, off the model didn't honour, a host refusing the thinking fields or the reasoning sent back. It
370
+ keeps the turn's `[level, ModelTarget]` and whether the generation thought; `turn_settings` and the stream handler
371
+ call it. Turn thread only.
372
+
329
373
  ### Turn (Engine::Turn)
330
374
 
331
375
  The turn as the Engine sees it (the KernelLoop has its own `Turn` struct):
@@ -435,7 +479,7 @@ hook registry (`@hooks.fire`), not on the sink or the observers. The other hook
435
479
  ```
436
480
  Engine initialization (in this order, among other things):
437
481
  @turn_state, @steer_cut (SteerCut: its GenerationPhase and WaitingSteer)
438
- @host_registry, @effective_model_name; @client = @host_registry.resolve(...).client
482
+ @host_registry, @model_choice (ModelChoice: the name and the name as typed); @client = effective_model.client
439
483
  @given_profile = profile or nil (resolved on first need, so building an Engine makes no network call)
440
484
  @hook_routes (HookRoutes: notices, hook questions, stop_turn/stop_generation, steer attribution)
441
485
  @guardrail_wiring, @question_desk
@@ -453,8 +497,9 @@ Engine initialization (in this order, among other things):
453
497
  @session_observer with the metrics, the log subscriber and the thinking tails subscribed (subscribe_session_observers)
454
498
 
455
499
  Model switching, Engine#switch_model!(model_name, persist_default:, typed:):
456
- → checks the host (ModelProfile.check_host!), sets @effective_model_name
457
- → drops the given profile and the profile resolution (resolved again on first need)
500
+ → checks the host (ModelProfile.check_host!), sets @model_choice (name and typed name in one assignment)
501
+ → drops the given profile and the profile resolution (resolved again on first need; one that another
502
+ thread finishes for the old model is neither kept nor applied: #store_profile)
458
503
  → sync_model_key!, @prompt_builder.reset! (the system prompt is built again)
459
504
  → sync_kernel_client! (the kernel talks to the new host), the context window cache dropped
460
505
  → the kernel is never rebuilt; the next turn's backend follows the new host's api
@@ -608,6 +653,10 @@ Paths are under `lib/samagotchi/`.
608
653
  | `turn_state.rb` | Cross-thread turn state (running, controller, sink, steers, activity clock) |
609
654
  | `steer_cut.rb` | `SteerCut`: the Engine's seam for a message cutting the generation |
610
655
  | `generation_phase.rb`, `waiting_steer.rb` | Whether a steer may cut the running generation, and the steer that waits to |
656
+ | `effective_model.rb` | `EffectiveModel` (the effective model's settings, a snapshot per read; `.of_session`), `ModelChoice`, the fallbacks' log lines (`Unresolved`) |
657
+ | `session_setting.rb` | `SessionSetting`: the per-session settings' descriptors (own value, chips, the web's fallback) |
658
+ | `level_setting_command.rb`, `thinking_command.rb`, `reasoning_history_command.rb` | `/thinking` and `/reasoning-history` (`LevelSettingCommand` and its two Specs) |
659
+ | `thinking_notices.rb` | `ThinkingNotices`: the notices a turn's thinking level leads to |
611
660
  | `steer_cut_after.rb`, `cut_policy.rb` | The model's cut wait (`SteerCutAfter.resolve`); the loops' cut path (`CutPolicy`) |
612
661
  | `steer.rb` | Queued input and steers at iteration boundaries |
613
662
  | `kernel_loop.rb` | Native loop: `#run` (format, generate, parse, dispatch, iterate) |
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), `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` (`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), `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`), `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`).
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:**
data/docs/testing.md CHANGED
@@ -17,7 +17,7 @@ clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (We
17
17
 
18
18
  The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_view.js`,
19
19
  `turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
20
- `question_cards.js`, `stream_handlers.js`, the chips, `info_bar.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
20
+ `question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
21
21
  `chains.js`, `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
22
22
  never shipped; lit reads `document` as it loads). The shim's `markupOf(template)`,
23
23
  `htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments; the DOM writes text with only `&`, `<`, `>`
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.67.0
3
+ version: 0.68.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:
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "host_registry"
4
+ require_relative "log"
5
+ require_relative "context_window"
6
+ require_relative "llm_context_strategy"
7
+ require_relative "model_profile"
8
+ require_relative "reasoning_history"
9
+ require_relative "sampling_settings"
10
+ require_relative "served_model"
11
+ require_relative "steer_cut"
12
+ require_relative "steer_cut_after"
13
+ require_relative "thinking"
14
+ require_relative "vision_support"
15
+
16
+ module Samagotchi
17
+ # The model a session runs on, as set: the name (maybe an alias;
18
+ # HostRegistry#resolve applies it) and the name as typed at start or at
19
+ # the last /model. One value, swapped in one assignment, so a reader on
20
+ # another thread never pairs one model's name with another's typed name.
21
+ ModelChoice = Data.define(:name, :typed)
22
+
23
+ # The effective model resolved once (HostRegistry#resolve: its host and
24
+ # client, the id sent) with the names its models: entry may be under
25
+ # (HostRegistry#lookup_names), and its settings read from them. An
26
+ # immutable snapshot built per read, never kept across reads: config.yml,
27
+ # a model listing (which may move a bare id to another host) and another
28
+ # process's saved lists all change what a resolve gives without any
29
+ # /model. Frozen, so any thread may hold one.
30
+ #
31
+ # Each setting reader rescues its own failure and gives the fallback the
32
+ # Engine has always given (Unresolved); building one raises as
33
+ # HostRegistry#resolve does (the Engine's #model_setting gives the
34
+ # fallback then).
35
+ EffectiveModel = Data.define(:registry, :name, :typed, :target, :names) do
36
+ # @param registry [HostRegistry]
37
+ # @param choice [ModelChoice]
38
+ # @raise what HostRegistry#resolve raises
39
+ def self.of(registry, choice)
40
+ target = registry.resolve(choice.name)
41
+ new(registry: registry, name: choice.name, typed: choice.typed, target: target,
42
+ names: registry.lookup_names(choice.typed, resolved: choice.name, target: target))
43
+ end
44
+
45
+ # A saved session's model (no worker): its model name, the default when
46
+ # blank, and the name it was typed as.
47
+ # @param session [Session]
48
+ def self.of_session(session, registry:)
49
+ name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
50
+ of(registry, ModelChoice.new(name: name, typed: session.model_typed || name))
51
+ end
52
+
53
+ def entry = target.entry
54
+ def client = target.client
55
+ def bare = target.bare_model
56
+ def host_name = entry.name
57
+
58
+ # A chat host (api: openai or anthropic), whose loop uses no prompt profile.
59
+ def chat? = entry.chat?
60
+
61
+ # The resolved ref a session stores (ModelRef#ref): no resolve.
62
+ def ref = registry.model_ref(name).ref
63
+
64
+ # The configured request parameters (hosts: and models: sampling).
65
+ def sampling
66
+ SamplingSettings.for(target, names: names)
67
+ rescue StandardError => e
68
+ unresolved(:sampling_unresolved, e, SamplingSettings::EMPTY)
69
+ end
70
+
71
+ # "temperature=0.6 (hosts.work)", nil when none.
72
+ def sampling_summary
73
+ SamplingSettings.summary(target, names: names)
74
+ rescue StandardError => e
75
+ unresolved(:sampling_unresolved, e, nil)
76
+ end
77
+
78
+ # The thinking level (Thinking.resolve), the session's +own+ first.
79
+ def thinking(own:)
80
+ Thinking.resolve(target, names: names, session: own).first
81
+ rescue StandardError => e
82
+ unresolved(:thinking_unresolved, e, Thinking::DEFAULT)
83
+ end
84
+
85
+ # The thinking level with where it came from (SettingExplained), nil
86
+ # when it can't be resolved.
87
+ def thinking_explained(own:)
88
+ Thinking.explain(target, names: names, session: own)
89
+ rescue StandardError => e
90
+ unresolved(:thinking_unresolved, e, nil)
91
+ end
92
+
93
+ # What a thinking level change costs the prompt cache
94
+ # (Thinking.cache_cost); the block gives the prompt profile, asked only
95
+ # on a native host. Raises.
96
+ # @return [Symbol]
97
+ def thinking_cache_cost(&)
98
+ Thinking.cache_cost(target, profile: entry&.chat? ? nil : yield&.name)
99
+ end
100
+
101
+ # The reasoning_history level (ReasoningHistory.resolve), the session's
102
+ # +own+ first.
103
+ def reasoning_history(own:)
104
+ ReasoningHistory.resolve(target, names: names, session: own).first
105
+ rescue StandardError => e
106
+ unresolved(:reasoning_history_unresolved, e, ReasoningHistory::DEFAULT)
107
+ end
108
+
109
+ # The reasoning_history level with where it came from (SettingExplained);
110
+ # nil on a native host unless +native+ (its loop ignores the level), or
111
+ # when it can't be resolved.
112
+ def reasoning_history_explained(own:, native: false)
113
+ return nil unless native || entry&.chat?
114
+
115
+ ReasoningHistory.explain(target, names: names, session: own)
116
+ rescue StandardError => e
117
+ unresolved(:reasoning_history_unresolved, e, nil)
118
+ end
119
+
120
+ # "drop (models: qwen)"; nil for the default, on a native host, or when
121
+ # it can't be resolved.
122
+ def reasoning_history_summary(own:)
123
+ return nil unless entry&.chat?
124
+
125
+ level, source = ReasoningHistory.resolve(target, names: names, session: own)
126
+ source ? ReasoningHistory.label(level, source) : nil
127
+ rescue StandardError => e
128
+ unresolved(:reasoning_history_unresolved, e, nil)
129
+ end
130
+
131
+ # What a reasoning_history change from +from+ to +to+ costs the prompt
132
+ # cache, given +conversation+ (ReasoningHistory.cache_cost).
133
+ def reasoning_history_cache_cost(from, to, conversation:)
134
+ ReasoningHistory.cache_cost(from, to, target: target, conversation: conversation)
135
+ rescue StandardError => e
136
+ unresolved(:reasoning_history_unresolved, e, nil)
137
+ end
138
+
139
+ # [seconds, source] a cut waits for thinking (SteerCutAfter.resolve);
140
+ # the global and its name when it can't be resolved.
141
+ def steer_cut_after_explained
142
+ SteerCutAfter.resolve(target, names: names)
143
+ rescue StandardError => e
144
+ unresolved(:steer_cut_after_unresolved, e, [SteerCut.cut_after, "steer.cut_after"])
145
+ end
146
+
147
+ # The LLM context strategy with where each value came from
148
+ # (LLMContextStrategy.explain) under the session's +args+
149
+ # (LLMContextOverride#resolve_args), nil when it can't be resolved.
150
+ def llm_context_explained(args)
151
+ LLMContextStrategy.explain(target, names: names, **args)
152
+ rescue StandardError => e
153
+ unresolved(:llm_context_unresolved, e, nil)
154
+ end
155
+
156
+ # The LLM context layers its turns run under; [] when they can't be
157
+ # resolved.
158
+ def llm_context_layers(args)
159
+ LLMContextStrategy.resolve(target, names: names, **args).active_layers
160
+ rescue StandardError => e
161
+ unresolved(:llm_context_unresolved, e, [])
162
+ end
163
+
164
+ # models:/hosts: window_tokens (ContextWindow.setting), nil when unset.
165
+ def window_setting
166
+ ContextWindow.setting(target, names: names)
167
+ rescue StandardError => e
168
+ unresolved(:window_unresolved, e, nil)
169
+ end
170
+
171
+ # The window as its loop would see it (ChatLoop#context_window: a remote
172
+ # chat host has no /props, only its model list); nil when it can't be
173
+ # resolved.
174
+ def context_window
175
+ client = self.client
176
+ adapter = nil
177
+ if entry.chat?
178
+ adapter = registry.adapter_for(entry)
179
+ client = nil if adapter.remote?
180
+ end
181
+ ContextWindow.resolve(client: client, model: bare, adapter: adapter, setting: ContextWindow.setting(target, names: names))
182
+ rescue StandardError => e
183
+ unresolved(:window_unresolved, e, nil)
184
+ end
185
+
186
+ # Its price on its host (hosts.<name>.models.<id>.price), by the id
187
+ # asked for, never the one served; nil when none.
188
+ def price
189
+ entry.models&.dig(bare.to_s.strip.downcase)&.price
190
+ rescue StandardError => e
191
+ unresolved(:price_unresolved, e, nil)
192
+ end
193
+
194
+ # [served, +asked+] from llama.cpp's /props (one short cached probe),
195
+ # [nil, nil] for a remote chat host, without an answer, or when it
196
+ # can't be asked.
197
+ def served_from_props(asked)
198
+ if entry.chat? && registry.adapter_for(entry).remote?
199
+ return [nil, nil]
200
+ end
201
+
202
+ served = ServedModel.from_props(client.server_props(model: bare))
203
+ served ? [served, asked] : [nil, nil]
204
+ rescue StandardError => e
205
+ # The model resolved; asking its server failed (its /props, its adapter).
206
+ unresolved(:served_probe_failed, e, [nil, nil])
207
+ end
208
+
209
+ # The host whose hosts.<name>.models.<id>.served names +served+
210
+ # (ServedModel.expected_by), nil when none does.
211
+ def served_expected_by(served)
212
+ ServedModel.expected_by(target, served)
213
+ rescue StandardError => e
214
+ unresolved(:served_unresolved, e, nil)
215
+ end
216
+
217
+ # What a local native host's cached /props names when it isn't the
218
+ # model asked for; nil for a chat or remote host, or before any probe.
219
+ def prompt_served
220
+ return nil if entry.nil? || entry.chat? || entry.remote? || !client.respond_to?(:cached_server_props)
221
+
222
+ served = ServedModel.from_props(client.cached_server_props(model: bare))
223
+ served if ServedModel.differs?(bare, served)
224
+ rescue StandardError => e
225
+ unresolved(:served_unresolved, e, nil)
226
+ end
227
+
228
+ # Whether it sees images (VisionSupport.for) under +profile+.
229
+ def vision_support(profile)
230
+ VisionSupport.for(target, profile: profile, adapter: vision_adapter, names: names)
231
+ end
232
+
233
+ # A chat host's adapter (what it says of images), nil on a native host.
234
+ def vision_adapter
235
+ entry.chat? ? registry.adapter_for(entry) : nil
236
+ rescue StandardError => e
237
+ unresolved(:vision_adapter_unresolved, e, nil)
238
+ end
239
+
240
+ # Which prompt profile it gets (ModelProfile.resolve: may probe /props).
241
+ # Not cached here. Raises.
242
+ # @return [ModelProfile::Resolution]
243
+ def profile_resolution
244
+ ModelProfile.resolve(names: names, entry: entry, client: client, bare_model: bare)
245
+ end
246
+
247
+ # See Unresolved.fallback.
248
+ def unresolved(event, error, value) = EffectiveModel::Unresolved.fallback(event, self, error, value)
249
+ private :unresolved
250
+
251
+ # The name, typed name and host only: the default would print the
252
+ # registry and the client into any error message.
253
+ def inspect = "#<Samagotchi::EffectiveModel #{name.inspect} typed=#{typed.inspect} host=#{entry&.name.inspect}>"
254
+ alias_method :to_s, :inspect
255
+ end
256
+
257
+ class EffectiveModel
258
+ # The fallback an effective model's reader gives when its setting
259
+ # can't be resolved, with a log line: WARN the first time per process
260
+ # for an (event, model, error class), DEBUG after. The session state is
261
+ # read on every Bridge /state and SSE reset, so a broken host config
262
+ # would otherwise WARN per poll.
263
+ module Unresolved
264
+ @seen = Set.new
265
+ @mutex = Mutex.new
266
+
267
+ # @param event [Symbol] the log event (<setting>_unresolved)
268
+ # @param model [EffectiveModel, String] the model (or its name)
269
+ # @param error [StandardError]
270
+ # @return +value+
271
+ def self.fallback(event, model, error, value)
272
+ name = model.respond_to?(:name) ? model.name : model.to_s
273
+ first = @mutex.synchronize { @seen.add?([event, name, error.class.name]) }
274
+ fields = { model: name, error: error.class.name, msg: error.message.to_s[0, 200] }
275
+ first ? Log.warn(:model, event.to_s, **fields) : Log.debug(:model, event.to_s, **fields)
276
+ value
277
+ end
278
+
279
+ # Forget what was logged (specs).
280
+ def self.reset! = @mutex.synchronize { @seen.clear }
281
+ end
282
+ end
283
+ end