samagotchi 0.154.1 → 0.156.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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +12 -7
  5. data/docs/bundles.md +21 -0
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +2 -2
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/anthropic.md +5 -5
  10. data/docs/internals/attached-context.md +9 -6
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/block-renderers.md +8 -1
  13. data/docs/internals/engine.md +642 -831
  14. data/docs/internals/inline-widgets.md +136 -0
  15. data/docs/internals/llm-context-forget.md +8 -3
  16. data/docs/internals/objectives.md +8 -6
  17. data/docs/internals/participants.md +5 -5
  18. data/docs/internals/prompt-caching.md +13 -6
  19. data/docs/internals/remote-sessions.md +3 -1
  20. data/docs/internals/web-frontend.md +1 -1
  21. data/docs/internals/working-set.md +2 -1
  22. data/docs/plugins.md +2 -0
  23. data/docs/releasing.md +1 -1
  24. data/docs/sessions.md +4 -4
  25. data/docs/system-one.md +3 -2
  26. data/docs/testing.md +1 -1
  27. data/lib/samagotchi/block_renderers.rb +5 -0
  28. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  29. data/lib/samagotchi/broadcast_command.rb +3 -3
  30. data/lib/samagotchi/bundle_command/check.rb +1 -1
  31. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  33. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  34. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/child_move.rb +7 -51
  38. data/lib/samagotchi/children_status.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  40. data/lib/samagotchi/config.rb +17 -112
  41. data/lib/samagotchi/context_command.rb +1 -2
  42. data/lib/samagotchi/context_window.rb +3 -3
  43. data/lib/samagotchi/desktop/macos.rb +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +21 -119
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/models.rb +5 -2
  48. data/lib/samagotchi/engine/objectives.rb +30 -80
  49. data/lib/samagotchi/engine/participants.rb +15 -10
  50. data/lib/samagotchi/engine/plugins.rb +3 -1
  51. data/lib/samagotchi/engine/questions.rb +1 -1
  52. data/lib/samagotchi/engine/side_requests.rb +26 -13
  53. data/lib/samagotchi/engine/toolkit.rb +35 -191
  54. data/lib/samagotchi/engine/turn_end.rb +1 -1
  55. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  56. data/lib/samagotchi/engine/verify.rb +7 -143
  57. data/lib/samagotchi/engine.rb +166 -98
  58. data/lib/samagotchi/engine_peers.rb +1 -1
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +28 -11
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/host_config.rb +72 -1
  67. data/lib/samagotchi/host_model.rb +2 -6
  68. data/lib/samagotchi/host_registry.rb +1 -1
  69. data/lib/samagotchi/inline_widgets.rb +325 -0
  70. data/lib/samagotchi/kernel_loop.rb +215 -313
  71. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  72. data/lib/samagotchi/llm/backend.rb +3 -1
  73. data/lib/samagotchi/llm/chat_loop.rb +87 -49
  74. data/lib/samagotchi/llm/native_backend.rb +3 -2
  75. data/lib/samagotchi/llm_context_apply.rb +54 -1
  76. data/lib/samagotchi/llm_context_forget.rb +5 -3
  77. data/lib/samagotchi/llm_context_notice.rb +1 -1
  78. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  79. data/lib/samagotchi/llm_context_view.rb +9 -0
  80. data/lib/samagotchi/model_binding.rb +3 -3
  81. data/lib/samagotchi/model_price.rb +4 -10
  82. data/lib/samagotchi/model_profile.rb +6 -6
  83. data/lib/samagotchi/model_settings.rb +37 -2
  84. data/lib/samagotchi/note_command.rb +1 -2
  85. data/lib/samagotchi/objectives_audit.rb +4 -3
  86. data/lib/samagotchi/participants.rb +2 -2
  87. data/lib/samagotchi/plugin/context.rb +1 -1
  88. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  89. data/lib/samagotchi/plugin/sessions.rb +2 -5
  90. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  91. data/lib/samagotchi/project_env.rb +1 -1
  92. data/lib/samagotchi/question_desk.rb +1 -1
  93. data/lib/samagotchi/reasoning_history.rb +3 -3
  94. data/lib/samagotchi/reminder_carry.rb +1 -1
  95. data/lib/samagotchi/reprefill_meter.rb +45 -0
  96. data/lib/samagotchi/review_command.rb +1 -1
  97. data/lib/samagotchi/revise_anchor.rb +1 -1
  98. data/lib/samagotchi/sampling_settings.rb +3 -3
  99. data/lib/samagotchi/self_report.rb +7 -7
  100. data/lib/samagotchi/session.rb +50 -7
  101. data/lib/samagotchi/session_access/local.rb +7 -5
  102. data/lib/samagotchi/session_access/remote.rb +17 -3
  103. data/lib/samagotchi/session_archive_command.rb +2 -2
  104. data/lib/samagotchi/session_chain.rb +7 -6
  105. data/lib/samagotchi/session_continue.rb +6 -5
  106. data/lib/samagotchi/session_delete_command.rb +1 -1
  107. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  108. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  109. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  110. data/lib/samagotchi/session_manager/listing.rb +189 -0
  111. data/lib/samagotchi/session_manager/removal.rb +190 -0
  112. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  113. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  114. data/lib/samagotchi/session_manager.rb +26 -1540
  115. data/lib/samagotchi/session_metrics.rb +1 -1
  116. data/lib/samagotchi/sessions_command.rb +1 -1
  117. data/lib/samagotchi/share_command.rb +1 -1
  118. data/lib/samagotchi/starting_marker.rb +65 -0
  119. data/lib/samagotchi/steer_cut_after.rb +3 -3
  120. data/lib/samagotchi/system_one/recorder.rb +49 -0
  121. data/lib/samagotchi/system_prompt.rb +37 -30
  122. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  123. data/lib/samagotchi/terminal_ui.rb +2 -6
  124. data/lib/samagotchi/testing/boundary.rb +7 -0
  125. data/lib/samagotchi/testing/bundle.rb +7 -5
  126. data/lib/samagotchi/testing/install.rb +3 -2
  127. data/lib/samagotchi/testing/sessions.rb +1 -1
  128. data/lib/samagotchi/thinking.rb +3 -3
  129. data/lib/samagotchi/token_usage.rb +1 -1
  130. data/lib/samagotchi/tool_activity.rb +15 -7
  131. data/lib/samagotchi/tool_declarations.rb +6 -6
  132. data/lib/samagotchi/tool_output_cut.rb +17 -0
  133. data/lib/samagotchi/tool_response.rb +4 -3
  134. data/lib/samagotchi/tool_runner.rb +96 -102
  135. data/lib/samagotchi/toolkit_binding.rb +40 -14
  136. data/lib/samagotchi/toolkit_command.rb +1 -1
  137. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  138. data/lib/samagotchi/tools/delegate.rb +10 -5
  139. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  140. data/lib/samagotchi/tools/index_hint.rb +14 -0
  141. data/lib/samagotchi/tools/registry.rb +64 -44
  142. data/lib/samagotchi/tools/send_note.rb +4 -6
  143. data/lib/samagotchi/tools/tool_index.rb +150 -102
  144. data/lib/samagotchi/tools/tool_surface.rb +187 -0
  145. data/lib/samagotchi/turn_context.rb +68 -0
  146. data/lib/samagotchi/turn_stages/context_judge.rb +172 -0
  147. data/lib/samagotchi/turn_stages/objectives.rb +150 -0
  148. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +257 -0
  149. data/lib/samagotchi/turn_stages/verify_anchors.rb +168 -0
  150. data/lib/samagotchi/turn_stages/warmup.rb +82 -0
  151. data/lib/samagotchi/turn_state.rb +46 -4
  152. data/lib/samagotchi/version.rb +1 -1
  153. data/lib/samagotchi/vision_support.rb +3 -3
  154. data/lib/samagotchi/web/app.rb +15 -8
  155. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  156. data/lib/samagotchi/web/message_display.rb +1 -1
  157. data/lib/samagotchi/web/participant_access.rb +2 -2
  158. data/lib/samagotchi/web/public/app.js +5 -1
  159. data/lib/samagotchi/web/public/block_host.js +74 -199
  160. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  161. data/lib/samagotchi/web/public/block_source.js +7 -4
  162. data/lib/samagotchi/web/public/block_watch.js +256 -0
  163. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  164. data/lib/samagotchi/web/public/index.html +28 -9
  165. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  166. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  167. data/lib/samagotchi/web/public/participant.js +1 -1
  168. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  169. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  170. data/lib/samagotchi/web/public/text_roots.js +1 -1
  171. data/lib/samagotchi/web/public/verify_target.js +1 -1
  172. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  173. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  174. data/lib/samagotchi/web/public/widgets.css +70 -0
  175. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  176. data/lib/samagotchi/web/session_control.rb +3 -3
  177. data/lib/samagotchi/web/session_create.rb +3 -3
  178. data/lib/samagotchi/web/session_hub.rb +1 -1
  179. data/lib/samagotchi/web/session_show.rb +3 -3
  180. data/lib/samagotchi/web/session_summary.rb +3 -4
  181. data/lib/samagotchi/worker.rb +55 -27
  182. data/lib/samagotchi/worker_boot.rb +184 -0
  183. data/lib/samagotchi/worker_signals.rb +1 -1
  184. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  185. metadata +29 -1
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../log"
5
+ require_relative "../side_judgments"
6
+ require_relative "../system_one/client"
7
+ require_relative "../toolkit"
8
+ require_relative "../toolkit_suggest"
9
+ require_relative "../tools/tool_index"
10
+
11
+ module Samagotchi
12
+ # The steps of a turn that keep state across its preparation, each a
13
+ # collaborator the Engine builds from constructor arguments and
14
+ # Engine#prepare_turn calls by name (docs/internals/engine.md, "Turn
15
+ # stages").
16
+ module TurnStages
17
+ # System 1's toolkit suggestion (ToolkitSuggest): the ask a turn starts
18
+ # before the probes, settles before apply_toolkit! (on mode) or abandons
19
+ # at a Stop or a failure, and the idle worker's warm-up. Its state is
20
+ # the running turn's Pending (turn thread only) and the warm-up thread.
21
+ class ToolkitSuggestion
22
+ # @param side_judgments [SideJudgments] the Engine's side asks (:toolkit kind)
23
+ # @param host_registry [HostRegistry] System 1's client resolves its host here
24
+ # @param toolkit_binding [ToolkitBinding] the kits, #suggest and #fell_back
25
+ # @param tools [Tools::Registry] the registered tools, for the snapshot
26
+ def initialize(side_judgments:, host_registry:, toolkit_binding:, tools:)
27
+ @side_judgments = side_judgments
28
+ @host_registry = host_registry
29
+ @toolkit_binding = toolkit_binding
30
+ @tools = tools
31
+ @pending = nil
32
+ @pending_turn = nil
33
+ @warmup_thread = nil
34
+ end
35
+
36
+ # For specs: the running warm-up (#warm!), nil when none started or it
37
+ # was stopped.
38
+ attr_reader :warmup_thread
39
+
40
+ # For specs: the ask's thread (SideJudgments' :toolkit kind), nil before any.
41
+ def ask_thread = @side_judgments.thread(:toolkit)
42
+
43
+ # Start the toolkit classifier after :turn_started, for audit and on
44
+ # mode alike (one eligibility, one snapshot, one thread). All session
45
+ # and tool-registry reads happen here; the thread receives only this
46
+ # snapshot. Audit never blocks: the thread logs its line. On mode
47
+ # keeps the ask's Pending for +turn+, which #settle joins before
48
+ # apply_toolkit!, with the turn's controller as the request's cancel
49
+ # controller. The suggestion log is in the turn's sessions dir
50
+ # (TurnContext#state_dir).
51
+ # @param recorder [SystemOne::Recorder] the ask's runs (built for this call)
52
+ # @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
53
+ def start(turn, recorder:)
54
+ @pending = nil
55
+ @pending_turn = turn
56
+ @pending = ask(turn, recorder)
57
+ end
58
+
59
+ # On mode, before apply_toolkit!: wait for +turn+'s ask (#start) up to
60
+ # System 1's timeout from its start, or until a Stop, and put the
61
+ # session on a candidate picked with confidence ≥ threshold
62
+ # (ToolkitBinding#suggest: origin suggested, saved, the notice). Any
63
+ # other outcome (the default, below threshold, undecided, no judgment,
64
+ # a timeout, a Stop) leaves the session on its default. Nothing here
65
+ # sends a request; prepare_turn's cancel check ends a stopped turn. A
66
+ # Stop wins over an answer already in (Pending#wait checks it first).
67
+ def settle(turn)
68
+ pending = pending_for(turn)
69
+ return if pending.nil?
70
+
71
+ outcome, judged = pending.wait(turn.controller)
72
+ if outcome == :timeout
73
+ # The session starts on its default without a word otherwise: a
74
+ # slow System 1 host (a local one) is the usual cause.
75
+ timeout_ms = SystemOne::Client.configured_timeout_ms
76
+ Log.warn(:toolkit, "toolkit_suggest_timeout", session_id: turn.session.id, timeout_ms: timeout_ms,
77
+ reason: "System 1 gave no answer within system_one.timeout_ms")
78
+ @toolkit_binding.fell_back(turn.session, "System 1 took over #{timeout_ms} ms")
79
+ return
80
+ end
81
+ unless outcome == :answered
82
+ Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
83
+ return
84
+ end
85
+ return if judged.nil?
86
+
87
+ pending.record(outcome_of(turn.session, judged))
88
+ rescue StandardError => e
89
+ ToolkitSuggest.warn(e)
90
+ pending&.record("failed")
91
+ end
92
+
93
+ # +turn+ ended before it settled its ask (a Stop at the probes, a
94
+ # failure, an Interrupt): the ask's line says +reason+
95
+ # (Pending#abandon; nothing once it was settled, so prepare_turn's
96
+ # ensure calls it too). Nothing when +turn+ started no ask.
97
+ def abandon(turn, reason)
98
+ pending_for(turn)&.abandon(reason)
99
+ end
100
+
101
+ # +turn+'s suggestion is done (prepare_turn's ensure, after #abandon):
102
+ # its Pending and the turn are let go, so nothing of it outlives its
103
+ # preparation. Nothing for another turn's.
104
+ def finish(turn)
105
+ return unless @pending_turn.equal?(turn)
106
+
107
+ @pending = nil
108
+ @pending_turn = nil
109
+ end
110
+
111
+ # On mode, as an idle worker starts (Worker#start_background): one ask
112
+ # with the real question and a stand-in prompt, on its own thread,
113
+ # never logged and never waited for, so System 1's server has the
114
+ # question's prefix cached for the first prompt. Only for a session a
115
+ # first prompt could be asked for: origin default, no model reply, not
116
+ # a delegate child or a review session, at least two candidates; and
117
+ # not when the worker starts on a first prompt (that ask comes at once).
118
+ def warm!(session)
119
+ return unless Config.get("toolkit.suggest.mode").to_s == "on"
120
+
121
+ return if session.nil? || session.toolkit_origin != "default" || session.delegate? || session.review?
122
+ return if ToolkitSuggest.model_reply?(session.messages) || !session.last_prompt.to_s.strip.empty?
123
+
124
+ default = Toolkit.of(session.toolkit)
125
+ kits = @toolkit_binding.kits
126
+ return if ToolkitSuggest.candidates(kits, default_name: default.name).size < 2
127
+
128
+ client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
129
+ return unless client
130
+
131
+ project = File.basename(session.project_root.to_s)
132
+ @warmup_thread = Thread.new do
133
+ ToolkitSuggest.warm(client: client, default: default, kits: kits, project: project)
134
+ end
135
+ rescue StandardError => e
136
+ ToolkitSuggest.warn(e)
137
+ nil
138
+ end
139
+
140
+ # A still-running warm-up, stopped: a real ask (or the worker leaving)
141
+ # doesn't wait behind it.
142
+ def stop_warmup
143
+ @warmup_thread&.kill
144
+ @warmup_thread = nil
145
+ end
146
+
147
+ private
148
+
149
+ # The Pending #start kept for this very turn: another turn's (one that
150
+ # never reached #start) is never touched.
151
+ def pending_for(turn) = (@pending if @pending_turn.equal?(turn))
152
+
153
+ def ask(turn, recorder)
154
+ mode = Config.get("toolkit.suggest.mode").to_s
155
+ return if mode == "off"
156
+
157
+ joined = mode == "on"
158
+ session = turn.session
159
+ default = Toolkit.of(session.toolkit)
160
+ log = suggestion_log(turn.context.state_dir)
161
+ # The predicate checks the session first; the kits (memory kits are
162
+ # read from disk) and the log (read whole) only when it gets there.
163
+ kits = nil
164
+ candidates = nil
165
+ return unless ToolkitSuggest.eligible?(
166
+ session: session, prompt: turn.prompt, author: turn.origin.author,
167
+ candidate_count: lambda {
168
+ kits = @toolkit_binding.kits
169
+ candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
170
+ candidates.size
171
+ },
172
+ last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
173
+ defer_turns: Config.get("toolkit.suggest.defer_turns"),
174
+ defer_tokens: Config.get("toolkit.suggest.defer_tokens")
175
+ )
176
+
177
+ # One ask at a time: an eligible turn while the last one still runs
178
+ # goes unasked, and says so.
179
+ if @side_judgments.in_flight?(:toolkit)
180
+ Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
181
+ return
182
+ end
183
+
184
+ client = recorder.wrap(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit),
185
+ ToolkitSuggest::JUDGMENT, session, mode: Config.get("toolkit.suggest.mode").to_s)
186
+ return unless client
187
+
188
+ # The warm-up's only value is the cache: the real ask never queues
189
+ # behind it.
190
+ stop_warmup
191
+
192
+ # Rules, not tool lists: this runs before await_init_tasks and
193
+ # apply_staged_tools!, so a first turn's plugin/MCP tools aren't
194
+ # registered yet; the report expands the rules (ToolkitSuggest.rules).
195
+ snapshots = candidates.map do |kit|
196
+ ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
197
+ rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
198
+ end.freeze
199
+ default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
200
+ rules: ToolkitSuggest.rules(default.resolve(kits)))
201
+ registered = Tools::ToolSurface.for(@tools, toolkit: nil).declared
202
+ .reject { |entry| entry.source == Tools::ToolIndex::SOURCE }
203
+ core_tools, plugin_tools = registered.partition(&:core?).map { |entries| entries.map { |entry| entry.name.dup.freeze }.freeze }
204
+ state = ToolkitSuggest.question(
205
+ default: default, kits: kits, project: File.basename(session.project_root.to_s),
206
+ prompt: turn.prompt
207
+ ).last
208
+ snapshot = ToolkitSuggest::Snapshot.new(
209
+ session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
210
+ default: default_snapshot, candidates: snapshots, core_tools: core_tools, plugin_tools: plugin_tools,
211
+ state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
212
+ )
213
+ threshold = Config.get("toolkit.suggest.threshold")
214
+ runs = session.scratch ? nil : recorder.runs
215
+ pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
216
+ acted: runs && ->(judged, did) { runs.acted(judged.fields[:run_id], did) },
217
+ timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
218
+ # On mode: cut by a Stop while the turn waits (Pending#wait), not
219
+ # by one after it went on; audit's ask is never cut.
220
+ controller = joined ? pending.cancel_controller : nil
221
+ @side_judgments.start(:toolkit, warn: ToolkitSuggest.method(:warn), finish: pending.method(:finish)) do
222
+ ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
223
+ cancel_controller: controller)
224
+ end
225
+ joined ? pending : nil
226
+ rescue StandardError => e
227
+ ToolkitSuggest.warn(e)
228
+ nil
229
+ end
230
+
231
+ # What the turn does with an answered ask, as the log line's did:
232
+ # suggested (switched), chosen (the toolkit isn't the default any
233
+ # more), unavailable (the kit is gone), defer or default.
234
+ def outcome_of(session, judged)
235
+ case judged.would
236
+ when "suggested"
237
+ return "chosen" unless session.toolkit_origin == "default"
238
+ return "unavailable" unless @toolkit_binding.kits.key?(judged.suggestion.kit)
239
+
240
+ switched = @toolkit_binding.suggest(session, judged.suggestion.kit, confidence: judged.suggestion.confidence,
241
+ deferred: ToolkitSuggest.model_reply?(session.messages))
242
+ switched ? "suggested" : "chosen"
243
+ when "defer" then "defer"
244
+ when "no_judgment"
245
+ # System 1 failed (its own warn) or answered nothing usable.
246
+ @toolkit_binding.fell_back(session, "System 1 gave no answer")
247
+ "default"
248
+ else "default"
249
+ end
250
+ end
251
+
252
+ def suggestion_log(state_dir)
253
+ SideJudgments.log(ToolkitSuggest.log_path(state_dir), tag: :toolkit, event: "toolkit_suggest_log_failed")
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../answer_tail"
5
+ require_relative "../log"
6
+ require_relative "../output_formatter"
7
+ require_relative "../redact"
8
+ require_relative "../revise_anchor"
9
+ require_relative "../session"
10
+ require_relative "../steer"
11
+ require_relative "../verdict"
12
+ require_relative "../verify_anchor"
13
+
14
+ module Samagotchi
15
+ module TurnStages
16
+ # Verify and Revise anchors over a turn: a Verify turn's quote checked
17
+ # against its source session as the turn begins; at its end (inside
18
+ # Engine#end_turn's event step) the opening's stamp, a Revise turn's
19
+ # draft checked and a Verify turn's verdict recorded. No state of its
20
+ # own: it reads the turn, and the sessions it loads from the state dir
21
+ # it is given.
22
+ class VerifyAnchors
23
+ # A Verify turn's claim (Engine#begin_turn), when its quote is in the
24
+ # source session's answer for that turn; nil (logged) otherwise.
25
+ # @param anchor [VerifyAnchor, nil]
26
+ # @param state_dir [String] the sessions dir (a fresh Verify child's
27
+ # parent is loaded from it): the Engine's at the call, as no
28
+ # TurnContext holds it yet
29
+ # @return [VerifyAnchor, nil]
30
+ def verified(session, anchor, state_dir:)
31
+ return unless anchor
32
+
33
+ source = source_session(session, anchor, state_dir)
34
+ return unless source
35
+
36
+ turn = AnswerTail.turn_slice(Array(source.messages), anchor.turn_id)
37
+ return reject_verify(anchor, :turn_missing) unless turn
38
+
39
+ reply = turn.reverse.find { |message| AnswerTail.answer?(message) }
40
+ return reject_verify(anchor, :reply_missing) unless reply
41
+
42
+ quote_text = comparison_text(anchor.quote)
43
+ quoted = !quote_text.empty? && quote_in?(quote_text, (reply[:content] || reply["content"]).to_s, source)
44
+ return reject_verify(anchor, :quote_missing) unless quoted
45
+
46
+ anchor
47
+ end
48
+
49
+ # The turn's anchors, stamped on its opening message (the prompt, or a
50
+ # wake turn's note) in the messages it keeps: display metadata, never
51
+ # in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
52
+ # A Verify turn's claim however it ended; a Revise turn's draft only
53
+ # once it completed with an answer (#revision_target). Returns the
54
+ # stamped ReviseAnchor (the end event's revises), or nil.
55
+ def stamp_opening(messages, turn, outcome)
56
+ return unless messages.is_a?(Array)
57
+
58
+ revises = revision_target(messages, turn, outcome)
59
+ fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
60
+ return if fields.empty?
61
+
62
+ opening = messages.reverse.find do |message|
63
+ Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
64
+ end
65
+ return unless opening
66
+
67
+ opening.merge!(fields)
68
+ revises
69
+ end
70
+
71
+ # A completed Verify turn's verdict (Verdict.parse of its answer), added
72
+ # to the session's verdicts; the record (the end event's verdict), or nil.
73
+ def record_verdict(turn, outcome)
74
+ return unless outcome == "completed" && turn.verify
75
+
76
+ answer = AnswerTail.find(turn.session.messages, turn_id: turn.id)
77
+ parsed = Verdict.parse(answer && (answer[:content] || answer["content"]))
78
+ return unless parsed
79
+
80
+ record = { anchor: turn.verify.to_h, verdict: parsed.verdict, reason: parsed.reason,
81
+ by: turn.origin.author, check_turn_id: turn.id, at: Time.now.iso8601(3) }
82
+ turn.session.verdicts = turn.session.verdicts + [record]
83
+ record
84
+ end
85
+
86
+ private
87
+
88
+ # The turn a Revise turn revised (D6: "this turn revised that one"):
89
+ # only a completed turn with an answer (AnswerTail) has revised
90
+ # anything. A draft no prompt in the messages carries is no target (a
91
+ # stale page, a rolled-back turn); one a stamped turn already revised
92
+ # is re-targeted to its stack's top, the newest version, which the
93
+ # person meant ("this draft").
94
+ def revision_target(messages, turn, outcome)
95
+ return unless turn.revises && outcome == "completed"
96
+
97
+ slice = AnswerTail.turn_slice(messages, turn.id)
98
+ return unless slice&.any? { |message| AnswerTail.answer?(message) }
99
+
100
+ target = turn.revises.turn_id
101
+ seen = [turn.id]
102
+ until seen.include?(target)
103
+ return reject_revise(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
104
+
105
+ seen << target
106
+ newer = messages.reverse.find do |message|
107
+ Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
108
+ end
109
+ return ReviseAnchor.new(turn_id: target) unless newer
110
+
111
+ target = (newer[:turn_id] || newer["turn_id"]).to_s
112
+ end
113
+ reject_revise(turn.revises, :cycle)
114
+ end
115
+
116
+ def reject_revise(anchor, reason)
117
+ Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
118
+ nil
119
+ end
120
+
121
+ # Whether +quote_text+ is in +answer+ as the model wrote it, or as the
122
+ # pages show it: a page quotes its own text, which reads the session's
123
+ # folder relative, the home folder as ~ and a secret shape as
124
+ # [redacted] (a participant's page gets it so, Redact;
125
+ # the owner's page rewrites its quote the same way, claim_link.js
126
+ # normalizeClaim, so claim links match both pages). +source+ is the
127
+ # session whose folder that is.
128
+ def quote_in?(quote_text, answer, source)
129
+ return true if comparison_text(answer).include?(quote_text)
130
+
131
+ viewer = Redact::Viewer.new(cwd: source.working_directory)
132
+ comparison_text(Redact.text(answer, viewer)).include?(quote_text)
133
+ end
134
+
135
+ def source_session(session, anchor, state_dir)
136
+ return session unless session.verify_fresh
137
+
138
+ fresh = session.verify_fresh
139
+ return reject_verify(anchor, :fresh_anchor_mismatch) unless fresh.anchor == anchor
140
+ return reject_verify(anchor, :fresh_parent_missing) unless session.parent_id
141
+
142
+ Session.load(session.parent_id, state_dir: state_dir)
143
+ rescue ArgumentError, SystemCallError, IOError => e
144
+ Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason: :source_session_unavailable,
145
+ error: e.class.name)
146
+ nil
147
+ end
148
+
149
+ def comparison_text(text)
150
+ OutputFormatter.strip_markup(text)
151
+ .gsub(/!\[([^\]]*)\]\([^)]+\)/, '\1')
152
+ .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
153
+ .gsub(/^[ \t]*(?:\#{1,6}[ \t]+|>[ \t]?|[-+*][ \t]+|\d+[.)][ \t]+)/, "")
154
+ .gsub(/(\*\*|__|~~|`+)(.+?)\1/m, '\2')
155
+ .gsub(/(?<!\w)\*(\S(?:.*?\S)?)\*(?!\w)/m, '\1')
156
+ .gsub(/(?<!\w)_(\S(?:.*?\S)?)_(?!\w)/m, '\1')
157
+ .tr("|", " ") # a table row's cells; the page copies them without the pipes
158
+ .gsub(/\s+/, " ")
159
+ .strip
160
+ end
161
+
162
+ def reject_verify(anchor, reason)
163
+ Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason:)
164
+ nil
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../context_note"
4
+ require_relative "../log"
5
+ require_relative "../model_profile"
6
+ require_relative "../prompt_warmup"
7
+
8
+ module Samagotchi
9
+ module TurnStages
10
+ # The turn-end warm-up (PromptWarmup, cache.warmup): after a completed
11
+ # turn, the next turn's prompt prefilled up to its user message while
12
+ # the user reads. Called after Engine#complete_turn, so the context
13
+ # judge's asks started in the turn run first: on a shared local host the
14
+ # warm-up queues behind them. No state of its own: the PromptWarmup
15
+ # keeps its pin and thread.
16
+ class Warmup
17
+ # @param prompt_warmup [PromptWarmup] the Engine's warm-up (the kernel asks it for a slot pin)
18
+ # @param kernel [KernelLoop] the warm-up prompt, the last slot, warmed_up!
19
+ # @param turn_state [TurnState] whether steers are carried to the next turn
20
+ # @param model_settings [SessionModelSettings] the effective model now, the next turn's LLM context strategy
21
+ # @param reminder_queue [ReminderQueue] whether a reminder is due
22
+ # @param system_prompt [#call] (model) the system prompt (Engine#system_prompt): called last, only
23
+ # once every check passed, as building it has side effects (the memory index metric, the prompt notes)
24
+ def initialize(prompt_warmup:, kernel:, turn_state:, model_settings:, reminder_queue:, system_prompt:)
25
+ @prompt_warmup = prompt_warmup
26
+ @kernel = kernel
27
+ @turn_state = turn_state
28
+ @model_settings = model_settings
29
+ @reminder_queue = reminder_queue
30
+ @system_prompt = system_prompt
31
+ end
32
+
33
+ # After a completed turn, prefill the next turn's prompt up to its user
34
+ # message while the user reads (PromptWarmup, cache.warmup). Only on a
35
+ # local llama.cpp host on the native loop, and not when the next turn's
36
+ # start differs anyway or starts at once: a reminder due, a continue
37
+ # offer (the turn ran out of steps), a carried steer or queued input.
38
+ # Never fails the turn. Each read comes only once the checks before it
39
+ # passed: the model resolved after the turn's own outcome, the probe
40
+ # and the prompt only on a target.
41
+ # @param next_turn_waiting [#call, nil] the host's probe: true when the next turn starts at once or none
42
+ # follows (Engine#next_turn_waiting); nil: neither
43
+ def after(turn, result, next_turn_waiting:)
44
+ return if result.canceled? || result.resumable?
45
+
46
+ model = @model_settings.effective_model
47
+ client = model.client
48
+ return unless target?(model, client)
49
+ return if @reminder_queue.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.call(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; the turn's settings
54
+ # (TurnContext, set in Engine#generate) for the rest.
55
+ settings = turn.context.settings
56
+ prompt, images = @kernel.warmup_prompt(messages, llm_context: @model_settings.turn_llm_context || settings.llm_context,
57
+ vision: settings.vision)
58
+ return unless prompt
59
+
60
+ # The model name as the native run sent it (KernelLoop#run's
61
+ # completion name: the default model for a blank one).
62
+ started = @prompt_warmup.start(client: client, prompt: prompt, model: ModelProfile.required_model_name(settings.model_name),
63
+ slot: @kernel.last_slot, images: images)
64
+ @kernel.warmed_up! if started
65
+ rescue StandardError => e
66
+ Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
67
+ end
68
+
69
+ private
70
+
71
+ # Never a paid or remote host (cache.warmup auto means local only), nor
72
+ # the chat loop (Splash keeps its own cache; llama.cpp's chat path is
73
+ # not measured), nor a client that can't warm up (a spec's stub).
74
+ def target?(model, client)
75
+ return false if model.chat? || model.entry.remote?
76
+ return false unless client.respond_to?(:warm_up)
77
+
78
+ PromptWarmup.enabled?
79
+ end
80
+ end
81
+ end
82
+ end
@@ -6,7 +6,8 @@ module Samagotchi
6
6
  # A session's turn as other threads see it: whether one runs, its cancel
7
7
  # controller and event sink, the plugin steers waiting for its next
8
8
  # boundary, whether a person's message waits for it (its caller's
9
- # input_waiting: probe), and the activity clock the idle jobs read.
9
+ # input_waiting: probe), the turn's TurnContext (#context, and the last
10
+ # turn's, #last_context), and the activity clock the idle jobs read.
10
11
  #
11
12
  # The turn thread writes it (Engine#begin_turn, #release_turn); the idle
12
13
  # scheduler, the bridge's request threads, plugin threads and the UIs
@@ -27,6 +28,8 @@ module Samagotchi
27
28
  @ended = false
28
29
  @input_waiting = nil
29
30
  @input_waiting_warned = false
31
+ @context = nil
32
+ @last_context = nil
30
33
  @steers = []
31
34
  @carried_steers = []
32
35
  end
@@ -70,12 +73,16 @@ module Samagotchi
70
73
  end
71
74
 
72
75
  # A turn begins, on the calling thread (the turn's own): running, with
73
- # its controller, sink and input_waiting probe, in one step.
76
+ # its context, controller, sink and input_waiting probe, in one step, so
77
+ # no reader sees a running turn with the previous turn's context.
78
+ # @param context [TurnContext, nil] the turn's, built before (nil: none,
79
+ # a spec's bare turn)
74
80
  # @param input_waiting [#call, nil] true while a person's message waits
75
81
  # for the turn's next boundary (Engine#input_waiting?); nil: never
76
- def begin!(controller:, sink:, input_waiting: nil)
82
+ def begin!(controller:, sink:, context: nil, input_waiting: nil)
77
83
  @lock.synchronize do
78
84
  @running = true
85
+ @context = context
79
86
  @controller = controller
80
87
  @sink = sink
81
88
  @thread = Thread.current
@@ -87,10 +94,18 @@ module Samagotchi
87
94
  end
88
95
  end
89
96
 
90
- # The turn is over: not running, no controller, sink, probe or steers.
97
+ # The turn is over: not running, no context, controller, sink, probe or
98
+ # steers. Its context stays as the last one (#last_context); one that
99
+ # never got its settings (a turn stopped before Engine#generate) keeps
100
+ # the last settings there, as the kernel kept the last turn's before.
91
101
  # @return [Array<Hash>] the steers it never took ({text:, source:})
92
102
  def finish!
93
103
  @lock.synchronize do
104
+ if @context
105
+ settings = @context.settings || @last_context&.settings
106
+ @last_context = settings.equal?(@context.settings) ? @context : @context.with(settings: settings)
107
+ end
108
+ @context = nil
94
109
  @running = false
95
110
  @controller = nil
96
111
  @sink = nil
@@ -107,6 +122,33 @@ module Samagotchi
107
122
  @lock.synchronize { @ended = true }
108
123
  end
109
124
 
125
+ # @return [TurnContext, nil] the running turn's context (nil between
126
+ # turns); its settings are nil until Engine#generate
127
+ def context
128
+ @lock.synchronize { @context }
129
+ end
130
+
131
+ # @return [TurnContext, nil] the last finished turn's context (nil
132
+ # before any), kept while the next turn runs
133
+ def last_context
134
+ @lock.synchronize { @last_context }
135
+ end
136
+
137
+ # The turn thread's new context for its running turn (Engine#generate's
138
+ # settings): one assignment of a frozen value. Only the turn's own
139
+ # thread, and only for the same turn.
140
+ # @param context [TurnContext]
141
+ # @raise [ArgumentError] from another thread, outside a turn, or for
142
+ # another turn's context
143
+ def replace_context(context)
144
+ @lock.synchronize do
145
+ raise ArgumentError, "no turn runs on this thread" unless @running && @thread.equal?(Thread.current)
146
+ raise ArgumentError, "not the running turn's context" unless @context.nil? || @context.id == context.id
147
+
148
+ @context = context
149
+ end
150
+ end
151
+
110
152
  # @return [CancellationController, nil] the running turn's controller
111
153
  def controller
112
154
  @lock.synchronize { @controller }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.154.1"
4
+ VERSION = "0.156.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -24,7 +24,7 @@ module Samagotchi
24
24
  # @param target [HostRegistry::ModelTarget]
25
25
  # @param profile [ModelProfile, nil] the model's prompt profile (native)
26
26
  # @param adapter [LLM::OpenAIChat, nil] the chat host's adapter
27
- # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
27
+ # @param models [Hash{String => ModelSettings}, nil] ModelSettings.all (specs)
28
28
  # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
29
29
  # @return [Answer]
30
30
  def self.for(target, names:, profile: nil, adapter: nil, models: nil)
@@ -47,11 +47,11 @@ module Samagotchi
47
47
 
48
48
  def self.configured(_target, entry, models, names)
49
49
  models ||= begin
50
- ConfigFile.model_settings
50
+ ModelSettings.all
51
51
  rescue StandardError
52
52
  {}
53
53
  end
54
- if (setting = ConfigFile.model_setting(names, :vision, models: models))
54
+ if (setting = ModelSettings.find(names, :vision, models: models))
55
55
  return Answer.new(value: setting.value, reason: setting.value ? nil : "models: #{setting.key} sets vision: false")
56
56
  end
57
57
  return nil if entry.vision.nil?