samagotchi 0.125.0 → 0.127.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +1 -0
  4. data/docs/cli.md +1 -1
  5. data/docs/configuration.md +2 -2
  6. data/docs/guardrails.md +22 -0
  7. data/docs/internals/participants.md +82 -8
  8. data/docs/internals/web-push.md +10 -6
  9. data/docs/sessions.md +6 -1
  10. data/lib/samagotchi/bridge.rb +4 -3
  11. data/lib/samagotchi/bridge_client.rb +8 -1
  12. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
  13. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/child_reports.rb +3 -0
  16. data/lib/samagotchi/children_status.rb +6 -1
  17. data/lib/samagotchi/engine.rb +43 -7
  18. data/lib/samagotchi/guardrail_wiring.rb +10 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +1 -0
  20. data/lib/samagotchi/session.rb +40 -3
  21. data/lib/samagotchi/session_inbox.rb +14 -8
  22. data/lib/samagotchi/session_manager.rb +11 -4
  23. data/lib/samagotchi/session_participants.rb +24 -3
  24. data/lib/samagotchi/verdict.rb +20 -0
  25. data/lib/samagotchi/verify_anchor.rb +28 -0
  26. data/lib/samagotchi/verify_fresh.rb +275 -0
  27. data/lib/samagotchi/verify_fresh_origin.rb +71 -0
  28. data/lib/samagotchi/version.rb +1 -1
  29. data/lib/samagotchi/web/app.rb +15 -0
  30. data/lib/samagotchi/web/participant_access.rb +31 -5
  31. data/lib/samagotchi/web/participant_review.rb +7 -1
  32. data/lib/samagotchi/web/participant_view.rb +46 -5
  33. data/lib/samagotchi/web/project_list.rb +52 -0
  34. data/lib/samagotchi/web/public/annotate_bar.js +9 -1
  35. data/lib/samagotchi/web/public/app.js +77 -4
  36. data/lib/samagotchi/web/public/composer.css +12 -1
  37. data/lib/samagotchi/web/public/data.js +2 -1
  38. data/lib/samagotchi/web/public/index.html +36 -16
  39. data/lib/samagotchi/web/public/notify.js +15 -2
  40. data/lib/samagotchi/web/public/participant.html +33 -32
  41. data/lib/samagotchi/web/public/participant.js +70 -7
  42. data/lib/samagotchi/web/public/project_picker.js +115 -0
  43. data/lib/samagotchi/web/public/restore.js +7 -4
  44. data/lib/samagotchi/web/public/scope.js +8 -10
  45. data/lib/samagotchi/web/public/send_turn.js +8 -1
  46. data/lib/samagotchi/web/public/session_state.js +5 -0
  47. data/lib/samagotchi/web/public/stage_view.js +1 -1
  48. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  49. data/lib/samagotchi/web/public/verdict_card.js +65 -0
  50. data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
  51. data/lib/samagotchi/web/public/verify_card.css +17 -0
  52. data/lib/samagotchi/web/push/attention.rb +48 -9
  53. data/lib/samagotchi/web/push/notifier.rb +4 -3
  54. data/lib/samagotchi/web/session_control.rb +2 -0
  55. data/lib/samagotchi/web/session_summary.rb +25 -1
  56. data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
  57. data/lib/samagotchi/worker.rb +75 -13
  58. data/lib/samagotchi/worker_inbound.rb +4 -4
  59. metadata +11 -1
@@ -86,11 +86,16 @@ module Samagotchi
86
86
  state: state_of(session, row, reply), waiting: row[:waiting], live: !!row[:live],
87
87
  delegate: session.delegate?, cwd: session.working_directory, branch: GitHead.branch(session.working_directory),
88
88
  last_reply: reply && first_line(session.id, reply, state_dir: state_dir),
89
- last_reply_at: reply && reply_time(reply), reported: !reply.nil? && cursor.reply_file == reply,
89
+ last_reply_at: reply && reply_time(reply), reported: !reply.nil? && reported?(session, cursor, reply),
90
90
  updated_at: session.updated_at, archived: !!row[:archived])
91
91
  end
92
92
  private_class_method :child
93
93
 
94
+ # A reply the parent was given; a Verify fresh child's always counts
95
+ # (its verdict goes to the parent's card, never a report).
96
+ def reported?(session, cursor, reply) = !session.verify_fresh.nil? || cursor.reply_file == reply
97
+ private_class_method :reported?
98
+
94
99
  def state_of(session, row, reply)
95
100
  return "waiting" if row[:waiting]
96
101
  return "running" if Tools::Delegate.running?(row)
@@ -15,6 +15,8 @@ require_relative "steer"
15
15
  require_relative "turn_note"
16
16
  require_relative "turn_endings"
17
17
  require_relative "turn_origin"
18
+ require_relative "verify_anchor"
19
+ require_relative "verdict"
18
20
  require_relative "model_profile"
19
21
  require_relative "effective_model"
20
22
  require_relative "session_setting"
@@ -901,10 +903,12 @@ module Samagotchi
901
903
 
902
904
  # Event types #announce accepts: facts about the session's input queue,
903
905
  # a failed turn's prompt handed back and the continue offer, which live
904
- # UIs need in the event log, emitted outside any turn's stream.
906
+ # UIs need in the event log, emitted outside any turn's stream; a
907
+ # Verify fresh child's verdict landing on its card (:verdict_recorded).
905
908
  ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
906
909
  command_queued command_ran context_added card hook_notice guardrail_warning
907
- plugin_init_started plugin_init_finished working_set_updated participants_changed].freeze
910
+ plugin_init_started plugin_init_finished working_set_updated participants_changed
911
+ verdict_recorded].freeze
908
912
 
909
913
  # Put a transport-level event into the ordered event log. Unlike turn
910
914
  # events it reaches only persistent observers (no turn sink, no memory
@@ -1589,7 +1593,10 @@ module Samagotchi
1589
1593
  seconds = turn.elapsed
1590
1594
  synchronize_events do
1591
1595
  kept, event = yield(seconds)
1596
+ stamp_verify_anchor(kept, turn)
1592
1597
  replace_session_messages(turn.session, kept) if kept
1598
+ verdict = record_verdict(turn, outcome)
1599
+ event[:verdict] = verdict if verdict
1593
1600
  emit_working_set(turn.on_event, turn.session, turn.session.messages)
1594
1601
  turn.session.status = Session::STATUS_IDLE
1595
1602
  record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
@@ -1602,6 +1609,31 @@ module Samagotchi
1602
1609
  end
1603
1610
  private :end_turn
1604
1611
 
1612
+ def stamp_verify_anchor(messages, turn)
1613
+ return unless turn.verify && messages.is_a?(Array)
1614
+
1615
+ opening = messages.reverse.find do |message|
1616
+ (message[:role] || message["role"]).to_s == "user" &&
1617
+ (message[:turn_id] || message["turn_id"]).to_s == turn.id
1618
+ end
1619
+ opening[:verify] = turn.verify.to_h if opening
1620
+ end
1621
+ private :stamp_verify_anchor
1622
+
1623
+ def record_verdict(turn, outcome)
1624
+ return unless outcome == "completed" && turn.verify
1625
+
1626
+ answer = AnswerTail.find(turn.session.messages, turn_id: turn.id)
1627
+ parsed = Verdict.parse(answer && (answer[:content] || answer["content"]))
1628
+ return unless parsed
1629
+
1630
+ record = { anchor: turn.verify.to_h, verdict: parsed.verdict, reason: parsed.reason,
1631
+ by: turn.origin.author, check_turn_id: turn.id, at: Time.now.iso8601(3) }
1632
+ turn.session.verdicts = turn.session.verdicts + [record]
1633
+ record
1634
+ end
1635
+ private :record_verdict
1636
+
1605
1637
  def save_quietly(session)
1606
1638
  session.save(state_dir: session_state_dir)
1607
1639
  rescue StandardError => e
@@ -2011,7 +2043,7 @@ module Samagotchi
2011
2043
  # +wall+: the wall time it started (MessageTime.now, once): the
2012
2044
  # :turn_started event's at and its user message's (and reminders') stamp.
2013
2045
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
2014
- :settings, :id, :limit, :resumed, :resume_from, :wall) do
2046
+ :settings, :id, :limit, :resumed, :resume_from, :wall, :verify) do
2015
2047
  def resumed? = resumed ? true : false
2016
2048
 
2017
2049
  # The turn's boundary events carry the origin only when it names a
@@ -2083,9 +2115,9 @@ module Samagotchi
2083
2115
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
2084
2116
  # one-line summary:.
2085
2117
  def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
2086
- origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil)
2118
+ origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil)
2087
2119
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
2088
- continue: continue, id: id)
2120
+ continue: continue, id: id, verify: verify)
2089
2121
  if continue
2090
2122
  turn.resumed = resumed
2091
2123
  turn.resume_from = resume_from
@@ -2133,8 +2165,11 @@ module Samagotchi
2133
2165
  # The turn's state before anything can fail it: the session running,
2134
2166
  # the turn flag, its cancel controller and sink. The cross-thread state
2135
2167
  # is set here and cleared in #release_turn only.
2136
- def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil)
2168
+ def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil)
2137
2169
  origin = TurnOrigin.of(origin)
2170
+ # A participant's Verify fresh child: every turn is theirs (their
2171
+ # author, boundary and cap), whoever started it.
2172
+ origin = session.verify_fresh.origin_for(origin) if session.respond_to?(:verify_fresh) && session.verify_fresh
2138
2173
  # Track the active session for recap and status snapshot.
2139
2174
  @session = session
2140
2175
  # status is turn state: running now, idle again before the turn's end
@@ -2157,7 +2192,7 @@ module Samagotchi
2157
2192
 
2158
2193
  Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
2159
2194
  controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
2160
- id: id || SecureRandom.uuid, wall: MessageTime.now)
2195
+ id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify))
2161
2196
  end
2162
2197
  private :begin_turn
2163
2198
 
@@ -2360,6 +2395,7 @@ module Samagotchi
2360
2395
  unless turn.continue
2361
2396
  user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
2362
2397
  **participant_mode_fields(turn.origin.author), MessageTime::KEY => turn.at }
2398
+ user_message[:verify] = turn.verify.to_h if turn.verify
2363
2399
  user_message[:images] = image_refs unless image_refs.empty?
2364
2400
  turn.messages << user_message
2365
2401
  session.last_prompt = turn.prompt
@@ -109,9 +109,18 @@ module Samagotchi
109
109
  @git = Guardrails::GitInfo.new
110
110
  author = turn_author
111
111
  @turn_entry = author && @participant.call(author)
112
- @turn_mode = author && ParticipantMode.for_turn(@session_lookup.call, @turn_entry)
112
+ @turn_mode = author && participant_turn_mode(@session_lookup.call, @turn_entry)
113
113
  end
114
114
 
115
+ # A participant's turn's mode: in a Verify fresh child the one saved
116
+ # on it at spawn (VerifyFreshOrigin#turn_mode: :review unless saved
117
+ # :pair), never looked up again; elsewhere ParticipantMode.for_turn.
118
+ def participant_turn_mode(session, entry)
119
+ fresh = session.respond_to?(:verify_fresh) && session.verify_fresh
120
+ fresh ? fresh.turn_mode : ParticipantMode.for_turn(session, entry)
121
+ end
122
+ private :participant_turn_mode
123
+
115
124
  # The running participant turn's mode (ParticipantMode.for_turn: :pair
116
125
  # only in a working session for a participant invited to act), decided
117
126
  # at its start; nil for the owner's turns. The boundary in #checks, the
@@ -447,6 +447,7 @@ module Samagotchi
447
447
  message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
448
448
  message[:turn_id] = entry[:turn_id] if entry[:turn_id]
449
449
  message[:turn_start] = true if entry[:turn_start] == true
450
+ message[:verify] = entry[:verify] if entry[:verify].is_a?(Hash)
450
451
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
451
452
  message[:anthropic_content] = entry[:anthropic_content] if entry[:anthropic_content].is_a?(Array)
452
453
  ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
@@ -20,6 +20,10 @@ require_relative "project_scope"
20
20
  require_relative "guardrails/parent_levels"
21
21
 
22
22
  module Samagotchi
23
+ # Loaded on first use (a Verify fresh child): keeps the bundle test kit's
24
+ # load budget (spec/samagotchi/testing/sandbox_spec.rb).
25
+ autoload :VerifyFreshOrigin, File.expand_path("verify_fresh_origin", __dir__)
26
+
23
27
  # archive_store requires this file.
24
28
  autoload :ArchiveStore, File.expand_path("archive_store", __dir__)
25
29
 
@@ -134,6 +138,14 @@ module Samagotchi
134
138
  @last_turn = LastTurn.from_file(value)
135
139
  end
136
140
 
141
+ # Verdict cards recorded by completed Verify turns, in append order.
142
+ # A missing field stays missing until the first verdict is recorded.
143
+ def verdicts = @verdicts || []
144
+
145
+ def verdicts=(values)
146
+ @verdicts = values.nil? ? nil : Array(values)
147
+ end
148
+
137
149
  # The mark of a turn a worker checkpointed and never finished
138
150
  # (InterruptedTurn), as loaded; the next worker settles it and sets it
139
151
  # to nil. While a turn runs, #turn_checkpoint's marker is saved instead.
@@ -280,6 +292,20 @@ module Samagotchi
280
292
  # Whether this is a review session (chi review, the web's Start review).
281
293
  def review? = !@review.nil?
282
294
 
295
+ # A Verify fresh child's requester, rules and claim (VerifyFreshOrigin),
296
+ # nil for any other session. Set once, at spawn (VerifyFresh.start).
297
+ attr_reader :verify_fresh
298
+
299
+ # @param value [VerifyFreshOrigin, Hash, nil] the session file's form too
300
+ def verify_fresh=(value)
301
+ @verify_fresh = value.nil? ? nil : VerifyFreshOrigin.of(value)
302
+ end
303
+
304
+ # The session whose participants.json names this session's
305
+ # participants: a Verify fresh child's parent (its requester's grant is
306
+ # the parent's), else the session itself.
307
+ def participants_source_id = @verify_fresh && @parent_id ? @parent_id : @id
308
+
283
309
  # How many tool outputs its history holds: its tool messages
284
310
  # (tool_response, a chat loop's tool; a native step's one message holds
285
311
  # every call's output and counts once).
@@ -333,7 +359,7 @@ module Samagotchi
333
359
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
334
360
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
335
361
  toolkit: nil, toolkit_origin: "chosen", settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
336
- working_set_pins: [], review: nil)
362
+ working_set_pins: [], review: nil, verdicts: nil, verify_fresh: nil)
337
363
  @id = id
338
364
  @metadata_version = metadata_version
339
365
  @mode = mode
@@ -370,6 +396,8 @@ module Samagotchi
370
396
  self.parent_answers = parent_answers
371
397
  self.working_set_pins = working_set_pins
372
398
  self.review = review
399
+ self.verdicts = verdicts
400
+ self.verify_fresh = verify_fresh
373
401
  self.interrupted_turn = interrupted_turn
374
402
  @turn_checkpoint = nil
375
403
  @archived = false
@@ -539,6 +567,7 @@ module Samagotchi
539
567
  "parent_id" => nil,
540
568
  "scratch" => false,
541
569
  "last_turn" => nil,
570
+ "verdicts" => nil,
542
571
  "delegate" => false,
543
572
  "llm_context" => nil,
544
573
  "continues" => nil,
@@ -553,12 +582,13 @@ module Samagotchi
553
582
  "parent_answers" => nil,
554
583
  "working_set_pins" => [],
555
584
  "review" => nil,
556
- "first_turn_id" => nil
585
+ "first_turn_id" => nil,
586
+ "verify_fresh" => nil
557
587
  }.freeze
558
588
 
559
589
  # FIELDS a session file has only when set: a session without them saves
560
590
  # as it did before they existed.
561
- OPTIONAL_FIELDS = %w[review first_turn_id].freeze
591
+ OPTIONAL_FIELDS = %w[review first_turn_id verdicts verify_fresh].freeze
562
592
 
563
593
  # A session from a parsed session file. +messages+ false leaves the
564
594
  # conversation out (the lists' lightweight summaries).
@@ -712,6 +742,13 @@ module Samagotchi
712
742
  when "last_turn" then @last_turn&.to_file
713
743
  when "working_set_pins" then @working_set_pins.map(&:to_file)
714
744
  when "review" then @review&.to_file
745
+ when "verify_fresh" then @verify_fresh&.to_file
746
+ when "verdicts"
747
+ @verdicts&.map do |record|
748
+ scrub_utf8(record.to_h do |key, entry|
749
+ [key.to_s, entry.is_a?(Hash) ? stringify_message_keys(entry) : entry]
750
+ end)
751
+ end
715
752
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
716
753
  Array(instance_variable_get(:"@#{key}"))
717
754
  else instance_variable_get(:"@#{key}")
@@ -12,6 +12,10 @@ require_relative "steer"
12
12
  require_relative "turn_origin"
13
13
 
14
14
  module Samagotchi
15
+ # Loaded on first use (a Verify turn's input): keeps the bundle test kit's
16
+ # load budget (spec/samagotchi/testing/sandbox_spec.rb).
17
+ autoload :VerifyAnchor, File.expand_path("verify_anchor", __dir__)
18
+
15
19
  # A session directory's file inbox and outbox (see SessionManager for the
16
20
  # layout): input/ holds one JSON file per queued turn, notes/ the context
17
21
  # notes a worker adds between turns, output/ one text file per reply,
@@ -53,7 +57,7 @@ module Samagotchi
53
57
  # @param now [Time] the time the file is named by (a spec's clock)
54
58
  # @return [String, false] the input file's path, or false
55
59
  def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [],
56
- delivery: nil, author: nil, now: Time.now)
60
+ delivery: nil, author: nil, verify: nil, now: Time.now)
57
61
  images = Array(images)
58
62
  input_dir = File.join(session_dir, INPUT_DIR)
59
63
  FileUtils.mkdir_p(input_dir)
@@ -63,7 +67,7 @@ module Samagotchi
63
67
  "no_interrupt" => (no_interrupt ? true : nil),
64
68
  "delivery" => (Delivery.queue?(delivery) ? Delivery::QUEUE : nil),
65
69
  "images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }),
66
- "author" => author }.compact
70
+ "author" => author, "verify" => VerifyAnchor.from(verify)&.to_file }.compact
67
71
  AtomicFile.write(path, JSON.generate(record))
68
72
  path
69
73
  rescue StandardError
@@ -228,9 +232,9 @@ module Samagotchi
228
232
  # (+no_interrupt+, --no-interrupt), its +images+ ({file:, name:} refs),
229
233
  # and its +delivery+ ("queue" or nil; a file without the key is one an
230
234
  # older chi wrote, so nil).
231
- Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery) do
232
- def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil)
233
- super(prompt:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:)
235
+ Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery, :verify) do
236
+ def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil, verify: nil)
237
+ super(prompt:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:, verify: verify && VerifyAnchor.from(verify))
234
238
  end
235
239
  end
236
240
  # A file that doesn't parse, or isn't a JSON object: no prompt.
@@ -246,7 +250,7 @@ module Samagotchi
246
250
  origin = TurnOrigin.from(data.slice("client_id", "enqueued_id", "author"))
247
251
  images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
248
252
  Input.new(prompt: data["prompt"].to_s, origin: origin, no_interrupt: data["no_interrupt"] == true,
249
- images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil)
253
+ images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil, verify: data["verify"])
250
254
  rescue JSON::ParserError
251
255
  UNREADABLE_INPUT
252
256
  end
@@ -278,9 +282,11 @@ module Samagotchi
278
282
  true
279
283
  end
280
284
 
281
- # A parsed input file's #waits_for_turn_end?.
285
+ # A parsed input file's #waits_for_turn_end?. A Verify turn waits too:
286
+ # merged into a running turn as a steer, its anchor would be lost.
282
287
  def self.turn_end_data?(data)
283
- Array(data["images"]).any? || Delivery.queue?(data["delivery"]) || Steer.participant_author?(data["author"])
288
+ Array(data["images"]).any? || Delivery.queue?(data["delivery"]) || Steer.participant_author?(data["author"]) ||
289
+ data["verify"].is_a?(Hash)
284
290
  end
285
291
 
286
292
  # How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
@@ -164,6 +164,8 @@ module Samagotchi
164
164
  # turn sees it
165
165
  # @param review [SessionReview, nil] a review session's (ReviewStart; a
166
166
  # continue keeps its previous link's)
167
+ # @param verify_fresh [VerifyFreshOrigin, nil] a Verify fresh child's
168
+ # requester, rules and claim (VerifyFresh.start)
167
169
  # @param before_spawn [#call, nil] (session) runs once the session and
168
170
  # its note are saved, before its worker spawns
169
171
  # @return [Session] with #seed_images_dropped: refs whose file was gone,
@@ -171,7 +173,7 @@ module Samagotchi
171
173
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
172
174
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
173
175
  preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
174
- note_source: "chi", before_spawn: nil, review: nil)
176
+ note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil)
175
177
  sd = state_dir || Session.default_state_dir
176
178
  # The resolved ref is stored (a resumed session keeps its model when an
177
179
  # alias is retargeted), with the name as typed beside it. A wrong host
@@ -198,6 +200,8 @@ module Samagotchi
198
200
  )
199
201
  session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
200
202
  session.toolkit_origin = setup.toolkit_origin || "default"
203
+ # Saved before the worker starts: its first turn is already the requester's.
204
+ session.verify_fresh = verify_fresh
201
205
  session.model_warning = model_warning
202
206
  # With no prompt there is no first turn to run (an attaching UI sends
203
207
  # the prompts), so the session starts idle.
@@ -1310,10 +1314,10 @@ module Samagotchi
1310
1314
  # session's images/
1311
1315
  # @return [String, false] the input file's path, or false.
1312
1316
  def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
1313
- images: [], delivery: nil, author: nil)
1317
+ images: [], delivery: nil, author: nil, verify: nil)
1314
1318
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1315
1319
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1316
- no_interrupt: no_interrupt, images: images, delivery: delivery, author: author)
1320
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, verify: verify)
1317
1321
  end
1318
1322
 
1319
1323
  # How long a turn waits for the Bridge of a worker a resume just spawned.
@@ -1395,7 +1399,8 @@ module Samagotchi
1395
1399
  # @raise [OwnedByTUI] a chi REPL owns the session
1396
1400
  # @raise [ArgumentError] no such session
1397
1401
  def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
1398
- author: nil, pending_limit: nil, state_dir: nil, manager: self, bridge: nil)
1402
+ author: nil, pending_limit: nil, verify: nil, state_dir: nil, manager: self, bridge: nil)
1403
+ verify = VerifyAnchor.from(verify)
1399
1404
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1400
1405
  bridge ||= lambda do
1401
1406
  link = WorkerLink.await(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT)
@@ -1417,6 +1422,7 @@ module Samagotchi
1417
1422
  options[:no_interrupt] = true if no_interrupt
1418
1423
  options[:author] = author if author
1419
1424
  options[:pending_limit] = pending_limit if author && pending_limit
1425
+ options[:verify] = verify if verify
1420
1426
  reply = client.post_turn(**options)
1421
1427
  ack = reply.json
1422
1428
  return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
@@ -1454,6 +1460,7 @@ module Samagotchi
1454
1460
  input[:delivery] = delivery unless Delivery.next_step?(delivery)
1455
1461
  input[:no_interrupt] = true if no_interrupt
1456
1462
  input[:author] = author if author
1463
+ input[:verify] = verify if verify
1457
1464
  path = participant_queue_lock(session_dir, author && pending_limit) do
1458
1465
  # No worker: the participant's waiting files are all their turns,
1459
1466
  # counted and written under one lock (the Bridge counts under its).
@@ -22,8 +22,10 @@ module Samagotchi
22
22
  end
23
23
 
24
24
  # Reads +session+'s sidecar again.
25
+ # A Verify fresh child reads its parent's (Session#participants_source_id):
26
+ # its requester's grant is there.
25
27
  def refresh!(session)
26
- @session_id = session&.id
28
+ @session_id = source_id(session)
27
29
  @entries = read
28
30
  self
29
31
  end
@@ -44,7 +46,7 @@ module Samagotchi
44
46
  def entry_for(author, session: nil)
45
47
  return nil unless Steer.participant_author?(author)
46
48
 
47
- refresh!(session) if session && session.id != @session_id
49
+ refresh!(session) if session && source_id(session) != @session_id
48
50
  id = Steer.participant_id(author)
49
51
  @entries.find { |e| e.id == id } || (@entries = read).find { |e| e.id == id }
50
52
  end
@@ -52,16 +54,35 @@ module Samagotchi
52
54
  # The system prompt's participants line while anyone is in it (#active?
53
55
  # as of the last #refresh!): ParticipantMode::PAIR when +session+ is a
54
56
  # working session and an active participant was invited to act, else
55
- # ParticipantMode::REVIEW; nil with nobody active.
57
+ # ParticipantMode::REVIEW; nil with nobody active. A Verify fresh
58
+ # child reads its parent's sidecar but has one participant at most, its
59
+ # requester: their saved mode while their grant is active, nil for the
60
+ # owner's child (no participant ever writes in it).
56
61
  def prompt_mode(session)
57
62
  active = @entries.select { |entry| entry.active?(@clock.call) }
58
63
  return nil if active.empty?
59
64
 
65
+ fresh = session.respond_to?(:verify_fresh) && session.verify_fresh
66
+ return fresh_prompt_mode(fresh, active) if fresh
67
+
60
68
  active.any? { |entry| ParticipantMode.for_turn(session, entry) == ParticipantMode::PAIR } ? ParticipantMode::PAIR : ParticipantMode::REVIEW
61
69
  end
62
70
 
63
71
  private
64
72
 
73
+ def fresh_prompt_mode(fresh, active)
74
+ return nil unless fresh.participant?
75
+
76
+ id = Steer.participant_id(fresh.author)
77
+ active.any? { |entry| entry.id == id } ? fresh.turn_mode : nil
78
+ end
79
+
80
+ def source_id(session)
81
+ return nil unless session
82
+
83
+ session.respond_to?(:participants_source_id) ? session.participants_source_id : session.id
84
+ end
85
+
65
86
  def read
66
87
  return [] if @session_id.to_s.empty?
67
88
 
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A parsed verdict line from a Verify turn's final answer.
5
+ Verdict = Data.define(:verdict, :reason) do
6
+ LINE = /\AVerdict:\s*(confirmed|refuted|unclear)\b\s*(?:—|–|-)\s*(.+?)\s*\z/i
7
+ MARKDOWN = /(?:\*\*|__|`)/
8
+
9
+ def self.parse(text)
10
+ line = text.to_s.lines.reverse_each.find { |candidate| !candidate.strip.empty? }
11
+ return unless line
12
+
13
+ normalized = line.strip.gsub(MARKDOWN, "")
14
+ match = LINE.match(normalized)
15
+ return unless match
16
+
17
+ new(verdict: match[1].downcase, reason: match[2].strip)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A claim selected in an earlier turn, carried only by a Verify turn.
5
+ VerifyAnchor = Data.define(:turn_id, :quote) do
6
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
7
+ QUOTE_MAX = 200
8
+
9
+ def self.from(value)
10
+ return value if value.is_a?(self)
11
+ return nil unless value.is_a?(Hash)
12
+
13
+ fields = value.to_h { |key, entry| [key.to_s, entry] }
14
+ return nil unless fields.keys.sort == %w[quote turn_id]
15
+
16
+ turn_id = fields["turn_id"]
17
+ quote = fields["quote"]
18
+ return nil unless turn_id.is_a?(String) && UUID.match?(turn_id)
19
+ return nil unless quote.is_a?(String) && quote.length.between?(1, QUOTE_MAX) && !quote.strip.empty?
20
+
21
+ new(turn_id:, quote:)
22
+ end
23
+
24
+ def to_h = { turn_id:, quote: }
25
+
26
+ def to_file = { "turn_id" => turn_id, "quote" => quote }
27
+ end
28
+ end