samagotchi 0.124.0 → 0.126.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/docs/architecture.md +4 -2
  4. data/docs/cli.md +20 -8
  5. data/docs/guardrails.md +22 -0
  6. data/docs/internals/participants.md +96 -3
  7. data/docs/internals/remote-sessions.md +2 -0
  8. data/docs/internals/web-push.md +10 -6
  9. data/docs/sessions.md +5 -2
  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/process_group.rb +10 -1
  21. data/lib/samagotchi/reminder_carry.rb +92 -0
  22. data/lib/samagotchi/reminder_store.rb +49 -0
  23. data/lib/samagotchi/session.rb +40 -3
  24. data/lib/samagotchi/session_inbox.rb +14 -8
  25. data/lib/samagotchi/session_manager.rb +39 -8
  26. data/lib/samagotchi/session_participants.rb +10 -2
  27. data/lib/samagotchi/sessions_command.rb +1 -1
  28. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
  29. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
  30. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
  31. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
  32. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
  34. data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
  35. data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
  36. data/lib/samagotchi/verdict.rb +20 -0
  37. data/lib/samagotchi/verify_anchor.rb +28 -0
  38. data/lib/samagotchi/verify_fresh.rb +275 -0
  39. data/lib/samagotchi/verify_fresh_origin.rb +71 -0
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +10 -3
  42. data/lib/samagotchi/web/participant_access.rb +15 -4
  43. data/lib/samagotchi/web/participant_review.rb +7 -1
  44. data/lib/samagotchi/web/participant_view.rb +46 -5
  45. data/lib/samagotchi/web/participants_admin.rb +15 -4
  46. data/lib/samagotchi/web/public/annotate_bar.css +8 -4
  47. data/lib/samagotchi/web/public/annotate_bar.js +43 -7
  48. data/lib/samagotchi/web/public/app.js +134 -8
  49. data/lib/samagotchi/web/public/claim_link.js +347 -0
  50. data/lib/samagotchi/web/public/data.js +2 -1
  51. data/lib/samagotchi/web/public/index.html +1 -0
  52. data/lib/samagotchi/web/public/notify.js +13 -2
  53. data/lib/samagotchi/web/public/participant.html +3 -1
  54. data/lib/samagotchi/web/public/participant.js +113 -9
  55. data/lib/samagotchi/web/public/participants_panel.js +15 -0
  56. data/lib/samagotchi/web/public/route.js +3 -2
  57. data/lib/samagotchi/web/public/send_turn.js +6 -1
  58. data/lib/samagotchi/web/public/session_state.js +5 -0
  59. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  60. data/lib/samagotchi/web/public/tokens.css +2 -1
  61. data/lib/samagotchi/web/public/verdict_card.js +65 -0
  62. data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
  63. data/lib/samagotchi/web/public/verify_card.css +17 -0
  64. data/lib/samagotchi/web/public/verify_preset.js +18 -0
  65. data/lib/samagotchi/web/push/attention.rb +25 -5
  66. data/lib/samagotchi/web/push/notifier.rb +4 -3
  67. data/lib/samagotchi/web/session_control.rb +2 -0
  68. data/lib/samagotchi/web/session_summary.rb +25 -1
  69. data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
  70. data/lib/samagotchi/worker.rb +125 -12
  71. data/lib/samagotchi/worker_idle_exit.rb +7 -6
  72. data/lib/samagotchi/worker_inbound.rb +4 -4
  73. metadata +19 -1
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tools/task_record"
4
+
5
+ module Samagotchi
6
+ class TerminalUI
7
+ # The /exit ask and its reply lines: pure text from a reply, holds, and
8
+ # the invocation used to spell commands.
9
+ module ExitLines
10
+ module_function
11
+
12
+ # Why a request the Bridge read after its deadline didn't go through.
13
+ LATE = "worker not answering in time"
14
+ # An exit the worker agreed to in an empty session. Said before it has
15
+ # left: it checks again once it lets go, and a note or prompt arriving
16
+ # first keeps the session (SessionManager.discard_left_session).
17
+ EMPTY_WORDS = "the session is empty: it will be discarded if nothing arrives before its worker stops."
18
+ EMPTY_EXIT = "Detached; #{EMPTY_WORDS}"
19
+ # Why the worker stayed up after /exit, by the reason it gave.
20
+ HELD_REASONS = {
21
+ "turn_running" => "a turn is running", "input_queued" => "prompts are queued",
22
+ "continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
23
+ "reminders" => "reminders are set", "starting" => "the worker is still starting",
24
+ "background_tasks" => "background tasks it started are still running",
25
+ "delegates" => "delegates of this session are still running"
26
+ }.freeze
27
+ # How to end a background task that holds the worker up (an older
28
+ # worker's 409 has no hint of its own).
29
+ TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
30
+ # The /exit ask, when every hold is one the user can stop: what still
31
+ # runs, then the three answers. Each kind, singular and plural.
32
+ EXIT_HOLD_WORDS = {
33
+ "reminders" => %w[reminder reminders],
34
+ "background_tasks" => ["background task", "background tasks"],
35
+ "delegates" => %w[delegate delegates]
36
+ }.freeze
37
+ # The 200's +stopped+ keys, by the kind the ask lists them under.
38
+ STOPPED_KEYS = { "reminders" => "reminders", "tasks" => "background_tasks", "delegates" => "delegates" }.freeze
39
+ EXIT_ASK_OPTIONS = ["Keep running in the background (detach)", "Stop them and exit", "Back to the session"].freeze
40
+ # How the ask reads an answer.
41
+ EXIT_ANSWERS = {
42
+ "1" => :keep, "keep" => :keep, "detach" => :keep, "background" => :keep,
43
+ "2" => :stop, "stop" => :stop, "exit" => :stop,
44
+ "3" => :back, "back" => :back, "stay" => :back
45
+ }.freeze
46
+
47
+ # The ask: what still runs, then the three answers.
48
+ def ask_lines(holds)
49
+ lines = holds.map do |hold|
50
+ items = Array(hold["items"])
51
+ singular, plural = hold_words(hold["kind"])
52
+ " • #{items.size} #{items.size == 1 ? singular : plural}: #{items.map { |item| item_word(item, hold["kind"]) }.join(", ")}"
53
+ end
54
+ lines += EXIT_ASK_OPTIONS.each_with_index.map { |option, index| " #{index + 1}. #{option}" }
55
+ (["This session still has:"] + lines).join("\n")
56
+ end
57
+
58
+ # One entry of a hold: the command or name, with its detail in
59
+ # brackets; a delegate leads with its short id and its title in quotes.
60
+ def item_word(item, kind)
61
+ label = item["label"].to_s
62
+ label = "#{item["id"].to_s[0, 8]} \"#{label}\"" if kind == "delegates"
63
+ detail = item["detail"].to_s
64
+ detail.empty? ? label : "#{label} (#{detail})"
65
+ end
66
+
67
+ # 1: detach, everything left running.
68
+ def kept_line(holds, via:, session_id:)
69
+ "Detached; the session keeps running (#{hold_words_list(holds)}). " \
70
+ "Re-attach with: #{via.attach(session_id)}"
71
+ end
72
+
73
+ # "2 reminders, 1 background task, 1 delegate" from the ask's holds.
74
+ def hold_words_list(holds)
75
+ holds.map do |hold|
76
+ items = Array(hold["items"])
77
+ singular, plural = hold_words(hold["kind"])
78
+ "#{items.size} #{items.size == 1 ? singular : plural}"
79
+ end.join(", ")
80
+ end
81
+
82
+ def hold_words(kind) = EXIT_HOLD_WORDS.fetch(kind.to_s, [kind.to_s, kind.to_s])
83
+
84
+ # What a stop-all exit stopped: "stopped 2 reminders (api_health, git
85
+ # push), 1 background task (rails s)"; nil when it stopped nothing.
86
+ def stopped_words(stopped)
87
+ return nil unless stopped.is_a?(Hash)
88
+
89
+ parts = STOPPED_KEYS.filter_map do |key, kind|
90
+ names = Array(stopped[key])
91
+ next if names.empty?
92
+
93
+ singular, plural = hold_words(kind)
94
+ "#{names.size} #{names.size == 1 ? singular : plural} (#{names.join(", ")})"
95
+ end
96
+ parts.empty? ? nil : "stopped #{parts.join(", ")}"
97
+ end
98
+
99
+ def exit_line(reply, via:, session_id:)
100
+ error = reply.json&.fetch("error", nil)
101
+ case reply.status
102
+ when 200
103
+ stopped = stopped_words(reply.json&.fetch("stopped", nil))
104
+ return EMPTY_EXIT if discards?(reply) && stopped.nil?
105
+ return "Detached; #{stopped}. #{EMPTY_WORDS.capitalize}" if discards?(reply)
106
+
107
+ "Detached; #{stopped || "the session's worker is stopping"}. Resume with: #{via.resume(session_id)}"
108
+ when 409
109
+ reason = reply.json&.fetch("reason", nil).to_s
110
+ "Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
111
+ "Re-attach with: #{via.attach(session_id)}"
112
+ when 0 then exit_failed_line("no reply", via: via, session_id: session_id)
113
+ when 408 then exit_failed_line(LATE, via: via, session_id: session_id)
114
+ else
115
+ # A worker from before the route answers the Bridge's plain 404.
116
+ if error == "not_found"
117
+ return "Detached; this worker runs an older chi and can't be stopped from here: #{via.command("sessions stop #{session_id}")}"
118
+ end
119
+
120
+ exit_failed_line([reply.status, reply.json&.fetch("detail", nil) || error].compact.join(" "),
121
+ via: via, session_id: session_id)
122
+ end
123
+ end
124
+
125
+ # Why the worker stayed up; the background tasks it waits for by
126
+ # their short id and command (a finish wake's, D3), and how to stop one.
127
+ def held_words(reason, tasks)
128
+ words = HELD_REASONS.fetch(reason, reason)
129
+ listed = Array(tasks).filter_map { |t| "#{Tools::TaskRecord.short_id(t["id"])} (#{t["command"]})" if t.is_a?(Hash) }
130
+ listed.empty? ? words : "#{words}: #{listed.join(", ")}; #{TASK_STOP_HINT}"
131
+ end
132
+
133
+ # An older worker leaves the field out.
134
+ def discards?(reply) = reply.json&.fetch("discard", nil) == true
135
+
136
+ def exit_failed_line(why, via:, session_id:)
137
+ "Detached (could not ask the worker to stop: #{why}). Re-attach with: #{via.attach(session_id)}"
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../context_note"
4
+ require_relative "../image_store"
5
+ require_relative "../log"
6
+ require_relative "../message_parts"
7
+ require_relative "../output_formatter"
8
+ require_relative "../steer"
9
+ require_relative "../tool_activity"
10
+ require_relative "../tool_view"
11
+ require_relative "../turn_note"
12
+ require_relative "event_renderer"
13
+ require_relative "formatting"
14
+
15
+ module Samagotchi
16
+ class TerminalUI
17
+ # Draws the saved part of a worker snapshot when an attached TUI joins or
18
+ # resyncs. The live loop owns the collaborators; this owns replay only.
19
+ class JoinReplay
20
+ include Formatting
21
+
22
+ # How much of the last answer a join shows.
23
+ JOIN_ANSWER_LINES = 12
24
+ JOIN_ANSWER_CHARS = 1200
25
+
26
+ def initialize(screen:, view:, link:, gaps:, renderer:)
27
+ @screen = screen
28
+ @view = view
29
+ @link = link
30
+ @gaps = gaps
31
+ @renderer = renderer
32
+ end
33
+
34
+ def render_join(snapshot, saved)
35
+ render_join_header(Array(snapshot[:messages]))
36
+ render_join_gap(snapshot, saved)
37
+ end
38
+
39
+ # The snapshot's cards and between-turns notices (Bridge CardStore):
40
+ # on join the ones since the last turn (and the running turn's), as
41
+ # the join header shows the last exchange; on a resync only cards not
42
+ # shown here yet, and not the ones an earlier worker saved (a resync
43
+ # onto a new worker: earlier turns, not news). A turn's own rows (notices, retries) are left out:
44
+ # the running turn's come with its parts, and the join shows no older
45
+ # turn's steps.
46
+ def render_snapshot_cards(entries, joining:)
47
+ entries.each do |entry|
48
+ case entry[:type].to_s
49
+ when "hook_notice"
50
+ next if entry[:in_turn]
51
+
52
+ @screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
53
+ when "card"
54
+ next unless joining ? entry[:turns_since].to_i.zero? : !entry[:earlier] && !@renderer.card_shown?(entry[:id])
55
+
56
+ @renderer.render_card(entry, updated: entry[:updated] == true)
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ # Where the session's last activity was: its newest stamped entry,
64
+ # else its saved recap's time. A gap ago (and no turn running): one
65
+ # line after the join block says when.
66
+ def render_join_gap(snapshot, saved)
67
+ @gaps.seed(snapshot[:messages], fallback: saved&.dig(:created_at))
68
+ return if snapshot[:current_turn]
69
+
70
+ line = @gaps.join_line
71
+ @screen.commit(gap_divider(line)) if line
72
+ end
73
+
74
+ def render_join_header(messages)
75
+ exchange = messages.select { |m| %w[user model assistant].include?(m[:role].to_s) }
76
+ # The id stays findable: the detach line and chi sessions list show it.
77
+ Log.session_id = @link.client.session_id
78
+ Log.info(:attached, "joined", session: @link.client.session_id, messages: exchange.size,
79
+ notes: messages.count { |m| ContextNote.note?(m) })
80
+ last_user = exchange.rindex { |m| Steer.prompt?(m) }
81
+ render_join_notes(messages)
82
+ return unless last_user
83
+
84
+ @screen.commit(join_prompt_line(exchange[last_user]))
85
+ # An image-only turn's text names its images already.
86
+ unless ImageRef.placeholder_only?(exchange[last_user][:content])
87
+ Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
88
+ end
89
+ from = messages.index { |m| m.equal?(exchange[last_user]) }
90
+ render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
91
+ record = @link.saved_turn_record(exchange[last_user][:turn_id] || exchange[last_user]["turn_id"])
92
+ failed = join_failed_record(record)
93
+ # A turn that ended with no answer: its notice, as live.
94
+ empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
95
+ if empty
96
+ @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"], empty[:refusal] || empty["refusal"]))
97
+ elsif failed
98
+ # A failed turn whose steps stayed: the line the live view left,
99
+ # and no step text as if it were the answer (the web keeps a
100
+ # failed turn's texts as steps too).
101
+ failure = failed["failure"]
102
+ @screen.commit(@view.turn_failed_line(failure["summary"] || failure["message"], failed["duration_ms"]))
103
+ @screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) if failure["kept_steps"]
104
+ elsif record && record["status"] == "canceled"
105
+ # A canceled turn (a Stop, Ctrl-C): its line, as live, and no
106
+ # text it streamed before the cancel as if it were the answer.
107
+ @screen.commit(@view.turn_canceled_line(record["cancellation_reason"], record["duration_ms"], by: record["cancelled_by"]))
108
+ @screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) unless record["continue"]
109
+ else
110
+ # The saved answer is raw: the latest keeps its thinking, and one may
111
+ # be only a tool call.
112
+ answer = exchange[(last_user + 1)..].reverse_each
113
+ .map { |m| OutputFormatter.strip_markup(m[:content]) }
114
+ .find { |text| !text.empty? }
115
+ @screen.commit(last_lines(answer)) if answer
116
+ end
117
+ render_join_notes(messages, after_exchange: true)
118
+ end
119
+
120
+ # What the last exchange did, as the live turn drew it: each saved
121
+ # call's tool row (action, params, status from its result, and its
122
+ # duration from +durations+, the turn's saved tool records) and a
123
+ # plugin's steer where it came. The step texts stay out, as live (the
124
+ # ticker's).
125
+ def render_join_steps(messages, from, durations: [])
126
+ records = durations.dup
127
+ messages.each_with_index.drop(from + 1).each do |m, i|
128
+ if Steer.steer?(m)
129
+ @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
130
+ elsif %w[model assistant].include?(m[:role].to_s)
131
+ responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
132
+ Array(MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
133
+ record = join_record(records, part)
134
+ duration = record&.dig("duration_ms")
135
+ @screen.commit(join_tool_line(part, duration_ms: duration.is_a?(Numeric) ? duration : nil,
136
+ saved_status: record&.dig("status"),
137
+ auto_allowed: record&.dig("guardrail")))
138
+ end
139
+ end
140
+ end
141
+ end
142
+
143
+ # The prompt's turn's saved tool records (AttachLink: SessionMetrics,
144
+ # in this machine's state dir, as #attach_images): none for a prompt
145
+ # with no turn id (an older session) or a session with no analytics.
146
+ def join_tool_records(prompt)
147
+ @link.saved_tool_records(prompt[:turn_id] || prompt["turn_id"])
148
+ end
149
+
150
+ # The turn's saved +record+ when it failed with the fields its line is
151
+ # made of (SessionMetrics#failure_fields), else nil: a completed or
152
+ # older turn — or one saved before the failure field — draws its
153
+ # answer as before (a canceled one its line).
154
+ def join_failed_record(record)
155
+ return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
156
+
157
+ failure = record["failure"]
158
+ return nil if failure["summary"].to_s.empty? && failure["message"].to_s.empty?
159
+
160
+ record
161
+ end
162
+
163
+ # The saved record of +part+'s call (its duration and status): the
164
+ # next record of its tool, in call order, consumed with the ones it
165
+ # skipped. By tool and order, not by iteration: an empty retry spends
166
+ # an iteration that left no message (as the web's timing.js
167
+ # takeRecord). nil for a call that left none: it never finished, or a
168
+ # worker before turn ids saved it.
169
+ def join_record(records, part)
170
+ return nil if part[:output].nil?
171
+
172
+ at = records.index { |r| r["tool"].to_s == part[:tool].to_s }
173
+ return nil unless at
174
+
175
+ records.shift(at + 1).last
176
+ end
177
+
178
+ # A saved call's row; "no result" for a call the turn never answered
179
+ # (it ended first). Its status is the saved record's, as live and the
180
+ # web's reload (a guardrail's "blocked" is no error), else read from
181
+ # its result (a guardrail's deny text reads blocked too). A record that
182
+ # System 1 allowed once (+auto_allowed+, its saved {rule, p}) says so,
183
+ # as the live row does.
184
+ def join_tool_line(part, duration_ms: nil, saved_status: nil, auto_allowed: nil)
185
+ output = part[:output]
186
+ command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
187
+ text = ToolActivity.saved_result_text(output) unless output.nil?
188
+ status = if output.nil?
189
+ "no result"
190
+ elsif !saved_status.to_s.empty?
191
+ saved_status.to_s
192
+ elsif ToolActivity.guardrail_denied?(text)
193
+ "blocked"
194
+ else
195
+ ToolActivity.tool_activity_status(text, part[:tool], command: command)
196
+ end
197
+ activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
198
+ params: part[:params], status: status,
199
+ description: ToolView.description_title(part[:view] || {}) }
200
+ "#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
201
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}" \
202
+ "#{auto_allowed_suffix(auto_allowed)}"
203
+ end
204
+
205
+ # The live row's System 1 suffix for a saved record's {rule, p}.
206
+ def auto_allowed_suffix(saved)
207
+ return "" unless saved.is_a?(Hash)
208
+
209
+ format_tool_guardrail_suffix({ decided_by: "system one", p: saved["p"] || saved[:p] })
210
+ end
211
+
212
+ # The context notes that came since the last prompt (all of them in a
213
+ # session with none), after the exchange the header shows.
214
+ def render_join_notes(messages, after_exchange: false)
215
+ last_user = messages.rindex { |m| Steer.prompt?(m) }
216
+ return if after_exchange != !last_user.nil?
217
+
218
+ messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
219
+ @screen.commit(context_note_line(ContextNote.label_of(m), ContextNote.text_of(m)))
220
+ end
221
+ end
222
+
223
+ # The end of a long answer; the whole of it is in the session.
224
+ def last_lines(text)
225
+ lines = text.split("\n", -1)
226
+ if lines.size > JOIN_ANSWER_LINES
227
+ text = "(… #{lines.size - JOIN_ANSWER_LINES} earlier lines)\n#{lines.last(JOIN_ANSWER_LINES).join("\n")}"
228
+ end
229
+ return text if text.length <= JOIN_ANSWER_CHARS
230
+
231
+ "(… earlier text)\n…#{text[-JOIN_ANSWER_CHARS..]}"
232
+ end
233
+ end
234
+ end
235
+ end
@@ -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