samagotchi 0.5.1 → 0.6.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +4 -1
  7. data/docs/desktop.md +3 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +2 -2
  22. data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
  23. data/lib/samagotchi/desktop/macos/Panel.swift +15 -0
  24. data/lib/samagotchi/engine.rb +138 -618
  25. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  26. data/lib/samagotchi/hooks/registry.rb +11 -3
  27. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  28. data/lib/samagotchi/hooks.rb +3 -0
  29. data/lib/samagotchi/kernel_loop.rb +65 -6
  30. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  31. data/lib/samagotchi/llm/openai_chat.rb +4 -2
  32. data/lib/samagotchi/log_subscriber.rb +10 -4
  33. data/lib/samagotchi/plugin/api.rb +8 -0
  34. data/lib/samagotchi/plugin/context.rb +12 -3
  35. data/lib/samagotchi/plugin_tasks.rb +229 -0
  36. data/lib/samagotchi/question_desk.rb +280 -0
  37. data/lib/samagotchi/recap_setup.rb +128 -0
  38. data/lib/samagotchi/session.rb +9 -1
  39. data/lib/samagotchi/sessions_command.rb +255 -0
  40. data/lib/samagotchi/steer.rb +19 -2
  41. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  42. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  43. data/lib/samagotchi/tool_declarations.rb +2 -2
  44. data/lib/samagotchi/tools/delegate.rb +4 -1
  45. data/lib/samagotchi/turn_note.rb +20 -1
  46. data/lib/samagotchi/version.rb +1 -1
  47. data/lib/samagotchi/web/app.rb +12 -8
  48. data/lib/samagotchi/web/public/app.js +94 -52
  49. data/lib/samagotchi/web/public/card.js +8 -1
  50. data/lib/samagotchi/web/public/format.js +1 -1
  51. data/lib/samagotchi/web/public/index.html +2 -5
  52. data/lib/samagotchi/web/public/stage_model.js +44 -0
  53. data/lib/samagotchi/web/public/stage_view.js +26 -17
  54. data/lib/samagotchi/web/public/timing.js +9 -3
  55. data/lib/samagotchi/web/public/turn_events.js +18 -5
  56. data/lib/samagotchi/web/public/turn_view.js +68 -16
  57. data/lib/samagotchi/web/server.rb +4 -3
  58. metadata +9 -2
  59. data/lib/samagotchi/web/public/chat_view.js +0 -376
data/docs/architecture.md CHANGED
@@ -110,7 +110,7 @@ bin/chi ─▶ TerminalUI ─▶ Engine#run_turn ─▶ KernelLoop ──┬─
110
110
  `dismiss_question`). The worker runs in the session's `working_directory`, so `!cmd` and
111
111
  the tools don't depend on where the terminal attached from.
112
112
  - `bin/chi web` → builds `Web::Server` (Rack+WEBrick on `127.0.0.1:4567`, `--port`/`SAMAGOTCHI_WEB_PORT`, `--open`).
113
- - `bin/chi sessions {list,stop,delete,prune,clean}` → retention & ordering (`Session.prune`, `updated_at desc`, dry-run, test-only); `stop` is `SessionManager.stop_session(wait:)`, which waits for the worker to release `owner.lock`; `delete` (`SessionDeleteCommand`) is `SessionManager.delete_session(stop:)`, which the TUI's `/exit --delete` and the web's `DELETE /api/sessions/:id` use too.
113
+ - `bin/chi sessions {list,stop,delete,prune,clean}` (`SessionsCommand`; not the REPL's `SessionCommands`) → retention & ordering (`Session.prune`, `updated_at desc`, dry-run, test-only); `stop` is `SessionManager.stop_session(wait:)`, which waits for the worker to release `owner.lock`; `delete` (`SessionDeleteCommand`) is `SessionManager.delete_session(stop:)`, which the TUI's `/exit --delete` and the web's `DELETE /api/sessions/:id` use too.
114
114
  - `--prompt`, `--non-interactive`, and `SessionManager` workers build `Engine` directly.
115
115
 
116
116
  ## Session retention & ordering
@@ -275,6 +275,13 @@ its model turns keep their `tool_calls` and tool results their `tool_call_id`,
275
275
  so later requests and resumed sessions pair them. It has its own system prompt
276
276
  (no raw-prompt tool declarations; the tools go as JSON schemas).
277
277
 
278
+ `Engine#build_stream_event_handler` is the one seam both loops' streams pass
279
+ through: with a `:generation_progress` hook registered it feeds a
280
+ `Hooks::StreamWatch` each chunk's thinking and text after the UIs had it. A
281
+ hook's `stop_generation` cancels the generation's own controller (a child of
282
+ the turn's, `CancellationController#generation`), and each loop takes the cut
283
+ as an empty answer and asks again.
284
+
278
285
  **Adapters.** `Client` (raw-prompt servers) and `LLM::OpenAIChat` (one per host,
279
286
  `HostRegistry#adapter_for`) share `LLM::HTTP`: timeouts, TLS for https, a line
280
287
  reader for streamed bodies, the retry loop (`retry.*`; network errors, 429 and
data/docs/cli.md CHANGED
@@ -14,12 +14,12 @@
14
14
  - `chi web [--port 4567] [--open] [--scope=all]` — start the Web UI (single localhost port session control plane) on this git project's sessions (`--scope=all`, or a folder in no repo: every session); if a chi web already runs on the port, print (with `--open`, open) its page for this folder and exit. Something else on the port (an older chi web too) exits 1 with "port N is in use"
15
15
  - `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
16
16
  - `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages
17
- - `chi web --web-view stage|chat` — draw turns with the stage view (the running turn pinned above the composer) or as the classic row of bubbles instead of the default turn view (each turn as one block of steps, the running one at the bottom); `?view=turn|stage|chat` on the page URL overrides it (see [Web views](#web-views))
17
+ - `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
18
18
  - `chi sessions list|stop|archive|unarchive|delete|prune|clean` — 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))
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 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, and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending; see [Starting a session](sessions.md#starting-a-session))
21
21
  - `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text to live sessions as context notes (see [Desktop helper](desktop.md))
22
- - `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles
22
+ - `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles; `chi self --model` prints only the model a new session starts on (the desktop helper's hint)
23
23
  - `chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, in one table (see [Updating](#updating))
24
24
  - `chi bundle install|upgrade|uninstall|status|diff|list|build` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs (see [Guardrails](guardrails.md), [Plugins](plugins.md#the-btw-bundle), [the mcp bundle](plugins.md#the-mcp-bundle) [the loop-guard bundle](plugins.md#the-loop-guard-bundle), [the check-in bundle](plugins.md#the-check-in-bundle) and [the skills bundle](plugins.md#the-skills-bundle))
25
25
 
@@ -420,26 +420,25 @@ its code; a prompt copies the text as you typed it.
420
420
 
421
421
  ### Web views
422
422
 
423
- `web.view` picks how the page draws a turn: `turn` (the default, below),
424
- `stage` (the running turn pinned above the composer, below) or `chat` (the
425
- classic row of bubbles: one thinking block, one activity panel and one
426
- answer bubble per generation).
423
+ `web.view` picks how the page draws a turn: `stage` (the default: the
424
+ running turn pinned above the composer, below) or `turn` (each turn as one
425
+ block of steps in the history, the running one at the bottom, below).
427
426
 
428
427
  ```sh
429
- chi web --web-view stage # or chat; turn is the default
428
+ chi web --web-view turn # stage is the default
430
429
  ```
431
430
 
432
- The setting also supports `SAMAGOTCHI_WEB_VIEW=stage` or the global config:
431
+ The setting also supports `SAMAGOTCHI_WEB_VIEW=turn` or the global config:
433
432
 
434
433
  ```yaml
435
434
  web:
436
- view: stage
435
+ view: turn
437
436
  ```
438
437
 
439
- `?view=turn`, `?view=stage` or `?view=chat` on the page URL picks the view
440
- for that page load, whatever the config says; the parameter is dropped
441
- when you switch between the project and all-sessions views. The terminal
442
- UIs are not affected.
438
+ `?view=stage` or `?view=turn` on the page URL picks the view for that page
439
+ load, whatever the config says; the parameter is dropped when you switch
440
+ between the project and all-sessions views. The terminal UIs are not
441
+ affected.
443
442
 
444
443
  **The stage view** pins the running turn above the composer, in its own
445
444
  card, so it stays on screen without scrolling: a status row (what it is
@@ -538,6 +538,9 @@ unless `sampling:` sets one; the REPL prints `↻ empty answer, asking again (1/
538
538
  the step. `retry.empty_answer` (default `1`, at most `3`, env `SAMAGOTCHI_RETRY_EMPTY_ANSWER`, no CLI flag) is how
539
539
  many times per turn; `0` ends the turn at the empty answer as before. An answer cut because the context is full
540
540
  (90 % or more) is not retried. When the retries run out the turn ends with "(the model returned an empty answer)".
541
+ A generation a plugin cuts while it streams (loop-guard's thinking watch, or any `stop_generation`,
542
+ [hooks.md](hooks.md#watching-the-stream)) uses the same budget: `↻ cut by loop-guard, asking again (1/1)`, and with
543
+ none left the turn ends as cancelled (hook).
541
544
 
542
545
  Assist-mode UX:
543
546
 
@@ -763,7 +766,7 @@ described in their own sections.
763
766
  | `web.port` | `4567` | `--port` | `chi web`'s port. See [CLI](cli.md). |
764
767
  | `web.host` | `127.0.0.1` | yes | `127.0.0.1`, `::1` or `localhost`; `lan` (this machine's private IPv4 address) or one of its IPv4 addresses also opens `chi web` to the network, with an access token (`chi web --new-token` replaces it). Anything else binds `127.0.0.1` with a warning. See [CLI: chi web on your phone](cli.md#chi-web-on-your-phone). |
765
768
  | `web.markdown` | `false` | yes | Render answers as Markdown in `chi web`. |
766
- | `web.view` | `turn` | yes | How `chi web` draws a turn: `turn` (one block per turn), `stage` (the running turn pinned above the composer) or `chat` (the row of bubbles). See [CLI](cli.md#web-views). |
769
+ | `web.view` | `stage` | yes | How `chi web` draws a turn: `stage` (the running turn pinned above the composer) or `turn` (one block per turn in the history). See [CLI](cli.md#web-views). |
767
770
  | `web.annotate_presets` | `Agreed\|Could you please elaborate?` | yes | Quick replies next to Annotate in `chi web`, `\|`-separated (a YAML list works too); `""` in the file or on the CLI leaves only Annotate (an empty env value means the default). See [CLI](cli.md#web-annotate-presets). |
768
771
  | `history.file` | state dir | | Prompt history path. |
769
772
  | `no_interrupt` | `false` | `--no-interrupt` | Raise the tool-call limit of a turn to 1000; a top-level key. |
data/docs/desktop.md CHANGED
@@ -32,7 +32,9 @@ The first row, **New session in <folder>** (⌘0), starts a session with the mes
32
32
  <folder>`, so it shows in `chi web` at once (see [Starting a session](sessions.md#starting-a-session)). The folder is
33
33
  that of the most recently updated live session, else of the newest recent one, else your home folder. It is ticked
34
34
  alone (ticking it clears the sessions and the reverse) and is preselected when no session is live. ⌘⏎ on it beeps: a
35
- note needs a session. After the send the panel shows `started <id>…` for 3 s.
35
+ note needs a session. After the send the panel shows `started <id>…` for 3 s. On its right, in grey, the row names
36
+ the model the new session starts on (`chi self --model`: `default.model` from config.yml, or its env override); it
37
+ is left out when none is configured.
36
38
 
37
39
  A session open in a `chi --no-shared` REPL isn't listed: it takes no notes or messages.
38
40
 
data/docs/hooks.md CHANGED
@@ -52,14 +52,15 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
52
52
  | `:before_turn` | Before each turn starts | `{ type: :before_turn, session_id: "...", prompt: "..." (nil on a continue), messages: [...] (the history before this turn) }` |
53
53
  | `:after_turn` | After a turn completed or was cancelled (not after one that failed) | `{ type: :after_turn, status: "completed" \| "canceled", present: (see [Presenting the answer](#presenting-the-answer-display-only)), messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
54
54
  | `:before_generation` | Before each LLM API call (both loops) | `{ type: :before_generation, iteration: N }` |
55
- | `:after_generation` | After LLM returns (both loops) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
55
+ | `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
56
+ | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N }` |
56
57
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
57
58
  | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." }` |
58
59
  | `:session_end` | After every turn (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
59
60
 
60
61
  Every event also carries the hook runtime (next section): `hook:` (the label
61
62
  of the hook about to run) and the callables `notify:`, `ask_user:`,
62
- `stop_turn:`, `steer:`.
63
+ `stop_turn:`, `steer:`, `stop_generation:`.
63
64
 
64
65
  `messages:` is a **read-only copy**: a frozen array of copied message hashes
65
66
  (`{role:, content:, …}`). A hook that mutates it, or its strings, gets
@@ -69,8 +70,9 @@ cheap).
69
70
  ## What a hook can do: the runtime
70
71
 
71
72
  Besides reading (and, on `:before_tool_call`, voting on) its event, a hook
72
- can talk to the user, and to the running turn, through four callables the
73
- registry puts on every event:
73
+ can talk to the user, and to the running turn, through the callables the
74
+ registry puts on every event (`stop_generation`, the fifth, is in
75
+ [Watching the stream](#watching-the-stream)):
74
76
 
75
77
  ```ruby
76
78
  class Watchful
@@ -114,12 +116,75 @@ known-names)` for a bundle hook, `audit.rb (config)` for a config hook,
114
116
  A steer is saved in the session as `{role: "user", kind: "steer", source:
115
117
  "<bundle>", content: "…"}`; the model reads only its text, as a user turn.
116
118
  Its `source` is the hook's bundle (a config or turn hook's label otherwise).
119
+ The user's own lines typed into a running turn are saved as `{role: "user",
120
+ kind: "input", content: "…"}`: part of that turn, not a turn of their own.
117
121
 
118
122
  Timing: a notice from `:after_turn` or `:session_end` shows after the turn's
119
123
  end line. A question from `:before_tool_call` shows **before** the tool
120
124
  line (the gate runs first), so its text should name the call. The notices
121
125
  are also logged (`turn` tag, `hook_notice`).
122
126
 
127
+ ## Watching the stream
128
+
129
+ `:generation_progress` sees a model response **while it streams**: the
130
+ thinking and the visible text, on both the raw-prompt path (llama.cpp
131
+ `/completion`, `/v1/completions`) and the chat path (`api: openai`). It fires
132
+ for the turn's own generations only (not a plugin's `ctx.ask_model`, not a
133
+ recap).
134
+
135
+ When it fires: once 2000 new chars (thinking + text) are pending, or once a
136
+ second has passed since the last fire with anything pending. It is checked
137
+ as each chunk arrives (there is no timer), so a silent stream fires nothing.
138
+ There is no fire at the end of a generation (`:after_generation` sees the
139
+ whole response), and none after the turn or the generation was cancelled.
140
+ A 240k-char thinking gives about 120 fires.
141
+
142
+ `thinking` holds what the model streamed as thinking: `reasoning_content`
143
+ on the chat path, a Qwen `<think>` block on the raw-prompt path. Some
144
+ thinking arrives as `text` instead: Gemma 4's on the raw-prompt path (it has
145
+ no close marker), and a chat provider's that puts `<think>` inside the
146
+ answer's content. Tool-call bodies are left out of `text`. With streaming off
147
+ (`stream: false` on a chat host) there are no chunks, so no fires.
148
+
149
+ **Keep it fast.** The hook runs on the turn's thread, inside the HTTP read:
150
+ while it runs, tokens wait in the socket (nothing is lost). A hook over
151
+ 100 ms logs `stream_hook_slow` (warn, with its label and the ms) once per
152
+ turn. Don't ask questions or call the model from it: `event[:ask_user]`
153
+ returns nil at once here, and `ctx.ask_model` would hold the stream for its
154
+ whole answer. A hook that never returns hangs the turn, as any hook does. A
155
+ plugin's block that raises is logged (`plugin_hook_failed`) at most once a
156
+ minute.
157
+
158
+ Two ways to act:
159
+
160
+ - `event[:stop_turn].call(reason)`: as anywhere, a warn notice and the turn
161
+ ends cancelled (hook). The socket closes at once.
162
+ - `event[:stop_generation].call(reason)`: cut **this generation** only. The
163
+ turn goes on: the model is asked again with a hidden note ("your last
164
+ reply was cut off by <bundle>: <reason>. Don't start the same reasoning
165
+ again; …") at the retry temperature, and the UIs print `↻ cut by
166
+ <bundle>, asking again (1/1)`. True when a streaming generation was cut
167
+ now; false with none streaming, once it was cut, after a cancel, and from
168
+ `:after_turn` / `:session_end`.
169
+
170
+ What a cut is, in detail:
171
+
172
+ - It is an empty answer made early: it uses the `retry.empty_answer` budget
173
+ ([configuration.md](configuration.md#llama-network-retry-behavior)). With
174
+ no retry left (`retry.empty_answer: 0`, or already used), the turn ends
175
+ cancelled (hook): "✕ turn canceled (hook)".
176
+ - It shows nothing by itself: post your own notice (`event[:notify]`) to say
177
+ why. The bundle and the reason go into the note the model reads and the
178
+ log (`generation_stopped`).
179
+ - Queued input (the user's line, or a hook's `steer`) goes in place of the
180
+ hidden note, as for an empty answer; then there is no `↻` line.
181
+ - The cut generation is not kept: its thinking and any visible text it had
182
+ streamed go with it (the retry answers anew). `:after_generation` doesn't
183
+ fire for it, and its token usage is lost (the stream never sent its last
184
+ chunk). A client that joins the running turn still sees its thinking in
185
+ the turn so far until the turn ends.
186
+ - A Stop from the user right after a cut is a plain cancel.
187
+
123
188
  ## Presenting the answer (display only)
124
189
 
125
190
  `:after_turn` carries one more callable, `present:`. It changes how the
data/docs/plugins.md CHANGED
@@ -311,6 +311,34 @@ This is a bundle hook, the same as a `hooks/*.rb` file. See
311
311
  `plugin.rb (bundle my-bundle)`. The block may take only the event. If it
312
312
  raises, the error is logged and the hook is skipped.
313
313
 
314
+ #### Watching the stream
315
+
316
+ `chi.on(:generation_progress)` sees a response while it streams, in batches
317
+ (2000 chars or a second), and `event[:stop_generation]` (or
318
+ `ctx.stop_generation`) cuts it while the turn goes on: the model is asked
319
+ again. The rules (the thread it runs on, keeping it fast, what a cut does)
320
+ are in [hooks.md](hooks.md#watching-the-stream).
321
+
322
+ ```ruby
323
+ class Plugin
324
+ LIMIT = 50_000
325
+
326
+ def register(chi)
327
+ chi.on(:generation_progress) do |event, ctx|
328
+ next if event[:thinking_chars] < LIMIT
329
+
330
+ # Once per generation: a cut generation fires no more.
331
+ if event[:stop_generation].call("it thought for over #{LIMIT / 1000}k chars")
332
+ ctx.notify("thinking too long (#{event[:elapsed_ms] / 1000} s): cut", level: :warn)
333
+ end
334
+ end
335
+ end
336
+ end
337
+ ```
338
+
339
+ loop-guard's thinking watch is built on this ([The loop-guard
340
+ bundle](#the-loop-guard-bundle)).
341
+
314
342
  ### `chi.service(name, eager: false) { |svc| … }`
315
343
 
316
344
  A long-lived thing the plugin keeps for the session: a server process, a
@@ -439,6 +467,7 @@ session's life, and each read gives the session as it is now.
439
467
  | `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
440
468
  | `ctx.steer(text)` | put text into the running turn, like a hook's `event[:steer]`: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook, your own |
441
469
  | `ctx.stop_turn(reason)` | stop the running turn after a warn notice with the reason, like a hook's `event[:stop_turn]`; true when it stopped one now |
470
+ | `ctx.stop_generation(reason)` | cut the generation that is streaming, like a hook's `event[:stop_generation]`: the turn goes on and the model is asked again ([hooks.md](hooks.md#watching-the-stream)). Shows nothing: post your own notice. True when it cut one now |
442
471
  | `ctx.ask_model(messages:, prompt:, …)` | a side answer from the session's model: see [Side answers](#side-answers-ctxask_model) |
443
472
  | `ctx.sessions` | fork, send to and read other sessions: see [Other sessions](#other-sessions-ctxsessions) |
444
473
 
@@ -706,7 +735,9 @@ bundles:
706
735
 
707
736
  `chi bundle install loop-guard` installs the bundle shipped with chi. It is
708
737
  written only against this API (`lib/samagotchi/bundles/loop-guard/plugin.rb`),
709
- with `chi.on` hooks, and has no memory file.
738
+ with `chi.on` hooks, and has no memory file. It guards two kinds of loop:
739
+ repeated tool calls (below) and [thinking that repeats
740
+ itself](#thinking-that-repeats-itself).
710
741
 
711
742
  A local model can run the same tool call again and again in one turn: each
712
743
  step's thinking starts over, so it never notices it already tried. (A real
@@ -749,12 +780,76 @@ bundles:
749
780
  Its hooks run at the default priority (100), after known-names (50), so in
750
781
  known-names' `correct` mode loop-guard keys the corrected call.
751
782
 
783
+ ### Thinking that repeats itself
784
+
785
+ A model can also go in circles inside one generation: its thinking says the
786
+ same few sentences again and again ("Wait, let me check once more…") for
787
+ minutes, until the provider's output cap ends it with no answer. loop-guard
788
+ watches the thinking while it streams ([Watching the
789
+ stream](hooks.md#watching-the-stream)) and cuts it:
790
+
791
+ - The thinking is cut into sentences; short ones (under `min_words`) are
792
+ skipped. Two sentences count as the same when their words and word pairs
793
+ mostly match (`similarity`), so a reworded round ("Hmm, …", a synonym, a
794
+ swapped clause) still counts.
795
+ - A loop is a cycle of 1 to `max_period` sentences seen `repeats` times in a
796
+ row, over at least `min_span_sentences` sentences and `min_span_chars`
797
+ chars; or one sentence `max_same` times anywhere in the generation. One
798
+ sentence over and over must be near-identical (`similarity` + 0.4), and a
799
+ longer cycle must hold different sentences: a list of templated sentences
800
+ ("Now I need to open the file <path> and …" for 14 paths) is no loop.
801
+ - Nothing triggers before `min_chars` of thinking. The watch sees a loop
802
+ within one batch (2000 chars, or a second) of its third cycle.
803
+
804
+ What happens (`action: retry`, the default):
805
+
806
+ 1. The first loop in a turn: the generation is cut and the model asked
807
+ again, with a hidden note that it was cut off. The user sees
808
+ `loop-guard> thinking repeats itself (3 sentences ×3, 4k chars, 8 s): cut`
809
+ and `↻ cut by loop-guard, asking again (1/1)`.
810
+ 2. If the retry loops too, the turn is stopped ("stopped the turn: …", "✕
811
+ turn canceled (hook)") with a card that quotes the repeated sentences.
812
+
813
+ The cut uses the `retry.empty_answer` budget: with `retry.empty_answer: 0`
814
+ the first loop ends the turn as cancelled (hook), with the notice and no
815
+ card. `action: stop` stops the turn at the first loop; `action: notify` only
816
+ warns, once per generation.
817
+
818
+ ```yaml
819
+ bundles:
820
+ loop-guard:
821
+ thinking:
822
+ watch: true # false: the tool-call guard only
823
+ action: retry # retry (cut, ask again; then stop) | stop | notify
824
+ min_chars: 2000 # thinking this long before anything triggers
825
+ repeats: 3 # a cycle seen this many times is a loop
826
+ max_period: 6 # cycles of up to this many sentences
827
+ similarity: 0.5 # 0..1, how alike two sentences must be to count as the same
828
+ min_span_sentences: 6 # a loop spans at least this many sentences…
829
+ min_span_chars: 600 # …and this many chars
830
+ max_same: 8 # one sentence this many times in one generation is a loop
831
+ min_words: 5 # shorter sentences are ignored
832
+ ```
833
+
834
+ Not watched:
835
+
836
+ - thinking that arrives as answer text: Gemma 4 on the raw-prompt path (no
837
+ close marker), a chat provider that puts `<think>` in the content;
838
+ - a chat host with streaming off (no chunks);
839
+ - loops in the visible answer;
840
+ - a runaway without sentences, such as an endless comma list of numbers: it
841
+ ends at the output cap as an empty answer, which `retry.empty_answer`
842
+ retries after the fact.
843
+
844
+ A real small model's circular re-checking that repeats one sentence 8 times
845
+ is cut even if it would have found an answer later; `max_same` sets how
846
+ patient that is.
847
+
752
848
  Not caught (yet):
753
849
 
754
850
  - near-duplicates, such as `find . -name 'config*'` after `'config.yml'`;
755
851
  - loops across turns;
756
- - alternating calls (A, B, A, B) that each return something new;
757
- - thinking that goes in circles inside one long generation.
852
+ - alternating calls (A, B, A, B) that each return something new.
758
853
 
759
854
  ## The check-in bundle
760
855
 
data/docs/sessions.md CHANGED
@@ -50,7 +50,7 @@ chi sessions clean --dry-run # every test session, whatever
50
50
  chi sessions clean --dry-run --days 7 # test sessions older than 7 days
51
51
  ```
52
52
 
53
- `--dry-run` is the safe preview. Web has no prune endpoint; use the CLI.
53
+ `--dry-run` is the safe preview. `--keep N` keeps the N newest sessions that stay: one that goes anyway (a leftover scratch session, one left empty, one older than `--days`) takes no place in the count. Web has no prune endpoint; use the CLI.
54
54
 
55
55
  `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
56
56
 
@@ -175,8 +175,8 @@ chi send --wait 3fa2 # sends nothing: waits fo
175
175
 
176
176
  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.
177
177
 
178
- - `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). 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.
179
- - `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child. The reply comes back once; a later call waits for the next one.
178
+ - `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.
179
+ - `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.
180
180
  - What a wait returns, `session: <id>` then `status:` and the text: `done` with the reply after a `---` line; `waiting_for_answer` as soon as the child opens a question or a guardrail approval (nothing is answered for it: attach or use the web, then wait again); `canceled` when the parent's own turn is canceled (the child keeps running; the next turn can wait again); `no_reply` when the child's turn ended with nothing to say (canceled, failed or empty: its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `running` on timeout. A reply saved before the child's worker idle-exited is returned at once.
181
181
  - Every child starts with the shipped `delegated` system memory preloaded (`preloaded_memory_names: ["system/delegated"]`; `--mute delegated` would hide it): put everything in the final reply, don't delegate further, don't write memories or change config, stay in the folder. Its system prompt also says `Delegated by session <parent id>`. The parent's own `--mute` list does not carry over.
182
182
  - Limits: a child cannot `delegate` (depth 1; the tool refuses), and one session may have at most `session.max_children` (default `4`, env `SAMAGOTCHI_SESSION_MAX_CHILDREN`) children running at a time, counted from the session files (a finished or idle-exited child does not count; one whose worker is still starting does, for 15 s). A refused call creates no session. Nothing stops children with the parent: `chi sessions stop ID` does.
@@ -2,14 +2,18 @@
2
2
 
3
3
  module Samagotchi
4
4
  class Bridge
5
- # The last cards (Engine#show_card) and hook notices (:hook_notice),
6
- # for a UI that joins later: the Bridge's snapshot[:cards]. A
7
- # persistent observer, like TurnAccumulator.
5
+ # The last cards (Engine#show_card), hook notices (:hook_notice) and
6
+ # load warnings (:guardrail_warning), for a UI that joins later: the
7
+ # Bridge's snapshot[:cards]. A persistent observer, like TurnAccumulator.
8
8
  #
9
9
  # A turn's own notice (no between_turns) is in_turn like a turn's card,
10
10
  # with the step it came in: +iteration+ and +calls+, the calls of that
11
11
  # iteration started before it (a before_tool_call hook's notice comes
12
- # before its call's row, so the web puts it above row calls + 1).
12
+ # before its call's row, so the web puts it above row calls + 1). The
13
+ # loop's "asking again" row (:empty_answer_retry, after an empty or cut
14
+ # answer) is kept the same way, and so is a question the turn asked
15
+ # (:question_requested), with how it was answered or cancelled, so a
16
+ # reload draws the resolved card where it was.
13
17
  #
14
18
  # A card with an earlier card's id replaces it where it was. Each entry
15
19
  # says where it belongs as the recap does: +turns_since+, the turns
@@ -81,6 +85,15 @@ module Samagotchi
81
85
  settle_during(after_turn: true)
82
86
  when :card then add_card(event)
83
87
  when :hook_notice then add_notice(event)
88
+ # What failed to load (guardrails, plugins): before the first turn
89
+ # this worker runs, where it showed live.
90
+ when :guardrail_warning then push({ type: :guardrail_warning, message: event[:message], label: event[:label] }
91
+ .compact.merge(in_turn: false, turns: @turns_done))
92
+ when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by)) if @running
93
+ when :question_requested
94
+ add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running
95
+ when :question_answered then resolve_question(event[:id], answer: event[:answer])
96
+ when :question_cancelled then resolve_question(event[:id], cancelled: true, reason: event[:reason])
84
97
  end
85
98
  end
86
99
 
@@ -113,10 +126,22 @@ module Samagotchi
113
126
  if event[:between_turns] || !@running
114
127
  push(notice.merge(in_turn: false, turns: @turns_done))
115
128
  else
116
- push(notice.merge(in_turn: true, turns: @turns_done, iteration: @iteration, calls: @calls).compact)
129
+ add_turn_row(notice)
117
130
  end
118
131
  end
119
132
 
133
+ # A row of the running turn's current step.
134
+ def add_turn_row(row)
135
+ push(row.merge(in_turn: true, turns: @turns_done, iteration: @iteration, calls: @calls).compact)
136
+ end
137
+
138
+ def resolve_question(id, **outcome)
139
+ entry = @entries.reverse_each.find do |e|
140
+ e[:type] == :question && e.dig(:pending_question, :id).to_s == id.to_s
141
+ end
142
+ entry&.merge!(Marshal.load(Marshal.dump(outcome.compact)))
143
+ end
144
+
120
145
  def push(entry)
121
146
  @entries << entry
122
147
  @entries.shift while @entries.size > @capacity
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../kernel_loop"
4
4
  require_relative "../steer"
5
+ require_relative "turn_notice"
5
6
 
6
7
  module Samagotchi
7
8
  class Bridge
@@ -33,8 +34,11 @@ module Samagotchi
33
34
  end
34
35
 
35
36
  # @return [Hash, nil] a copy of the turn in progress: prompt, origin,
36
- # continue, ordered parts (thinking / text / tool / input / steer / reminder / hook_notice),
37
- # pending_question and the last event_seq folded in
37
+ # continue, ordered parts (thinking / text / tool / input / steer /
38
+ # reminder / notice), pending_question and the last event_seq folded
39
+ # in. A notice part holds the event of one of the turn's rows
40
+ # (TurnNotice: a hook's notice, an empty-answer retry, a question and
41
+ # its answer), which a UI replays through its live handler.
38
42
  def current_turn
39
43
  @mutex.synchronize { @turn && Marshal.load(Marshal.dump(@turn)) }
40
44
  end
@@ -173,13 +177,12 @@ module Samagotchi
173
177
  end
174
178
  when :reminder_injected
175
179
  parts << { kind: "reminder", reminders: Array(event[:reminders]).map(&:dup) }
176
- when :hook_notice
177
- parts << { kind: "hook_notice", hook: event[:hook].to_s, text: event[:text].to_s, level: event[:level].to_s }
178
180
  when :question_requested
179
181
  @turn[:pending_question] = event[:pending_question]&.dup
180
182
  when :question_answered, :question_cancelled
181
183
  @turn[:pending_question] = nil
182
184
  end
185
+ parts << { kind: "notice", event: TurnNotice.slice(event) } if TurnNotice.notice?(event)
183
186
  end
184
187
 
185
188
  def append_text(iteration, kind, text)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Bridge
5
+ # The events a running turn shows as rows of its own (not text, thinking
6
+ # or a tool call): a hook's notice, the loop asking again after an empty
7
+ # or cut answer, a question and how it was answered. A UI that joins the
8
+ # turn mid-way (TurnAccumulator's "notice" part) and one that reloads a
9
+ # finished turn (CardStore) get the event back with the fields listed
10
+ # here, and draw it with the handler that drew it live, where it was.
11
+ module TurnNotice
12
+ FIELDS = {
13
+ hook_notice: %i[hook text level],
14
+ empty_answer_retry: %i[iteration attempt of stopped_by],
15
+ question_requested: %i[pending_question],
16
+ question_answered: %i[id answer],
17
+ question_cancelled: %i[id reason]
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ # @return [Boolean] whether +event+ is one of a turn's rows
23
+ def notice?(event)
24
+ FIELDS.key?(event[:type])
25
+ end
26
+
27
+ # The event as a UI replays it: its type (a String, as JSON has it)
28
+ # and the fields it had, deep-copied.
29
+ # @return [Hash]
30
+ def slice(event)
31
+ fields = event.slice(*FIELDS.fetch(event[:type]))
32
+ copy = Marshal.load(Marshal.dump(fields))
33
+ { type: event[:type].to_s, **copy }
34
+ end
35
+ end
36
+ end
37
+ end