samagotchi 0.155.0 → 0.157.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 (234) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/bin/chi +2 -1
  4. data/docs/architecture.md +22 -13
  5. data/docs/bundles.md +3 -1
  6. data/docs/cli.md +9 -8
  7. data/docs/configuration.md +36 -4
  8. data/docs/guardrails.md +9 -5
  9. data/docs/hooks.md +11 -5
  10. data/docs/internals/anthropic.md +1 -1
  11. data/docs/internals/attached-context.md +3 -2
  12. data/docs/internals/background-tasks.md +1 -1
  13. data/docs/internals/bridge.md +83 -6
  14. data/docs/internals/bundles.md +28 -18
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +641 -937
  17. data/docs/internals/llm-context-forget.md +8 -3
  18. data/docs/internals/objectives.md +2 -2
  19. data/docs/internals/participants.md +3 -2
  20. data/docs/internals/prompt-caching.md +4 -1
  21. data/docs/internals/remote-sessions.md +8 -3
  22. data/docs/internals/web-frontend.md +3 -2
  23. data/docs/internals/working-set.md +2 -2
  24. data/docs/memory.md +13 -2
  25. data/docs/plugins.md +35 -2
  26. data/docs/sessions.md +11 -11
  27. data/docs/sub-agent.md +17 -2
  28. data/docs/system-one.md +3 -2
  29. data/docs/testing.md +53 -2
  30. data/lib/samagotchi/answer_command.rb +2 -1
  31. data/lib/samagotchi/archive_store.rb +4 -3
  32. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  33. data/lib/samagotchi/bootstrap_command.rb +3 -2
  34. data/lib/samagotchi/bridge/card_places.rb +10 -4
  35. data/lib/samagotchi/bridge/card_store.rb +3 -0
  36. data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
  37. data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
  38. data/lib/samagotchi/bridge.rb +31 -6
  39. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  40. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  41. data/lib/samagotchi/broadcast_command.rb +3 -3
  42. data/lib/samagotchi/bundle_command/check.rb +18 -0
  43. data/lib/samagotchi/bundle_command/try/sandbox.rb +2 -2
  44. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  45. data/lib/samagotchi/bundle_command.rb +5 -2
  46. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  47. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  48. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  49. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  50. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  51. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  52. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  53. data/lib/samagotchi/child_move.rb +13 -57
  54. data/lib/samagotchi/child_reports.rb +2 -2
  55. data/lib/samagotchi/children_status.rb +15 -21
  56. data/lib/samagotchi/cli/parent_wait.rb +9 -23
  57. data/lib/samagotchi/config.rb +20 -112
  58. data/lib/samagotchi/context_command.rb +1 -2
  59. data/lib/samagotchi/context_sources.rb +5 -4
  60. data/lib/samagotchi/context_window.rb +9 -12
  61. data/lib/samagotchi/desktop/macos.rb +1 -1
  62. data/lib/samagotchi/effective_model.rb +57 -22
  63. data/lib/samagotchi/engine/context_judge.rb +19 -11
  64. data/lib/samagotchi/engine/messages.rb +1 -1
  65. data/lib/samagotchi/engine/models.rb +5 -2
  66. data/lib/samagotchi/engine/objectives.rb +29 -11
  67. data/lib/samagotchi/engine/plugins.rb +1 -1
  68. data/lib/samagotchi/engine/recap.rb +8 -4
  69. data/lib/samagotchi/engine/side_requests.rb +21 -11
  70. data/lib/samagotchi/engine/toolkit.rb +10 -9
  71. data/lib/samagotchi/engine/turn_end.rb +1 -1
  72. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  73. data/lib/samagotchi/engine/verify.rb +1 -1
  74. data/lib/samagotchi/engine.rb +43 -12
  75. data/lib/samagotchi/events.rb +3 -2
  76. data/lib/samagotchi/extension_load.rb +6 -3
  77. data/lib/samagotchi/guardrails/targets.rb +24 -9
  78. data/lib/samagotchi/guardrails_command.rb +1 -1
  79. data/lib/samagotchi/host_address.rb +52 -0
  80. data/lib/samagotchi/host_config.rb +72 -1
  81. data/lib/samagotchi/host_model.rb +2 -6
  82. data/lib/samagotchi/host_registry.rb +58 -46
  83. data/lib/samagotchi/kernel_loop.rb +53 -124
  84. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  85. data/lib/samagotchi/llm/chat_loop.rb +40 -21
  86. data/lib/samagotchi/llm/native_backend.rb +1 -1
  87. data/lib/samagotchi/llm_context_apply.rb +54 -1
  88. data/lib/samagotchi/llm_context_notice.rb +1 -1
  89. data/lib/samagotchi/llm_context_strategy.rb +29 -67
  90. data/lib/samagotchi/llm_context_view.rb +9 -0
  91. data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
  92. data/lib/samagotchi/memory_bundle/builder.rb +2 -1
  93. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  94. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  95. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  96. data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
  97. data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
  98. data/lib/samagotchi/memory_bundle/installer.rb +185 -169
  99. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  100. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  101. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  102. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  103. data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
  104. data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
  105. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
  106. data/lib/samagotchi/memory_bundle/status.rb +14 -20
  107. data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
  108. data/lib/samagotchi/memory_bundle/trash.rb +23 -8
  109. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  110. data/lib/samagotchi/memory_usage.rb +1 -1
  111. data/lib/samagotchi/model_lookup.rb +75 -0
  112. data/lib/samagotchi/model_price.rb +4 -10
  113. data/lib/samagotchi/model_profile.rb +7 -6
  114. data/lib/samagotchi/model_settings.rb +37 -2
  115. data/lib/samagotchi/models_command.rb +1 -1
  116. data/lib/samagotchi/note_command.rb +2 -3
  117. data/lib/samagotchi/objectives_audit.rb +4 -3
  118. data/lib/samagotchi/participant_mode.rb +2 -1
  119. data/lib/samagotchi/participants.rb +2 -2
  120. data/lib/samagotchi/plugin/api.rb +41 -6
  121. data/lib/samagotchi/plugin/context.rb +1 -1
  122. data/lib/samagotchi/plugin/loader.rb +33 -10
  123. data/lib/samagotchi/plugin/sessions.rb +3 -6
  124. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  125. data/lib/samagotchi/project_env.rb +1 -1
  126. data/lib/samagotchi/reasoning_history.rb +14 -20
  127. data/lib/samagotchi/recap_place.rb +27 -0
  128. data/lib/samagotchi/reminder_carry.rb +1 -1
  129. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  130. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  131. data/lib/samagotchi/reply_wait/target.rb +113 -0
  132. data/lib/samagotchi/reply_wait.rb +22 -2
  133. data/lib/samagotchi/reprefill_meter.rb +45 -0
  134. data/lib/samagotchi/review_command.rb +1 -1
  135. data/lib/samagotchi/sampling_settings.rb +9 -16
  136. data/lib/samagotchi/self_report.rb +36 -37
  137. data/lib/samagotchi/send_command.rb +17 -23
  138. data/lib/samagotchi/session.rb +370 -445
  139. data/lib/samagotchi/session_access/local.rb +12 -11
  140. data/lib/samagotchi/session_access/remote.rb +22 -6
  141. data/lib/samagotchi/session_archive_command.rb +2 -2
  142. data/lib/samagotchi/session_chain.rb +7 -6
  143. data/lib/samagotchi/session_continue.rb +6 -5
  144. data/lib/samagotchi/session_delete_command.rb +1 -1
  145. data/lib/samagotchi/session_field.rb +133 -0
  146. data/lib/samagotchi/session_inbox.rb +49 -1
  147. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  148. data/lib/samagotchi/session_manager/emptiness.rb +139 -0
  149. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  150. data/lib/samagotchi/session_manager/listing.rb +118 -0
  151. data/lib/samagotchi/session_manager/removal.rb +190 -0
  152. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  153. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  154. data/lib/samagotchi/session_manager.rb +28 -1541
  155. data/lib/samagotchi/session_metrics.rb +8 -5
  156. data/lib/samagotchi/session_paths.rb +57 -0
  157. data/lib/samagotchi/session_retention.rb +2 -2
  158. data/lib/samagotchi/session_row.rb +200 -0
  159. data/lib/samagotchi/session_setup.rb +15 -12
  160. data/lib/samagotchi/sessions_command.rb +11 -129
  161. data/lib/samagotchi/share_command.rb +1 -1
  162. data/lib/samagotchi/starting_marker.rb +65 -0
  163. data/lib/samagotchi/steer_cut_after.rb +7 -14
  164. data/lib/samagotchi/system_one/recorder.rb +49 -0
  165. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  166. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  167. data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
  168. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  169. data/lib/samagotchi/terminal_ui.rb +5 -7
  170. data/lib/samagotchi/testing/install.rb +2 -1
  171. data/lib/samagotchi/testing/sessions.rb +1 -1
  172. data/lib/samagotchi/thinking.rb +16 -21
  173. data/lib/samagotchi/token_usage.rb +1 -1
  174. data/lib/samagotchi/tool_activity.rb +10 -4
  175. data/lib/samagotchi/tool_declarations.rb +2 -2
  176. data/lib/samagotchi/tool_output_cut.rb +17 -0
  177. data/lib/samagotchi/tool_response.rb +5 -3
  178. data/lib/samagotchi/tool_runner.rb +25 -5
  179. data/lib/samagotchi/toolkit_command.rb +1 -1
  180. data/lib/samagotchi/tools/delegate.rb +55 -45
  181. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  182. data/lib/samagotchi/tools/delegate_result.rb +5 -5
  183. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  184. data/lib/samagotchi/tools/index_hint.rb +14 -0
  185. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  186. data/lib/samagotchi/tools/registry.rb +28 -11
  187. data/lib/samagotchi/tools/send_note.rb +4 -6
  188. data/lib/samagotchi/tools/tool_index.rb +52 -51
  189. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  190. data/lib/samagotchi/turn_context.rb +2 -1
  191. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  192. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  193. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  194. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  195. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  196. data/lib/samagotchi/update_command.rb +11 -7
  197. data/lib/samagotchi/verify_fresh.rb +7 -11
  198. data/lib/samagotchi/version.rb +1 -1
  199. data/lib/samagotchi/vision_support.rb +11 -14
  200. data/lib/samagotchi/web/app.rb +45 -28
  201. data/lib/samagotchi/web/message_display.rb +57 -7
  202. data/lib/samagotchi/web/participant_access.rb +3 -3
  203. data/lib/samagotchi/web/public/activity.js +16 -7
  204. data/lib/samagotchi/web/public/app.js +29 -8
  205. data/lib/samagotchi/web/public/bubbles.js +8 -3
  206. data/lib/samagotchi/web/public/cards_place.js +15 -0
  207. data/lib/samagotchi/web/public/commands.js +5 -1
  208. data/lib/samagotchi/web/public/data.js +4 -2
  209. data/lib/samagotchi/web/public/history_shape.js +220 -0
  210. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  211. data/lib/samagotchi/web/public/reload_check.js +71 -0
  212. data/lib/samagotchi/web/public/stage_model.js +7 -5
  213. data/lib/samagotchi/web/public/stage_view.js +7 -3
  214. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  215. data/lib/samagotchi/web/public/timing.js +43 -2
  216. data/lib/samagotchi/web/public/turn_block.js +215 -30
  217. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  218. data/lib/samagotchi/web/public/turn_events.js +10 -1
  219. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  220. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  221. data/lib/samagotchi/web/server.rb +4 -2
  222. data/lib/samagotchi/web/session_access_routes.rb +6 -6
  223. data/lib/samagotchi/web/session_control.rb +4 -4
  224. data/lib/samagotchi/web/session_create.rb +3 -3
  225. data/lib/samagotchi/web/session_hub.rb +9 -17
  226. data/lib/samagotchi/web/session_show.rb +67 -5
  227. data/lib/samagotchi/web/session_summary.rb +39 -28
  228. data/lib/samagotchi/worker.rb +83 -32
  229. data/lib/samagotchi/worker_boot.rb +184 -0
  230. data/lib/samagotchi/worker_link.rb +3 -0
  231. data/lib/samagotchi/worker_signals.rb +1 -1
  232. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  233. metadata +32 -4
  234. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -27,6 +27,8 @@ require_relative "tools/builtin_calls"
27
27
  require_relative "tools/tool_surface"
28
28
  require_relative "muted_memories"
29
29
  require_relative "tool_activity"
30
+ require_relative "reprefill_meter"
31
+ require_relative "tool_output_cut"
30
32
  require_relative "tool_runner"
31
33
  require_relative "tool_response"
32
34
  require_relative "tool_ids"
@@ -123,13 +125,12 @@ module Samagotchi
123
125
  # conversation (KernelLoop#forget_outputs).
124
126
  def forget_outputs(call) = @kernel.forget_outputs(call, @context)
125
127
 
126
- # The turn's Tools::ToolSurface (TurnContext#surface), else one
127
- # computed now from the kernel's tools (a bare kernel): what the tool
128
+ # The turn's Tools::ToolSurface (TurnContext#surface; a bare kernel's
129
+ # own context has one too, KernelLoop#own_context): what the tool
128
130
  # index's handlers (find_tools, call_tool) read.
129
- def surface = Tools::ToolSurface.of(@context, @kernel.tools)
131
+ def surface = Tools::ToolSurface.of(@context)
130
132
  end
131
133
 
132
- DEFAULT_MAX_TOOL_OUTPUT_CHARS = 10_000
133
134
  QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT = 2
134
135
  # How much of a malformed generation's end the warning keeps.
135
136
  MALFORMED_TAIL_CHARS = 400
@@ -172,12 +173,16 @@ module Samagotchi
172
173
 
173
174
  # @param services [Services, nil] what the kernel holds of its Engine
174
175
  # (Services.none by default: the built-in tools alone)
176
+ # @param reprefill [ReprefillMeter, nil] the re-prefill baseline its
177
+ # requests count against (the Engine's, shared with its ChatLoop; its
178
+ # own by default)
175
179
  # @param sleeper [#call, nil] waits the given seconds before a dropped
176
180
  # stream's step is asked again (specs pass one that doesn't sleep)
177
- def initialize(client: nil, profile: nil, model_name: nil, model_key: nil, services: nil, sleeper: nil)
181
+ def initialize(client: nil, profile: nil, model_name: nil, model_key: nil, services: nil, reprefill: nil, sleeper: nil)
178
182
  @client = client || Client.new
179
183
  @sleeper = sleeper || ->(seconds) { sleep(seconds) }
180
184
  @services = services || Services.none
185
+ @reprefill = reprefill || ReprefillMeter.new
181
186
  resolved_model_name = ModelProfile.required_model_name(model_name)
182
187
  # The context of the run in progress, else of the last run (#context):
183
188
  # a kernel that ran none has its own (no turn, no settings but its
@@ -193,11 +198,14 @@ module Samagotchi
193
198
  # @return [Services] what the kernel holds of its Engine (#attach)
194
199
  attr_reader :services
195
200
 
196
- # Hand the kernel its Engine's services, all at once: the Engine does
197
- # for a kernel it is given (specs) what KernelLoop.new does for its own.
201
+ # Hand the kernel its Engine's services, all at once, and its
202
+ # re-prefill meter (nil keeps the one it has): the Engine does for a
203
+ # kernel it is given (specs) what KernelLoop.new does for its own.
198
204
  # @param services [Services]
205
+ # @param reprefill [ReprefillMeter, nil]
199
206
  # @return [Services]
200
- def attach(services)
207
+ def attach(services, reprefill: nil)
208
+ @reprefill = reprefill if reprefill
201
209
  @services = services
202
210
  end
203
211
 
@@ -288,16 +296,16 @@ module Samagotchi
288
296
  # their thought blocks (#prepare_conversation), as within one run. nil:
289
297
  # every earlier thought is stripped.
290
298
  # @param context [TurnContext] the run's turn (Engine#generate's); a
291
- # bare kernel's last (#context) by default. Its settings take the
299
+ # bare kernel's own (#own_context) by default. Its settings take the
292
300
  # run's model name: this run's own copy, never published.
293
301
  # @return [LLM::ModelResult] final visible response with continuation metadata
294
302
  def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil,
295
- reasoning_since: nil, context: @context)
303
+ reasoning_since: nil, context: own_context)
296
304
  settings = context.settings || LLM::TurnSettings.none
297
305
  @context = context.with(settings: settings.with(model_name: completion_model_name(model_name)))
298
306
  @last_slot = nil
299
307
  turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
300
- pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars),
308
+ pending_input: pending_input, cap: ToolOutputCut.max_chars(max_tool_output_chars),
301
309
  reasoning_since: reasoning_since)
302
310
 
303
311
  max_iterations.times do |iteration_index|
@@ -330,21 +338,6 @@ module Samagotchi
330
338
  @profile_source = resolution.label
331
339
  end
332
340
 
333
- # Resolve the per-output character cap for the tool outputs the model
334
- # gets back and the emitted tool call events.
335
- #
336
- # Precedence: an explicit override wins, then max_tool_output_chars
337
- # (Config). A non-positive value falls back to
338
- # DEFAULT_MAX_TOOL_OUTPUT_CHARS (there is intentionally no "unlimited" — live UIs get a
339
- # bounded `output:` plus a truthful `output_truncated:` flag). The one
340
- # exception is memory_read, which ToolRunner leaves out of this cap
341
- # (memory.read_max_chars, 0: no fixed cap; a memory is read whole).
342
- # Class-level so the chat loop resolves it the same way.
343
- def self.resolve_output_char_cap(override)
344
- parsed = (override || Samagotchi::Config.get("max_tool_output_chars")).to_i
345
- parsed.positive? ? parsed : DEFAULT_MAX_TOOL_OUTPUT_CHARS
346
- end
347
-
348
341
  private
349
342
 
350
343
  # ── One turn (#run) ────────────────────────────────────────────────────────
@@ -659,7 +652,7 @@ module Samagotchi
659
652
  def cache_fields(cache) = cache ? TokenUsage.cache_fields(**cache) : {}
660
653
 
661
654
  def reprefill_field(model, cache)
662
- tokens = cache && reprefilled_tokens(model, prompt: cache[:prompt], cached: cache[:cached])
655
+ tokens = cache && @reprefill.tokens(model, client: @client, prompt: cache[:prompt], cached: cache[:cached])
663
656
  tokens ? { reprefill_tokens: tokens } : {}
664
657
  end
665
658
 
@@ -799,20 +792,24 @@ module Samagotchi
799
792
  turn.qwen_attempts = 0
800
793
  turn.qwen_partial = nil
801
794
  runs = []
795
+ # The batch's ids before it runs (each run's events carry its own):
796
+ # nothing appends to the conversation during a batch, so they are the
797
+ # ids the entry would get after it (spec/tool_ids_pin_spec.rb).
798
+ ids = ToolIds.next_ids(turn.conversation, calls.size)
802
799
  begin
803
800
  ToolResponse.run_batch(tool_runner, calls, context: @context, iteration: turn.iteration, emit: turn.emit,
804
- on_stream_event: turn.on_stream_event, cap: turn.cap) { |run, _| runs << run }
801
+ on_stream_event: turn.on_stream_event, cap: turn.cap, ids: ids) { |run, _| runs << run }
805
802
  rescue Interrupt
806
803
  # A Ctrl-C mid-batch: the entry still answers every call (the
807
804
  # finished runs, then run_batch's interrupted ones), for #run to
808
805
  # hand on with the conversation.
809
- turn.conversation << ToolResponse.joined(runs, ids: ToolIds.next_ids(turn.conversation, runs.size))
806
+ turn.conversation << ToolResponse.joined(runs, ids: ids.first(runs.size))
810
807
  raise
811
808
  end
812
809
  turn.tool_activity.concat(ToolResponse.activities(runs))
813
810
  # One entry for the batch: the capped outputs joined (ToolResponse.joined),
814
811
  # an id per run (ToolIds).
815
- turn.conversation << ToolResponse.joined(runs, ids: ToolIds.next_ids(turn.conversation, runs.size))
812
+ turn.conversation << ToolResponse.joined(runs, ids: ids.first(runs.size))
816
813
  turn.pending_tool_calls = true
817
814
  :next
818
815
  end
@@ -877,12 +874,6 @@ module Samagotchi
877
874
  # Coerce a value to boolean — handles true/false, nil, and string "true"/"false".
878
875
  def truthy?(val) = Tools::Builtins.truthy?(val)
879
876
 
880
- # ── Output char cap resolution ─────────────────────────────────────────────
881
-
882
- def resolve_output_char_cap(override)
883
- self.class.resolve_output_char_cap(override)
884
- end
885
-
886
877
  # One request, cancelled by +generation_controller+ (the turn's child)
887
878
  # when there is one.
888
879
  def request_generation(prompt, generation_controller:, cancel_controller:, **)
@@ -1005,14 +996,10 @@ module Samagotchi
1005
996
  WARMUP_CUT = "chi-warmup-cut"
1006
997
  private_constant :WARMUP_CUT
1007
998
 
1008
- # What both loops send of the conversation (LLMContextView), under the
1009
- # turn's strategy (TurnSettings#llm_context, none without one), or the
1010
- # one given (the warm-up's, the next turn's): the prompt, the warm-up
1011
- # and the chat messages all format through it, so the warm-up warms
1012
- # the prompt the next turn sends.
1013
- def llm_context_view(llm_context = turn_settings&.llm_context)
1014
- LLMContextView.new(strategy: llm_context&.strategy || LLMContextView::NONE)
1015
- end
999
+ # What the native prompt and the warm-up send of the conversation
1000
+ # (LLMContextView.for), under the turn's strategy or the one given (the
1001
+ # warm-up's, the next turn's).
1002
+ def llm_context_view(llm_context = turn_settings&.llm_context) = LLMContextView.for(llm_context)
1016
1003
 
1017
1004
  # The model's forget_outputs call (LLMContextForget) on the running
1018
1005
  # turn (the loop's llm_context_turn), under +context+'s strategy (the
@@ -1042,84 +1029,19 @@ module Samagotchi
1042
1029
  end
1043
1030
  private :pinned_ids
1044
1031
 
1045
- # Before each request of both loops (+moment+ :request; the warm-up
1046
- # too, on its own copy, as the next turn's first request) and at the
1047
- # end of a turn the model answered (:turn_end): the strategy's layers
1048
- # stage their edits on +conversation+ and the apply rule
1049
- # (LLMContextApply, llm_context.apply) saves the batch it lets through
1050
- # as applied, so the request about to be sent (or the next turn's
1051
- # first, and the warm-up) is the first with the stubs. +context+: the
1052
- # turn's ContextStatus (payoff applies in its top bucket; an applied
1053
- # batch rebases its estimate, ContextStatus#edited!). none changes
1054
- # nothing. Logged as llm_context_apply when a batch is applied, and
1055
- # shown to the user as a ✂ row (+emit+ takes the :llm_context_edited
1056
- # event, LLMContextNotice; the warm-up's own copy has none).
1032
+ # The strategy's edits on +conversation+ (LLMContextApply.apply!), under
1033
+ # the turn's strategy and model name by default: the native run's
1034
+ # requests and turn end, the warm-up's copy, a forget_outputs call.
1057
1035
  # @return [LLMContextApply::Outcome]
1058
- # +model+: the run's model name, in the log line (the chat loop's own).
1059
- def apply_llm_context!(conversation, llm_context = turn_settings&.llm_context, moment: :request, context: nil,
1060
- warmup: false, emit: nil, model: turn_settings&.model_name)
1061
- view = llm_context_view(llm_context)
1062
- return LLMContextApply::Outcome.none if view.none?
1063
-
1064
- outcome = LLMContextApply.run!(conversation, layers: view.layers, rule: llm_context.apply, moment: moment,
1065
- protect_steps: llm_context.protect_steps,
1066
- top_bucket: context&.top_bucket? || false,
1067
- changes: llm_context.stale_edits)
1068
- if outcome.applied?
1069
- # The prompt just shrank: the context estimate counts from it.
1070
- context&.edited!
1071
- unless warmup
1072
- log_llm_context(outcome, llm_context, moment, model)
1073
- emit_llm_context(emit, conversation, outcome, moment) if emit
1074
- end
1075
- end
1076
- outcome
1077
- end
1078
-
1079
- def log_llm_context(outcome, llm_context, moment, model)
1080
- Log.info(:model, "llm_context_apply", rule: llm_context.apply, at: moment, why: outcome.why,
1081
- applied: outcome.applied.size, ids: outcome.applied.map(&:id).join(","),
1082
- staged: outcome.staged, freed: (outcome.freed_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
1083
- tail: (outcome.tail_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
1084
- model: model)
1085
- end
1086
- private :log_llm_context
1087
-
1088
- # The batch's ✂ row; a row that can't be built never breaks the turn.
1089
- def emit_llm_context(emit, conversation, outcome, moment)
1090
- emit.call(LLMContextNotice.event(conversation, outcome, moment: moment))
1091
- rescue StandardError => e
1092
- Log.warn(:model, "llm_context_notice_failed", error: e.class.name, message: e.message)
1093
- end
1094
- private :emit_llm_context
1095
-
1096
- # A request's re-prefilled tokens, for both loops: of the prompt the
1097
- # last request on +model+ through this client (the host) sent (this
1098
- # kernel's, any turn), what the server didn't reuse from its cache this
1099
- # time (that prompt less +cached+), so an LLM context edit's cache break
1100
- # shows, and so does anything else that rewrote the prompt's middle or
1101
- # lost the cache. nil under TokenUsage::REPREFILL_MIN_TOKENS (a cache
1102
- # block's rounding), for a server that reports no cached count (or 0: a
1103
- # cold cache isn't told from no cache), the first request, a model
1104
- # switch (another bare model, or the same one on another host: the
1105
- # Engine points #client at the new host's; SessionMetrics counts that
1106
- # prefill as the switch's), and the first request after a warm-up
1107
- # (#warmed_up!).
1108
- # @return [Integer, nil]
1109
- def reprefilled_tokens(model, prompt:, cached:)
1110
- previous = @reprefill_baseline
1111
- @reprefill_baseline = { model: model, client: @client, prompt: prompt.to_i } if prompt.to_i.positive?
1112
- return nil unless previous && previous[:model] == model && previous[:client].equal?(@client) && cached.to_i.positive?
1113
-
1114
- tokens = previous[:prompt] - cached.to_i
1115
- tokens >= TokenUsage::REPREFILL_MIN_TOKENS ? tokens : nil
1036
+ def apply_llm_context!(conversation, llm_context = turn_settings&.llm_context, model: turn_settings&.model_name, **)
1037
+ LLMContextApply.apply!(conversation, llm_context, model: model, **)
1116
1038
  end
1117
1039
 
1118
1040
  # The Engine started a turn-end warm-up: it prefills the next prompt
1119
1041
  # off the clock, so the next request's cache counts are against it, not
1120
- # the last request (#reprefilled_tokens starts over).
1042
+ # the last request (the re-prefill meter starts over, ReprefillMeter#reset!).
1121
1043
  def warmed_up!
1122
- @reprefill_baseline = nil
1044
+ @reprefill.reset!
1123
1045
  end
1124
1046
 
1125
1047
  # Public wrapper so other loops (e.g. the chat loop) can strip
@@ -1212,21 +1134,28 @@ module Samagotchi
1212
1134
  # first (see Samagotchi::LLM::NativeToolNormalizer); dispatch itself never
1213
1135
  # parses provider text. +context+: the call's turn (TurnContext: its
1214
1136
  # ToolSurface, the handler's ToolContext); a bare kernel's own
1215
- # (#context) by default.
1216
- def dispatch_tool_call(call, context = @context)
1137
+ # (#own_context) by default.
1138
+ def dispatch_tool_call(call, context = own_context)
1217
1139
  dispatch(call, context)
1218
1140
  end
1219
1141
 
1142
+ # The context a bare kernel (no Engine: a spec, a bench) runs in when
1143
+ # given none: #context, with a Tools::ToolSurface built now from the
1144
+ # kernel's tools when it has none (Tools::ToolSurface.bare). An
1145
+ # Engine's turn always hands its own down (Engine#generate's surface).
1146
+ # @return [TurnContext]
1147
+ def own_context = Tools::ToolSurface.bare(@context, tools)
1148
+
1220
1149
  private
1221
1150
 
1222
1151
  # The call runs on the entry the turn's surface gives it
1223
1152
  # (Tools::ToolSurface#entry_for: a call_tool-reached one only while the
1224
1153
  # toolkit reaches it that way, a reachable one by its name on a host
1225
1154
  # that loads tools); without one it gets ToolSurface#unknown_tool_error.
1226
- # A context without a surface (a bare kernel) computes one now.
1155
+ # A context without a surface raises Tools::MissingSurface.
1227
1156
  def dispatch(call, context)
1228
1157
  model = context.settings&.model_name
1229
- surface = Tools::ToolSurface.of(context, tools)
1158
+ surface = Tools::ToolSurface.of(context)
1230
1159
  entry = surface.entry_for(call)
1231
1160
  unless entry
1232
1161
  result = surface.unknown_tool_error(call[:name], via: call[:via])
@@ -1271,10 +1200,10 @@ module Samagotchi
1271
1200
  rescue StandardError => e
1272
1201
  dump_log("tool_error", e.message, model: context.settings&.model_name, tool: call[:name], error: e.class.name)
1273
1202
  result = "Error: #{e.message}"
1274
- {
1275
- output: "[#{call[:name]}] #{result}",
1276
- activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: tools, surface: surface)
1277
- }
1203
+ # No surface (Tools::MissingSurface from ToolSurface.of): no row either,
1204
+ # it would read the surface again.
1205
+ activity = surface && ToolActivity.tool_activity_event(call[:name], call, result, registry: tools, surface: surface)
1206
+ { output: "[#{call[:name]}] #{result}", activity: activity }
1278
1207
  end
1279
1208
  end
1280
1209
  end
@@ -4,7 +4,7 @@ require "digest"
4
4
  require "json"
5
5
  require_relative "../log"
6
6
  require_relative "utf8_scrub"
7
- require_relative "../tools/tool_index"
7
+ require_relative "../tools/index_hint"
8
8
 
9
9
  module Samagotchi
10
10
  module LLM
@@ -278,7 +278,7 @@ module Samagotchi
278
278
  # don't change; only this wire says it).
279
279
  def loaded_text(text, names)
280
280
  line = "Loaded: #{names.join(", ")}. #{LOADED_HINT}"
281
- text = text.sub("#{Tools::ToolIndex::CALL_HINT}\n\n", "")
281
+ text = text.sub("#{Tools::IndexHint::CALL_HINT}\n\n", "")
282
282
  blank?(text) ? line : "#{line}\n\n#{text}"
283
283
  end
284
284
 
@@ -20,6 +20,7 @@ require_relative "../cut_policy"
20
20
  require_relative "../context_status"
21
21
  require_relative "../context_window"
22
22
  require_relative "../context_note"
23
+ require_relative "../tool_output_cut"
23
24
  require_relative "../tool_runner"
24
25
  require_relative "../tool_response"
25
26
  require_relative "../tool_ids"
@@ -27,7 +28,10 @@ require_relative "../tool_declarations"
27
28
  require_relative "../tools/tool_surface"
28
29
  require_relative "../tool_loads"
29
30
  require_relative "../vision_context"
31
+ require_relative "../llm_context_apply"
32
+ require_relative "../llm_context_view"
30
33
  require_relative "../log"
34
+ require_relative "../reprefill_meter"
31
35
  require_relative "../empty_answer_retry"
32
36
  require_relative "../steer"
33
37
  require_relative "../reasoning_history"
@@ -66,6 +70,12 @@ module Samagotchi
66
70
  # The session every request is for; Engine sets it with the adapter.
67
71
  # Goes out as a Session-Id header (OpenAIChat#chat).
68
72
  attr_accessor :session_id
73
+ # The effective model's host's client (EffectiveModel#client): what
74
+ # #context_window probes on a local host (a llama.cpp /props) and what
75
+ # a request's re-prefill is counted against. Engine sets it with the
76
+ # adapter (Engine#backend_for, under its chat backend mutex, from any
77
+ # thread a model switch runs on); nil until then (no probe).
78
+ attr_accessor :client
69
79
  # The last counted request's chars per token
70
80
  # (ContextStatus#counted_chars_per_token), carried to the next turn's
71
81
  # tracker: its first estimate counts the tool schemas the chars leave
@@ -73,6 +83,9 @@ module Samagotchi
73
83
  attr_accessor :counted_chars_per_token
74
84
 
75
85
  # @param kernel [KernelLoop] tool dispatch, thought stripping, hooks
86
+ # @param reprefill [ReprefillMeter, nil] the re-prefill baseline its
87
+ # requests count against (the Engine's, shared with its KernelLoop;
88
+ # its own by default)
76
89
  # @param adapter [OpenAIChat, nil] the host to talk to; Engine sets it
77
90
  # per turn for the effective model's host
78
91
  # @param stable_length [#call, nil] text → the length of the system
@@ -81,8 +94,9 @@ module Samagotchi
81
94
  # stream's step is asked again (specs pass one that doesn't sleep)
82
95
  # @param context [TurnContext, nil] the turn its helpers read before any
83
96
  # #complete (a bench building wire messages); a run's replaces it
84
- def initialize(kernel:, adapter: nil, stable_length: nil, sleeper: nil, context: nil)
97
+ def initialize(kernel:, reprefill: nil, adapter: nil, stable_length: nil, sleeper: nil, context: nil)
85
98
  @kernel = kernel
99
+ @reprefill = reprefill || ReprefillMeter.new
86
100
  @turn_context = context
87
101
  @adapter = adapter
88
102
  @stable_length = stable_length
@@ -106,7 +120,7 @@ module Samagotchi
106
120
  @turn_context = context if context
107
121
  conversation = Array(messages).map(&:dup)
108
122
  Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: reasoning_since)
109
- .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
123
+ .call(max_iterations: max_iterations || 1, cap: ToolOutputCut.max_chars(max_tool_output_chars))
110
124
  rescue StandardError, Interrupt => e
111
125
  # A failure, or a Ctrl-C (its calls answered: ToolResponse.run_batch),
112
126
  # carries the conversation so far for the Engine to keep.
@@ -137,10 +151,9 @@ module Samagotchi
137
151
  # (ToolSurface#deferred_for).
138
152
  def deferred_tools(conversation) = surface.deferred_for(conversation)
139
153
 
140
- # The turn's ToolSurface (#turn_context's), else one computed now from
141
- # the kernel's tools (a bare loop).
154
+ # The turn's ToolSurface (#turn_context's).
142
155
  # @return [Tools::ToolSurface]
143
- def surface = Tools::ToolSurface.of(turn_context, tools)
156
+ def surface = Tools::ToolSurface.of(turn_context)
144
157
 
145
158
  # Whether the adapter takes tools it doesn't declare up front for
146
159
  # +model+ (AnthropicMessages#loads_tools?); OpenAIChat doesn't.
@@ -159,9 +172,12 @@ module Samagotchi
159
172
 
160
173
  # The TurnContext of the run in progress, else of the last run
161
174
  # (#complete's context:); before any, the one it was built with, else a
162
- # context of the kernel's own settings (a bare loop). Read on the turn's thread only.
175
+ # context of the kernel's own settings and a surface of its tools
176
+ # built now (a bare loop, Tools::ToolSurface.bare). Read on the turn's thread only.
163
177
  # @return [TurnContext]
164
- def turn_context = @turn_context || TurnContext.none(settings: @kernel.turn_settings)
178
+ def turn_context
179
+ @turn_context || Tools::ToolSurface.bare(TurnContext.none(settings: @kernel.turn_settings), tools)
180
+ end
165
181
 
166
182
  # The turn's LLM::TurnSettings (#turn_context's).
167
183
  def settings = turn_context.settings
@@ -173,16 +189,17 @@ module Samagotchi
173
189
  # when none was resolved.
174
190
  def llm_context = settings&.llm_context
175
191
 
176
- # KernelLoop#reprefilled_tokens, the kernel's request history.
177
- def reprefilled_tokens(model, **) = @kernel.reprefilled_tokens(model, **)
192
+ # The request's re-prefilled tokens on the loop's meter, against this
193
+ # loop's host (#client): ReprefillMeter#tokens.
194
+ def reprefilled_tokens(model, **) = @reprefill.tokens(model, client: @client, **)
178
195
 
179
- # The kernel's LLMContextView under the turn's strategy.
180
- def llm_context_view = @kernel.llm_context_view(llm_context)
196
+ # The LLMContextView under the turn's strategy (LLMContextView.for).
197
+ def llm_context_view = LLMContextView.for(llm_context)
181
198
 
182
199
  # The strategy's own edits before a request and at turn end
183
- # (KernelLoop#apply_llm_context!), under the turn's strategy.
200
+ # (LLMContextApply.apply!), under the turn's strategy and model name.
184
201
  def apply_llm_context!(conversation, **)
185
- @kernel.apply_llm_context!(conversation, llm_context, model: settings&.model_name, **)
202
+ LLMContextApply.apply!(conversation, llm_context, model: settings&.model_name, **)
186
203
  end
187
204
 
188
205
  # The running turn, for forget_outputs (KernelLoop#llm_context_turn).
@@ -398,14 +415,13 @@ module Samagotchi
398
415
  @tool_runner ||= ToolRunner.new(@kernel, window: ->(run_settings) { context_window(run_settings&.model_name, run_settings) })
399
416
  end
400
417
 
401
- # The window for +model+: the kernel's client probes the server it
402
- # points at (Engine keeps it on the effective model's host; a local
403
- # llama.cpp answers /props), then the host's model list, the model's
418
+ # The window for +model+: the loop's client (#client) probes the server
419
+ # it points at (a local llama.cpp answers /props), then the host's model list, the model's
404
420
  # or host's window_tokens, config, env and the default. A remote
405
421
  # provider has no /props to probe. +run_settings+: the LLM::TurnSettings
406
422
  # whose window_setting counts (the turn's by default).
407
423
  def context_window(model, run_settings = settings)
408
- client = @adapter.remote? ? nil : @kernel.client
424
+ client = @adapter.remote? ? nil : @client
409
425
  ContextWindow.resolve(client: client, model: model, adapter: @adapter, setting: run_settings&.window_setting)
410
426
  rescue StandardError
411
427
  nil
@@ -897,7 +913,7 @@ module Samagotchi
897
913
  count
898
914
  end
899
915
 
900
- # The request's re-prefilled tokens (KernelLoop#reprefilled_tokens).
916
+ # The request's re-prefilled tokens (ChatLoop#reprefilled_tokens).
901
917
  def reprefill_field(usage)
902
918
  return {} unless usage&.source == :server
903
919
 
@@ -1116,10 +1132,13 @@ module Samagotchi
1116
1132
  def dispatch(tool_calls, iteration, cap)
1117
1133
  log_direct_deferred(tool_calls)
1118
1134
  calls = tool_calls.map { |tool_call| NativeToolNormalizer.normalize(tool_call) }
1135
+ # The batch's ids before it runs (each run's events carry its own):
1136
+ # the same as one next id per result as it lands
1137
+ # (spec/tool_ids_pin_spec.rb).
1138
+ ids = ToolIds.next_ids(@conversation, calls.size)
1119
1139
  runs = ToolResponse.run_batch(@loop.tool_runner, calls, context: @turn_context, iteration: iteration, emit: method(:emit),
1120
- on_stream_event: @on_stream_event, cap: cap) do |run, index|
1121
- @conversation << ToolResponse.single(run, tool_call_id: tool_calls[index].id,
1122
- ids: ToolIds.next_ids(@conversation, 1))
1140
+ on_stream_event: @on_stream_event, cap: cap, ids: ids) do |run, index|
1141
+ @conversation << ToolResponse.single(run, tool_call_id: tool_calls[index].id, ids: [ids[index]])
1123
1142
  end
1124
1143
  @tool_activity.concat(ToolResponse.activities(runs))
1125
1144
  end
@@ -27,7 +27,7 @@ module Samagotchi
27
27
  max_tool_output_chars: max_tool_output_chars,
28
28
  pending_input: pending_input,
29
29
  reasoning_since: reasoning_since,
30
- context: context || @kernel.context
30
+ context: context || @kernel.own_context
31
31
  )
32
32
  end
33
33
  end
@@ -4,8 +4,14 @@ require "time"
4
4
  require_relative "llm_context_edit"
5
5
  require_relative "llm_context_stale"
6
6
  require_relative "llm_context_view"
7
+ require_relative "log"
8
+ require_relative "token_usage"
7
9
 
8
10
  module Samagotchi
11
+ # The ✂ row's builder (#apply!), loaded on first use: the test kit loads
12
+ # this file (through the strategy) and never emits a row.
13
+ autoload :LLMContextNotice, File.expand_path("llm_context_notice", __dir__)
14
+
9
15
  # When a layer's edits reach the prompt: the apply rule
10
16
  # (llm_context.apply, LLMContextStrategy). Every edit breaks the prompt
11
17
  # cache from the earliest entry it changes, so the edits due at one point
@@ -59,6 +65,53 @@ module Samagotchi
59
65
 
60
66
  module_function
61
67
 
68
+ # Before each request of both loops (+moment+ :request; the warm-up
69
+ # too, on its own copy, as the next turn's first request) and at the
70
+ # end of a turn the model answered (:turn_end): +llm_context+'s layers
71
+ # (LLMContextStrategy::Resolved, the turn's) stage their edits on
72
+ # +conversation+ and its apply rule (#run!) saves the batch it lets
73
+ # through as applied, so the request about to be sent (or the next
74
+ # turn's first, and the warm-up) is the first with the stubs.
75
+ # +context+: the turn's ContextStatus (payoff applies in its top bucket;
76
+ # an applied batch rebases its estimate, ContextStatus#edited!). none
77
+ # changes nothing. Logged as llm_context_apply when a batch is applied
78
+ # (+model+: the run's model name, in the line), and shown to the user as
79
+ # a ✂ row (+emit+ takes the :llm_context_edited event, LLMContextNotice;
80
+ # the warm-up's own copy, +warmup+, has neither).
81
+ # @return [Outcome]
82
+ def apply!(conversation, llm_context, moment: :request, context: nil, warmup: false, emit: nil, model: nil)
83
+ view = LLMContextView.for(llm_context)
84
+ return Outcome.none if view.none?
85
+
86
+ outcome = run!(conversation, layers: view.layers, rule: llm_context.apply, moment: moment,
87
+ protect_steps: llm_context.protect_steps, top_bucket: context&.top_bucket? || false,
88
+ changes: llm_context.stale_edits)
89
+ if outcome.applied?
90
+ # The prompt just shrank: the context estimate counts from it.
91
+ context&.edited!
92
+ unless warmup
93
+ log_applied(outcome, llm_context, moment, model)
94
+ emit_applied(emit, conversation, outcome, moment) if emit
95
+ end
96
+ end
97
+ outcome
98
+ end
99
+
100
+ def log_applied(outcome, llm_context, moment, model)
101
+ Log.info(:model, "llm_context_apply", rule: llm_context.apply, at: moment, why: outcome.why,
102
+ applied: outcome.applied.size, ids: outcome.applied.map(&:id).join(","),
103
+ staged: outcome.staged, freed: (outcome.freed_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
104
+ tail: (outcome.tail_chars / TokenUsage::CHARS_PER_TOKEN).ceil,
105
+ model: model)
106
+ end
107
+
108
+ # The batch's ✂ row; a row that can't be built never breaks the turn.
109
+ def emit_applied(emit, conversation, outcome, moment)
110
+ emit.call(LLMContextNotice.event(conversation, outcome, moment: moment))
111
+ rescue StandardError => e
112
+ Log.warn(:model, "llm_context_notice_failed", error: e.class.name, message: e.message)
113
+ end
114
+
62
115
  # Stages what +layers+ find on +conversation+ and applies what +rule+
63
116
  # lets through at +moment+.
64
117
  # @param top_bucket [Boolean] the context is in the top ContextStatus bucket
@@ -160,6 +213,6 @@ module Samagotchi
160
213
  end
161
214
  end
162
215
 
163
- private_class_method :stage, :choose, :measure, :saved_staged
216
+ private_class_method :stage, :choose, :measure, :saved_staged, :log_applied, :emit_applied
164
217
  end
165
218
  end
@@ -7,7 +7,7 @@ require_relative "token_usage"
7
7
  module Samagotchi
8
8
  # The ✂ row a turn gets for a batch of LLM context edits the apply rule
9
9
  # let through (LLMContextApply::Outcome#applied?): the :llm_context_edited
10
- # event both loops emit (KernelLoop#apply_llm_context!, its +emit+), at a
10
+ # event both loops emit (LLMContextApply.apply!, its +emit+), at a
11
11
  # request (+moment+ :request; a forget_outputs call's batch too) or at the
12
12
  # end of a turn (:turn_end); never for the warm-up, a held or a staged
13
13
  # batch, or a restore (the forget_outputs row says it). The web and the