samagotchi 0.46.0 → 0.46.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fb03f917133e78236dfb0bf16eae6c21b00ec2fb3720501d6c1982aeb9dc220
4
- data.tar.gz: b3edff20b87e1f954c6079cd0315ab64559a5814f9a64e92e215fedc745a2778
3
+ metadata.gz: 9143ff1fecadcc026c2e0b079d61551359985d72d080f33fc7074af8f90402c1
4
+ data.tar.gz: 04d7e899417b9684ac61d337361ca91e8fa58dcdd091611109fcfff677a351ba
5
5
  SHA512:
6
- metadata.gz: 96b8d90741656cae236454b5f1b56f49e77ad1a511eb81cdc31594b311031b57363993091ae0df70cf78c6f346a2b6b867896b53e0093ae2e59671146cd12eb6
7
- data.tar.gz: 6041ee413db16da9147f763f83cfabcf91e0c62ea95380fa8ae454aab9e242b646501fafdd2ed5071008b4b218ad740c585517ee1e3cd0fb84531cb36a43d59d
6
+ metadata.gz: 272e94da604a43b0d3b6f405dc7b2bbe420e4c7adffc9fba6b8c5ecf52811b4411c4fce03e32e85e00ee60b1f30b395daf107a459e842e196803d08658bcfdf0
7
+ data.tar.gz: b3262e257e88b57a739e61c3774d5c2bbef910956fcd9fc6ef726275ddc4bbc10a59ee70a1a9236a50aada19e9663338e32077720bfc1d6b0db6fcbf7e059a43
data/CHANGELOG.md CHANGED
@@ -8,6 +8,46 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.46.1] - 2026-10-09
12
+
13
+ ### Changed
14
+
15
+ - The terminal and the web word a retry line the same: `↻ retrying (503) in 4.0s, 2/5`, why in parens (the HTTP
16
+ status, the error's short name such as `ECONNREFUSED`, or `stream dropped`), the wait with one decimal, and the
17
+ retry of all there will be. The terminal said `retrying (2/5 in 4.0s)`, the web `↻ retrying (503) in 4 s, 2/5`.
18
+
19
+ ### Fixed
20
+
21
+ - A question the model asks in a `chi -p --non-interactive` run no longer tells the model the user dismissed it:
22
+ it is told no one could answer (a non-interactive run). The question closes with reason `non_interactive`.
23
+ - The web's card for a question closed that way reads "No one could answer (non-interactive run)", not
24
+ "Cancelled (non_interactive)".
25
+ - A delegate's report read by a turn that ended canceled is no longer brought a second time when the Engine's
26
+ post-turn work then fails (a full disk, say).
27
+ - github-pr 0.3.3: a `path:line` into a vendored copy (`vendor/lib/foo.rb:21`, or `/abs/repo/vendor/lib/foo.rb:21`)
28
+ is no longer linked to the PR's `lib/foo.rb` in the web: an absolute path's part before the PR path must be a
29
+ worktree's root (a directory with `.git`), or no directory on this machine. An absolute path into a worktree, or
30
+ one up from a subdir (`../lib/foo.rb`), still is.
31
+ - A step whose stream kept dropping now fails saying how many requests it made ("after 3 attempts"), not
32
+ "after 1 attempts"; one attempt is said in the singular.
33
+ - A step that ends on another error after its stream dropped (a 503) counts the dropped requests in that
34
+ error's attempts too, and a step asked again without thinking fields (a host refused them) keeps its count of
35
+ stream drops: it no longer gets two more ask-agains of its own.
36
+ - A host that is down is named once in the models list's warning and in `/models` (`gw: connection refused`, not
37
+ `gw: gw: connection refused`).
38
+ - A host whose model listing kept failing on network errors is named once too, in the models list's warning, the
39
+ web's and `/models` (`gw: request failed after 3 attempts: …`, not `gw: gw request failed after …`).
40
+ - `/stats`' estimated cost names the host whose prices it used (`from hosts.gw.models prices`), not a literal
41
+ `hosts.<name>.models`.
42
+ - The config warning for a model price's old `cached` key suggests `cache_read`, its new name.
43
+ - The web's ⚠ for a model the server served instead of the one asked now shows on a session opened with no
44
+ worker running too (from its saved analytics), not only while its worker runs.
45
+ - A host whose `/v1/models` fails no longer drops the ids declared under `hosts.<name>.models` from `chi models`,
46
+ `/models` and the web model picker: they stay, marked `host down` (greyed in the picker, still pickable;
47
+ `unavailable: true` in the JSON), next to the host's warning. Ids only the host itself lists still drop out.
48
+
49
+ Update with `chi update`: it updates github-pr (0.3.3: no links from a vendored copy to the PR's file).
50
+
11
51
  ## [0.46.0] - 2026-10-09
12
52
 
13
53
  ### Added
@@ -2143,7 +2183,8 @@ and long-lived sessions.
2143
2183
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2144
2184
  hotkey panel that send selected text or the clipboard to your sessions.
2145
2185
 
2146
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.46.0...HEAD
2186
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.46.1...HEAD
2187
+ [0.46.1]: https://github.com/dm1try/samagotchi/compare/v0.46.0...v0.46.1
2147
2188
  [0.46.0]: https://github.com/dm1try/samagotchi/compare/v0.45.0...v0.46.0
2148
2189
  [0.45.0]: https://github.com/dm1try/samagotchi/compare/v0.44.0...v0.45.0
2149
2190
  [0.44.0]: https://github.com/dm1try/samagotchi/compare/v0.43.0...v0.44.0
data/docs/cli.md CHANGED
@@ -87,15 +87,18 @@ $ chi models qwen # only the names containing "qwen" (any case)
87
87
 
88
88
  - A host's ids declared under `hosts.<name>.models` (see
89
89
  [configuration](configuration.md#models-a-host-serves-but-doesnt-list)) come first under that host, listed or not,
90
- as plain names like any other.
90
+ as plain names like any other. They stay when the host fails, and its stderr warning says so
91
+ (`chi models: box: connection refused (its declared ids are still listed)`).
91
92
  - A default-host id is bare, except one with a `:` (`qwen3:8b`), which is written `<host>:qwen3:8b` so it can't
92
93
  read as a host name. An id that an alias of the same name hides is left out.
93
94
  - Each run lists anew (no cache); `--timeout S` caps the wait for the hosts (default 4 s). A host that fails or doesn't
94
95
  answer in time is noted on stderr (`chi models: box: no answer in 4 s`).
95
96
  - `--format json` prints one object: `default` (and `default_typed` when it was set as an alias), `default_host`,
96
- `models` (`name`, `host`, `id`, and `configured: true` on an id declared under `hosts.<name>.models`), `aliases` (`name`, `ref`, `host`) and `warnings`. The desktop helper's model
97
+ `models` (`name`, `host`, `id`, and `configured: true` on an id declared under `hosts.<name>.models`,
98
+ `unavailable: true` too when that host failed to list its models), `aliases` (`name`, `ref`, `host`) and `warnings`. The desktop helper's model
97
99
  chooser reads it.
98
- - Exit 0 when any host listed its models, 1 when none did (the default is still printed), 2 on a usage error.
100
+ - Exit 0 when any host listed its models, 1 when none did (the default and declared names still print), 2 on a
101
+ usage error.
99
102
 
100
103
  ### Updating
101
104
 
@@ -250,7 +253,8 @@ Notes:
250
253
  cancel note and exits 130.
251
254
  Nobody can answer there: an approval is denied, and a question the model
252
255
  asks (`ask_user_question`) is dismissed at once (one line on stderr), never
253
- read from stdin, so the model finishes its reply.
256
+ read from stdin. The model is told no one could answer (a non-interactive
257
+ run), not that you dismissed it, and finishes its reply.
254
258
 
255
259
  ### Scratch sessions
256
260
 
@@ -438,7 +442,7 @@ thinking cuts (generations a plugin such as loop-guard cut) and output cap hits
438
442
  (generations that ended at the provider's cap), context used and window (the used figure is counted against the smaller of the window and the session's LLM context budget, and says so: `context used: 16000 tokens (25.0% of the 64000 budget)`), the memory indexes the session's prompt holds ([Memory](memory.md#the-indexs-size)), the model notes it carried (`model notes: model_notes_deepseek (system, 612 chars)`, none without any; [Memory](memory.md#model-notes)), the prompt profile and the model the server says it ran, and the
439
443
  cost when the provider reports one (OpenRouter's `usage.cost`), and the cost
440
444
  estimated from a price under `hosts.<name>.models` when it reports none or 0,
441
- marked `~` (`cost: $0.42 reported + ~$0.12 from hosts.<name>.models prices (this session only)`;
445
+ marked `~` and naming the current model's host (`cost: $0.42 reported + ~$0.12 from hosts.gw.models prices (this session only)`;
442
446
  see "Prices" in [configuration](configuration.md#prices)). The speed line is the last generation's and the session's average
443
447
  (weighted by tokens): llama.cpp reports it exactly (`87 tok/s out, 1.9k tok/s
444
448
  prompt (last, server)`); for other servers it is estimated from the first
@@ -742,7 +746,7 @@ In interactive assist mode, you can switch the request model without restarting:
742
746
  - `/model`: show the effective model (and default when diverged: `runtime model: <effective> (default: <default>, profile=<name>, <source>)`, e.g. `profile=qwen36, server (chat_template)`). When the session's prompt carries model notes the line ends with `; notes: model_notes_deepseek (system, 612 chars)`; a switch's `runtime model set to …` names the new model's ([Memory](memory.md#model-notes)).
743
747
  - `/model clear` (or `default`/`none`/`off`): clear the session override, reverting to the configured default.
744
748
  - `/guardrails`: the guardrail rules (by source), what failed to load, and your stored approvals, numbered; `/guardrails revoke N` removes approval N (see [Guardrails](guardrails.md)).
745
- - `/models`: list model ids aggregated across all `hosts:` (an alias shows next to its id: a bare alias on every host listing the id, a `host:model` one only under its host; grouped `host (host:port):` with per-host `unreachable` warnings, e.g. an unset `api_key_env`; lists cached 60s, 10 minutes for a remote host; lazy — no startup prefill). Ids the host declares under `hosts.<name>.models` come first, as `rr/x (config)`, and don't count toward the cap. At most 20 ids per host, then `… and N more`; `/models <text>` lists every id containing `<text>` (any case), e.g. `/models qwen` on OpenRouter.
749
+ - `/models`: list model ids aggregated across all `hosts:` (an alias shows next to its id: a bare alias on every host listing the id, a `host:model` one only under its host; grouped `host (host:port):` with per-host `unreachable` warnings, e.g. an unset `api_key_env`; lists cached 60s, 10 minutes for a remote host; lazy — no startup prefill). Ids the host declares under `hosts.<name>.models` come first, as `rr/x (config)`, and don't count toward the cap; an unreachable host keeps them, under its warning, as `rr/x (config, host down)`. At most 20 ids per host, then `… and N more`; `/models <text>` lists every id containing `<text>` (any case), e.g. `/models qwen` on OpenRouter.
746
750
 
747
751
  Notes:
748
752
 
@@ -893,7 +897,9 @@ While a turn runs, one row above the prompt says what it is doing, the spinner f
893
897
  Qwen `TURN:` prefix and inline markdown are left out.
894
898
  - `| waiting for the first token… 5s` after 2 s with nothing streamed.
895
899
  - `| running execute…` while a tool runs (its `tool>` line prints when it ends).
896
- - `| retrying (1/3 in 0.5s): Errno::ECONNREFUSED` while a network error is retried.
900
+ - `| ↻ retrying (ECONNREFUSED) in 0.5s, 1/3` while a failed request is asked again: why (the HTTP status such as
901
+ `503`, the error such as `ECONNREFUSED`, or `stream dropped`), the wait, and the retry of all there will be.
902
+ The web's timing line words it the same.
897
903
  - `| mcp: starting servers…` while a plugin's slow setup (an init task) runs, between turns too;
898
904
  `mcp> ✓ …` prints when it is done.
899
905
 
@@ -774,7 +774,9 @@ and a plugin's steer (`ctx.steer`, `ctx.sessions.send`) never cuts.
774
774
 
775
775
  Assist-mode UX:
776
776
 
777
- - While waiting, retry notices are rendered in the existing thinking spinner area as a red `network error: retrying ...` status.
777
+ - While waiting, a retry shows in the activity row (the web's timing line words it the same):
778
+ `↻ retrying (503) in 4.0s, 2/5`: why (the HTTP status, the error's short name such as `ECONNREFUSED`, or
779
+ `stream dropped`), the wait, and the retry of all there will be.
778
780
  - If retry attempts are exhausted, the submitted prompt is restored into the input editor so you can edit and resubmit.
779
781
 
780
782
  ## Server errors
data/docs/desktop.md CHANGED
@@ -62,7 +62,8 @@ for an alias), your recent picks and every host's
62
62
  models and aliases (from [`chi models`](cli.md#listing-the-models)), at most nine at a time.
63
63
 
64
64
  - Type to search: every word must match the host or the id; names starting with what you typed rank first. A name
65
- no host lists is offered at the bottom as "not listed" and sent as typed (a host that is down, a model just added).
65
+ no host lists is offered at the bottom as "not listed" and sent as typed (a model just added, or one a down host
66
+ doesn't declare: a down host's `hosts.<name>.models` ids stay in the list).
66
67
  - ↑/↓ and ⏎, a click, or ⌘1…⌘9 pick; Esc (or ⌘M) closes the chooser only, a second Esc the panel. While an input
67
68
  method is composing, ⏎, the arrows and Esc are its own.
68
69
  - The row then shows the pick in the accent colour, and ⏎ sends `chi send --new --model <name>`, the name exactly as
data/docs/sessions.md CHANGED
@@ -65,7 +65,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
65
65
  **Ordering:**
66
66
 
67
67
  - `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.
68
- - 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). 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. 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)`); 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?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host; `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).
68
+ - 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). 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. 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).
69
69
  - 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).
70
70
  - 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), `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). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
71
71
  - 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).
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: github-pr
3
- version: 0.3.1
3
+ version: 0.3.3
4
4
  scope: system
5
5
  description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can offer the PR with a card first, or attach nothing
6
6
  trust_level: reviewed
@@ -11,7 +11,7 @@ needs:
11
11
  hint: brew install gh, then gh auth login
12
12
  plugin:
13
13
  file: plugin.rb
14
- sha256: sha256:eb6207a0aec4267eb15d28e4db2edb10d458c3ff574c2be56f225b24b7afb733
14
+ sha256: sha256:f68b53bfb552e35c9b151ee42cc10c07923481fc3d5496f023422e388df6719a
15
15
  scripts:
16
16
  pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
17
17
  context_providers:
@@ -616,21 +616,42 @@ class LineLinker
616
616
  end
617
617
 
618
618
  # The PR file +path+ names: the path (or a rename's old one) exactly,
619
- # after a leading `./`; else the longest PR path +path+ ends with on a
620
- # `/` boundary (an absolute path into a worktree); else the one PR path
621
- # that ends with +path+ (a bare `foo.rb`). nil: none, or several.
619
+ # after a leading `./`; else, for an absolute path (into a worktree) or
620
+ # one up from the cwd (`../lib/foo.rb` from a subdir), the longest PR
621
+ # path it ends with on a `/` boundary (see #worktree_suffix); else the
622
+ # one PR path that ends with +path+ (a bare `foo.rb`). nil: none, or
623
+ # several. A relative path that only ends with a PR path
624
+ # (`vendor/lib/foo.rb`) is another file.
622
625
  def resolve(files, path)
623
626
  path = path.sub(%r{\A(?:\./)+}, "")
624
627
  exact = files.find { |file| file.path == path } || files.find { |file| file.previous_path == path }
625
628
  return exact if exact
626
629
 
627
- longer = files.select { |file| path.end_with?("/#{file.path}") }.max_by { |file| file.path.length }
628
- return longer if longer
630
+ if path.start_with?("/", "~/", "../")
631
+ longer = files.select { |file| path.end_with?("/#{file.path}") }.sort_by { |file| -file.path.length }
632
+ return path.start_with?("../") ? longer.first : worktree_suffix(longer, path)
633
+ end
629
634
 
630
635
  shorter = files.select { |file| file.path.end_with?("/#{path}") }
631
636
  shorter.one? ? shorter.first : nil
632
637
  end
633
638
 
639
+ # The first of +files+ (longest path first) that the absolute +path+
640
+ # names inside a worktree: the part before the PR path is a directory
641
+ # with a `.git` (/abs/repo/vendor/lib/foo.rb is a vendored copy, not the
642
+ # PR's lib/foo.rb), or no directory here at all (a path from elsewhere;
643
+ # nothing to tell by). nil: each part before is a directory, none a
644
+ # worktree's root.
645
+ def worktree_suffix(files, path)
646
+ full = File.expand_path(path)
647
+ files.find do |file|
648
+ root = full.delete_suffix("/#{file.path}")
649
+ !File.directory?(root) || File.exist?(File.join(root, ".git"))
650
+ end
651
+ rescue ArgumentError
652
+ files.first
653
+ end
654
+
634
655
  # The PR's Files changed anchor when the lines are inside one hunk (the
635
656
  # new side; a removed file's old side), else the file at the PR's head
636
657
  # (a removed one: at its base), which has every line.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.46.0
3
+ version: 0.46.1
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:
@@ -636,11 +636,15 @@ module Samagotchi
636
636
  end
637
637
  end
638
638
 
639
+ # A price key's old name: the warning suggests the new one.
640
+ RENAMED_PRICE_KEYS = { "cached" => "cache_read" }.freeze
641
+
639
642
  def price_key_problems(prefix, price)
640
643
  return [] unless price.is_a?(Hash)
641
644
 
642
645
  (price.keys.map(&:to_s) - ModelPrice::KEYS).map do |k|
643
- unknown_key_message("#{prefix}#{k}", k, ModelPrice::KEYS, prefix: prefix)
646
+ # A renamed key is compared by its new name, so that name is the hint.
647
+ unknown_key_message("#{prefix}#{k}", RENAMED_PRICE_KEYS.fetch(k, k), ModelPrice::KEYS, prefix: prefix)
644
648
  end
645
649
  end
646
650
 
@@ -945,6 +945,8 @@ module Samagotchi
945
945
  # context window, the served model and (on a native host) the prompt
946
946
  # profile come from the effective model's server, so this may make one
947
947
  # short /props GET; the cheap #session_state_snapshot never does.
948
+ # price_host: the effective model's host, whose hosts.<name>.models
949
+ # prices the cost estimate names.
948
950
  # @return [Hash] @metrics.snapshot, filled in
949
951
  def stats_snapshot
950
952
  snapshot = @metrics.snapshot
@@ -952,7 +954,7 @@ module Samagotchi
952
954
  served, served_for = served_model_for(snapshot, target: target)
953
955
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
954
956
  served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
955
- prompt_notes: prompt_notes.map(&:to_h))
957
+ prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
956
958
  unless snapshot.dig(:context, :window_tokens)
957
959
  window = current_context_window(target)
958
960
  if window
@@ -1051,8 +1053,7 @@ module Samagotchi
1051
1053
  # warn about. nil when none does.
1052
1054
  # @return [String, nil]
1053
1055
  def served_expected_by(served, target: nil)
1054
- target ||= @host_registry.resolve(@effective_model_name)
1055
- target.entry.name if target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.serves?(served)
1056
+ ServedModel.expected_by(target || @host_registry.resolve(@effective_model_name), served)
1056
1057
  rescue StandardError
1057
1058
  nil
1058
1059
  end
@@ -1447,8 +1448,6 @@ module Samagotchi
1447
1448
  @question_desk.request(payload)
1448
1449
  end
1449
1450
 
1450
- QUESTION_DISMISSED_NOTE = QuestionDesk::DISMISSED_NOTE
1451
-
1452
1451
  # Open a question for the UIs and wait for its answer
1453
1452
  # (QuestionDesk#open_question: BLOCKS until answered or cancelled).
1454
1453
  # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
@@ -78,18 +78,22 @@ module Samagotchi
78
78
  # One host's rows as the listings show them, declared ids first, then
79
79
  # the listed ones in the host's order (see Row). An id both declared and
80
80
  # listed is one row. Pure: +results+ (list_all_models') is only read.
81
- # A host that errored has no rows: its declared ids aren't shown
82
- # either (the listings say it's unreachable).
81
+ # A host that errored keeps only its declared ids, marked unavailable
82
+ # (still pickable; the listings warn it's unreachable); [] when it
83
+ # declares none.
83
84
  # @param results [Hash{String => Hash}] host name => {models: [ModelInfo], error:}
84
85
  # @param entries [Hash{String => HostRegistry::HostEntry}]
85
- # @return [Hash{String => Array<Row>}] for the hosts that answered
86
+ # @return [Hash{String => Array<Row>}] for every host in +results+
86
87
  def self.rows(results, entries)
87
88
  results.each_with_object({}) do |(name, data), out|
88
- next if data[:error]
89
+ declared = entries[name]&.models || {}
90
+ if data[:error]
91
+ out[name] = declared.values.map { |m| HostModel::Row.new(id: m.id, configured: true, info: nil, unavailable: true) }
92
+ next
93
+ end
89
94
 
90
95
  infos = Array(data[:models])
91
96
  by_id = infos.group_by { |info| info.id.to_s.downcase }
92
- declared = entries[name]&.models || {}
93
97
  out[name] = declared.values.map do |m|
94
98
  info = by_id[m.id.downcase]&.first
95
99
  # A listed id keeps the host's spelling: a case-sensitive server takes only that.
@@ -111,6 +115,9 @@ module Samagotchi
111
115
  # A listing row (HostModel.rows): id as the host lists it, else as
112
116
  # declared;
113
117
  # configured: declared under hosts.<name>.models; info: the host's
114
- # ModelInfo for it, nil when only declared.
115
- HostModel::Row = Data.define(:id, :configured, :info)
118
+ # ModelInfo for it, nil when only declared; unavailable: the host failed
119
+ # to list its models; only its declared ids are shown.
120
+ HostModel::Row = Data.define(:id, :configured, :info, :unavailable) do
121
+ def initialize(id:, configured:, info:, unavailable: false) = super
122
+ end
116
123
  end
@@ -322,7 +322,7 @@ module Samagotchi
322
322
  save_model_list(name, models)
323
323
  rescue StandardError => e
324
324
  Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
325
- data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }
325
+ data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: list_error(e) }
326
326
  end
327
327
  results_mutex.synchronize { results[name] = data }
328
328
  end
@@ -374,6 +374,11 @@ module Samagotchi
374
374
 
375
375
  private
376
376
 
377
+ # A listing error as text without the host's name (a provider error's
378
+ # #detail: "connection refused", "request failed after …"): every line
379
+ # that shows it names the host itself.
380
+ def list_error(error) = error.is_a?(LLM::ProviderError) ? error.detail : error.message
381
+
377
382
  # Save a host's just-listed models on disk, for the processes that spawn
378
383
  # a worker without listing (`chi send --new --model`, delegate):
379
384
  # ModelListStore is what they check an id against.
@@ -510,15 +510,18 @@ module Samagotchi
510
510
  generation_controller = nil
511
511
  response = with_generation do |controller|
512
512
  generation_controller = controller
513
+ # One count for the step: the ask-again without thinking fields
514
+ # doesn't get drops of its own.
515
+ drops = DropCount.new(0, 0)
513
516
  begin
514
- request_with_drops(iteration, retry_generation, streamed, thought, 0, controller)
517
+ request_with_drops(iteration, retry_generation, streamed, thought, drops, controller)
515
518
  rescue BadRequest => e
516
519
  raise unless thinking_refused?(e)
517
520
 
518
521
  # Once per model: asked again without the thinking fields.
519
522
  @loop.thinking_refused!(@model_name)
520
523
  emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
521
- request_with_drops(iteration, retry_generation, streamed, thought, 0, controller)
524
+ request_with_drops(iteration, retry_generation, streamed, thought, drops, controller)
522
525
  end
523
526
  rescue RequestCancelled
524
527
  raise unless generation_controller&.cancelled? && !@cancel_controller.cancelled?
@@ -596,9 +599,14 @@ module Samagotchi
596
599
  # retries.
597
600
  MAX_STREAM_DROPS = 2
598
601
 
599
- # Runs one request, asking again on a mid-stream drop. +attempt+
600
- # counts the drops already retried this step.
601
- def request_with_drops(iteration, retry_generation, streamed, thought, attempt, generation_controller)
602
+ # A step's drops asked again (+drops+) and the HTTP requests made so
603
+ # far (+requests+: each failed request's transport attempts), so the
604
+ # error that ends the step counts every request, not the last one's.
605
+ DropCount = Struct.new(:drops, :requests)
606
+
607
+ # Runs one request, asking again on a mid-stream drop; +count+ (a
608
+ # DropCount) carries on across calls for one step.
609
+ def request_with_drops(iteration, retry_generation, streamed, thought, count, generation_controller)
602
610
  streamed_any = false
603
611
  request(iteration, retry_generation, streamed, thought, generation_controller, -> { streamed_any = true })
604
612
  rescue RetryExhausted => e
@@ -607,10 +615,16 @@ module Samagotchi
607
615
  cancelled = [@cancel_controller, generation_controller].compact.find(&:cancelled?)
608
616
  raise RequestCancelled, cancelled.reason if cancelled
609
617
 
618
+ count.requests += e.attempts.to_i
610
619
  limit = @loop.stream_drop_limit(MAX_STREAM_DROPS)
611
- raise if attempt >= limit || !streamed_any
620
+ if count.drops >= limit || !streamed_any
621
+ raise if count.requests == e.attempts.to_i
622
+
623
+ raise RetryExhausted.new(attempts: count.requests, last_error: e.last_error, label: e.host)
624
+ end
612
625
 
613
- attempt += 1
626
+ count.drops += 1
627
+ attempt = count.drops
614
628
  streamed.clear
615
629
  thought.clear
616
630
  delay = @loop.generation_retry_delay(attempt)
@@ -620,6 +634,12 @@ module Samagotchi
620
634
  next_delay: delay, error_class: e.class.name, error_message: e.message, status: nil, restarted: true)
621
635
  @loop.wait_retry(delay, @cancel_controller, generation_controller)
622
636
  retry
637
+ rescue ProviderError => e
638
+ # Another failure (a 503, a refusal) after earlier requests this
639
+ # step: its count takes theirs in.
640
+ count.requests += e.attempts.to_i
641
+ e.attempts = count.requests
642
+ raise
623
643
  end
624
644
 
625
645
  # A 400 about reasoning, for a request that carried thinking fields
@@ -223,11 +223,17 @@ module Samagotchi
223
223
 
224
224
  def initialize(attempts:, last_error:, label: "llama.cpp")
225
225
  @last_error = last_error
226
- super("#{label} request failed after #{attempts} attempts: #{last_error.class}: #{last_error.message}",
226
+ super("#{label} request failed after #{self.class.count(attempts)}: #{last_error.class}: #{last_error.message}",
227
227
  host: label, retryable: false, attempts: attempts)
228
228
  end
229
229
 
230
- def summary = "network error after #{attempts} attempts (host #{host}: #{last_error.class})"
230
+ def summary = "network error after #{self.class.count(attempts)} (host #{host}: #{last_error.class})"
231
+
232
+ # The message without its leading "host ": "request failed after …".
233
+ def detail = host ? message.delete_prefix("#{host} ") : message
234
+
235
+ # "1 attempt", "3 attempts".
236
+ def self.count(attempts) = "#{attempts} attempt#{"s" unless attempts == 1}"
231
237
  end
232
238
 
233
239
  # A refused connection: nothing listens at the host's address. Not
@@ -181,6 +181,7 @@ module Samagotchi
181
181
  return nil if session.nil?
182
182
 
183
183
  require_relative "config"
184
+ require_relative "model_profile"
184
185
  registry ||= begin
185
186
  require_relative "host_registry"
186
187
  HostRegistry.new
@@ -9,13 +9,14 @@ module Samagotchi
9
9
  module ModelCatalog
10
10
  # host: the host that listed (or declares) the id; ref: the name
11
11
  # `--model` takes for it (see .spell); configured: declared under
12
- # hosts.<name>.models (HostModel.rows).
13
- Row = Data.define(:host, :id, :ref, :configured) do
14
- def initialize(host:, id:, ref:, configured: false) = super
12
+ # hosts.<name>.models (HostModel.rows); unavailable: a declared id of a
13
+ # host that failed to list its models (still pickable).
14
+ Row = Data.define(:host, :id, :ref, :configured, :unavailable) do
15
+ def initialize(host:, id:, ref:, configured: false, unavailable: false) = super
15
16
  end
16
17
  # rows: the default host's first, then the others by name, each host's
17
- # declared ids first (HostModel.rows); warnings:
18
- # "host: error" per host that failed.
18
+ # declared ids first (HostModel.rows; a host that failed keeps only
19
+ # those); warnings: "host: error" per host that failed.
19
20
  Listing = Data.define(:rows, :warnings)
20
21
 
21
22
  module_function
@@ -29,15 +30,13 @@ module Samagotchi
29
30
  warnings = []
30
31
  host_rows = HostModel.rows(results, registry.entries)
31
32
  results.keys.sort_by { |name| [name == default_host ? 0 : 1, name] }.each do |host|
32
- if results[host][:error]
33
- warnings << "#{host}: #{results[host][:error]}"
34
- next
35
- end
36
- host_rows[host].each do |row|
33
+ warnings << "#{host}: #{results[host][:error]}" if results[host][:error]
34
+ host_rows.fetch(host, []).each do |row|
37
35
  id = row.id
38
36
  next if id.strip.empty? || (!row.configured && id.end_with?(":batch"))
39
37
 
40
- rows << Row.new(host: host, id: id, ref: spell(host, id, default_host), configured: row.configured)
38
+ rows << Row.new(host: host, id: id, ref: spell(host, id, default_host), configured: row.configured,
39
+ unavailable: row.unavailable)
41
40
  end
42
41
  end
43
42
  Listing.new(rows: rows, warnings: warnings)
@@ -55,7 +54,8 @@ module Samagotchi
55
54
  # go first (its rows are the "default host" group). default: the default's listed
56
55
  # row name when it resolves to one, else its resolved ref; default_typed:
57
56
  # the alias it was typed as. A row whose name resolves elsewhere (an
58
- # alias named like the id shadows it) carries shadowed_by.
57
+ # alias named like the id shadows it) carries shadowed_by; a declared id
58
+ # of a host that failed to list carries unavailable.
59
59
  # @param default_name [String, nil] default.model as configured
60
60
  # @return [Hash]
61
61
  def payload(results, registry:, default_name:)
@@ -63,6 +63,7 @@ module Samagotchi
63
63
  models = listing.rows.map do |row|
64
64
  hash = { name: row.ref, host: row.host, id: row.id }
65
65
  hash[:configured] = true if row.configured
66
+ hash[:unavailable] = true if row.unavailable
66
67
  shadow = shadowed_by(row, registry)
67
68
  shadow ? hash.merge(shadowed_by: shadow) : hash
68
69
  end
@@ -232,10 +232,11 @@ module Samagotchi
232
232
  return unknown_model_message(parsed.id, host, list.ids) if list.age < RELIST_AFTER_SECONDS
233
233
 
234
234
  fresh_ids = relist_or_nil(relist || default_relist(hosts), host, env)
235
- # A re-list that answered: the saved list catches up (the default
236
- # re-list, HostRegistry#list_models, saved it already).
235
+ # A re-list that answered: the saved list catches up. The default
236
+ # re-list, HostRegistry#list_models, saved it already; a given one
237
+ # is saved here.
237
238
  if fresh_ids
238
- Samagotchi::ModelListStore.save(host, fresh_ids, env: env)
239
+ Samagotchi::ModelListStore.save(host, fresh_ids, env: env) if relist
239
240
  return nil if fresh_ids.any? { |id| id.to_s.casecmp?(parsed.id.to_s.strip) }
240
241
 
241
242
  return unknown_model_message(parsed.id, host, fresh_ids)
@@ -23,13 +23,14 @@ module Samagotchi
23
23
  Lists the models every configured host offers, as the names
24
24
  --model takes: the default first, a default-host id bare, another
25
25
  host's as host:id, then the aliases as "name -> ref". A host that
26
- fails or doesn't answer in time is noted on stderr.
26
+ fails or doesn't answer in time is noted on stderr; the ids
27
+ declared under its hosts.<name>.models are still listed.
27
28
  TEXT only the names containing it (any case)
28
29
  --format json the default, the models (host, id, name), the aliases
29
30
  and the warnings as one JSON object
30
31
  --timeout S wait at most S seconds for the hosts; default 4
31
32
  Exit 0 when any host listed its models, 1 when none did (the
32
- default is still printed), 2 on a usage error.
33
+ default and declared names still print), 2 on a usage error.
33
34
  TEXT
34
35
 
35
36
  FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
@@ -59,8 +60,10 @@ module Samagotchi
59
60
  return usage_error("--timeout takes a number of seconds above 0") unless timeout&.positive?
60
61
 
61
62
  payload, listed = catalog(timeout)
63
+ # Before the filter: a down host's declared ids are listed even when TEXT hides them.
64
+ down_hosts = payload[:models].select { |m| m[:unavailable] }.map { |m| m[:host] }.uniq
62
65
  payload = filtered(payload, parsed.args.join(" ").strip)
63
- options[:format] == "json" ? print_json(payload) : print_text(payload)
66
+ options[:format] == "json" ? print_json(payload) : print_text(payload, down_hosts)
64
67
  listed ? 0 : 1
65
68
  end
66
69
 
@@ -99,14 +102,18 @@ module Samagotchi
99
102
  @stdout.puts(JSON.generate(payload.except(:filter)))
100
103
  end
101
104
 
102
- def print_text(payload)
105
+ # @param down_hosts [Array<String>] hosts that failed but declare ids
106
+ def print_text(payload, down_hosts)
103
107
  default = payload[:default]
104
108
  names = payload[:models].reject { |m| m[:shadowed_by] }.map { |m| m[:name] }
105
109
  shown_default = default && (payload[:filter].nil? || default.downcase.include?(payload[:filter].downcase))
106
110
  @stdout.puts(default) if shown_default
107
111
  (names - [default]).each { |name| @stdout.puts(name) }
108
112
  payload[:aliases].each { |a| @stdout.puts("#{a[:name]} -> #{a[:ref]}") }
109
- payload[:warnings].each { |w| @stderr.puts("chi models: #{w}") }
113
+ payload[:warnings].each do |w|
114
+ kept = down_hosts.any? { |host| w.start_with?("#{host}: ") }
115
+ @stderr.puts("chi models: #{w}#{" (its declared ids are still listed)" if kept}")
116
+ end
110
117
  end
111
118
  end
112
119
  end
@@ -64,6 +64,13 @@ module Samagotchi
64
64
  "or anything else that changes files or state. Finish your reply with what you found " \
65
65
  "and what you would do, and wait."
66
66
 
67
+ # The cancel reason of a question no one could answer: a
68
+ # --non-interactive run has no one to ask (TerminalUI).
69
+ UNANSWERABLE_REASON = "non_interactive"
70
+ UNANSWERABLE_NOTE = "No one could answer the question: this is a non-interactive run. Don't do what you " \
71
+ "asked about, or anything else that changes files or state. Finish your reply with " \
72
+ "what you found and what you would do."
73
+
67
74
  # @param session [#call] → Session, nil
68
75
  # @param state_dir [#call] → String, the session's state dir
69
76
  # @param emit [#call] (event) → emits an event to the turn sink + observers
@@ -82,6 +89,8 @@ module Samagotchi
82
89
  @cv = @lock.new_cond
83
90
  @pending = nil
84
91
  @answer = nil
92
+ # Why the open question was cancelled (#cancel), for the sync path.
93
+ @cancel_reason = nil
85
94
  @sync_handler = nil
86
95
  # The standing question while it is @pending: {id:, fields:,
87
96
  # on_answer:, on_superseded_close:}; @shelved, one superseded by (or
@@ -106,7 +115,10 @@ module Samagotchi
106
115
  .merge(multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform]))
107
116
  # Dismissed (the card's dismiss, Esc): an answer of its own, not a
108
117
  # tool failure the model learns to avoid the tool from.
109
- result = { dismissed: true, id: result[:id], note: DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
118
+ if result.is_a?(Hash) && result[:error] == "no answer"
119
+ note = result[:reason] == UNANSWERABLE_REASON ? UNANSWERABLE_NOTE : DISMISSED_NOTE
120
+ result = { dismissed: true, id: result[:id], note: note }
121
+ end
110
122
  # Who answered is for the relay, not the model's tool result.
111
123
  result = result.except(:by) if result.is_a?(Hash)
112
124
  result.is_a?(String) ? result : JSON.generate(result)
@@ -213,7 +225,15 @@ module Samagotchi
213
225
  emit({ type: :question_answered, id: id, answer: ans })
214
226
  return ans
215
227
  end
216
- return sync_res if sync_res.is_a?(String) && !sync_res.strip.empty?
228
+ if sync_res.is_a?(String) && !sync_res.strip.empty?
229
+ # The handler's text is the answer: nothing stays pending, and
230
+ # every UI hears it was answered (the text as its freeform).
231
+ @lock.synchronize { @pending = nil }
232
+ clear_saved_question
233
+ emit({ type: :question_answered, id: id,
234
+ answer: { id: id.to_s, selected: [], freeform: sync_res.strip, selected_indices: [] } })
235
+ return sync_res
236
+ end
217
237
  rescue StandardError => e
218
238
  Log.warn(:turn, "question_handler_failed", echo: "[ask_user_question] sync handler failed: #{e.message}", error: e.class.name)
219
239
  end
@@ -221,9 +241,12 @@ module Samagotchi
221
241
  # CV (no cross-thread answerer exists for synchronous UIs). Clear pending
222
242
  # and return an error so the model can fallback to plain text. Generic
223
243
  # observers will discard the stale question_requested via staleness check.
224
- @lock.synchronize { @pending = nil }
244
+ reason = @lock.synchronize do
245
+ @pending = nil
246
+ @cancel_reason
247
+ end
225
248
  clear_saved_question
226
- return { error: "no answer", detail: "handler failed to capture selection", id: id }
249
+ return { error: "no answer", detail: "handler failed to capture selection", id: id, reason: reason }.compact
227
250
  end
228
251
 
229
252
  # Block until answered/cancelled (cross-thread path: WEB/Bridge/background worker)
@@ -356,6 +379,7 @@ module Samagotchi
356
379
  next if @standing
357
380
 
358
381
  pending[:status] = "cancelled"
382
+ @cancel_reason = reason.to_s
359
383
  @cv.broadcast
360
384
  pending[:id]
361
385
  end
@@ -403,6 +427,7 @@ module Samagotchi
403
427
  @lock.synchronize do
404
428
  @pending = pending
405
429
  @answer = nil
430
+ @cancel_reason = nil
406
431
  @standing = standing&.merge(id: pending[:id])
407
432
  end
408
433
  if session
@@ -17,6 +17,15 @@ module Samagotchi
17
17
  name.is_a?(String) && !name.strip.empty? ? name : nil
18
18
  end
19
19
 
20
+ # The host whose hosts.<name>.models.<id>.served names +served+ for
21
+ # +target+'s model (a gateway's round-robin targets): not a mismatch to
22
+ # warn about. nil when none does.
23
+ # @param target [HostRegistry::ModelTarget]
24
+ # @return [String, nil]
25
+ def expected_by(target, served)
26
+ target.entry.name if target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.serves?(served)
27
+ end
28
+
20
29
  # The separators a provider uses when it decorates a model name: a tag
21
30
  # (`qwen3:latest`), a date or quant suffix (`gpt-4o-2024-08-06`), an
22
31
  # owner (`@org/name`) or a path (`meta-llama/Llama-3`). Anything else
@@ -573,14 +573,14 @@ module Samagotchi
573
573
  results.keys.sort.each do |hname|
574
574
  data = results[hname]
575
575
  host_label = "#{hname} (#{data[:host]}:#{data[:port]})"
576
+ # Declared ids (hosts.<name>.models) first, as "(config)", never cut
577
+ # by MODELS_PER_HOST; a host that failed keeps only those, as
578
+ # "(config, host down)" under its unreachable line.
579
+ rows = host_rows.fetch(hname, [])
576
580
  if data[:error]
577
581
  lines << "#{host_label} — unreachable: #{data[:error]}"
578
- next
579
- end
580
- # Declared ids (hosts.<name>.models) first, as "(config)", never cut
581
- # by MODELS_PER_HOST.
582
- rows = host_rows[hname]
583
- if rows.empty?
582
+ next if rows.empty?
583
+ elsif rows.empty?
584
584
  lines << "#{host_label} — no models discovered"
585
585
  next
586
586
  end
@@ -599,13 +599,14 @@ module Samagotchi
599
599
  end
600
600
  next if declared.empty? && shown.empty?
601
601
 
602
- lines << "#{host_label}:"
602
+ lines << "#{host_label}:" unless data[:error]
603
603
  hidden = needle.empty? ? [shown.size - MODELS_PER_HOST, 0].max : 0
604
604
  (declared + shown.first(shown.size - hidden)).each do |row, identifier|
605
605
  raw = row.info&.raw || {}
606
606
  raw_status = raw["status"] || raw[:status]
607
607
  status = raw_status.is_a?(Hash) ? (raw_status["value"] || raw_status[:value] || raw_status["status"] || raw_status[:status]) : raw_status
608
608
  status = ["config", status].reject { |s| s.to_s.empty? }.join(", ") if row.configured
609
+ status = "#{status}, host down" if row.unavailable
609
610
  base = status.to_s.empty? ? " #{identifier}" : " #{identifier} (#{status})"
610
611
  alias_list = (by_model[identifier.to_s.downcase] || []) + (by_model["#{hname}:#{identifier}".downcase] || [])
611
612
  alias_list.uniq!
@@ -96,17 +96,29 @@ module Samagotchi
96
96
  paint(TurnNote.empty_answer_line(retries), 90)
97
97
  end
98
98
 
99
- # "retrying (1/3 in 0.5s): Errno::ECONNREFUSED": the retry of all the
100
- # retries there will be (as the web counts), the wait before it, and
101
- # what failed (generation_retrying).
99
+ # "↻ retrying (503) in 4.0s, 2/5": the live status while the provider
100
+ # is asked again (generation_retrying), worded as the web words it
101
+ # (turn_events.js retryStatusLine; spec/shared/labels_matrix.json):
102
+ # why (the HTTP status, the error's short class name, or "stream
103
+ # dropped" for a dropped stream's step asked again), the wait before
104
+ # it, and the retry of all the retries there will be.
102
105
  def format_generation_retry_line(event)
103
- return "retrying (attempt #{event[:attempt]})" unless event[:max_retries]
106
+ why = event[:restarted] ? "stream dropped" : retry_cause(event)
107
+ delay = Float(event[:next_delay], exception: false)
108
+ text = +"\u21bb retrying"
109
+ text << " (#{why})" unless why.empty?
110
+ text << " in #{format("%.1f", (delay * 10).round / 10.0)}s" if delay&.finite?
111
+ text << ", #{event[:attempt]}#{"/#{event[:max_retries]}" if event[:max_retries]}" if event[:attempt]
112
+ text
113
+ end
104
114
 
105
- text = "retrying (#{event[:attempt]}/#{event[:max_retries]} in #{format("%.1f", event[:next_delay].to_f)}s)"
106
- # restarted: a dropped stream's step asked again (the chat loop's).
107
- return "#{text}: stream dropped" if event[:restarted]
115
+ # What failed, for a retry line: the HTTP status, else the error's
116
+ # class name without its namespace (Errno::ECONNREFUSED: ECONNREFUSED).
117
+ def retry_cause(event)
118
+ status = event[:status]
119
+ return status.to_s unless status.nil? || status.to_s.empty? || status.to_s == "0"
108
120
 
109
- event[:error_class].to_s.empty? ? text : "#{text}: #{event[:error_class]}"
121
+ event[:error_class].to_s.split("::").last.to_s
110
122
  end
111
123
 
112
124
  # " → image 1280×800" after a tool line whose tool read an image.
@@ -356,13 +368,16 @@ module Samagotchi
356
368
  end
357
369
 
358
370
  # /stats' cost: the reported part and the estimated one, each when present.
359
- def stats_cost_text(tokens)
371
+ # +price_host+ names the host whose hosts.<name>.models prices the
372
+ # estimate came from (Engine#stats_snapshot; nil from an older worker).
373
+ def stats_cost_text(tokens, price_host: nil)
360
374
  reported = cost_text(tokens[:cost_sum])
361
375
  estimated = cost_text(tokens[:cost_estimate_sum], estimate: true)
362
376
  return nil if reported.empty? && estimated.empty?
363
377
  return "#{reported} (this session only)" if estimated.empty?
364
378
 
365
- estimate = "#{estimated} from hosts.<name>.models prices"
379
+ source = price_host.to_s.empty? ? "configured prices" : "hosts.#{price_host}.models prices"
380
+ estimate = "#{estimated} from #{source}"
366
381
  "#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
367
382
  end
368
383
 
@@ -492,7 +507,7 @@ module Samagotchi
492
507
  "#{token_breakdown_text(tokens)}"
493
508
  speed = stats_speed_text(tokens)
494
509
  lines << "speed: #{speed}" if speed
495
- cost = stats_cost_text(tokens)
510
+ cost = stats_cost_text(tokens, price_host: snapshot[:price_host])
496
511
  lines << "cost: #{cost}" if cost
497
512
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
498
513
  lines << "cancellations: #{snapshot[:cancellations]}"
@@ -1351,9 +1351,11 @@ module Samagotchi
1351
1351
  # question there): the question is dismissed at once, as an empty answer
1352
1352
  # would be, and the model finishes its reply. Never a read of stdin: a
1353
1353
  # pipe its writer keeps open would block it forever. Its one line goes to
1354
- # stderr (#banner): stdout is the answer's alone.
1354
+ # stderr (#banner): stdout is the answer's alone. The model is told no
1355
+ # one could answer (QuestionDesk::UNANSWERABLE_NOTE), not that the user
1356
+ # dismissed it.
1355
1357
  def dismiss_unaskable_question(prompt)
1356
- @engine.cancel_question("user") rescue nil
1358
+ @engine.cancel_question(QuestionDesk::UNANSWERABLE_REASON) rescue nil
1357
1359
  banner(prompt.summary("(dismissed: no one to answer in a non-interactive run)", paint: method(:paint)))
1358
1360
  false
1359
1361
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.46.0"
4
+ VERSION = "0.46.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -24,6 +24,7 @@ require_relative "../llm_context_override"
24
24
  require_relative "../model_catalog"
25
25
  require_relative "../model_profile"
26
26
  require_relative "../sampling_settings"
27
+ require_relative "../served_model"
27
28
  require_relative "../project_scope"
28
29
  require_relative "../prompt_history"
29
30
  require_relative "../version"
@@ -445,6 +446,7 @@ module Samagotchi
445
446
  listing.rows.each do |row|
446
447
  model = { name: row.host == default_host ? row.id : "#{row.host}:#{row.id}", host: row.host, id: row.id }
447
448
  model[:configured] = true if row.configured
449
+ model[:unavailable] = true if row.unavailable
448
450
  models << model
449
451
  end
450
452
  end
@@ -487,6 +489,33 @@ module Samagotchi
487
489
  nil
488
490
  end
489
491
 
492
+ # The served model a session's last generation reported (analytics.json,
493
+ # SessionMetrics#persist), for a session no worker runs: shown only
494
+ # while the name it answered is still the session's model, as
495
+ # Engine#served_model does, with the host whose served: expects it.
496
+ # {} when none applies.
497
+ def saved_served_model(session, persisted)
498
+ served = persisted["served_model"]
499
+ model = session.model_name.to_s
500
+ return {} unless served.is_a?(String) && !served.empty? && !model.empty?
501
+
502
+ registry = host_registry
503
+ asked = registry.bare_name(model)
504
+ return {} unless persisted["served_model_for"] == asked
505
+
506
+ { served_model: served, served_model_for: asked, served_expected_by: saved_served_expected_by(registry, model, served) }
507
+ rescue StandardError
508
+ {}
509
+ end
510
+
511
+ # See ServedModel.expected_by; nil when the model doesn't resolve (the
512
+ # mismatch still shows).
513
+ def saved_served_expected_by(registry, model, served)
514
+ ServedModel.expected_by(registry.resolve(model), served)
515
+ rescue StandardError
516
+ nil
517
+ end
518
+
490
519
  # A session's LLM context strategy, apply rule and budget, each with
491
520
  # where it came from (LLMContextStrategy::Explained#summary), as its
492
521
  # worker would resolve them; nil when they can't be.
@@ -657,18 +686,22 @@ module Samagotchi
657
686
  # A /model in the worker changes it before the file catches up.
658
687
  session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
659
688
  session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
660
- # What the worker's server said it served for that model (after a turn).
689
+ # What the worker's server said it served for that model (after a turn);
690
+ # without a worker, what the session's analytics.json last saved.
691
+ persisted = snapshot ? nil : read_analytics(id)
661
692
  if snapshot
662
693
  session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
663
694
  served_expected_by: snapshot["served_expected_by"])
664
695
  # The model notes its prompt carries now (a /model there changes them).
665
696
  session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
697
+ else
698
+ session_json = session_json.merge(saved_served_model(session, persisted))
666
699
  end
667
700
  # The LLM context strategy the next turn runs under (the info bar's
668
701
  # chip): the worker's, else worked out here from the session file.
669
702
  session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
670
703
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
671
- timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"])
704
+ timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
672
705
  json_response(200, {
673
706
  session: session_json,
674
707
  history: read_history(id),
@@ -1502,8 +1535,9 @@ module Samagotchi
1502
1535
 
1503
1536
  # The whole timing: analytics.json's records merged with a live
1504
1537
  # worker's (by id), and turn_count, the finished turns (#turn_count).
1505
- def timing_payload(session_id, live_metrics: nil)
1506
- persisted = read_analytics(session_id)
1538
+ # +persisted+: the session's analytics.json when the caller read it.
1539
+ def timing_payload(session_id, live_metrics: nil, persisted: nil)
1540
+ persisted ||= read_analytics(session_id)
1507
1541
  live = live_metrics.is_a?(Hash)
1508
1542
  source = if live
1509
1543
  persisted.merge(live_metrics).merge(
@@ -494,6 +494,7 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
494
494
  #composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
495
495
  #composer .actions-col .model-pick[hidden],body.has-session #composer .actions-col .model-pick{display:none}
496
496
  .model-pick-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
497
+ #composer .actions-col .model-pick.unavailable .model-pick-name{color:var(--text-faint)}
497
498
  .model-pick::after{content:"";position:absolute;right:9px;top:50%;width:5px;height:5px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:translateY(-70%) rotate(45deg);pointer-events:none;transition:transform .12s ease}
498
499
  .model-pick[aria-expanded="true"]::after{transform:translateY(-20%) rotate(225deg)}
499
500
  @media(max-width:600px){#composer .actions-col .model-pick{max-width:60%}}
@@ -529,6 +530,8 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
529
530
  #modelList mark{background:none;color:var(--accent);font-weight:600}
530
531
  #modelList .model-option.chosen mark{color:var(--text-bright)}
531
532
  #modelList .model-note{flex:none;font-size:11px;color:var(--text-faint)}
533
+ #modelList .model-option.unavailable:not(.chosen) .model-id,#modelList .model-option.unavailable .model-host{color:var(--text-faint)}
534
+ #modelList .model-option.unavailable .model-note{color:var(--warn-text)}
532
535
  #modelList .model-empty{padding:10px;font-size:13px;color:var(--text-faint)}
533
536
  @media(max-width:600px){#modelPanel{left:6px;width:calc(100% - 12px)}#modelSearch{font-size:16px}#modelList .model-option{padding-top:8px;padding-bottom:8px}}
534
537
  /* The / autocomplete: a list over the composer card's top edge. It wears the glass edge and shadow, but opaque:
@@ -2,16 +2,19 @@
2
2
 
3
3
  export const MODEL_KEY = "chi_model";
4
4
 
5
- // The name to preselect: the remembered one when it is still offered (any
6
- // case), else the server's default, else the first offered, else "".
7
- export function pickModel(names, defaultName, stored) {
5
+ // The name to preselect from the rows (modelRows): the remembered one when
6
+ // it is still offered (any case), else the server's default, both even
7
+ // when their host is down; else the first row whose host answered, else
8
+ // the first row, else "".
9
+ export function pickModel(rows, defaultName, stored) {
10
+ const names = rows.map((r) => r.name);
8
11
  const want = String(stored || "").trim().toLowerCase();
9
12
  if (want) {
10
13
  const kept = names.find((n) => n.toLowerCase() === want);
11
14
  if (kept) return kept;
12
15
  }
13
16
  if (defaultName && names.includes(defaultName)) return defaultName;
14
- return defaultName || names[0] || "";
17
+ return defaultName || (rows.find((r) => !r.unavailable) || rows[0])?.name || "";
15
18
  }
16
19
 
17
20
 
@@ -21,7 +24,8 @@ export const RECENT_KEY = "chi_model_recent";
21
24
  // sampling (the configured request parameters as /model words them) when
22
25
  // the model has some, and llm_context (the LLM context it starts under,
23
26
  // LLMContextStrategy::Explained#summary) when the server sent one, and
24
- // configured when the host declares the id (hosts.<name>.models). The
27
+ // configured when the host declares the id (hosts.<name>.models), and
28
+ // unavailable when that host failed to list its models. The
25
29
  // default host's names are bare (name == id); the server's own default,
26
30
  // unshifted when no host lists it, has no host and goes with them, shown
27
31
  // by its name.
@@ -36,6 +40,7 @@ export function modelRows(payload) {
36
40
  const row = { name, host, id, isDefaultHost: !host || name === id };
37
41
  if (m.sampling) row.sampling = String(m.sampling);
38
42
  if (m.configured === true) row.configured = true;
43
+ if (m.unavailable === true) row.unavailable = true;
39
44
  if (m.llm_context && typeof m.llm_context === "object") row.llm_context = m.llm_context;
40
45
  return row;
41
46
  });
@@ -44,9 +49,9 @@ export function modelRows(payload) {
44
49
  const byId = (a, b) => (b.configured === true) - (a.configured === true) ||
45
50
  a.id.localeCompare(b.id, "en", { sensitivity: "base" });
46
51
 
47
- // The rows under their hosts, [{host, isDefault, rows}]: the default host
48
- // first, the others in the server's order; inside a host the configured
49
- // ids first, each part A-Z.
52
+ // The rows under their hosts, [{host, isDefault, unavailable, rows}]: the
53
+ // default host first, the others in the server's order; inside a host the
54
+ // configured ids first, each part A-Z. unavailable: every row's host is down.
50
55
  export function groupRows(rows) {
51
56
  const groups = [];
52
57
  const find = (key) => groups.find((g) => g.key === key);
@@ -61,23 +66,25 @@ export function groupRows(rows) {
61
66
  group.rows.push(row);
62
67
  }
63
68
  return groups.map(({ host, isDefault, rows: rs }) => ({
64
- host: host || "default", isDefault, rows: [...rs].sort(byId),
69
+ host: host || "default", isDefault, unavailable: rs.every((r) => r.unavailable === true), rows: [...rs].sort(byId),
65
70
  }));
66
71
  }
67
72
 
68
- // The word after a row's id: "default" for the default model, else
69
- // "config" for an id the host declares (hosts.<name>.models), else "".
73
+ // The words after a row's id: "default" for the default model, else
74
+ // "config" for an id the host declares (hosts.<name>.models), else "";
75
+ // then "host down" when its host failed to list its models.
70
76
  export function rowNote(row, defaultName) {
71
- if (row.name === defaultName) return "default";
72
- return row.configured ? "config" : "";
77
+ const word = row.name === defaultName ? "default" : row.configured ? "config" : "";
78
+ return [word, row.unavailable ? "host down" : ""].filter(Boolean).join(" · ");
73
79
  }
74
80
 
75
- // A row's tooltip: its configured sampling, and where a configured id
76
- // comes from; "" when neither.
81
+ // A row's tooltip: its configured sampling, where a configured id comes
82
+ // from, and that its host is down; "" when none.
77
83
  export function rowTitle(row) {
78
84
  const parts = [];
79
85
  if (row.sampling) parts.push(`sampling: ${row.sampling}`);
80
86
  if (row.configured) parts.push(`served by config (hosts.${row.host}.models)`);
87
+ if (row.unavailable) parts.push(`host ${row.host} didn't list its models (down?); a chat on it may fail`);
81
88
  return parts.join("; ");
82
89
  }
83
90
 
@@ -50,16 +50,19 @@ export function createModelPicker({ button, panel, input, list, composer, center
50
50
 
51
51
  const isOpen = () => !panel.hidden;
52
52
 
53
+ // Greyed while the chosen row's host is down (the button's tooltip,
54
+ // app.js', carries the host's warning).
53
55
  function setChosen(name) {
54
56
  chosen = name;
55
57
  button.querySelector(".model-pick-name").textContent = name;
58
+ button.classList.toggle("unavailable", rows.some((r) => r.name === name && r.unavailable === true));
56
59
  }
57
60
 
58
61
  function rowHtml(row, i, { hostMarks = [], idMarks = [], showHost = !row.isDefaultHost } = {}) {
59
62
  const host = showHost ? `<span class="model-host">${markedHtml(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : "";
60
63
  const noteText = rowNote(row, defaultName);
61
64
  const note = noteText ? `<span class="model-note">${noteText}</span>` : "";
62
- const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}`;
65
+ const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}${row.unavailable ? " unavailable" : ""}`;
63
66
  const titleText = rowTitle(row);
64
67
  const title = titleText ? ` title="${escapeHtml(titleText)}"` : "";
65
68
  return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}"${title}>` +
@@ -83,7 +86,7 @@ export function createModelPicker({ button, panel, input, list, composer, center
83
86
  recent.forEach((row) => add(row));
84
87
  }
85
88
  for (const group of groupRows(rows)) {
86
- html += head(group.host, group.isDefault ? "default host" : "");
89
+ html += head(group.host, [group.isDefault ? "default host" : "", group.unavailable ? "host down" : ""].filter(Boolean).join(" · "));
87
90
  group.rows.forEach((row) => add(row, { showHost: false }));
88
91
  }
89
92
  }
@@ -217,7 +220,7 @@ export function createModelPicker({ button, panel, input, list, composer, center
217
220
  close();
218
221
  rows = next;
219
222
  defaultName = String(payload.default || "");
220
- setChosen(pickModel(rows.map((r) => r.name), payload.default, stored || chosen));
223
+ setChosen(pickModel(rows, payload.default, stored || chosen));
221
224
  return true;
222
225
  },
223
226
  chosen: () => chosen,
@@ -25,6 +25,13 @@ const CONTINUE_CLOSED = {
25
25
  replaced: "Asked again",
26
26
  };
27
27
 
28
+ // A close reason that reads as words on any card, not as "(reason)": a
29
+ // question asked in a --non-interactive run (QuestionDesk's
30
+ // UNANSWERABLE_REASON) had no one to answer it.
31
+ const CLOSED_PLAIN = {
32
+ non_interactive: "No one could answer (non-interactive run)",
33
+ };
34
+
28
35
  // A delegate's approval relayed to this session's user (the approval
29
36
  // relay): who asks (the chain, "ab12 → cd34" for a grandchild's) and its
30
37
  // task, or null.
@@ -97,6 +104,7 @@ function relayClosedText(relay, reason) {
97
104
  export function resultText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
98
105
  const approval = isApproval(pq);
99
106
  const relay = relayView(pq);
107
+ if (cancelled && Object.hasOwn(CLOSED_PLAIN, reason)) return CLOSED_PLAIN[reason];
100
108
  if (cancelled && relay && reason !== "dismissed") return relayClosedText(relay, reason);
101
109
  if (isContinue(pq)) return continueResultText({ answer, cancelled, reason });
102
110
  if (cancelled) {
@@ -208,12 +208,16 @@ export function noticeLine(entry = {}) {
208
208
  }
209
209
 
210
210
  // The live turn's status while the provider is asked again after an error
211
- // (generation_retrying): "↻ retrying (503) in 4 s, 2/5" (retry 2 of 5).
211
+ // (generation_retrying): "↻ retrying (503) in 4.0s, 2/5" (retry 2 of 5).
212
+ // The TUI words it the same (Formatting#format_generation_retry_line;
213
+ // spec/shared/labels_matrix.json).
212
214
  export function retryStatusLine({ attempt, max_retries, next_delay, status, error_class, restarted } = {}) {
213
215
  // restarted: a dropped stream's step asked again (the chat loop's).
214
216
  const why = restarted ? "stream dropped" : status || String(error_class || "").split("::").pop();
215
- const delay = Number(next_delay);
216
- const when = Number.isFinite(delay) ? ` in ${delay < 1 ? delay.toFixed(1) : Math.round(delay)} s` : "";
217
+ // null/"" are no delay (Number would make them 0).
218
+ const delay = next_delay === null || next_delay === "" ? NaN : Number(next_delay);
219
+ // Rounded as Ruby's Float#round does (half away from zero), then one decimal.
220
+ const when = Number.isFinite(delay) ? ` in ${(Math.round(delay * 10) / 10).toFixed(1)}s` : "";
217
221
  const count = attempt ? `, ${attempt}${max_retries ? `/${max_retries}` : ""}` : "";
218
222
  return `↻ retrying${why ? ` (${why})` : ""}${when}${count}`;
219
223
  }
@@ -850,14 +850,18 @@ module Samagotchi
850
850
  # start only, so the reports its loop merged are not in the
851
851
  # conversation: their rings stay for the next turn, even when the turn
852
852
  # itself stays (a reminder or continue turn). Not one raised after the
853
- # turn ended (the Engine's post-turn work): the turn was kept as
854
- # completed, the reports in it.
853
+ # turn ended (the Engine's post-turn work): the turn was kept as it
854
+ # ended, completed or canceled, the reports in it (a turn TurnFlow
855
+ # takes back is a restore, above).
855
856
  def merges_lost?(error)
856
857
  return false if error.nil? || error.is_a?(LLM::FailedTurn)
857
858
 
858
- @session.last_turn&.dig("outcome") != "completed"
859
+ !KEPT_TURN_OUTCOMES.include?(@session.last_turn&.dig("outcome"))
859
860
  end
860
861
 
862
+ # The Engine's turn endings that keep the turn's conversation (#merges_lost?).
863
+ KEPT_TURN_OUTCOMES = %w[completed canceled].freeze
864
+
861
865
  # A delegate child's turn that was its parent's (#reports_to_parent?)
862
866
  # rings the parent, after the save: the parent reads a settled child.
863
867
  # A question it rang about that still waits makes the next turn ring
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.46.0
4
+ version: 0.46.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov