samagotchi 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/docs/cli.md +4 -4
  4. data/docs/hooks.md +1 -1
  5. data/docs/plugins.md +1 -1
  6. data/docs/sessions.md +2 -2
  7. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  8. data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
  9. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
  10. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/command_steps.rb +35 -15
  13. data/lib/samagotchi/host_registry.rb +4 -0
  14. data/lib/samagotchi/kernel_loop.rb +19 -6
  15. data/lib/samagotchi/llm/chat_loop.rb +12 -6
  16. data/lib/samagotchi/model_list_store.rb +117 -0
  17. data/lib/samagotchi/model_profile.rb +43 -0
  18. data/lib/samagotchi/send_command.rb +5 -3
  19. data/lib/samagotchi/session.rb +5 -0
  20. data/lib/samagotchi/session_manager.rb +9 -2
  21. data/lib/samagotchi/session_metrics.rb +6 -3
  22. data/lib/samagotchi/terminal_ui/formatting.rb +7 -4
  23. data/lib/samagotchi/terminal_ui.rb +35 -5
  24. data/lib/samagotchi/tool_activity.rb +6 -2
  25. data/lib/samagotchi/tools/delegate.rb +14 -10
  26. data/lib/samagotchi/tools/execute.rb +3 -1
  27. data/lib/samagotchi/version.rb +1 -1
  28. data/lib/samagotchi/web/public/activity.js +2 -1
  29. data/lib/samagotchi/web/public/app.js +9 -7
  30. data/lib/samagotchi/web/public/command_view.js +19 -5
  31. data/lib/samagotchi/web/public/index.html +14 -6
  32. data/lib/samagotchi/web/public/stage_model.js +18 -0
  33. data/lib/samagotchi/web/public/stage_view.js +16 -11
  34. data/lib/samagotchi/web/public/task_stop.js +11 -2
  35. data/lib/samagotchi/web/public/timing.js +12 -7
  36. data/lib/samagotchi/web/public/turn_events.js +3 -1
  37. data/lib/samagotchi/web/public/turn_model.js +16 -11
  38. data/lib/samagotchi/web/public/turn_view.js +26 -3
  39. data/lib/samagotchi/worker.rb +21 -13
  40. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 581b7b0fa7184fb49a83588921656f2f91f05ae04538592e69874ba1207b686c
4
- data.tar.gz: 4c381eb155ab69b933cda29461a02b111910adfb53309bb38ffc5819dfc764d6
3
+ metadata.gz: 7af50a3a7e90170a9d6b4f3f5ab5914a0ab536ea121b498779109dabfb7c0b51
4
+ data.tar.gz: 4ddba29c753f1a6900956cc5abbbef70c3d912286e252c4c9adf08534a773368
5
5
  SHA512:
6
- metadata.gz: 84b0ba1d65c4c5d41fcbee9e925934f6fd6ee1eb4b46bb35af98b46cde28f9ad334ab56ff44e1ee503f0f7f9dab107d03429c145c7097fa35f90aa8ab86f1f4f
7
- data.tar.gz: 7725dc03f4d5c9d42e7a5c4cae0ba5729740a6d95d37fb04c7bdcc611489907b039ad2ec9b0770c7f47ff04fc681d750309988825896c0e90c1e5b7c0dda3605
6
+ metadata.gz: 497f4731e7a4ca9c1bd5073bea8e897506d37a5fcdfd15ea394f5837edfc85d76fbaade739ec4f6de998f7da5a982c3a8a9c1d4674b1e218a50ab14fe673291b
7
+ data.tar.gz: 0c6428021607e8540dcb21801510eca0295be3fba2de0e09eb4c2fd3c2c362326301065b52c5bc7745e006768522312d1934659da4cacc93254560402c767e76
data/CHANGELOG.md CHANGED
@@ -8,6 +8,58 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.30.0] - 2026-10-05
12
+
13
+ ### Added
14
+
15
+ - chi saves the model ids each host last listed (`~/.local/state/samagotchi/model_lists.json`), so a command that
16
+ starts a session without listing the hosts itself knows what they serve.
17
+ - `chi send --new --model` and the `delegate` tool's `model:` warn about a model id the named host's saved list
18
+ doesn't have: `host 'box' doesn't list model 'gemma-smal' (did you mean: gemma-small?); started it anyway`, on
19
+ stderr / in the tool's result. The session still starts: some hosts serve ids they don't list (a one-model
20
+ llama.cpp server takes any name, OpenRouter's `:nitro`). A name that names no host, a host with no saved list, and
21
+ a list over a week old are not checked.
22
+
23
+ ### Changed
24
+
25
+ - Web: an open step whose label is its narration's first line or its first call's title shows only its call count,
26
+ so it doesn't repeat the words its body starts with; closed, the label is unchanged.
27
+ - Web stage: an answered turn says its step count once (in the cloud's chip) and its time once (in the status row).
28
+ - Web stage: `execute` and the task tools are blue in the running-tool row and the ticks, as in the tool rows under
29
+ them; purple is thinking's colour only (other tools' ticks are a quieter blue-grey).
30
+ - Web stage: `edit` and `write` are blue in the running-tool row and the ticks too, as in the tool rows; green is a
31
+ finished call's `✓` and an answered turn's only.
32
+
33
+ ### Fixed
34
+
35
+ - A command the user's Stop killed (or kept from starting) is saved as `stopped`, not `error`: the web shows it with
36
+ ■ and `stopped`, and the turn's `(N failed)` and the session's tool errors no longer count it.
37
+ - Web: the stop-task confirm names a task from an earlier turn by its command (the wait row's title), not its id.
38
+ - Web: a canceled turn's end line (the Stop, Ctrl-C, a hook, the session's stop) reads `■ canceled (stopped)` in the
39
+ stopped calls' amber, live and after a reload, not as a red error; a worker that exited mid-turn stays red `✕`.
40
+ - Web stage: a call the Stop cut has an amber tick and an amber `■` in the trail, as its `■ STOPPED` row, instead of
41
+ its tool kind's colour.
42
+ - A call still running when its turn is canceled is saved in the session's tool records as `stopped` (was
43
+ `canceled`), the word its row uses; one cut by a failed turn stays `canceled`.
44
+ - A canceled turn's last step keeps the thinking it streamed, so reloading it shows the thought instead of an empty
45
+ step (both loops).
46
+ - A plain REPL (`--no-shared`) keeps its session's `pending_card.json` as a worker does, so `chi web` marks a session
47
+ waiting on an open card it showed.
48
+ - source-links: a ref that names its own repo (`other/repo#12`) takes `{host}` from the git remote only when the
49
+ source's `remote_host:` lists that host, so it can't link to the local checkout's host.
50
+ - Web stage: the running-tool row no longer reads `thinking…` once the turn has ended (answered, canceled or
51
+ failed): it is idle, with no word.
52
+ - The REPL and attached mode end a canceled turn with an amber `■` (`■ turn canceled (Ctrl-C)`, `■ turn stopped by
53
+ loop-guard`), as the web does, instead of `✕`: a stop someone chose, not an error; a failed turn keeps `✕`.
54
+ - Web stage: a call a guardrail blocked has a red tick and a red `✕` in the trail, as its `✕ BLOCKED` row, instead
55
+ of its tool kind's colour.
56
+ - A command step with more than one heredoc (`-f body="$(cat <<EOF …)" -f title="$(cat <<EOF …)"`) cuts every body
57
+ out of its text, and its chip says so: `EOF · 3 lines +1`, with all of them on hover.
58
+ - A session worker survives a failed save between turns too (its first prompt, a command, taken notes, a dropped
59
+ question): it logs it and keeps going; notes whose save failed are kept and saved on the next pass.
60
+
61
+ Update with `chi update` (source-links 0.3.4, mcp 0.4.4) and restart `chi web`.
62
+
11
63
  ## [0.29.0] - 2026-10-05
12
64
 
13
65
  ### Added
@@ -1500,7 +1552,8 @@ and long-lived sessions.
1500
1552
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1501
1553
  hotkey panel that send selected text or the clipboard to your sessions.
1502
1554
 
1503
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.29.0...HEAD
1555
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.30.0...HEAD
1556
+ [0.30.0]: https://github.com/dm1try/samagotchi/compare/v0.29.0...v0.30.0
1504
1557
  [0.29.0]: https://github.com/dm1try/samagotchi/compare/v0.28.0...v0.29.0
1505
1558
  [0.28.0]: https://github.com/dm1try/samagotchi/compare/v0.27.0...v0.28.0
1506
1559
  [0.27.0]: https://github.com/dm1try/samagotchi/compare/v0.26.0...v0.27.0
data/docs/cli.md CHANGED
@@ -849,12 +849,12 @@ While a turn runs, you can cancel it without exiting the process:
849
849
  Behavior notes:
850
850
 
851
851
  - Cancellation returns control to the prompt immediately; what you typed there stays.
852
- - The turn ends with one line, `✕ turn canceled (Ctrl-C) · 3.1s`, and for a prompt turn a dim
852
+ - The turn ends with one line, `■ turn canceled (Ctrl-C) · 3.1s` (`■`, a stop someone chose, in amber), and for a prompt turn a dim
853
853
  `partial progress kept; !rollback restores the pre-turn state` under it (a canceled continue is back where it
854
854
  started). A failed turn ends with `✕ turn failed: <summary> · 2.0s` and a dim `prompt restored for retry`. The
855
- REPL and attached mode say the same; the web says `✕ canceled (Ctrl-C)` (`stopped` for its Stop button,
856
- `by a hook` for a hook's). A turn a bundle's plugin stopped names it: `✕ turn stopped by loop-guard` (the web:
857
- `✕ stopped by loop-guard`).
855
+ REPL and attached mode say the same; the web says `■ canceled (Ctrl-C)` (`stopped` for its Stop button,
856
+ `by a hook` for a hook's). A turn a bundle's plugin stopped names it: `■ turn stopped by loop-guard` (the web:
857
+ `■ stopped by loop-guard`).
858
858
  - Visible text the canceled request had streamed stays in the conversation, marked `[interrupted]`, so the next
859
859
  message (or a continue) picks up from the half-finished reply; the canceled request's thinking and any unfinished
860
860
  tool call are dropped.
data/docs/hooks.md CHANGED
@@ -179,7 +179,7 @@ What a cut is, in detail:
179
179
  - It is an empty answer made early: it uses the `retry.empty_answer` budget
180
180
  ([configuration.md](configuration.md#llama-network-retry-behavior)). With
181
181
  no retry left (`retry.empty_answer: 0`, or already used) and nothing
182
- queued, the turn ends cancelled (hook): "✕ turn canceled (hook)".
182
+ queued, the turn ends cancelled (hook): "■ turn canceled (by a hook)".
183
183
  - It shows nothing by itself: post your own notice (`event[:notify]`) to say
184
184
  why. The bundle and the reason go into the note the model reads and the
185
185
  log (`generation_stopped`).
data/docs/plugins.md CHANGED
@@ -863,7 +863,7 @@ What happens (`action: retry`, the default):
863
863
  `loop-guard> thinking repeats itself ("Wait, the count of the letter r…", 3 sentences ×3, 4k chars, 8 s): cut`
864
864
  (a short run or a window says how many different sentences it held:
865
865
  "12 different sentences in 48"), and `↻ cut by loop-guard, asking again (1/1)`.
866
- 2. If the retry loops too, the turn is stopped ("stopped the turn: …", "✕
866
+ 2. If the retry loops too, the turn is stopped ("stopped the turn: …", "■
867
867
  turn stopped by loop-guard") with a card that quotes the repeated
868
868
  sentences.
869
869
  3. A loop is forgotten after `forget_after` good steps in a row (generations
data/docs/sessions.md CHANGED
@@ -168,7 +168,7 @@ chi send --wait -m "and the tests?" 3fa2 # a follow-up in the same
168
168
  chi send --wait 3fa2 # sends nothing: waits for its next reply
169
169
  ```
170
170
 
171
- - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one; the name isn't checked up front, a wrong one fails the worker's first turn).
171
+ - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one). A host config.yml doesn't have is refused before anything is spawned (exit 1). A `host:id` whose id that host's last saved list doesn't have (`~/.local/state/samagotchi/model_lists.json`, what `chi models` / `/models` / the web last listed for that host, at most a week old) gets one warning line on stderr, with up to three close ids as a hint (`did you mean: gemma-small?`), and the session starts anyway: some hosts serve ids they don't list (a one-model llama.cpp server takes any name). A bare id (no `host:`) isn't checked that way — routing sends it to whichever host lists it — and with no saved list for the host nothing is said; a wrong id then fails the worker's first turn.
172
172
  - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
173
173
  - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
174
174
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
@@ -179,7 +179,7 @@ chi send --wait 3fa2 # sends nothing: waits fo
179
179
 
180
180
  ## Delegating
181
181
 
182
- The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; nothing checks the host serves it, so an unknown one fails the child's first turn). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail like an `execute` result. Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (a `↳` chip on its card, `delegated by` in its info bar, listed right after its parent in the all-sessions view) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
182
+ The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail like an `execute` result. Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (a `↳` chip on its card, `delegated by` in its info bar, listed right after its parent in the all-sessions view) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
183
183
 
184
184
  - `delegate(task, model:, session:, wait:, timeout:)`: starts a child and, by default, waits for its reply (`wait: false` returns at once, so several children can run side by side; `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting). With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one.
185
185
  - `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.3
3
+ version: 0.4.4
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:079a42892f10822115235e1ced7ccd481f80c9ef54abb094f9a1c599bb03e8e2
10
+ sha256: sha256:05543038e2950a5391655184f57a1028133eda8ca05f9e46e5cd2eddc3e9e608
11
11
  requires_chi: ">= 0.8.0"
@@ -18,11 +18,12 @@
18
18
  # attach_image_paths: true # default: an image path in the text, in the
19
19
  # # temp dir or cwd, attaches it
20
20
  #
21
- # Image blocks in a result go to the model as images (text: "[image 1:
22
- # image/png, attached]"); so does an image file named in a text block,
21
+ # Image blocks in a result go to the model as images (the text keeps
22
+ # "[image 1: image/png]"); so does an image file named in a text block,
23
23
  # under the system temp dir or the server's cwd: the block is only its
24
24
  # absolute path (chrome-devtools-mcp --slim answers a screenshot that way),
25
- # or it says one ending in an image extension ("Saved it to /tmp/a.png.").
25
+ # or it says one ending in an image extension ("Saved it to /tmp/a.png."),
26
+ # noted as "[image 1: a.png]".
26
27
  # A server that doesn't start, answer or list its tools is skipped with a
27
28
  # notice; the rest of chi works. One that exits mid-session starts again on
28
29
  # its next call, at most MAX_RESTARTS times a session. /mcp lists the
@@ -40,8 +40,12 @@ require "open3"
40
40
  # A `url:` template takes {match} (group 1, else the whole ref), {1}…{9}
41
41
  # (numbered groups), {name} (named groups), and {repo}/{host}: the named
42
42
  # group when it took part, else the project's (Dir.pwd's) git remote, asked
43
- # of git once per worker. A ref with a placeholder that can't be filled is
44
- # not linked; a `{word}` that is none of these stays text (warned at load).
43
+ # of git once per worker. A {repo} the ref itself names ("other/repo#12")
44
+ # is that ref's own, so its {host} comes from the remote only when the
45
+ # source's `remote_host:` list has the remote's host: else the ref would
46
+ # link to this checkout's host with someone else's repo. A ref with a
47
+ # placeholder that can't be filled is not linked; a `{word}` that is none
48
+ # of these stays text (warned at load).
45
49
  #
46
50
  # The note is not stored in the conversation: it is an event, replayed by a
47
51
  # UI only while the session's worker lives (a reload keeps it; a stopped
@@ -457,7 +461,7 @@ class SourceLinks
457
461
  when /\A\d+\z/ then match[word.to_i]&.then { |value| escape_url(value) }
458
462
  when "repo", "host"
459
463
  value = match.names.include?(word) ? match[word] : nil
460
- value ||= remote_value(word, source)
464
+ value ||= remote_value(word, source, repo_from_match: match.names.include?("repo") ? match["repo"] : nil)
461
465
  word == "repo" ? escape_repo(value) : value&.then { |host| escape_url(host) }
462
466
  else match[word]&.then { |value| escape_url(value) }
463
467
  end
@@ -466,7 +470,16 @@ class SourceLinks
466
470
  # {repo} / {host} from the project's git remote (the source's `remote:`,
467
471
  # default origin); nil when there is none, or when its host is not one of
468
472
  # the source's `remote_host:` list.
469
- def remote_value(word, source)
473
+ #
474
+ # +repo_from_match+: the match's own {repo}, when it has one. Such a ref
475
+ # names its own repo (`other/repo#12`), not the local checkout's, so its
476
+ # {host} may only come from the remote when the source lists that remote's
477
+ # host (remote_host:): else the ref would link to the local remote's host
478
+ # with someone else's repo.
479
+ def remote_value(word, source, repo_from_match: nil)
480
+ derived_host = word == "host" && !repo_from_match.to_s.empty?
481
+ return nil if derived_host && source[:remote_hosts].empty?
482
+
470
483
  remote = project_remote(source[:remote])
471
484
  return nil unless remote
472
485
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.3.3
3
+ version: 0.3.4
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
9
  hooks:
10
10
  source_links.rb:
11
- sha256: sha256:454d3512a4aa078b288ff6d0ed25867222a56ead1a57ef1eaa14918e631fec23
11
+ sha256: sha256:c93269964ebc824b5f15ab60db81a458facd50de61446c550f254ea4c68964e2
12
12
  event: after_turn
13
13
  on_error: log
14
14
  priority: 90
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.29.0
3
+ version: 0.30.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -9,7 +9,9 @@ module Samagotchi
9
9
  # command as the model wrote it. Rules, in order:
10
10
  #
11
11
  # - a heredoc's body leaves its step's text and becomes its +heredoc+
12
- # ({tag:, lines:}), also one inside a "$(cat <<'EOF' … EOF)" argument;
12
+ # ({tag:, lines:}), also one inside a "$(cat <<'EOF' … EOF)" argument; a
13
+ # step with more than one is cut of them all, and lists them as
14
+ # +heredocs+ (+heredoc+ stays the first; the web's chip names it, +N);
13
15
  # - steps are split at && || ; | |& & and newlines; a ( … ) subshell stays
14
16
  # one step; +op+ is the operator before a step (nil for the first);
15
17
  # - a leading "cd X &&" / "cd X;" becomes +cd+ (only the first);
@@ -23,8 +25,8 @@ module Samagotchi
23
25
  # parentheses) gives nil: the UI then shows the title and the raw
24
26
  # command.
25
27
  module CommandSteps
26
- Step = Data.define(:text, :op, :label, :limit, :heredoc) do
27
- def initialize(text:, op: nil, label: nil, limit: nil, heredoc: nil) = super
28
+ Step = Data.define(:text, :op, :label, :limit, :heredoc, :heredocs) do
29
+ def initialize(text:, op: nil, label: nil, limit: nil, heredoc: nil, heredocs: nil) = super
28
30
 
29
31
  def to_h = super.compact
30
32
  end
@@ -122,29 +124,47 @@ module Samagotchi
122
124
 
123
125
  slice = text[from...to]
124
126
  heredoc = body && { tag: bodies[body.last], lines: body_lines(text[body.last]) }
125
- slice, heredoc = inner_heredoc(slice) unless heredoc
126
- Step.new(text: slice.strip, op: op, heredoc: heredoc)
127
+ slice, heredocs = inner_heredocs(slice) if heredoc.nil?
128
+ Step.new(text: slice.strip, op: op, heredoc: heredoc || heredocs&.first,
129
+ heredocs: heredocs && heredocs.size > 1 ? heredocs : nil)
127
130
  end
128
131
 
129
- # A heredoc inside a $(…) in +slice+ (git commit -m "$(cat <<'EOF' …)"):
130
- # the slice with the body cut out, and the heredoc; or the slice as is.
131
- def inner_heredoc(slice)
132
- return [slice, nil] unless slice.include?("<<")
132
+ # Every heredoc inside a $(…) in +slice+ (git commit -m "$(cat <<'EOF'
133
+ # …)"): the slice with all their bodies cut out, and the heredocs ([] for
134
+ # none). One step can hold several ("-f body=$(cat <<EOF …)" and
135
+ # "-f title=$(cat <<EOF …)"), and the rest of the step is text the reader
136
+ # would otherwise see.
137
+ def inner_heredocs(slice)
138
+ heredocs = []
139
+ return [slice, heredocs] unless slice.include?("<<")
133
140
 
134
141
  at = 0
135
142
  while (open = slice.index("$(", at))
136
143
  lexer = Lex::Lexer.new(slice[(open + 2)..])
137
144
  lexer.tokens
138
145
  doc = lexer.heredoc_spans.first
139
- if doc
140
- from = open + 2 + doc[:span].begin - 1 # the newline before the body
141
- to = open + 2 + doc[:span].end
142
- to += 1 if slice[to] == "\n"
143
- return [slice[0...from] + slice[to..], { tag: doc[:tag], lines: body_lines(slice[(from + 1)...to]) }]
146
+ unless doc
147
+ at = open + 2
148
+ next
144
149
  end
150
+
151
+ from = open + 2 + doc[:span].begin - 1 # the newline before the body
152
+ to = open + 2 + doc[:span].end
153
+ to += 1 if slice[to] == "\n"
154
+ heredocs << { tag: doc[:tag], lines: body_lines(slice[(from + 1)...to]) }
155
+ cut = slice[0...from] + slice[to..]
156
+ # A span that cut nothing (it can't) would loop here: move past it.
157
+ if cut.length >= slice.length
158
+ at = open + 2
159
+ next
160
+ end
161
+
162
+ slice = cut
163
+ # The cut moved everything after it left; keep looking from the same
164
+ # spot, past this "$(".
145
165
  at = open + 2
146
166
  end
147
- [slice, nil]
167
+ [slice, heredocs]
148
168
  end
149
169
 
150
170
  # The body's line count, its terminator line not counted.
@@ -5,6 +5,7 @@ require_relative "config"
5
5
  require_relative "log"
6
6
  require_relative "model_ref"
7
7
  require_relative "client"
8
+ require_relative "model_list_store"
8
9
  require_relative "llm/openai_chat"
9
10
 
10
11
  module Samagotchi
@@ -287,6 +288,9 @@ module Samagotchi
287
288
  models = list_models_for(entry)
288
289
  data = { host: entry.host, port: entry.port, transport: entry.transport, models: models, error: nil }
289
290
  @mutex.synchronize { @host_lists[name] = { data: data, at: @clock.call } }
291
+ # On disk too: a process that spawns a worker without listing
292
+ # (`chi send --new --model`, delegate) checks an id against it.
293
+ ModelListStore.save(name, models.map(&:id))
290
294
  rescue StandardError => e
291
295
  Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
292
296
  data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }
@@ -197,7 +197,8 @@ module Samagotchi
197
197
  emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason,
198
198
  stopped_by: turn.cancel_controller&.stopped_by)
199
199
  return cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: e.reason,
200
- partial_assistant_text: turn.buffer)
200
+ partial_assistant_text: turn.buffer,
201
+ streamed_thinking: turn.streamed_thinking)
201
202
  end
202
203
  finish(turn)
203
204
  rescue StandardError => e
@@ -239,8 +240,8 @@ module Samagotchi
239
240
  # context tracker and the retry budget, the tool activity, the visible
240
241
  # text streamed so far (salvaged on a cancel), the Qwen recovery state,
241
242
  # the prefill, and what the caller gave.
242
- Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :qwen_attempts, :qwen_partial,
243
- :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
243
+ Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :streamed_thinking, :qwen_attempts,
244
+ :qwen_partial, :prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
244
245
  :cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, keyword_init: true)
245
246
  # One request: the prompt and its images as sent, the images' token
246
247
  # estimate, and the window it was measured against.
@@ -258,7 +259,8 @@ module Samagotchi
258
259
  conversation = prepare_conversation(messages)
259
260
  Turn.new(
260
261
  conversation: conversation, context: ContextStatus.new(conversation: conversation),
261
- empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", qwen_attempts: 0, qwen_partial: nil,
262
+ empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
263
+ qwen_attempts: 0, qwen_partial: nil,
262
264
  # Qwen with thinking off: an empty thought after the cue, so the model
263
265
  # answers at once. Kept in the turn's model messages, so each tool-loop
264
266
  # prompt starts with what the server already has cached.
@@ -329,6 +331,9 @@ module Samagotchi
329
331
  generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
330
332
  fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
331
333
  buffer_mark = turn.buffer.length
334
+ # A cancel salvages only this generation's thinking: the earlier
335
+ # steps' is on their own messages already.
336
+ turn.streamed_thinking = +""
332
337
  generation.response = with_generation(turn.cancel_controller) do |generation_controller|
333
338
  request_generation(
334
339
  request.prompt,
@@ -342,6 +347,7 @@ module Samagotchi
342
347
  generation.usage = nil
343
348
  generation.cache = nil
344
349
  generation.streamed_thinking = 0
350
+ turn.streamed_thinking = +""
345
351
  generation.finish_reason = nil
346
352
  turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
347
353
  },
@@ -368,6 +374,7 @@ module Samagotchi
368
374
  generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
369
375
  split = stream_splitter.feed(chunk[:content])
370
376
  turn.buffer << split[:text]
377
+ turn.streamed_thinking << split[:thinking].to_s
371
378
  generation.streamed_thinking += split[:thinking].to_s.length
372
379
  return unless turn.on_stream_event
373
380
 
@@ -625,7 +632,7 @@ module Samagotchi
625
632
  ModelProfile.required_model_name(override)
626
633
  end
627
634
 
628
- def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "")
635
+ def cancelled_result(conversation, tool_activity:, reason:, partial_assistant_text: "", streamed_thinking: "")
629
636
  partial = partial_assistant_text.to_s.strip
630
637
  conversation = duplicate_conversation(conversation)
631
638
  # Salvage the already-streamed visible reply (thought/tool_call lanes
@@ -633,7 +640,13 @@ module Samagotchi
633
640
  # cannot leak) so a follow-up steering message continues with the model's
634
641
  # half-finished work in context instead of losing it.
635
642
  unless partial.empty?
636
- conversation << { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
643
+ message = { role: "model", content: "#{partial}\n[interrupted]", interrupted: true }
644
+ # The thinking this generation streamed goes with it (the chat loop's
645
+ # with_thinking key, which MessageParts reads and the prompt doesn't):
646
+ # without it, reloading the cut step shows the empty answer it isn't.
647
+ thinking = streamed_thinking.to_s
648
+ message[:thinking] = thinking unless thinking.strip.empty?
649
+ conversation << message
637
650
  end
638
651
  LLM::ModelResult.new(
639
652
  text: "",
@@ -338,8 +338,8 @@ module Samagotchi
338
338
  return canceled(iteration, @cancel_controller.reason) if @cancel_controller&.cancelled?
339
339
 
340
340
  inject_pending_input(iteration)
341
- response, partial = generate(iteration)
342
- return canceled(iteration, response, partial) if partial
341
+ response, partial, thought = generate(iteration)
342
+ return canceled(iteration, response, partial, thought) if partial
343
343
 
344
344
  if response.cut
345
345
  outcome = after_cut(iteration, response)
@@ -468,7 +468,7 @@ module Samagotchi
468
468
  messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
469
469
  [response, nil]
470
470
  rescue RequestCancelled => e
471
- [e.reason, streamed]
471
+ [e.reason, streamed, thought]
472
472
  end
473
473
 
474
474
  # The request runs under the generation's own controller (the turn
@@ -593,11 +593,17 @@ module Samagotchi
593
593
  end
594
594
 
595
595
  # The text streamed before the cancel stays, marked [interrupted],
596
- # as the native loop's salvage does.
597
- def canceled(iteration, reason, partial = "")
596
+ # as the native loop's salvage does; the reasoning this generation
597
+ # streamed rides along on the message (with_thinking's key).
598
+ def canceled(iteration, reason, partial = "", thinking = "")
598
599
  emit(type: :generation_cancelled, iteration: iteration, reason: reason, stopped_by: @cancel_controller&.stopped_by)
599
600
  visible = @loop.strip_model_thought(partial.to_s).strip
600
- @conversation << { role: "model", content: "#{visible}\n[interrupted]", interrupted: true } unless visible.empty?
601
+ unless visible.empty?
602
+ message = { role: "model", content: "#{visible}\n[interrupted]", interrupted: true }
603
+ thought = thinking.to_s
604
+ message[:thinking] = thought unless thought.strip.empty?
605
+ @conversation << message
606
+ end
601
607
  conversation = @loop.plain(@conversation)
602
608
  conversation.last[:interrupted] = true unless visible.empty?
603
609
  ModelResult.new(text: "", conversation: conversation, canceled: true,
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ require_relative "atomic_file"
7
+ require_relative "log"
8
+ require_relative "paths"
9
+
10
+ module Samagotchi
11
+ # The model ids each host last listed, saved in
12
+ # <state dir>/model_lists.json for the processes that spawn a worker but
13
+ # never list a host themselves: `chi send --new --model` and the
14
+ # `delegate` tool warn about an unknown model id up front from it
15
+ # (ModelProfile.model_warning) instead of only the worker's first turn
16
+ # failing. It is a cache of the hosts' own lists, so it never raises: no
17
+ # file, an unreadable one or bad JSON reads as no list at all. Only a
18
+ # successful listing writes (a host that is down keeps the ids we know),
19
+ # and a list older than TTL_SECONDS is no evidence about the host any
20
+ # more.
21
+ #
22
+ # { "<host name>" => { "ids" => ["…"], "at" => <epoch seconds> } }
23
+ module ModelListStore
24
+ FILE = "model_lists.json"
25
+ # When a saved list is too old to say what a host serves: a model may
26
+ # have been added or removed since.
27
+ TTL_SECONDS = 7 * 24 * 60 * 60
28
+
29
+ # One host's saved list: the ids it listed and when (+at+, epoch
30
+ # seconds).
31
+ Saved = Data.define(:host, :ids, :at) do
32
+ def age(now: Time.now.to_i) = now.to_i - at.to_i
33
+
34
+ # Too old to check an id against (ModelProfile.model_warning): a model
35
+ # may have been added or removed since.
36
+ def stale?(now: Time.now.to_i) = age(now: now) > ModelListStore::TTL_SECONDS
37
+
38
+ # A host's own list spells its ids, and resolution matches them by
39
+ # case (the registry's model index is downcased): so does this.
40
+ def known?(id) = ids.any? { |known| known.casecmp?(id.to_s.strip) }
41
+ end
42
+
43
+ module_function
44
+
45
+ # @return [String] <state home>/samagotchi/model_lists.json
46
+ def path(env: ENV)
47
+ File.join(Paths.state_dir(env: env), FILE)
48
+ end
49
+
50
+ # Every saved list by host name, the lists as [Saved] (empty without a
51
+ # file, with bad JSON, or with nothing readable in it).
52
+ # @return [Hash{String => Saved}]
53
+ def read(env: ENV)
54
+ all(env: env).each_with_object({}) do |(name, entry), acc|
55
+ list = saved_from(name, entry)
56
+ acc[name] = list if list
57
+ end
58
+ end
59
+
60
+ # One host's saved list.
61
+ # @return [Saved, nil] nil without a file, with bad JSON, or with no
62
+ # entry for the host
63
+ def find(host, env: ENV)
64
+ read(env: env)[host.to_s.strip.downcase]
65
+ end
66
+
67
+ # Save the ids +host+ just listed, keeping every other host's entry.
68
+ # Best effort: a store that can't be written never fails the listing
69
+ # that found the ids. An empty list is not saved (it is no evidence the
70
+ # host serves nothing: keep the ids we know).
71
+ # @return [Saved, nil] what was saved, nil when nothing was
72
+ def save(host, ids, at: Time.now.to_i, env: ENV)
73
+ name = host.to_s.strip.downcase
74
+ list = Array(ids).map { |id| id.to_s.strip }.reject(&:empty?).uniq
75
+ return nil if name.empty? || list.empty?
76
+
77
+ file = path(env: env)
78
+ FileUtils.mkdir_p(File.dirname(file))
79
+ # Under the lock, so two workers listing a host at once (or two
80
+ # threads of one registry) never drop each other's entries.
81
+ File.open("#{file}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
82
+ lock.flock(File::LOCK_EX)
83
+ entries = all(env: env)
84
+ entries[name] = { "ids" => list, "at" => at.to_i }
85
+ AtomicFile.write(file, JSON.pretty_generate(entries) + "\n")
86
+ end
87
+ Saved.new(host: name, ids: list, at: at.to_i)
88
+ rescue StandardError => e
89
+ Log.warn(:model, "model_list_save_failed", host: host.to_s, error: e.class.name, msg: e.message.to_s[0, 200])
90
+ nil
91
+ end
92
+
93
+ # The whole file, host names downcased: {name => entry}.
94
+ # @return [Hash]
95
+ def all(env: ENV)
96
+ data = JSON.parse(File.read(path(env: env)))
97
+ return {} unless data.is_a?(Hash)
98
+
99
+ data.each_with_object({}) do |(name, entry), acc|
100
+ acc[name.to_s.strip.downcase] = entry if entry.is_a?(Hash)
101
+ end
102
+ rescue StandardError
103
+ {}
104
+ end
105
+
106
+ # One +entry+ of the file as a Saved, or nil when it holds no ids or no
107
+ # time.
108
+ def saved_from(name, entry)
109
+ ids = Array(entry["ids"]).map { |id| id.to_s.strip }.reject(&:empty?)
110
+ at = entry["at"].to_i
111
+ return nil if ids.empty? || at <= 0
112
+
113
+ Saved.new(host: name, ids: ids, at: at)
114
+ end
115
+ private_class_method :saved_from
116
+ end
117
+ end
@@ -179,6 +179,49 @@ module Samagotchi
179
179
  raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(", ")}"
180
180
  end
181
181
 
182
+ # A warning when +model_name+ resolves to a model id a host's saved
183
+ # list doesn't have (ModelListStore: what a `chi models`, a /models or
184
+ # the web's GET /api/models last listed for that host), else nil.
185
+ # Called where a session is spawned without listing the hosts itself
186
+ # (`chi send --new --model`, the delegate tool), so a typo is
187
+ # named before the worker's first turn fails on it. Only a warning: some
188
+ # hosts serve ids they don't list (a one-model llama.cpp server takes any
189
+ # name, OpenRouter's :nitro variants), so the session starts anyway.
190
+ # Never asks a host.
191
+ #
192
+ # Only a ref that names a host is checked (its alias resolved first, as
193
+ # ModelRef.parse does), and only against that host's own list: a bare id
194
+ # goes to whichever host lists it (HostRegistry#host_for_model), so no
195
+ # single list can judge it. Nothing is checked either without a saved
196
+ # list for the host or with one older than ModelListStore::TTL_SECONDS
197
+ # (a week: the host may serve different models by now).
198
+ # @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
199
+ # entries); config.yml's by default
200
+ # @param lists [#read] ModelListStore by default
201
+ # @return [String, nil]
202
+ def self.model_warning(model_name, env: ENV, hosts: nil, lists: nil)
203
+ require_relative "config"
204
+ require_relative "model_list_store"
205
+ hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
206
+ lists ||= Samagotchi::ModelListStore
207
+ parsed = Samagotchi::ConfigFile.model_ref(model_name, env: env, hosts: hosts)
208
+ host = parsed.host_name
209
+ return nil unless host
210
+
211
+ list = lists.read(env: env)[host.to_s.strip.downcase]
212
+ return nil if list.nil? || list.stale? || list.known?(parsed.id)
213
+
214
+ unknown_model_message(parsed.id, host, list.ids)
215
+ end
216
+
217
+ # The warning for an id the host doesn't list, with up to three close
218
+ # ids (Config.near_names) when there are any.
219
+ def self.unknown_model_message(id, host, ids)
220
+ near = Samagotchi::Config.near_names(id, ids).first(3)
221
+ hint = near.empty? ? "" : " (did you mean: #{near.join(", ")}?)"
222
+ "host '#{host}' doesn't list model '#{id}'#{hint}; started it anyway; `chi models` lists what the hosts serve"
223
+ end
224
+
182
225
  # The prefix of "box:x" when box is a host config.yml has with
183
226
  # enabled: false (left out of +hosts+), else nil.
184
227
  def self.disabled_host_prefix(ref, hosts, env)