samagotchi 0.25.0 → 0.27.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/docs/architecture.md +6 -0
  4. data/docs/configuration.md +1 -0
  5. data/docs/hooks.md +1 -1
  6. data/docs/internals/engine.md +478 -0
  7. data/docs/internals/prompt-caching.md +33 -7
  8. data/docs/plugins.md +3 -2
  9. data/lib/samagotchi/bridge.rb +37 -1
  10. data/lib/samagotchi/bridge_client.rb +9 -0
  11. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -5
  13. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/cancellation_controller.rb +8 -0
  17. data/lib/samagotchi/client.rb +53 -3
  18. data/lib/samagotchi/config.rb +9 -2
  19. data/lib/samagotchi/engine.rb +50 -7
  20. data/lib/samagotchi/kernel_loop.rb +39 -3
  21. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  22. data/lib/samagotchi/llm/http.rb +5 -4
  23. data/lib/samagotchi/log.rb +19 -3
  24. data/lib/samagotchi/prompt_warmup.rb +95 -0
  25. data/lib/samagotchi/terminal_ui.rb +2 -0
  26. data/lib/samagotchi/thinking_tails.rb +4 -3
  27. data/lib/samagotchi/tool_activity.rb +3 -2
  28. data/lib/samagotchi/tool_declarations.rb +1 -1
  29. data/lib/samagotchi/tool_view.rb +14 -3
  30. data/lib/samagotchi/tools/task_get.rb +3 -2
  31. data/lib/samagotchi/tools/task_runtime.rb +49 -12
  32. data/lib/samagotchi/tools/task_stop.rb +1 -1
  33. data/lib/samagotchi/tools/task_wait.rb +22 -10
  34. data/lib/samagotchi/turn_note.rb +28 -2
  35. data/lib/samagotchi/turn_state.rb +6 -0
  36. data/lib/samagotchi/version.rb +1 -1
  37. data/lib/samagotchi/web/app.rb +25 -0
  38. data/lib/samagotchi/web/markdown_renderer.rb +21 -6
  39. data/lib/samagotchi/web/public/app.js +4 -0
  40. data/lib/samagotchi/web/public/command_view.js +6 -3
  41. data/lib/samagotchi/web/public/data.js +11 -0
  42. data/lib/samagotchi/web/public/index.html +39 -13
  43. data/lib/samagotchi/web/public/stage_model.js +8 -2
  44. data/lib/samagotchi/web/public/stage_view.js +14 -2
  45. data/lib/samagotchi/web/public/task_stop.js +101 -0
  46. data/lib/samagotchi/web/public/turn_view.js +24 -1
  47. data/lib/samagotchi/worker.rb +3 -0
  48. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6abdccc8a805a545ad1e27898b3caf43516d16ef1b7832444bac7b6e0a4b5e63
4
- data.tar.gz: 8c0e0f354030f8ad954afdfd9dff96ef54d0b1fdd79d8df190cf494a8a32b18d
3
+ metadata.gz: b91f830918a3f58dac1d54820450ec90b4caa2d8f970b3fab03c8a0199b3ac2e
4
+ data.tar.gz: 1d1f0f1dc839247801be1462a895e8a57e189a68882fbaeb5a7707fdb4add0ab
5
5
  SHA512:
6
- metadata.gz: 36530955a235e3d73adaa4496545c12f6abea21daf1ff0a40eaba9477c22e1e7007dced1a500912878164c0a9f1a9ec76b45d4d8a34709473d8c496b1d53ce69
7
- data.tar.gz: 125d6ab7dd048082ff6296d663803da9d6a8073c7ead520f2264ec4cc27c28d29bdb772addd47f2be39b9aa497308ef09236bd5e3e1bc43854341970fc8e6a6e
6
+ metadata.gz: 398faa2152be479cf78093fb9c752158932a2c186bf1fa5fd1ee9a622895bf742f82f1a25c843febfb877c826cee869623ee9871c50154015fdb54d02a490054
7
+ data.tar.gz: 35a4b45af554dd9cfd79c87d67ed7d819d14759421f8f669ac769ddbb1b37a64f5b0c79101f15a002ebdb4a2e2e9bee1def9eead820d50f61312c9f4a8cc41c8
data/CHANGELOG.md CHANGED
@@ -8,6 +8,50 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.27.0] - 2026-10-04
12
+
13
+ ### Added
14
+
15
+ - Web: a "stop task" button on a running `task_wait` stops the background task; the turn goes on and the model is
16
+ told the user stopped it (Cancel still cancels the turn and leaves the task running).
17
+
18
+ ### Changed
19
+
20
+ - `/mcp` lists each server's tools alphabetically, not in the order the server sent them (mcp bundle 0.4.3).
21
+
22
+ ### Fixed
23
+
24
+ - A stopped background task ends as `stopped`, not `failed`, and records who stopped it (the user or the model).
25
+ - Code blocks in the web follow the light theme too: their highlighting comes from the page's palette instead of one
26
+ fixed dark theme.
27
+
28
+ Update with `chi update` (mcp 0.4.3).
29
+
30
+ ## [0.26.0] - 2026-10-04
31
+
32
+ ### Added
33
+
34
+ - The web has a light theme: with the system set to light it follows (`prefers-color-scheme`). There is no
35
+ in-page switch yet. Native radios, inputs and scrollbars now follow the theme, so they are dark in dark mode.
36
+ - On a local llama.cpp host, the next turn starts faster: when a turn ends, chi sends the next turn's prompt (up to
37
+ your next message) in the background, so the server has it ready while you read. `cache.warmup: off` turns it
38
+ off; remote and paid hosts never get it.
39
+
40
+ ### Changed
41
+
42
+ - `execute`'s `description` parameter now says it is shown to the user as the step's title and asks for it on
43
+ every call (still optional), so models, Sonnet in particular, now label execute steps.
44
+ - loop-guard (bundle 0.3.4) words a loop its short run or its window found by the different sentences it held,
45
+ `12 different sentences in 48`, instead of `12 sentences ×4`, which read as a cycle of 12.
46
+ - `sampling:` can't set llama.cpp's `id_slot` (pinning a session to a slot made the next turn re-read the whole
47
+ prompt).
48
+
49
+ ### Fixed
50
+
51
+ - `thinking_tails.jsonl` names the plugin that stopped a turn instead of "hook".
52
+
53
+ Update with `chi update` (loop-guard 0.3.4).
54
+
11
55
  ## [0.25.0] - 2026-10-04
12
56
 
13
57
  ### Added
@@ -1394,7 +1438,9 @@ and long-lived sessions.
1394
1438
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1395
1439
  hotkey panel that send selected text or the clipboard to your sessions.
1396
1440
 
1397
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.25.0...HEAD
1441
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.27.0...HEAD
1442
+ [0.27.0]: https://github.com/dm1try/samagotchi/compare/v0.26.0...v0.27.0
1443
+ [0.26.0]: https://github.com/dm1try/samagotchi/compare/v0.25.0...v0.26.0
1398
1444
  [0.25.0]: https://github.com/dm1try/samagotchi/compare/v0.24.0...v0.25.0
1399
1445
  [0.24.0]: https://github.com/dm1try/samagotchi/compare/v0.23.0...v0.24.0
1400
1446
  [0.23.0]: https://github.com/dm1try/samagotchi/compare/v0.22.0...v0.23.0
data/docs/architecture.md CHANGED
@@ -246,6 +246,12 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
246
246
  `guardrails.parent_approvals` is `403 parent_approval_refused`, the question still open.
247
247
  - `POST /session/:id/question/dismiss` — leave the question unanswered (an approval: denied);
248
248
  `200`, or `409` when it is no longer pending.
249
+ - `POST /session/:id/tasks/stop` — `{task_id}`: stop a background task as the user
250
+ (`TaskRuntime.stop_task by: "user"`, the web's stop-task button on a running `task_wait`;
251
+ web route `POST /api/sessions/:id/tasks/:task_id/stop`). Only a task this conversation's
252
+ `task_create` started (the saved messages, or the running turn's accumulator); answers once the
253
+ task is gone: `200 {status, stop_reason, task_id}`, `404 task_not_found`, `409 not_running`.
254
+ The worker names it in its sidecar features (`task_stop`).
249
255
  - `POST /session/:id/relay` — the approval relay, on a delegated child's worker
250
256
  (`{action, relay_id, question_id}`): `opened` / `closed` (with a `reason`) set or clear the
251
257
  pending question's `relayed_to` mark (`QuestionDesk#annotate`, `:question_relay`); `answered`
@@ -898,6 +898,7 @@ described in their own sections.
898
898
  | `models.<key>.thinking`, `hosts.<name>.thinking` | none | | A model's or host's level. See "Thinking". |
899
899
  | `hosts.<name>.remote` | by address | | `true`/`false`: treat the host as a remote provider or a local server. See "Remote or local". |
900
900
  | `max_tool_output_chars` | `10000` | yes | Tool output kept in the conversation; a top-level key (see below). |
901
+ | `cache.warmup` | `auto` | | `auto`: after a turn, send the next turn's prompt (up to the next message) to a local llama.cpp host on the native loop, so the next turn prefills only its message; never a remote or `api: openai` host. `off`: never. See [prompt caching](internals/prompt-caching.md#the-turn-end-warm-up). |
901
902
  | `retry.max` | `5` | yes | See "Llama Network Retry Behavior". |
902
903
  | `retry.base_delay` | `0.5` | yes | |
903
904
  | `retry.max_delay` | `8.0` | yes | |
data/docs/hooks.md CHANGED
@@ -56,7 +56,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
56
56
  | `: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) }` |
57
57
  | `: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 }` |
58
58
  | `: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 }` |
59
- | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }` |
59
+ | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped |
60
60
  | `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
61
61
 
62
62
  Every event also carries the hook runtime (next section): `hook:` (the label
@@ -0,0 +1,478 @@
1
+ # Engine: the core agent loop
2
+
3
+ The Engine (`lib/samagotchi/engine.rb`) runs a session's turns. It owns the turn lifecycle, the hooks and
4
+ plugins, and the model↔tool cycle, and it reports what happens as events, so every UI (the REPL, a worker
5
+ behind the web and the attached TUI, one-shot CLI runs) runs turns the same way without the Engine knowing how
6
+ they render.
7
+
8
+ ```
9
+ Engine#run_turn → backend.complete(...) → KernelLoop#run (native) or ChatLoop#complete (chat)
10
+ → per iteration: one generation, then ToolResponse.run_batch over the calls
11
+ → ToolRunner#run per call → Guardrails::Gate → KernelLoop#dispatch_tool_call
12
+ → repeat until the model answers without tool calls, or the iteration limit
13
+ ```
14
+
15
+ ## Quick reference: the turn lifecycle
16
+
17
+ ```
18
+ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:, max_tool_output_chars:,
19
+ pending_input:, continue:, origin:, images:)
20
+ │
21
+ ├── begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:)
22
+ │ # @session = session, session.status = RUNNING, used memories absorbed,
23
+ │ # TurnState#begin!(controller:, sink:) (a new CancellationController when none is given),
24
+ │ # a recap in flight invalidated, GuardrailWiring#begin_turn(origin)
25
+ │ # Returns a Turn {session, prompt (nil on a continue), continue, on_event, controller,
26
+ │ # origin, started_at, id}
27
+ │
28
+ ├── Client.swap_probe_cancel(turn.controller) # a Stop cuts this turn's /props probes
29
+ │
30
+ ├── prepare_turn(turn, images)
31
+ │ # Emits :turn_started {session_id, prompt, turn_id, continue: true (only on a continue),
32
+ │ # images (only when there are any)}
33
+ │ # refresh_profile! (may ask the server's /props once)
34
+ │ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name is set in generate)
35
+ │ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools
36
+ │ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
37
+ │ # (a frozen copy of the history, and the prompt)
38
+ │ # turn_messages: system head + history + due reminders (:reminder_injected) + the user message
39
+ │ # Returns false when a Stop came during the probes or the init wait, else true
40
+ │
41
+ ├── generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
42
+ │ # sync_kernel_client!, then @kernel.turn_settings = turn.settings with model_name and window_setting
43
+ │ # turn.limit = max_iterations or IterationLimit.for
44
+ │ # backend.complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input))
45
+ │ # Returns LLM::ModelResult (text, conversation, canceled?, exhausted?, tool_activity,
46
+ │ # context_status, empty_steps, empty_retries)
47
+ │
48
+ ├── publish_used_memories(session, on_event) # :used_memories_updated when there are any
49
+ │
50
+ ├── complete_turn(turn, result)
51
+ │ # end_turn(turn, "completed" | "canceled"): under the event lock the session gets the kept
52
+ │ # messages, goes IDLE, records last_turn, and :turn_completed {result, turn_summary,
53
+ │ # display_pending} or :turn_canceled {cancellation_reason, cancelled_by, duration_ms} is emitted
54
+ │ # then the :after_turn hook (status, messages, present: the AnswerDisplay presenter)
55
+ │ # store_answer_display (:answer_display to the observers when a hook changed the answer,
56
+ │ # or display: nil when the web was told to wait for it)
57
+ │ # then the :session_end hook
58
+ │
59
+ ├── warm_up_next_turn(turn, result)
60
+ │ # local llama.cpp on the native loop only: prefills the next turn's prompt while the user
61
+ │ # reads (PromptWarmup; see prompt-caching.md, "The turn-end warm-up"). Never fails the turn.
62
+ │
63
+ └── ensure: release_turn(probe_cancel_before)
64
+ # TurnState#finish! (turn flag off, controller, sink and steers cleared)
65
+ # GenerationPhase#finished!, WaitingSteer#clear!
66
+ # probe cancel swapped back
67
+ # the context window cache dropped, so the next turn asks the server again
68
+ # steers left logged as dropped, activity recorded, turn-scoped hooks cleared
69
+ ```
70
+
71
+ ### Error paths
72
+
73
+ ```
74
+ StandardError in run_turn:
75
+ → if turn.ended (it came from the after_turn/session_end hooks): logged as post_turn_error, re-raised
76
+ → else: end_turn(turn, "failed", save: true)
77
+ # Both loops call LLM::FailedTurn.attach(e, conversation) before re-raising, so
78
+ # failed_messages keeps the loop's partial conversation (tool iterations included),
79
+ # else turn.messages, with TurnNote.failed(summary, continued: turn.continue) at the tail.
80
+ # :turn_failed {error_class, message, duration_ms}; a provider error adds
81
+ # error_kind, retryable, host and summary.
82
+ → re-raised
83
+
84
+ Interrupt (Ctrl-C):
85
+ → if turn.ended: re-raised only
86
+ → turn.controller.cancel!(:ctrl_c)
87
+ → end_turn(turn, "canceled"): ctrl_c_messages keeps turn.messages with TurnNote.cancelled(:ctrl_c)
88
+ # Both loops work on their own copy of the conversation, and Interrupt is not a
89
+ # StandardError, so nothing attaches the loop's conversation: only what the Engine
90
+ # built before the loop (system head, history, reminders, the prompt) survives.
91
+ # Tasks the turn started are missed in the cancel note for the same reason.
92
+ → :turn_canceled {cancellation_reason: :ctrl_c, duration_ms}
93
+ → re-raised (the caller decides whether to exit)
94
+
95
+ A Stop (Engine#cancel_current_turn!, e.g. the Bridge's POST cancel) or a hook's stop_turn:
96
+ → not an error: the loop returns LLM::ModelResult(canceled: true, conversation: ...)
97
+ → complete_turn keeps the loop's conversation (tool iterations included) with a
98
+ TurnNote.cancelled note; a text streamed before the cancel stays, marked [interrupted]
99
+
100
+ Stopped before the model was asked (prepare_turn returned false):
101
+ → stopped_before_model(turn) → end_turn(turn, "canceled")
102
+ → nothing kept, :turn_canceled {cancellation_reason, cancelled_by, duration_ms}
103
+ → LLM::ModelResult(text: "", canceled: true, cancellation_reason: reason)
104
+ ```
105
+
106
+ ## The model↔tool loop
107
+
108
+ The Engine does not talk to models directly. `Engine#backend` picks the loop the effective model's host speaks:
109
+
110
+ ```
111
+ Engine#backend → backend_for(@host_registry.resolve(@effective_model_name))
112
+ → host entry chat? (api: openai): LLM::ChatLoop (one per Engine, built on first use)
113
+ → else: LLM::NativeBackend wrapping the Engine's KernelLoop
114
+ ```
115
+
116
+ Both implement the `LLM::ModelBackend` contract (`#complete(messages:, max_iterations:, on_stream_event:,
117
+ cancel_controller:, model_name:, max_tool_output_chars:, pending_input:)`). `NativeBackend#complete` only calls
118
+ `KernelLoop#run` with the same arguments. `ChatLoop` runs its own loop but is built with the same KernelLoop,
119
+ whose tools, hooks, gate and dispatch it uses.
120
+
121
+ ### KernelLoop (the native, raw-prompt loop)
122
+
123
+ Runs models whose server takes a raw prompt (llama.cpp `/completion`, or `/v1/completions` on an
124
+ OpenAI-compatible completions server). chi formats the prompt itself, per model profile (Gemma 4, Qwen 3.6), and
125
+ parses tool calls out of the generated text.
126
+
127
+ ```
128
+ KernelLoop#run(messages, max_iterations:, on_stream_event:, cancel_controller:,
129
+ model_name:, max_tool_output_chars:, pending_input:)
130
+ │
131
+ │ turn = start_turn(...) # turn.conversation = prepare_conversation(messages): a copy,
132
+ │ # old model messages without their thought blocks
133
+ │ each iteration:
134
+ │ 1. Queued input (user lines, plugin steers) goes in at the boundary (Steer.inject!)
135
+ │ 2. Format the conversation as prompt text (Prompt.format_with_images); a context
136
+ │ guidance line goes on the tail when the window fills past a threshold
137
+ │ 3. Stream one generation (:generation_started, :generation_chunk, :generation_completed)
138
+ │ 4. Parse tool calls (ToolCallParser for the profile):
139
+ │ → Gemma 4: <|tool_call>call:NAME{params}<tool_call|>
140
+ │ → Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
141
+ │ A Qwen block left open is asked to be finished (QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT = 2)
142
+ │ 5. Calls: dispatch_calls → ToolResponse.run_batch; one tool_response entry for the batch
143
+ │ 6. No calls: an answer, an empty answer to retry (EmptyAnswerRetry), or queued input to answer
144
+ │
145
+ └──→ LLM::ModelResult(text, conversation, exhausted, canceled, tool_activity, ...)
146
+ ```
147
+
148
+ `turn.conversation` is the loop's own copy, not the Engine's `turn.messages`. A failure attaches it to the error
149
+ (`LLM::FailedTurn.attach`), so `Engine#failed_messages` can keep it; a Ctrl-C does not (see Error paths).
150
+
151
+ ### ChatLoop (the OpenAI chat API)
152
+
153
+ Runs hosts with `api: openai`: remote providers such as OpenRouter (Claude and others) and any OpenAI-compatible
154
+ chat server. The model gets the tools as JSON schemas and returns structured tool calls.
155
+
156
+ ```
157
+ ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controller:,
158
+ model_name:, max_tool_output_chars:, pending_input:)
159
+ │
160
+ │ conversation = Array(messages).map(&:dup) # a shallow copy of each message
161
+ │ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input)
162
+ │ .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
163
+ │
164
+ └──→ LLM::ModelResult(text, conversation, exhausted, canceled, tool_activity, ...)
165
+ ```
166
+
167
+ The differences from the native loop:
168
+
169
+ - The tools go in each request's `tools` field (`tool_definitions`), not in the system prompt.
170
+ - The conversation is converted to the OpenAI wire format (`wire_messages`).
171
+ - The first system message carries `cache_split:` (from `SystemPrompt#stable_length`, which the Engine passes in
172
+ as `stable_length:`), where the prompt's stable part ends. For remote Claude, `LLM::PromptCache` puts the
173
+ first cache breakpoint there; see prompt-caching.md.
174
+ - Each call's result is its own tool message, paired by `tool_call_id`.
175
+
176
+ ### Shared: ToolResponse (per batch) and ToolRunner (per call)
177
+
178
+ Both loops run a batch of calls the same way:
179
+
180
+ ```
181
+ One iteration with tool calls:
182
+ KernelLoop#dispatch_calls / ChatLoop::Run#dispatch
183
+ → ToolResponse.run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
184
+ → :tool_dispatch_started {iteration, call_count}
185
+ → for each call: ToolRunner#run(call, iteration:, call_index:, call_count:,
186
+ on_stream_event:, max_tool_output_chars:)
187
+ → Guardrails::Gate#evaluate (the before_tool_call hooks, then the core checks)
188
+ → :tool_call_started
189
+ → an ask verdict: Gate#settle_ask (asks the user, records waited_ms)
190
+ → KernelLoop#dispatch_tool_call, or the denial text
191
+ → :tool_call_completed, then the :after_tool_call hook
192
+ → returns {output:, capped_output:, truncated:, activity:, images:, shown_params:,
193
+ shown_label:, diff:}
194
+ → :tool_dispatch_completed {iteration, call_count}
195
+ → native: ToolResponse.joined(runs), one entry with the full outputs
196
+ chat: ToolResponse.single(run, tool_call_id:) per call, with the capped output
197
+ ```
198
+
199
+ **ToolRunner** (`ToolRunner#run`):
200
+ - Runs the gate first, so `:tool_call_started` shows the call that will run (a before_tool_call hook may
201
+ replace it; known-names corrects a misspelled name this way, and the model gets one line saying what ran).
202
+ - Emits `:tool_call_started` {iteration, call_count, call_index, tool, call, params, label (plugin tools),
203
+ title, view}.
204
+ - Settles an ask verdict after `:tool_call_started`, so a UI shows the tool line and then the approval.
205
+ - Dispatches through `KernelLoop#dispatch_tool_call`, or writes the denial text. Unknown tools are answered in
206
+ `KernelLoop#dispatch` ("Error: unknown tool ...").
207
+ - For edit and write: diffs the file before and after, and refreshes a memory's index line
208
+ (`MemoryBundle::IndexSync`).
209
+ - Attaches returned images, at most `MAX_IMAGES_PER_RESULT` (4) per result.
210
+ - Scrubs bytes that aren't UTF-8, and caps the event's output at `max_tool_output_chars`.
211
+ - Emits `:tool_call_completed` {iteration, call_count, call_index, tool, output, output_truncated, activity,
212
+ images, diff, waited_ms, view}, then fires `:after_tool_call` {iteration, tool, output, status}.
213
+
214
+ A denied call gets denial text in place of its output; the rest of the batch still runs. After a hook's
215
+ `stop_turn`, the gate denies every remaining call of the batch.
216
+
217
+ ## State management
218
+
219
+ ### TurnState (cross-thread)
220
+
221
+ One object, guarded by a `Monitor`, that the turn thread writes (`begin_turn`, `release_turn`) and other threads
222
+ read: the idle scheduler, the Bridge's request threads, plugin threads, the UIs.
223
+
224
+ ```
225
+ TurnState
226
+ ├── running?
227
+ ├── controller (the running turn's CancellationController)
228
+ ├── sink (the turn's on_event)
229
+ ├── steers (plugin steers for the running turn's next boundary)
230
+ ├── carried_steers (for the next turn that begins)
231
+ └── activity clock (last_activity_at, activity_seq)
232
+ ```
233
+
234
+ - `begin!(controller:, sink:)`: running, with controller and sink, in one step; carried steers become steers.
235
+ - `finish!`: not running, controller and sink cleared; returns the steers never taken (logged as dropped).
236
+ - `steer(text, source:)`: queues a steer for the running turn (false with no turn).
237
+ - `steer_next_turn(text, source:)`: queues a steer for the next turn that begins.
238
+
239
+ The lock is a leaf: nothing is called out while it is held.
240
+
241
+ ### Turn (Engine::Turn)
242
+
243
+ The turn as the Engine sees it (the KernelLoop has its own `Turn` struct):
244
+
245
+ ```ruby
246
+ Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
247
+ :settings, :id, :limit) do
248
+ def tag(event) = origin ? event.merge(origin: origin) : event # boundary events carry the origin
249
+ def elapsed = ... # seconds since started_at
250
+ end
251
+ ```
252
+
253
+ - `messages` is what the Engine sends: system head, history, reminders, the prompt. The loop works on its own
254
+ copy; the Engine keeps `result.conversation` after a normal end or a Stop, and the error's partial
255
+ conversation after a failure.
256
+ - `controller` is the turn's `CancellationController`, an in-process signal. Another process stops a worker's
257
+ turn through the Bridge (`POST cancel` → `Engine#cancel_current_turn!`).
258
+ - `on_event` is the turn's sink.
259
+ - `ended` is set by `end_turn`.
260
+
261
+ ### GenerationPhase
262
+
263
+ What the running generation streams, fed by the Engine's stream handler: whether a user's steer may cut it.
264
+ A generation is cuttable while it has streamed only thinking, is still thinking, and has thought for at least
265
+ `steer.cut_after` seconds (`cuttable?(min_age)`). It has `started!`, `chunk!`, `retrying!` and `finished!`.
266
+
267
+ ### WaitingSteer
268
+
269
+ A message that may cut the generation but came too early (the generation hadn't been thinking long enough). It
270
+ waits until the thinking passes `steer.cut_after` (the Engine checks on each thinking chunk), until a drain hands
271
+ the message to the model at a boundary, or until the turn ends (`clear!` in `release_turn`).
272
+
273
+ ## Events
274
+
275
+ ### The turn's sink (`on_event`) and the observers
276
+
277
+ `Engine#emit_event` sends each turn event to the turn's sink (`on_event`, the REPL's renderer) and then to the
278
+ persistent observers (`Engine#subscribe`), which get a copy with a monotonic `event_seq`. The observers are the
279
+ metrics, the debug log, the thinking tails, and in a worker the Bridge, which streams to the web and the attached
280
+ TUI. Errors in the sink or an observer are caught; they never break the turn.
281
+
282
+ `Engine#announce` puts an event that belongs to no turn (`ANNOUNCEABLE_EVENTS`: `turn_enqueued`, `card`,
283
+ `hook_notice`, `guardrail_warning`, `plugin_init_started`, and more) to the observers only.
284
+
285
+ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
286
+
287
+ | Event | When | Payload |
288
+ |---|---|---|
289
+ | `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, continue?, images?}` |
290
+ | `:reminder_injected` | due reminders went into the turn | `{reminders}` |
291
+ | `:context_status` | the window's fill crossed a bucket | `{iteration, ...}` |
292
+ | `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source}` (native adds `profile`, `profile_source`) |
293
+ | `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` |
294
+ | `:generation_retrying` | the transport retries the request | `{iteration, ...}` (the retry's fields) |
295
+ | `:generation_completed` | a generation ended, or was cut | `{iteration, content_length, thinking_chars, served_model, requested_model, finish_reason, ...}`; a cut adds `stopped_by`, `stop_reason`; the Engine adds `speed`, `tokens` |
296
+ | `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
297
+ | `:empty_answer_retry` | an empty or cut answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, ...}` |
298
+ | `:steer_cut` | a steer cut the generation | `{iteration, source}` |
299
+ | `:pending_input_merged` | queued input joined the conversation | `{iteration, count, content, steers?, answer}` |
300
+ | `:tool_dispatch_started` / `:tool_dispatch_completed` | around a batch | `{iteration, call_count}` |
301
+ | `:tool_call_started` | before a call runs | see ToolRunner |
302
+ | `:tool_call_completed` | after a call ran | see ToolRunner |
303
+ | `:used_memories_updated` | a memory was read, and after the loop | `{used_memory_names, read_names?}` |
304
+ | `:hook_notice` | a hook's or plugin's notify during a turn | `{hook, text, level}` (announced with `between_turns: true` outside a turn) |
305
+ | `:guardrail_warning` | a guardrails line during a turn | `{message}` |
306
+ | `:turn_completed` | normal end | `{result, turn_summary, display_pending}` |
307
+ | `:turn_canceled` | Stop, hook stop, Ctrl-C | `{cancellation_reason, cancelled_by?, duration_ms}` |
308
+ | `:turn_failed` | an error | `{error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
309
+ | `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display}` (a string, or nil) |
310
+
311
+ The boundary events (`:turn_started`, `:turn_completed`, `:turn_canceled`, `:turn_failed`) also carry `origin`
312
+ when the turn was queued with one.
313
+
314
+ ### Hook points (not events)
315
+
316
+ `:session_start`, `:before_turn`, `:after_turn` and `:session_end` are hook points: the Engine fires them on the
317
+ hook registry (`@hooks.fire`), not on the sink or the observers. The other hook points are `:before_generation`,
318
+ `:after_generation`, `:generation_progress` (Hooks::StreamWatch, in batches while the response streams),
319
+ `:before_tool_call` (the guardrail gate) and `:after_tool_call` (ToolRunner). docs/hooks.md lists their payloads.
320
+
321
+ ## Session lifecycle
322
+
323
+ ```
324
+ Engine initialization (in this order, among other things):
325
+ @turn_state, @generation_phase, @waiting_steer
326
+ @host_registry, @effective_model_name; @client = @host_registry.resolve(...).client
327
+ @given_profile = profile or nil (resolved on first need, so building an Engine makes no network call)
328
+ @guardrail_wiring, @question_desk
329
+ @hooks = load_hooks_from_config (config.yml hooks), then load_hooks_from_bundles
330
+ @tools = Tools::Builtins.registry; the bundles' plugins load (load_plugins)
331
+ @kernel = KernelLoop.new(client:, profile:, hooks:, reminder_store:, tools:)
332
+ @kernel.guardrail_gate = @guardrail_wiring.gate; @kernel.warmup = PromptWarmup.new
333
+ @hooks.runtime = hook_runtime
334
+ @native_backend = LLM::NativeBackend.new(kernel: @kernel)
335
+ @prompt_builder = SystemPrompt.new(...)
336
+ @session_observer with the metrics, the log subscriber and the thinking tails subscribed
337
+
338
+ Model switching, Engine#switch_model!(model_name, persist_default:, typed:):
339
+ → checks the host (ModelProfile.check_host!), sets @effective_model_name
340
+ → drops the given profile and the profile resolution (resolved again on first need)
341
+ → sync_model_key!, @prompt_builder.reset! (the system prompt is built again)
342
+ → sync_kernel_client! (the kernel talks to the new host), the context window cache dropped
343
+ → the kernel is never rebuilt; the next turn's backend follows the new host's api
344
+
345
+ Model probes (per turn):
346
+ The profile, window, served model and vision are asked from the server's /props as needed.
347
+ The cache is dropped at the turn's end, not its start, so one turn doesn't ask twice.
348
+ A Stop cuts the probes early (Client.swap_probe_cancel).
349
+ ```
350
+
351
+ ## Hooks
352
+
353
+ Plugins and hook files register hooks on hook points (see "Hook points" above, and docs/hooks.md). Every fire
354
+ puts the hook runtime on the event: `event[:hook]` (the hook's label) and five helpers, which call the Engine's
355
+ `Hooks::Runtime`. A plugin's `ctx` has the same ones (`ctx.notify`, `ctx.steer`, ...).
356
+
357
+ | Helper | What it does | Used by |
358
+ |---|---|---|
359
+ | `event[:notify].call(text, level:)` | One line to the user (`:hook_notice`) | mcp (a server didn't start), loop-guard |
360
+ | `event[:ask_user].call(question:, options:, header:, allow_freeform:)` | A single-select question through the question flow; nil when there is no one to ask | known-names (a near-miss name: correct it?) |
361
+ | `event[:stop_turn].call(reason)` | Cancel the running turn (reason `:hook`), with a warn notice | loop-guard (repeated calls), check-in (`/checkin stop`) |
362
+ | `event[:stop_generation].call(reason)` | Cut the streaming generation; the turn goes on | loop-guard (thinking repeats itself) |
363
+ | `event[:steer].call(text)` | Queue text for the running turn's next boundary | check-in (nudge mode), skills |
364
+
365
+ From `:after_turn` and `:session_end` there is no turn left: `stop_turn` and `steer` return false.
366
+
367
+ ### Hook lifecycle
368
+
369
+ - **Bundle hooks** (`Registry#register_bundle`) and **config hooks** (`register_persistent`, from config.yml)
370
+ live for the process: `clear_all` leaves them, so they apply to every turn.
371
+ - **Turn-scoped hooks** (`Engine#register_hook`, `Registry#register`) are cleared by `clear_hooks` in
372
+ `release_turn`.
373
+ - Order of a fire: bundle hooks (by priority, bundle, hook name), then config hooks, then turn-scoped hooks.
374
+ - A hook that raises is logged; it never breaks the turn.
375
+ - A notice from a hook goes to the running turn's sink and the observers. Outside a turn it is announced with
376
+ `between_turns: true`; while the plugins load it is held and announced after.
377
+ - Plugin init tasks (`chi.init`, slow setup such as an MCP server's first start): a turn waits for the ones that
378
+ provide tools before it builds its messages, and a Stop or Ctrl-C ends the wait.
379
+
380
+ ## Guardrails
381
+
382
+ `ToolRunner` asks the kernel's `Guardrails::Gate` (set by the Engine from `GuardrailWiring`) before each call;
383
+ the gate is not itself a hook. `Gate#evaluate` fires the `:before_tool_call` hooks (the voters, e.g. the
384
+ known-names bundle or a config hook), which vote through `event[:guardrail]` (`deny!`, `ask!`) or the legacy
385
+ `event[:blocked]` flag. A deny stays a deny for the hooks after it. Then the core checks run on the final call:
386
+ protected paths, then the rules (config and bundles, e.g. the guardrails bundle's `rules.yml`; a rule can match
387
+ the tool, its targets and the model). A gate that raises denies the call.
388
+
389
+ The verdict is one of:
390
+
391
+ - **allow**: the call runs.
392
+ - **deny**: that call's output is replaced with denial text; the rest of the batch still runs.
393
+ - **ask**: `Gate#settle_ask` first looks for a stored approval that covers the call. Otherwise
394
+ `Engine#request_approval` asks the user through the question flow (REPL, attached TUI, web). A
395
+ `--non-interactive` run has no one to ask, so the call is denied. An approval wider than this once is stored.
396
+
397
+ docs/guardrails.md covers the rules and the approval scopes.
398
+
399
+ ## Anytime commands
400
+
401
+ A slash command a plugin registers with `anytime: true` (the btw bundle's `/btw`) runs on its own thread, even
402
+ while a turn runs:
403
+
404
+ ```
405
+ Engine
406
+ ├── spawn_anytime { ... } # a thread #shutdown waits for
407
+ ├── running_anytime { ... } # cards and notices on this thread are announced at once,
408
+ │ # marked anytime: true, never as the running turn's events
409
+ ├── add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed:)
410
+ └── await_init_tasks(controller, on_event)
411
+ ```
412
+
413
+ `add_init_task` and `await_init_tasks` belong to plugin init tasks (`PluginTasks`), not to anytime commands.
414
+
415
+ ## Shared loop behaviors
416
+
417
+ Both loops:
418
+
419
+ - **Empty-answer retry** (`EmptyAnswerRetry`): a generation with no visible text and no tool calls is asked
420
+ again, up to `retry.empty_answer` times (at most 3), with a hidden nudge (`TurnNote.empty_retry`) on the tail.
421
+ The retry request runs at temperature 0.6 when the configured sampling sets no temperature; a configured one
422
+ is kept. A length stop with the context at least 90% full is not retried. When the retries run out, the
423
+ Engine keeps a `TurnNote.empty` note as the turn's end.
424
+ - **A cut generation** (`after_cut`): a generation a plugin cut (`stop_generation`) spends the same retry budget
425
+ with its own nudge; when it is used up, the turn ends cancelled (`:hook`). A steer's cut asks again with the
426
+ steer and spends nothing.
427
+ - **Steering**: queued input goes in at iteration boundaries (`Steer.inject!`, `:pending_input_merged`).
428
+ - **Context status**: an estimate of the window's fill before each request (`:context_status`), with a guidance
429
+ line on the tail when it rises far enough.
430
+ - **Cancel**: text streamed before a cancel stays, marked `[interrupted]`.
431
+ - **The same events**: generation events, tool dispatch and tool call events, and the before/after generation
432
+ and tool call hooks.
433
+
434
+ Native loop only: the Qwen open-block recovery, and the thinking prefill (Qwen with thinking off gets an empty
435
+ thought after the cue, kept in the model message so the next prompt starts with what the server has cached).
436
+
437
+ ## Key design principles
438
+
439
+ 1. **Turn state is cross-thread safe.** TurnState uses one Monitor and calls nothing out while it is held.
440
+ 2. **Turn-scoped and process-wide hooks.** `register_hook` hooks are cleared at `release_turn`; bundle hooks
441
+ and config.yml hooks survive.
442
+ 3. **Each loop works on its own copy of the conversation.** The Engine's `turn.messages` is what it sent. After
443
+ a normal end or a Stop it keeps `result.conversation`; after a failure, the copy attached with
444
+ `FailedTurn.attach`; after a Ctrl-C, only `turn.messages`.
445
+ 4. **Events are the contract.** UIs read events, not internal state. `on_event` is per turn; observers are
446
+ persistent.
447
+ 5. **A stable system prompt for the cache.** `SystemPrompt#build` caches the prompt per Engine (per chat/native
448
+ and thinking level) and is reset only on a model switch, a tools change or a profile change. The stable part
449
+ (base prompt, identity, memories, AGENT.md, memory indexes) comes before the volatile tail (model, working
450
+ directory, session). prompt-caching.md covers this in detail.
451
+
452
+ ## Key files
453
+
454
+ Paths are under `lib/samagotchi/`.
455
+
456
+ | File | Role |
457
+ |---|---|
458
+ | `engine.rb` | Engine: turn lifecycle, event emission, hooks runtime, plugins, model switching |
459
+ | `turn_state.rb` | Cross-thread turn state (running, controller, sink, steers, activity clock) |
460
+ | `generation_phase.rb`, `waiting_steer.rb` | Whether a steer may cut the running generation, and the steer that waits to |
461
+ | `steer.rb` | Queued input and steers at iteration boundaries |
462
+ | `kernel_loop.rb` | Native loop: `#run` (format, generate, parse, dispatch, iterate) |
463
+ | `llm/chat_loop.rb` | Chat loop: `#complete` (tool schemas per request, wire conversion) |
464
+ | `llm/backend.rb` | The `LLM::ModelBackend` contract (`#complete(...)`) |
465
+ | `llm/native_backend.rb` | Wraps KernelLoop as a backend (`#complete` calls `#run`) |
466
+ | `llm/model_result.rb` | `LLM::ModelResult`, what a backend returns |
467
+ | `tool_runner.rb` | Per-call path (gate, start/complete events, output cap, images, diffs) |
468
+ | `tool_response.rb` | Per-batch dispatch and tool_response entries (joined for native, single for chat) |
469
+ | `empty_answer_retry.rb` | The empty-answer and cut retry budget |
470
+ | `prompt_warmup.rb` | The turn-end warm-up |
471
+ | `tools/registry.rb` | The tools a session offers: schemas, handlers, order |
472
+ | `system_prompt.rb` | System prompt construction (stable part, volatile tail) |
473
+ | `hooks/registry.rb` | Hook registry, fire order, `Hooks::Runtime` |
474
+ | `guardrail_wiring.rb`, `guardrails/gate.rb` | The gate's context and approvals; the verdict per call |
475
+ | `events.rb` | Event type sets shared by the Worker, the Bridge, the TUI and the web |
476
+ | `cancellation_controller.rb` | A turn's cancel signal, and a generation's child controller |
477
+ | `session.rb` | Session model (status, messages, persistence) |
478
+ | `session_manager.rb` | Background session processes (workers) and their IPC |
@@ -60,9 +60,30 @@ right-hand column happens.
60
60
  #2, so the **next request in the session** reads everything up to it. Mid and tail system messages never take
61
61
  a breakpoint. The split point comes from `SystemPrompt#stable_length` via the message's `cache_split:`, which
62
62
  never goes on the wire.
63
- - Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt. On a
64
- single-slot local server they can push the session's prefix out of the cache. Fixing that is slice 2 of the
65
- plan (R4 `id_slot`, R2 turn-end warm-up).
63
+ - Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt. On a current
64
+ llama.cpp they don't evict the session (see the server settings below); chi never pins them to a slot.
65
+
66
+ ## The turn-end warm-up
67
+
68
+ The next turn's prompt differs from the turn's last request at its tail: the previous answer's thinking is
69
+ stripped (the models' specs require it). A hybrid model on llama.cpp can only resume from a restore point, so the
70
+ next turn prefills from well before the end, more with every turn. `PromptWarmup` moves that work into the time
71
+ the user reads: after a completed turn, `Engine#warm_up_next_turn` formats the next prompt up to where the new
72
+ user message starts (`KernelLoop#warmup_prompt`) and sends it in a thread (`Client#warm_up`: native
73
+ `/completion`, `n_predict: 1`, not streamed, no retries, log purpose `warmup`, then a `model warmup` line with
74
+ `cached=`, `prefilled=` and `prefill_ms=`).
75
+
76
+ - Only on a local llama.cpp host on the native loop, with `cache.warmup: auto` (the default). Never a remote or
77
+ paid host, never the chat loop (Splash keeps its own cache; llama.cpp's chat path is not measured).
78
+ - Not after a failed, cancelled or step-limited turn, nor when a reminder is due, a steer is carried or input
79
+ waits (the next turn starts at once), nor in a `--non-interactive` run.
80
+ - **Slot rule.** The warm-up runs on the slot the turn's last request used (`id_slot` from its stream). The next
81
+ request is pinned to that slot only while the warm-up still runs, so it queues behind it; once the warm-up is
82
+ done nothing is pinned. Pinning across an idle gap is the one thing that hurts: a pinned request to a slot the
83
+ server has since cleared skips its prompt cache and prefills everything (measured 0.5 s → 12 s). For the same
84
+ reason `id_slot` is a reserved sampling key.
85
+ - What breaks it (the warm-up simply misses): a system-prompt rebuild (`/thinking`, `/model`, a tool change),
86
+ the image window sliding, `!rollback`.
66
87
 
67
88
  ## Rules for changes
68
89
 
@@ -85,10 +106,15 @@ right-hand column happens.
85
106
 
86
107
  - **Splash:** `--persistent-cache` (with `--max-cache-disk`) keeps restore points across restarts. Splash learns
87
108
  a new branch point on the 2nd session, so the 3rd one gets the full reuse.
88
- - **llama.cpp:** a single slot is shared by everything sent to it. `--parallel N` and `--cache-ram` give side
89
- requests room without evicting the session. `--ctx-checkpoints` controls how many restore points a hybrid
90
- model keeps.
109
+ - **llama.cpp:** `--cache-ram` (the host-memory prompt cache, 8 GiB by default in recent builds) is what keeps
110
+ sessions apart: when a task starts on a slot, the idle slots' state goes to that cache, and an unpinned request
111
+ restores the best match on whatever slot it gets. So a recap or another session between two turns costs
112
+ nothing (measured on Ornith: the next turn still reuses 11.9k of 12.3k tokens). Raise it for many or long
113
+ sessions (roughly 0.25–0.3 GB per 12k-token Ornith session). `--parallel` matters little; pinning a request to
114
+ a slot (`id_slot`) is the hazard. With `--cache-ram 0` or an old single-slot build, a recap does evict the
115
+ session. `--ctx-checkpoints` controls how many restore points a hybrid model keeps. chi sends
116
+ `cache_prompt: true` on every native request.
91
117
  - **OpenRouter / Anthropic:** the default cache TTL is 5 minutes. Provider routing (which upstream serves a
92
118
  request) also decides whether a cache is there to hit.
93
119
 
94
- Still to come: side requests on their own slot, a turn-end warm-up, image-window batches and longer remote TTLs.
120
+ Still to come: image-window batches and longer remote TTLs.