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
@@ -0,0 +1,261 @@
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
+ # @param state_dir [#call] the sessions dir (the suggestion log's place)
27
+ # @param recording [#call] (client, judgment, session, mode:) → the client saving its runs
28
+ # @param runs [#call] the Engine's SystemOne::Runs
29
+ def initialize(side_judgments:, host_registry:, toolkit_binding:, tools:, state_dir:, recording:, runs:)
30
+ @side_judgments = side_judgments
31
+ @host_registry = host_registry
32
+ @toolkit_binding = toolkit_binding
33
+ @tools = tools
34
+ @state_dir = state_dir
35
+ @recording = recording
36
+ @runs = runs
37
+ @pending = nil
38
+ @pending_turn = nil
39
+ @warmup_thread = nil
40
+ end
41
+
42
+ # For specs: the running warm-up (#warm!), nil when none started or it
43
+ # was stopped.
44
+ attr_reader :warmup_thread
45
+
46
+ # For specs: the ask's thread (SideJudgments' :toolkit kind), nil before any.
47
+ def ask_thread = @side_judgments.thread(:toolkit)
48
+
49
+ # Start the toolkit classifier after :turn_started, for audit and on
50
+ # mode alike (one eligibility, one snapshot, one thread). All session
51
+ # and tool-registry reads happen here; the thread receives only this
52
+ # snapshot. Audit never blocks: the thread logs its line. On mode
53
+ # keeps the ask's Pending for +turn+, which #settle joins before
54
+ # apply_toolkit!, with the turn's controller as the request's cancel
55
+ # controller.
56
+ # @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
57
+ def start(turn)
58
+ @pending = nil
59
+ @pending_turn = turn
60
+ @pending = ask(turn)
61
+ end
62
+
63
+ # On mode, before apply_toolkit!: wait for +turn+'s ask (#start) up to
64
+ # System 1's timeout from its start, or until a Stop, and put the
65
+ # session on a candidate picked with confidence ≥ threshold
66
+ # (ToolkitBinding#suggest: origin suggested, saved, the notice). Any
67
+ # other outcome (the default, below threshold, undecided, no judgment,
68
+ # a timeout, a Stop) leaves the session on its default. Nothing here
69
+ # sends a request; prepare_turn's cancel check ends a stopped turn. A
70
+ # Stop wins over an answer already in (Pending#wait checks it first).
71
+ def settle(turn)
72
+ pending = pending_for(turn)
73
+ return if pending.nil?
74
+
75
+ outcome, judged = pending.wait(turn.controller)
76
+ if outcome == :timeout
77
+ # The session starts on its default without a word otherwise: a
78
+ # slow System 1 host (a local one) is the usual cause.
79
+ timeout_ms = SystemOne::Client.configured_timeout_ms
80
+ Log.warn(:toolkit, "toolkit_suggest_timeout", session_id: turn.session.id, timeout_ms: timeout_ms,
81
+ reason: "System 1 gave no answer within system_one.timeout_ms")
82
+ @toolkit_binding.fell_back(turn.session, "System 1 took over #{timeout_ms} ms")
83
+ return
84
+ end
85
+ unless outcome == :answered
86
+ Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
87
+ return
88
+ end
89
+ return if judged.nil?
90
+
91
+ pending.record(outcome_of(turn.session, judged))
92
+ rescue StandardError => e
93
+ ToolkitSuggest.warn(e)
94
+ pending&.record("failed")
95
+ end
96
+
97
+ # +turn+ ended before it settled its ask (a Stop at the probes, a
98
+ # failure, an Interrupt): the ask's line says +reason+
99
+ # (Pending#abandon; nothing once it was settled, so prepare_turn's
100
+ # ensure calls it too). Nothing when +turn+ started no ask.
101
+ def abandon(turn, reason)
102
+ pending_for(turn)&.abandon(reason)
103
+ end
104
+
105
+ # +turn+'s suggestion is done (prepare_turn's ensure, after #abandon):
106
+ # its Pending and the turn are let go, so nothing of it outlives its
107
+ # preparation. Nothing for another turn's.
108
+ def finish(turn)
109
+ return unless @pending_turn.equal?(turn)
110
+
111
+ @pending = nil
112
+ @pending_turn = nil
113
+ end
114
+
115
+ # On mode, as an idle worker starts (Worker#start_background): one ask
116
+ # with the real question and a stand-in prompt, on its own thread,
117
+ # never logged and never waited for, so System 1's server has the
118
+ # question's prefix cached for the first prompt. Only for a session a
119
+ # first prompt could be asked for: origin default, no model reply, not
120
+ # a delegate child or a review session, at least two candidates; and
121
+ # not when the worker starts on a first prompt (that ask comes at once).
122
+ def warm!(session)
123
+ return unless Config.get("toolkit.suggest.mode").to_s == "on"
124
+
125
+ return if session.nil? || session.toolkit_origin != "default" || session.delegate? || session.review?
126
+ return if ToolkitSuggest.model_reply?(session.messages) || !session.last_prompt.to_s.strip.empty?
127
+
128
+ default = Toolkit.of(session.toolkit)
129
+ kits = @toolkit_binding.kits
130
+ return if ToolkitSuggest.candidates(kits, default_name: default.name).size < 2
131
+
132
+ client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
133
+ return unless client
134
+
135
+ project = File.basename(session.project_root.to_s)
136
+ @warmup_thread = Thread.new do
137
+ ToolkitSuggest.warm(client: client, default: default, kits: kits, project: project)
138
+ end
139
+ rescue StandardError => e
140
+ ToolkitSuggest.warn(e)
141
+ nil
142
+ end
143
+
144
+ # A still-running warm-up, stopped: a real ask (or the worker leaving)
145
+ # doesn't wait behind it.
146
+ def stop_warmup
147
+ @warmup_thread&.kill
148
+ @warmup_thread = nil
149
+ end
150
+
151
+ private
152
+
153
+ # The Pending #start kept for this very turn: another turn's (one that
154
+ # never reached #start) is never touched.
155
+ def pending_for(turn) = (@pending if @pending_turn.equal?(turn))
156
+
157
+ def ask(turn)
158
+ mode = Config.get("toolkit.suggest.mode").to_s
159
+ return if mode == "off"
160
+
161
+ joined = mode == "on"
162
+ session = turn.session
163
+ default = Toolkit.of(session.toolkit)
164
+ log = suggestion_log
165
+ # The predicate checks the session first; the kits (memory kits are
166
+ # read from disk) and the log (read whole) only when it gets there.
167
+ kits = nil
168
+ candidates = nil
169
+ return unless ToolkitSuggest.eligible?(
170
+ session: session, prompt: turn.prompt, author: turn.origin.author,
171
+ candidate_count: lambda {
172
+ kits = @toolkit_binding.kits
173
+ candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
174
+ candidates.size
175
+ },
176
+ last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
177
+ defer_turns: Config.get("toolkit.suggest.defer_turns"),
178
+ defer_tokens: Config.get("toolkit.suggest.defer_tokens")
179
+ )
180
+
181
+ # One ask at a time: an eligible turn while the last one still runs
182
+ # goes unasked, and says so.
183
+ if @side_judgments.in_flight?(:toolkit)
184
+ Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
185
+ return
186
+ end
187
+
188
+ client = @recording.call(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit),
189
+ ToolkitSuggest::JUDGMENT, session, mode: Config.get("toolkit.suggest.mode").to_s)
190
+ return unless client
191
+
192
+ # The warm-up's only value is the cache: the real ask never queues
193
+ # behind it.
194
+ stop_warmup
195
+
196
+ # Rules, not tool lists: this runs before await_init_tasks and
197
+ # apply_staged_tools!, so a first turn's plugin/MCP tools aren't
198
+ # registered yet; the report expands the rules (ToolkitSuggest.rules).
199
+ snapshots = candidates.map do |kit|
200
+ ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
201
+ rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
202
+ end.freeze
203
+ default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
204
+ rules: ToolkitSuggest.rules(default.resolve(kits)))
205
+ registered = Tools::ToolSurface.for(@tools, toolkit: nil).declared
206
+ .reject { |entry| entry.source == Tools::ToolIndex::SOURCE }
207
+ core_tools, plugin_tools = registered.partition(&:core?).map { |entries| entries.map { |entry| entry.name.dup.freeze }.freeze }
208
+ state = ToolkitSuggest.question(
209
+ default: default, kits: kits, project: File.basename(session.project_root.to_s),
210
+ prompt: turn.prompt
211
+ ).last
212
+ snapshot = ToolkitSuggest::Snapshot.new(
213
+ session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
214
+ default: default_snapshot, candidates: snapshots, core_tools: core_tools, plugin_tools: plugin_tools,
215
+ state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
216
+ )
217
+ threshold = Config.get("toolkit.suggest.threshold")
218
+ runs = session.scratch ? nil : @runs.call
219
+ pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
220
+ acted: runs && ->(judged, did) { runs.acted(judged.fields[:run_id], did) },
221
+ timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
222
+ # On mode: cut by a Stop while the turn waits (Pending#wait), not
223
+ # by one after it went on; audit's ask is never cut.
224
+ controller = joined ? pending.cancel_controller : nil
225
+ @side_judgments.start(:toolkit, warn: ToolkitSuggest.method(:warn), finish: pending.method(:finish)) do
226
+ ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
227
+ cancel_controller: controller)
228
+ end
229
+ joined ? pending : nil
230
+ rescue StandardError => e
231
+ ToolkitSuggest.warn(e)
232
+ nil
233
+ end
234
+
235
+ # What the turn does with an answered ask, as the log line's did:
236
+ # suggested (switched), chosen (the toolkit isn't the default any
237
+ # more), unavailable (the kit is gone), defer or default.
238
+ def outcome_of(session, judged)
239
+ case judged.would
240
+ when "suggested"
241
+ return "chosen" unless session.toolkit_origin == "default"
242
+ return "unavailable" unless @toolkit_binding.kits.key?(judged.suggestion.kit)
243
+
244
+ switched = @toolkit_binding.suggest(session, judged.suggestion.kit, confidence: judged.suggestion.confidence,
245
+ deferred: ToolkitSuggest.model_reply?(session.messages))
246
+ switched ? "suggested" : "chosen"
247
+ when "defer" then "defer"
248
+ when "no_judgment"
249
+ # System 1 failed (its own warn) or answered nothing usable.
250
+ @toolkit_binding.fell_back(session, "System 1 gave no answer")
251
+ "default"
252
+ else "default"
253
+ end
254
+ end
255
+
256
+ def suggestion_log
257
+ SideJudgments.log(ToolkitSuggest.log_path(@state_dir.call), tag: :toolkit, event: "toolkit_suggest_log_failed")
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,169 @@
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
+ class VerifyAnchors
22
+ # @param state_dir [#call] the sessions dir (a fresh Verify child's parent is loaded from it)
23
+ def initialize(state_dir:)
24
+ @state_dir = state_dir
25
+ end
26
+
27
+ # A Verify turn's claim (Engine#begin_turn), when its quote is in the
28
+ # source session's answer for that turn; nil (logged) otherwise.
29
+ # @param anchor [VerifyAnchor, nil]
30
+ # @return [VerifyAnchor, nil]
31
+ def verified(session, anchor)
32
+ return unless anchor
33
+
34
+ source = source_session(session, anchor)
35
+ return unless source
36
+
37
+ turn = AnswerTail.turn_slice(Array(source.messages), anchor.turn_id)
38
+ return reject_verify(anchor, :turn_missing) unless turn
39
+
40
+ reply = turn.reverse.find { |message| AnswerTail.answer?(message) }
41
+ return reject_verify(anchor, :reply_missing) unless reply
42
+
43
+ quote_text = comparison_text(anchor.quote)
44
+ quoted = !quote_text.empty? && quote_in?(quote_text, (reply[:content] || reply["content"]).to_s, source)
45
+ return reject_verify(anchor, :quote_missing) unless quoted
46
+
47
+ anchor
48
+ end
49
+
50
+ # The turn's anchors, stamped on its opening message (the prompt, or a
51
+ # wake turn's note) in the messages it keeps: display metadata, never
52
+ # in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
53
+ # A Verify turn's claim however it ended; a Revise turn's draft only
54
+ # once it completed with an answer (#revision_target). Returns the
55
+ # stamped ReviseAnchor (the end event's revises), or nil.
56
+ def stamp_opening(messages, turn, outcome)
57
+ return unless messages.is_a?(Array)
58
+
59
+ revises = revision_target(messages, turn, outcome)
60
+ fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
61
+ return if fields.empty?
62
+
63
+ opening = messages.reverse.find do |message|
64
+ Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
65
+ end
66
+ return unless opening
67
+
68
+ opening.merge!(fields)
69
+ revises
70
+ end
71
+
72
+ # A completed Verify turn's verdict (Verdict.parse of its answer), added
73
+ # to the session's verdicts; the record (the end event's verdict), or nil.
74
+ def record_verdict(turn, outcome)
75
+ return unless outcome == "completed" && turn.verify
76
+
77
+ answer = AnswerTail.find(turn.session.messages, turn_id: turn.id)
78
+ parsed = Verdict.parse(answer && (answer[:content] || answer["content"]))
79
+ return unless parsed
80
+
81
+ record = { anchor: turn.verify.to_h, verdict: parsed.verdict, reason: parsed.reason,
82
+ by: turn.origin.author, check_turn_id: turn.id, at: Time.now.iso8601(3) }
83
+ turn.session.verdicts = turn.session.verdicts + [record]
84
+ record
85
+ end
86
+
87
+ private
88
+
89
+ # The turn a Revise turn revised (D6: "this turn revised that one"):
90
+ # only a completed turn with an answer (AnswerTail) has revised
91
+ # anything. A draft no prompt in the messages carries is no target (a
92
+ # stale page, a rolled-back turn); one a stamped turn already revised
93
+ # is re-targeted to its stack's top, the newest version, which the
94
+ # person meant ("this draft").
95
+ def revision_target(messages, turn, outcome)
96
+ return unless turn.revises && outcome == "completed"
97
+
98
+ slice = AnswerTail.turn_slice(messages, turn.id)
99
+ return unless slice&.any? { |message| AnswerTail.answer?(message) }
100
+
101
+ target = turn.revises.turn_id
102
+ seen = [turn.id]
103
+ until seen.include?(target)
104
+ return reject_revise(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
105
+
106
+ seen << target
107
+ newer = messages.reverse.find do |message|
108
+ Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
109
+ end
110
+ return ReviseAnchor.new(turn_id: target) unless newer
111
+
112
+ target = (newer[:turn_id] || newer["turn_id"]).to_s
113
+ end
114
+ reject_revise(turn.revises, :cycle)
115
+ end
116
+
117
+ def reject_revise(anchor, reason)
118
+ Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
119
+ nil
120
+ end
121
+
122
+ # Whether +quote_text+ is in +answer+ as the model wrote it, or as the
123
+ # pages show it: a page quotes its own text, which reads the session's
124
+ # folder relative, the home folder as ~ and a secret shape as
125
+ # [redacted] (a participant's page gets it so, Redact;
126
+ # the owner's page rewrites its quote the same way, claim_link.js
127
+ # normalizeClaim, so claim links match both pages). +source+ is the
128
+ # session whose folder that is.
129
+ def quote_in?(quote_text, answer, source)
130
+ return true if comparison_text(answer).include?(quote_text)
131
+
132
+ viewer = Redact::Viewer.new(cwd: source.working_directory)
133
+ comparison_text(Redact.text(answer, viewer)).include?(quote_text)
134
+ end
135
+
136
+ def source_session(session, anchor)
137
+ return session unless session.verify_fresh
138
+
139
+ fresh = session.verify_fresh
140
+ return reject_verify(anchor, :fresh_anchor_mismatch) unless fresh.anchor == anchor
141
+ return reject_verify(anchor, :fresh_parent_missing) unless session.parent_id
142
+
143
+ Session.load(session.parent_id, state_dir: @state_dir.call)
144
+ rescue ArgumentError, SystemCallError, IOError => e
145
+ Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason: :source_session_unavailable,
146
+ error: e.class.name)
147
+ nil
148
+ end
149
+
150
+ def comparison_text(text)
151
+ OutputFormatter.strip_markup(text)
152
+ .gsub(/!\[([^\]]*)\]\([^)]+\)/, '\1')
153
+ .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
154
+ .gsub(/^[ \t]*(?:\#{1,6}[ \t]+|>[ \t]?|[-+*][ \t]+|\d+[.)][ \t]+)/, "")
155
+ .gsub(/(\*\*|__|~~|`+)(.+?)\1/m, '\2')
156
+ .gsub(/(?<!\w)\*(\S(?:.*?\S)?)\*(?!\w)/m, '\1')
157
+ .gsub(/(?<!\w)_(\S(?:.*?\S)?)_(?!\w)/m, '\1')
158
+ .tr("|", " ") # a table row's cells; the page copies them without the pipes
159
+ .gsub(/\s+/, " ")
160
+ .strip
161
+ end
162
+
163
+ def reject_verify(anchor, reason)
164
+ Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason:)
165
+ nil
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,83 @@
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 next turn's LLM context strategy
21
+ # @param model [#call] the effective model now (Engine#effective_model)
22
+ # @param system_prompt [#call] (model) the system prompt (Engine#system_prompt)
23
+ # @param reminders_due [#call] whether a reminder is due (Engine#reminders_due?)
24
+ # @param next_turn_waiting [#call] whether the next turn starts at once or none follows
25
+ # (Engine#next_turn_waiting, called)
26
+ def initialize(prompt_warmup:, kernel:, turn_state:, model_settings:, model:, system_prompt:, reminders_due:,
27
+ next_turn_waiting:)
28
+ @prompt_warmup = prompt_warmup
29
+ @kernel = kernel
30
+ @turn_state = turn_state
31
+ @model_settings = model_settings
32
+ @model = model
33
+ @system_prompt = system_prompt
34
+ @reminders_due = reminders_due
35
+ @next_turn_waiting = next_turn_waiting
36
+ end
37
+
38
+ # After a completed turn, prefill the next turn's prompt up to its user
39
+ # message while the user reads (PromptWarmup, cache.warmup). Only on a
40
+ # local llama.cpp host on the native loop, and not when the next turn's
41
+ # start differs anyway or starts at once: a reminder due, a continue
42
+ # offer (the turn ran out of steps), a carried steer or queued input.
43
+ # Never fails the turn.
44
+ def after(turn, result)
45
+ return if result.canceled? || result.resumable?
46
+
47
+ model = @model.call
48
+ client = model.client
49
+ return unless target?(model, client)
50
+ return if @reminders_due.call || @turn_state.carried_steers? || @next_turn_waiting.call
51
+
52
+ messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: @system_prompt.call(model) })
53
+ # Under the next turn's strategy (a /model switch may have changed it);
54
+ # the last turn's when it can't be resolved; the turn's settings
55
+ # (TurnContext, set in Engine#generate) for the rest.
56
+ settings = turn.context.settings
57
+ prompt, images = @kernel.warmup_prompt(messages, llm_context: @model_settings.turn_llm_context || settings.llm_context,
58
+ vision: settings.vision)
59
+ return unless prompt
60
+
61
+ # The model name as the native run sent it (KernelLoop#run's
62
+ # completion name: the default model for a blank one).
63
+ started = @prompt_warmup.start(client: client, prompt: prompt, model: ModelProfile.required_model_name(settings.model_name),
64
+ slot: @kernel.last_slot, images: images)
65
+ @kernel.warmed_up! if started
66
+ rescue StandardError => e
67
+ Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
68
+ end
69
+
70
+ private
71
+
72
+ # Never a paid or remote host (cache.warmup auto means local only), nor
73
+ # the chat loop (Splash keeps its own cache; llama.cpp's chat path is
74
+ # not measured), nor a client that can't warm up (a spec's stub).
75
+ def target?(model, client)
76
+ return false if model.chat? || model.entry.remote?
77
+ return false unless client.respond_to?(:warm_up)
78
+
79
+ PromptWarmup.enabled?
80
+ end
81
+ end
82
+ end
83
+ 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.155.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end