samagotchi 0.154.1 → 0.155.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/bundles.md +21 -0
  5. data/docs/cli.md +1 -1
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/anthropic.md +4 -4
  9. data/docs/internals/attached-context.md +8 -6
  10. data/docs/internals/block-renderers.md +8 -1
  11. data/docs/internals/engine.md +220 -99
  12. data/docs/internals/inline-widgets.md +136 -0
  13. data/docs/internals/objectives.md +8 -6
  14. data/docs/internals/participants.md +5 -5
  15. data/docs/internals/prompt-caching.md +9 -5
  16. data/docs/internals/web-frontend.md +1 -1
  17. data/docs/internals/working-set.md +2 -1
  18. data/docs/plugins.md +2 -0
  19. data/docs/releasing.md +1 -1
  20. data/docs/sessions.md +2 -2
  21. data/docs/testing.md +1 -1
  22. data/lib/samagotchi/block_renderers.rb +5 -0
  23. data/lib/samagotchi/bundle_command/check.rb +1 -1
  24. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  25. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  26. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  27. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  28. data/lib/samagotchi/engine/context_judge.rb +19 -125
  29. data/lib/samagotchi/engine/guardrails.rb +4 -2
  30. data/lib/samagotchi/engine/messages.rb +9 -6
  31. data/lib/samagotchi/engine/objectives.rb +22 -90
  32. data/lib/samagotchi/engine/participants.rb +15 -10
  33. data/lib/samagotchi/engine/plugins.rb +3 -1
  34. data/lib/samagotchi/engine/questions.rb +1 -1
  35. data/lib/samagotchi/engine/side_requests.rb +5 -2
  36. data/lib/samagotchi/engine/toolkit.rb +33 -190
  37. data/lib/samagotchi/engine/turn_end.rb +1 -1
  38. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  39. data/lib/samagotchi/engine/verify.rb +7 -143
  40. data/lib/samagotchi/engine.rb +162 -99
  41. data/lib/samagotchi/engine_peers.rb +1 -1
  42. data/lib/samagotchi/event_fanout.rb +18 -12
  43. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  44. data/lib/samagotchi/guardrails/gate.rb +41 -40
  45. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  46. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  47. data/lib/samagotchi/guardrails/targets.rb +6 -4
  48. data/lib/samagotchi/guardrails_command.rb +1 -1
  49. data/lib/samagotchi/inline_widgets.rb +325 -0
  50. data/lib/samagotchi/kernel_loop.rb +190 -213
  51. data/lib/samagotchi/llm/backend.rb +3 -1
  52. data/lib/samagotchi/llm/chat_loop.rb +63 -41
  53. data/lib/samagotchi/llm/native_backend.rb +3 -2
  54. data/lib/samagotchi/llm_context_forget.rb +5 -3
  55. data/lib/samagotchi/model_binding.rb +3 -3
  56. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  57. data/lib/samagotchi/question_desk.rb +1 -1
  58. data/lib/samagotchi/revise_anchor.rb +1 -1
  59. data/lib/samagotchi/system_prompt.rb +37 -30
  60. data/lib/samagotchi/testing/boundary.rb +7 -0
  61. data/lib/samagotchi/testing/bundle.rb +7 -5
  62. data/lib/samagotchi/testing/install.rb +3 -2
  63. data/lib/samagotchi/tool_activity.rb +8 -6
  64. data/lib/samagotchi/tool_declarations.rb +4 -4
  65. data/lib/samagotchi/tool_response.rb +4 -3
  66. data/lib/samagotchi/tool_runner.rb +91 -100
  67. data/lib/samagotchi/toolkit_binding.rb +40 -14
  68. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  69. data/lib/samagotchi/tools/registry.rb +43 -40
  70. data/lib/samagotchi/tools/tool_index.rb +135 -88
  71. data/lib/samagotchi/tools/tool_surface.rb +173 -0
  72. data/lib/samagotchi/turn_context.rb +67 -0
  73. data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
  74. data/lib/samagotchi/turn_stages/objectives.rb +146 -0
  75. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
  76. data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
  77. data/lib/samagotchi/turn_stages/warmup.rb +83 -0
  78. data/lib/samagotchi/turn_state.rb +46 -4
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  81. data/lib/samagotchi/web/message_display.rb +1 -1
  82. data/lib/samagotchi/web/public/app.js +5 -1
  83. data/lib/samagotchi/web/public/block_host.js +74 -199
  84. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  85. data/lib/samagotchi/web/public/block_source.js +7 -4
  86. data/lib/samagotchi/web/public/block_watch.js +256 -0
  87. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  88. data/lib/samagotchi/web/public/index.html +28 -9
  89. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  90. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  91. data/lib/samagotchi/web/public/participant.js +1 -1
  92. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  93. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  94. data/lib/samagotchi/web/public/text_roots.js +1 -1
  95. data/lib/samagotchi/web/public/verify_target.js +1 -1
  96. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  97. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  98. data/lib/samagotchi/web/public/widgets.css +70 -0
  99. metadata +17 -1
@@ -4,9 +4,9 @@ module Samagotchi
4
4
  class Engine
5
5
  # The session's toolkit: the kits and what a turn declares
6
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.
7
+ # appends, and System 1's toolkit suggestion (TurnStages::ToolkitSuggestion,
8
+ # forwarded). Reads @toolkit_binding, @tools, @tool_index, @tool_loads,
9
+ # @toolkit_suggestion, @session.
10
10
  module ToolkitPart
11
11
  private
12
12
 
@@ -17,22 +17,29 @@ module Samagotchi
17
17
  def toolkit_explained(toolkit = nil) = @toolkit_binding.explained(toolkit)
18
18
  def toolkit_summary = @toolkit_binding.summary
19
19
 
20
+ # The Registry published for readers off the turn thread
21
+ # (ToolkitBinding#publish!): at the end of the Engine's build, after a
22
+ # turn's staged tool sets and its toolkit apply.
23
+ def publish_tools! = @toolkit_binding.publish!
24
+
20
25
  # The tools this session registered (Tools::Registry), declared or not.
21
26
  attr_reader :tools
22
27
 
23
28
  public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
24
29
 
25
30
  # 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.
31
+ # (Tools::ToolIndex#deferred_name, on the published Registry) that the
32
+ # session neither declares nor loaded with an earlier tool change, for
33
+ # bundle +bundle+. A name that names no tool at all is logged; a
34
+ # declared or removed one is skipped quietly.
30
35
  # @return [Array<String>] the wire names staged
31
36
  def load_tools(names, bundle)
32
37
  added = ToolLoads.added(@session&.messages)
38
+ published = @toolkit_binding.published
39
+ surface = Tools::ToolSurface.for(published)
33
40
  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)
41
+ wire = @tool_index.deferred_name(name, surface)
42
+ Log.info(:plugins, "load_tools_skipped", bundle: bundle, tool: name) unless wire || published.key?(name)
36
43
  wire
37
44
  end
38
45
  @tool_loads.stage(wanted - added, by: bundle)
@@ -79,191 +86,27 @@ module Samagotchi
79
86
  def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
80
87
  private :apply_toolkit!
81
88
 
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
- )
89
+ # System 1's toolkit suggestion (TurnStages::ToolkitSuggestion):
90
+ # prepare_turn starts, settles and abandons it; the Worker warms it.
91
+ attr_reader :toolkit_suggestion
92
+ private :toolkit_suggestion
113
93
 
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
94
+ # On mode, as an idle worker starts (Worker#start_background): the
95
+ # warm-up ask (TurnStages::ToolkitSuggestion#warm!).
96
+ def warm_toolkit_suggestion! = @toolkit_suggestion.warm!(@session)
253
97
  public :warm_toolkit_suggestion!
254
98
 
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")
99
+ # The stage over this Engine's side asks, binding and tools (built in
100
+ # #build_tools_and_plugins, before the plugins load).
101
+ def build_toolkit_suggestion
102
+ TurnStages::ToolkitSuggestion.new(
103
+ side_judgments: @side_judgments, host_registry: @host_registry, toolkit_binding: @toolkit_binding,
104
+ tools: @tools, state_dir: -> { session_state_dir },
105
+ recording: ->(client, judgment, session, mode:) { system_one_recording(client, judgment, session, mode: mode) },
106
+ runs: -> { system_one_runs }
107
+ )
265
108
  end
266
- private :toolkit_suggestion_log
109
+ private :build_toolkit_suggestion
267
110
  end
268
111
  end
269
112
  end
@@ -117,7 +117,7 @@ module Samagotchi
117
117
  # a display version of the answer (AnswerDisplay) and event[:refs]
118
118
  # for refs on it (AnswerRefs).
119
119
  session = turn.session
120
- audit_objectives_outgoing(turn, result) unless canceled || result.exhausted?
120
+ @objectives_stage.outgoing(turn, result) unless canceled || result.exhausted?
121
121
  display = AnswerDisplay.new(session.messages)
122
122
  refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
123
123
  after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
@@ -5,8 +5,9 @@ module Samagotchi
5
5
  # What a turn's steps use before and around the model: its settings,
6
6
  # images and vision, the model ref and resumed run start it generates
7
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.
8
+ # next one (TurnStages::Warmup, built here). Writes @next_turn_waiting;
9
+ # reads @model_settings, @kernel, @prompt_warmup, @thinking_notices,
10
+ # @turn_state, @turn_warmup.
10
11
  module TurnHelpersPart
11
12
  # The turn's settings for the kernel (LLM::TurnSettings): vision,
12
13
  # sampling and thinking, the thinking resolved before the hooks run.
@@ -34,44 +35,20 @@ module Samagotchi
34
35
  # warm-up then. nil: neither.
35
36
  attr_accessor :next_turn_waiting
36
37
 
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?
38
+ # The turn-end warm-up's stage (TurnStages::Warmup).
39
+ attr_reader :turn_warmup
40
+ private :turn_warmup
41
+
42
+ # The stage over the kernel and the Engine's PromptWarmup (built in
43
+ # #initialize, once the kernel is).
44
+ def build_turn_warmup
45
+ TurnStages::Warmup.new(
46
+ prompt_warmup: @prompt_warmup, kernel: @kernel, turn_state: @turn_state, model_settings: @model_settings,
47
+ model: -> { effective_model }, system_prompt: ->(model) { system_prompt(model) },
48
+ reminders_due: -> { reminders_due? }, next_turn_waiting: -> { next_turn_waiting&.call }
49
+ )
73
50
  end
74
- private :warmup_target?
51
+ private :build_turn_warmup
75
52
 
76
53
  # The ref (host:model, as asked) a turn's generations ran on: the model
77
54
  # #generate resolved, after the before_turn hooks (which may switch it).
@@ -2,152 +2,16 @@
2
2
 
3
3
  module Samagotchi
4
4
  class Engine
5
- # Verify and Revise anchors at a turn's end: the opening's stamp, a Revise
6
- # turn's draft checked, a Verify turn's quote checked against its source
7
- # session and its verdict recorded. No ivars of its own: it reads the turn,
8
- # and the sessions it loads through Engine methods.
5
+ # Verify and Revise anchors (TurnStages::VerifyAnchors): #begin_turn
6
+ # checks a Verify turn's claim, #end_turn's event step stamps the opening
7
+ # and records the verdict. Reads @verify_anchors.
9
8
  module VerifyPart
10
- # The turn's anchors, stamped on its opening message (the prompt, or a
11
- # wake turn's note) in the messages it keeps: display metadata, never
12
- # in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
13
- # A Verify turn's claim however it ended; a Revise turn's draft only
14
- # once it completed with an answer (#revision_target). Returns the
15
- # stamped ReviseAnchor (the end event's revises), or nil.
16
- def stamp_opening(messages, turn, outcome)
17
- return unless messages.is_a?(Array)
9
+ private
18
10
 
19
- revises = revision_target(messages, turn, outcome)
20
- fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
21
- return if fields.empty?
11
+ # The anchors' stage (TurnStages::VerifyAnchors).
12
+ attr_reader :verify_anchors
22
13
 
23
- opening = messages.reverse.find do |message|
24
- Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
25
- end
26
- return unless opening
27
-
28
- opening.merge!(fields)
29
- revises
30
- end
31
- private :stamp_opening
32
-
33
- # The turn a Revise turn revised (D6: "this turn revised that one"):
34
- # only a completed turn with an answer (AnswerTail) has revised
35
- # anything. A draft no prompt in the messages carries is no target (a
36
- # stale page, a rolled-back turn); one a stamped turn already revised
37
- # is re-targeted to its stack's top, the newest version, which the
38
- # person meant ("this draft").
39
- def revision_target(messages, turn, outcome)
40
- return unless turn.revises && outcome == "completed"
41
-
42
- slice = AnswerTail.turn_slice(messages, turn.id)
43
- return unless slice&.any? { |message| AnswerTail.answer?(message) }
44
-
45
- target = turn.revises.turn_id
46
- seen = [turn.id]
47
- until seen.include?(target)
48
- return reject_revise_anchor(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
49
-
50
- seen << target
51
- newer = messages.reverse.find do |message|
52
- Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
53
- end
54
- return ReviseAnchor.new(turn_id: target) unless newer
55
-
56
- target = (newer[:turn_id] || newer["turn_id"]).to_s
57
- end
58
- reject_revise_anchor(turn.revises, :cycle)
59
- end
60
- private :revision_target
61
-
62
- def reject_revise_anchor(anchor, reason)
63
- Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
64
- nil
65
- end
66
- private :reject_revise_anchor
67
-
68
- def verified_anchor(session, anchor)
69
- return unless anchor
70
-
71
- source = verify_anchor_source_session(session, anchor)
72
- return unless source
73
-
74
- turn = AnswerTail.turn_slice(Array(source.messages), anchor.turn_id)
75
- return reject_verify_anchor(anchor, :turn_missing) unless turn
76
-
77
- reply = turn.reverse.find { |message| AnswerTail.answer?(message) }
78
- return reject_verify_anchor(anchor, :reply_missing) unless reply
79
-
80
- quote_text = verify_comparison_text(anchor.quote)
81
- quoted = !quote_text.empty? && verify_quote_in?(quote_text, (reply[:content] || reply["content"]).to_s, source)
82
- return reject_verify_anchor(anchor, :quote_missing) unless quoted
83
-
84
- anchor
85
- end
86
- private :verified_anchor
87
-
88
- # Whether +quote_text+ is in +answer+ as the model wrote it, or as the
89
- # pages show it: a page quotes its own text, which reads the session's
90
- # folder relative, the home folder as ~ and a secret shape as
91
- # [redacted] (a participant's page gets it so, Redact;
92
- # the owner's page rewrites its quote the same way, claim_link.js
93
- # normalizeClaim, so claim links match both pages). +source+ is the
94
- # session whose folder that is.
95
- def verify_quote_in?(quote_text, answer, source)
96
- return true if verify_comparison_text(answer).include?(quote_text)
97
-
98
- viewer = Redact::Viewer.new(cwd: source.working_directory)
99
- verify_comparison_text(Redact.text(answer, viewer)).include?(quote_text)
100
- end
101
- private :verify_quote_in?
102
-
103
- def verify_anchor_source_session(session, anchor)
104
- return session unless session.verify_fresh
105
-
106
- fresh = session.verify_fresh
107
- return reject_verify_anchor(anchor, :fresh_anchor_mismatch) unless fresh.anchor == anchor
108
- return reject_verify_anchor(anchor, :fresh_parent_missing) unless session.parent_id
109
-
110
- Session.load(session.parent_id, state_dir: session_state_dir)
111
- rescue ArgumentError, SystemCallError, IOError => e
112
- Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason: :source_session_unavailable,
113
- error: e.class.name)
114
- nil
115
- end
116
- private :verify_anchor_source_session
117
-
118
- def verify_comparison_text(text)
119
- OutputFormatter.strip_markup(text)
120
- .gsub(/!\[([^\]]*)\]\([^)]+\)/, '\1')
121
- .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
122
- .gsub(/^[ \t]*(?:\#{1,6}[ \t]+|>[ \t]?|[-+*][ \t]+|\d+[.)][ \t]+)/, "")
123
- .gsub(/(\*\*|__|~~|`+)(.+?)\1/m, '\2')
124
- .gsub(/(?<!\w)\*(\S(?:.*?\S)?)\*(?!\w)/m, '\1')
125
- .gsub(/(?<!\w)_(\S(?:.*?\S)?)_(?!\w)/m, '\1')
126
- .tr("|", " ") # a table row's cells; the page copies them without the pipes
127
- .gsub(/\s+/, " ")
128
- .strip
129
- end
130
- private :verify_comparison_text
131
-
132
- def reject_verify_anchor(anchor, reason)
133
- Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason:)
134
- nil
135
- end
136
- private :reject_verify_anchor
137
-
138
- def record_verdict(turn, outcome)
139
- return unless outcome == "completed" && turn.verify
140
-
141
- answer = AnswerTail.find(turn.session.messages, turn_id: turn.id)
142
- parsed = Verdict.parse(answer && (answer[:content] || answer["content"]))
143
- return unless parsed
144
-
145
- record = { anchor: turn.verify.to_h, verdict: parsed.verdict, reason: parsed.reason,
146
- by: turn.origin.author, check_turn_id: turn.id, at: Time.now.iso8601(3) }
147
- turn.session.verdicts = turn.session.verdicts + [record]
148
- record
149
- end
150
- private :record_verdict
14
+ def build_verify_anchors = TurnStages::VerifyAnchors.new(state_dir: -> { session_state_dir })
151
15
  end
152
16
  end
153
17
  end