samagotchi 0.153.0 → 0.154.1

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 (226) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/README.md +2 -3
  4. data/bin/chi +23 -24
  5. data/docs/architecture.md +30 -33
  6. data/docs/broadcast.md +5 -5
  7. data/docs/bundles.md +9 -12
  8. data/docs/cli.md +82 -96
  9. data/docs/configuration.md +27 -26
  10. data/docs/context.md +3 -3
  11. data/docs/desktop.md +1 -1
  12. data/docs/guardrails.md +8 -10
  13. data/docs/hooks.md +9 -9
  14. data/docs/internals/attached-context.md +2 -2
  15. data/docs/internals/background-tasks.md +3 -4
  16. data/docs/internals/block-renderers.md +4 -0
  17. data/docs/internals/bridge.md +21 -8
  18. data/docs/internals/context-telemetry.md +1 -1
  19. data/docs/internals/engine.md +33 -7
  20. data/docs/internals/objectives.md +6 -5
  21. data/docs/internals/participants.md +7 -7
  22. data/docs/internals/prompt-caching.md +4 -2
  23. data/docs/internals/remote-sessions.md +7 -7
  24. data/docs/internals/web-frontend.md +2 -2
  25. data/docs/internals/web-push.md +1 -1
  26. data/docs/memory.md +2 -2
  27. data/docs/plugins.md +23 -28
  28. data/docs/sessions.md +24 -24
  29. data/docs/sub-agent.md +2 -2
  30. data/docs/system-one.md +1 -1
  31. data/docs/testing.md +31 -6
  32. data/lib/samagotchi/answer_text.rb +33 -0
  33. data/lib/samagotchi/bridge/card_places.rb +56 -7
  34. data/lib/samagotchi/bridge/card_store.rb +24 -9
  35. data/lib/samagotchi/bridge.rb +52 -32
  36. data/lib/samagotchi/bridge_client.rb +19 -14
  37. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  38. data/lib/samagotchi/broadcast/triage.rb +1 -1
  39. data/lib/samagotchi/broadcast_command.rb +5 -5
  40. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  42. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  43. data/lib/samagotchi/bundle_command.rb +2 -2
  44. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  45. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  46. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  47. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  48. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  50. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  51. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  52. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  53. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  54. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  55. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  56. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  57. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  58. data/lib/samagotchi/child_move.rb +3 -3
  59. data/lib/samagotchi/commands/registry.rb +6 -6
  60. data/lib/samagotchi/composer_route.rb +4 -5
  61. data/lib/samagotchi/config.rb +14 -12
  62. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  63. data/lib/samagotchi/context_candidates.rb +4 -3
  64. data/lib/samagotchi/continue_offer.rb +1 -1
  65. data/lib/samagotchi/debug_log.rb +1 -1
  66. data/lib/samagotchi/degrade.rb +75 -0
  67. data/lib/samagotchi/desktop/macos/ChiRunner.swift +3 -3
  68. data/lib/samagotchi/engine/cards.rb +87 -0
  69. data/lib/samagotchi/engine/context_judge.rb +138 -0
  70. data/lib/samagotchi/engine/events.rb +131 -0
  71. data/lib/samagotchi/engine/guardrails.rb +87 -0
  72. data/lib/samagotchi/engine/hooks.rb +119 -0
  73. data/lib/samagotchi/engine/messages.rb +124 -0
  74. data/lib/samagotchi/engine/model_settings.rb +127 -0
  75. data/lib/samagotchi/engine/models.rb +261 -0
  76. data/lib/samagotchi/engine/objectives.rb +116 -0
  77. data/lib/samagotchi/engine/participants.rb +86 -0
  78. data/lib/samagotchi/engine/plugins.rb +208 -0
  79. data/lib/samagotchi/engine/questions.rb +98 -0
  80. data/lib/samagotchi/engine/recap.rb +130 -0
  81. data/lib/samagotchi/engine/session.rb +131 -0
  82. data/lib/samagotchi/engine/side_requests.rb +135 -0
  83. data/lib/samagotchi/engine/steering.rb +134 -0
  84. data/lib/samagotchi/engine/toolkit.rb +269 -0
  85. data/lib/samagotchi/engine/turn_end.rb +180 -0
  86. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  87. data/lib/samagotchi/engine/verify.rb +153 -0
  88. data/lib/samagotchi/engine.rb +62 -2646
  89. data/lib/samagotchi/engine_snapshots.rb +9 -8
  90. data/lib/samagotchi/grants.rb +575 -0
  91. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  92. data/lib/samagotchi/guardrails/context.rb +2 -3
  93. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  94. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  95. data/lib/samagotchi/hook_routes.rb +3 -3
  96. data/lib/samagotchi/idle_recap.rb +7 -6
  97. data/lib/samagotchi/idle_reminders.rb +3 -4
  98. data/lib/samagotchi/iteration_limit.rb +2 -2
  99. data/lib/samagotchi/kernel_loop.rb +13 -13
  100. data/lib/samagotchi/launch_mode.rb +13 -32
  101. data/lib/samagotchi/level_setting_command.rb +3 -2
  102. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  103. data/lib/samagotchi/llm_context_command.rb +3 -2
  104. data/lib/samagotchi/log.rb +7 -7
  105. data/lib/samagotchi/log_line.rb +1 -1
  106. data/lib/samagotchi/log_path.rb +1 -1
  107. data/lib/samagotchi/note_command.rb +1 -1
  108. data/lib/samagotchi/note_delivery.rb +18 -8
  109. data/lib/samagotchi/objectives.rb +24 -3
  110. data/lib/samagotchi/objectives_check.rb +8 -27
  111. data/lib/samagotchi/objectives_command.rb +4 -69
  112. data/lib/samagotchi/objectives_edit.rb +87 -0
  113. data/lib/samagotchi/output_formatter.rb +7 -1
  114. data/lib/samagotchi/owner_lock.rb +7 -1
  115. data/lib/samagotchi/parent_answers_command.rb +5 -4
  116. data/lib/samagotchi/participant_exposure.rb +42 -0
  117. data/lib/samagotchi/participant_mode.rb +1 -1
  118. data/lib/samagotchi/participant_name.rb +1 -1
  119. data/lib/samagotchi/participants.rb +17 -17
  120. data/lib/samagotchi/participants_watch.rb +1 -1
  121. data/lib/samagotchi/plugin/api.rb +3 -2
  122. data/lib/samagotchi/plugin/context.rb +21 -7
  123. data/lib/samagotchi/plugin/peer.rb +2 -1
  124. data/lib/samagotchi/plugin/sessions.rb +6 -4
  125. data/lib/samagotchi/plugin_tasks.rb +1 -2
  126. data/lib/samagotchi/private_files.rb +29 -0
  127. data/lib/samagotchi/project_env.rb +2 -2
  128. data/lib/samagotchi/question_desk.rb +8 -7
  129. data/lib/samagotchi/recap_command.rb +2 -1
  130. data/lib/samagotchi/recap_setup.rb +1 -1
  131. data/lib/samagotchi/recap_store.rb +1 -1
  132. data/lib/samagotchi/redact.rb +86 -0
  133. data/lib/samagotchi/reminder_queue.rb +2 -2
  134. data/lib/samagotchi/remote_attach_command.rb +1 -1
  135. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  136. data/lib/samagotchi/review_start.rb +1 -1
  137. data/lib/samagotchi/run_options.rb +6 -6
  138. data/lib/samagotchi/self_report.rb +42 -39
  139. data/lib/samagotchi/send_command/busy.rb +1 -1
  140. data/lib/samagotchi/send_command.rb +4 -1
  141. data/lib/samagotchi/session.rb +11 -2
  142. data/lib/samagotchi/session_access/contract.rb +3 -3
  143. data/lib/samagotchi/session_access/local.rb +9 -9
  144. data/lib/samagotchi/session_access/remote.rb +17 -7
  145. data/lib/samagotchi/session_access.rb +4 -4
  146. data/lib/samagotchi/session_archive_command.rb +2 -2
  147. data/lib/samagotchi/session_chain.rb +8 -6
  148. data/lib/samagotchi/session_commands.rb +45 -48
  149. data/lib/samagotchi/session_continue.rb +1 -1
  150. data/lib/samagotchi/session_delete_command.rb +4 -4
  151. data/lib/samagotchi/session_manager.rb +67 -49
  152. data/lib/samagotchi/session_metrics.rb +2 -2
  153. data/lib/samagotchi/session_model_settings.rb +3 -2
  154. data/lib/samagotchi/session_participants.rb +1 -1
  155. data/lib/samagotchi/session_retention.rb +27 -7
  156. data/lib/samagotchi/sessions_command.rb +4 -4
  157. data/lib/samagotchi/share_command.rb +10 -10
  158. data/lib/samagotchi/steer.rb +13 -2
  159. data/lib/samagotchi/stream_event.rb +22 -0
  160. data/lib/samagotchi/system_prompt.rb +154 -145
  161. data/lib/samagotchi/task_watch.rb +1 -1
  162. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  163. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  164. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  165. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  166. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  167. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -11
  168. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  169. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  170. data/lib/samagotchi/terminal_ui/input_support.rb +17 -22
  171. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  172. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  173. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  174. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  175. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  176. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  177. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  178. data/lib/samagotchi/terminal_ui/reline_seam.rb +2 -12
  179. data/lib/samagotchi/terminal_ui/status_row.rb +6 -14
  180. data/lib/samagotchi/terminal_ui.rb +142 -1387
  181. data/lib/samagotchi/title_command.rb +2 -2
  182. data/lib/samagotchi/tool_runner.rb +15 -16
  183. data/lib/samagotchi/toolkit_command.rb +2 -1
  184. data/lib/samagotchi/tools/delegate.rb +3 -1
  185. data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
  186. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  187. data/lib/samagotchi/tools/objectives_tool.rb +2 -2
  188. data/lib/samagotchi/tools/send_note.rb +2 -1
  189. data/lib/samagotchi/turn_endings.rb +2 -2
  190. data/lib/samagotchi/turn_flow.rb +11 -7
  191. data/lib/samagotchi/update_hint.rb +2 -2
  192. data/lib/samagotchi/version.rb +1 -1
  193. data/lib/samagotchi/web/app.rb +42 -51
  194. data/lib/samagotchi/web/message_display.rb +7 -0
  195. data/lib/samagotchi/web/participant_access.rb +2 -2
  196. data/lib/samagotchi/web/participant_review.rb +5 -5
  197. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  198. data/lib/samagotchi/web/participant_view.rb +2 -82
  199. data/lib/samagotchi/web/participants_admin.rb +1 -1
  200. data/lib/samagotchi/web/public/block_host.js +35 -8
  201. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  202. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  203. data/lib/samagotchi/web/public/cards_place.js +5 -3
  204. data/lib/samagotchi/web/public/chains.js +2 -2
  205. data/lib/samagotchi/web/public/claim_link.js +4 -4
  206. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  207. data/lib/samagotchi/web/public/format.js +4 -3
  208. data/lib/samagotchi/web/public/turn_html.js +8 -4
  209. data/lib/samagotchi/web/push/store.rb +1 -1
  210. data/lib/samagotchi/web/session_access_routes.rb +13 -4
  211. data/lib/samagotchi/web/session_control.rb +12 -8
  212. data/lib/samagotchi/web/session_create.rb +1 -1
  213. data/lib/samagotchi/web/session_hub.rb +2 -2
  214. data/lib/samagotchi/web/session_show.rb +6 -2
  215. data/lib/samagotchi/web/session_summary.rb +1 -1
  216. data/lib/samagotchi/worker.rb +103 -9
  217. data/lib/samagotchi/worker_link.rb +3 -3
  218. data/lib/samagotchi/working_set_command.rb +3 -2
  219. data/lib/samagotchi.rb +0 -1
  220. metadata +31 -7
  221. data/lib/samagotchi/pending_input_queue.rb +0 -55
  222. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  223. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  224. data/lib/samagotchi/web/grants.rb +0 -575
  225. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  226. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -17,7 +17,7 @@ require_relative "turn_endings"
17
17
  require_relative "turn_origin"
18
18
  require_relative "revise_anchor"
19
19
  require_relative "verify_anchor"
20
- require_relative "web/participant_view"
20
+ require_relative "redact"
21
21
  require_relative "verdict"
22
22
  require_relative "model_profile"
23
23
  require_relative "effective_model"
@@ -108,6 +108,26 @@ require_relative "prompt_warmup"
108
108
  require_relative "working_set"
109
109
  require_relative "participants_watch"
110
110
  require_relative "session_participants"
111
+ require_relative "engine/steering"
112
+ require_relative "engine/side_requests"
113
+ require_relative "engine/recap"
114
+ require_relative "engine/models"
115
+ require_relative "engine/model_settings"
116
+ require_relative "engine/events"
117
+ require_relative "engine/cards"
118
+ require_relative "engine/objectives"
119
+ require_relative "engine/messages"
120
+ require_relative "engine/plugins"
121
+ require_relative "engine/hooks"
122
+ require_relative "engine/guardrails"
123
+ require_relative "engine/participants"
124
+ require_relative "engine/session"
125
+ require_relative "engine/verify"
126
+ require_relative "engine/questions"
127
+ require_relative "engine/toolkit"
128
+ require_relative "engine/context_judge"
129
+ require_relative "engine/turn_helpers"
130
+ require_relative "engine/turn_end"
111
131
 
112
132
  module Samagotchi
113
133
  # Engine owns the core agent logic: system prompt construction, tool
@@ -115,7 +135,36 @@ module Samagotchi
115
135
  #
116
136
  # It exposes an event-based API (`on_event`) so that any UI can run
117
137
  # turns without coupling to terminal rendering.
138
+ #
139
+ # This file keeps the wiring (initialize, the build_* methods, the
140
+ # kernel) and the turn skeleton (run_turn, begin_turn, prepare_turn's
141
+ # ordered steps, generate, end_turn) and shutdown. The rest is in parts
142
+ # by concern: lib/samagotchi/engine/<concern>.rb holds module
143
+ # Engine::<Concern>Part (the Part suffix keeps a part's name off the
144
+ # Samagotchi constants Engine's code names bare: Toolkit, Objectives…),
145
+ # included here; they share Engine's ivars.
118
146
  class Engine
147
+ include SteeringPart
148
+ include SideRequestsPart
149
+ include RecapPart
150
+ include ModelsPart
151
+ include ModelSettingsPart
152
+ include EventsPart
153
+ include CardsPart
154
+ include ObjectivesPart
155
+ include MessagesPart
156
+ include PluginsPart
157
+ include HooksPart
158
+ include GuardrailsPart
159
+ include ParticipantsPart
160
+ include SessionPart
161
+ include VerifyPart
162
+ include QuestionsPart
163
+ include ToolkitPart
164
+ include ContextJudgePart
165
+ include TurnHelpersPart
166
+ include TurnEndPart
167
+
119
168
  # Raised by #answer_question when the question it targets is no longer
120
169
  # open (never asked, superseded, already answered or cancelled). A subclass
121
170
  # of ArgumentError for existing callers; transports map it to 409 Conflict.
@@ -182,7 +231,7 @@ module Samagotchi
182
231
  @host_registry = host_registry || HostRegistry.new
183
232
  # A model given here (a worker's session model) is the one it runs,
184
233
  # so its host is checked; the config default is only checked once it
185
- # is used (the REPL checks the model it starts on, #switch_model!).
234
+ # is used (the one-shot checks the model it starts on, #switch_model!).
186
235
  @default_model_name = ModelProfile.required_model_name(model_name)
187
236
  ModelProfile.check_host!(model_name, hosts: @host_registry.entries) unless model_name.to_s.strip.empty?
188
237
  # The model choice and the profile resolved for it, under one mutex.
@@ -274,9 +323,8 @@ module Samagotchi
274
323
  end
275
324
  # The idle subsystems' (session recap + reminders) inactivity clock
276
325
  # starts now, after the plugins loaded. `record_activity` is the
277
- # single seam every UI calls (run_turn itself, the REPL on keystrokes
278
- # and after a reminder turn), so the idle layer's clock is identical
279
- # across UIs.
326
+ # single seam every host calls (run_turn itself, the worker after a
327
+ # reminder turn), so the idle layer's clock is identical across hosts.
280
328
  @turn_state.restart_clock!
281
329
  @side_client_mutex = Mutex.new
282
330
  # Kept for a rebuild under a bound session's own recap values.
@@ -364,7 +412,7 @@ module Samagotchi
364
412
  # The persistent observers of every turn's events.
365
413
  def subscribe_session_observers
366
414
  # The metrics collector is a persistent observer so every run_turn event
367
- # (the REPL, -p/--non-interactive/--resume and SessionManager workers)
415
+ # (a -p --non-interactive one-shot and SessionManager workers)
368
416
  # feeds it automatically.
369
417
  @session_observer.subscribe(observer: @metrics)
370
418
  # And the event trail in the debug log (`turn` records).
@@ -572,330 +620,11 @@ module Samagotchi
572
620
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
573
621
  end
574
622
 
575
- # The backend for the next turn: the loop the effective model's host speaks.
576
- def backend(model = effective_model)
577
- backend_for(model)
578
- end
579
-
580
- # Record that activity happened (user input or a completed turn): the
581
- # idle jobs' seam (TurnState#record_activity). Each call advances both
582
- # the last-activity timestamp and the activity sequence.
583
- # @param now [Float, nil] injectable monotonic time (defaults to now)
584
- def record_activity(now = nil)
585
- @turn_state.record_activity(now)
586
- end
587
-
588
- # @return [Float] monotonic seconds of the last recorded activity
589
- def last_activity_at
590
- @turn_state.last_activity_at
591
- end
592
-
593
- # @return [Integer] monotonically-increasing activity counter (advanced by
594
- # #record_activity; lets the idle detector summarize once per idle window)
595
- def activity_seq
596
- @turn_state.activity_seq
597
- end
598
-
599
- # @return [Boolean] true while a turn is in flight (the idle jobs never
600
- # fire, nor render, while the model is generating)
601
- def turn_running?
602
- @turn_state.running?
603
- end
604
-
605
- # Put +text+ into the running turn, as a UI's steering does: it joins the
606
- # conversation at the loop's next iteration boundary as its own user
607
- # message (kind: "steer", source:). Callable from any thread; never
608
- # blocks. With no turn running it does nothing (it never starts one).
609
- # True means queued, not merged: at the after-answer boundary, and when
610
- # the turn ends first, it is dropped (logged).
611
- # @return [Boolean] whether it was queued
612
- def steer(text, source:)
613
- @turn_state.steer(text, source: source)
614
- end
615
-
616
- # A message for the running turn from +source+ cuts the generation
617
- # streaming only thinking (SteerCut#cut_for_steer). Call it after the
618
- # message is queued, with the #input_epoch read before queueing it.
619
- # @return [Boolean] whether a generation was cut now
620
- def cut_for_steer(source, epoch: nil) = @steer_cut.cut_for_steer(source, epoch: epoch)
621
-
622
- # The drains that took input so far: read it before queueing a message
623
- # for #cut_for_steer.
624
- def input_epoch = @steer_cut.input_epoch
625
-
626
- # The running generation's phase (SteerCut#generation_snapshot): what
627
- # Bridge#snapshot puts in current_turn.generation.
628
- def generation_snapshot = @steer_cut.generation_snapshot
629
-
630
- # Like #steer, for the turn that begins next (a continue turn answered
631
- # with a text): it joins that turn at its first boundary. Callable from
632
- # any thread; a turn that never begins keeps it for the next one, unless
633
- # #drop_carried_steers drops it.
634
- # @return [Boolean] whether it was queued (false: blank)
635
- def steer_next_turn(text, source:)
636
- @turn_state.steer_next_turn(text, source: source)
637
- end
638
-
639
- # Drop the #steer_next_turn steers no turn began with (the continue turn
640
- # failed before it began), logged; a no-op once a turn took them.
641
- def drop_carried_steers
642
- log_dropped_steers(@turn_state.drop_carried_steers, "not_begun")
643
- end
644
-
645
- # The drain a turn's loop gets: the caller's lines (a UI's steering; nil
646
- # in a --non-interactive run) and the plugin steers. At the after-answer
647
- # boundary the steers are dropped: the model answered, and a nudge would
648
- # only restart the turn. The steer part fails on its own, never taking
649
- # the user's lines with it. Items are a String (a user line), a
650
- # Steer::Line (a worker's input line with its sender) or a steer Hash.
651
- def turn_drain(pending_input)
652
- lambda do |at_answer: false|
653
- lines = pending_input ? Array(pending_input.call) : []
654
- # The input went to the model: a message that waited to cut is in.
655
- @steer_cut.delivered! unless lines.empty?
656
- lines + take_steers(at_answer)
657
- end
658
- end
659
- private :turn_drain
660
-
661
- def take_steers(drop)
662
- steers = @turn_state.take_steers
663
- return steers unless drop
664
-
665
- log_dropped_steers(steers, "answered")
666
- []
667
- rescue StandardError => e
668
- Log.warn(:turn, "take_steers_failed", error: e.class.name, msg: e.message.to_s[0, 200])
669
- []
670
- end
671
- private :take_steers
672
-
673
- def log_dropped_steers(steers, why)
674
- steers.each { |steer| Log.info(:turn, "steer_dropped", source: steer[:source], why: why, chars: steer[:text].length) }
675
- end
676
- private :log_dropped_steers
677
-
678
- # @return [Array<String>] reminder names queued for a synthetic REPL turn
679
- def due_reminder_names
680
- @reminder_queue.pending_names
681
- end
682
-
683
- # Queue reminder names for a synthetic REPL turn (IdleReminders callback).
684
- def note_due_reminders(names)
685
- @reminder_queue.note_pending(names)
686
- end
687
-
688
- def clear_due_reminder_names!
689
- @reminder_queue.clear_pending!
690
- end
691
-
692
- # @return [CancellationController, nil] active turn's cancellation controller
693
- def active_cancel_controller
694
- @turn_state.controller
695
- end
696
-
697
- # Whether a message a person sent waits for the running turn's next
698
- # boundary (run_turn's input_waiting: probe): a task_wait or delegate wait
699
- # returns on it, an execute hands its command over early
700
- # (PeerView#input_waiting?). False outside a turn, for a turn given no
701
- # probe, and when the probe raises (warned once a turn; the tool never
702
- # fails for it).
703
- def input_waiting?
704
- probe = @turn_state.input_waiting
705
- !!probe&.call
706
- rescue StandardError => e
707
- Log.warn(:turn, "input_waiting_failed", error: e.class.name, msg: e.message) if @turn_state.input_waiting_failed!
708
- false
709
- end
710
-
711
- # Cancel the currently running turn, if any.
712
- # @param reason [Symbol] cancellation reason
713
- # @return [Boolean] whether a cancellation was triggered
714
- def cancel_current_turn!(reason = :manual)
715
- @turn_state.cancel!(reason)
716
- end
717
-
718
- # Snapshot the current session messages as a JSON string for the idle
719
- # recap. The array is never mutated in place (a turn or a note replaces
720
- # it), so the reference read here is a consistent snapshot; a dup'd
721
- # copy is serialized. Never mutates session.messages.
722
- # @return [String] JSON array of the messages
723
- def messages_json_for_recap
724
- snapshot = @session&.messages
725
- return "[]" if snapshot.nil?
726
-
727
- JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
728
- end
729
-
730
- # Emit a :recap_ready event (additive slot) carrying the generated recap
731
- # and the generation id an observer uses to reject an invalidated recap.
732
- # +covered+ counts the session messages it summarizes; +created_at+ is
733
- # the saved recap's (UTC ISO; the UIs show its local time).
734
- def emit_recap(recap:, generation:, covered: nil, created_at: nil)
735
- event = { type: :recap_ready, recap: recap, generation: generation, covered: covered }
736
- event[:created_at] = created_at if created_at
737
- @session_observer.notify(event)
738
- end
739
-
740
- # Count one side request (a recap, a plugin's ctx.ask_model: IdleClient's
741
- # on_usage) in the session's side line, apart from its turns' totals.
742
- # Folded and announced as :side_usage under the event lock, so the side
743
- # blocks reach the UIs in fold order (the lock order event lock →
744
- # metrics mutex is the existing one); then, between turns, saved to
745
- # analytics.json (a running turn's end or checkpoint saves it), unless
746
- # the session is gone (no session file: a late recap thread must not
747
- # recreate a deleted session's folder; the folder itself may not exist
748
- # yet before a first turn). Safe from any thread: the recap's, a
749
- # plugin's, the turn's.
750
- # A side request counts against the participant who caused it
751
- # (#side_author), its usage's own author when it names one.
752
- # @param usage [SideUsage]
753
- # @return [void]
754
- def note_side_usage(usage)
755
- usage = usage.with(author: side_author(usage.kind)) if usage.author.nil?
756
- synchronize_events do
757
- block = @metrics.add_side(usage)
758
- @session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
759
- end
760
- sid = @metrics.session_id
761
- return if sid.nil? || turn_running?
762
- return unless File.exist?(Session.session_file(sid, state_dir: session_state_dir))
763
-
764
- @metrics.persist(state_dir: session_state_dir)
765
- end
766
-
767
- # Who caused a side request of +kind+ now: the running turn's author (a
768
- # plugin's ctx.ask_model in it), the last turn's for the idle recap
769
- # (it recaps that turn); nil, the owner's, for anything else (an owner
770
- # command's ask_model between turns).
771
- # @return [String, nil]
772
- def side_author(kind)
773
- # Check this is only ever the owner's, whichever turn runs as it ends.
774
- return nil if kind.to_s == ObjectivesCheck::SOURCE
775
- return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
776
-
777
- @guardrail_wiring.turn_author
778
- end
779
-
780
623
  RECAP_SIDE_KIND = "recap"
781
624
 
782
- # The running turn's author when a participant wrote it, else nil
783
- # (ctx.turn_author, the hooks' author).
784
- # @return [String, nil]
785
- def turn_author = turn_running? ? @guardrail_wiring.turn_author : nil
786
-
787
625
  # @return [SessionMetrics] the per-session analytics collector
788
626
  attr_reader :metrics
789
627
 
790
- # The model as given (maybe an alias; HostRegistry#resolve applies it).
791
- def effective_model_name = @model_binding.choice.name
792
-
793
- # The model as typed at start or at the last /model.
794
- def typed_model_name = @model_binding.choice.typed
795
-
796
- # The resolved ref a session stores for the effective model (ModelRef#ref:
797
- # the alias applied, "host:id" when it names a host).
798
- def effective_model_ref
799
- model_ref_for(effective_model_name)
800
- end
801
-
802
- # The effective model as the system prompt names it (SystemPrompt::ModelIdentity):
803
- # the ref, its host, the overlay key and, for a local llama.cpp host
804
- # only, the model its /props names when that differs. Never a new
805
- # request: only the /props answer profile resolution already cached.
806
- def model_identity
807
- model = effective_model
808
- entry = model.entry
809
- host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
810
- SystemPrompt::ModelIdentity.new(ref: model.ref, host: host, key: @model_binding.model_key,
811
- served: model.prompt_served, fallback_key: @model_binding.fallback_model_key)
812
- end
813
-
814
- # The model notes for the effective model (ModelNotes): matched on its
815
- # bare id and key, the session's mutes left out.
816
- # @return [Array<ModelNotes::Note>]
817
- def model_notes
818
- ModelNotes.for(name: guardrail_model_name, key: @model_binding.model_key, fallback_key: @model_binding.fallback_model_key,
819
- muted: @muted_memory_names)
820
- end
821
-
822
- # The model notes the session's prompt carried at its last build
823
- # (Session#prompt_notes): a resumed session's or a woken worker's saved
824
- # ones until this process builds its prompt; a fresh session (no build
825
- # yet, no messages) has none saved, so the effective model's prompt
826
- # would load them: the notes #model_notes names, as without a session.
827
- # The three readers (/model, /stats, the web) share this.
828
- # @return [Array<PromptNote>]
829
- def prompt_notes
830
- return model_notes.map { |note| PromptNote.from_note(note) } unless @session
831
-
832
- saved = @session.prompt_notes
833
- return saved.dup if !@prompt_builder.prompt_notes.nil? || !saved.empty? || @session.messages.to_a.any?
834
-
835
- model_notes.map { |note| PromptNote.from_note(note) }
836
- rescue StandardError => e
837
- Log.warn(:model, "prompt_notes_failed", error: e.class.name, msg: e.message.to_s[0, 200])
838
- []
839
- end
840
-
841
- # Records +notes+ (ModelNotes::Note) on the session as the ones its
842
- # prompt carries; saved with it.
843
- def record_prompt_notes(notes)
844
- @session.prompt_notes = Array(notes).map { |note| PromptNote.from_note(note) } if @session && notes
845
- end
846
- private :record_prompt_notes
847
-
848
- # A failed turn's one line (the error's #summary). A host's failure
849
- # under an alias says what the alias became (HostRegistry#alias_note):
850
- # "host main" alone doesn't tell --model splash's user it was splash.
851
- # @return [String]
852
- def failure_summary(error)
853
- summary = error.respond_to?(:summary) ? error.summary : error.message
854
- return summary unless error.is_a?(LLM::ProviderError) && error.host
855
-
856
- note = @host_registry.alias_note(effective_model_name)
857
- note && note.end_with?("on host #{error.host}") ? "#{summary} (#{note})" : summary
858
- rescue StandardError => e
859
- EffectiveModel::Unresolved.fallback(:alias_note_failed, effective_model_name, e, summary)
860
- end
861
-
862
- # +name+'s resolved ref (ModelRef#ref).
863
- def model_ref_for(name)
864
- @host_registry.model_ref(name).ref
865
- end
866
-
867
- # The ref #switch_model! would switch +model_name+ to, checked the way it
868
- # checks one (UnknownHost, MissingModel), without switching.
869
- # @return [String]
870
- def model_switch_ref(model_name)
871
- model_ref_for(ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries))
872
- end
873
-
874
- # Writes the effective model to +session+: the resolved ref, and the
875
- # name as typed when that differs (model_typed: an alias, for the
876
- # models: lookup after a resume).
877
- def store_model!(session)
878
- ref = effective_model_ref
879
- session.model_name = ref
880
- typed = typed_model_name
881
- session.model_typed = typed.to_s == ref ? nil : typed
882
- session
883
- end
884
-
885
- # The effective model's key (ModelOverlay.key_for its bare name): memory
886
- # overlays, guardrail rules' models:.
887
- # @return [String, nil]
888
- def model_key = @model_binding.model_key
889
-
890
- # The prompt profile for the effective model (see #profile_resolution).
891
- # @return [ModelProfile]
892
- def profile = profile_resolution.profile
893
-
894
- # Which profile the effective model gets and where that came from,
895
- # kept with the model choice it was resolved for
896
- # (ModelBinding#profile_resolution).
897
- # @return [ModelProfile::Resolution]
898
- def profile_resolution = @model_binding.profile_resolution
899
628
  attr_reader :default_model_name, :host_registry, :client
900
629
 
901
630
  # @return [Commands::Registry] the commands this session runs: the
@@ -905,55 +634,6 @@ module Samagotchi
905
634
  # @return [PeerSources] the sources the peer bundles declared
906
635
  attr_reader :peer_sources
907
636
 
908
- # The model id sent for +full_ref+ (its alias applied, the host prefix off).
909
- def bare_model_name(full_ref)
910
- @host_registry.bare_name(full_ref)
911
- end
912
-
913
- # The memory overlay keys follow the model sent (ModelBinding#sync_model_key!).
914
- def sync_model_key! = @model_binding.sync_model_key!
915
- private :sync_model_key!
916
-
917
- # The effective model's bare name for the guardrails' models: rules;
918
- # nil without a model name.
919
- def guardrail_model_name
920
- model = effective_model_name
921
- name = model && bare_model_name(model)
922
- name.to_s.strip.empty? ? nil : name
923
- end
924
-
925
- # Point the kernel (and a chat backend) at the effective model's host
926
- # (after /model, --model, resume).
927
- def sync_kernel_client!(model = effective_model)
928
- @client = model.client
929
- @kernel.client = model.client if @kernel.client != model.client
930
- backend_for(model)
931
- end
932
-
933
- # Subscribe a persistent observer to engine events.
934
- #
935
- # Unlike the turn-scoped `on_event:` sink, a subscribed observer keeps
936
- # receiving events across every `run_turn` call on this Engine. Each
937
- # delivery carries a locally-monotonic `event_seq`. The returned handle can
938
- # be used to unsubscribe later.
939
- # @param observer [#call] receives event hashes (with `event_seq:` merged in)
940
- # @return [Samagotchi::SessionObserver::SubscribedObserver] handle to unsubscribe
941
- def subscribe(observer:)
942
- @session_observer.subscribe(observer: observer)
943
- end
944
-
945
- # Unsubscribe a previously-registered observer.
946
- # @param handle [Samagotchi::SessionObserver::SubscribedObserver]
947
- # @return [Boolean] whether the observer was removed (nil/unknown never raises)
948
- def unsubscribe(handle:)
949
- @session_observer.unsubscribe(handle: handle)
950
- end
951
-
952
- # @return [Integer] total engine events emitted so far (locally monotonic)
953
- def event_count
954
- @session_observer.event_count
955
- end
956
-
957
637
  # Event types #announce accepts: facts about the session's input queue,
958
638
  # a failed turn's prompt handed back and the continue offer, which live
959
639
  # UIs need in the event log, emitted outside any turn's stream; a
@@ -963,217 +643,6 @@ module Samagotchi
963
643
  plugin_init_started plugin_init_finished working_set_updated participants_changed
964
644
  verdict_recorded objectives_updated].freeze
965
645
 
966
- # Put a transport-level event into the ordered event log. Unlike turn
967
- # events it reaches only persistent observers (no turn sink, no memory
968
- # capture).
969
- # @param event [Hash] with :type in ANNOUNCEABLE_EVENTS
970
- # @raise [ArgumentError] for any other type
971
- def announce(event)
972
- unless ANNOUNCEABLE_EVENTS.include?(event[:type])
973
- raise ArgumentError, "cannot announce #{event[:type].inspect} (allowed: #{ANNOUNCEABLE_EVENTS.join(", ")})"
974
- end
975
-
976
- @session_observer.notify(event)
977
- end
978
-
979
- # Run the block with the event log held: no event is numbered or
980
- # delivered meanwhile, and events the block emits keep their order.
981
- def synchronize_events(&)
982
- @session_observer.synchronize(&)
983
- end
984
-
985
- # The working set's :working_set_updated, announced between turns
986
- # (after a command changed what the next request sends), when it
987
- # differs from the last one emitted (#emit_working_set). Under the
988
- # event lock, so it is ordered with the change's own events.
989
- def announce_working_set(session = @session)
990
- return unless session
991
-
992
- synchronize_events { emit_working_set(nil, session, session.messages) }
993
- end
994
-
995
- # The session's objectives as the page draws them (Objectives.view), when
996
- # that differs from the last announced: after an edit (its file written,
997
- # pending), once a list note is in, at a turn's end (its moves) and after
998
- # a rollback. A session with no list announces nothing. It only informs:
999
- # a failure is logged, never the turn's.
1000
- def announce_objectives(session = @session)
1001
- return unless session
1002
-
1003
- synchronize_events do
1004
- view = Objectives.view(session.id, session.messages, state_dir: session_state_dir, running: live_conversation&.dup)
1005
- next if view.nil? || view == @objectives_announced
1006
-
1007
- @objectives_announced = view
1008
- announce({ type: :objectives_updated, session_id: session.id, **view })
1009
- end
1010
- rescue StandardError => e
1011
- Log.warn(:turn, "objectives_announce_failed", error: e.class.name, msg: e.message)
1012
- end
1013
-
1014
- # Check this (ObjectivesCheck)'s one side request. Its usage is in the
1015
- # session's side line. Raises as ctx.ask_model's request does.
1016
- # @param request [Array<Hash>] chat messages
1017
- # @param model [String, nil] a configured model; nil: the session's
1018
- # @return [String] the answer
1019
- def ask_objective_check(request, model:)
1020
- ask_side_model(request, timeout: ObjectivesCheck::TIMEOUT_SECONDS, max_tokens: ObjectivesCheck::MAX_TOKENS,
1021
- cancel_controller: nil, model: model, source: ObjectivesCheck::SOURCE)
1022
- end
1023
-
1024
- # A change the model proposes (the objectives tool), kept until the turn
1025
- # is over, as #context_choice's cards are: a card shown then is a quiet one, between turns, with its Apply kept
1026
- # over a restart (CardStore), not a running turn's that says it waits on the
1027
- # user. The same change twice in a turn is one card.
1028
- # @return [Boolean] false past Objectives::PROPOSALS_PER_TURN
1029
- def propose_objective(proposal)
1030
- @objective_proposals_mutex.synchronize do
1031
- next true if @objective_proposals.any? { |p| p.line == proposal.line }
1032
- next false if @objective_proposals.size >= Objectives::PROPOSALS_PER_TURN
1033
-
1034
- @objective_proposals << proposal
1035
- true
1036
- end
1037
- end
1038
-
1039
- def show_objective_proposals
1040
- drain_objective_proposals.each do |proposal|
1041
- id = "objectives-#{SecureRandom.hex(4)}"
1042
- show_card(source: Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
1043
- actions: [{ label: "Apply", command: proposal.action_line(id) },
1044
- { label: "Dismiss", command: "/objectives --card #{id} dismiss" }])
1045
- end
1046
- rescue StandardError => e
1047
- Log.warn(:turn, "objectives_proposal_failed", error: e.class.name, msg: e.message)
1048
- end
1049
- private :show_objective_proposals
1050
-
1051
- # A turn that did not complete (canceled, failed) takes its proposals
1052
- # off the buffer without showing them: their Apply card would sit after a
1053
- # turn whose `objectives propose` call is not in the history (a rollback
1054
- # after Ctrl-C drops it), and leaving them in the buffer would show them
1055
- # at the end of the next turn.
1056
- def discard_objective_proposals(turn)
1057
- dropped = drain_objective_proposals
1058
- Log.info(:turn, "objectives_proposals_dropped", count: dropped.size, outcome: turn.outcome.to_s) unless dropped.empty?
1059
- end
1060
- private :discard_objective_proposals
1061
-
1062
- def drain_objective_proposals
1063
- @objective_proposals_mutex.synchronize { @objective_proposals.slice!(0..) }
1064
- end
1065
- private :drain_objective_proposals
1066
-
1067
- # The strategy the working set is built under now, as LLMContextView
1068
- # takes it: the running turn's, else the next turn's (#emit_working_set).
1069
- # /working-set's ops act under it.
1070
- # @return [Symbol, Array<Symbol>]
1071
- def working_set_strategy
1072
- working_set_llm_context&.strategy || LLMContextView::NONE
1073
- end
1074
-
1075
- # Replace the current session's conversation, between turns (a
1076
- # /working-set op's copy: #clone_messages, then its edits).
1077
- # @param messages [Array<Hash>]
1078
- # @return [Array<Hash>] the session's messages
1079
- def replace_messages(messages)
1080
- raise ArgumentError, "no current session" unless @session
1081
-
1082
- replace_session_messages(@session, messages)
1083
- end
1084
-
1085
- # Add a context note to the session's conversation, between turns: a
1086
- # tail system message the next turn sees, announced as :context_added.
1087
- # The array is replaced, not mutated, and the append and the event
1088
- # happen under the event lock, so a joining UI sees both or neither.
1089
- # The caller saves the session.
1090
- # @param note [Hash] SessionInbox.read_note's shape
1091
- # @return [Hash, nil] the message, or nil when the conversation already
1092
- # holds this note (a note file re-read after a crash)
1093
- def add_context_note(session, note)
1094
- synchronize_events do
1095
- next nil if Array(session.messages).any? { |m| m[:note_id] == note[:note_id] }
1096
-
1097
- message = ContextNote.message(**note)
1098
- entry = note[:objectives] && Objectives.entry(note[:objectives], before: Objectives.build(session.messages))
1099
- replace_session_messages(session, Array(session.messages) + [message])
1100
- announce({ type: :context_added, session_id: session.id, note_id: note[:note_id], source: note[:source],
1101
- label: ContextNote.label_of(message), from_session: note[:from_session],
1102
- from_cwd: note[:from_cwd], text: note[:text], context_source: note[:context_source],
1103
- created_at: note[:created_at], objectives: entry }.compact)
1104
- announce_objectives(session) if entry
1105
- objectives_audit.note(session, note)
1106
- message
1107
- end
1108
- end
1109
-
1110
- # ── Cards ───────────────────────────────────────────────────────────────
1111
-
1112
- # Show a card in every UI (docs/plugins.md, Cards): {type: :card, id:,
1113
- # source:, title:, body:, level:, actions: [{label:, command:}], in_turn:}.
1114
- # Where it goes (held while the plugins load, an anytime command's, an
1115
- # init task's, a turn event, else announced) is EventRoutes#route's,
1116
- # marked in_turn: (CARD_MARKS). A card with the id of an earlier one
1117
- # replaces it (btw's "thinking…" → the answer).
1118
- # @param source [String] who shows it (a bundle's name)
1119
- # @param actions [Array<Hash>] {label:, command:}; a command is a line
1120
- # the session runs, as typed (D3)
1121
- # @return [String] the card's id
1122
- # @raise [ArgumentError] a card without a title, or a bad action
1123
- def show_card(source:, title:, body: "", actions: [], level: :info, id: nil)
1124
- card = Plugin::Card.build(source: source, title: title, body: body, actions: actions, level: level, id: id)
1125
- command = Thread.current[running_command_key]
1126
- card[:command] = command if command
1127
- @event_routes.route(card, **EventRoutes::CARD_MARKS)
1128
- card[:id]
1129
- end
1130
-
1131
- # Run an anytime command's block (D8): the cards and notices it shows on
1132
- # this thread are announced at once, marked anytime: true, and are
1133
- # never a running turn's events. They belong to the command, not the
1134
- # turn, and a UI shows them after the command's own line (a web
1135
- # command bubble drawn at its command_queued), even mid-turn.
1136
- # @return the block's value
1137
- def running_anytime
1138
- key = :"samagotchi_anytime_#{object_id}"
1139
- outer = Thread.current[key]
1140
- Thread.current[key] = true
1141
- yield
1142
- ensure
1143
- Thread.current[key] = outer
1144
- end
1145
-
1146
- # Run a session command's dispatch: a card shown on this thread says
1147
- # it's that command's (:card's command:), for a reload that no longer has
1148
- # the command's line above it (Bridge::CardStore). A thread of its own
1149
- # isn't the command, nor a card shown after it.
1150
- # @param name [String] the command, "/short"
1151
- # @return the block's value
1152
- def running_command(name)
1153
- outer = Thread.current[running_command_key]
1154
- Thread.current[running_command_key] = name
1155
- yield
1156
- ensure
1157
- Thread.current[running_command_key] = outer
1158
- end
1159
-
1160
- # Whether an anytime command (/btw) still runs on its thread.
1161
- def anytime_running?
1162
- @lifecycle_mutex.synchronize { @anytime_threads.any?(&:alive?) }
1163
- end
1164
-
1165
- # Start an anytime command on its own thread (D8), one #shutdown waits
1166
- # for, so its command_ran is announced before the process leaves.
1167
- # @return [Thread]
1168
- def spawn_anytime(&)
1169
- thread = Thread.new(&)
1170
- @lifecycle_mutex.synchronize do
1171
- @anytime_threads.select!(&:alive?)
1172
- @anytime_threads << thread
1173
- end
1174
- thread
1175
- end
1176
-
1177
646
  # ── Plugin init tasks (chi.init) ──────────────────────────────────────
1178
647
 
1179
648
  # A plugin's slow setup (docs/plugins.md, Init tasks): PluginTasks.
@@ -1181,63 +650,6 @@ module Samagotchi
1181
650
  INIT_TASK_TIMEOUT = PluginTasks::INIT_TASK_TIMEOUT
1182
651
  INIT_WAIT_POLL = PluginTasks::INIT_WAIT_POLL
1183
652
 
1184
- # Add a plugin's init task (Plugin::Api#init at commit); it starts with
1185
- # #start_init_tasks!.
1186
- def add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed: nil, &)
1187
- @plugin_tasks.add(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
1188
- quiet: quiet, timeout: timeout, failed: failed, &)
1189
- end
1190
-
1191
- # Start the init tasks not started yet, each on its own thread
1192
- # (PluginTasks#start!).
1193
- def start_init_tasks!
1194
- @plugin_tasks.start!
1195
- end
1196
-
1197
- # The running init tasks a UI shows (not the quiet ones), for a UI that
1198
- # joins while they run (Bridge#snapshot, with the event log held).
1199
- # @return [Array<Hash>] {bundle:, id:, label:}
1200
- def init_tasks
1201
- @plugin_tasks.running
1202
- end
1203
-
1204
- # Wait for the running init tasks that provide tools (PluginTasks#await).
1205
- # @return [Boolean] whether it waited
1206
- def await_init_tasks(controller = nil, on_event = nil)
1207
- @plugin_tasks.await(controller, on_event)
1208
- end
1209
-
1210
- # The init task this thread runs, or nil.
1211
- def current_init_task = @plugin_tasks.current
1212
- private :current_init_task
1213
-
1214
- # ── Load events ───────────────────────────────────────────────────────
1215
-
1216
- # Announce what failed to load and what plugins showed while loading,
1217
- # once, as soon as the owner can show it (the worker's Bridge is up, the
1218
- # REPL renders events): the guardrail and plugin warnings, an orphaned
1219
- # project memory folder (label: "memory", MemoryOrphans), then the
1220
- # held notices (between_turns) and cards (in_turn: false, so late
1221
- # joiners get them). Without this call the first turn announces them
1222
- # (#announce_guardrail_failures).
1223
- def announce_load_events!
1224
- synchronize_events do
1225
- next if @guardrail_failures_announced
1226
-
1227
- @guardrail_failures_announced = true
1228
- message = @guardrail_failures.message
1229
- announce({ type: :guardrail_warning, message: message }) if message
1230
- plugins = @plugin_failures.message
1231
- announce({ type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
1232
- memory = MemoryOrphans.message
1233
- announce({ type: :guardrail_warning, message: memory, label: "memory" }) if memory
1234
- @extension_load.held_events.each do |event|
1235
- announce(event[:type] == :card ? event.merge(in_turn: false) : event.merge(between_turns: true))
1236
- end
1237
- end
1238
- nil
1239
- end
1240
-
1241
653
  # How long #shutdown waits for the running anytime commands, in all.
1242
654
  SHUTDOWN_JOIN_SECONDS = 3.0
1243
655
  # How long #shutdown waits for the running side judgments (System 1
@@ -1245,8 +657,8 @@ module Samagotchi
1245
657
  # after its turn), in all; a slower ask goes unlogged.
1246
658
  SIDE_JUDGMENTS_JOIN_SECONDS = 1.0
1247
659
 
1248
- # The REPL or the session's worker is leaving (/exit, an idle exit, a
1249
- # crash, TERM): stop the idle jobs, give the running anytime commands
660
+ # The session's worker (or a one-shot run) is leaving (/exit, an idle
661
+ # exit, a crash, TERM): stop the idle jobs, give the running anytime commands
1250
662
  # up to +join_timeout+ seconds to finish (their command_ran is
1251
663
  # announced), then stop the plugins' services, newest first. Once;
1252
664
  # later calls do nothing.
@@ -1279,474 +691,9 @@ module Samagotchi
1279
691
  self
1280
692
  end
1281
693
 
1282
- def anytime_thread? = Thread.current[:"samagotchi_anytime_#{object_id}"] == true
1283
- private :anytime_thread?
1284
-
1285
- def announce_anytime(event)
1286
- event = event.merge(anytime: true)
1287
- announce(event)
1288
- event
1289
- end
1290
- private :announce_anytime
1291
-
1292
- # Run the block holding back the cards and between-turns notices it
1293
- # announces on this thread, so the caller announces them after its own
1294
- # event (a worker's command: its command_ran first, as the REPL prints
1295
- # the command's output before its cards).
1296
- # @return [Array(Object, Array<Hash>)] the block's value and the held events
1297
- def holding_announcements
1298
- key = :"samagotchi_held_#{object_id}"
1299
- outer = Thread.current[key]
1300
- Thread.current[key] = []
1301
- value = yield
1302
- [value, Thread.current[key]]
1303
- ensure
1304
- Thread.current[key] = outer
1305
- end
1306
-
1307
- # Announce +event+ now, or keep it for #holding_announcements' caller.
1308
- def announce_or_hold(event)
1309
- held = Thread.current[:"samagotchi_held_#{object_id}"]
1310
- held ? held << event : announce(event)
1311
- end
1312
- private :announce_or_hold
1313
-
1314
- # ── Model switching ────────────────────────────────────────────────────────
1315
-
1316
- # @param typed [String, nil] the name the model was typed as when
1317
- # +model_name+ is a stored ref (a resumed session's model_typed)
1318
- # @return [String] the resolved ref the session stores (ModelRef#ref)
1319
- def switch_model!(model_name, persist_default: false, typed: nil)
1320
- # The alias is applied where the model is resolved (HostRegistry#resolve),
1321
- # once; checked here: an unknown host, an alias naming another host.
1322
- resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
1323
- choice = ModelChoice.new(name: resolved, typed: typed.to_s.strip.empty? ? model_name : typed.to_s.strip)
1324
- @model_binding.switch!(choice)
1325
- sync_model_key!
1326
- @prompt_builder.reset!
1327
- # The notes the new model's prompt loads at its next build, so /model,
1328
- # /stats and the web name them before the next turn does.
1329
- record_prompt_notes(model_notes) if @session
1330
- sync_kernel_client!
1331
- @client.invalidate_context_window!
1332
- @metrics.forget_model_reports!
1333
- # The last turn's ctx % was the old model's window's
1334
- # (#announce_switched_context! sends the new one's).
1335
- @last_context_status = nil
1336
- ref = effective_model_ref
1337
- if persist_default
1338
- ConfigFile.write_default_model!(ref)
1339
- @default_model_name = ref
1340
- end
1341
- ref
1342
- end
1343
-
1344
- # After /model: how full the context is on the new model's window,
1345
- # before its first turn: the last turn's count (the old model's
1346
- # tokenizer's, replaced at the next generation) against the new model's
1347
- # window (EffectiveModel#context_window, ContextWindow.resolve: the
1348
- # running server first, one /props GET that a remote chat host is
1349
- # spared; else the host's cached model list; else the configured
1350
- # window) and the
1351
- # session's budget. Sent as a :context_status event (the web's ctx
1352
- # meter, the attached TUI's status row) with context_window_tokens, and
1353
- # kept for a client that joins later (#session_state_snapshot; the
1354
- # window in the metrics' context too, which a page counts from). A window
1355
- # that can't be resolved sends usage nil: the UIs clear the % until the
1356
- # next generation. Nothing before the session's first counted turn.
1357
- # +configured_only+: the configured window only, never asking a server
1358
- # (EffectiveModel#configured_context_window): a loaded session's (#session=).
1359
- # @return [Hash, nil] the event sent
1360
- def announce_switched_context!(configured_only: false)
1361
- used = @metrics.snapshot.dig(:context, :used_tokens)
1362
- return nil unless used.is_a?(Integer)
1363
-
1364
- model = effective_model
1365
- window = configured_only ? model.configured_context_window : current_context_window(model)
1366
- display = window && saved_context_status({ used_tokens: used, window_tokens: window.tokens })
1367
- @last_context_status = display&.dup
1368
- @metrics.note_context_window(window.tokens, window.source) if window
1369
- usage = display && { estimated_pct: display[:est_pct], estimated_used_tokens: used, window_tokens: window.tokens,
1370
- window_source: window.source.to_s, source: "switch" }
1371
- event = { type: :context_status, usage: usage, bucket: display&.dig(:bucket),
1372
- context_window_tokens: window&.tokens, context_window_source: window&.source&.to_s }
1373
- @session_observer.notify(event)
1374
- event
1375
- rescue StandardError => e
1376
- Log.warn(:model, "switched_context_failed", error: e.class.name, msg: e.message.to_s[0, 200])
1377
- nil
1378
- end
1379
-
1380
- # ── Hooks API ──────────────────────────────────────────────────────────────
1381
-
1382
- # Register a hook callback for a named lifecycle event.
1383
- #
1384
- # Hooks are turn-scoped: they are automatically cleared after each
1385
- # `run_turn` call so that a single turn's hooks do not leak into the next.
1386
- #
1387
- # A hook that raises tells the user once, as a bundle's does
1388
- # (HookRoutes#plugin_hook_failed); the registry logs each failure and goes on.
1389
- #
1390
- # @param name [Symbol] one of the hook names (see Hooks module)
1391
- # @param block [Proc] receives an event hash (may mutate in place)
1392
- # @return [void]
1393
- def register_hook(name, &block)
1394
- return @hooks.register(name) unless block
1395
-
1396
- @hooks.register(name) do |event|
1397
- yield(event)
1398
- rescue StandardError => e
1399
- @hook_routes.plugin_hook_failed("#{Hooks::Registry::TURN_HOOK_LABEL} (#{name})", "#{e.class}: #{e.message}")
1400
- raise
1401
- end
1402
- end
1403
-
1404
- # Unregister a previously registered hook.
1405
- # @param name [Symbol]
1406
- # @return [Boolean] true if it was removed, false if not found
1407
- def unregister_hook(name)
1408
- @hooks.unregister(name)
1409
- end
1410
-
1411
- # Clear all registered hooks. Called automatically at the end of each
1412
- # `run_turn` to keep hooks turn-scoped.
1413
- # @return [void]
1414
- def clear_hooks
1415
- @hooks.clear_all
1416
- end
1417
-
1418
- # ── Reminders API ────────────────────────────────────────────────────────────
1419
- #
1420
- # Each turn injects the reminders due then (#turn_messages,
1421
- # ReminderQueue#inject!). Between turns the idle tick (IdleReminders)
1422
- # finds them due and its callback queues their names
1423
- # (#note_due_reminders); the worker and the REPL run a reminder turn
1424
- # (a continue turn) for them.
1425
-
1426
- # @return [Boolean] whether any reminder is due now (the store's view,
1427
- # which a turn would inject), regardless of the queued names
1428
- def reminders_due?
1429
- @reminder_queue.due?
1430
- end
1431
-
1432
- # @return [ReminderStore] the reminder store for inspection
1433
- attr_reader :reminder_store
1434
-
1435
- # The metrics for /stats, which may make one short /props GET
1436
- # (EngineSnapshots#stats_snapshot).
1437
- # @return [Hash] @metrics.snapshot, filled in
1438
- def stats_snapshot = @engine_snapshots.stats_snapshot
1439
-
1440
- # ── The session's model settings (SessionModelSettings) ──────────────────
1441
-
1442
- # The session's own thinking, reasoning_history and llm_context values
1443
- # over the effective model's, the summaries /model and /stats show, and
1444
- # the saved context's status: SessionModelSettings' (resolved on every
1445
- # read); these forward.
1446
- def saved_context_status(context, model: :current) = @model_settings.saved_context_status(context, model: model)
1447
- def llm_context_explained(own: :session) = @model_settings.llm_context_explained(own: own)
1448
- def llm_context_summary = @model_settings.llm_context_summary
1449
- def llm_context_override = @model_settings.llm_context_override
1450
- def chat_model? = @model_settings.chat_model?
1451
- def sampling_summary = @model_settings.sampling_summary
1452
- def thinking_summary = @model_settings.thinking_summary
1453
- def steer_cut_after_explained = @model_settings.steer_cut_after_explained
1454
- def steer_cut_after = @model_settings.steer_cut_after
1455
- def steer_cut_summary = @model_settings.steer_cut_summary
1456
- def thinking_explained(own: thinking_override) = @model_settings.thinking_explained(own: own)
1457
- def reasoning_history_summary = @model_settings.reasoning_history_summary
1458
- def reasoning_history_cache_cost(from, to) = @model_settings.reasoning_history_cache_cost(from, to)
1459
- def reasoning_history_override = @model_settings.reasoning_history_override
1460
- def thinking_override = @model_settings.thinking_override
1461
- def served_expected_by(served) = @model_settings.served_expected_by(served)
1462
- def served_expected?(served) = @model_settings.served_expected?(served)
1463
-
1464
- def reasoning_history_explained(native: false, own: reasoning_history_override)
1465
- @model_settings.reasoning_history_explained(native: native, own: own)
1466
- end
1467
-
1468
- # Set the session's own reasoning_history or thinking level (nil unsets
1469
- # it): SessionModelSettings'. The caller saves the session.
1470
- # @raise [ArgumentError] with no session yet
1471
- def reasoning_history_override=(level)
1472
- @model_settings.reasoning_history_override = level
1473
- end
1474
-
1475
- def thinking_override=(level)
1476
- @model_settings.thinking_override = level
1477
- end
1478
-
1479
- # Set the session's own llm_context values (an empty override unsets
1480
- # them); the next turn's start resolves them, never a running one (its
1481
- # TurnSettings are fixed). The caller saves the session.
1482
- # @raise [ArgumentError] with no session yet
1483
- def llm_context_override=(override)
1484
- raise ArgumentError, "no session yet" unless @session
1485
-
1486
- SessionSetting::LLM_CONTEXT.set(@session, override)
1487
- # The status a later client reads counts against the new budget.
1488
- @last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
1489
- end
1490
-
1491
- # Whether the session has a model reply (a cache to restart at all).
1492
- def history?
1493
- Array(@session&.messages).any? { |entry| entry[:role].to_s == "model" }
1494
- end
1495
-
1496
- # The latest known context size used by a request, for a cache-restart
1497
- # confirmation; nil before any request reports a count.
1498
- # @return [Integer, nil]
1499
- def cache_restart_tokens
1500
- used = @metrics.snapshot.dig(:context, :used_tokens)
1501
- used if used.is_a?(Integer)
1502
- end
1503
-
1504
- # Whether a cache restart can invalidate preserved thinking blocks.
1505
- def cache_restart_anthropic?
1506
- effective_model.entry&.api.to_s == "anthropic"
1507
- end
1508
-
1509
- # What a thinking level change costs the effective model's prompt
1510
- # cache (Thinking.cache_cost): :full, :provider or :tail.
1511
- # @return [Symbol]
1512
- def thinking_cache_cost
1513
- effective_model.thinking_cache_cost { profile }
1514
- end
1515
-
1516
- # The model the server serves for the current model, and the name asked
1517
- # for: what the last generation of that name reported, else llama.cpp's
1518
- # model_alias (/props, one short cached probe; not with probe: false),
1519
- # else [nil, nil].
1520
- # @return [Array(String, String), Array(nil, nil)]
1521
- def served_model(probe: true)
1522
- @engine_snapshots.served_model_for(@metrics.served_report, model: probe ? effective_model : nil)
1523
- end
1524
-
1525
- # Read-only snapshot of the engine's view of the current session plus the
1526
- # live event sequence; cheap, never probes, never raises
1527
- # (EngineSnapshots#session_state_snapshot has its keys).
1528
- # @return [Hash]
1529
- def session_state_snapshot = @engine_snapshots.session_state_snapshot
1530
-
1531
- # @return [Array<String>] deduped used memory names (thread-safe copy)
1532
- def used_memory_names
1533
- @used_memories.names
1534
- end
1535
-
1536
- # @return [Array<String>] the memories hidden from this session (normalized names)
1537
- def muted_memory_names
1538
- @muted_memory_names.dup
1539
- end
1540
-
1541
- # @return [Array<String>] the names the session preloads (config baseline
1542
- # + --memory, minus mutes), known before the prompt is built, unlike
1543
- # #activated_memory_names
1544
- def preloaded_memory_names
1545
- @prompt_builder.preloaded_memory_names
1546
- end
1547
-
1548
- # ── Guardrails ─────────────────────────────────────────────────────────────
1549
-
1550
- # Who can answer an approval: :repl, :worker or :non_interactive (the
1551
- # default, so a bare Engine denies instead of waiting for nobody). Set
1552
- # by the host (TerminalUI, Worker).
1553
- def interface
1554
- @guardrail_wiring.interface
1555
- end
1556
-
1557
- def interface=(value)
1558
- @guardrail_wiring.interface = value
1559
- end
1560
-
1561
- # Where the approval store lives: beside Session's state dir
1562
- # ($XDG_STATE_HOME/samagotchi/guardrails/). A Worker with its own state
1563
- # dir passes it.
1564
- # Where sessions live (a session's images/ are under it); a worker sets
1565
- # its own.
1566
- attr_writer :session_state_dir
1567
-
1568
- # The metrics load the session's saved records from its state dir, once.
1569
- def bind_metrics(session)
1570
- return unless session&.id
1571
-
1572
- @metrics.state_dir = session_state_dir
1573
- @metrics.session_id = session.id
1574
- end
1575
-
1576
- def session_state_dir = @session_state_dir || Session.default_state_dir
1577
-
1578
- # Whether a participant's pair turn must stop, read before each of its
1579
- # generations (#guard_participant_spend): author ("p:<id>") → why (their
1580
- # link ended, or they passed their cap), nil while it may go on. The
1581
- # Worker sets it (Worker#participant_turn_stop); without one (the REPL)
1582
- # nothing is checked mid-turn.
1583
- attr_writer :participant_stop
1584
-
1585
694
  # What a participant turn's stop is attributed to (HookRoutes#stop_turn).
1586
695
  PARTICIPANTS_HOOK = "participants"
1587
696
 
1588
- # Before each generation (KernelLoop#generation_guard): a pair turn whose
1589
- # participant's link ended, or whose spend (Engine#metrics: their
1590
- # finished turns, this one's finished generations, their side requests)
1591
- # passed their cap, stops on the plugins' stop path (cost-cap's,
1592
- # work-cap's), with its notice. A generation's own cost is known only
1593
- # once it ends, so a turn can pass the cap by its last generation. The
1594
- # turn-start check is the Worker's (Worker#participant_refusal); a
1595
- # review turn only reads, and keeps to it.
1596
- def guard_participant_spend
1597
- author = turn_author
1598
- return if author.nil? || @participant_stop.nil?
1599
- return unless @guardrail_wiring.turn_mode == ParticipantMode::PAIR
1600
-
1601
- reason = @participant_stop.call(author) or return
1602
- hook_stop_turn(reason, PARTICIPANTS_HOOK)
1603
- end
1604
- private :guard_participant_spend
1605
-
1606
- # The running turn's metrics so far, saved as a turn a worker died in
1607
- # would leave them (SessionMetrics#persist's running_turn): a worker's
1608
- # turn checkpoint (TurnCheckpoint) calls it when it writes.
1609
- # @return [Boolean] whether they were saved
1610
- def checkpoint_metrics = @metrics.persist(state_dir: session_state_dir, running_turn: true)
1611
-
1612
- # What a Bridge adds to a plugin's ctx.messages while a turn runs (the
1613
- # turn so far, as messages); nil without a Bridge (the REPL).
1614
- attr_writer :running_turn_messages
1615
-
1616
- def guardrail_state_dir=(state_dir)
1617
- # Also where list_sessions and send_note look for other sessions.
1618
- @state_dir = state_dir
1619
- @guardrail_wiring.state_dir = state_dir
1620
- end
1621
-
1622
- # The YAML rules, read again when a file changed (GuardrailWiring#rules).
1623
- # @return [Guardrails::Rules]
1624
- def guardrail_rules
1625
- @guardrail_wiring.rules
1626
- end
1627
-
1628
- # @return [Guardrails::LoadFailures]
1629
- attr_reader :guardrail_failures
1630
-
1631
- # @return [Guardrails::Approvals]
1632
- def guardrail_approvals
1633
- @guardrail_wiring.approvals
1634
- end
1635
-
1636
- # @return [Guardrails::LoadFailures] the plugins that failed to load
1637
- attr_reader :plugin_failures
1638
-
1639
- # Once per Engine, on its first turn: what failed to load, so every UI
1640
- # (REPL, attached TUI, web) shows it: the guardrails, then the plugins
1641
- # (label: "plugins"; the UIs say guardrails without one), an orphaned
1642
- # project memory folder (label: "memory"), then the notices and cards plugins showed as they loaded.
1643
- def announce_guardrail_failures(on_event)
1644
- return if @guardrail_failures_announced
1645
-
1646
- @guardrail_failures_announced = true
1647
- message = @guardrail_failures.message
1648
- emit_event(on_event, { type: :guardrail_warning, message: message }) if message
1649
- plugins = @plugin_failures.message
1650
- emit_event(on_event, { type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
1651
- memory = MemoryOrphans.message
1652
- emit_event(on_event, { type: :guardrail_warning, message: memory, label: "memory" }) if memory
1653
- @extension_load.held_events.each { |event| emit_event(on_event, event) }
1654
- end
1655
- private :announce_guardrail_failures
1656
-
1657
- # The warning the first turn announced (nil before it, or with nothing
1658
- # failed), for a UI that joins later (Bridge#snapshot).
1659
- def guardrail_warning
1660
- @guardrail_failures.message if @guardrail_failures_announced
1661
- end
1662
-
1663
- # The plugins' load warning the first turn announced, likewise, and what
1664
- # their failed chi.on handlers told (HookRoutes#plugin_hook_failed).
1665
- def plugin_warning
1666
- loaded = @plugin_failures.message if @guardrail_failures_announced
1667
- [loaded, @hook_routes.hook_failures_message].compact.join("; ").then { |text| text unless text.empty? }
1668
- end
1669
-
1670
- # Ask the user to approve a call the gate voted `ask` on
1671
- # (GuardrailWiring#request_approval).
1672
- # @param verdict [Guardrails::Verdict]
1673
- # @return [Guardrails::Verdict]
1674
- def request_approval(verdict)
1675
- @guardrail_wiring.request_approval(verdict)
1676
- end
1677
-
1678
- # The conversation as a hook may read it: a frozen array of copied
1679
- # messages, so a hook cannot change what the turn sends or stores.
1680
- def hook_messages(messages)
1681
- AnswerDisplay.strip_all(messages).map(&:dup).freeze
1682
- end
1683
- private :hook_messages
1684
-
1685
- # How the turn ended, on the session (Session#last_turn): the caller's
1686
- # save puts it in the file the session hub watches. Public for the
1687
- # Worker's turn that failed before it began (no #end_turn ran).
1688
- # @param limit [Integer, nil] the turn ran out of iterations at this
1689
- # limit: "exhausted" => true, "limit" => N, so a wait with nobody to
1690
- # answer the continue offer says so (ReplyWait, ParentReport "limit")
1691
- # @param origin [TurnOrigin] the turn's (TurnOrigin::NONE for no sender)
1692
- # @param event [Hash] the turn's end event (#stop_facts)
1693
- def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
1694
- enqueued_id = origin.enqueued_id
1695
- session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
1696
- origin: origin.source, exhausted: limit ? true : nil, limit: limit,
1697
- enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
1698
- author: origin.participant_author,
1699
- **stop_facts(event))
1700
- end
1701
-
1702
- # A participant joined (invited) or left (revoked, expired) since the
1703
- # last look: :participants_changed to the observers (ParticipantsWatch;
1704
- # the worker looks on its idle tick). The first look announces nobody.
1705
- # @return [ParticipantsWatch::Change, nil]
1706
- def announce_participants
1707
- change = @participants_watch.changes or return nil
1708
-
1709
- announce(change.to_event)
1710
- change
1711
- end
1712
-
1713
- # Reads the session's participants at a turn's start; the first to join,
1714
- # the first invited to act (a pair participant: the line's words) and
1715
- # the last to leave rebuild the system prompt (its participants line),
1716
- # once each: a turn in between sends the same prefix.
1717
- def refresh_participants!(session)
1718
- line = @participants.refresh!(session).prompt_mode(session)
1719
- return if line == @participants_line
1720
-
1721
- @participants_line = line
1722
- @prompt_builder.reset!
1723
- end
1724
- private :refresh_participants!
1725
-
1726
- # The author fields a message +author+ wrote carries (Steer.author_fields:
1727
- # author:, and author_name: from participants.json), {} for the owner.
1728
- # The worker's drain names a participant's merged lines with it, and
1729
- # its running save a participant's prompt.
1730
- # @param author [String, nil] a turn's or a line's author (TurnOrigin#author)
1731
- # @param session [Session, nil] the session it is for (the turn's)
1732
- def author_fields(author, session: @session)
1733
- return {} unless Steer.participant_author?(author)
1734
-
1735
- Steer.author_fields(author, @participants.name_for(author, session: session))
1736
- end
1737
-
1738
- # A participant's opening message keeps the rules it was delivered under
1739
- # (ParticipantMode), stamped before any request carries it, so its header
1740
- # never changes when the session's mode does.
1741
- def participant_mode_fields(author)
1742
- mode = Steer.participant_author?(author) && @guardrail_wiring.turn_mode
1743
- mode ? { participant_mode: mode.to_s } : {}
1744
- end
1745
-
1746
- # Why a turn stopped (TurnEndings#stop_facts).
1747
- def stop_facts(event) = @turn_endings.stop_facts(event)
1748
- private :stop_facts
1749
-
1750
697
  # How every turn ends (completed, canceled, failed): the session gets
1751
698
  # what the turn kept, goes idle and records the ending, and the end
1752
699
  # event is announced, as one step of the event log (a snapshot taken
@@ -1786,223 +733,8 @@ module Samagotchi
1786
733
  end
1787
734
  private :end_turn
1788
735
 
1789
- # The turn's anchors, stamped on its opening message (the prompt, or a
1790
- # wake turn's note) in the messages it keeps: display metadata, never
1791
- # in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
1792
- # A Verify turn's claim however it ended; a Revise turn's draft only
1793
- # once it completed with an answer (#revision_target). Returns the
1794
- # stamped ReviseAnchor (the end event's revises), or nil.
1795
- def stamp_opening(messages, turn, outcome)
1796
- return unless messages.is_a?(Array)
1797
-
1798
- revises = revision_target(messages, turn, outcome)
1799
- fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
1800
- return if fields.empty?
1801
-
1802
- opening = messages.reverse.find do |message|
1803
- Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
1804
- end
1805
- return unless opening
1806
-
1807
- opening.merge!(fields)
1808
- revises
1809
- end
1810
- private :stamp_opening
1811
-
1812
- # The turn a Revise turn revised (D6: "this turn revised that one"):
1813
- # only a completed turn with an answer (AnswerTail) has revised
1814
- # anything. A draft no prompt in the messages carries is no target (a
1815
- # stale page, a rolled-back turn); one a stamped turn already revised
1816
- # is re-targeted to its stack's top, the newest version, which the
1817
- # person meant ("this draft").
1818
- def revision_target(messages, turn, outcome)
1819
- return unless turn.revises && outcome == "completed"
1820
-
1821
- slice = AnswerTail.turn_slice(messages, turn.id)
1822
- return unless slice&.any? { |message| AnswerTail.answer?(message) }
1823
-
1824
- target = turn.revises.turn_id
1825
- seen = [turn.id]
1826
- until seen.include?(target)
1827
- return reject_revise_anchor(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
1828
-
1829
- seen << target
1830
- newer = messages.reverse.find do |message|
1831
- Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
1832
- end
1833
- return ReviseAnchor.new(turn_id: target) unless newer
1834
-
1835
- target = (newer[:turn_id] || newer["turn_id"]).to_s
1836
- end
1837
- reject_revise_anchor(turn.revises, :cycle)
1838
- end
1839
- private :revision_target
1840
-
1841
- def reject_revise_anchor(anchor, reason)
1842
- Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
1843
- nil
1844
- end
1845
- private :reject_revise_anchor
1846
-
1847
- def verified_anchor(session, anchor)
1848
- return unless anchor
1849
-
1850
- source = verify_anchor_source_session(session, anchor)
1851
- return unless source
1852
-
1853
- turn = AnswerTail.turn_slice(Array(source.messages), anchor.turn_id)
1854
- return reject_verify_anchor(anchor, :turn_missing) unless turn
1855
-
1856
- reply = turn.reverse.find { |message| AnswerTail.answer?(message) }
1857
- return reject_verify_anchor(anchor, :reply_missing) unless reply
1858
-
1859
- quote_text = verify_comparison_text(anchor.quote)
1860
- quoted = !quote_text.empty? && verify_quote_in?(quote_text, (reply[:content] || reply["content"]).to_s, source)
1861
- return reject_verify_anchor(anchor, :quote_missing) unless quoted
1862
-
1863
- anchor
1864
- end
1865
- private :verified_anchor
1866
-
1867
- # Whether +quote_text+ is in +answer+ as the model wrote it, or as the
1868
- # pages show it: a page quotes its own text, which reads the session's
1869
- # folder relative, the home folder as ~ and a secret shape as
1870
- # [redacted] (a participant's page gets it so, ParticipantView::Redact;
1871
- # the owner's page rewrites its quote the same way, claim_link.js
1872
- # normalizeClaim, so claim links match both pages). +source+ is the
1873
- # session whose folder that is.
1874
- def verify_quote_in?(quote_text, answer, source)
1875
- return true if verify_comparison_text(answer).include?(quote_text)
1876
-
1877
- viewer = Web::ParticipantView::Viewer.new(cwd: source.working_directory)
1878
- verify_comparison_text(Web::ParticipantView::Redact.text(answer, viewer)).include?(quote_text)
1879
- end
1880
- private :verify_quote_in?
1881
-
1882
- def verify_anchor_source_session(session, anchor)
1883
- return session unless session.verify_fresh
1884
-
1885
- fresh = session.verify_fresh
1886
- return reject_verify_anchor(anchor, :fresh_anchor_mismatch) unless fresh.anchor == anchor
1887
- return reject_verify_anchor(anchor, :fresh_parent_missing) unless session.parent_id
1888
-
1889
- Session.load(session.parent_id, state_dir: session_state_dir)
1890
- rescue ArgumentError, SystemCallError, IOError => e
1891
- Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason: :source_session_unavailable,
1892
- error: e.class.name)
1893
- nil
1894
- end
1895
- private :verify_anchor_source_session
1896
-
1897
- def verify_comparison_text(text)
1898
- OutputFormatter.strip_markup(text)
1899
- .gsub(/!\[([^\]]*)\]\([^)]+\)/, '\1')
1900
- .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
1901
- .gsub(/^[ \t]*(?:\#{1,6}[ \t]+|>[ \t]?|[-+*][ \t]+|\d+[.)][ \t]+)/, "")
1902
- .gsub(/(\*\*|__|~~|`+)(.+?)\1/m, '\2')
1903
- .gsub(/(?<!\w)\*(\S(?:.*?\S)?)\*(?!\w)/m, '\1')
1904
- .gsub(/(?<!\w)_(\S(?:.*?\S)?)_(?!\w)/m, '\1')
1905
- .tr("|", " ") # a table row's cells; the page copies them without the pipes
1906
- .gsub(/\s+/, " ")
1907
- .strip
1908
- end
1909
- private :verify_comparison_text
1910
-
1911
- def reject_verify_anchor(anchor, reason)
1912
- Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason:)
1913
- nil
1914
- end
1915
- private :reject_verify_anchor
1916
-
1917
- def record_verdict(turn, outcome)
1918
- return unless outcome == "completed" && turn.verify
1919
-
1920
- answer = AnswerTail.find(turn.session.messages, turn_id: turn.id)
1921
- parsed = Verdict.parse(answer && (answer[:content] || answer["content"]))
1922
- return unless parsed
1923
-
1924
- record = { anchor: turn.verify.to_h, verdict: parsed.verdict, reason: parsed.reason,
1925
- by: turn.origin.author, check_turn_id: turn.id, at: Time.now.iso8601(3) }
1926
- turn.session.verdicts = turn.session.verdicts + [record]
1927
- record
1928
- end
1929
- private :record_verdict
1930
-
1931
- def save_quietly(session)
1932
- session.save(state_dir: session_state_dir)
1933
- rescue StandardError => e
1934
- Log.warn(:turn, "turn_end_save_failed", error: e.class.name, msg: e.message.to_s[0, 200])
1935
- nil
1936
- end
1937
- private :save_quietly
1938
-
1939
- # Keep what the after_turn hooks presented as the answer's `display`
1940
- # and the refs they attached as its `display_refs` (AnswerRefs, one
1941
- # replace for both), and tell the observers (:answer_display, the
1942
- # display or nil, and how many refs), after the turn_completed they
1943
- # already had: the web re-reads the answer then. A turn_completed
1944
- # with display_pending (after_turn hooks were about to run) always gets
1945
- # one, `display: nil` when nothing was presented, so the web holds the
1946
- # answer's pop until then and never swaps it after. Terminals are not
1947
- # sinks of it; they printed the answer already. The session is saved by
1948
- # the caller, as after every turn.
1949
- def store_answer_display(session, display, refs, pending: false)
1950
- synchronize_events do
1951
- # A plugin that kept event[:refs] and calls it later gets nil: the
1952
- # list is stored now (and the turn is over on every path).
1953
- refs.close!
1954
- messages = Array(session.messages)
1955
- changed = display.changed? && messages.last.equal?(display.target)
1956
- added = refs.added? && messages.last.equal?(refs.target)
1957
- if changed || added
1958
- merged = messages.last.dup
1959
- merged[AnswerDisplay::KEY] = display.text if changed
1960
- merged[AnswerRefs::KEY] = refs.to_a if added
1961
- replace_session_messages(session, messages[0...-1] + [merged])
1962
- event = { type: :answer_display, display: changed ? display.text : nil }
1963
- event[:refs] = refs.count if added
1964
- @session_observer.notify(event)
1965
- elsif pending
1966
- @session_observer.notify({ type: :answer_display, display: nil })
1967
- end
1968
- end
1969
- end
1970
- private :store_answer_display
1971
-
1972
- # ── The hook runtime (HookRoutes) ───────────────────────────────────────
1973
-
1974
- # What a hook (Hooks::Runtime) and a plugin (Plugin::Host) can do beyond
1975
- # reading its event, routed to the UIs by HookRoutes; these forward.
1976
- def hook_runtime = @hook_routes.runtime
1977
- private :hook_runtime
1978
-
1979
- def hook_source(hook) = @hook_routes.source(hook)
1980
- private :hook_source
1981
-
1982
- # What a notice may stand in for (hook_notify's fallback_for:).
1983
- NOTICE_FALLBACKS = HookRoutes::NOTICE_FALLBACKS
1984
-
1985
- # One line to the user (:hook_notice), HookRoutes#notify.
1986
- def hook_notify(text, level, hook, fallback_for: nil, extra: {})
1987
- @hook_routes.notify(text, level, hook, fallback_for: fallback_for, extra: extra)
1988
- end
1989
- private :hook_notify
1990
-
1991
- def guardrail_notify(message) = @hook_routes.guardrail_notify(message)
1992
- private :guardrail_notify
1993
-
1994
- # @param question [HookQuestion]
1995
- # @return [Hash, nil] {selected:, freeform:, selected_indices:}
1996
- def hook_ask_user(question) = @hook_routes.ask_user(question)
1997
- private :hook_ask_user
1998
-
1999
- # @return [Boolean] true when a running turn was cancelled now
2000
- def hook_stop_turn(reason, hook) = @hook_routes.stop_turn(reason, hook)
2001
- private :hook_stop_turn
2002
-
2003
- # @return [Boolean] true when a streaming generation was cut now
2004
- def hook_stop_generation(reason, hook) = @hook_routes.stop_generation(reason, hook)
2005
- private :hook_stop_generation
736
+ # What a notice may stand in for (hook_notify's fallback_for:).
737
+ NOTICE_FALLBACKS = HookRoutes::NOTICE_FALLBACKS
2006
738
 
2007
739
  # Where cards and notices go (see #initialize for when). Each lookup
2008
740
  # calls the Engine method the routes used before EventRoutes, at use.
@@ -2032,192 +764,6 @@ module Samagotchi
2032
764
  end
2033
765
  private :build_hook_routes
2034
766
 
2035
- # ── Ask-user-question (structured qualification) ──────────────────────────
2036
-
2037
- # @return [PendingQuestion, nil] the question pending now
2038
- def pending_question
2039
- @question_desk.pending
2040
- end
2041
-
2042
- # Ask the model's question (ask_user_question): the kernel's
2043
- # question_handler, called on the turn thread with the payload
2044
- # Tools::AskUserQuestion.validate made (QuestionDesk#request), after
2045
- # the :question_asked hooks (QuestionAsked) gave the step its refs; a
2046
- # --non-interactive run has no one to ask, so they don't fire there.
2047
- # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
2048
- # @return [String] normalized answer JSON
2049
- def request_question(payload)
2050
- payload = question_asked(QuestionAsked::KIND_QUESTION, payload) unless interface == :non_interactive
2051
- @question_desk.request(payload)
2052
- end
2053
-
2054
- # The :question_asked hooks for a question of the model's about to
2055
- # open: the fields with the asking step's refs (QuestionAsked.fire).
2056
- # @return [Hash]
2057
- def question_asked(kind, fields)
2058
- QuestionAsked.fire(hooks: @hooks, conversation: live_conversation, kind: kind, fields: fields,
2059
- cwd: @session&.working_directory, synchronize: ->(&block) { synchronize_events(&block) },
2060
- visible: ->(content) { question_step_text(content) })
2061
- end
2062
- private :question_asked
2063
-
2064
- # A step's text as the asking slot shows it: the native loop's raw
2065
- # response without its thoughts and tool-call bodies (a chat step has
2066
- # neither), then the markup's tokens (OutputFormatter.strip_markup).
2067
- def question_step_text(content)
2068
- parser = @kernel.parser
2069
- OutputFormatter.strip_markup(parser.strip_tool_calls(parser.strip_thought(content)))
2070
- end
2071
- private :question_step_text
2072
-
2073
- # Open a question for the UIs and wait for its answer
2074
- # (QuestionDesk#open_question: BLOCKS until answered or cancelled).
2075
- # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
2076
- # (as a Hash or as keywords)
2077
- # @param watch [#call, nil] closes the question with the reason it returns
2078
- # @return [QuestionAnswer, Hash, String] the answer, or {error:, …}, or a
2079
- # sync handler's text
2080
- def open_question(fields = nil, watch: nil, **more)
2081
- @question_desk.open_question({ **fields.to_h, **more }, watch: watch)
2082
- end
2083
-
2084
- # Publish a standing question that blocks nobody (QuestionDesk#post: a
2085
- # worker's step-limit question).
2086
- # @return [PendingQuestion, nil] the pending question, nil while another is open
2087
- def post_question(fields, on_answer:, on_superseded_close: nil)
2088
- @question_desk.post(fields, on_answer: on_answer, on_superseded_close: on_superseded_close)
2089
- end
2090
-
2091
- # Withdraw the standing question (QuestionDesk#withdraw).
2092
- # @return [Boolean]
2093
- def withdraw_question(reason, id: nil)
2094
- @question_desk.withdraw(reason, id: id)
2095
- end
2096
-
2097
- # Answer the pending question (called from UI thread; QuestionDesk#answer).
2098
- # @return [QuestionAnswer]
2099
- def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
2100
- @question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id,
2101
- parent_agent: parent_agent)
2102
- end
2103
-
2104
- # Mark the pending question as relayed, or clear the mark
2105
- # (QuestionDesk#annotate).
2106
- # @return [Boolean]
2107
- def annotate_question(id, relayed_to:, reason: nil)
2108
- @question_desk.annotate(id, relayed_to: relayed_to, reason: reason)
2109
- end
2110
-
2111
- def set_question_sync_handler(&block)
2112
- @question_desk.sync_handler = block
2113
- end
2114
-
2115
- # Cancel the pending question (QuestionDesk#cancel).
2116
- # @return [Boolean] whether it was cancelled
2117
- # @raise [QuestionDesk::NotDismissable] +id+ names a standing question
2118
- def cancel_question(reason = "user", id: nil)
2119
- @question_desk.cancel(reason, id: id)
2120
- end
2121
-
2122
- # The system prompt for a target's loop (default: the effective model's):
2123
- # the chat loop's leaves out the raw-prompt tool text, since its tools go
2124
- # as schemas. Built once per loop (and again after a model switch) so the
2125
- # prompt prefix, and the server's KV cache for it, stay stable.
2126
- # @param model [EffectiveModel, nil]
2127
- # @return [String]
2128
- # The native prompt depends on the thinking level too (Gemma's token):
2129
- # a level change builds it again. A build that
2130
- # misses the cache names the model and the level through its own
2131
- # lambdas (#model_identity, #turn_thinking), which resolve again.
2132
- def system_prompt(model = nil)
2133
- model ||= effective_model
2134
- chat = model.chat?
2135
- level = chat ? nil : @model_settings.thinking_level(model)
2136
- prompt = @prompt_builder.build(chat: chat, thinking: level, layers: model.llm_context_layers(@model_settings.llm_context_session_args))
2137
- # The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
2138
- @metrics.memory_index = @prompt_builder.memory_index
2139
- # The model notes it carries: the session records them (/stats, /model, the web).
2140
- record_prompt_notes(@prompt_builder.prompt_notes)
2141
- prompt
2142
- end
2143
-
2144
- # The base prompt (specs, plugins' declarations).
2145
- def assist_system_prompt(chat: false)
2146
- @prompt_builder.base(chat: chat)
2147
- end
2148
-
2149
- # The --memory names activated while building the prompt; the TerminalUI
2150
- # mirrors them into its status line.
2151
- def activated_memory_names
2152
- @prompt_builder.activated_memory_names
2153
- end
2154
-
2155
- # @return [Session] current session (Engine owns create/resume)
2156
- attr_reader :session
2157
-
2158
- # This worker's relays of its delegates' approvals (memory only).
2159
- # @return [RelayDesk]
2160
- attr_reader :relay_desk
2161
-
2162
- # The relay's view of this Engine, or nil when it can't host a relay: a
2163
- # worker's Engine only (its Bridge is what a child verifies against;
2164
- # --non-interactive has no one to ask), never with a REPL's sync
2165
- # handler (no wait loop, no watch).
2166
- # @return [RelayPeer, nil]
2167
- def relay_peer
2168
- return nil unless interface == :worker && !@question_desk.sync_handler?
2169
-
2170
- RelayPeer.new(self)
2171
- end
2172
-
2173
- # @return [String] the state dir holding this Engine's sessions
2174
- def peer_state_dir = @state_dir
2175
-
2176
- # Set the current session outside a turn (the REPL does, before its first
2177
- # turn, so the messages API and recap see it)
2178
- def session=(session)
2179
- @session = session
2180
- # One session per REPL/worker process: its records carry this sid.
2181
- Log.session_id = session.id if session&.id
2182
- # Its own recap values (chi --set recap.enabled=false) rebuild the job.
2183
- rebuild_recap!
2184
- # A woken worker's /stats and status line count the turns before it.
2185
- bind_metrics(session)
2186
- @used_memories.absorb(session)
2187
- # Loaded on another model than its last turn ran on (a --resume'd
2188
- # REPL's --model, a worker woken after a /model with no turn since):
2189
- # the saved ctx % was that model's window's. Drop it and count it as
2190
- # /model does, on a configured window only: this runs before a
2191
- # worker's Bridge serves, so no /props GET or model list here (a slow
2192
- # or hung host would hold up the worker's start). No configured
2193
- # window: no % (ctx ?) until the first generation reports one.
2194
- return unless model_switched_since_last_turn?
2195
-
2196
- @metrics.forget_model_reports!
2197
- announce_switched_context!(configured_only: true)
2198
- end
2199
-
2200
- # Whether the last turn that named its model ran on another one than
2201
- # the effective model; false before any (or when the ref can't be told).
2202
- def model_switched_since_last_turn?
2203
- last = @metrics.last_model_ref
2204
- !last.nil? && last != effective_model_ref
2205
- rescue StandardError => e
2206
- Log.warn(:model, "switch_check_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2207
- false
2208
- end
2209
- private :model_switched_since_last_turn?
2210
-
2211
- # @return [IdleRecap, nil] the idle recap detector, or nil when disabled
2212
- attr_reader :recap
2213
-
2214
- # Whether a continue offer waits (the Worker's or the REPL's TurnFlow),
2215
- # for the recap job: this one and any rebuilt one (IdleRecap#awaiting_continue=).
2216
- def recap_awaiting_continue=(callable)
2217
- @recap_awaiting_continue = callable
2218
- @recap&.awaiting_continue = callable
2219
- end
2220
-
2221
767
  # The idle recap job (RecapSetup.build) under the current session's own
2222
768
  # recap values, which it records.
2223
769
  # @return [IdleRecap, nil]
@@ -2232,95 +778,6 @@ module Samagotchi
2232
778
  end
2233
779
  private :build_recap
2234
780
 
2235
- # The bound session's own recap.* values ({} for none).
2236
- def session_recap_values
2237
- (@session&.settings&.core || {}).select { |key, _| key.start_with?("recap.") }
2238
- end
2239
- private :session_recap_values
2240
-
2241
- # A new recap job when the bound session's own recap values differ from
2242
- # the ones the current job was built under (none at start): the old one
2243
- # is dropped (an attempt in flight goes stale) and the scheduler swaps
2244
- # it, keeping its one thread. Binding the same session again changes
2245
- # nothing (the REPL binds it twice).
2246
- def rebuild_recap!
2247
- return if session_recap_values == @recap_built_for
2248
-
2249
- old = @recap
2250
- old&.invalidate!
2251
- @recap = build_recap
2252
- @idle_scheduler&.replace_job(old, @recap)
2253
- end
2254
-
2255
- # /recap on|off|default: the bound session's own recap.enabled (true,
2256
- # false; nil removes it, so config.yml's applies again), and the recap
2257
- # job rebuilt under it at once. It doesn't save: the command says
2258
- # changed: [:recap] and its host saves (Worker#run_command).
2259
- # @param enabled [Boolean, nil]
2260
- # @raise [ArgumentError] no session is bound
2261
- def recap_override=(enabled)
2262
- raise ArgumentError, "no session" unless @session
2263
-
2264
- core = @session.settings.core.dup
2265
- enabled.nil? ? core.delete(RECAP_ENABLED_KEY) : core[RECAP_ENABLED_KEY] = enabled
2266
- @session.settings = @session.settings.with(core: core)
2267
- rebuild_recap!
2268
- end
2269
-
2270
- # The bound session's own recap.enabled (/recap on|off, --set), or nil.
2271
- # @return [Boolean, nil]
2272
- def recap_override = @session&.settings&.core&.fetch(RECAP_ENABLED_KEY, nil)
2273
-
2274
- # Write the recap now, before the session is left (IdleRecap#write_now).
2275
- # @return [String, nil] the recap written, nil when none was (recap off,
2276
- # nothing new, an error, the timeout)
2277
- def write_recap_now(on_start: nil)
2278
- @recap&.write_now(on_start: on_start)
2279
- end
2280
-
2281
- # The longest #write_recap_now waits (recap.timeout), nil when no recap
2282
- # is set up.
2283
- # @return [Float, nil]
2284
- def recap_timeout = @recap&.timeout
2285
-
2286
- # Ask for a recap now (/recap), without waiting for the idle window.
2287
- # @return [Symbol] IdleRecap#request_now's answer, or :off
2288
- def request_recap
2289
- @recap ? @recap.request_now : :off
2290
- end
2291
-
2292
- # The recap saved with the current session (recap.json), and how many
2293
- # user turns came after it (0: it is current).
2294
- # @return [Hash, nil] {text:, covered:, turns_since:, created_at:}
2295
- def saved_recap
2296
- state = @recap&.state
2297
- return nil unless state
2298
-
2299
- messages = @session&.messages || []
2300
- covered = state[:covered].to_i
2301
- turns_since = Array(messages).drop(covered).count { |m| Steer.turn_opening?(m) }
2302
- { text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
2303
- end
2304
-
2305
- # Start the shared idle scheduler (reminders + optional recap). The recap
2306
- # job is only registered when recap is configured; the reminders job is
2307
- # always present. TerminalUI calls this before the REPL and SessionManager
2308
- # workers call it so reminders can trigger turns; one-shot/worker paths
2309
- # that never start it poll nothing.
2310
- def start_idle
2311
- @idle_scheduler&.start
2312
- self
2313
- end
2314
-
2315
- # Stop the shared idle scheduler (TerminalUI calls this when the REPL
2316
- # exits). +join+: wait for a tick under way (IdleScheduler#stop); a
2317
- # caller holding #synchronize_events passes false, since a recap's emit
2318
- # in that tick waits for the same lock.
2319
- def stop_idle(join: true)
2320
- @idle_scheduler&.stop(join: join)
2321
- self
2322
- end
2323
-
2324
781
  # One turn's values, for #run_turn and its endings. Per turn and
2325
782
  # single-threaded: the cross-thread turn state (the cancel slot, the
2326
783
  # sink, steers) stays on the Engine.
@@ -2462,7 +919,7 @@ module Samagotchi
2462
919
 
2463
920
  # Keep what the turn got to (the prompt plus the loop's completed
2464
921
  # tool iterations) like a cancel does, and save it: a worker exits
2465
- # after a failed turn. The REPL still rolls back to its checkpoint.
922
+ # after a failed turn.
2466
923
  end_turn(turn, "failed", save: true) { |seconds| [failed_messages(turn, e), failed_event(e, seconds)] }
2467
924
  raise
2468
925
  ensure
@@ -2497,7 +954,7 @@ module Samagotchi
2497
954
  # or a plugin's card from another thread never sees a running turn
2498
955
  # without them. The idle recap detector does not fire (or render an
2499
956
  # invalidated recap) while the model is working. A hook's notice goes
2500
- # where the turn's events go (the REPL renders only its sink; a
957
+ # where the turn's events go (a one-shot prints only its sink; a
2501
958
  # worker's observers carry it to the bridge).
2502
959
  @turn_state.begin!(controller: effective_controller, sink: on_event, input_waiting: input_waiting)
2503
960
  # Drop a recap already in flight: the turn makes it stale.
@@ -2602,90 +1059,6 @@ module Samagotchi
2602
1059
  end
2603
1060
  private :prepare_turn
2604
1061
 
2605
- # A turn stopped before the model was asked (#prepare_turn returned
2606
- # false): canceled with nothing kept, like a Ctrl-C there; the result
2607
- # has no conversation, so a UI's checkpoint stands.
2608
- def stopped_before_model(turn)
2609
- reason = turn.controller.reason
2610
- end_turn(turn, "canceled") do |seconds|
2611
- [nil, { type: :turn_canceled, cancellation_reason: reason, cancelled_by: cancelled_by(turn),
2612
- duration_ms: (seconds * 1000).round }]
2613
- end
2614
- LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
2615
- end
2616
- private :stopped_before_model
2617
-
2618
- # Who stopped a turn a hook stopped or cut (the controller's detail: the
2619
- # bundle, or the hook's label), for the UIs' "stopped by loop-guard";
2620
- # nil for any other cancel.
2621
- def cancelled_by(turn) = turn.controller.stopped_by
2622
- private :cancelled_by
2623
-
2624
- # The turn's settings for the kernel (LLM::TurnSettings): vision,
2625
- # sampling and thinking, the thinking resolved before the hooks run.
2626
- # The model name is added in #generate, after the hooks that may switch
2627
- # the model.
2628
- # One resolve for all of them: nothing runs between them that could
2629
- # change what a resolve gives.
2630
- def turn_settings(session)
2631
- model = effective_model
2632
- vision = turn_vision(session, model)
2633
- sampling = model.sampling
2634
- level = @model_settings.thinking_level(model)
2635
- @thinking_notices.turn_started(level, model.target)
2636
- LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: level,
2637
- reasoning_history: @model_settings.reasoning_history_level(model), model_name: nil,
2638
- window_setting: nil, llm_context: nil, price: nil)
2639
- end
2640
- private :turn_settings
2641
-
2642
- # @return [PromptWarmup] the turn-end warm-up (specs, measurements)
2643
- attr_reader :prompt_warmup
2644
-
2645
- # A caller's check that the next turn starts at once (input queued for
2646
- # it: a worker's inbox) or that none follows (a one-shot run): no
2647
- # warm-up then. nil: neither.
2648
- attr_accessor :next_turn_waiting
2649
-
2650
- # After a completed turn, prefill the next turn's prompt up to its user
2651
- # message while the user reads (PromptWarmup, cache.warmup). Only on a
2652
- # local llama.cpp host on the native loop, and not when the next turn's
2653
- # start differs anyway or starts at once: a reminder due, a continue
2654
- # offer (the turn ran out of steps), a carried steer or queued input.
2655
- # Never fails the turn.
2656
- def warm_up_next_turn(turn, result)
2657
- return if result.canceled? || result.resumable?
2658
-
2659
- model = effective_model
2660
- client = model.client
2661
- return unless warmup_target?(model, client)
2662
- return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
2663
-
2664
- messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(model) })
2665
- # Under the next turn's strategy (a /model switch may have changed it);
2666
- # the last turn's when it can't be resolved.
2667
- prompt, images = @kernel.warmup_prompt(messages, llm_context: @model_settings.turn_llm_context || @kernel.turn_settings&.llm_context)
2668
- return unless prompt
2669
-
2670
- started = @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
2671
- slot: @kernel.last_slot, images: images)
2672
- @kernel.warmed_up! if started
2673
- rescue StandardError => e
2674
- Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
2675
- end
2676
- private :warm_up_next_turn
2677
-
2678
- # Never a paid or remote host (cache.warmup auto means local only), nor
2679
- # the chat loop (Splash keeps its own cache; llama.cpp's chat path is
2680
- # not measured), nor a client that can't warm up (a spec's stub).
2681
- def warmup_target?(model, client)
2682
- return false if model.chat? || model.entry.remote?
2683
- return false unless client.respond_to?(:warm_up)
2684
-
2685
- PromptWarmup.enabled?
2686
- end
2687
- private :warmup_target?
2688
-
2689
1062
  # The messages the turn sends: the history under the system head, due
2690
1063
  # reminders, the prompt. turn.messages is set first and grown in place,
2691
1064
  # so a Ctrl-C or a failure meanwhile keeps what is there.
@@ -2767,250 +1140,6 @@ module Samagotchi
2767
1140
  end
2768
1141
  private :generate
2769
1142
 
2770
- # The ref (host:model, as asked) a turn's generations ran on: the model
2771
- # #generate resolved, after the before_turn hooks (which may switch it).
2772
- # SessionMetrics keys its per-model segments on it. nil when it can't be
2773
- # named (the turn's record then has none).
2774
- def generation_model_ref(model)
2775
- model.ref
2776
- rescue StandardError => e
2777
- Log.warn(:turn, "model_ref_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2778
- nil
2779
- end
2780
- private :generation_model_ref
2781
-
2782
- # A step-limit Continue's resume_from, when it is inside the messages the
2783
- # run gets (a wrong one only drops reasoning: it is ignored, logged).
2784
- # Entries before it never move between the turns (#turn_messages adds
2785
- # at the tail, and the system head is replaced in place).
2786
- def resumed_run_start(turn, run_start)
2787
- start = turn.resume_from
2788
- return nil if start.nil?
2789
- return start if start.is_a?(Integer) && start.between?(0, run_start)
2790
-
2791
- Log.warn(:turn, "resume_start_invalid", resume_from: start, length: run_start)
2792
- nil
2793
- end
2794
- private :resumed_run_start
2795
-
2796
- # The turn's LLM context strategy and where it came from, in the turn
2797
- # log: when it runs a layer, or the session set it (none too).
2798
- def log_llm_context(explained)
2799
- return unless explained
2800
- return if explained.resolved.active_layers.empty? && explained.strategy.source != :session
2801
-
2802
- summary = explained.summary
2803
- Log.info(:turn, "llm_context", **summary.slice(:strategy, :strategy_source, :apply, :apply_source, :budget_tokens,
2804
- :budget_source).compact)
2805
- end
2806
- private :log_llm_context
2807
-
2808
- def publish_used_memories(session, on_event)
2809
- # Persist deduped used memories onto the session for Web + reload.
2810
- begin
2811
- session.used_memory_names = used_memory_names
2812
- rescue StandardError => e
2813
- Log.warn(:memory, "used_memories_store_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2814
- end
2815
- # Notify live observers of the updated memory list (so yellow bar refreshes
2816
- # even without a tool_call event if the preload was the only addition).
2817
- # Only emit when there is something to report to avoid noisy event_count drift.
2818
- return unless used_memory_names.any?
2819
-
2820
- begin
2821
- emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
2822
- rescue StandardError => e
2823
- Log.warn(:memory, "used_memories_emit_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2824
- end
2825
- end
2826
- private :publish_used_memories
2827
-
2828
- # The normal ending (an answer, an empty answer, a Stop, the iteration
2829
- # limit), then the after-turn hooks.
2830
- def complete_turn(turn, result)
2831
- canceled = result.canceled?
2832
- # after_turn hooks run below and may present the answer (the web
2833
- # holds its pop until it knows).
2834
- display_pending = !canceled && @hooks.any?(:after_turn)
2835
- end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
2836
- kept = kept_messages(turn, result, seconds)
2837
- if canceled
2838
- [kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason, cancelled_by: cancelled_by(turn),
2839
- duration_ms: (seconds * 1000).round }]
2840
- else
2841
- # For a client that attaches later (session_state_snapshot).
2842
- @last_context_status = result.context_status.dup if result.context_status
2843
- [kept, { type: :turn_completed, result: result, turn_summary: turn_summary(result),
2844
- display_pending: display_pending }]
2845
- end
2846
- end
2847
-
2848
- # Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
2849
- # ends the turn in #run_turn without it), with a read-only
2850
- # copy of the conversation the turn stored, event[:answer] (the answer's
2851
- # text: the native loop's stored message still holds its thought),
2852
- # event[:present] for
2853
- # a display version of the answer (AnswerDisplay) and event[:refs]
2854
- # for refs on it (AnswerRefs).
2855
- session = turn.session
2856
- audit_objectives_outgoing(turn, result) unless canceled || result.exhausted?
2857
- display = AnswerDisplay.new(session.messages)
2858
- refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
2859
- after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
2860
- messages: hook_messages(session.messages),
2861
- answer: (result.text unless canceled || result.exhausted?) }
2862
- after_turn[:present] = display.presenter(after_turn)
2863
- after_turn[:refs] = refs.adder(after_turn)
2864
- @hooks.fire(:after_turn, after_turn)
2865
- store_answer_display(session, display, refs, pending: display_pending)
2866
-
2867
- # Fire :session_end after the turn (turn-level lifecycle), as :after_turn
2868
- @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
2869
- end
2870
- private :complete_turn
2871
-
2872
- # What each turn ending keeps and announces is TurnEndings'
2873
- # (#build_turn_endings); these forward to it.
2874
- def kept_messages(turn, result, seconds) = @turn_endings.kept_messages(turn, result, seconds)
2875
- private :kept_messages
2876
- def ctrl_c_messages(turn, interrupt, seconds) = @turn_endings.ctrl_c_messages(turn, interrupt, seconds)
2877
- private :ctrl_c_messages
2878
- def loop_conversation(error) = @turn_endings.loop_conversation(error)
2879
- private :loop_conversation
2880
- def failed_messages(turn, error) = @turn_endings.failed_messages(turn, error)
2881
- private :failed_messages
2882
- def failed_event(error, seconds) = @turn_endings.failed_event(error, seconds)
2883
- private :failed_event
2884
-
2885
- # However the turn ended (#run_turn's ensure): the turn flag off, the
2886
- # probe cancel restored, the cancel controller, sink and steers cleared
2887
- # (a steer left is logged as dropped), activity recorded, the
2888
- # turn-scoped hooks cleared.
2889
- def release_turn(probe_cancel_before)
2890
- # A completed turn is activity: release the turn flag and advance the
2891
- # shared inactivity clock so the idle recap detector (shared with the REPL)
2892
- # treats the just-finished turn as activity and re-arms its window.
2893
- left = @turn_state.finish!
2894
- @steer_cut.finished!
2895
- Client.swap_probe_cancel(probe_cancel_before)
2896
- # The next turn's window and served model are asked again: the cache
2897
- # (this host's, process-wide) is dropped here, at the turn's end,
2898
- # rather than at its start, so what this turn read (the profile's own
2899
- # probe included) is not asked for a second time within the turn. A
2900
- # restart with another -c between turns is still noticed: the next
2901
- # turn's first probe goes to the server.
2902
- @client.invalidate_context_window!
2903
- log_dropped_steers(left, "turn_ended")
2904
- record_activity
2905
- # Clear hooks so they remain turn-scoped and never leak into the next turn.
2906
- clear_hooks
2907
- end
2908
- private :release_turn
2909
-
2910
- # [refs, nil] for a turn's images, or [[], error] when one can't be used
2911
- # (the turn then fails right after :turn_started).
2912
- def turn_image_refs(session, images)
2913
- return [[], nil] if Array(images).empty?
2914
-
2915
- [ImageStore.resolve_all(Session.session_dir(session.id, state_dir: session_state_dir), images), nil]
2916
- rescue ImageStore::Error => e
2917
- [[], e]
2918
- end
2919
-
2920
- # The turn's VisionContext: the session's images folder, and whether the
2921
- # effective model can see images, asked only when a request carries one.
2922
- def turn_vision(session, model = effective_model)
2923
- VisionContext.new(session_dir: Session.session_dir(session.id, state_dir: session_state_dir),
2924
- capability: -> { model.vision_support(profile) })
2925
- end
2926
-
2927
- # The effective model's sampling and thinking level as the next turn
2928
- # reads them (SessionModelSettings).
2929
- def turn_sampling = @model_settings.turn_sampling
2930
- def turn_thinking = @model_settings.turn_thinking
2931
-
2932
- # A model known not to see images fails a turn with images up front.
2933
- def refuse_images!(vision)
2934
- return if vision.sendable?
2935
-
2936
- host = effective_model.host_name
2937
- raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
2938
- end
2939
-
2940
- # JSON-safe digest of a finished turn for renderers (TurnEndings#turn_summary).
2941
- # @param result [LLM::ModelResult]
2942
- # @return [Hash]
2943
- def turn_summary(result) = @turn_endings.turn_summary(result)
2944
-
2945
- # ── Session messages API ───────────────────────────────────────────────
2946
- #
2947
- # Out-of-turn edits to the current session's conversation (`!cmd` output,
2948
- # rollback after Ctrl-C). Each replaces the array rather than mutating it,
2949
- # so the recap's lock-free snapshot never sees a half-applied edit.
2950
-
2951
- # The answer a UI shows last (AnswerTail), a copy of that one message
2952
- # only: walked back from the end, never a #messages_checkpoint of the
2953
- # whole conversation (the Bridge's GET tail). +turn_id+: that turn's
2954
- # answer (AnswerTail.find); unknown or nil: the newest.
2955
- # @return [Hash, nil]
2956
- def last_answer_message(turn_id: nil)
2957
- AnswerTail.find(@session&.messages, turn_id: turn_id)&.dup
2958
- end
2959
-
2960
- # @return [Array<Hash>] a copy of the current session's messages to hand
2961
- # back to #rollback_to later
2962
- def messages_checkpoint
2963
- clone_messages(@session&.messages)
2964
- end
2965
-
2966
- # The running turn's conversation as its loop builds it (both loops
2967
- # register it on the kernel's llm_context_turn): the same array the loop
2968
- # appends to, not a copy, so read it on the turn's thread (the pending
2969
- # input drain, at an iteration boundary: TurnCheckpoint), or from another
2970
- # only as a dup, read only (/objectives). nil when no
2971
- # turn runs (llm_context_turn is never cleared, and still holds the
2972
- # last turn's then).
2973
- # @return [Array<Hash>, nil]
2974
- def live_conversation
2975
- return nil unless turn_running? && @kernel.respond_to?(:llm_context_turn)
2976
-
2977
- @kernel.llm_context_turn&.conversation
2978
- end
2979
-
2980
- # Append messages to the current session's conversation.
2981
- # @param messages [Array<Hash>]
2982
- # @return [Array<Hash>] the session's messages
2983
- def append_messages(messages)
2984
- raise ArgumentError, "no current session" unless @session
2985
-
2986
- replace_session_messages(@session, Array(@session.messages) + clone_messages(messages))
2987
- end
2988
-
2989
- # Restore the current session's conversation to a #messages_checkpoint.
2990
- # Between turns the working set is announced when that changed it (a
2991
- # !rollback or a canceled continue dropping the turn's tool calls, the
2992
- # set emptied too), so the page doesn't keep the dropped items until
2993
- # the next request.
2994
- # @param checkpoint [Array<Hash>]
2995
- # @return [Array<Hash>] the session's messages
2996
- def rollback_to(checkpoint)
2997
- raise ArgumentError, "no current session" unless @session
2998
-
2999
- messages = replace_session_messages(@session, clone_messages(checkpoint))
3000
- unless turn_running?
3001
- announce_working_set
3002
- announce_objectives
3003
- end
3004
- messages
3005
- end
3006
-
3007
- # Clone a messages array (shallow dup of each element).
3008
- # @param messages [Array<Hash>]
3009
- # @return [Array<Hash>]
3010
- def clone_messages(messages)
3011
- Array(messages).map(&:dup)
3012
- end
3013
-
3014
1143
  private
3015
1144
 
3016
1145
  def running_command_key = :"samagotchi_command_#{object_id}"
@@ -3027,365 +1156,10 @@ module Samagotchi
3027
1156
  session.messages = messages
3028
1157
  end
3029
1158
 
3030
- # Pick the loop for +model+ (EffectiveModel); the chat loop is pointed
3031
- # at its host's adapter (one per host, kept for its cached model list).
3032
- def backend_for(model)
3033
- return @native_backend unless model.chat?
3034
-
3035
- @chat_backend_mutex.synchronize do
3036
- @chat_backend ||= LLM::ChatLoop.new(kernel: @kernel, stable_length: ->(text) { @prompt_builder.stable_length(text) })
3037
- @chat_backend.adapter = @host_registry.adapter_for(model.entry)
3038
- @chat_backend.session_id = session&.id
3039
- @chat_backend
3040
- end
3041
- end
3042
-
3043
- def load_plugins
3044
- host = plugin_host
3045
- registries = Plugin::Registries.new(
3046
- commands: @command_registry, tools: @tools, hooks: @hooks,
3047
- context_for: lambda { |bundle, settings, label|
3048
- Plugin::Context.new(bundle: bundle, label: label, settings: settings, host: host)
3049
- },
3050
- tools_changed: -> { tools_changed! },
3051
- services: @services,
3052
- stage_tools: ->(bundle, specs, context) { stage_tools(bundle, specs, context) },
3053
- init: lambda { |bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
3054
- add_init_task(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
3055
- quiet: quiet, timeout: timeout, failed: failed, &block)
3056
- },
3057
- toolkits: @toolkit_binding.plugin_kits,
3058
- tool_index: @tool_index,
3059
- peer_sources: @peer_sources,
3060
- prompt_sections: @prompt_sections,
3061
- prompt_changed: -> { prompt_changed! },
3062
- judgments: @judgments,
3063
- hook_failed: ->(hook, failed) { @hook_routes.plugin_hook_failed(hook, failed) }
3064
- )
3065
- # What plugins show while they load (an MCP server that didn't
3066
- # start) waits for the first turn, beside the load warnings: no UI
3067
- # is there yet, and the Engine isn't built.
3068
- @extension_load.load_plugins(registries, failures: @plugin_failures) do
3069
- warn_undeclared_judgments
3070
- warn_bundle_settings
3071
- end
3072
- end
3073
-
3074
- # config.yml bundles: names and keys that do nothing (BundleSettingsCheck),
3075
- # held like the judgments' line, for the same reason. Logged by every
3076
- # Engine; shown by the first to find them (BundleSettingsCheck.unshown).
3077
- def warn_bundle_settings
3078
- lines = BundleSettingsCheck.warnings(@extension_load.bundle_settings)
3079
- lines.each { |line| Log.warn(:config, "bundle_setting_unknown", msg: line) }
3080
- BundleSettingsCheck.unshown(lines).each { |line| hook_notify(line, :warn, "config") }
3081
- end
3082
- private :warn_bundle_settings
3083
-
3084
- # Held with the plugins' own notices, so every UI gets it: a worker's
3085
- # stderr goes nowhere.
3086
- def warn_undeclared_judgments
3087
- @judgments.undeclared.each do |name|
3088
- Log.warn(:config, "judgment_undeclared", judgment: name)
3089
- hook_notify("system_one.judgments.#{name}: no loaded bundle declares it, so it does nothing", :warn, "config")
3090
- end
3091
- end
3092
- private :warn_undeclared_judgments
3093
-
3094
- # Keep a plugin's new tool set (chi.replace_tools, from any thread)
3095
- # for the turn thread (PluginTasks#stage_tools).
3096
- def stage_tools(bundle, specs, context)
3097
- @plugin_tasks.stage_tools(bundle, specs, context)
3098
- end
3099
- private :stage_tools
3100
-
3101
- # Apply the staged tool sets, on the turn thread, before the turn's
3102
- # system prompt is built (PluginTasks#apply_staged_tools!). A changed
3103
- # set may change the chi group the search tool's description counts
3104
- # (a plugin tool the toolkit leaves out).
3105
- # @return [Boolean] whether the tools changed
3106
- def apply_staged_tools!
3107
- changed = @plugin_tasks.apply_staged_tools!
3108
- @tool_index.refresh! if changed
3109
- changed
3110
- end
3111
- public :apply_staged_tools!
3112
-
3113
- # The session's toolkit (ToolkitBinding): the kits, what its next turn
3114
- # declares, ctx.use_toolkit and the turn-start apply.
3115
- def toolkit_kits(errors = []) = @toolkit_binding.kits(errors)
3116
- def resolved_toolkit(session, errors = []) = @toolkit_binding.resolved(session, errors)
3117
- def toolkit_explained(toolkit = nil) = @toolkit_binding.explained(toolkit)
3118
- def toolkit_summary = @toolkit_binding.summary
3119
-
3120
- # The tools this session registered (Tools::Registry), declared or not.
3121
- attr_reader :tools
3122
-
3123
- public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
3124
-
3125
- # A plugin's ctx.load_tools: stage the reachable tools +names+ names
3126
- # (Tools::ToolIndex#deferred_name) that the session neither declares
3127
- # nor loaded with an earlier tool change, for bundle +bundle+. A name
3128
- # that names no tool at all is logged; a declared or removed one is
3129
- # skipped quietly.
3130
- # @return [Array<String>] the wire names staged
3131
- def load_tools(names, bundle)
3132
- added = ToolLoads.added(@session&.messages)
3133
- wanted = names.filter_map do |name|
3134
- wire = @tool_index.deferred_name(name)
3135
- Log.info(:plugins, "load_tools_skipped", bundle: bundle, tool: name) unless wire || @tools.key?(name)
3136
- wire
3137
- end
3138
- @tool_loads.stage(wanted - added, by: bundle)
3139
- end
3140
- public :load_tools
3141
-
3142
- # The staged tool loads, appended to +turn+'s messages (after its user
3143
- # message, or the trailing results and notes of a turn with none) as
3144
- # one ToolLoads entry per bundle, when the turn's host takes tool
3145
- # changes (ChatLoop#tool_changes?). Elsewhere they are dropped, logged.
3146
- # A conversation that ends with a reply keeps them staged (a system
3147
- # change must follow a user turn).
3148
- def append_tool_loads(turn)
3149
- return unless @tool_loads.any?
3150
- return if turn.messages.last&.dig(:role).to_s == "model"
3151
-
3152
- model = effective_model
3153
- host = backend(model)
3154
- staged = @tool_loads.take
3155
- added = ToolLoads.added(turn.messages)
3156
- unless host.respond_to?(:tool_changes?) && host.tool_changes?(bare_model_name(model.name))
3157
- Log.info(:plugins, "tool_loads_not_sent", tools: staged.values.flatten.join(","), model: model.name.to_s)
3158
- return
3159
- end
3160
-
3161
- staged.each do |bundle, names|
3162
- names -= added
3163
- next if names.empty?
3164
-
3165
- turn.messages << ToolLoads.entry(names, by: bundle)
3166
- hook_notify("tools loaded: #{names.join(", ")}", :info, bundle)
3167
- end
3168
- end
3169
- private :append_tool_loads
3170
-
3171
- # A plugin's ctx.use_toolkit (ToolkitBinding#use).
3172
- # @return [Boolean] whether it was staged
3173
- # @raise [ArgumentError] an unknown toolkit
3174
- def use_toolkit(name) = @toolkit_binding.use(name)
3175
- public :use_toolkit
3176
-
3177
- # At a turn's start (ToolkitBinding#apply!).
3178
- # @return [Boolean] whether the declared tools changed
3179
- def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
3180
- private :apply_toolkit!
3181
-
3182
- # Start the toolkit classifier after :turn_started, for audit and on
3183
- # mode alike (one eligibility, one snapshot, one thread). All session
3184
- # and tool-registry reads happen here; the thread receives only this
3185
- # snapshot. Audit never blocks: the thread logs its line. On mode
3186
- # returns the ask's Pending, which the turn joins before apply_toolkit!
3187
- # (#settle_toolkit_suggestion), with the turn's controller as the
3188
- # request's cancel controller.
3189
- # @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
3190
- def start_toolkit_suggestion(turn)
3191
- mode = Config.get("toolkit.suggest.mode").to_s
3192
- return if mode == "off"
3193
-
3194
- joined = mode == "on"
3195
- session = turn.session
3196
- default = Toolkit.of(session.toolkit)
3197
- log = toolkit_suggestion_log
3198
- # The predicate checks the session first; the kits (memory kits are
3199
- # read from disk) and the log (read whole) only when it gets there.
3200
- kits = nil
3201
- candidates = nil
3202
- return unless ToolkitSuggest.eligible?(
3203
- session: session, prompt: turn.prompt, author: turn.origin.author,
3204
- candidate_count: lambda {
3205
- kits = @toolkit_binding.kits
3206
- candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
3207
- candidates.size
3208
- },
3209
- last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
3210
- defer_turns: Config.get("toolkit.suggest.defer_turns"),
3211
- defer_tokens: Config.get("toolkit.suggest.defer_tokens")
3212
- )
3213
-
3214
- # One ask at a time: an eligible turn while the last one still runs
3215
- # goes unasked, and says so.
3216
- if @side_judgments.in_flight?(:toolkit)
3217
- Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
3218
- return
3219
- end
3220
-
3221
- client = system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit),
3222
- ToolkitSuggest::JUDGMENT, session, mode: Config.get("toolkit.suggest.mode").to_s)
3223
- return unless client
3224
-
3225
- # The warm-up's only value is the cache: the real ask never queues
3226
- # behind it.
3227
- stop_toolkit_warmup
3228
-
3229
- # Rules, not tool lists: this runs before await_init_tasks and
3230
- # apply_staged_tools!, so a first turn's plugin/MCP tools aren't
3231
- # registered yet; the report expands the rules (ToolkitSuggest.rules).
3232
- snapshots = candidates.map do |kit|
3233
- ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
3234
- rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
3235
- end.freeze
3236
- default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
3237
- rules: ToolkitSuggest.rules(default.resolve(kits)))
3238
- registered = @tools.entries(toolkit: nil).reject { |entry| entry.source == Tools::ToolIndex::SOURCE }
3239
- core_tools, plugin_tools = registered.partition(&:core?).map { |entries| entries.map { |entry| entry.name.dup.freeze }.freeze }
3240
- state = ToolkitSuggest.question(
3241
- default: default, kits: kits, project: File.basename(session.project_root.to_s),
3242
- prompt: turn.prompt
3243
- ).last
3244
- snapshot = ToolkitSuggest::Snapshot.new(
3245
- session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
3246
- default: default_snapshot, candidates: snapshots, core_tools: core_tools, plugin_tools: plugin_tools,
3247
- state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
3248
- )
3249
- threshold = Config.get("toolkit.suggest.threshold")
3250
- runs = session.scratch ? nil : system_one_runs
3251
- pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
3252
- acted: runs && ->(judged, did) { runs.acted(judged.fields[:run_id], did) },
3253
- timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
3254
- # On mode: cut by a Stop while the turn waits (Pending#wait), not
3255
- # by one after it went on; audit's ask is never cut.
3256
- controller = joined ? pending.cancel_controller : nil
3257
- @side_judgments.start(:toolkit, warn: ToolkitSuggest.method(:warn), finish: pending.method(:finish)) do
3258
- ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
3259
- cancel_controller: controller)
3260
- end
3261
- joined ? pending : nil
3262
- rescue StandardError => e
3263
- ToolkitSuggest.warn(e)
3264
- nil
3265
- end
3266
- private :start_toolkit_suggestion
3267
-
3268
- # On mode, before apply_toolkit!: wait for the ask (#start_toolkit_
3269
- # suggestion) up to System 1's timeout from its start, or until a Stop,
3270
- # and put the session on a candidate picked with confidence ≥ threshold
3271
- # (ToolkitBinding#suggest: origin suggested, saved, the notice). Any
3272
- # other outcome (the default, below threshold, undecided, no judgment,
3273
- # a timeout, a Stop) leaves the session on its default. Nothing here
3274
- # sends a request; prepare_turn's cancel check ends a stopped turn. A
3275
- # Stop wins over an answer already in (Pending#wait checks it first).
3276
- def settle_toolkit_suggestion(turn, pending)
3277
- return if pending.nil?
3278
-
3279
- outcome, judged = pending.wait(turn.controller)
3280
- if outcome == :timeout
3281
- # The session starts on its default without a word otherwise: a
3282
- # slow System 1 host (a local one) is the usual cause.
3283
- timeout_ms = SystemOne::Client.configured_timeout_ms
3284
- Log.warn(:toolkit, "toolkit_suggest_timeout", session_id: turn.session.id, timeout_ms: timeout_ms,
3285
- reason: "System 1 gave no answer within system_one.timeout_ms")
3286
- @toolkit_binding.fell_back(turn.session, "System 1 took over #{timeout_ms} ms")
3287
- return
3288
- end
3289
- unless outcome == :answered
3290
- Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
3291
- return
3292
- end
3293
- return if judged.nil?
3294
-
3295
- pending.record(toolkit_suggestion_outcome(turn.session, judged))
3296
- rescue StandardError => e
3297
- ToolkitSuggest.warn(e)
3298
- pending&.record("failed")
3299
- end
3300
- private :settle_toolkit_suggestion
3301
-
3302
- # What the turn does with an answered ask, as the log line's did:
3303
- # suggested (switched), chosen (the toolkit isn't the default any
3304
- # more), unavailable (the kit is gone), defer or default.
3305
- def toolkit_suggestion_outcome(session, judged)
3306
- case judged.would
3307
- when "suggested"
3308
- return "chosen" unless session.toolkit_origin == "default"
3309
- return "unavailable" unless @toolkit_binding.kits.key?(judged.suggestion.kit)
3310
-
3311
- switched = @toolkit_binding.suggest(session, judged.suggestion.kit, confidence: judged.suggestion.confidence,
3312
- deferred: ToolkitSuggest.model_reply?(session.messages))
3313
- switched ? "suggested" : "chosen"
3314
- when "defer" then "defer"
3315
- when "no_judgment"
3316
- # System 1 failed (its own warn) or answered nothing usable.
3317
- @toolkit_binding.fell_back(session, "System 1 gave no answer")
3318
- "default"
3319
- else "default"
3320
- end
3321
- end
3322
- private :toolkit_suggestion_outcome
3323
-
3324
- # On mode, as an idle worker starts (Worker#start_background): one ask
3325
- # with the real question and a stand-in prompt, on its own thread,
3326
- # never logged and never waited for, so System 1's server has the
3327
- # question's prefix cached for the first prompt. Only for a session a
3328
- # first prompt could be asked for: origin default, no model reply, not
3329
- # a delegate child or a review session, at least two candidates; and
3330
- # not when the worker starts on a first prompt (that ask comes at once).
3331
- def warm_toolkit_suggestion!
3332
- return unless Config.get("toolkit.suggest.mode").to_s == "on"
3333
-
3334
- session = @session
3335
- return if session.nil? || session.toolkit_origin != "default" || session.delegate? || session.review?
3336
- return if ToolkitSuggest.model_reply?(session.messages) || !session.last_prompt.to_s.strip.empty?
3337
-
3338
- default = Toolkit.of(session.toolkit)
3339
- kits = @toolkit_binding.kits
3340
- return if ToolkitSuggest.candidates(kits, default_name: default.name).size < 2
3341
-
3342
- client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
3343
- return unless client
3344
-
3345
- project = File.basename(session.project_root.to_s)
3346
- @toolkit_warmup_thread = Thread.new do
3347
- ToolkitSuggest.warm(client: client, default: default, kits: kits, project: project)
3348
- end
3349
- rescue StandardError => e
3350
- ToolkitSuggest.warn(e)
3351
- nil
3352
- end
3353
- public :warm_toolkit_suggestion!
3354
-
3355
- # A still-running warm-up, stopped: a real ask (or the worker leaving)
3356
- # doesn't wait behind it.
3357
- def stop_toolkit_warmup
3358
- @toolkit_warmup_thread&.kill
3359
- @toolkit_warmup_thread = nil
3360
- end
3361
- private :stop_toolkit_warmup
3362
-
3363
- def toolkit_suggestion_log
3364
- SideJudgments.log(ToolkitSuggest.log_path(session_state_dir), tag: :toolkit, event: "toolkit_suggest_log_failed")
3365
- end
3366
- private :toolkit_suggestion_log
3367
-
3368
- # +client+ (nil for none) saving its asks as +judgment+'s runs. Anything
3369
- # going wrong here leaves the client as it was: a run is never worth a
3370
- # judgment.
3371
- #
3372
- # A scratch session's asks aren't saved; a participant's turn is never
3373
- # judged, so it never comes here.
3374
- # @param mode [String, nil] the use's mode, for its runs (Recording.wrap)
3375
- def system_one_recording(client, judgment, session, mode: nil)
3376
- return client if client.nil? || session&.scratch
3377
-
3378
- context = SystemOne::Recording::Context.of(session, session_model: session && effective_model_name)
3379
- SystemOne::Recording.wrap(client, judgment: judgment, runs: system_one_runs, context: context, recorded_mode: mode)
3380
- rescue StandardError => e
3381
- Log.warn(:system_one, "system_one_recording_failed", error: e.class.name, msg: e.message.to_s[0, 200])
3382
- client
3383
- end
3384
- private :system_one_recording
3385
-
3386
1159
  # Bundles' judgments (chi.judgment), on this Engine's Runs and side asks.
3387
1160
  # A judgment's own host and model are for bundles only: core's clients
3388
- # above never pass them (D2, JudgmentSettings).
1161
+ # (#system_one_recording's, engine/side_requests.rb) never pass them (D2,
1162
+ # JudgmentSettings).
3389
1163
  def build_judgments
3390
1164
  SystemOne::Judgments.new(
3391
1165
  client_for: lambda { |_name, settings|
@@ -3400,165 +1174,6 @@ module Samagotchi
3400
1174
  end
3401
1175
  private :build_judgments
3402
1176
 
3403
- # Where this Engine's runs are saved; read each time, as the settings are.
3404
- def system_one_runs = SystemOne::Runs.for_sessions_dir(session_state_dir)
3405
- private :system_one_runs
3406
-
3407
- # The candidate judge (ContextJudge) at an owner turn's start, after
3408
- # the toolkit's ask settled: the session's candidates' status changes
3409
- # are logged (ContextCandidatesLog#look) and, unless
3410
- # context.candidates.mode is off, the proposed candidates due an ask
3411
- # are asked about the owner's prompts so far, this one included. Never
3412
- # a participant's turn, a review session or a Continue (no new prompt).
3413
- def start_context_judge(turn)
3414
- session = turn.session
3415
- return unless turn.prompt.is_a?(String) && OwnerPrompt.turn?(session: session, author: turn.origin.author)
3416
-
3417
- context_judge_pass(session, ContextCandidates.owner_texts(session.messages) + [turn.prompt], phase: "turn")
3418
- end
3419
- private :start_context_judge
3420
-
3421
- # System 1 on the objectives, in audit (ObjectivesAudit): the owner's turns
3422
- # only, and for a message a person's: a reminder's or a delegate's prompt isn't one.
3423
- def audit_objectives_incoming(turn)
3424
- return unless turn.prompt.is_a?(String) && turn.origin.human? && OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
3425
-
3426
- objectives_audit.incoming(turn.session, turn.prompt)
3427
- end
3428
- private :audit_objectives_incoming
3429
-
3430
- def audit_objectives_outgoing(turn, result)
3431
- return unless OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
3432
-
3433
- objectives_audit.outgoing(turn.session, result.text)
3434
- end
3435
- private :audit_objectives_outgoing
3436
-
3437
- # With saving off nothing would come of an ask, so none is made.
3438
- def objectives_audit
3439
- @objectives_audit ||= ObjectivesAudit.new(
3440
- side: @side_judgments, state_dir: -> { session_state_dir },
3441
- client_for: lambda do |judgment, session|
3442
- next nil unless system_one_runs.on?
3443
-
3444
- system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: ObjectivesAudit::MODE, tag: :system_one),
3445
- judgment, session)
3446
- end
3447
- )
3448
- end
3449
- private :objectives_audit
3450
-
3451
- # A late proposal no owner prompt mentions (Plugin::Host
3452
- # #candidate_proposed: github-pr's init task runs beside the first
3453
- # turn): judged on the owner's prompts so far, the running turn's too.
3454
- def judge_late_candidates
3455
- session = @session
3456
- return if session.nil? || session.review?
3457
-
3458
- texts = ContextCandidates.owner_texts(plugin_messages)
3459
- context_judge_pass(session, texts, phase: "late") unless texts.empty?
3460
- end
3461
- private :judge_late_candidates
3462
-
3463
- # One look at +session+'s candidates; the asks go to SideJudgments'
3464
- # :context kind, one thread asking each due candidate in turn.
3465
- def context_judge_pass(session, texts, phase:)
3466
- return if session.id.nil? || session.scratch
3467
-
3468
- rows = ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: session_state_dir)
3469
- return if rows.empty?
3470
-
3471
- log = ContextCandidatesLog.log_for(session_state_dir)
3472
- records = ContextCandidatesLog.records_for(log, session.id)
3473
- ContextCandidatesLog.look(log, session.id, rows, records) if phase == "turn"
3474
- settings = ContextJudge::Settings.from_config
3475
- return if settings.mode == "off"
3476
-
3477
- jobs = ContextJudge.jobs(rows, records, session_id: session.id, texts: texts, settings: settings, phase: phase,
3478
- parent_attached: context_parent_attached(session))
3479
- return if jobs.empty?
3480
-
3481
- client = system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :context),
3482
- ContextJudge::JUDGMENT, session, mode: settings.mode)
3483
- return unless client
3484
-
3485
- start_context_asks(client, jobs, log, settings, context_choice(session, log, settings))
3486
- rescue StandardError => e
3487
- ContextJudge.warn(e)
3488
- end
3489
- private :context_judge_pass
3490
-
3491
- # @return [Thread, nil] the asks' thread, nil when none started
3492
- def start_context_asks(client, jobs, log, settings, choice)
3493
- if @side_judgments.in_flight?(:context)
3494
- Log.info(:context, "context_judge_in_flight_skipped", session_id: jobs.first.session_id)
3495
- return nil
3496
- end
3497
-
3498
- @side_judgments.start(:context, warn: ContextJudge.method(:warn)) do
3499
- jobs.each { |job| choice.call(job, ContextJudge.ask(client: client, job: job, log: log, settings: settings)) }
3500
- end
3501
- end
3502
- private :start_context_asks
3503
-
3504
- # What an answer does on the asks' thread (ContextCandidateChoice
3505
- # #after_judged: on mode attaches or cards; audit nothing). Taken on
3506
- # the caller's thread. The attach only writes the session's folder: its
3507
- # note comes with the next between-turn delivery, never into a running
3508
- # turn. A card shows only while this Engine is still on that session,
3509
- # and not in a running turn (it isn't the turn's: no "waiting for you"
3510
- # on it), but once it has ended.
3511
- # @return [#call] (job, fields)
3512
- def context_choice(session, log, settings)
3513
- session_id = session.id
3514
- project_root = session.project_root
3515
- state_dir = session_state_dir
3516
- show = lambda do |**card|
3517
- current = @session
3518
- next nil unless current.nil? || current.id == session_id
3519
-
3520
- @context_cards_mutex.synchronize do
3521
- next @context_cards_after_turn << card if turn_running?
3522
-
3523
- show_card(**card)
3524
- end
3525
- end
3526
- lambda do |job, fields|
3527
- ContextCandidateChoice.after_judged(job: job, fields: fields, settings: settings, project_root: project_root,
3528
- state_dir: state_dir, log: log, show_card: show)
3529
- end
3530
- end
3531
- private :context_choice
3532
-
3533
- # The cards #context_choice held while a turn ran (#run_turn's ensure,
3534
- # after the turn flag is released).
3535
- def show_context_cards_after_turn
3536
- cards = @context_cards_mutex.synchronize { @context_cards_after_turn.slice!(0..) }
3537
- cards.each { |card| show_card(**card) }
3538
- rescue StandardError => e
3539
- ContextJudge.warn(e)
3540
- end
3541
- private :show_context_cards_after_turn
3542
-
3543
- # A delegate child's parent's sources, by title (else name): the
3544
- # judge's started_from. nil for any other session (a chi send --new
3545
- # session has no parent; a fork isn't a delegate).
3546
- def context_parent_attached(session)
3547
- return nil unless session.delegate? && session.parent_id
3548
-
3549
- parent_root = if Session.exist?(session.parent_id, state_dir: session_state_dir)
3550
- Session.load(session.parent_id, state_dir: session_state_dir).project_root
3551
- else
3552
- session.project_root
3553
- end
3554
- ContextSources.attached(session.parent_id, project_root: parent_root, state_dir: session_state_dir)
3555
- .map { |attached| attached.source.title || attached.name }
3556
- rescue StandardError => e
3557
- Log.warn(:context, "context_parent_attached_failed", error: e.class.name, msg: e.message.to_s[0, 200])
3558
- []
3559
- end
3560
- private :context_parent_attached
3561
-
3562
1177
  # The session's toolkit (see #initialize for when).
3563
1178
  def build_toolkit_binding
3564
1179
  ToolkitBinding.new(
@@ -3574,160 +1189,6 @@ module Samagotchi
3574
1189
  end
3575
1190
  private :build_toolkit_binding
3576
1191
 
3577
- # The tools changed (a plugin's chi.tools_changed!): the system prompts,
3578
- # which declare them, are built again on the next turn.
3579
- def tools_changed!
3580
- prompt_changed!
3581
- end
3582
-
3583
- # A plugin's prompt section would give other text now
3584
- # (chi.prompt_changed!): the system prompts are built again at their
3585
- # next build (the next turn's start; a running turn keeps the prompt it
3586
- # started with). One cache restart: the plugin's command asked first
3587
- # (Plugin::Context#cache_confirm).
3588
- def prompt_changed!
3589
- @prompt_builder&.reset!
3590
- end
3591
- public :prompt_changed!
3592
-
3593
- # What a Plugin::Context reads and calls: the session now, and the
3594
- # hook runtime's notify and ask_user.
3595
- def plugin_host
3596
- Plugin::Host.new(
3597
- session_id: -> { @session&.id },
3598
- cwd: -> { @session&.working_directory },
3599
- messages: -> { plugin_messages },
3600
- messages_partial: -> { turn_running? && @running_turn_messages.nil? },
3601
- turn_running: -> { turn_running? },
3602
- notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
3603
- ask_user: ->(question) { hook_ask_user(question) },
3604
- # An init task's is its own (a Ctrl-C ends a turn, not the task).
3605
- cancelled: lambda {
3606
- (task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
3607
- },
3608
- card: ->(**card) { show_card(**card) },
3609
- steer: ->(text, label) { steer(text, source: hook_source(label)) },
3610
- stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
3611
- stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
3612
- toolkit: -> { @toolkit_binding.name },
3613
- declared_tools: -> { toolkit_explained.declared.map(&:name) },
3614
- use_toolkit: ->(name) { use_toolkit(name) },
3615
- load_tools: ->(names, bundle) { load_tools(names, bundle) },
3616
- cancel_controller: ->(handler: false) { plugin_cancel_controller(handler: handler) },
3617
- ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
3618
- ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
3619
- model: model, source: source)
3620
- },
3621
- model_name: -> { effective_model_ref },
3622
- model_key: -> { @model_binding.model_key },
3623
- # The names a models: map looks the model up under; [] when it doesn't resolve.
3624
- model_names: lambda do
3625
- effective_model.names
3626
- rescue StandardError
3627
- []
3628
- end,
3629
- state_dir: -> { session_state_dir },
3630
- scratch: -> { @scratch },
3631
- setup: -> { SessionSetup.of(@session) },
3632
- # The files whose text the built system prompt holds; [] until a
3633
- # build and after a rebuild that drops them (SystemPrompt#prompt_files
3634
- # returns [] itself: the || keeps a nil builder safe).
3635
- prompt_files: -> { @prompt_builder&.prompt_files || [] },
3636
- frontend: -> { interface },
3637
- cost: -> { @metrics.cost },
3638
- usage: -> { @metrics.usage },
3639
- turn_author: -> { turn_author },
3640
- # A plugin's prompt section is in the stable part: a full re-read.
3641
- cache_confirm: ->(line, changed) { CacheConfirm.ask(line: line, cost: :full, changed: changed, engine: self) },
3642
- candidate_proposed: ->(_candidate) { judge_late_candidates },
3643
- judgments: -> { @judgments }
3644
- )
3645
- end
3646
-
3647
- # The controller a plugin's ctx.http call without cancel: stops with
3648
- # (docs/plugins.md): an init task's own, else the running turn's for the
3649
- # turn's own work until the turn has ended (TurnState#own_controller); nil
3650
- # for an anytime command, a service or a thread of the plugin's.
3651
- def plugin_cancel_controller(handler:)
3652
- (task = current_init_task) ? task.cancel : @turn_state.own_controller(handler: handler)
3653
- end
3654
- private :plugin_cancel_controller
3655
-
3656
- # A plugin's ctx.messages: the conversation without the system prompt
3657
- # (the REPL's starts with it, a worker's new session doesn't), and,
3658
- # while a turn runs, the turn so far from the Bridge (plan O1). Read
3659
- # with the event log held, so a turn is in exactly one of the two.
3660
- def plugin_messages
3661
- synchronize_events do
3662
- messages = AnswerDisplay.strip_all(messages_checkpoint)
3663
- first = messages.first
3664
- messages = messages.drop(1) if first && first[:role].to_s == "system" && first[:kind].to_s.empty?
3665
- running = turn_running? && @running_turn_messages ? Array(@running_turn_messages.call) : []
3666
- messages + running
3667
- end
3668
- end
3669
- private :plugin_messages
3670
-
3671
- # ctx.ask_model's request: the session's current model on its host, as
3672
- # a turn resolves them (a /model switch counts), or a configured +model+
3673
- # (a host:id ref, an alias, a bare id one of a host's models), through its own
3674
- # IdleClient, so it shares nothing with the turn's backend. The client
3675
- # is kept while the target and timeout stay (what it learned, e.g. a
3676
- # refused thinking-off, holds for the next request); the session's id
3677
- # goes with the request. Its usage counts in the session's side line
3678
- # (#note_side_usage) under +source+, the asking bundle's name.
3679
- # @return [String] the answer
3680
- def ask_side_model(request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil)
3681
- target = model.nil? ? session_model_recap_target : configured_side_target(model)
3682
- client = side_model_client(target, timeout)
3683
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
3684
- answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id,
3685
- kind: source.to_s.empty? ? "ask" : source.to_s, on_usage: method(:note_side_usage))
3686
- Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
3687
- ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
3688
- answer.text
3689
- end
3690
- private :ask_side_model
3691
-
3692
- # The target of ctx.ask_model(model:): +model+ as a turn resolves it,
3693
- # once it is known to name something configured. IdleTarget.of_model
3694
- # never raises (an unknown host falls to the default host, an unknown id
3695
- # to the default entry), so a typo would silently ask another model.
3696
- # @raise [ArgumentError] +model+ names no configured host and is no
3697
- # host's model (cached, declared or saved lists; nothing is asked)
3698
- def configured_side_target(model)
3699
- ref = @host_registry.model_ref(model)
3700
- unless ref.host_name || ref.alias_name || @host_registry.hosts_with_model(ref.id).any?
3701
- raise ArgumentError, "ask_model: unknown model #{model.to_s.inspect} (use a configured host:id, an alias, " \
3702
- "or a model id one of your hosts lists)"
3703
- end
3704
-
3705
- IdleTarget.of_model(@host_registry, model)
3706
- end
3707
- private :configured_side_target
3708
-
3709
- # The IdleClient for +target+ at +timeout+: the last one when both are
3710
- # the same, else a new one.
3711
- def side_model_client(target, timeout)
3712
- @side_client_mutex.synchronize do
3713
- key = [target, timeout]
3714
- unless @side_client && @side_client_key == key
3715
- @side_client = IdleClient.for(target, timeout: timeout)
3716
- @side_client_key = key
3717
- end
3718
- @side_client
3719
- end
3720
- end
3721
- private :side_model_client
3722
-
3723
- # The session's current model as a recap target: its host's OpenAI API
3724
- # (native llama.cpp hosts serve /v1/chat/completions too), key variable
3725
- # and bare model name, as a turn resolves them.
3726
- # @return [IdleTarget]
3727
- def session_model_recap_target
3728
- IdleTarget.of_model(@host_registry, effective_model_name)
3729
- end
3730
-
3731
1192
  # Build the idle reminders job. Always created (reminders are opt-in
3732
1193
  # via the agent calling register_reminder). The shared IdleScheduler
3733
1194
  # polls it and triggers synthetic turns when reminders are due.
@@ -3743,50 +1204,5 @@ module Samagotchi
3743
1204
  callback: @auto_turn_callback
3744
1205
  )
3745
1206
  end
3746
-
3747
- # ── Event helpers ──────────────────────────────────────────────────────────
3748
-
3749
- # The stream handler, the working set and the event fan-out are
3750
- # EventFanout's (#build_event_fanout); these forward to it.
3751
- def build_stream_event_handler(...) = @event_fanout.build_stream_event_handler(...)
3752
- def emit_working_set(...) = @event_fanout.emit_working_set(...)
3753
- private :emit_working_set
3754
- def working_set_llm_context = @event_fanout.working_set_llm_context
3755
- private :working_set_llm_context
3756
- def with_generation_report(event) = @event_fanout.with_generation_report(event)
3757
- private :with_generation_report
3758
- def stream_watch(cancel_controller) = @event_fanout.stream_watch(cancel_controller)
3759
- private :stream_watch
3760
- def emit_event(on_event, event) = @event_fanout.emit_event(on_event, event)
3761
-
3762
- # The effective model's window_tokens and price, read per turn
3763
- # (SessionModelSettings).
3764
- def turn_window_setting = @model_settings.turn_window_setting
3765
- def turn_price = @model_settings.turn_price
3766
-
3767
- # The snapshots' helpers are EngineSnapshots' (#build_engine_snapshots);
3768
- # these forward to it.
3769
- def served_model_for(...) = @engine_snapshots.served_model_for(...)
3770
- def kit_prompt_stats = @engine_snapshots.kit_prompt_stats
3771
-
3772
- # The window as +model+'s loop would see it (EffectiveModel#context_window).
3773
- def current_context_window(model) = model.context_window
3774
-
3775
- # +settings+' chips (EngineSnapshots#setting_chips).
3776
- def setting_chips(settings, model) = @engine_snapshots.setting_chips(settings, model)
3777
-
3778
- # The effective model now, resolved on each call, never kept
3779
- # (SessionModelSettings#effective_model). Raises as HostRegistry#resolve does.
3780
- # @return [EffectiveModel]
3781
- def effective_model(choice = @model_binding.choice) = @model_settings.effective_model(choice)
3782
-
3783
- # ── Prompt profile ─────────────────────────────────────────────────────────
3784
-
3785
- # The profile's resolve, keep, apply and retry are ModelBinding's
3786
- # (#build_model_binding); these forward to it.
3787
- def resolve_profile(choice) = @model_binding.resolve_profile(choice)
3788
- def store_profile(choice, resolution) = @model_binding.store_profile(choice, resolution)
3789
- def apply_profile(resolution) = @model_binding.apply_profile(resolution)
3790
- def refresh_profile! = @model_binding.refresh_profile!
3791
1207
  end
3792
1208
  end