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,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+ require_relative "atomic_file"
7
+ require_relative "log"
8
+ require_relative "session"
9
+ require_relative "session_metrics"
10
+ require_relative "steer"
11
+ require_relative "verify_anchor"
12
+ require_relative "verify_fresh_origin"
13
+
14
+ module Samagotchi
15
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
16
+
17
+ # Verify fresh (docs/internals/participants.md, Verify fresh): an unclear
18
+ # verdict checked again by a delegate child that sees only the claim, in
19
+ # the parent session's own folder, with none of its history. The child's
20
+ # field (VerifyFreshOrigin) makes every turn of a participant's child
21
+ # theirs: their author, the boundary their grant has in the parent, their
22
+ # cap counted across the parent and all their fresh children (.spend).
23
+ #
24
+ # Two files in the parent's directory, neither in its conversation:
25
+ # - LEDGER, one line per child started (child id, the verdict it checks,
26
+ # who asked): the running check (one fresh child per verdict at a time)
27
+ # and the spend rollup read it;
28
+ # - INBOX, the child's verdicts (.forward, written by the child's worker
29
+ # after its turn's save), taken into the parent's verdicts by the
30
+ # parent's worker between turns (.take) and shown before that by chi
31
+ # web (.verdicts_of). Verdicts are display state: nothing here enters a
32
+ # prompt.
33
+ module VerifyFresh
34
+ LEDGER = "verify_fresh.jsonl"
35
+ LOCK = "verify_fresh.lock"
36
+ INBOX = "fresh_verdicts"
37
+ # The child's first message, after the claim.
38
+ ASK = "Verify this claim against the files here. You have none of the original session's context. " \
39
+ "End with a line `Verdict: confirmed|refuted|unclear — <one-line reason>`."
40
+ # How long a child whose worker hasn't come up yet counts as running
41
+ # (Tools::Delegate::STARTING_GRACE_SECONDS).
42
+ STARTING_GRACE_SECONDS = 15
43
+
44
+ # A start refused: the HTTP +status+, the error +code+ and its words.
45
+ class Refused < StandardError
46
+ attr_reader :status, :code
47
+
48
+ def initialize(status, code, detail)
49
+ @status = status
50
+ @code = code
51
+ super(detail)
52
+ end
53
+ end
54
+
55
+ NOT_FOUND = "no Verify verdict with that check turn in this session"
56
+ NOT_UNCLEAR = "only an unclear verdict can be verified fresh"
57
+ RUNNING = "a fresh check of this claim is already running"
58
+ BUSY = "a fresh check this requester started is still running: one at a time each"
59
+ FULL = "this session has as many delegates running as it may (session.max_children): wait for one to finish"
60
+
61
+ module_function
62
+
63
+ # The child's first prompt: the claim, quoted, and the ask. Nothing of
64
+ # the parent's conversation.
65
+ def prompt_for(quote)
66
+ claim = quote.to_s.lines.map { |line| "> #{line.chomp}" }.join("\n")
67
+ "Claim:\n#{claim}\n\n#{ASK}"
68
+ end
69
+
70
+ # Starts a fresh child for the parent's unclear verdict +of+ (its
71
+ # check_turn_id). +author+: the requester ("p:<id>"), nil for the
72
+ # owner; +mode+: a participant's grant's mode in the parent
73
+ # (ParticipantMode.live_for), saved on the child.
74
+ # @return [Session] the child
75
+ # @raise [Refused] no such verdict, not unclear, or a fresh check of it
76
+ # still runs
77
+ def start(parent_id, of:, author:, mode: nil, state_dir: Session.default_state_dir, spawn: nil)
78
+ parent = Session.load(parent_id, state_dir: state_dir)
79
+ record = original(parent.verdicts, of) or raise Refused.new(404, "not_found", NOT_FOUND)
80
+ raise Refused.new(409, "not_unclear", NOT_UNCLEAR) unless field(record, :verdict).to_s == "unclear"
81
+
82
+ anchor = VerifyAnchor.from(field(record, :anchor)) or raise Refused.new(404, "not_found", NOT_FOUND)
83
+ author = Steer.sender_author(author)
84
+ origin = VerifyFreshOrigin.new(author: author, mode: author && mode.to_s, of: of, anchor: anchor)
85
+ dir = Session.session_dir(parent.id, state_dir: state_dir)
86
+ spawn ||= SessionManager.method(:spawn_session)
87
+ locked(dir) do
88
+ refuse_busy!(parent.id, of, author, state_dir)
89
+
90
+ child = spawn.call(prompt: prompt_for(anchor.quote), working_directory: parent.working_directory,
91
+ model_name: parent.model_name, memories: [Session::DELEGATE_MEMORY],
92
+ parent_id: parent.id, delegate: true, verify_fresh: origin, state_dir: state_dir)
93
+ append_ledger(dir, { "child_id" => child.id, "of" => of, "author" => author, "at" => Time.now.utc.iso8601(3) })
94
+ child
95
+ end
96
+ end
97
+
98
+ # Under the lock: one fresh check per verdict, one running fresh child
99
+ # per requester (a participant or the owner: each start passes the cap
100
+ # on saved spend, so parallel children would multiply it), and no more
101
+ # running children of any kind than session.max_children.
102
+ def refuse_busy!(parent_id, of, author, state_dir)
103
+ fresh = running(parent_id, state_dir: state_dir)
104
+ raise Refused.new(409, "fresh_running", RUNNING) if fresh.any? { |row| row[:of] == of }
105
+ raise Refused.new(409, "fresh_busy", BUSY) if fresh.any? { |row| row[:author] == author }
106
+
107
+ require_relative "tools/delegate"
108
+ return if Tools::Delegate.running_children(parent_id, state_dir: state_dir).size < Tools::Delegate.max_children
109
+
110
+ raise Refused.new(409, "children_full", FULL)
111
+ end
112
+ private_class_method :refuse_busy!
113
+
114
+ # The parent's verdict +of+ names, never a fresh one (a fresh verdict
115
+ # lands on its original's card; the original takes another check).
116
+ def original(verdicts, of)
117
+ Array(verdicts).find do |record|
118
+ record.is_a?(Hash) && field(record, :check_turn_id) == of && !field(record, :fresh)
119
+ end
120
+ end
121
+
122
+ # The fresh children of +parent_id+ running now, newest last: [{of:,
123
+ # child_id:, author:}].
124
+ def running(parent_id, state_dir: Session.default_state_dir)
125
+ ledger(Session.session_dir(parent_id, state_dir: state_dir)).select do |row|
126
+ child_running?(row[:child_id], state_dir)
127
+ end
128
+ end
129
+
130
+ # What +author+ spent in the fresh children of +parent_id+ (their saved
131
+ # analytics), but +except+'s (the child asking, whose own spend its
132
+ # worker counts live).
133
+ # @return [Float]
134
+ def children_spend(parent_id, author, state_dir: Session.default_state_dir, except: nil)
135
+ return 0.0 unless Steer.participant_author?(author)
136
+
137
+ ledger(Session.session_dir(parent_id, state_dir: state_dir)).sum(0.0) do |row|
138
+ next 0.0 unless row[:author] == author && row[:child_id] != except
139
+
140
+ SessionMetrics.saved_spend(Session.session_dir(row[:child_id], state_dir: state_dir), author)
141
+ end
142
+ end
143
+
144
+ # What +author+ spent outside +session+'s own metrics that counts
145
+ # against their cap there: in a parent, their fresh children's; in a
146
+ # fresh child, the parent's and its other fresh children's.
147
+ # @return [Float]
148
+ def related_spend(session, author, state_dir: Session.default_state_dir)
149
+ return 0.0 unless Steer.participant_author?(author) && session
150
+
151
+ if session.verify_fresh && session.parent_id
152
+ parent_dir = Session.session_dir(session.parent_id, state_dir: state_dir)
153
+ SessionMetrics.saved_spend(parent_dir, author) +
154
+ children_spend(session.parent_id, author, state_dir: state_dir, except: session.id)
155
+ else
156
+ children_spend(session.id, author, state_dir: state_dir)
157
+ end
158
+ rescue StandardError => e
159
+ Log.warn(:worker, "fresh_spend_failed", error: e.class.name, msg: e.message)
160
+ 0.0
161
+ end
162
+
163
+ # The child's new verdicts (+records+, Engine's verdict records) into
164
+ # its parent's INBOX, marked fresh: true with the child's id and the
165
+ # verdict they check again (of). Best effort.
166
+ # @param child [Session] a Verify fresh child
167
+ # @return [Integer] how many were written
168
+ def forward(child, records, state_dir: Session.default_state_dir)
169
+ fresh = child&.verify_fresh
170
+ return 0 unless fresh&.of && child.parent_id && Session.exist?(child.parent_id, state_dir: state_dir)
171
+
172
+ dir = File.join(Session.session_dir(child.parent_id, state_dir: state_dir), INBOX)
173
+ FileUtils.mkdir_p(dir)
174
+ Array(records).count do |record|
175
+ check = field(record, :check_turn_id).to_s
176
+ next false unless Session.valid_id?(check)
177
+
178
+ out = record.to_h { |key, value| [key.to_s, value.is_a?(Hash) ? value.transform_keys(&:to_s) : value] }
179
+ out.merge!("fresh" => true, "of" => fresh.of, "child_id" => child.id)
180
+ AtomicFile.write(File.join(dir, "#{check}.json"), JSON.generate(out))
181
+ true
182
+ end
183
+ rescue StandardError => e
184
+ Log.warn(:worker, "fresh_forward_failed", parent: child&.parent_id.to_s[0, 8], error: e.class.name, msg: e.message)
185
+ 0
186
+ end
187
+
188
+ # The verdicts waiting in +session_dir+'s INBOX: [[record, file]],
189
+ # oldest first.
190
+ def pending(session_dir)
191
+ Dir.glob(File.join(session_dir, INBOX, "*.json")).sort_by { |path| [File.mtime(path), path] }.filter_map do |path|
192
+ record = JSON.parse(File.read(path))
193
+ record.is_a?(Hash) && record["fresh"] == true ? [record, path] : nil
194
+ rescue JSON::ParserError, SystemCallError
195
+ nil
196
+ end
197
+ end
198
+
199
+ # The parent's side, between turns (its worker): the waiting verdicts
200
+ # appended to +session+'s verdicts (one already there is not added
201
+ # twice). The caller saves the session, then deletes the files.
202
+ # @return [Array(Array<Hash>, Array<String>)] the records added and the
203
+ # files taken
204
+ def take(session, session_dir)
205
+ waiting = pending(session_dir)
206
+ return [[], []] if waiting.empty?
207
+
208
+ known = session.verdicts.map { |record| field(record, :check_turn_id) }
209
+ added = waiting.map(&:first).reject { |record| known.include?(record["check_turn_id"]) }
210
+ session.verdicts = session.verdicts + added unless added.empty?
211
+ [added, waiting.map(&:last)]
212
+ end
213
+
214
+ # +session+'s verdicts as a page shows them: its own, then the fresh
215
+ # ones still waiting in its INBOX (no worker took them yet).
216
+ def verdicts_of(session, session_dir)
217
+ own = Array(session.verdicts)
218
+ known = own.map { |record| field(record, :check_turn_id) }
219
+ own + pending(session_dir).map(&:first).reject { |record| known.include?(record["check_turn_id"]) }
220
+ rescue StandardError
221
+ Array(session.verdicts)
222
+ end
223
+
224
+ # The LEDGER's rows: [{child_id:, of:, author:}].
225
+ def ledger(session_dir)
226
+ path = File.join(session_dir, LEDGER)
227
+ return [] unless File.exist?(path)
228
+
229
+ File.readlines(path, chomp: true).filter_map do |line|
230
+ row = JSON.parse(line)
231
+ next unless row.is_a?(Hash) && Session.valid_id?(row["child_id"].to_s)
232
+
233
+ { child_id: row["child_id"], of: row["of"].to_s, author: row["author"] }
234
+ rescue JSON::ParserError
235
+ nil
236
+ end
237
+ rescue SystemCallError
238
+ []
239
+ end
240
+
241
+ def append_ledger(session_dir, row)
242
+ FileUtils.mkdir_p(session_dir)
243
+ File.open(File.join(session_dir, LEDGER), File::WRONLY | File::APPEND | File::CREAT, 0o600) do |io|
244
+ io.write("#{JSON.generate(row)}\n")
245
+ end
246
+ end
247
+ private_class_method :append_ledger
248
+
249
+ # One start at a time per parent: the running check and the spawn.
250
+ def locked(session_dir)
251
+ FileUtils.mkdir_p(session_dir)
252
+ File.open(File.join(session_dir, LOCK), File::RDWR | File::CREAT, 0o600) do |io|
253
+ io.flock(File::LOCK_EX)
254
+ yield
255
+ end
256
+ end
257
+ private_class_method :locked
258
+
259
+ # A child runs while its worker runs a turn, or while it is marked
260
+ # running and its worker is on its way (Tools::Delegate.running?).
261
+ def child_running?(child_id, state_dir)
262
+ child = Session.summary_from_file(Session.session_file(child_id, state_dir: state_dir)) or return false
263
+ return false unless child.status == Session::STATUS_RUNNING
264
+ return true if SessionManager.session_owner(child_id, state_dir: state_dir)&.worker?
265
+
266
+ Time.now - Time.iso8601(child.updated_at.to_s) < STARTING_GRACE_SECONDS
267
+ rescue ArgumentError
268
+ false
269
+ end
270
+ private_class_method :child_running?
271
+
272
+ def field(record, key) = record[key] || record[key.to_s]
273
+ private_class_method :field
274
+ end
275
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "steer"
4
+ require_relative "turn_origin"
5
+ require_relative "verify_anchor"
6
+ require_relative "participant_mode"
7
+
8
+ module Samagotchi
9
+ # A Verify fresh child's own field (Session#verify_fresh; VerifyFresh
10
+ # starts one): who asked for it (+author+, a participant's "p:<id>", nil
11
+ # for the owner), the rules a participant's turns run under in it
12
+ # (+mode+, "review" or "pair": their grant's mode in the parent when it
13
+ # started, ParticipantMode.live_for), the parent's verdict it checks again
14
+ # (+of+, that verdict's check_turn_id) and the claim (+anchor+, a
15
+ # VerifyAnchor). Every turn of a participant's child is theirs
16
+ # (#origin_for): their author, their boundary, their cap. A field that is
17
+ # there but doesn't read as one is INVALID, whose author is no
18
+ # participant's: its turns are refused, never run as the owner's.
19
+ VerifyFreshOrigin = Data.define(:author, :mode, :of, :anchor) do
20
+ # @param value [VerifyFreshOrigin, Hash, nil] the session file's form too
21
+ # @return [VerifyFreshOrigin, nil] nil for nil; INVALID for anything
22
+ # else that doesn't read as one
23
+ def self.of(value)
24
+ return value if value.is_a?(self)
25
+ return nil if value.nil?
26
+ return self::INVALID unless value.is_a?(Hash)
27
+
28
+ field = ->(key) { value.key?(key) ? value[key] : value[key.to_s] }
29
+ author = field.call(:author)
30
+ mode = field.call(:mode)
31
+ anchor = VerifyAnchor.from(field.call(:anchor))
32
+ of = field.call(:of)
33
+ return self::INVALID unless anchor && of.is_a?(String) && !of.empty?
34
+ return new(author: nil, mode: nil, of: of, anchor: anchor) if author.nil?
35
+ return self::INVALID unless author.is_a?(String) && Steer.participant_author?(author) && self::MODES.include?(mode)
36
+
37
+ new(author: author, mode: mode, of: of, anchor: anchor)
38
+ end
39
+
40
+ # Whether a participant asked for it (the owner's child runs as any
41
+ # delegate child).
42
+ def participant? = Steer.participant_author?(author)
43
+
44
+ # The boundary a participant's turn runs under here: :pair only when
45
+ # saved so, else :review (fail closed).
46
+ def turn_mode = mode == ParticipantMode::PAIR.to_s ? ParticipantMode::PAIR : ParticipantMode::REVIEW
47
+
48
+ # +origin+ with this child's requester as its author: every turn of a
49
+ # participant's child is theirs, whoever typed it; the owner's child
50
+ # keeps the origin as it is.
51
+ # @param origin [TurnOrigin]
52
+ # @return [TurnOrigin]
53
+ def origin_for(origin)
54
+ origin = TurnOrigin.of(origin)
55
+ participant? ? origin.with(author: author) : origin
56
+ end
57
+
58
+ # The session file's form.
59
+ def to_file = { "author" => author, "mode" => mode, "of" => of, "anchor" => anchor&.to_file }.compact
60
+ end
61
+
62
+ # A verify_fresh field that doesn't read: a participant author nobody
63
+ # has, so the worker refuses its turns ("revoked"), under the review
64
+ # boundary.
65
+ # The modes a participant's child may be saved with (a constant of
66
+ # VerifyFreshOrigin: one inside the Data.define block would be Samagotchi's).
67
+ VerifyFreshOrigin::MODES = [ParticipantMode::REVIEW.to_s, ParticipantMode::PAIR.to_s].freeze
68
+
69
+ VerifyFreshOrigin::INVALID = VerifyFreshOrigin.new(author: "#{Steer::PARTICIPANT_PREFIX}invalid", mode: "review",
70
+ of: nil, anchor: nil)
71
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.124.0"
4
+ VERSION = "0.126.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -69,6 +69,7 @@ require_relative "message_display"
69
69
  require_relative "session_show"
70
70
  require_relative "session_create"
71
71
  require_relative "session_control"
72
+ require_relative "verify_fresh_routes"
72
73
  require_relative "push_routes"
73
74
  require_relative "update_check"
74
75
  require_relative "proxy_gate"
@@ -99,6 +100,7 @@ module Samagotchi
99
100
  include SessionShow
100
101
  include SessionCreate
101
102
  include SessionControl
103
+ include VerifyFreshRoutes
102
104
  include PushRoutes
103
105
  include ProxyGate
104
106
 
@@ -265,6 +267,7 @@ module Samagotchi
265
267
  ["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn, :participant, :session],
266
268
  ["POST", %r{\A/api/sessions/([^/]+)/wait/prepare\z}, :handle_wait_prepare, :participant, :session],
267
269
  ["POST", %r{\A/api/sessions/([^/]+)/wait\z}, :handle_wait, :participant, :session],
270
+ ["POST", %r{\A/api/sessions/([^/]+)/verdicts/([^/]+)/fresh\z}, :handle_verify_fresh, :participant, :session],
268
271
  ["POST", %r{\A/api/sessions/([^/]+)/notes\z}, :handle_notes, :owner, :session],
269
272
  ["POST", %r{\A/api/sessions/([^/]+)/wake\z}, :handle_wake, :owner, :session],
270
273
  ["GET", %r{\A/api/sessions/([^/]+)/worker\z}, :handle_worker, :owner, :session],
@@ -1878,9 +1881,10 @@ module Samagotchi
1878
1881
  def home_label(dir) = Paths.tilde(dir)
1879
1882
 
1880
1883
  # <body> data for the page: where sessions are stored, where an
1881
- # all-view new chat starts (the start folder), and, when ?dir names a
1882
- # folder in a repo, that project's name and root. A bad dir adds
1883
- # nothing here: the list call answers 400 and the page says so.
1884
+ # all-view new chat starts (the start folder), the home folder, and,
1885
+ # when ?dir names a folder in a repo, that project's name and root. A
1886
+ # bad dir adds nothing here: the list call answers 400 and the page
1887
+ # says so.
1884
1888
  # Without ?dir (and without ?scope=all) the page is the start folder's
1885
1889
  # project view when that folder is in a repo: data-scope-dir names it
1886
1890
  # for the page (the URL stays bare, so a token's 303, a link code's
@@ -1890,6 +1894,9 @@ module Samagotchi
1890
1894
  # version: the chi that served the page; the events snapshot names
1891
1895
  # the one serving now (an upgraded chi web after a reconnect).
1892
1896
  attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(start_dir), "version" => Samagotchi::VERSION }
1897
+ # The owner's home: a claim link's quote reads it "~", as the
1898
+ # participant page shows it (claim_link.js).
1899
+ attrs["home"] = Dir.home
1893
1900
  attrs["view"] = view_name(req.params["view"])
1894
1901
  # Only when on: the page reads its presence (the turn view ignores it).
1895
1902
  attrs["working-set"] = "1" if working_set?(req.params["working_set"])
@@ -14,6 +14,7 @@ require_relative "../participant_messages"
14
14
  require_relative "../participants"
15
15
  require_relative "../session"
16
16
  require_relative "../turn_checkpoint"
17
+ require_relative "../verify_fresh"
17
18
  require_relative "../log"
18
19
 
19
20
  module Samagotchi
@@ -271,8 +272,10 @@ module Samagotchi
271
272
  reason = participant_turn_refusal(grant, prompt, body)
272
273
  return participant_turn_refused(grant, prompt, *reason) if reason
273
274
 
275
+ verify = VerifyAnchor.from(body["verify"])
274
276
  result = SessionManager.deliver_turn(id, prompt: prompt, client_id: ClientId.review(grant.id),
275
277
  delivery: Delivery::QUEUE, author: grant.author,
278
+ verify: verify,
276
279
  pending_limit: grant.queue_limit + 1,
277
280
  state_dir: @state_dir, manager: @manager,
278
281
  bridge: -> { live_bridge_client(id) })
@@ -291,15 +294,23 @@ module Samagotchi
291
294
 
292
295
  # [429, cap_reached, …] once the grant spent its cap (D7: their turns
293
296
  # and the side requests they caused, from the session's saved
294
- # analytics; a running turn's spend counts from its end, and the
295
- # worker checks again before a queued one starts), nil before.
297
+ # analytics, and their Verify fresh children's; a running turn's
298
+ # spend counts from its end, and the worker checks again before a
299
+ # queued one starts), nil before.
296
300
  def participant_cap_refusal(grant)
297
- dir = Session.session_dir(grant.session_id, state_dir: default_state_dir)
298
- return nil if SessionMetrics.saved_spend(dir, grant.author) < grant.spend_cap
301
+ return nil if participant_saved_spend(grant) < grant.spend_cap
299
302
 
300
303
  [429, "cap_reached", ParticipantMessages::CAP_REACHED]
301
304
  end
302
305
 
306
+ # What +grant+'s participant spent, saved: in the session and in
307
+ # their Verify fresh children (VerifyFresh.children_spend).
308
+ def participant_saved_spend(grant)
309
+ dir = Session.session_dir(grant.session_id, state_dir: default_state_dir)
310
+ SessionMetrics.saved_spend(dir, grant.author) +
311
+ VerifyFresh.children_spend(grant.session_id, grant.author, state_dir: default_state_dir)
312
+ end
313
+
303
314
  def participant_turn_refused(grant, prompt, status, code, detail)
304
315
  audit_turn(grant, prompt, status: "refused", reason: code)
305
316
  error_response(status, code, detail)
@@ -7,6 +7,7 @@ require_relative "../session_metrics"
7
7
  require_relative "../review_start"
8
8
  require_relative "../guardrails/review_paths"
9
9
  require_relative "../participant_mode"
10
+ require_relative "../verify_fresh"
10
11
 
11
12
  module Samagotchi
12
13
  module Web
@@ -73,6 +74,10 @@ module Samagotchi
73
74
  current_turn: current,
74
75
  waiting_for_owner: waiting,
75
76
  queued: review_queue(queued || [], viewer),
77
+ verdicts: ParticipantView.verdicts(VerifyFresh.verdicts_of(session, Session.session_dir(id, state_dir: default_state_dir)), viewer),
78
+ # The verdicts a fresh check runs for now (their check_turn_id;
79
+ # never the child's id: a participant can't open it).
80
+ fresh_running: VerifyFresh.running(id, state_dir: default_state_dir).map { |row| row[:of] }.uniq,
76
81
  last_event_id: turn_snapshot && turn_snapshot["event_id"]
77
82
  })
78
83
  end
@@ -190,7 +195,8 @@ module Samagotchi
190
195
  dir = Session.session_dir(grant.session_id, state_dir: default_state_dir)
191
196
  saved = SessionMetrics.saved_spend(dir, grant.author)
192
197
  live = live_spend(state, grant.author)
193
- [saved, live].max.round(4)
198
+ fresh = VerifyFresh.children_spend(grant.session_id, grant.author, state_dir: default_state_dir)
199
+ ([saved, live].max + fresh).round(4)
194
200
  end
195
201
 
196
202
  # +author+'s cost in the live worker's metrics (spend_by_author), 0.0
@@ -4,15 +4,17 @@ require_relative "../output_formatter"
4
4
  require_relative "../steer"
5
5
  require_relative "../turn_origin"
6
6
  require_relative "../participant_mode"
7
+ require_relative "../verify_anchor"
7
8
 
8
9
  module Samagotchi
9
10
  module Web
10
11
  # What a participant sees of their session (D5 a): prompts and replies
11
12
  # with their authors, one-line tool rows (the tool's name, and a
12
13
  # project-relative path for a file tool), turn boundaries, and the
13
- # waiting states. Never: tool output or arguments, thinking, context
14
- # notes, plugin steers, automatic input, cards, commands, questions'
15
- # internals, memories, the working set, cost or timing, host paths.
14
+ # waiting states. Verdict cards expose only their anchor, verdict,
15
+ # reason and author. Never: tool output or arguments, thinking, context
16
+ # notes, plugin steers, automatic input, other cards, commands,
17
+ # questions' internals, memories, the working set, cost or timing, host paths.
16
18
  #
17
19
  # Every read a participant reaches goes through here: the stream's
18
20
  # events (#event, ParticipantStreamFilter), the review route's
@@ -47,6 +49,9 @@ module Samagotchi
47
49
  "turn_completed" => :turn_ended,
48
50
  "turn_canceled" => :turn_ended,
49
51
  "turn_failed" => :turn_ended,
52
+ # A Verify fresh child's verdict landing on its card: the card's
53
+ # fields only (#verdict_record).
54
+ "verdict_recorded" => :verdict_recorded,
50
55
  "generation_chunk" => :generation_chunk,
51
56
  "generation_retrying" => :generation_retrying,
52
57
  "tool_call_started" => :tool_call_started,
@@ -125,8 +130,44 @@ module Samagotchi
125
130
  out
126
131
  end
127
132
 
128
- def turn_ended(event, _viewer)
129
- { "type" => field(event, :type).to_s, **id_fields(event) }
133
+ def turn_ended(event, viewer)
134
+ out = { "type" => field(event, :type).to_s, **id_fields(event) }
135
+ verdict = verdict_record(field(event, :verdict), viewer)
136
+ out["verdict"] = verdict if verdict
137
+ out
138
+ end
139
+
140
+ def verdicts(records, viewer)
141
+ Array(records).filter_map { |record| verdict_record(record, viewer) }
142
+ end
143
+
144
+ def verdict_record(raw, viewer)
145
+ return unless raw.is_a?(Hash)
146
+
147
+ anchor = VerifyAnchor.from(field(raw, :anchor))
148
+ verdict = field(raw, :verdict).to_s
149
+ reason = field(raw, :reason)
150
+ check_turn_id = field(raw, :check_turn_id)
151
+ by = field(raw, :by)
152
+ return unless anchor && %w[confirmed refuted unclear].include?(verdict) && reason.is_a?(String)
153
+ return unless check_turn_id.is_a?(String) && !check_turn_id.empty?
154
+ return unless by.nil? || by.is_a?(String)
155
+
156
+ out = {
157
+ "anchor" => { "turn_id" => anchor.turn_id, "quote" => anchor.quote },
158
+ "verdict" => verdict, "reason" => reason, "by" => by,
159
+ "check_turn_id" => check_turn_id
160
+ }
161
+ # A Verify fresh verdict: which verdict it checked again (never the
162
+ # child's id: a participant can't open that session).
163
+ of = field(raw, :of)
164
+ out.merge!("fresh" => true, "of" => of) if field(raw, :fresh) == true && of.is_a?(String) && !of.empty?
165
+ Redact.deep(out, viewer)
166
+ end
167
+
168
+ def verdict_recorded(event, viewer)
169
+ verdict = verdict_record(field(event, :verdict), viewer) or return nil
170
+ { "type" => "verdict_recorded", "verdict" => verdict }
130
171
  end
131
172
 
132
173
  # The reply's visible text only: never thinking, a tool call's bytes
@@ -26,15 +26,17 @@ module Samagotchi
26
26
  # +identity_header+: the header this chi web's auth proxy names the
27
27
  # signed-in user in (null without one), for the Invite's email field;
28
28
  # +mode+: what a participant invited now gets, "pair" or "review"
29
- # (ReviewStart.share_terms), for the panel's words.
30
- def handle_participants_list(_req, id = nil)
29
+ # (ReviewStart.share_terms), for the panel's words; +link_base+: where
30
+ # a participant's link opens (participant_base), for a claim link
31
+ # (claim_link.js) the participant's browser opens with its cookie.
32
+ def handle_participants_list(req, id = nil)
31
33
  now = @grants.now
32
34
  grants = @grants.list(session_id: id).map { |grant| grant.public_h(now) }
33
35
  return json_response(200, { participants: grants }) unless id
34
36
 
35
37
  terms = ReviewStart.share_terms(Session.load(id, state_dir: default_state_dir), state_dir: default_state_dir)
36
38
  json_response(200, { participants: with_spend(id, grants), warning: terms.warning, mode: terms.mode.to_s,
37
- identity_header: @public_url&.identity_header })
39
+ identity_header: @public_url&.identity_header, link_base: participant_base(req) })
38
40
  rescue Grants::Unwritable => e
39
41
  grants_unwritable(e)
40
42
  end
@@ -149,10 +151,19 @@ module Samagotchi
149
151
  return @public_url.link(@link_codes.issue(kind: :grant, token: token, ttl: ttl), "s=#{id}")
150
152
  end
151
153
 
154
+ "#{participant_base(req)}?token=#{token}#s=#{id}"
155
+ end
156
+
157
+ # Where a participant's link opens, no token: web.public_url's origin,
158
+ # else this chi web's LAN address, else the Host asked
159
+ # ("http://192.168.1.5:4567/").
160
+ def participant_base(req)
161
+ return "#{@public_url.origin}/" if @public_url
162
+
152
163
  host = req.get_header("HTTP_HOST").to_s
153
164
  port = host[/:(\d+)\z/, 1] || req.port.to_s
154
165
  name = @lan ? @lan[:ip] : host.sub(/:\d*\z/, "")
155
- "http://#{name}:#{port}/?token=#{token}#s=#{id}"
166
+ "http://#{name}:#{port}/"
156
167
  end
157
168
  end
158
169
  end
@@ -1,6 +1,6 @@
1
- /* The selection bubble (annotate_bar.js): Annotate + the presets (web.annotate_presets), small pills with a gap (they
2
- wrap on a narrow page). Both pages link it: the owner's (app.js) and the participant page (participant.js). Colours
3
- are tokens.css's. */
1
+ /* The selection bubble (annotate_bar.js): Annotate + the presets (web.annotate_presets), Copy link last where the page
2
+ has one, small pills with a gap (they wrap on a narrow page). Both pages link it: the owner's (app.js) and the
3
+ participant page (participant.js). Colours are tokens.css's. */
4
4
  #annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
5
5
  #annotateBar[hidden]{display:none}
6
6
  #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px var(--shadow-strong)}
@@ -15,4 +15,8 @@
15
15
  #annotateBar.docked{right:8px;justify-content:center}
16
16
  @media (pointer:coarse){#annotateBar button{font-size:14px;padding:8px 14px}}
17
17
  #annotateBar .annotate-more-list[hidden]{display:none}
18
- #annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
18
+ #annotateBar .annotate-preset,#annotateBar .annotate-link{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
19
+ /* A claim link's target (claim_link.js): its bubble (or its turn's opening) ringed, the quote itself highlighted. One
20
+ name for both pages; --claim-mark/--claim-ring follow the palette (warn-bright and warn are set per palette). */
21
+ .claim-hit{outline:2px solid var(--claim-ring);outline-offset:3px;border-radius:12px}
22
+ ::highlight(chi-claim){background-color:var(--claim-mark);color:inherit}