samagotchi 0.76.0 → 0.78.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +10 -6
  5. data/docs/internals/anthropic.md +73 -5
  6. data/docs/internals/engine.md +2 -1
  7. data/docs/internals/prompt-caching.md +3 -1
  8. data/docs/plugins.md +42 -0
  9. data/docs/sessions.md +1 -0
  10. data/lib/samagotchi/answer_command.rb +5 -5
  11. data/lib/samagotchi/bridge.rb +3 -3
  12. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/child_move.rb +2 -2
  17. data/lib/samagotchi/child_reports.rb +3 -3
  18. data/lib/samagotchi/children_status.rb +2 -2
  19. data/lib/samagotchi/engine.rb +21 -12
  20. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  21. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  22. data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
  23. data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
  24. data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
  25. data/lib/samagotchi/llm/chat_loop.rb +49 -2
  26. data/lib/samagotchi/llm/errors.rb +6 -4
  27. data/lib/samagotchi/llm/http.rb +2 -1
  28. data/lib/samagotchi/llm/openai_chat.rb +12 -2
  29. data/lib/samagotchi/memory_bundle/builder.rb +2 -0
  30. data/lib/samagotchi/message_parts.rb +272 -0
  31. data/lib/samagotchi/note_delivery.rb +2 -2
  32. data/lib/samagotchi/parent_report.rb +30 -22
  33. data/lib/samagotchi/pending_question.rb +49 -0
  34. data/lib/samagotchi/plugin/sessions.rb +2 -2
  35. data/lib/samagotchi/question_desk.rb +39 -32
  36. data/lib/samagotchi/relay.rb +8 -9
  37. data/lib/samagotchi/relay_verifier.rb +2 -2
  38. data/lib/samagotchi/relay_watcher.rb +2 -2
  39. data/lib/samagotchi/reply_wait.rb +6 -7
  40. data/lib/samagotchi/row_refs.rb +1 -1
  41. data/lib/samagotchi/session.rb +22 -14
  42. data/lib/samagotchi/session_commands.rb +4 -1
  43. data/lib/samagotchi/session_manager.rb +0 -1
  44. data/lib/samagotchi/session_metrics/totals.rb +4 -3
  45. data/lib/samagotchi/session_metrics.rb +9 -0
  46. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  48. data/lib/samagotchi/terminal_ui/formatting.rb +6 -106
  49. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  50. data/lib/samagotchi/terminal_ui.rb +7 -5
  51. data/lib/samagotchi/tool_output_cut.rb +1 -1
  52. data/lib/samagotchi/tool_response.rb +13 -13
  53. data/lib/samagotchi/tool_row_fields.rb +1 -1
  54. data/lib/samagotchi/tool_runner.rb +16 -9
  55. data/lib/samagotchi/tools/delegate.rb +2 -2
  56. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  57. data/lib/samagotchi/tools/delegate_result.rb +2 -2
  58. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  59. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  60. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +2 -2
  63. data/lib/samagotchi/web/public/app.js +234 -74
  64. data/lib/samagotchi/web/public/composer_fold.js +58 -0
  65. data/lib/samagotchi/web/public/composer_size.js +10 -0
  66. data/lib/samagotchi/web/public/ctx.js +12 -1
  67. data/lib/samagotchi/web/public/index.html +29 -4
  68. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  69. data/lib/samagotchi/web/public/stream_handlers.js +7 -3
  70. data/lib/samagotchi/web/public/toasts.js +69 -0
  71. data/lib/samagotchi/worker.rb +1 -0
  72. metadata +7 -2
  73. data/lib/samagotchi/web/message_parts.rb +0 -274
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2bec0eb4ac3d46dcca5ca46f2496c4c419d1373ea595fcceae35d5a96098899
4
- data.tar.gz: b9be22b2481f7489457eceb98eb1ace0cc3b58988ab34790d0c40197dc586ccc
3
+ metadata.gz: 1b19c3433cc4f46956563a8e4e6532d8b60061084b8050b534b9230b3c608c54
4
+ data.tar.gz: 97b8e078c43e1c9901139f1f937d16e43b57b6147cd8298fbb9b77abb37c0b96
5
5
  SHA512:
6
- metadata.gz: f8d1c6f29de97f66b9ece157418f02628939f98c9a94926bbde5536fd2e1d126ca42cfce51497290f32e225d0ac169b81eda6e4aa1836c1d4d7bdd94d1d4c929
7
- data.tar.gz: 9811070c6def3fe473d2e5fba2a044c25e1d843c12097b738a14854e1a9c6064e84f1b77ae56960166fceac101eb06aca6cbcb03c148e3017c034594cdb246a0
6
+ metadata.gz: '0022706491e62a2d7f603a5290fd4c5525b17c6712af6d31dc5ce163b36c4e4414074d7b4da609f890ec5e243ea89b33a5ba0621528d8face6c5226108d0a443'
7
+ data.tar.gz: da23fef030284a3401d5ec17017ef2a7d1165d30909f4a96e095823315bc38bbbe1eaf1bdfd76eefe604af19cd160c5f68fd19cb3c4ccb478b28bd9ea1de57eb
data/CHANGELOG.md CHANGED
@@ -8,6 +8,54 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.78.0] - 2026-10-17
12
+
13
+ ### Added
14
+
15
+ - Web: dragging the message box's top-edge grip down folds the box away for room to read; the info bar stays at the
16
+ bottom, and the grip stays on its top edge as a handle. Pull the handle up or click it, or just type on the page, to
17
+ bring the box back (a draft marks the handle in the accent, a session waiting on you in the attention colour), and
18
+ Stop turn moves into the info bar while it is folded. The choice is remembered per browser, and an archived session
19
+ opens folded.
20
+
21
+ ### Changed
22
+
23
+ - Workers no longer load the terminal UI or Reline, saving about 50–100 ms and 2 MB per worker start; a plugin
24
+ that uses Reline, StringIO, Forwardable or Tempfile must now require it itself.
25
+
26
+ ### Fixed
27
+
28
+ - Claude 5.x on `api: anthropic`: a request whose earlier thinking the API refuses after the system prompt or tools
29
+ changed (a resume or respawn after memory or AGENTS.md edits, `/model`, `/thinking`, `/toolkit`) no longer fails the
30
+ turn. chi asks again with Anthropic's binding controls (or without those thinking blocks), drops that thinking from
31
+ the session, and `/stats` counts it as `thinking dropped`.
32
+
33
+ Update with `chi update` (the system bundle 0.78.0), then restart `chi web`.
34
+
35
+ ## [0.77.0] - 2026-10-17
36
+
37
+ ### Changed
38
+
39
+ - A session's worker starts about 80 ms faster: it loads the HTML parser (Nokogiri) only when `web_fetch` first reads
40
+ an HTML page.
41
+
42
+ ### Fixed
43
+
44
+ - Web: toasts stack (up to three) instead of replacing one another, so a later notice no longer hides a sticky error
45
+ such as "Can't send: …"; each one has its own timer and ✕.
46
+ - After `/model`, a session's card drops the old model's context % right away (the new window's %, or none while it
47
+ is unknown), in every tab and after a reload, instead of keeping it until the next turn ends.
48
+ - Web: the context chip and its tooltip update as soon as a generation starts, not at its first chunk.
49
+ - Web: a model ref longer than 40 characters is cut in the middle in the ctx tooltip's per-model rows, so it no
50
+ longer stretches the tooltip.
51
+ - REPL (`--no-shared`): the status row drops the old context % after `/model` when the new window is unknown.
52
+ - source-links: a URL in the terminal answer no longer takes in trailing punctuation (`.`, `,`, `)` …) or an unmatched
53
+ `]`, the same as in the web.
54
+ - `chi bundle build` skips a symlinked memory (`notes.md is a symlink (not included)`) instead of copying the file it
55
+ links to into the bundle.
56
+
57
+ Update with `chi update` (the system bundle 0.77.0, `source-links` 0.5.1), then restart `chi web`.
58
+
11
59
  ## [0.76.0] - 2026-10-17
12
60
 
13
61
  ### Security
@@ -3221,7 +3269,9 @@ and long-lived sessions.
3221
3269
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3222
3270
  hotkey panel that send selected text or the clipboard to your sessions.
3223
3271
 
3224
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.76.0...HEAD
3272
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.78.0...HEAD
3273
+ [0.78.0]: https://github.com/dm1try/samagotchi/compare/v0.77.0...v0.78.0
3274
+ [0.77.0]: https://github.com/dm1try/samagotchi/compare/v0.76.0...v0.77.0
3225
3275
  [0.76.0]: https://github.com/dm1try/samagotchi/compare/v0.75.0...v0.76.0
3226
3276
  [0.75.0]: https://github.com/dm1try/samagotchi/compare/v0.74.0...v0.75.0
3227
3277
  [0.74.0]: https://github.com/dm1try/samagotchi/compare/v0.73.0...v0.74.0
data/docs/cli.md CHANGED
@@ -459,7 +459,8 @@ request (with the cached tokens, the cache writes, the re-prefilled tokens and t
459
459
  reports them),
460
460
  the decode speed, generation latency, cancellations, retries (the network's),
461
461
  thinking cuts (generations a plugin such as loop-guard cut) and output cap hits
462
- (generations that ended at the provider's cap), context used and window (the used figure is counted against the smaller of the window and the session's LLM context budget, and says so: `context used: 16000 tokens (25.0% of the 64000 budget)`), the memory indexes the session's prompt holds ([Memory](memory.md#the-indexs-size)), the model notes it carried (`model notes: model_notes_deepseek (system, 612 chars)`, none without any; [Memory](memory.md#model-notes)), the toolkit's prompt text (`kit prompt: coding (~20 tokens)`, only when its kit has one; [Toolkits](configuration.md#toolkits)), the prompt profile and the model the server says it ran, and the
462
+ (generations that ended at the provider's cap), the thinking blocks a Claude 5.x preserved-thinking recovery dropped
463
+ (`thinking dropped:`, only when there were any; [configuration](configuration.md)), context used and window (the used figure is counted against the smaller of the window and the session's LLM context budget, and says so: `context used: 16000 tokens (25.0% of the 64000 budget)`), the memory indexes the session's prompt holds ([Memory](memory.md#the-indexs-size)), the model notes it carried (`model notes: model_notes_deepseek (system, 612 chars)`, none without any; [Memory](memory.md#model-notes)), the toolkit's prompt text (`kit prompt: coding (~20 tokens)`, only when its kit has one; [Toolkits](configuration.md#toolkits)), the prompt profile and the model the server says it ran, and the
463
464
  cost when the provider reports one (OpenRouter's `usage.cost`), and the cost
464
465
  estimated from a price under `hosts.<name>.models` when it reports none or 0,
465
466
  marked `~` and naming the current model's host (`cost: $0.42 reported + ~$0.12 from hosts.gw.models prices (this session only)`;
@@ -343,12 +343,16 @@ hosts:
343
343
 
344
344
  `auth:` works on any host (`bearer` is the default everywhere but `api: anthropic`).
345
345
 
346
- **Known limit (until a later release):** Claude 5.x models (Opus, Sonnet and Haiku 5.5, Fable 5.1) bind each
347
- thinking block to the request it came from. When the history before a block changes (the system prompt rebuilt after a
348
- worker restart or a `/model` switch, a changed tool set, the forget layer's edits), such a model may answer HTTP 400
349
- "… bound to a different conversation" and the turn fails with that error; `/reasoning-history turn` makes it rarer.
350
- A later release sends Anthropic's binding controls and recovers from that 400. Fireworks, Splash and other
351
- non-Claude models are not affected.
346
+ **Claude 5.x preserved thinking:** Claude 5.x models (Opus, Sonnet and Haiku 5.5, Fable 5.1) bind each thinking
347
+ block to everything before it: the system prompt, the tools and the earlier messages. chi keeps the history
348
+ append-only, but the system prompt or the tools can change under a session: a resume or a worker respawn after
349
+ memory or AGENTS.md edits, `/model`, `/thinking`, `/toolkit`, a plugin's tools, or an `llm_context` layer stubbing
350
+ older outputs. Then the API refuses the earlier thinking once (HTTP 400 "… bound to a different conversation"); chi
351
+ asks again with Anthropic's binding controls (or without those blocks), drops that thinking from the session for
352
+ good, and no turn fails. `/stats` counts it (`thinking dropped: N blocks (preserved-thinking recovery)`), and each
353
+ time costs one extra (free) 400 and one cache write. `reasoning_history` on these hosts is `all` by default; `turn`
354
+ drops more reasoning but never needs a recovery at a turn's start. Fireworks, Splash and other non-Claude models
355
+ never see these requests. internals/anthropic.md has the details.
352
356
 
353
357
  The loop, tools, events, hooks and UIs are the chat loop's, as for `api: openai`; only the wire differs: the
354
358
  system prompt goes as the request's `system` (later system notes as user text), tool calls as `tool_use` blocks,
@@ -17,7 +17,10 @@ ChatLoop::Run#generate
17
17
  → POST {base}/v1/messages # LLM::HTTP: retries, cancel, first-token limit
18
18
  → AnthropicAssembly#add per SSE event # → ChatResponse (text, reasoning, tool_calls, usage,
19
19
  # finish_reason, provider_content)
20
+ → a signature 400 (Claude 5.x preserved thinking): #binding_recovery, then the same
21
+ request with drop_block or the blocks stripped; ChatResponse#thinking_drop names them
20
22
  → a 400 it can fix: #refusal_fix, then the same request again (once per kind)
23
+ → ChatLoop#settle_dropped_thinking! # the dropped blocks leave the stored model entries
21
24
  ```
22
25
 
23
26
  ## Files
@@ -32,7 +35,7 @@ Paths are under `lib/samagotchi/llm/`.
32
35
  | `anthropic_assembly.rb` | `AnthropicAssembly`: a response from a whole body (`.from_message`) or stream events (`#add`), its usage in the OpenAI shape |
33
36
  | `dialect.rb` | `Dialect` (`STANDARD`, `FIREWORKS`): what a provider's `/v1/messages` does differently |
34
37
  | `prompt_cache.rb` | `PromptCache.control`: the `cache_control` a breakpoint carries (`cache.ttl`) |
35
- | `chat_loop.rb` | `ChatLoop#replay_blocks`, `#reasoning_history`'s floor, `Run#with_thinking` (saves `anthropic_content`) |
38
+ | `chat_loop.rb` | `ChatLoop#replay_blocks`, `#reasoning_history`'s floor, `Run#with_thinking` (saves `anthropic_content`), `#settle_dropped_thinking!` |
36
39
 
37
40
  ## The adapter
38
41
 
@@ -112,8 +115,70 @@ text and `tool_use` blocks (a tool loop with thinking needs the last assistant t
112
115
  - `ChatLoop#reasoning_history` floors the level at `turn` on a replaying adapter: `drop` would remove the running
113
116
  run's thinking, which Messages needs. For the same reason a reasoning 400 doesn't ask again without reasoning
114
117
  (`Run#generate`: it would resend the same body).
115
- - A 400 about a thinking block's signature (`BadRequest#thinking_signature?`, a Claude 5.x block bound to a
116
- different conversation) fails the turn for now; configuration.md has the known limit.
118
+ - A 400 about a thinking block's signature (`BadRequest#thinking_signature?`) is recovered in the adapter, and the
119
+ blocks it cost leave the history (next section).
120
+
121
+ ## Preserved thinking (Claude 5.x)
122
+
123
+ Claude 5.x models bind each thinking block to the prefix it was minted after: both `system` blocks (the stable part
124
+ and the per-session tail), the tool set, and every earlier message. Sent back after any of those changed, the block
125
+ gets a 400 `messages.N.content.M: Invalid signature in thinking block. The block is bound to a different
126
+ conversation.`, and its last sentence says what changed ("The `system` prompt differs …"). chi's history is
127
+ append-only within a process, so what changes the prefix is:
128
+
129
+ 1. **A system-prompt rebuild** (`SystemPrompt#build` is cached per process and key; `PromptBuilder#reset!` drops it):
130
+ a new process (`--resume`, a respawn after `session.idle_exit_minutes`, a crash) after a memory index line, an
131
+ AGENTS.md, an appended file, a model note or a bundle memory changed; `/model` (the tail's `Model:` line, its
132
+ overlays and notes); `/thinking` (another key); a toolkit or profile change; a plugin's `tools_changed!`.
133
+ 2. **A tool set change**: the same triggers, plus MCP lines that differ on a respawn.
134
+ 3. **History edits by design**: the `llm_context` `stale`/`forget` stubs, and the image cut moving a batch.
135
+
136
+ A model switch never 400s: the API drops blocks another model can't read silently (`model_binding_mismatch`).
137
+
138
+ **Recover on the 400, then settle.** Normal requests carry no beta header and no `block_binding`, so Fireworks,
139
+ Splash and gateways see the same bytes as before. On a signature 400, before anything streamed,
140
+ `AnthropicMessages#binding_recovery` picks the next step, once each per request:
141
+
142
+ - **drop_block**: the same body with `thinking.block_binding: {prefix_mismatch_behavior: "drop_block"}` (the body's
143
+ adaptive or enabled thinking, `display` as sent; adaptive when the level sends none) and the header
144
+ `anthropic-beta: thinking-binding-controls-2026-08-01`. The answer's `input_transformations` (in `message_start`'s
145
+ message, or the body; `AnthropicAssembly#input_transformations`) list the dropped blocks by path into the body
146
+ sent; the adapter maps the `prefix_binding_mismatch` ones to block identities. Any other entry
147
+ (`model_binding_mismatch`: a switch back reads those blocks again) is counted as kept, never settled.
148
+ - **strip**: the block the 400 names (else the first replayed thinking block) and every thinking or
149
+ redacted_thinking block after it removed from the body (a trailing run, which Messages takes). For a bad
150
+ signature (the 400 without "bound to a different conversation": drop_block doesn't apply), thinking `disabled`
151
+ (block_binding goes only with adaptive or enabled), a model on the budget form with no thinking field, a host
152
+ that refused the controls (`binding_controls`, remembered), or a second signature 400 after drop_block.
153
+
154
+ A third signature 400 fails the turn. The extra requests are 400s, rejected before generation and not billed. The
155
+ step is request state that `#request_body`'s rebuild keeps, so a later `#refusal_fix` (display, cache_control,
156
+ output cap) in the same request keeps the binding or the strip. The response carries `thinking_drop`
157
+ (`LLM::ThinkingDrop`: `identities`, `how`), and the adapter logs `thinking_recovered` (`how`, `dropped`, `kept`,
158
+ `why`: the 400's last sentence).
159
+
160
+ A block's identity (`AnthropicWire.block_identity`, shared by the adapter and ChatLoop) is its `signature`, a
161
+ redacted block's `data`, or, when that is empty (Splash's `signature: ""`), `text:` + the SHA256 of its text.
162
+
163
+ `ChatLoop::Run#generate` hands those identities to `ChatLoop#settle_dropped_thinking!` before the step adds its own
164
+ entry, whatever the finish reason: each model entry holding one is replaced in place (same index) with a copy
165
+ without those blocks (`thinking:` stays; an entry left with none loses `anthropic_content`). That is the edit the
166
+ API made, so the next request is valid with no beta (a pruned history is valid: removing exactly the dropped set
167
+ leaves no gap), and the session file keeps it, since `Engine#kept_messages` and `#failed_messages` take the run's
168
+ conversation whole. It emits `:thinking_dropped` (`count`, `how`) and `thinking_dropped` on `:generation_completed`,
169
+ which `SessionMetrics` sums for `/stats`. A drop that settles nothing (blocks already settled came back, e.g. a
170
+ rolled-back turn's) logs `thinking_recovery_repeated` once per run. A cancelled, cut or dropped generation settles
171
+ nothing; the next request recovers again.
172
+
173
+ Each break costs one free 400, one cache write of the prefix from the edit point, and the reasoning the dropped
174
+ blocks carried. Whether the request after a drop_block one reads the cache that request wrote (it no longer carries
175
+ `block_binding`) is unverified.
176
+
177
+ Why not send drop_block on every request: it needs adaptive or enabled thinking on the body (`default` sends no
178
+ thinking field, `off` sends disabled), it would put a beta header on every request to every host, and it would drop
179
+ the same blocks again on every request until something pruned them. The reactive form does the same work in one
180
+ place, for one extra 400 round trip per break. The `anthropic-thinking-prefix-mismatch` diagnosis header (on the
181
+ 400, undocumented) is kept on the provider error and logged as `diagnosis` on `thinking_recovered`.
117
182
 
118
183
  ## Cache breakpoints
119
184
 
@@ -152,7 +217,10 @@ adapter's life, so ChatLoop's broader paths never see them (`#refusal_fix`, logg
152
217
  | adaptive thinking or its effort (`ADAPTIVE_REFUSED_RE`) | the budget form: `thinking: {type: "enabled", budget_tokens: BUDGETS[level]}`, no effort (`#budget_form`) | per model (`thinking_form_switched`) |
153
218
  | `cache_control` | no breakpoints | per host (`cache_control_refused`) |
154
219
  | `max_tokens: N > M` (`OUTPUT_CAP_RE`) | `max_tokens` capped at M | per model (`output_cap`) |
220
+ | `block_binding` or the binding beta, on a drop_block retry (`thinking_binding_unsupported?`) | the strip instead (Preserved thinking) | per host (`binding_controls_refused`) |
155
221
 
156
222
  The display check comes before the adaptive one: its path (`thinking.adaptive.display`) names adaptive too. A context
157
- overflow, a refused sampling field, a signature or a binding-controls 400 is not one of these: it goes on as it is,
158
- to the loop's own handling (ChatLoop's sampling and thinking-fields fallbacks) or to the turn's failure.
223
+ overflow, a refused sampling field, a signature or a binding-controls 400 is not one of these (`#refusal_fix` keeps
224
+ excluding them, so `ADAPTIVE_REFUSED_RE` never reads a binding 400's "adaptive" as a form refusal): a signature 400
225
+ goes to the binding recovery above, the rest on as they are, to the loop's own handling (ChatLoop's sampling and
226
+ thinking-fields fallbacks) or to the turn's failure.
@@ -290,7 +290,7 @@ One iteration with tool calls:
290
290
  replace it; known-names corrects a misspelled name this way, and the model gets one line saying what ran).
291
291
  - Emits `:tool_call_started` {iteration, call_count, call_index, tool, call, params, label (plugin tools),
292
292
  title, called_as, via, view, ref, refs}. The row fields (title, called_as, via, view, ref) come from `ToolRowFields.for`, which the
293
- reload (`Web::MessageParts#tool_part`, after `BuiltinCalls.unwrap_saved`) calls too. `ToolRowFields::ACTIVITY_KEYS` (title, called_as, via) travel
293
+ reload (`MessageParts#tool_part`, after `BuiltinCalls.unwrap_saved`) calls too. `ToolRowFields::ACTIVITY_KEYS` (title, called_as, via) travel
294
294
  inside `activity` on the completed event; `EVENT_KEYS` (view, ref, refs) go top-level on both events.
295
295
  `Bridge::TurnAccumulator` (snapshot and replay) and the web (`activity.js` `ACTIVITY_FIELDS` /
296
296
  `EVENT_FIELDS`) copy them by these lists, so a new row field is added there. `ref` is `FileRef.for` (a
@@ -473,6 +473,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
473
473
  | `:generation_retrying` | the transport retries the request, or the chat loop asks again for a step whose stream dropped mid-answer (`restarted: true`: what the step streamed is void, and the web's live step, the TUI's activity line, the Bridge's TurnAccumulator and the stream hooks' watch start it over) | `{iteration, attempt, max_retries, next_delay, error_class, error_message, status, restarted?}` |
474
474
  | `: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`, `model_segments` |
475
475
  | `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
476
+ | `:thinking_dropped` | the chat loop settled a preserved-thinking recovery: the adapter dropped replayed thinking blocks and they left the history (`ChatLoop#settle_dropped_thinking!`; the step's `:generation_completed` carries `thinking_dropped`; internals/anthropic.md) | `{iteration, count, how}` (`how`: `:drop_block` or `:strip`) |
476
477
  | `:empty_answer_retry` | an empty, cut or malformed answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, stopped_by?, malformed?}` |
477
478
  | `:steer_cut` | a steer cut the generation | `{iteration, source}` |
478
479
  | `:llm_context_edited` | the apply rule applied a batch of LLM context edits (a request, a forget_outputs call, turn end; never the warm-up) | `{moment, why, freed_tokens, tail_tokens, staged, text, groups}` (`LLMContextNotice`) |
@@ -171,7 +171,9 @@ is prefilled once. The native and the chat loop use the same plan.
171
171
  first reply, on a server that renders it; a stock Qwen template without `preserve_thinking` re-renders from the
172
172
  reply before every user message whatever chi sends). A worker's
173
173
  mid-turn checkpoint (`TurnCheckpoint`) is append-only too (the history plus what the loop appended) and
174
- never changes the request bytes.
174
+ never changes the request bytes. On Messages with Claude 5.x a change to the prefix (the system prompt, the
175
+ tools, an earlier message) also costs the thinking blocks after it: one 400, then chi drops them for good
176
+ (internals/anthropic.md, "Preserved thinking").
175
177
  3. **Deterministic order.** Tools, memory lists and anything else that's rendered from a hash or a set needs a
176
178
  fixed sort. Plugin tools are sorted by (bundle, name) whatever order their plugins finished starting in
177
179
  (R5a).
data/docs/plugins.md CHANGED
@@ -990,6 +990,48 @@ context_providers:
990
990
  the PATH may be another one, such as macOS's 2.6).
991
991
  - The command follows [the contract](context.md#the-command-contract).
992
992
 
993
+ ## Stable for bundles
994
+
995
+ Bundles normally use only `chi.*` and `ctx`; the constants and helpers below
996
+ are the rest of the surface they may use. A change to any of them gets a
997
+ CHANGELOG line and bumps the shipped bundles that use it. Anything else under
998
+ `Samagotchi::` is internal.
999
+
1000
+ **Stable**
1001
+
1002
+ 1. `Samagotchi::Plugin::Api` / `chi.*` as documented above, including
1003
+ `Samagotchi::Plugin::ToolResult`, `Samagotchi::Plugin::ModelError` (and its
1004
+ subclass `Samagotchi::Plugin::ModelCancelled`),
1005
+ `Samagotchi::Plugin::Service::Stopped`, `Samagotchi::Plugin::Sessions::Error`
1006
+ and `Samagotchi::Plugin::AttachedContext::Error`.
1007
+ 2. `Samagotchi::Plugin::Peer` as a base class. A subclass defines `post`,
1008
+ `check_hint!`, `paired_lines`, `transport_line`, `peer_dir` and
1009
+ `setup_paragraph` (optionally `skill_substitutions`), and sets `NAME`,
1010
+ `APP`, `COMMAND`, `USAGE`, `OUTBOX` and `SCRIPTS_DIRS`. These constants are
1011
+ read as `self.class::X`. See the [claude-peer](#the-claude-peer-bundle) and
1012
+ [codex-peer](#the-codex-peer-bundle) bundles.
1013
+ 3. `Samagotchi::Tools::ToolIndex::Tool` and `Samagotchi::Tools::ToolIndex::Group`
1014
+ values, `Samagotchi::Tools::ToolIndex.schema_note` and
1015
+ `Samagotchi::Tools::ToolIndex.spec_tokens` (used by the mcp bundle).
1016
+ 4. `Samagotchi::Tools::Execute::NAME`, `TIMEOUT_PREFIX`, `HANDED_OVER` and
1017
+ `Samagotchi::Tools::Execute.timeout_seconds`.
1018
+ 5. `Samagotchi::Tools::TaskRuntime::TASKS_DIR` and `EXIT_CODE_FILENAME`.
1019
+ 6. `Samagotchi::AtomicFile.write`, `Samagotchi::Log.warn` /
1020
+ `Samagotchi::Log.debug`, `Samagotchi::Config.get` and
1021
+ `Samagotchi::VERSION`.
1022
+
1023
+ **Used by shipped bundles today, but internal** (may change without notice; a
1024
+ bundle that needs one should ask for an API instead):
1025
+
1026
+ - `Samagotchi::Tools::MemoryRead.memories_dir`, `Samagotchi::MemoryPaths.scope_dir`,
1027
+ `Samagotchi::ConfigFile.global_path` and
1028
+ `Samagotchi::Config.validate_yaml_sections`.
1029
+ - `Samagotchi::Ref` / `Samagotchi::RefAction` constants and
1030
+ `Samagotchi::TokenUsage::CHARS_PER_TOKEN`.
1031
+ - `Samagotchi::ProcessGroup.stop`, `Samagotchi::Guardrails::ShellLex` and
1032
+ `Samagotchi::Guardrails::GitInfo`.
1033
+ - `Samagotchi::Tools::Execute.const_get` reflection.
1034
+
993
1035
  ## The btw bundle
994
1036
 
995
1037
  `chi bundle install btw` (or the `dev` profile) installs the bundle shipped with chi. It is written
data/docs/sessions.md CHANGED
@@ -80,6 +80,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
80
80
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
81
81
  - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
82
82
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
83
+ - The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only, and a fold forgets the height the grip set (`chi_composer_floor`): the box reopens at one line, or at its text's height. The start page's grip only resizes.
83
84
 
84
85
  **Test-session hygiene:**
85
86
 
@@ -117,7 +117,7 @@ module Samagotchi
117
117
  # turn queued behind it; nil (an older worker): the next reply.
118
118
  turn = ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
119
119
 
120
- if pending && pending[:id].to_s == qid
120
+ if pending && pending.id.to_s == qid
121
121
  return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
122
122
 
123
123
  selected = selection(pending, options) or return @selection_status
@@ -140,7 +140,7 @@ module Samagotchi
140
140
  # exact label wins over a number.
141
141
  # @return [Array<String>, nil] nil after a usage error
142
142
  def selection(pending, options)
143
- offered = Array(pending[:options]).map(&:to_s)
143
+ offered = Array(pending.options).map(&:to_s)
144
144
  labels = options[:options].map do |given|
145
145
  next given if offered.include?(given)
146
146
 
@@ -151,7 +151,7 @@ module Samagotchi
151
151
  @selection_status = usage_failure("no option #{given}; the options: #{listed}")
152
152
  return nil
153
153
  end
154
- if labels.uniq.size > 1 && !pending[:multi_select]
154
+ if labels.uniq.size > 1 && !pending.multi_select
155
155
  @selection_status = usage_failure("one option only: the question is single-select")
156
156
  return nil
157
157
  end
@@ -163,7 +163,7 @@ module Samagotchi
163
163
  # Deny, text alone or a dismissal always passes: each one denies. The
164
164
  # worker checks it again with its own config.
165
165
  def approval_refusal(pending, selected, _id)
166
- offered = Array(pending[:options]).map(&:to_s)
166
+ offered = Array(pending.options).map(&:to_s)
167
167
  indices = selected.map { |label| offered.index(label) }
168
168
  reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: Guardrails::ParentApprovals.setting)
169
169
  reason && Guardrails::ParentApprovals.message(reason)
@@ -177,7 +177,7 @@ module Samagotchi
177
177
 
178
178
  # A step-limit question can't be left unanswered: the turn waits on it.
179
179
  def not_dismissable(pending)
180
- "the step-limit question can't be dismissed: answer #{Array(pending[:options]).join(" or ")} " \
180
+ "the step-limit question can't be dismissed: answer #{Array(pending.options).join(" or ")} " \
181
181
  "(--option Stop --text WHY stops it)"
182
182
  end
183
183
 
@@ -260,7 +260,7 @@ module Samagotchi
260
260
  recap: @accumulator.recap,
261
261
  saved_recap: @engine.saved_recap,
262
262
  continue_offer: @accumulator.continue_offer,
263
- pending_question: @engine.pending_question,
263
+ pending_question: @engine.pending_question&.to_h,
264
264
  guardrail_warning: @engine.guardrail_warning,
265
265
  plugin_warning: @engine.plugin_warning,
266
266
  init_tasks: @engine.init_tasks,
@@ -1178,8 +1178,8 @@ module Samagotchi
1178
1178
  # still that relay's.
1179
1179
  def relay_closed(qid, relay_id, reason)
1180
1180
  pending = @engine.pending_question
1181
- relay = pending && pending[:relayed_to]
1182
- unless pending && pending[:id].to_s == qid && relay && (relay[:relay_id] || relay["relay_id"]).to_s == relay_id
1181
+ relay = pending&.relayed_to
1182
+ unless pending && pending.id.to_s == qid && relay && (relay[:relay_id] || relay["relay_id"]).to_s == relay_id
1183
1183
  return [{}, 409, { error: "question_not_pending", detail: "no question #{qid} relayed by #{relay_id}" }]
1184
1184
  end
1185
1185
  return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless @engine.annotate_question(qid, relayed_to: nil, reason: reason)
@@ -4,8 +4,8 @@ require "time"
4
4
  require_relative "../session"
5
5
 
6
6
  module Samagotchi
7
- # Loaded on first use: session_manager requires terminal_ui, which
8
- # requires KernelLoop and so the tools (a require cycle otherwise).
7
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
8
+ # files unloaded until the delegate/session path needs them.
9
9
  autoload :SessionManager, File.expand_path("../session_manager", __dir__)
10
10
 
11
11
  module Broadcast
@@ -76,6 +76,7 @@ class SourceLinks
76
76
  # A bare URL (scheme-anchored, so it can't start inside a markdown label),
77
77
  # and a markdown link (label in group 1, target in group 2).
78
78
  URL_SPAN = %r{[a-z][a-z0-9+.-]*://\S+}i
79
+ TRAILING_URL_PUNCTUATION = /[.,;:!?'"]+\z/
79
80
  MARKDOWN_LINK = /\[([^\]]*)\]\(([^)]*)\)/
80
81
  # A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
81
82
  FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
@@ -328,35 +329,36 @@ class SourceLinks
328
329
 
329
330
  # The [start, end) character ranges of the answer that are a bare URL
330
331
  # (scheme-anchored, `[a-z][a-z0-9+.\-]*://\S+`). A ref inside one is not
331
- # linked again. The span stops at the first `)` that no `(` inside the URL
332
- # balances — so `(https://x.com/a)` ends before the `)`, while
333
- # `…/Foo_(bar)` keeps it.
332
+ # linked again. Trailing punctuation and a trailing `]` without a `[` are
333
+ # excluded; balanced parentheses and brackets stay part of the URL.
334
334
  def bare_url_spans(text)
335
335
  spans = []
336
336
  text.scan(URL_SPAN) do
337
337
  match = Regexp.last_match
338
- finish = trim_url_end(match[0], match.begin(0), match.end(0))
338
+ finish = trim_url_end(match[0], match.begin(0))
339
339
  spans << [match.begin(0), finish] if finish > match.begin(0)
340
340
  end
341
341
  spans
342
342
  end
343
343
 
344
- # The end offset of a URL match after trimming its tail: the span stops at
345
- # the first `)` that no `(` inside the URL balances.
346
- def trim_url_end(url, start, finish)
344
+ # The end offset of a URL match after trimming it like notice_links.js.
345
+ def trim_url_end(url, start)
346
+ trimmed = url.sub(TRAILING_URL_PUNCTUATION, "")
347
347
  depth = 0
348
- url.each_char.with_index do |char, index|
348
+ trimmed.each_char.with_index do |char, index|
349
349
  if char == "("
350
350
  depth += 1
351
351
  elsif char == ")"
352
352
  if depth.zero?
353
- finish = start + index
353
+ trimmed = trimmed[0...index]
354
354
  break
355
355
  end
356
356
  depth -= 1
357
357
  end
358
358
  end
359
- finish
359
+ trimmed = trimmed.sub(/\]+\z/, "") if trimmed.end_with?("]") && !trimmed.include?("[")
360
+ trimmed = trimmed.sub(TRAILING_URL_PUNCTUATION, "")
361
+ start + trimmed.length
360
362
  end
361
363
 
362
364
  # A markdown link's label and target ranges, with the target text. A ref in
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.5.0
3
+ version: 0.5.1
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
@@ -9,7 +9,7 @@ files:
9
9
  source_links.md: sha256:ace01a0f7c308a0ae54ece714c31c0e6d7a49c372d98111030e6d268320bed3a
10
10
  hooks:
11
11
  source_links.rb:
12
- sha256: sha256:8d84c68be88972737905925bc9fc4bd941007fb945e8104e9f6b3d61a1186b89
12
+ sha256: sha256:c41249ec0bbb708ec7827e1d0f66689dfbbc415c1da5fe33f5d3a8ec8e5eaa2f
13
13
  event: after_turn
14
14
  on_error: log
15
15
  priority: 90
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.76.0
3
+ version: 0.78.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:
@@ -11,8 +11,8 @@ require_relative "log"
11
11
  require_relative "tools/delegate_cursor"
12
12
 
13
13
  module Samagotchi
14
- # Loaded on first use: session_manager requires terminal_ui, which
15
- # requires KernelLoop and so the tools (a require cycle otherwise).
14
+ # Loaded on first use: session_manager requires this file (a require cycle
15
+ # otherwise).
16
16
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
17
17
  # SessionContinue::OpenChild, the rows a plan refuses: loaded on first use too.
18
18
  autoload :SessionContinue, File.expand_path("session_continue", __dir__)
@@ -12,8 +12,8 @@ require_relative "tools/delegate_wait"
12
12
  require_relative "tools/delegate_cursor"
13
13
 
14
14
  module Samagotchi
15
- # Loaded on first use: session_manager requires terminal_ui, which
16
- # requires KernelLoop and so the tools (a require cycle otherwise).
15
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
16
+ # files unloaded until the delegate/session path needs them.
17
17
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
18
18
 
19
19
  # A delegate child's news reaching its parent by itself (the delegate
@@ -254,7 +254,7 @@ module Samagotchi
254
254
  def reportable?(wait)
255
255
  case wait.status
256
256
  when :done, :no_reply, :error then true
257
- when :waiting_for_answer then [nil, "", "continue"].include?(wait.question&.dig(:kind)&.to_s)
257
+ when :waiting_for_answer then [nil, "", "continue"].include?(wait.question&.kind&.to_s)
258
258
  else false
259
259
  end
260
260
  end
@@ -9,8 +9,8 @@ require_relative "tools/delegate"
9
9
  require_relative "tools/delegate_cursor"
10
10
 
11
11
  module Samagotchi
12
- # Loaded on first use: session_manager requires terminal_ui, which
13
- # requires KernelLoop and so the tools (a require cycle otherwise).
12
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
13
+ # files unloaded until the delegate/session path needs them.
14
14
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
15
15
 
16
16
  # A parent's children as `/children` and the glance views show them: one
@@ -242,10 +242,7 @@ module Samagotchi
242
242
  wire_kernel(kernel, muted_memories)
243
243
  Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
244
244
  @resume_session = session_id ? Session.load(session_id) : nil
245
- @prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
246
- thinking: -> { turn_thinking }, model: -> { model_identity },
247
- model_notes: -> { model_notes }, memories: memories,
248
- muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
245
+ @prompt_builder = build_system_prompt(memories, appended_prompt_files)
249
246
  @session = nil
250
247
  @session_observer = SessionObserver.new
251
248
  @metrics = SessionMetrics.new
@@ -268,10 +265,7 @@ module Samagotchi
268
265
  # One shared poller for the whole idle layer (reminders + optional
269
266
  # recap). Both jobs read the activity seam above; the scheduler owns
270
267
  # the single background thread and isolates per-job failures.
271
- @idle_scheduler = IdleScheduler.new(
272
- engine: self,
273
- jobs: [@reminders, @recap].compact
274
- )
268
+ @idle_scheduler = build_idle_scheduler
275
269
  subscribe_session_observers
276
270
  end
277
271
 
@@ -388,6 +382,21 @@ module Samagotchi
388
382
  end
389
383
  private :build_plugin_tasks
390
384
 
385
+ # The system prompt's live sources and this session's fixed inputs.
386
+ def build_system_prompt(memories, appended_prompt_files)
387
+ SystemPrompt.new(profile: -> { profile }, tools: -> { @tools }, session: -> { @session },
388
+ thinking: -> { turn_thinking }, model: -> { model_identity },
389
+ model_notes: -> { model_notes }, memories: memories,
390
+ muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
391
+ end
392
+ private :build_system_prompt
393
+
394
+ # The idle layer's shared scheduler (see #initialize for when).
395
+ def build_idle_scheduler
396
+ IdleScheduler.new(engine: self, jobs: [@reminders, @recap].compact)
397
+ end
398
+ private :build_idle_scheduler
399
+
391
400
  # A monotonically-increasing clock (wall clock can jump backwards; the idle
392
401
  # detector must never treat a jump as "activity"). Injectable for specs.
393
402
  def monotonic_now
@@ -1218,7 +1227,7 @@ module Samagotchi
1218
1227
  # :last_prompt [String, nil] the last user prompt (empty string if none)
1219
1228
  # :event_seq [Integer] @session_observer.event_count
1220
1229
  # :metrics [Hash] @metrics.snapshot (per-session analytics)
1221
- # :pending_question [Hash, nil] current pending structured question
1230
+ # :pending_question [Hash, nil] the pending question (PendingQuestion#to_h)
1222
1231
  # :used_memory_names [Array<String>] deduped memory names active this session
1223
1232
  # :prompt_notes [Array<Hash>] the model notes the prompt carried
1224
1233
  # ({name:, scope:, chars:, digest:}; #prompt_notes)
@@ -1244,7 +1253,7 @@ module Samagotchi
1244
1253
  last_prompt: @session&.last_prompt,
1245
1254
  event_seq: @session_observer&.event_count,
1246
1255
  metrics: metrics,
1247
- pending_question: @question_desk.pending,
1256
+ pending_question: @question_desk.pending&.to_h,
1248
1257
  used_memory_names: @used_memories.names,
1249
1258
  preloaded_memory_names: preloaded_memory_names,
1250
1259
  muted_memory_names: @muted_memory_names.dup,
@@ -1554,7 +1563,7 @@ module Samagotchi
1554
1563
 
1555
1564
  # ── Ask-user-question (structured qualification) ──────────────────────────
1556
1565
 
1557
- # @return [Hash, nil] current pending question (thread-safe copy)
1566
+ # @return [PendingQuestion, nil] the question pending now
1558
1567
  def pending_question
1559
1568
  @question_desk.pending
1560
1569
  end
@@ -1580,7 +1589,7 @@ module Samagotchi
1580
1589
 
1581
1590
  # Publish a standing question that blocks nobody (QuestionDesk#post: a
1582
1591
  # worker's step-limit question).
1583
- # @return [Hash, nil] the pending question, nil while another is open
1592
+ # @return [PendingQuestion, nil] the pending question, nil while another is open
1584
1593
  def post_question(fields, on_answer:, on_superseded_close: nil)
1585
1594
  @question_desk.post(fields, on_answer: on_answer, on_superseded_close: on_superseded_close)
1586
1595
  end