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 +4 -4
- data/CHANGELOG.md +42 -1
- data/docs/cli.md +13 -7
- data/docs/configuration.md +3 -1
- data/docs/desktop.md +2 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +26 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +5 -1
- data/lib/samagotchi/engine.rb +4 -5
- data/lib/samagotchi/host_model.rb +14 -7
- data/lib/samagotchi/host_registry.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +27 -7
- data/lib/samagotchi/llm/errors.rb +8 -2
- data/lib/samagotchi/llm_context_strategy.rb +1 -0
- data/lib/samagotchi/model_catalog.rb +13 -12
- data/lib/samagotchi/model_profile.rb +4 -3
- data/lib/samagotchi/models_command.rb +12 -5
- data/lib/samagotchi/question_desk.rb +29 -4
- data/lib/samagotchi/served_model.rb +9 -0
- data/lib/samagotchi/session_commands.rb +8 -7
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -11
- data/lib/samagotchi/terminal_ui.rb +4 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +38 -4
- data/lib/samagotchi/web/public/index.html +3 -0
- data/lib/samagotchi/web/public/model_pick.js +22 -15
- data/lib/samagotchi/web/public/model_picker.js +6 -3
- data/lib/samagotchi/web/public/question_card.js +8 -0
- data/lib/samagotchi/web/public/turn_events.js +7 -3
- data/lib/samagotchi/worker.rb +7 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9143ff1fecadcc026c2e0b079d61551359985d72d080f33fc7074af8f90402c1
|
|
4
|
+
data.tar.gz: 04d7e899417b9684ac61d337361ca91e8fa58dcdd091611109fcfff677a351ba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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:
|
|
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
|
|
620
|
-
#
|
|
621
|
-
#
|
|
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
|
-
|
|
628
|
-
|
|
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.
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
|
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
|
|
82
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
600
|
-
#
|
|
601
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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
|
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
100
|
-
#
|
|
101
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
-
|
|
1506
|
-
|
|
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
|
|
6
|
-
// case), else the server's default,
|
|
7
|
-
|
|
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 ||
|
|
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)
|
|
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
|
|
48
|
-
// first, the others in the server's order; inside a host the
|
|
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
|
|
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
|
-
|
|
72
|
-
return row.
|
|
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,
|
|
76
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
216
|
-
const
|
|
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
|
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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")
|
|
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
|