samagotchi 0.155.0 → 0.157.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/bin/chi +2 -1
  4. data/docs/architecture.md +22 -13
  5. data/docs/bundles.md +3 -1
  6. data/docs/cli.md +9 -8
  7. data/docs/configuration.md +36 -4
  8. data/docs/guardrails.md +9 -5
  9. data/docs/hooks.md +11 -5
  10. data/docs/internals/anthropic.md +1 -1
  11. data/docs/internals/attached-context.md +3 -2
  12. data/docs/internals/background-tasks.md +1 -1
  13. data/docs/internals/bridge.md +83 -6
  14. data/docs/internals/bundles.md +28 -18
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +641 -937
  17. data/docs/internals/llm-context-forget.md +8 -3
  18. data/docs/internals/objectives.md +2 -2
  19. data/docs/internals/participants.md +3 -2
  20. data/docs/internals/prompt-caching.md +4 -1
  21. data/docs/internals/remote-sessions.md +8 -3
  22. data/docs/internals/web-frontend.md +3 -2
  23. data/docs/internals/working-set.md +2 -2
  24. data/docs/memory.md +13 -2
  25. data/docs/plugins.md +35 -2
  26. data/docs/sessions.md +11 -11
  27. data/docs/sub-agent.md +17 -2
  28. data/docs/system-one.md +3 -2
  29. data/docs/testing.md +53 -2
  30. data/lib/samagotchi/answer_command.rb +2 -1
  31. data/lib/samagotchi/archive_store.rb +4 -3
  32. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  33. data/lib/samagotchi/bootstrap_command.rb +3 -2
  34. data/lib/samagotchi/bridge/card_places.rb +10 -4
  35. data/lib/samagotchi/bridge/card_store.rb +3 -0
  36. data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
  37. data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
  38. data/lib/samagotchi/bridge.rb +31 -6
  39. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  40. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  41. data/lib/samagotchi/broadcast_command.rb +3 -3
  42. data/lib/samagotchi/bundle_command/check.rb +18 -0
  43. data/lib/samagotchi/bundle_command/try/sandbox.rb +2 -2
  44. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  45. data/lib/samagotchi/bundle_command.rb +5 -2
  46. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  47. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  48. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  49. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  50. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  51. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  52. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  53. data/lib/samagotchi/child_move.rb +13 -57
  54. data/lib/samagotchi/child_reports.rb +2 -2
  55. data/lib/samagotchi/children_status.rb +15 -21
  56. data/lib/samagotchi/cli/parent_wait.rb +9 -23
  57. data/lib/samagotchi/config.rb +20 -112
  58. data/lib/samagotchi/context_command.rb +1 -2
  59. data/lib/samagotchi/context_sources.rb +5 -4
  60. data/lib/samagotchi/context_window.rb +9 -12
  61. data/lib/samagotchi/desktop/macos.rb +1 -1
  62. data/lib/samagotchi/effective_model.rb +57 -22
  63. data/lib/samagotchi/engine/context_judge.rb +19 -11
  64. data/lib/samagotchi/engine/messages.rb +1 -1
  65. data/lib/samagotchi/engine/models.rb +5 -2
  66. data/lib/samagotchi/engine/objectives.rb +29 -11
  67. data/lib/samagotchi/engine/plugins.rb +1 -1
  68. data/lib/samagotchi/engine/recap.rb +8 -4
  69. data/lib/samagotchi/engine/side_requests.rb +21 -11
  70. data/lib/samagotchi/engine/toolkit.rb +10 -9
  71. data/lib/samagotchi/engine/turn_end.rb +1 -1
  72. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  73. data/lib/samagotchi/engine/verify.rb +1 -1
  74. data/lib/samagotchi/engine.rb +43 -12
  75. data/lib/samagotchi/events.rb +3 -2
  76. data/lib/samagotchi/extension_load.rb +6 -3
  77. data/lib/samagotchi/guardrails/targets.rb +24 -9
  78. data/lib/samagotchi/guardrails_command.rb +1 -1
  79. data/lib/samagotchi/host_address.rb +52 -0
  80. data/lib/samagotchi/host_config.rb +72 -1
  81. data/lib/samagotchi/host_model.rb +2 -6
  82. data/lib/samagotchi/host_registry.rb +58 -46
  83. data/lib/samagotchi/kernel_loop.rb +53 -124
  84. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  85. data/lib/samagotchi/llm/chat_loop.rb +40 -21
  86. data/lib/samagotchi/llm/native_backend.rb +1 -1
  87. data/lib/samagotchi/llm_context_apply.rb +54 -1
  88. data/lib/samagotchi/llm_context_notice.rb +1 -1
  89. data/lib/samagotchi/llm_context_strategy.rb +29 -67
  90. data/lib/samagotchi/llm_context_view.rb +9 -0
  91. data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
  92. data/lib/samagotchi/memory_bundle/builder.rb +2 -1
  93. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  94. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  95. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  96. data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
  97. data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
  98. data/lib/samagotchi/memory_bundle/installer.rb +185 -169
  99. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  100. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  101. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  102. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  103. data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
  104. data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
  105. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
  106. data/lib/samagotchi/memory_bundle/status.rb +14 -20
  107. data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
  108. data/lib/samagotchi/memory_bundle/trash.rb +23 -8
  109. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  110. data/lib/samagotchi/memory_usage.rb +1 -1
  111. data/lib/samagotchi/model_lookup.rb +75 -0
  112. data/lib/samagotchi/model_price.rb +4 -10
  113. data/lib/samagotchi/model_profile.rb +7 -6
  114. data/lib/samagotchi/model_settings.rb +37 -2
  115. data/lib/samagotchi/models_command.rb +1 -1
  116. data/lib/samagotchi/note_command.rb +2 -3
  117. data/lib/samagotchi/objectives_audit.rb +4 -3
  118. data/lib/samagotchi/participant_mode.rb +2 -1
  119. data/lib/samagotchi/participants.rb +2 -2
  120. data/lib/samagotchi/plugin/api.rb +41 -6
  121. data/lib/samagotchi/plugin/context.rb +1 -1
  122. data/lib/samagotchi/plugin/loader.rb +33 -10
  123. data/lib/samagotchi/plugin/sessions.rb +3 -6
  124. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  125. data/lib/samagotchi/project_env.rb +1 -1
  126. data/lib/samagotchi/reasoning_history.rb +14 -20
  127. data/lib/samagotchi/recap_place.rb +27 -0
  128. data/lib/samagotchi/reminder_carry.rb +1 -1
  129. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  130. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  131. data/lib/samagotchi/reply_wait/target.rb +113 -0
  132. data/lib/samagotchi/reply_wait.rb +22 -2
  133. data/lib/samagotchi/reprefill_meter.rb +45 -0
  134. data/lib/samagotchi/review_command.rb +1 -1
  135. data/lib/samagotchi/sampling_settings.rb +9 -16
  136. data/lib/samagotchi/self_report.rb +36 -37
  137. data/lib/samagotchi/send_command.rb +17 -23
  138. data/lib/samagotchi/session.rb +370 -445
  139. data/lib/samagotchi/session_access/local.rb +12 -11
  140. data/lib/samagotchi/session_access/remote.rb +22 -6
  141. data/lib/samagotchi/session_archive_command.rb +2 -2
  142. data/lib/samagotchi/session_chain.rb +7 -6
  143. data/lib/samagotchi/session_continue.rb +6 -5
  144. data/lib/samagotchi/session_delete_command.rb +1 -1
  145. data/lib/samagotchi/session_field.rb +133 -0
  146. data/lib/samagotchi/session_inbox.rb +49 -1
  147. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  148. data/lib/samagotchi/session_manager/emptiness.rb +139 -0
  149. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  150. data/lib/samagotchi/session_manager/listing.rb +118 -0
  151. data/lib/samagotchi/session_manager/removal.rb +190 -0
  152. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  153. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  154. data/lib/samagotchi/session_manager.rb +28 -1541
  155. data/lib/samagotchi/session_metrics.rb +8 -5
  156. data/lib/samagotchi/session_paths.rb +57 -0
  157. data/lib/samagotchi/session_retention.rb +2 -2
  158. data/lib/samagotchi/session_row.rb +200 -0
  159. data/lib/samagotchi/session_setup.rb +15 -12
  160. data/lib/samagotchi/sessions_command.rb +11 -129
  161. data/lib/samagotchi/share_command.rb +1 -1
  162. data/lib/samagotchi/starting_marker.rb +65 -0
  163. data/lib/samagotchi/steer_cut_after.rb +7 -14
  164. data/lib/samagotchi/system_one/recorder.rb +49 -0
  165. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  166. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  167. data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
  168. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  169. data/lib/samagotchi/terminal_ui.rb +5 -7
  170. data/lib/samagotchi/testing/install.rb +2 -1
  171. data/lib/samagotchi/testing/sessions.rb +1 -1
  172. data/lib/samagotchi/thinking.rb +16 -21
  173. data/lib/samagotchi/token_usage.rb +1 -1
  174. data/lib/samagotchi/tool_activity.rb +10 -4
  175. data/lib/samagotchi/tool_declarations.rb +2 -2
  176. data/lib/samagotchi/tool_output_cut.rb +17 -0
  177. data/lib/samagotchi/tool_response.rb +5 -3
  178. data/lib/samagotchi/tool_runner.rb +25 -5
  179. data/lib/samagotchi/toolkit_command.rb +1 -1
  180. data/lib/samagotchi/tools/delegate.rb +55 -45
  181. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  182. data/lib/samagotchi/tools/delegate_result.rb +5 -5
  183. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  184. data/lib/samagotchi/tools/index_hint.rb +14 -0
  185. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  186. data/lib/samagotchi/tools/registry.rb +28 -11
  187. data/lib/samagotchi/tools/send_note.rb +4 -6
  188. data/lib/samagotchi/tools/tool_index.rb +52 -51
  189. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  190. data/lib/samagotchi/turn_context.rb +2 -1
  191. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  192. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  193. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  194. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  195. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  196. data/lib/samagotchi/update_command.rb +11 -7
  197. data/lib/samagotchi/verify_fresh.rb +7 -11
  198. data/lib/samagotchi/version.rb +1 -1
  199. data/lib/samagotchi/vision_support.rb +11 -14
  200. data/lib/samagotchi/web/app.rb +45 -28
  201. data/lib/samagotchi/web/message_display.rb +57 -7
  202. data/lib/samagotchi/web/participant_access.rb +3 -3
  203. data/lib/samagotchi/web/public/activity.js +16 -7
  204. data/lib/samagotchi/web/public/app.js +29 -8
  205. data/lib/samagotchi/web/public/bubbles.js +8 -3
  206. data/lib/samagotchi/web/public/cards_place.js +15 -0
  207. data/lib/samagotchi/web/public/commands.js +5 -1
  208. data/lib/samagotchi/web/public/data.js +4 -2
  209. data/lib/samagotchi/web/public/history_shape.js +220 -0
  210. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  211. data/lib/samagotchi/web/public/reload_check.js +71 -0
  212. data/lib/samagotchi/web/public/stage_model.js +7 -5
  213. data/lib/samagotchi/web/public/stage_view.js +7 -3
  214. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  215. data/lib/samagotchi/web/public/timing.js +43 -2
  216. data/lib/samagotchi/web/public/turn_block.js +215 -30
  217. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  218. data/lib/samagotchi/web/public/turn_events.js +10 -1
  219. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  220. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  221. data/lib/samagotchi/web/server.rb +4 -2
  222. data/lib/samagotchi/web/session_access_routes.rb +6 -6
  223. data/lib/samagotchi/web/session_control.rb +4 -4
  224. data/lib/samagotchi/web/session_create.rb +3 -3
  225. data/lib/samagotchi/web/session_hub.rb +9 -17
  226. data/lib/samagotchi/web/session_show.rb +67 -5
  227. data/lib/samagotchi/web/session_summary.rb +39 -28
  228. data/lib/samagotchi/worker.rb +83 -32
  229. data/lib/samagotchi/worker_boot.rb +184 -0
  230. data/lib/samagotchi/worker_link.rb +3 -0
  231. data/lib/samagotchi/worker_signals.rb +1 -1
  232. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  233. metadata +32 -4
  234. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -27,6 +27,7 @@ require_relative "thinking_notices"
27
27
  require_relative "cancellation_controller"
28
28
  require_relative "context_window"
29
29
  require_relative "kernel_loop"
30
+ require_relative "reprefill_meter"
30
31
  require_relative "tools/builtins"
31
32
  require_relative "tools/task_runtime"
32
33
  require_relative "tools/tool_index"
@@ -55,6 +56,7 @@ require_relative "context_judge"
55
56
  require_relative "objectives_audit"
56
57
  require_relative "owner_prompt"
57
58
  require_relative "side_judgments"
59
+ require_relative "system_one/recorder"
58
60
  require_relative "toolkit_suggest"
59
61
  require_relative "turn_stages/context_judge"
60
62
  require_relative "turn_stages/objectives"
@@ -228,7 +230,9 @@ module Samagotchi
228
230
  @chat_backend_mutex = Mutex.new
229
231
  @side_judgments = SideJudgments.new
230
232
  @judgments = build_judgments
231
- @host_registry = host_registry || HostRegistry.new
233
+ # Taken once: a running worker keeps the hosts it started with. A
234
+ # client (specs) goes on a registry of its own, never the shared one.
235
+ @host_registry = host_registry || (client ? HostRegistry.new : HostRegistry.current)
232
236
  # The turn stages over the objectives (its audit, view and proposals)
233
237
  # and the Verify/Revise anchors (TurnStages).
234
238
  @objectives_stage = build_objectives_stage
@@ -451,6 +455,9 @@ module Samagotchi
451
455
  self.guardrail_state_dir = Session.default_state_dir
452
456
  # The turn-end warm-up; the kernel asks it for a slot pin per request.
453
457
  @prompt_warmup = PromptWarmup.new
458
+ # One re-prefill baseline for both loops (the native kernel's and the
459
+ # chat loop's requests), reset by the warm-up (KernelLoop#warmed_up!).
460
+ @reprefill = ReprefillMeter.new
454
461
  services = KernelLoop::Services.new(
455
462
  hooks: @hooks, tools: @tools, reminder_store: @reminder_store, muted_memory_names: @muted_memory_names,
456
463
  # ask_user_question blocks on the Engine's question flow (TUI/Web answer it).
@@ -466,8 +473,8 @@ module Samagotchi
466
473
  )
467
474
  # A given kernel (specs) gets the same services as the one built here.
468
475
  @kernel = kernel || KernelLoop.new(client: @client, profile: @model_binding.given_profile,
469
- model_name: effective_model_name, services: services)
470
- @kernel.attach(services) if kernel
476
+ model_name: effective_model_name, services: services, reprefill: @reprefill)
477
+ @kernel.attach(services, reprefill: @reprefill) if kernel
471
478
  sync_kernel_client!
472
479
  sync_model_key!
473
480
  # What a hook can do beyond reading its event (event[:notify],
@@ -713,7 +720,10 @@ module Samagotchi
713
720
  # (its limit goes in last_turn). The end event names its turn
714
721
  # (`turn_id:`, as :turn_started does) and, for a Verify turn,
715
722
  # `verify: true` (its opening's stamp: a page watching it live offers
716
- # no Revise on its answer, verdict or not, as a reload does).
723
+ # no Revise on its answer, verdict or not, as a reload does). When the
724
+ # turn keeps messages, the event also names its own range in them,
725
+ # `kept[kept_from...kept_to]` (display-side: Bridge::TurnDrawings
726
+ # reads it; no message gains a key).
717
727
  def end_turn(turn, outcome, save: false, exhausted: false)
718
728
  seconds = turn.elapsed
719
729
  turn.outcome = outcome
@@ -722,12 +732,13 @@ module Samagotchi
722
732
  event[:turn_id] = turn.id
723
733
  event[:verify] = true if turn.verify
724
734
  revises = @verify_anchors.stamp_opening(kept, turn, outcome)
735
+ kept_range!(event, turn, kept)
725
736
  replace_session_messages(turn.session, kept) if kept
726
737
  verdict = @verify_anchors.record_verdict(turn, outcome)
727
738
  event[:verdict] = verdict if verdict
728
739
  event[:revises] = revises.to_h if revises
729
740
  emit_working_set(turn.on_event, turn.session, turn.session.messages)
730
- @objectives_stage.announce(turn.session)
741
+ announce_objectives(turn.session)
731
742
  turn.session.status = Session::STATUS_IDLE
732
743
  record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
733
744
  emit_event(turn.on_event, turn.tag(event))
@@ -739,6 +750,20 @@ module Samagotchi
739
750
  end
740
751
  private :end_turn
741
752
 
753
+ # The end event's kept_from/kept_to (#end_turn): only when the turn
754
+ # keeps messages and its start is known (#turn_messages ran). An ending
755
+ # that replaces an earlier turn's note at the history's tail
756
+ # (TurnNote.replace_trailing on a Continue stopped before its first step)
757
+ # leaves only system messages and its own note after it, so the range
758
+ # still starts at or before the turn's note and draws nothing else.
759
+ def kept_range!(event, turn, kept)
760
+ return unless kept && turn.history_size
761
+
762
+ event[:kept_from] = turn.history_size
763
+ event[:kept_to] = kept.size
764
+ end
765
+ private :kept_range!
766
+
742
767
  # What a notice may stand in for (hook_notify's fallback_for:).
743
768
  NOTICE_FALLBACKS = HookRoutes::NOTICE_FALLBACKS
744
769
 
@@ -801,8 +826,11 @@ module Samagotchi
801
826
  # +wall+: the wall time it started (MessageTime.now, once): the
802
827
  # :turn_started event's at and its user message's (and reminders') stamp.
803
828
  # +verify+, +revises+: a Verify turn's claim, a Revise turn's draft.
829
+ # +history_size+: turn.messages' size under the system head, before
830
+ # the reminders and the prompt (#turn_messages): where the turn's own
831
+ # messages start in what it keeps (the end event's kept_from).
804
832
  Turn = Struct.new(:context, :prompt, :continue, :on_event, :started_at, :messages, :ended, :prepared_settings,
805
- :limit, :resumed, :resume_from, :wall, :verify, :revises, :outcome) do
833
+ :limit, :resumed, :resume_from, :wall, :verify, :revises, :outcome, :history_size) do
806
834
  # The turn's id (:turn_started's turn_id, its metrics record's id and
807
835
  # its saved prompt's), session, origin and controller: its context's.
808
836
  def id = context.id
@@ -911,7 +939,7 @@ module Samagotchi
911
939
  pending_input: pending_input)
912
940
  publish_used_memories(session, on_event)
913
941
  complete_turn(turn, result)
914
- @turn_warmup.after(turn, result)
942
+ @turn_warmup.after(turn, result, next_turn_waiting: next_turn_waiting)
915
943
  result
916
944
  rescue Interrupt => e
917
945
  # After the end event (in the after_turn/session_end hooks) the
@@ -941,7 +969,7 @@ module Samagotchi
941
969
  @context_judge.show_cards_after_turn
942
970
  # Only a completed turn shows its proposal cards; any other's are
943
971
  # dropped, so the next turn does not show them either.
944
- @objectives_stage.finally(turn)
972
+ show_objective_proposals(turn)
945
973
  end
946
974
  end
947
975
 
@@ -954,7 +982,7 @@ module Samagotchi
954
982
  # A participant's Verify fresh child: every turn is theirs (their
955
983
  # author, boundary and cap), whoever started it.
956
984
  origin = session.verify_fresh.origin_for(origin) if session.respond_to?(:verify_fresh) && session.verify_fresh
957
- verify = @verify_anchors.verified(session, VerifyAnchor.from(verify))
985
+ verify = @verify_anchors.verified(session, VerifyAnchor.from(verify), state_dir: session_state_dir)
958
986
  # Track the active session for recap and status snapshot.
959
987
  @session = session
960
988
  # status is turn state: running now, idle again before the turn's end
@@ -1044,7 +1072,7 @@ module Samagotchi
1044
1072
  # On mode's ask starts here too; the turn joins it before
1045
1073
  # apply_toolkit! below, so its latency hides behind the probes and
1046
1074
  # the init wait.
1047
- @toolkit_suggestion.start(turn)
1075
+ @toolkit_suggestion.start(turn, recorder: system_one_recorder)
1048
1076
 
1049
1077
  # The window and the profile probes run after run_turn's probe-cancel
1050
1078
  # swap, so a Stop cuts them; a failure ends the turn as turn_failed.
@@ -1081,8 +1109,8 @@ module Samagotchi
1081
1109
  @toolkit_suggestion.settle(turn)
1082
1110
  # The candidate judge: its asks run beside the turn; on mode's
1083
1111
  # attaches reach the model at the next between-turn delivery.
1084
- @context_judge.start(turn)
1085
- @objectives_stage.incoming(turn)
1112
+ @context_judge.start(turn, recorder: system_one_recorder)
1113
+ @objectives_stage.incoming(turn, recorder: system_one_recorder)
1086
1114
  apply_toolkit!(session, turn.on_event)
1087
1115
  refresh_participants!(session)
1088
1116
  # A Stop during the waits above: the turn ends here,
@@ -1119,6 +1147,9 @@ module Samagotchi
1119
1147
  # Built once per Engine (and again after a model switch) so the prompt
1120
1148
  # prefix, and the model server's KV cache for it, stay stable.
1121
1149
  turn.messages = ContextNote.with_system_head(turn.messages, { role: "system", content: system_prompt })
1150
+ # Where the turn's own messages start (the end event's kept_from):
1151
+ # after the head this may have added, before the reminders and prompt.
1152
+ turn.history_size = turn.messages.size
1122
1153
  # Explicit --memory preloads are now known after system prompt build.
1123
1154
  @used_memories.add(activated_memory_names).absorb(session)
1124
1155
 
@@ -13,8 +13,9 @@ module Samagotchi
13
13
  # (#kept_turn?).
14
14
  TURN_KEPT = %i[turn_completed turn_canceled].freeze
15
15
  # The kinds of a running turn's parts (Bridge::TurnAccumulator), which a
16
- # joining UI replays as live events.
17
- PART_KINDS = %w[generation thinking text tool input steer reminder notice].freeze
16
+ # joining UI replays as live events (a card part only in a finished
17
+ # turn's drawing: a join takes its cards from CardStore).
18
+ PART_KINDS = %w[generation thinking text tool input steer reminder notice card].freeze
18
19
  # Types no Engine emits that a UI handles: the Bridge's stream frames
19
20
  # and the web's one synthetic replay event (a prompt merged into the
20
21
  # turn, turn_events.js snapshotEvents).
@@ -49,12 +49,15 @@ module Samagotchi
49
49
  end
50
50
 
51
51
  # Load the installed bundles' plugins into +registries+; one that fails
52
- # goes to +failures+, and the rest still load. The block runs after them,
53
- # still loading: what it shows is held with theirs.
52
+ # goes to +failures+, a required one (a guardrail plugin) to the hook
53
+ # failures, where it denies every tool call, and the rest still load.
54
+ # The block runs after them, still loading: what it shows is held with
55
+ # theirs.
54
56
  def load_plugins(registries, failures:)
55
57
  @held_events = []
56
58
  @loading = true
57
- Plugin::Loader.load_installed(registries, failures: failures, settings: bundle_settings).tap { yield if block_given? }
59
+ Plugin::Loader.load_installed(registries, failures: failures, guardrail_failures: @hook_failures, settings: bundle_settings)
60
+ .tap { yield if block_given? }
58
61
  ensure
59
62
  @loading = false
60
63
  end
@@ -113,8 +113,10 @@ module Samagotchi
113
113
  # @param model_name [String, nil] the bare model name, for a rule's models:
114
114
  # @param registry [Tools::Registry, nil] the session's tools: a plugin
115
115
  # tool's entry says what it acts on (its targets:)
116
- # @param surface [Tools::ToolSurface, nil] the turn's: call_tool's
117
- # targets name what the turn reaches (nil: the Registry now)
116
+ # @param surface [Tools::ToolSurface, nil] the turn's: the entry is
117
+ # looked up in it (.entry_of) and call_tool's targets name what it
118
+ # reaches (nil: the registry's entry; call_tool's then raises
119
+ # Tools::MissingSurface)
118
120
  def self.for(call, context, model_key: nil, model_name: nil, registry: nil, surface: nil)
119
121
  tool = call[:name].to_s
120
122
  base = context.cwd
@@ -143,7 +145,7 @@ module Samagotchi
143
145
  given_cwd = given[:cwd].to_s.strip
144
146
  cwd = File.expand_path(given_cwd, base) unless given_cwd.empty?
145
147
  paths.concat(given[:paths].flat_map { |path| absolutes(path, cwd) })
146
- entry = registry && registry[tool]
148
+ entry = entry_of(tool, registry, surface)
147
149
  args = given[:args] || call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
148
150
  acts_as = given[:acts_as]
149
151
  label = given[:label]
@@ -164,9 +166,12 @@ module Samagotchi
164
166
  # logged). args: counts when it is a Hash; acts_as: is dropped (and
165
167
  # logged) when it names a core tool or another bundle's tool, so a
166
168
  # call can't pass for one and match (or reuse the approvals of) its
167
- # rules. label: counts when it is one short line (.label_of).
169
+ # rules. label: counts when it is one short line (.label_of). A
170
+ # missing surface (Tools::MissingSurface) is raised, never taken as
171
+ # nothing: call_tool's acts_as would go missing, and with it the
172
+ # mcp_* rules on the call (fail-open).
168
173
  def self.plugin_targets(call, registry, surface = nil)
169
- entry = registry && registry[call[:name].to_s]
174
+ entry = entry_of(call[:name], registry, surface)
170
175
  none = { command: nil, paths: [], cwd: nil, acts_as: nil, args: nil, label: nil }
171
176
  return none unless entry && !entry.core? && entry.targets
172
177
 
@@ -176,8 +181,10 @@ module Samagotchi
176
181
  given = given.transform_keys(&:to_sym)
177
182
  command = given[:command].to_s
178
183
  { command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), cwd: given[:cwd],
179
- acts_as: acts_as_of(given[:acts_as], entry, registry), args: given[:args].is_a?(Hash) ? given[:args] : nil,
184
+ acts_as: acts_as_of(given[:acts_as], entry, registry, surface), args: given[:args].is_a?(Hash) ? given[:args] : nil,
180
185
  label: label_of(given[:label], entry) }
186
+ rescue Tools::MissingSurface
187
+ raise
181
188
  rescue StandardError => e
182
189
  Log.warn(:plugins, "plugin_targets_failed", tool: call[:name].to_s, error: e.class.name,
183
190
  msg: "#{call[:name]} targets failed: #{e.message}")
@@ -192,24 +199,32 @@ module Samagotchi
192
199
  # name, ToolIndex#claimed), so the registry is the wrong owner test,
193
200
  # and a bundle tool sharing an MCP tool's name would otherwise take
194
201
  # call_tool calls of that tool out of its mcp_* rules.
195
- def self.acts_as_of(name, entry, registry)
202
+ def self.acts_as_of(name, entry, registry, surface = nil)
196
203
  name = name.to_s.strip
197
204
  return nil if name.empty? || name == entry.name
198
205
 
199
- other = registry[name]
206
+ other = entry_of(name, registry, surface)
200
207
  whose = if CORE_TOOLS.include?(name) || other&.core? then "a core tool"
201
208
  elsif other && other.source != entry.source && entry.source != Tools::ToolIndex::SOURCE
202
209
  "bundle #{other.source}'s tool"
203
210
  end
204
211
  return name unless whose
205
212
 
206
- if first_drop?(registry, entry.name, name)
213
+ if first_drop?(registry || surface, entry.name, name)
207
214
  Log.warn(:plugins, "plugin_acts_as_dropped", tool: entry.name, acts_as: name,
208
215
  msg: "#{entry.name} targets: acts_as #{name} dropped: it is #{whose}")
209
216
  end
210
217
  nil
211
218
  end
212
219
 
220
+ # The tool +name+ names: the turn's entry (+surface+'s registered),
221
+ # else +registry+'s (a gate without a turn's surface); nil for none.
222
+ # @return [Tools::Registry::Entry, nil]
223
+ def self.entry_of(name, registry, surface)
224
+ name = name.to_s
225
+ surface ? surface.registered[name] : registry&.[](name)
226
+ end
227
+
213
228
  # The drops logged, per registry (a session's): each tool's acts_as
214
229
  # once, not at every call (targets run several times a call).
215
230
  DROPS_LOGGED = ObjectSpace::WeakKeyMap.new
@@ -190,7 +190,7 @@ module Samagotchi
190
190
  threshold = threshold(options[:threshold])
191
191
  return usage_error(threshold) if threshold.is_a?(String)
192
192
 
193
- client = @client || SystemOne::Client.from_config(host_registry: HostRegistry.new, mode: Config.get("guardrails.system_one.mode"), model: options[:model])
193
+ client = @client || SystemOne::Client.from_config(host_registry: HostRegistry.current, mode: Config.get("guardrails.system_one.mode"), model: options[:model])
194
194
  if client.nil?
195
195
  error_line("chi guardrails replay: no System 1 client — set guardrails.system_one.mode to audit or on " \
196
196
  "and system_one.host in config.yml")
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require_relative "config"
5
+
6
+ module Samagotchi
7
+ # What a host's address says about it, without asking it: whether it is
8
+ # remote (a provider on the network rather than a local server), and the
9
+ # configured first-token limit. A leaf: HostRegistry::HostEntry and
10
+ # BootstrapCommand read it, it reads only Config.
11
+ module HostAddress
12
+ # Loopback, private (RFC 1918, IPv6 unique local) and link-local nets.
13
+ LOCAL_NETS = %w[127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10
14
+ ::1/128 fc00::/7 fe80::/10].map { |net| IPAddr.new(net) }.freeze
15
+
16
+ # Name suffixes that stay on a home or office network: mDNS, the
17
+ # special-use and customary private names, router defaults (fritz.box)
18
+ # and Tailscale's MagicDNS. Compared on whole labels.
19
+ LOCAL_NAME_SUFFIXES = %w[localhost local lan home home.arpa internal intranet localdomain private corp test
20
+ box ts.net].freeze
21
+
22
+ # Whether an address is remote: https, an http IP address outside the
23
+ # local nets (LOCAL_NETS), or an http name with a dot whose suffix isn't
24
+ # a local one (LOCAL_NAME_SUFFIXES): `gpu.example.com` is remote, while
25
+ # `box`, `mac.local`, `gpu.lan` and `pc.tail1234.ts.net` are local. chi
26
+ # doesn't look names up; hosts.<name>.remote overrides either way.
27
+ def self.remote_address?(scheme, host)
28
+ return true if scheme.to_s == "https"
29
+
30
+ address = IPAddr.new(host.to_s.delete_prefix("[").delete_suffix("]"))
31
+ address = address.native if address.ipv4_mapped?
32
+ LOCAL_NETS.none? { |net| net.family == address.family && net.include?(address) }
33
+ rescue IPAddr::Error
34
+ remote_name?(host)
35
+ end
36
+
37
+ def self.remote_name?(host)
38
+ name = host.to_s.downcase.delete_suffix(".")
39
+ return false unless name.include?(".")
40
+
41
+ LOCAL_NAME_SUFFIXES.none? { |suffix| name == suffix || name.end_with?(".#{suffix}") }
42
+ end
43
+ private_class_method :remote_name?
44
+
45
+ # server.first_token_timeout, or nil when unset or unreadable.
46
+ def self.configured_first_token_timeout
47
+ Config.get("server.first_token_timeout")
48
+ rescue StandardError # degrade-ok: no configured limit; the host's own or the remote default applies
49
+ nil
50
+ end
51
+ end
52
+ end
@@ -8,7 +8,7 @@ require_relative "llm/dialect"
8
8
  require_relative "reasoning_history"
9
9
 
10
10
  module Samagotchi
11
- # One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
11
+ # One config.yml hosts: entry, parsed and checked (HostConfig.all
12
12
  # holds them by lowercased name; HostRegistry builds its HostEntry from
13
13
  # one).
14
14
  #
@@ -28,6 +28,77 @@ module Samagotchi
28
28
  super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
29
29
  end
30
30
 
31
+ # config.yml's hosts: by lowercased name; an entry that is disabled or
32
+ # invalid (warned) is left out. No entry left: "default", from
33
+ # server.host/port.
34
+ # @return [Hash{String => HostConfig}]
35
+ def self.all(env: ENV, path: ConfigFile.global_path(env: env))
36
+ data = ConfigFile.read_yaml(env: env, path: path)
37
+ raw_hosts = data[ConfigFile::HOSTS_KEY] if data.is_a?(Hash)
38
+
39
+ normalized = {}
40
+ if raw_hosts.is_a?(Hash)
41
+ raw_hosts.each do |raw_name, raw_cfg|
42
+ host = parse(raw_name, raw_cfg)
43
+ normalized[host.name] = host if host
44
+ end
45
+ warn_duplicate_models(normalized)
46
+ end
47
+ normalized["default"] = default_host(data, env) if normalized.empty?
48
+ normalized
49
+ rescue StandardError => e
50
+ Log.error(:config, "hosts_config_failed", error: e.class.name, message: e.message)
51
+ {}
52
+ end
53
+
54
+ # The names (lowercased) of the hosts entries with enabled: false, which
55
+ # .all leaves out, from config.yml's hosts:.
56
+ # @return [Array<String>]
57
+ def self.disabled_names(env: ENV, path: ConfigFile.global_path(env: env))
58
+ data = ConfigFile.read_yaml(env: env, path: path)
59
+ raw = data[ConfigFile::HOSTS_KEY] if data.is_a?(Hash)
60
+ return [] unless raw.is_a?(Hash)
61
+
62
+ raw.filter_map { |name, cfg| name.to_s.strip.downcase if cfg.is_a?(Hash) && disabled?(cfg) }
63
+ rescue StandardError # degrade-ok: Config↔Log: Log resolves its file and level through Config
64
+ []
65
+ end
66
+
67
+ # +raw+ parsed against config.yml's hosts (or +hosts+) and aliases.
68
+ # @return [ModelRef]
69
+ def self.model_ref(raw, env: ENV, path: ConfigFile.global_path(env: env), hosts: nil)
70
+ ModelRef.parse(raw, hosts: hosts || all(env: env, path: path), aliases: ConfigFile.model_aliases(env: env, path: path))
71
+ end
72
+
73
+ # The host chi uses when hosts: names none: server.host/port.
74
+ def self.default_host(data, env)
75
+ opts = { file_data: data, env: env, cli_overrides: Config.cli_overrides }
76
+ host = Config.resolve("server.host", **opts).to_s.strip
77
+ host = "localhost" if host.empty?
78
+ port = Config.resolve("server.port", **opts).to_i
79
+ port = 8080 if port <= 0 || port > 65_535
80
+ new(name: "default", host: host, port: port)
81
+ end
82
+ private_class_method :default_host
83
+
84
+ # Two hosts declaring one id under hosts.<name>.models: a bare id goes
85
+ # where HostRegistry#host_for_model sends it (the default host when it
86
+ # declares the id, else the first in hosts: order); say so once.
87
+ def self.warn_duplicate_models(hosts)
88
+ declared = Hash.new { |h, k| h[k] = [] }
89
+ hosts.each { |name, cfg| cfg.models.each { |down, model| declared[down] << [name, model.id] } }
90
+ declared.each_value do |owners|
91
+ next if owners.size < 2
92
+
93
+ names = owners.map(&:first)
94
+ id = owners.first.last
95
+ goes = names.include?("default") ? "default" : "#{names.first} unless the default host lists it"
96
+ ConfigFile.warn_once "Warning: #{names.map { |n| "hosts.#{n}.models" }.join(" and ")} #{names.size == 2 ? "both" : "all"} " \
97
+ "declare #{id}; a bare #{id} goes to #{goes}"
98
+ end
99
+ end
100
+ private_class_method :warn_duplicate_models
101
+
31
102
  # A hosts: entry as written in config.yml,
32
103
  # or nil when it is disabled (enabled: false) or invalid. An invalid
33
104
  # one warns once, naming the entry, the way each reader of a setting
@@ -67,12 +67,8 @@ module Samagotchi
67
67
  served == "any" || served.any? { |id| id.casecmp?(name.to_s.strip) }
68
68
  end
69
69
 
70
- # config.rb requires this file, so config is required here only when a
71
- # warning needs it (this file loaded alone).
72
- def self.warn_once(message)
73
- require_relative "config"
74
- ConfigFile.warn_once(message)
75
- end
70
+ # Config comes with model_price.
71
+ def self.warn_once(message) = ConfigFile.warn_once(message)
76
72
  private_class_method :warn_once
77
73
 
78
74
  # One host's rows as the listings show them, declared ids first, then
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ipaddr"
4
3
  require_relative "config"
4
+ require_relative "host_address"
5
5
  require_relative "log"
6
6
  require_relative "model_ref"
7
7
  require_relative "client"
@@ -88,11 +88,11 @@ module Samagotchi
88
88
  def dialect_profile = LLM::Dialect.for(dialect)
89
89
 
90
90
  # A provider on the network rather than a local server: hosts.<name>.remote
91
- # when set, else by its address (HostRegistry.remote_address?). An API
91
+ # when set, else by its address (HostAddress.remote_address?). An API
92
92
  # key doesn't decide: a llama.cpp on the LAN can have one. A remote
93
93
  # host's model list is cached longer, it gets a first-token limit, and
94
94
  # it isn't asked for llama.cpp's /props.
95
- def remote? = remote.nil? ? HostRegistry.remote_address?(scheme, host) : remote
95
+ def remote? = remote.nil? ? HostAddress.remote_address?(scheme, host) : remote
96
96
 
97
97
  def models_ttl = remote? ? REMOTE_CACHE_TTL_SECONDS : CACHE_TTL_SECONDS
98
98
 
@@ -102,7 +102,7 @@ module Samagotchi
102
102
  # and read_timeout catches a dead one). 0 turns it off.
103
103
  def first_token_limit
104
104
  seconds = first_token_timeout
105
- seconds = HostRegistry.configured_first_token_timeout if seconds.nil?
105
+ seconds = HostAddress.configured_first_token_timeout if seconds.nil?
106
106
  seconds = remote? ? REMOTE_FIRST_TOKEN_TIMEOUT : nil if seconds.nil?
107
107
  seconds&.positive? ? seconds : nil
108
108
  end
@@ -115,18 +115,66 @@ module Samagotchi
115
115
  def openai_base_url = entry.openai_base_url
116
116
  end
117
117
 
118
+ # Raised when a client override is set on the shared registry (.current):
119
+ # every command and chi web's routes would then send through it.
120
+ class Shared < StandardError
121
+ def initialize(msg = "HostRegistry.current is shared: set a client override on a registry of your own") = super
122
+ end
123
+
118
124
  # A client that every target uses instead of its host's own (specs inject
119
125
  # a stub this way; the Engine/TUI `client:` keyword sets it).
120
- attr_accessor :client_override
126
+ attr_reader :client_override
127
+
128
+ # @raise [Shared] on the shared registry (.current)
129
+ def client_override=(client)
130
+ raise Shared if @shared
131
+
132
+ @client_override = client
133
+ end
134
+
135
+ @current_mutex = Mutex.new
136
+
137
+ # The registry this process's commands and chi web share: one per
138
+ # process, built again when config.yml's hosts: no longer equal the ones
139
+ # it was built from (HostConfig.all, compared as read: the YAML read is
140
+ # mtime-cached), so a host added while chi web runs is listed on the next
141
+ # read, and its model lists, index and adapters are kept while hosts:
142
+ # stay. It doesn't rebuild on server.first_token_timeout or the
143
+ # server.host/port fallback entry: those are read at construction. It
144
+ # refuses a client override (Shared). An Engine and the TerminalUI take
145
+ # it once, at their start: a running worker keeps the hosts it started
146
+ # with.
147
+ # For any env but the process ENV (chi self's spec envs): a fresh
148
+ # registry on that env, never kept.
149
+ # @return [HostRegistry]
150
+ def self.current(env: ENV)
151
+ return new(env: env) unless env.equal?(ENV)
152
+
153
+ @current_mutex.synchronize do
154
+ hosts = HostConfig.all
155
+ if @current.nil? || hosts != @current_hosts
156
+ @current = new(hosts_config: hosts, shared: true)
157
+ @current_hosts = hosts
158
+ end
159
+ @current
160
+ end
161
+ end
162
+
163
+ # Forget the shared registry (specs: one per example).
164
+ def self.reset_current!
165
+ @current_mutex.synchronize { @current = @current_hosts = nil }
166
+ end
121
167
 
122
168
  # @param clock [#call, nil] monotonic seconds (specs)
123
- def initialize(hosts_config: nil, env: ENV, client_override: nil, clock: nil)
169
+ # @param shared [Boolean] .current's own: refuses a client override
170
+ def initialize(hosts_config: nil, env: ENV, client_override: nil, clock: nil, shared: false)
124
171
  @client_override = client_override
172
+ @shared = shared
125
173
  @env = env
126
174
  @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
127
175
  @adapters = {}
128
176
  @host_lists = {}
129
- raw = hosts_config || ConfigFile.hosts_config(env: env)
177
+ raw = hosts_config || HostConfig.all(env: env)
130
178
  @entries = {}
131
179
  raw.each do |key, cfg|
132
180
  cfg = HostConfig.coerce(key, cfg)
@@ -155,45 +203,9 @@ module Samagotchi
155
203
 
156
204
  attr_reader :entries
157
205
 
158
- # Loopback, private (RFC 1918, IPv6 unique local) and link-local nets.
159
- LOCAL_NETS = %w[127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10
160
- ::1/128 fc00::/7 fe80::/10].map { |net| IPAddr.new(net) }.freeze
161
-
162
- # Name suffixes that stay on a home or office network: mDNS, the
163
- # special-use and customary private names, router defaults (fritz.box)
164
- # and Tailscale's MagicDNS. Compared on whole labels.
165
- LOCAL_NAME_SUFFIXES = %w[localhost local lan home home.arpa internal intranet localdomain private corp test
166
- box ts.net].freeze
167
-
168
- # Whether an address is remote: https, an http IP address outside the
169
- # local nets (LOCAL_NETS), or an http name with a dot whose suffix isn't
170
- # a local one (LOCAL_NAME_SUFFIXES): `gpu.example.com` is remote, while
171
- # `box`, `mac.local`, `gpu.lan` and `pc.tail1234.ts.net` are local. chi
172
- # doesn't look names up; hosts.<name>.remote overrides either way.
173
- def self.remote_address?(scheme, host)
174
- return true if scheme.to_s == "https"
175
-
176
- address = IPAddr.new(host.to_s.delete_prefix("[").delete_suffix("]"))
177
- address = address.native if address.ipv4_mapped?
178
- LOCAL_NETS.none? { |net| net.family == address.family && net.include?(address) }
179
- rescue IPAddr::Error
180
- remote_name?(host)
181
- end
182
-
183
- def self.remote_name?(host)
184
- name = host.to_s.downcase.delete_suffix(".")
185
- return false unless name.include?(".")
186
-
187
- LOCAL_NAME_SUFFIXES.none? { |suffix| name == suffix || name.end_with?(".#{suffix}") }
188
- end
189
- private_class_method :remote_name?
190
-
191
- # server.first_token_timeout, or nil when unset or unreadable.
192
- def self.configured_first_token_timeout
193
- Config.get("server.first_token_timeout")
194
- rescue StandardError
195
- nil
196
- end
206
+ # The env its hosts, aliases and saved lists are read with (ENV, or chi
207
+ # self's): what an EffectiveModel's ModelLookup reads every layer with.
208
+ attr_reader :env
197
209
 
198
210
  def default_entry
199
211
  @entries["default"] || @entries.values.first