samagotchi 0.152.0 → 0.154.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 (229) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -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/bundle-authoring.md +1 -1
  8. data/docs/bundles.md +93 -12
  9. data/docs/cli.md +83 -99
  10. data/docs/configuration.md +46 -40
  11. data/docs/context.md +3 -3
  12. data/docs/desktop.md +1 -1
  13. data/docs/guardrails.md +15 -15
  14. data/docs/hooks.md +13 -12
  15. data/docs/internals/anthropic.md +3 -3
  16. data/docs/internals/attached-context.md +5 -2
  17. data/docs/internals/background-tasks.md +3 -4
  18. data/docs/internals/block-renderers.md +4 -0
  19. data/docs/internals/bridge.md +3 -3
  20. data/docs/internals/context-telemetry.md +1 -1
  21. data/docs/internals/engine.md +42 -12
  22. data/docs/internals/objectives.md +6 -5
  23. data/docs/internals/participants.md +8 -8
  24. data/docs/internals/prompt-caching.md +8 -4
  25. data/docs/internals/remote-sessions.md +7 -7
  26. data/docs/internals/web-frontend.md +20 -17
  27. data/docs/internals/web-push.md +1 -1
  28. data/docs/memory.md +2 -2
  29. data/docs/plugins.md +52 -43
  30. data/docs/releasing.md +1 -1
  31. data/docs/sessions.md +25 -25
  32. data/docs/sub-agent.md +2 -2
  33. data/docs/system-one.md +1 -1
  34. data/docs/testing.md +18 -8
  35. data/lib/samagotchi/answer_text.rb +34 -0
  36. data/lib/samagotchi/bridge.rb +43 -32
  37. data/lib/samagotchi/bridge_client.rb +10 -9
  38. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  39. data/lib/samagotchi/broadcast/triage.rb +1 -1
  40. data/lib/samagotchi/broadcast_command.rb +5 -5
  41. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  42. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  43. data/lib/samagotchi/bundle_command.rb +2 -2
  44. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  45. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  46. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  48. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  49. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  50. data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
  51. data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
  52. data/lib/samagotchi/child_move.rb +3 -3
  53. data/lib/samagotchi/commands/registry.rb +6 -6
  54. data/lib/samagotchi/composer_route.rb +4 -5
  55. data/lib/samagotchi/config.rb +14 -12
  56. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  57. data/lib/samagotchi/context_candidates.rb +4 -3
  58. data/lib/samagotchi/continue_offer.rb +1 -1
  59. data/lib/samagotchi/debug_log.rb +1 -1
  60. data/lib/samagotchi/degrade.rb +75 -0
  61. data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
  62. data/lib/samagotchi/engine/cards.rb +87 -0
  63. data/lib/samagotchi/engine/context_judge.rb +138 -0
  64. data/lib/samagotchi/engine/events.rb +131 -0
  65. data/lib/samagotchi/engine/guardrails.rb +87 -0
  66. data/lib/samagotchi/engine/hooks.rb +119 -0
  67. data/lib/samagotchi/engine/messages.rb +124 -0
  68. data/lib/samagotchi/engine/model_settings.rb +127 -0
  69. data/lib/samagotchi/engine/models.rb +261 -0
  70. data/lib/samagotchi/engine/objectives.rb +116 -0
  71. data/lib/samagotchi/engine/participants.rb +86 -0
  72. data/lib/samagotchi/engine/plugins.rb +208 -0
  73. data/lib/samagotchi/engine/questions.rb +98 -0
  74. data/lib/samagotchi/engine/recap.rb +130 -0
  75. data/lib/samagotchi/engine/session.rb +131 -0
  76. data/lib/samagotchi/engine/side_requests.rb +135 -0
  77. data/lib/samagotchi/engine/steering.rb +134 -0
  78. data/lib/samagotchi/engine/toolkit.rb +269 -0
  79. data/lib/samagotchi/engine/turn_end.rb +180 -0
  80. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  81. data/lib/samagotchi/engine/verify.rb +153 -0
  82. data/lib/samagotchi/engine.rb +63 -2642
  83. data/lib/samagotchi/grants.rb +575 -0
  84. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  85. data/lib/samagotchi/guardrails/context.rb +2 -3
  86. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  87. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  88. data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
  89. data/lib/samagotchi/hook_routes.rb +3 -3
  90. data/lib/samagotchi/idle_recap.rb +4 -5
  91. data/lib/samagotchi/idle_reminders.rb +3 -4
  92. data/lib/samagotchi/iteration_limit.rb +2 -2
  93. data/lib/samagotchi/kernel_loop.rb +10 -11
  94. data/lib/samagotchi/launch_mode.rb +13 -32
  95. data/lib/samagotchi/level_setting_command.rb +3 -2
  96. data/lib/samagotchi/llm_context_command.rb +3 -2
  97. data/lib/samagotchi/llm_context_stale.rb +2 -2
  98. data/lib/samagotchi/log.rb +7 -7
  99. data/lib/samagotchi/log_line.rb +1 -1
  100. data/lib/samagotchi/log_path.rb +1 -1
  101. data/lib/samagotchi/message_parts.rb +3 -2
  102. data/lib/samagotchi/note_command.rb +1 -1
  103. data/lib/samagotchi/note_delivery.rb +18 -8
  104. data/lib/samagotchi/objectives.rb +24 -3
  105. data/lib/samagotchi/objectives_check.rb +8 -27
  106. data/lib/samagotchi/objectives_command.rb +4 -69
  107. data/lib/samagotchi/objectives_edit.rb +87 -0
  108. data/lib/samagotchi/output_formatter.rb +7 -1
  109. data/lib/samagotchi/owner_lock.rb +7 -1
  110. data/lib/samagotchi/parent_answers_command.rb +5 -4
  111. data/lib/samagotchi/participant_exposure.rb +42 -0
  112. data/lib/samagotchi/participant_mode.rb +1 -1
  113. data/lib/samagotchi/participant_name.rb +1 -1
  114. data/lib/samagotchi/participants.rb +17 -17
  115. data/lib/samagotchi/participants_watch.rb +1 -1
  116. data/lib/samagotchi/pending_input_queue.rb +2 -2
  117. data/lib/samagotchi/plugin/api.rb +3 -2
  118. data/lib/samagotchi/plugin/context.rb +21 -7
  119. data/lib/samagotchi/plugin/peer.rb +2 -1
  120. data/lib/samagotchi/plugin/sessions.rb +6 -4
  121. data/lib/samagotchi/plugin_tasks.rb +1 -2
  122. data/lib/samagotchi/private_files.rb +29 -0
  123. data/lib/samagotchi/project_env.rb +2 -2
  124. data/lib/samagotchi/question_desk.rb +8 -7
  125. data/lib/samagotchi/recap_command.rb +2 -1
  126. data/lib/samagotchi/recap_setup.rb +1 -1
  127. data/lib/samagotchi/recap_store.rb +1 -1
  128. data/lib/samagotchi/redact.rb +86 -0
  129. data/lib/samagotchi/reminder_queue.rb +2 -2
  130. data/lib/samagotchi/remote_attach_command.rb +1 -1
  131. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  132. data/lib/samagotchi/review_start.rb +1 -1
  133. data/lib/samagotchi/run_options.rb +6 -6
  134. data/lib/samagotchi/self_report.rb +42 -39
  135. data/lib/samagotchi/send_command/busy.rb +1 -1
  136. data/lib/samagotchi/send_command.rb +4 -1
  137. data/lib/samagotchi/session.rb +11 -2
  138. data/lib/samagotchi/session_access/contract.rb +3 -3
  139. data/lib/samagotchi/session_access/local.rb +9 -9
  140. data/lib/samagotchi/session_access/remote.rb +17 -7
  141. data/lib/samagotchi/session_access.rb +4 -4
  142. data/lib/samagotchi/session_archive_command.rb +2 -2
  143. data/lib/samagotchi/session_chain.rb +8 -6
  144. data/lib/samagotchi/session_commands.rb +45 -48
  145. data/lib/samagotchi/session_continue.rb +1 -1
  146. data/lib/samagotchi/session_delete_command.rb +4 -4
  147. data/lib/samagotchi/session_manager.rb +59 -36
  148. data/lib/samagotchi/session_metrics.rb +2 -2
  149. data/lib/samagotchi/session_model_settings.rb +3 -2
  150. data/lib/samagotchi/session_participants.rb +1 -1
  151. data/lib/samagotchi/session_retention.rb +3 -3
  152. data/lib/samagotchi/sessions_command.rb +4 -4
  153. data/lib/samagotchi/share_command.rb +10 -10
  154. data/lib/samagotchi/steer.rb +1 -1
  155. data/lib/samagotchi/system_prompt.rb +102 -109
  156. data/lib/samagotchi/task_watch.rb +1 -1
  157. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  158. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  159. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  160. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  161. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  162. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
  163. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  164. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  165. data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
  166. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  167. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  168. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  169. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  170. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  171. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  172. data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
  173. data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
  174. data/lib/samagotchi/terminal_ui.rb +142 -1387
  175. data/lib/samagotchi/title_command.rb +2 -2
  176. data/lib/samagotchi/tool_runner.rb +14 -16
  177. data/lib/samagotchi/toolkit.rb +8 -5
  178. data/lib/samagotchi/toolkit_binding.rb +30 -5
  179. data/lib/samagotchi/toolkit_command.rb +2 -1
  180. data/lib/samagotchi/toolkit_report_command.rb +9 -5
  181. data/lib/samagotchi/toolkit_suggest.rb +12 -6
  182. data/lib/samagotchi/tools/builtin_calls.rb +14 -10
  183. data/lib/samagotchi/tools/deferred_tools.rb +2 -2
  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/registry.rb +44 -20
  189. data/lib/samagotchi/tools/send_note.rb +2 -1
  190. data/lib/samagotchi/tools/tool_index.rb +27 -21
  191. data/lib/samagotchi/turn_endings.rb +2 -2
  192. data/lib/samagotchi/turn_flow.rb +4 -5
  193. data/lib/samagotchi/update_hint.rb +2 -2
  194. data/lib/samagotchi/version.rb +1 -1
  195. data/lib/samagotchi/web/app.rb +39 -50
  196. data/lib/samagotchi/web/participant_access.rb +2 -2
  197. data/lib/samagotchi/web/participant_review.rb +5 -5
  198. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  199. data/lib/samagotchi/web/participant_view.rb +2 -82
  200. data/lib/samagotchi/web/participants_admin.rb +1 -1
  201. data/lib/samagotchi/web/public/app.js +61 -357
  202. data/lib/samagotchi/web/public/block_host.js +35 -8
  203. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  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 +110 -0
  207. data/lib/samagotchi/web/public/format.js +3 -3
  208. data/lib/samagotchi/web/public/owner_claim.js +81 -0
  209. data/lib/samagotchi/web/public/owner_verify.js +112 -0
  210. data/lib/samagotchi/web/public/session_fields.js +3 -3
  211. data/lib/samagotchi/web/public/strip_grip.js +3 -3
  212. data/lib/samagotchi/web/public/strip_hidden.js +65 -0
  213. data/lib/samagotchi/web/public/version_notices.js +132 -0
  214. data/lib/samagotchi/web/push/store.rb +1 -1
  215. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  216. data/lib/samagotchi/web/session_control.rb +12 -8
  217. data/lib/samagotchi/web/session_create.rb +1 -1
  218. data/lib/samagotchi/web/session_hub.rb +2 -2
  219. data/lib/samagotchi/web/session_show.rb +2 -2
  220. data/lib/samagotchi/web/session_summary.rb +1 -1
  221. data/lib/samagotchi/worker.rb +106 -9
  222. data/lib/samagotchi/worker_link.rb +3 -3
  223. data/lib/samagotchi/working_set_command.rb +3 -2
  224. metadata +36 -6
  225. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  226. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  227. data/lib/samagotchi/web/grants.rb +0 -575
  228. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  229. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The session's toolkit: the kits and what a turn declares
6
+ # (ToolkitBinding, forwarded), ctx.load_tools and the staged loads a turn
7
+ # appends, and System 1's toolkit suggestion (start, settle, warm-up).
8
+ # Writes @toolkit_warmup_thread; reads @toolkit_binding, @tools,
9
+ # @tool_index, @tool_loads, @side_judgments, @session.
10
+ module ToolkitPart
11
+ private
12
+
13
+ # The session's toolkit (ToolkitBinding): the kits, what its next turn
14
+ # declares, ctx.use_toolkit and the turn-start apply.
15
+ def toolkit_kits(errors = []) = @toolkit_binding.kits(errors)
16
+ def resolved_toolkit(session, errors = []) = @toolkit_binding.resolved(session, errors)
17
+ def toolkit_explained(toolkit = nil) = @toolkit_binding.explained(toolkit)
18
+ def toolkit_summary = @toolkit_binding.summary
19
+
20
+ # The tools this session registered (Tools::Registry), declared or not.
21
+ attr_reader :tools
22
+
23
+ public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
24
+
25
+ # A plugin's ctx.load_tools: stage the reachable tools +names+ names
26
+ # (Tools::ToolIndex#deferred_name) that the session neither declares
27
+ # nor loaded with an earlier tool change, for bundle +bundle+. A name
28
+ # that names no tool at all is logged; a declared or removed one is
29
+ # skipped quietly.
30
+ # @return [Array<String>] the wire names staged
31
+ def load_tools(names, bundle)
32
+ added = ToolLoads.added(@session&.messages)
33
+ wanted = names.filter_map do |name|
34
+ wire = @tool_index.deferred_name(name)
35
+ Log.info(:plugins, "load_tools_skipped", bundle: bundle, tool: name) unless wire || @tools.key?(name)
36
+ wire
37
+ end
38
+ @tool_loads.stage(wanted - added, by: bundle)
39
+ end
40
+ public :load_tools
41
+
42
+ # The staged tool loads, appended to +turn+'s messages (after its user
43
+ # message, or the trailing results and notes of a turn with none) as
44
+ # one ToolLoads entry per bundle, when the turn's host takes tool
45
+ # changes (ChatLoop#tool_changes?). Elsewhere they are dropped, logged.
46
+ # A conversation that ends with a reply keeps them staged (a system
47
+ # change must follow a user turn).
48
+ def append_tool_loads(turn)
49
+ return unless @tool_loads.any?
50
+ return if turn.messages.last&.dig(:role).to_s == "model"
51
+
52
+ model = effective_model
53
+ host = backend(model)
54
+ staged = @tool_loads.take
55
+ added = ToolLoads.added(turn.messages)
56
+ unless host.respond_to?(:tool_changes?) && host.tool_changes?(bare_model_name(model.name))
57
+ Log.info(:plugins, "tool_loads_not_sent", tools: staged.values.flatten.join(","), model: model.name.to_s)
58
+ return
59
+ end
60
+
61
+ staged.each do |bundle, names|
62
+ names -= added
63
+ next if names.empty?
64
+
65
+ turn.messages << ToolLoads.entry(names, by: bundle)
66
+ hook_notify("tools loaded: #{names.join(", ")}", :info, bundle)
67
+ end
68
+ end
69
+ private :append_tool_loads
70
+
71
+ # A plugin's ctx.use_toolkit (ToolkitBinding#use).
72
+ # @return [Boolean] whether it was staged
73
+ # @raise [ArgumentError] an unknown toolkit
74
+ def use_toolkit(name) = @toolkit_binding.use(name)
75
+ public :use_toolkit
76
+
77
+ # At a turn's start (ToolkitBinding#apply!).
78
+ # @return [Boolean] whether the declared tools changed
79
+ def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
80
+ private :apply_toolkit!
81
+
82
+ # Start the toolkit classifier after :turn_started, for audit and on
83
+ # mode alike (one eligibility, one snapshot, one thread). All session
84
+ # and tool-registry reads happen here; the thread receives only this
85
+ # snapshot. Audit never blocks: the thread logs its line. On mode
86
+ # returns the ask's Pending, which the turn joins before apply_toolkit!
87
+ # (#settle_toolkit_suggestion), with the turn's controller as the
88
+ # request's cancel controller.
89
+ # @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
90
+ def start_toolkit_suggestion(turn)
91
+ mode = Config.get("toolkit.suggest.mode").to_s
92
+ return if mode == "off"
93
+
94
+ joined = mode == "on"
95
+ session = turn.session
96
+ default = Toolkit.of(session.toolkit)
97
+ log = toolkit_suggestion_log
98
+ # The predicate checks the session first; the kits (memory kits are
99
+ # read from disk) and the log (read whole) only when it gets there.
100
+ kits = nil
101
+ candidates = nil
102
+ return unless ToolkitSuggest.eligible?(
103
+ session: session, prompt: turn.prompt, author: turn.origin.author,
104
+ candidate_count: lambda {
105
+ kits = @toolkit_binding.kits
106
+ candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
107
+ candidates.size
108
+ },
109
+ last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
110
+ defer_turns: Config.get("toolkit.suggest.defer_turns"),
111
+ defer_tokens: Config.get("toolkit.suggest.defer_tokens")
112
+ )
113
+
114
+ # One ask at a time: an eligible turn while the last one still runs
115
+ # goes unasked, and says so.
116
+ if @side_judgments.in_flight?(:toolkit)
117
+ Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
118
+ return
119
+ end
120
+
121
+ client = system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit),
122
+ ToolkitSuggest::JUDGMENT, session, mode: Config.get("toolkit.suggest.mode").to_s)
123
+ return unless client
124
+
125
+ # The warm-up's only value is the cache: the real ask never queues
126
+ # behind it.
127
+ stop_toolkit_warmup
128
+
129
+ # Rules, not tool lists: this runs before await_init_tasks and
130
+ # apply_staged_tools!, so a first turn's plugin/MCP tools aren't
131
+ # registered yet; the report expands the rules (ToolkitSuggest.rules).
132
+ snapshots = candidates.map do |kit|
133
+ ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
134
+ rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
135
+ end.freeze
136
+ default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
137
+ rules: ToolkitSuggest.rules(default.resolve(kits)))
138
+ registered = @tools.entries(toolkit: nil).reject { |entry| entry.source == Tools::ToolIndex::SOURCE }
139
+ core_tools, plugin_tools = registered.partition(&:core?).map { |entries| entries.map { |entry| entry.name.dup.freeze }.freeze }
140
+ state = ToolkitSuggest.question(
141
+ default: default, kits: kits, project: File.basename(session.project_root.to_s),
142
+ prompt: turn.prompt
143
+ ).last
144
+ snapshot = ToolkitSuggest::Snapshot.new(
145
+ session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
146
+ default: default_snapshot, candidates: snapshots, core_tools: core_tools, plugin_tools: plugin_tools,
147
+ state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
148
+ )
149
+ threshold = Config.get("toolkit.suggest.threshold")
150
+ runs = session.scratch ? nil : system_one_runs
151
+ pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
152
+ acted: runs && ->(judged, did) { runs.acted(judged.fields[:run_id], did) },
153
+ timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
154
+ # On mode: cut by a Stop while the turn waits (Pending#wait), not
155
+ # by one after it went on; audit's ask is never cut.
156
+ controller = joined ? pending.cancel_controller : nil
157
+ @side_judgments.start(:toolkit, warn: ToolkitSuggest.method(:warn), finish: pending.method(:finish)) do
158
+ ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
159
+ cancel_controller: controller)
160
+ end
161
+ joined ? pending : nil
162
+ rescue StandardError => e
163
+ ToolkitSuggest.warn(e)
164
+ nil
165
+ end
166
+ private :start_toolkit_suggestion
167
+
168
+ # On mode, before apply_toolkit!: wait for the ask (#start_toolkit_
169
+ # suggestion) up to System 1's timeout from its start, or until a Stop,
170
+ # and put the session on a candidate picked with confidence ≥ threshold
171
+ # (ToolkitBinding#suggest: origin suggested, saved, the notice). Any
172
+ # other outcome (the default, below threshold, undecided, no judgment,
173
+ # a timeout, a Stop) leaves the session on its default. Nothing here
174
+ # sends a request; prepare_turn's cancel check ends a stopped turn. A
175
+ # Stop wins over an answer already in (Pending#wait checks it first).
176
+ def settle_toolkit_suggestion(turn, pending)
177
+ return if pending.nil?
178
+
179
+ outcome, judged = pending.wait(turn.controller)
180
+ if outcome == :timeout
181
+ # The session starts on its default without a word otherwise: a
182
+ # slow System 1 host (a local one) is the usual cause.
183
+ timeout_ms = SystemOne::Client.configured_timeout_ms
184
+ Log.warn(:toolkit, "toolkit_suggest_timeout", session_id: turn.session.id, timeout_ms: timeout_ms,
185
+ reason: "System 1 gave no answer within system_one.timeout_ms")
186
+ @toolkit_binding.fell_back(turn.session, "System 1 took over #{timeout_ms} ms")
187
+ return
188
+ end
189
+ unless outcome == :answered
190
+ Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
191
+ return
192
+ end
193
+ return if judged.nil?
194
+
195
+ pending.record(toolkit_suggestion_outcome(turn.session, judged))
196
+ rescue StandardError => e
197
+ ToolkitSuggest.warn(e)
198
+ pending&.record("failed")
199
+ end
200
+ private :settle_toolkit_suggestion
201
+
202
+ # What the turn does with an answered ask, as the log line's did:
203
+ # suggested (switched), chosen (the toolkit isn't the default any
204
+ # more), unavailable (the kit is gone), defer or default.
205
+ def toolkit_suggestion_outcome(session, judged)
206
+ case judged.would
207
+ when "suggested"
208
+ return "chosen" unless session.toolkit_origin == "default"
209
+ return "unavailable" unless @toolkit_binding.kits.key?(judged.suggestion.kit)
210
+
211
+ switched = @toolkit_binding.suggest(session, judged.suggestion.kit, confidence: judged.suggestion.confidence,
212
+ deferred: ToolkitSuggest.model_reply?(session.messages))
213
+ switched ? "suggested" : "chosen"
214
+ when "defer" then "defer"
215
+ when "no_judgment"
216
+ # System 1 failed (its own warn) or answered nothing usable.
217
+ @toolkit_binding.fell_back(session, "System 1 gave no answer")
218
+ "default"
219
+ else "default"
220
+ end
221
+ end
222
+ private :toolkit_suggestion_outcome
223
+
224
+ # On mode, as an idle worker starts (Worker#start_background): one ask
225
+ # with the real question and a stand-in prompt, on its own thread,
226
+ # never logged and never waited for, so System 1's server has the
227
+ # question's prefix cached for the first prompt. Only for a session a
228
+ # first prompt could be asked for: origin default, no model reply, not
229
+ # a delegate child or a review session, at least two candidates; and
230
+ # not when the worker starts on a first prompt (that ask comes at once).
231
+ def warm_toolkit_suggestion!
232
+ return unless Config.get("toolkit.suggest.mode").to_s == "on"
233
+
234
+ session = @session
235
+ return if session.nil? || session.toolkit_origin != "default" || session.delegate? || session.review?
236
+ return if ToolkitSuggest.model_reply?(session.messages) || !session.last_prompt.to_s.strip.empty?
237
+
238
+ default = Toolkit.of(session.toolkit)
239
+ kits = @toolkit_binding.kits
240
+ return if ToolkitSuggest.candidates(kits, default_name: default.name).size < 2
241
+
242
+ client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
243
+ return unless client
244
+
245
+ project = File.basename(session.project_root.to_s)
246
+ @toolkit_warmup_thread = Thread.new do
247
+ ToolkitSuggest.warm(client: client, default: default, kits: kits, project: project)
248
+ end
249
+ rescue StandardError => e
250
+ ToolkitSuggest.warn(e)
251
+ nil
252
+ end
253
+ public :warm_toolkit_suggestion!
254
+
255
+ # A still-running warm-up, stopped: a real ask (or the worker leaving)
256
+ # doesn't wait behind it.
257
+ def stop_toolkit_warmup
258
+ @toolkit_warmup_thread&.kill
259
+ @toolkit_warmup_thread = nil
260
+ end
261
+ private :stop_toolkit_warmup
262
+
263
+ def toolkit_suggestion_log
264
+ SideJudgments.log(ToolkitSuggest.log_path(session_state_dir), tag: :toolkit, event: "toolkit_suggest_log_failed")
265
+ end
266
+ private :toolkit_suggestion_log
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # How a turn ends after #end_turn's step: the normal ending and its
6
+ # after-turn hooks, a stop before the model, the release, what an ending
7
+ # keeps (TurnEndings, forwarded), last_turn, the answer's display.
8
+ # Writes @last_context_status; reads @turn_endings, @turn_state,
9
+ # @steer_cut, @hooks, @session_observer, @client.
10
+ module TurnEndPart
11
+ # How the turn ended, on the session (Session#last_turn): the caller's
12
+ # save puts it in the file the session hub watches. Public for the
13
+ # Worker's turn that failed before it began (no #end_turn ran).
14
+ # @param limit [Integer, nil] the turn ran out of iterations at this
15
+ # limit: "exhausted" => true, "limit" => N, so a wait with nobody to
16
+ # answer the continue offer says so (ReplyWait, ParentReport "limit")
17
+ # @param origin [TurnOrigin] the turn's (TurnOrigin::NONE for no sender)
18
+ # @param event [Hash] the turn's end event (#stop_facts)
19
+ def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
20
+ enqueued_id = origin.enqueued_id
21
+ session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
22
+ origin: origin.source, exhausted: limit ? true : nil, limit: limit,
23
+ enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
24
+ author: origin.participant_author,
25
+ **stop_facts(event))
26
+ end
27
+
28
+ # Why a turn stopped (TurnEndings#stop_facts).
29
+ def stop_facts(event) = @turn_endings.stop_facts(event)
30
+ private :stop_facts
31
+
32
+ def save_quietly(session)
33
+ session.save(state_dir: session_state_dir)
34
+ rescue StandardError => e
35
+ Log.warn(:turn, "turn_end_save_failed", error: e.class.name, msg: e.message.to_s[0, 200])
36
+ nil
37
+ end
38
+ private :save_quietly
39
+
40
+ # Keep what the after_turn hooks presented as the answer's `display`
41
+ # and the refs they attached as its `display_refs` (AnswerRefs, one
42
+ # replace for both), and tell the observers (:answer_display, the
43
+ # display or nil, and how many refs), after the turn_completed they
44
+ # already had: the web re-reads the answer then. A turn_completed
45
+ # with display_pending (after_turn hooks were about to run) always gets
46
+ # one, `display: nil` when nothing was presented, so the web holds the
47
+ # answer's pop until then and never swaps it after. Terminals are not
48
+ # sinks of it; they printed the answer already. The session is saved by
49
+ # the caller, as after every turn.
50
+ def store_answer_display(session, display, refs, pending: false)
51
+ synchronize_events do
52
+ # A plugin that kept event[:refs] and calls it later gets nil: the
53
+ # list is stored now (and the turn is over on every path).
54
+ refs.close!
55
+ messages = Array(session.messages)
56
+ changed = display.changed? && messages.last.equal?(display.target)
57
+ added = refs.added? && messages.last.equal?(refs.target)
58
+ if changed || added
59
+ merged = messages.last.dup
60
+ merged[AnswerDisplay::KEY] = display.text if changed
61
+ merged[AnswerRefs::KEY] = refs.to_a if added
62
+ replace_session_messages(session, messages[0...-1] + [merged])
63
+ event = { type: :answer_display, display: changed ? display.text : nil }
64
+ event[:refs] = refs.count if added
65
+ @session_observer.notify(event)
66
+ elsif pending
67
+ @session_observer.notify({ type: :answer_display, display: nil })
68
+ end
69
+ end
70
+ end
71
+ private :store_answer_display
72
+
73
+ # A turn stopped before the model was asked (#prepare_turn returned
74
+ # false): canceled with nothing kept, like a Ctrl-C there; the result
75
+ # has no conversation, so a UI's checkpoint stands.
76
+ def stopped_before_model(turn)
77
+ reason = turn.controller.reason
78
+ end_turn(turn, "canceled") do |seconds|
79
+ [nil, { type: :turn_canceled, cancellation_reason: reason, cancelled_by: cancelled_by(turn),
80
+ duration_ms: (seconds * 1000).round }]
81
+ end
82
+ LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
83
+ end
84
+ private :stopped_before_model
85
+
86
+ # Who stopped a turn a hook stopped or cut (the controller's detail: the
87
+ # bundle, or the hook's label), for the UIs' "stopped by loop-guard";
88
+ # nil for any other cancel.
89
+ def cancelled_by(turn) = turn.controller.stopped_by
90
+ private :cancelled_by
91
+
92
+ # The normal ending (an answer, an empty answer, a Stop, the iteration
93
+ # limit), then the after-turn hooks.
94
+ def complete_turn(turn, result)
95
+ canceled = result.canceled?
96
+ # after_turn hooks run below and may present the answer (the web
97
+ # holds its pop until it knows).
98
+ display_pending = !canceled && @hooks.any?(:after_turn)
99
+ end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
100
+ kept = kept_messages(turn, result, seconds)
101
+ if canceled
102
+ [kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason, cancelled_by: cancelled_by(turn),
103
+ duration_ms: (seconds * 1000).round }]
104
+ else
105
+ # For a client that attaches later (session_state_snapshot).
106
+ @last_context_status = result.context_status.dup if result.context_status
107
+ [kept, { type: :turn_completed, result: result, turn_summary: turn_summary(result),
108
+ display_pending: display_pending }]
109
+ end
110
+ end
111
+
112
+ # Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
113
+ # ends the turn in #run_turn without it), with a read-only
114
+ # copy of the conversation the turn stored, event[:answer] (the answer's
115
+ # text: the native loop's stored message still holds its thought),
116
+ # event[:present] for
117
+ # a display version of the answer (AnswerDisplay) and event[:refs]
118
+ # for refs on it (AnswerRefs).
119
+ session = turn.session
120
+ audit_objectives_outgoing(turn, result) unless canceled || result.exhausted?
121
+ display = AnswerDisplay.new(session.messages)
122
+ refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
123
+ after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
124
+ messages: hook_messages(session.messages),
125
+ answer: (result.text unless canceled || result.exhausted?) }
126
+ after_turn[:present] = display.presenter(after_turn)
127
+ after_turn[:refs] = refs.adder(after_turn)
128
+ @hooks.fire(:after_turn, after_turn)
129
+ store_answer_display(session, display, refs, pending: display_pending)
130
+
131
+ # Fire :session_end after the turn (turn-level lifecycle), as :after_turn
132
+ @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
133
+ end
134
+ private :complete_turn
135
+
136
+ # What each turn ending keeps and announces is TurnEndings'
137
+ # (#build_turn_endings); these forward to it.
138
+ def kept_messages(turn, result, seconds) = @turn_endings.kept_messages(turn, result, seconds)
139
+ private :kept_messages
140
+ def ctrl_c_messages(turn, interrupt, seconds) = @turn_endings.ctrl_c_messages(turn, interrupt, seconds)
141
+ private :ctrl_c_messages
142
+ def loop_conversation(error) = @turn_endings.loop_conversation(error)
143
+ private :loop_conversation
144
+ def failed_messages(turn, error) = @turn_endings.failed_messages(turn, error)
145
+ private :failed_messages
146
+ def failed_event(error, seconds) = @turn_endings.failed_event(error, seconds)
147
+ private :failed_event
148
+
149
+ # However the turn ended (#run_turn's ensure): the turn flag off, the
150
+ # probe cancel restored, the cancel controller, sink and steers cleared
151
+ # (a steer left is logged as dropped), activity recorded, the
152
+ # turn-scoped hooks cleared.
153
+ def release_turn(probe_cancel_before)
154
+ # A completed turn is activity: release the turn flag and advance the
155
+ # shared inactivity clock so the idle recap detector
156
+ # treats the just-finished turn as activity and re-arms its window.
157
+ left = @turn_state.finish!
158
+ @steer_cut.finished!
159
+ Client.swap_probe_cancel(probe_cancel_before)
160
+ # The next turn's window and served model are asked again: the cache
161
+ # (this host's, process-wide) is dropped here, at the turn's end,
162
+ # rather than at its start, so what this turn read (the profile's own
163
+ # probe included) is not asked for a second time within the turn. A
164
+ # restart with another -c between turns is still noticed: the next
165
+ # turn's first probe goes to the server.
166
+ @client.invalidate_context_window!
167
+ log_dropped_steers(left, "turn_ended")
168
+ record_activity
169
+ # Clear hooks so they remain turn-scoped and never leak into the next turn.
170
+ clear_hooks
171
+ end
172
+ private :release_turn
173
+
174
+ # JSON-safe digest of a finished turn for renderers (TurnEndings#turn_summary).
175
+ # @param result [LLM::ModelResult]
176
+ # @return [Hash]
177
+ def turn_summary(result) = @turn_endings.turn_summary(result)
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # What a turn's steps use before and around the model: its settings,
6
+ # images and vision, the model ref and resumed run start it generates
7
+ # with, the used memories it publishes, and the turn-end warm-up of the
8
+ # next one. Writes @next_turn_waiting; reads @model_settings, @kernel,
9
+ # @prompt_warmup, @thinking_notices, @turn_state.
10
+ module TurnHelpersPart
11
+ # The turn's settings for the kernel (LLM::TurnSettings): vision,
12
+ # sampling and thinking, the thinking resolved before the hooks run.
13
+ # The model name is added in #generate, after the hooks that may switch
14
+ # the model.
15
+ # One resolve for all of them: nothing runs between them that could
16
+ # change what a resolve gives.
17
+ def turn_settings(session)
18
+ model = effective_model
19
+ vision = turn_vision(session, model)
20
+ sampling = model.sampling
21
+ level = @model_settings.thinking_level(model)
22
+ @thinking_notices.turn_started(level, model.target)
23
+ LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: level,
24
+ reasoning_history: @model_settings.reasoning_history_level(model), model_name: nil,
25
+ window_setting: nil, llm_context: nil, price: nil)
26
+ end
27
+ private :turn_settings
28
+
29
+ # @return [PromptWarmup] the turn-end warm-up (specs, measurements)
30
+ attr_reader :prompt_warmup
31
+
32
+ # A caller's check that the next turn starts at once (input queued for
33
+ # it: a worker's inbox) or that none follows (a one-shot run): no
34
+ # warm-up then. nil: neither.
35
+ attr_accessor :next_turn_waiting
36
+
37
+ # After a completed turn, prefill the next turn's prompt up to its user
38
+ # message while the user reads (PromptWarmup, cache.warmup). Only on a
39
+ # local llama.cpp host on the native loop, and not when the next turn's
40
+ # start differs anyway or starts at once: a reminder due, a continue
41
+ # offer (the turn ran out of steps), a carried steer or queued input.
42
+ # Never fails the turn.
43
+ def warm_up_next_turn(turn, result)
44
+ return if result.canceled? || result.resumable?
45
+
46
+ model = effective_model
47
+ client = model.client
48
+ return unless warmup_target?(model, client)
49
+ return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
50
+
51
+ messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(model) })
52
+ # Under the next turn's strategy (a /model switch may have changed it);
53
+ # the last turn's when it can't be resolved.
54
+ prompt, images = @kernel.warmup_prompt(messages, llm_context: @model_settings.turn_llm_context || @kernel.turn_settings&.llm_context)
55
+ return unless prompt
56
+
57
+ started = @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
58
+ slot: @kernel.last_slot, images: images)
59
+ @kernel.warmed_up! if started
60
+ rescue StandardError => e
61
+ Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
62
+ end
63
+ private :warm_up_next_turn
64
+
65
+ # Never a paid or remote host (cache.warmup auto means local only), nor
66
+ # the chat loop (Splash keeps its own cache; llama.cpp's chat path is
67
+ # not measured), nor a client that can't warm up (a spec's stub).
68
+ def warmup_target?(model, client)
69
+ return false if model.chat? || model.entry.remote?
70
+ return false unless client.respond_to?(:warm_up)
71
+
72
+ PromptWarmup.enabled?
73
+ end
74
+ private :warmup_target?
75
+
76
+ # The ref (host:model, as asked) a turn's generations ran on: the model
77
+ # #generate resolved, after the before_turn hooks (which may switch it).
78
+ # SessionMetrics keys its per-model segments on it. nil when it can't be
79
+ # named (the turn's record then has none).
80
+ def generation_model_ref(model)
81
+ model.ref
82
+ rescue StandardError => e
83
+ Log.warn(:turn, "model_ref_failed", error: e.class.name, msg: e.message.to_s[0, 200])
84
+ nil
85
+ end
86
+ private :generation_model_ref
87
+
88
+ # A step-limit Continue's resume_from, when it is inside the messages the
89
+ # run gets (a wrong one only drops reasoning: it is ignored, logged).
90
+ # Entries before it never move between the turns (#turn_messages adds
91
+ # at the tail, and the system head is replaced in place).
92
+ def resumed_run_start(turn, run_start)
93
+ start = turn.resume_from
94
+ return nil if start.nil?
95
+ return start if start.is_a?(Integer) && start.between?(0, run_start)
96
+
97
+ Log.warn(:turn, "resume_start_invalid", resume_from: start, length: run_start)
98
+ nil
99
+ end
100
+ private :resumed_run_start
101
+
102
+ # The turn's LLM context strategy and where it came from, in the turn
103
+ # log: when it runs a layer, or the session set it (none too).
104
+ def log_llm_context(explained)
105
+ return unless explained
106
+ return if explained.resolved.active_layers.empty? && explained.strategy.source != :session
107
+
108
+ summary = explained.summary
109
+ Log.info(:turn, "llm_context", **summary.slice(:strategy, :strategy_source, :apply, :apply_source, :budget_tokens,
110
+ :budget_source).compact)
111
+ end
112
+ private :log_llm_context
113
+
114
+ def publish_used_memories(session, on_event)
115
+ # Persist deduped used memories onto the session for Web + reload.
116
+ begin
117
+ session.used_memory_names = used_memory_names
118
+ rescue StandardError => e
119
+ Log.warn(:memory, "used_memories_store_failed", error: e.class.name, msg: e.message.to_s[0, 200])
120
+ end
121
+ # Notify live observers of the updated memory list (so yellow bar refreshes
122
+ # even without a tool_call event if the preload was the only addition).
123
+ # Only emit when there is something to report to avoid noisy event_count drift.
124
+ return unless used_memory_names.any?
125
+
126
+ begin
127
+ emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
128
+ rescue StandardError => e
129
+ Log.warn(:memory, "used_memories_emit_failed", error: e.class.name, msg: e.message.to_s[0, 200])
130
+ end
131
+ end
132
+ private :publish_used_memories
133
+
134
+ # [refs, nil] for a turn's images, or [[], error] when one can't be used
135
+ # (the turn then fails right after :turn_started).
136
+ def turn_image_refs(session, images)
137
+ return [[], nil] if Array(images).empty?
138
+
139
+ [ImageStore.resolve_all(Session.session_dir(session.id, state_dir: session_state_dir), images), nil]
140
+ rescue ImageStore::Error => e
141
+ [[], e]
142
+ end
143
+
144
+ # The turn's VisionContext: the session's images folder, and whether the
145
+ # effective model can see images, asked only when a request carries one.
146
+ def turn_vision(session, model = effective_model)
147
+ VisionContext.new(session_dir: Session.session_dir(session.id, state_dir: session_state_dir),
148
+ capability: -> { model.vision_support(profile) })
149
+ end
150
+
151
+ # A model known not to see images fails a turn with images up front.
152
+ def refuse_images!(vision)
153
+ return if vision.sendable?
154
+
155
+ host = effective_model.host_name
156
+ raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
157
+ end
158
+ end
159
+ end
160
+ end