samagotchi 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/docs/cli.md +21 -7
  4. data/docs/configuration.md +11 -9
  5. data/docs/hooks.md +3 -2
  6. data/docs/internals/engine.md +4 -2
  7. data/docs/sessions.md +5 -4
  8. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  9. data/lib/samagotchi/bridge.rb +24 -9
  10. data/lib/samagotchi/bridge_client.rb +3 -1
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/delivery.rb +33 -0
  13. data/lib/samagotchi/host_registry.rb +16 -3
  14. data/lib/samagotchi/kernel_loop.rb +28 -2
  15. data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -2
  16. data/lib/samagotchi/send_command.rb +63 -10
  17. data/lib/samagotchi/session_commands.rb +9 -0
  18. data/lib/samagotchi/session_inbox.rb +27 -7
  19. data/lib/samagotchi/session_manager.rb +13 -3
  20. data/lib/samagotchi/session_metrics.rb +25 -6
  21. data/lib/samagotchi/sessions_command.rb +1 -1
  22. data/lib/samagotchi/steer_cut.rb +12 -8
  23. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -1
  24. data/lib/samagotchi/terminal_ui/formatting.rb +51 -4
  25. data/lib/samagotchi/terminal_ui.rb +48 -5
  26. data/lib/samagotchi/tool_activity.rb +3 -0
  27. data/lib/samagotchi/tool_runner.rb +2 -0
  28. data/lib/samagotchi/tools/builtin_calls.rb +33 -0
  29. data/lib/samagotchi/version.rb +1 -1
  30. data/lib/samagotchi/web/app.rb +6 -2
  31. data/lib/samagotchi/web/message_parts.rb +3 -0
  32. data/lib/samagotchi/web/public/activity.js +6 -0
  33. data/lib/samagotchi/web/public/app.js +66 -28
  34. data/lib/samagotchi/web/public/index.html +17 -15
  35. data/lib/samagotchi/web/public/session_title.js +26 -0
  36. data/lib/samagotchi/web/public/strip.js +4 -6
  37. data/lib/samagotchi/web/public/turn_events.js +3 -2
  38. data/lib/samagotchi/web/public/turn_html.js +3 -1
  39. data/lib/samagotchi/worker.rb +1 -1
  40. data/lib/samagotchi/worker_inbound.rb +8 -5
  41. data/lib/samagotchi.rb +1 -0
  42. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a34348a50eeb261495c985e51195fa87b9f04121e56852083b014229bf05323a
4
- data.tar.gz: 4bc36375cf4eb7bfba24212c080b86759026c29e17bae2adae653f0d2f7e88f2
3
+ metadata.gz: f93eddaef9e07210789382afa552868d94a39b0bca798bb7c0c5a8d4e23cc693
4
+ data.tar.gz: 61f1005b2dd4efcdfba5379622193e79c7bfd305a4eac651827830a54399d1c9
5
5
  SHA512:
6
- metadata.gz: 68eea3684c38f7ca72ab13898f461673e1ddb817dce4a6f9dc250ba41230e9e526601ed8c632b58cdba2dce5b5f6fbdb5559da4a6ffa5971680096d36e6973b5
7
- data.tar.gz: ecc35d61cb8b8560e688fcda868c91135c2f1a1662f649f5a21105c912614a2211d240ea39d3f15bb56500d52b98743decd2c0d5a13eab4f43ba68962076bd1b
6
+ metadata.gz: d6ea69e71620afeca9b1813fef569c9f39742d1dbf0d28ca9aa2675c0445ef5b397bdd44fd3b6d544dc3ba07a131771136e7d2163669d13925a0d952b69df175
7
+ data.tar.gz: 9abd2290def4fd2bc5b12e9ea0c5fe722c2b4a8ead03044a8944108a43e3105325fc7bf854e5dd5bba3641fb1e0c0c2047835aa4e5ddeef91013ccf115244a0c
data/CHANGELOG.md CHANGED
@@ -8,6 +8,48 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.51.0] - 2026-10-10
12
+
13
+ ### Added
14
+
15
+ - `/cut TEXT` in a terminal (plain REPL and attached) and `chi send --cut` ask the running turn for a cut: a
16
+ generation that has streamed only thinking for `steer.cut_after` seconds is cut (now, or once its thinking passes
17
+ that) and the step starts again with the message. The terminal's dim line and `chi send`'s line say what happened
18
+ (`cut in now`, `cuts in once the thinking passes 20 s`, `cutting is off; goes in at the next step`); `--format json`
19
+ adds `delivery` and `cut`. The Bridge's 202 for a turn names its `delivery` and, for a cut, the `cut` outcome.
20
+ - `/queue TEXT` in the plain REPL runs TEXT as a turn of its own after the running one.
21
+ - A tool call named `bash` (any case) with `execute`'s arguments runs as `execute`; the logs keep the model's
22
+ own spelling, and the tool row says "(called as bash)" (TUI and web, live and after a reload). `/stats` and
23
+ `chi sessions stats` count such calls by that spelling (`called as: bash=N`, `tool_calls_aliased` in JSON).
24
+
25
+ ### Changed
26
+
27
+ - A message sent during a running turn (typed in a terminal, from the web, `chi send`, a parent agent) goes in at the
28
+ turn's next step and no longer cuts the model's thinking; only a message sent as a cut does. A line typed mid-turn
29
+ says `(goes in at the next step)`, and `chi send` says `sent (goes in at the running turn's next step)`.
30
+ - A call for a tool that doesn't exist no longer gets its own wrong name quoted back (seeing it again made the
31
+ model call it again): shell-like names (`bash`, `shell`, …) get `Error: no such tool. Shell commands run with
32
+ execute (same arguments).` when `execute` is offered this turn, every other name the available-tools list; the
33
+ UI's tool row and the logs still show what the model called.
34
+ - Web: the open session's title shows once: in its strip card, or in the top bar when the strip is hidden or the
35
+ session has no card there (an older session, a folded delegate). The composer footer's first-message line and the
36
+ banner that appeared at 20 % context are gone; the memory names stay in the footer's `mem N` chip and tooltip.
37
+ - Web: the browser tab names the open session (`<first message> · Chi · <project>`), so several chi tabs read apart.
38
+ - Web: the hidden strip's "▾ N sessions" pill counts sessions, the number the "All sessions →" tile shows (it
39
+ counted cards, so a folded family made the two disagree).
40
+ - Web: the composer's "Cancel" reads "Stop turn" and the footer's "stop" reads "stop session", each saying what it
41
+ ends.
42
+ - Web: a toast (the update notice, an archive's Undo) sits above the open session's composer card instead of over
43
+ its footer, so it never covers the footer's controls (a worker badge's Restart); it follows the card as it grows.
44
+
45
+ ### Fixed
46
+
47
+ - A bare model id, or an alias with a bare target (`splash: incoai/Qwen3.8-27B-Splash`), in a process that hasn't
48
+ listed the hosts yet (`chi --model splash`, `chi send --new --model splash`, a delegate's worker) goes to the host
49
+ whose saved list (`model_lists.json`, at most a week old) has it, instead of always the default host.
50
+ - `chi sessions stats ID --format json` for a session no worker runs lists its newest saved turn (and that turn's
51
+ tool calls) in `turn_records` / `tool_records`, as a live worker's snapshot does, instead of empty lists.
52
+
11
53
  ## [0.50.0] - 2026-10-09
12
54
 
13
55
  ### Added
@@ -2301,7 +2343,8 @@ and long-lived sessions.
2301
2343
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2302
2344
  hotkey panel that send selected text or the clipboard to your sessions.
2303
2345
 
2304
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.50.0...HEAD
2346
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.51.0...HEAD
2347
+ [0.51.0]: https://github.com/dm1try/samagotchi/compare/v0.50.0...v0.51.0
2305
2348
  [0.50.0]: https://github.com/dm1try/samagotchi/compare/v0.49.0...v0.50.0
2306
2349
  [0.49.0]: https://github.com/dm1try/samagotchi/compare/v0.48.0...v0.49.0
2307
2350
  [0.48.0]: https://github.com/dm1try/samagotchi/compare/v0.47.0...v0.48.0
data/docs/cli.md CHANGED
@@ -18,7 +18,7 @@
18
18
  - `chi sessions list|stop|restart|archive|unarchive|delete|prune|clean|stats` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md)). `stats ID` prints a session's cost, tokens and progress (a live worker's `GET stats`, else its saved `analytics.json`; `--format json` one object) without running a model turn. A usage error (an unknown subcommand or flag, a flag missing its value, `stop`, `restart` or `delete` with no ids, a bad `list --format` or `--scope`) exits 2; an unknown or refused session exits 1
19
19
  - `chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all` — add a context note (TEXT or stdin) to sessions: background the model sees on its next turn; it starts no turn (see [Sessions: Context notes](sessions.md#context-notes))
20
20
  - `chi context <add|push|ls|show|refresh|rm|mute|unmute> …` — attach live external text to sessions (a command chi runs every so often, or text pushed in); the model gets a short note when it changes and reads it with `context_read`; `chi context add <URL>` attaches a GitHub PR with the github-pr bundle (see [Attached context](context.md))
21
- - `chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one picks it up); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending; `--format json` prints one JSON object instead; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
21
+ - `chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending; `--format json` prints one JSON object instead; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
22
22
  - `chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]` (or `--dismiss`) — answer the question a session waits on (the one `chi send --wait` exited 3 with), then wait and print what comes next as `chi send --wait` does: the reply (0), the next question (3), still running after `--timeout` (4). `--option` is 1-based or the label, repeated on a multi-select question; `--text` is free text, or a Deny's reason; `--dismiss` leaves it unanswered and the model finishes its reply. A question no longer open (the web answered first) isn't answered again: it waits for that turn's reply. An option the question doesn't offer exits 2; a worker that is gone exits 1 (`send the task again: chi send --wait -m "…" ID`). An approval can be denied, not allowed, unless `guardrails.parent_approvals: once` (a convention, not a security boundary; see [Guardrails](guardrails.md#approvals-from-a-parent-agent))
23
23
  - `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text or images to a session (live, stopped or new) as a message (⏎, a turn runs) or a context note (⌘⏎) (see [Desktop helper](desktop.md))
24
24
  - `chi models [--format text|json] [--timeout S] [TEXT]` — list the models every configured host offers, as the names `--model` takes (see [Listing the models](#listing-the-models))
@@ -289,11 +289,13 @@ to it (`session.shared`, default `true`). A worker's session can have any number
289
289
  of UIs at once: the Web UI and attached terminals (`chi`, `--resume`,
290
290
  `--attach`). They all see the same turns as they happen, and any of them can send
291
291
  a prompt, also while a turn runs (it merges into that turn as steering: the model
292
- is told it is a steer and who sent it). Steering waits for the model's next step,
293
- unless the model has streamed only thinking for `steer.cut_after` seconds (default
294
- 20), now or while your message waits: then that generation is cut and the step starts again with your message, marked
292
+ is told it is a steer and who sent it). Steering waits for the model's next step
293
+ and cuts nothing. `/cut TEXT` in a terminal (or `chi send --cut`) asks for a cut:
294
+ once the model has streamed only thinking for `steer.cut_after` seconds (default
295
+ 20), now or while your message waits, that generation is cut and the step starts again with your message, marked
295
296
  `↪ cut in for your message` (see
296
- [configuration.md](configuration.md#llama-network-retry-behavior)). The
297
+ [configuration.md](configuration.md#llama-network-retry-behavior)). The web
298
+ composer doesn't cut yet. The
297
299
  first answer to an `ask_user_question` wins; the other UIs close their widget.
298
300
  An empty answer dismisses the question in every UI. The model is then told
299
301
  not to go ahead with what it asked about, or change anything else, and to wait.
@@ -487,11 +489,23 @@ The prompt stays open while a turn runs, in an attached terminal and in the plai
487
489
  REPL alike:
488
490
 
489
491
  - A line you submit merges into the running turn at its next step (after the
490
- current tool call or answer), and `(1 message merged into the running turn)`
491
- says so. An answer the model finished just before the merge is printed first.
492
+ current tool call or answer): `(goes in at the next step)` at once, then
493
+ `(1 message merged into the running turn)` when it does. It never cuts the
494
+ model's thinking. An answer the model finished just before the merge is printed first.
492
495
  A line that comes after the turn's last step runs as the next turn, and so
493
496
  does one sent after Ctrl-C: it doesn't merge into the turn being cancelled.
494
497
  Reminder turns take merged lines too.
498
+ - `/cut TEXT` asks for a cut: a generation that has streamed only thinking for
499
+ `steer.cut_after` seconds is cut and the step starts again with TEXT. The dim
500
+ line says what happened: `(cut in now)`, `(cuts in once the thinking passes
501
+ 20 s)` (it waits for the thinking; the step may end first) or `(cutting is
502
+ off; goes in at the next step)` (`steer.cut_after: 0`, or no generation to
503
+ cut).
504
+ - `/queue TEXT` (plain REPL only) runs TEXT as a turn of its own after this
505
+ one: `(queued: runs after this turn)`. An attached terminal says
506
+ `(/queue isn't available in an attached session yet)` and sends nothing.
507
+ At the open prompt (no turn running) `/cut TEXT` and `/queue TEXT` run TEXT
508
+ as a normal prompt.
495
509
  - Commands answer, wait or are refused, by what they do:
496
510
  - At once, beside the turn: `/stats`, `/recap`, `/help`, `/context`, a
497
511
  plugin's anytime command (`/btw`), and the commands that only show
@@ -594,8 +594,10 @@ to the default host. A host's declared ids (`hosts.<name>.models`, below) count
594
594
  as listed. Only exact ids count, never a substring: `/model gemma` on
595
595
  a box that lists `gemma-4-26b` needs `box:gemma` or the exact id. The lists are
596
596
  known only after `/models` ran (nothing is fetched before the first turn), so
597
- until then an unqualified name goes to the default host, unless a host declares
598
- it; use `host:model` to pin one. A **remote** host (an `https` url or a public address; see "Remote or local") keeps its model
597
+ until then an unqualified name (or an alias's unqualified target) goes by the
598
+ lists last saved on disk (`model_lists.json`, what `chi models`, `/models` or the
599
+ web last listed, at most a week old) the same way, else to the default host,
600
+ unless a host declares it; use `host:model` to pin one. A **remote** host (an `https` url or a public address; see "Remote or local") keeps its model
599
601
  list for 10 minutes (60s for local hosts).
600
602
 
601
603
  **The context window** (what the context status and `ctx=` count against) comes from,
@@ -763,12 +765,12 @@ no made-up answer is saved.
763
765
  A generation a plugin cuts while it streams (loop-guard's thinking watch, or any `stop_generation`,
764
766
  [hooks.md](hooks.md#watching-the-stream)) uses the same budget: `↻ cut by loop-guard, asking again (1/1)`, and with
765
767
  none left the turn ends as cancelled (hook).
766
- A message for a running turn waits for the next step, unless the model's generation has streamed only thinking for
767
- `steer.cut_after` seconds (default `20`, env `SAMAGOTCHI_STEER_CUT_AFTER`, `0` = never; counted from its first
768
- thinking token, so the wait for a busy server doesn't count) and still does: then a message from you (the terminal,
769
- the web), `chi send` or a parent agent cuts that generation, and the model starts the step again with the message
770
- (`↪ cut in for your message`). A message that comes earlier cuts once the thinking passes `steer.cut_after`, unless
771
- the step ends first and the message goes in there. Such a cut spends no `retry.empty_answer` attempt and sends no
768
+ A message for a running turn goes in at its next step and cuts nothing. A message sent as a cut (`/cut TEXT` in a
769
+ terminal, `chi send --cut`) cuts the model's generation once it has streamed only thinking for `steer.cut_after`
770
+ seconds (default `20`, env `SAMAGOTCHI_STEER_CUT_AFTER`, `0` = never; counted from its first thinking token, so the
771
+ wait for a busy server doesn't count) and still does, and the model starts the step again with the message
772
+ (`↪ cut in for your message`). One that comes earlier cuts once the thinking passes `steer.cut_after`, unless the
773
+ step ends first and the message goes in there. The web composer and a parent agent never ask for a cut. Such a cut spends no `retry.empty_answer` attempt and sends no
772
774
  hidden note; only the cut thinking is lost. A generation that has streamed visible text or a tool call is never cut,
773
775
  and a plugin's steer (`ctx.steer`, `ctx.sessions.send`) never cuts.
774
776
 
@@ -1144,7 +1146,7 @@ described in their own sections.
1144
1146
  | `retry.base_delay` | `0.5` | yes | |
1145
1147
  | `retry.max_delay` | `8.0` | yes | |
1146
1148
  | `retry.empty_answer` | `1` | | Times a turn asks again after an empty answer (at most 3, `0` = off). See "Llama Network Retry Behavior". |
1147
- | `steer.cut_after` | `20` | | Seconds a generation must have streamed only thinking before a message for the running turn (yours, `chi send`'s, a parent agent's) cuts it; `0` = never. See "Llama Network Retry Behavior". |
1149
+ | `steer.cut_after` | `20` | | Seconds a generation must have streamed only thinking before a message sent as a cut (`/cut TEXT`, `chi send --cut`) cuts it; other messages go in at the next step; `0` = never. See "Llama Network Retry Behavior". |
1148
1150
  | `turn.max_iterations` | `100` | | A turn's step limit: model ↔ tool rounds before it stops and asks to continue (an integer ≥ 1). `--no-interrupt` turns get the larger of 1000 and this. See [CLI: Iteration Limit Behavior](cli.md#iteration-limit-behavior). |
1149
1151
  | `turn.parent_continue` | `true` | | Whether a parent agent (`chi answer`) may answer Continue to a session's step-limit question; `false`: Stop only. config.yml only: no environment variable. See [Sub-agent](sub-agent.md) and [Guardrails](guardrails.md#approvals-from-a-parent-agent). |
1150
1152
  | `update.gem` | `true` | | `false`: `chi update` never installs a newer gem (`--no-gem` for one run). See [CLI: Updating](cli.md#updating). |
data/docs/hooks.md CHANGED
@@ -220,8 +220,9 @@ What a cut is, in detail:
220
220
  chunk). A client that joins the running turn still sees its thinking in
221
221
  the turn so far until the turn ends.
222
222
  - A Stop from the user right after a cut is a plain cancel.
223
- - A cut for a message (a user's, `chi send`'s or a parent agent's line into
224
- a generation that has streamed only thinking for `steer.cut_after` seconds)
223
+ - A cut for a message (one sent as a cut, `/cut TEXT` or `chi send --cut`,
224
+ into a generation that has streamed only thinking for `steer.cut_after`
225
+ seconds; other messages go in at the next step and never cut)
225
226
  is not a plugin's cut: it sends no hidden note and spends no retry; the
226
227
  message goes in and the UIs print `↪ cut in for your message`. The
227
228
  stream's `generation_completed` says `stopped_by: "steer"`, and
@@ -203,7 +203,7 @@ One iteration with tool calls:
203
203
  title, view}.
204
204
  - Settles an ask verdict after `:tool_call_started`, so a UI shows the tool line and then the approval.
205
205
  - Dispatches through `KernelLoop#dispatch_tool_call`, or writes the denial text. Unknown tools are answered in
206
- `KernelLoop#dispatch` ("Error: unknown tool ...").
206
+ `KernelLoop#dispatch` ("Error: no such tool ...", never repeating the wrong name).
207
207
  - For edit and write: diffs the file before and after, and refreshes a memory's index line
208
208
  (`MemoryBundle::IndexSync`).
209
209
  - Attaches returned images, at most `MAX_IMAGES_PER_RESULT` (4) per result.
@@ -262,7 +262,9 @@ end
262
262
 
263
263
  ### GenerationPhase
264
264
 
265
- What the running generation streams, fed by the Engine's stream handler: whether a user's steer may cut it.
265
+ What the running generation streams, fed by the Engine's stream handler: whether a steer sent as a cut (delivery
266
+ `cut`: `/cut TEXT`, `chi send --cut`) may cut it. `SteerCut#cut_for_steer` answers `:now`, `:waits` or `:off`; the
267
+ Bridge acks it as `cut`.
266
268
  A generation is cuttable while it has streamed only thinking, is still thinking, and has thought for at least
267
269
  `steer.cut_after` seconds (`cuttable?(min_age)`). It has `started!`, `chunk!`, `retrying!` and `finished!`.
268
270
 
data/docs/sessions.md CHANGED
@@ -43,7 +43,7 @@ chi sessions clean [--dry-run] [--days N] # test sessions: all, or o
43
43
  chi sessions stats ID [--format text|json] # cost, tokens and progress, without a model turn
44
44
  ```
45
45
 
46
- `chi sessions stats ID` prints what `/stats` prints in a terminal, headed by `<id8> <status> <model>`: a live worker answers its Bridge's `GET stats` (no model call, and the command never starts a worker); without one, or when the worker does not answer in time, the same snapshot is rebuilt from the session's saved `analytics.json` (and the line says so when a worker was live). `--format json` prints one object `{session_id, status, live, metrics}` with the snapshot as `metrics`; a session with no `analytics.json` yet reports `metrics: null` (`(no metrics yet)` in text) and still exits 0. An unknown or ambiguous id exits 1 with the same wording `chi sessions stop` uses.
46
+ `chi sessions stats ID` prints what `/stats` prints in a terminal, headed by `<id8> <status> <model>`: a live worker answers its Bridge's `GET stats` (no model call, and the command never starts a worker); without one, or when the worker does not answer in time, the same snapshot is rebuilt from the session's saved `analytics.json` (and the line says so when a worker was live). `--format json` prints one object `{session_id, status, live, metrics}` with the snapshot as `metrics` (its `turn_records` / `tool_records` are the recent ones only: a live worker's newest finished turn and any not yet saved, the disk snapshot's newest saved turn; `analytics.json` holds them all); a session with no `analytics.json` yet reports `metrics: null` (`(no metrics yet)` in text) and still exits 0. An unknown or ambiguous id exits 1 with the same wording `chi sessions stop` uses.
47
47
 
48
48
  A session whose worker waits for an answer (a question, a hook's question, a guardrail approval, or the step-limit question of a turn that ran out of steps, which waits between turns) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"|"continue"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). A delegate's approval its parent relayed to its own card (see *Delegating*) reads `waiting (in parent ab12)`, json `relayed_to: "ab12…"` (the parent's short id, else null). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
49
49
 
@@ -149,20 +149,21 @@ cancel during its thinking, it said it had never been asked.
149
149
  `chi send` is the other half of `chi note`: the text goes in as your message, the same as typing it in the attached terminal or the web composer, so a turn runs.
150
150
 
151
151
  ```sh
152
- chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
152
+ chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
153
153
  chi send -m "is this the same bug?" 3fa2 # a message
154
+ chi send --cut -m "stop, wrong file" 3fa2 # cut the running turn's long thinking
154
155
  chi send --image shot.png -m "why is this red?" 3fa2 # with an image
155
156
  pbpaste | chi send -m "is this the same bug?" 3fa2 # the clipboard quoted above the message
156
157
  pbpaste | chi send 3fa2 # the clipboard is the message
157
158
  ```
158
159
 
159
160
  - With both stdin and `-m`, stdin is context: each line becomes a `> ` quote (as web annotations quote a selection), then a blank line, then the message. With only one, it goes in as is. A terminal on stdin is ignored. Both empty is a usage error; over 16 KiB is refused, never cut.
160
- - It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then.
161
+ - It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then, and cuts nothing. `--cut` asks for a cut, as `/cut TEXT` in a terminal does: a generation that has streamed only thinking for `steer.cut_after` seconds is cut (now, or once its thinking passes that), and the step starts again with the message. To an idle session `--cut` sends normally. `--cut --wait` into a running turn is refused like any `--wait` there (`busy: a turn is running`).
161
162
  - A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Only sessions on this machine: Bridges listen on 127.0.0.1.
162
163
  - Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
163
164
  - `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image stops the send (exit 1; with `--wait --format json`, an `error` line) and nothing is sent; more than 20 is a usage error (exit 2). Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
164
165
  - A session command runs as the command, as typed in a terminal: `chi send -m "/model qwen" 3fa2` switches the model (`sent as a session command`), and the model never sees the line. What counts is what the session's worker knows (the built-ins, `!cmd`, its bundles' commands); an unknown `/word` (`/usr/bin/env is missing`) goes in as a message. A UI-only command (`/stats`, `/exit`, `/recap`, …) is refused instead: the worker doesn't run it, so the line would reach the model as a prompt (`<id> refused: /stats is a terminal and web command; use chi sessions stats <id8>`; exit 1, and nothing is sent). With `--new` a line starting with `/` or `!` starts the session idle and goes to its worker once it is up, which decides. With `--wait` a command has no reply to wait for: exit 0 at once, `<id> sent as a session command: no reply to wait for …` on stderr (`{"status":"command",…,"detail":…}` with `--format json`); its output shows where the session is open.
165
- - One line per session: `sent`, `sent as a session command`, `sent with 2 images`, `sent (the running turn picks it up)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
166
+ - One line per session: `sent`, `sent as a session command`, `sent with 2 images`, `sent (goes in at the running turn's next step)`, with `--cut` `sent (cut in now)`, `sent (cuts in once the thinking passes 20 s)` or `sent (cutting is off; goes in at the next step)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
166
167
 
167
168
  The Automator action above works for messages too: swap its last line for `pbpaste | "$chi" send -m "what do you make of this?" $(print -r -- "$picked" | cut -f1)`.
168
169
 
@@ -158,7 +158,8 @@ module Samagotchi
158
158
  def self.tool_replay_events(part)
159
159
  call = { iteration: part[:iteration], call_index: part[:call_index], tool: part[:tool] }
160
160
  call[:label] = part[:label] if part[:label]
161
- title = part[:title] ? { title: part[:title] } : {}
161
+ # Its title and, for an alias's call, the name the model used.
162
+ title = part.slice(:title, :called_as).compact
162
163
  view = part[:view] ? { view: part[:view] } : {}
163
164
  events = [{ type: :tool_call_started, **call, params: part[:params], **title, **view }]
164
165
  if part[:status] == "running"
@@ -274,6 +275,7 @@ module Samagotchi
274
275
  tool: event[:tool], params: event[:params], status: "running" }
275
276
  part[:label] = event[:label] if event[:label]
276
277
  part[:title] = event[:title] if event[:title]
278
+ part[:called_as] = event[:called_as] if event[:called_as]
277
279
  part[:view] = event[:view] if event[:view]
278
280
  parts << part
279
281
  @tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
@@ -7,6 +7,7 @@ require "fileutils"
7
7
  require "securerandom"
8
8
  require "time"
9
9
  require_relative "atomic_file"
10
+ require_relative "delivery"
10
11
  require_relative "session_inbox"
11
12
 
12
13
  require_relative "bridge/bounded_queue"
@@ -61,6 +62,10 @@ module Samagotchi
61
62
  MAX_BODY_BYTES = 1_000_000
62
63
  # A request's deadline (see #handle_post_turn) that isn't epoch seconds.
63
64
  BAD_DEADLINE = [{ "Allow" => "POST" }, 400, { error: "bad_deadline", detail: "deadline must be epoch seconds" }].freeze
65
+ # A request's delivery (see #handle_post_turn) that isn't a string. An
66
+ # unknown string is not an error: it is the default, as a missing one is.
67
+ BAD_DELIVERY = [{ "Allow" => "POST" }, 400,
68
+ { error: "bad_delivery", detail: "delivery must be a string" }].freeze
64
69
 
65
70
  # @param engine [Samagotchi::Engine] the owning engine (must already live
66
71
  # in this process)
@@ -826,6 +831,11 @@ module Samagotchi
826
831
  deadline = fetched(parsed, "deadline")
827
832
  return BAD_DEADLINE unless deadline_valid?(deadline)
828
833
 
834
+ raw_delivery = fetched(parsed, "delivery")
835
+ return BAD_DELIVERY unless raw_delivery.nil? || raw_delivery.is_a?(String)
836
+
837
+ delivery = Delivery.parse(raw_delivery)
838
+
829
839
  # A session command sent as a message (chi send -m "/model x", chi -p,
830
840
  # a UI whose command list lags) runs as the command, as typed in a
831
841
  # TUI; an unknown /word stays a prompt. Not with images: those are
@@ -844,7 +854,7 @@ module Samagotchi
844
854
  next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
845
855
 
846
856
  enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
847
- no_interrupt: no_interrupt, images: images).tap do |ok|
857
+ no_interrupt: no_interrupt, images: images, delivery: delivery).tap do |ok|
848
858
  next unless ok
849
859
 
850
860
  enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
@@ -856,12 +866,17 @@ module Samagotchi
856
866
  return deadline_passed("turn") if enqueued == :expired
857
867
  return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
858
868
 
859
- # Queued (outside the event lock): a running generation that has only
860
- # been thinking for long is cut for it, unless a plugin or a client
861
- # chi doesn't know sent it. An image input never merges mid-turn, so
862
- # it never cuts.
863
- @engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty?
864
- [{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id }]
869
+ # Queued (outside the event lock): a message sent as +cut+ cuts a
870
+ # running generation that has only been thinking for long, unless a
871
+ # plugin or a client chi doesn't know sent it. An image input never
872
+ # merges mid-turn, so it never cuts. Nothing else cuts: a message
873
+ # that says nothing (an older client, the web composer) goes in at
874
+ # the running turn's next step.
875
+ cut = nil
876
+ cut = @engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty? && Delivery.cut?(delivery)
877
+ ack = { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id, delivery: delivery }
878
+ ack[:cut] = cut.to_s if cut
879
+ [{}, 202, ack]
865
880
  end
866
881
 
867
882
  # Read-only snapshot surface. AC #4: too-old reconnects re-derive state
@@ -933,9 +948,9 @@ module Samagotchi
933
948
 
934
949
  # Write a turn into this session's input dir, reusing the file IPC the
935
950
  # worker polls. Never calls run_turn across the boundary.
936
- def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [])
951
+ def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil)
937
952
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
938
- no_interrupt: no_interrupt, images: images)
953
+ no_interrupt: no_interrupt, images: images, delivery: delivery)
939
954
  end
940
955
 
941
956
  # ── HTTP plumbing ────────────────────────────────────────────────────────
@@ -4,6 +4,7 @@ require "json"
4
4
  require "socket"
5
5
 
6
6
  require_relative "worker_sidecar"
7
+ require_relative "delivery"
7
8
  require_relative "bridge_client/sse_parser"
8
9
  require_relative "bridge_client/event_stream"
9
10
 
@@ -139,10 +140,11 @@ module Samagotchi
139
140
  # @param no_interrupt [Boolean] run the turn with the raised iteration limit
140
141
  # @param images [Array<Hash>] refs ({file:, name:}) to images already in
141
142
  # the session's images/
142
- def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil)
143
+ def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil)
143
144
  body = { session_id: @session_id, prompt: prompt, client_id: client_id }
144
145
  body[:no_interrupt] = true if no_interrupt
145
146
  body[:images] = images if images && !images.empty?
147
+ body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
146
148
  body[:deadline] = deadline
147
149
  post("turn", body, read_body: true)
148
150
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.50.0
3
+ version: 0.51.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # What a message sent to a running turn does when it arrives: wait for the
5
+ # turn's next step boundary and go in there (the default), cut the running
6
+ # generation short to go in now, or wait for the turn to end and run as a
7
+ # turn of its own (see docs/configuration.md). The field travels as the
8
+ # wire value name; a sender that names nothing gets the default.
9
+ module Delivery
10
+ NEXT_STEP = "next_step"
11
+ CUT = "cut"
12
+ QUEUE = "queue"
13
+ VALUES = [NEXT_STEP, CUT, QUEUE].freeze
14
+
15
+ # A body's delivery field as one of VALUES. Missing, empty or unknown:
16
+ # NEXT_STEP (an older client, a hand-written request).
17
+ # @param raw [Object] the wire value
18
+ # @return [String] one of VALUES
19
+ def self.parse(raw)
20
+ raw.is_a?(String) && VALUES.include?(raw) ? raw : NEXT_STEP
21
+ end
22
+
23
+ # Whether it is the one every sender defaulting to a step boundary uses.
24
+ def self.next_step?(value) = parse(value) == NEXT_STEP
25
+
26
+ # Whether the running turn should be cut for it (only when the message
27
+ # asks: nothing else cuts).
28
+ def self.cut?(value) = parse(value) == CUT
29
+
30
+ # Whether it waits for the turn to end and runs as a turn of its own.
31
+ def self.queue?(value) = parse(value) == QUEUE
32
+ end
33
+ end
@@ -200,9 +200,11 @@ module Samagotchi
200
200
  # id it is sent as (ModelRef: one alias pass). A ref that names a host
201
201
  # goes there. A bare id goes to the hosts whose cached list has it or
202
202
  # that declare it (hosts.<name>.models, known before any /models): the
203
- # default host when it is one of them, else the first in hosts: order,
204
- # else the default host (before /models nothing is listed: no
205
- # discovery here, the latency budget). Never by a substring.
203
+ # default host when it is one of them, else the first in hosts: order.
204
+ # When none does, the hosts whose saved list (ModelListStore, another
205
+ # process's listing: a worker spawned with --model never lists) has
206
+ # it, the same way; else the default host (no discovery here, the
207
+ # latency budget). Never by a substring.
206
208
  def host_for_model(raw_model)
207
209
  ref = model_ref(raw_model)
208
210
  # ModelRef names a host only when it is one of ours
@@ -211,6 +213,7 @@ module Samagotchi
211
213
  down = ref.id.to_s.strip.downcase
212
214
  listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
213
215
  candidates = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
216
+ candidates = saved_hosts_for(down) if candidates.empty?
214
217
  default = default_entry
215
218
  name = candidates.include?(default.name) ? default.name : candidates.first
216
219
  [(name && find_entry(name)) || default, ref.id]
@@ -386,6 +389,16 @@ module Samagotchi
386
389
  ModelListStore.save(name, Array(models).map(&:id), env: @env)
387
390
  end
388
391
 
392
+ # The configured hosts, in hosts: order, whose fresh saved list
393
+ # (ModelListStore) has +down+ (a downcased id). The store never raises.
394
+ def saved_hosts_for(down)
395
+ saved = ModelListStore.read(env: @env)
396
+ @entries.each_key.select do |name|
397
+ list = saved[name]
398
+ list && !list.stale? && list.known?(down)
399
+ end
400
+ end
401
+
389
402
  def fresh_list(name, entry)
390
403
  @mutex.synchronize do
391
404
  cached = @host_lists[name]
@@ -952,11 +952,30 @@ module Samagotchi
952
952
 
953
953
  private
954
954
 
955
+ # Shell-like names (the harness's is `execute`). The unknown-tool error
956
+ # never echoes the name the model used — a model that sees its own wrong
957
+ # name again calls it again (a steer that named `bash` raised one model's
958
+ # bash rate from 9% to 30%); the activity event and the logs keep it.
959
+ SHELL_LIKE_TOOL_NAMES = %w[
960
+ bash sh zsh shell terminal cmd command run run_command run_shell exec execute_command
961
+ ].freeze
962
+
963
+ # The error an unknown tool name gets back. A shell-like name with
964
+ # `execute` offered this turn points at `execute`; every other unknown
965
+ # name lists what is offered. Neither echoes the wrong name.
966
+ def unknown_tool_error(name, layers)
967
+ names = @tools.names(layers: layers)
968
+ if SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
969
+ "Error: no such tool. Shell commands run with `execute` (same arguments)."
970
+ else
971
+ "Error: no such tool. Available: #{names.join(", ")}"
972
+ end
973
+ end
974
+
955
975
  def dispatch(call)
956
976
  entry = @tools.offered(call[:name], layers: llm_context_layers)
957
977
  unless entry
958
- available = @tools.names(layers: llm_context_layers).join(", ")
959
- result = "Error: unknown tool '#{call[:name]}'. Available: #{available}"
978
+ result = unknown_tool_error(call[:name], llm_context_layers)
960
979
  return {
961
980
  output: result,
962
981
  activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
@@ -965,6 +984,13 @@ module Samagotchi
965
984
 
966
985
  dump_log("tool_call", call[:content], tool: call[:name], path: call[:path], scope: call[:scope])
967
986
 
987
+ if call[:called_as]
988
+ # The model's spelling for a tool an alias ran (bash → execute); its
989
+ # rate is what the alias exists for, so it goes in the log. Tag :turn
990
+ # like the other per-call tool logs (there is no :tool tag).
991
+ Log.info(:turn, "tool_alias", name: call[:called_as], as: call[:name])
992
+ end
993
+
968
994
  result = entry.handler.call(call, tool_context)
969
995
 
970
996
  dump_log("tool_result", result, tool: call[:name])
@@ -11,8 +11,8 @@ module Samagotchi
11
11
  # the call, as it does for the Gemma and Qwen parsers.
12
12
  #
13
13
  # Unknown tool names pass through with their arguments on args:, so
14
- # `dispatch` renders its standard "unknown tool … available: …" error —
15
- # the loop feeds that back like any tool result.
14
+ # `dispatch` renders its standard "no such tool …" error (which never
15
+ # echoes the name) — the loop feeds that back like any tool result.
16
16
  class NativeToolNormalizer
17
17
  class << self
18
18
  # Map a single native tool call (LLM::ToolCall) to the internal call hash.