samagotchi 0.125.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -1
- data/docs/guardrails.md +22 -0
- data/docs/internals/participants.md +70 -2
- data/docs/internals/web-push.md +10 -6
- data/docs/sessions.md +3 -0
- data/lib/samagotchi/bridge.rb +4 -3
- data/lib/samagotchi/bridge_client.rb +8 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +3 -0
- data/lib/samagotchi/children_status.rb +6 -1
- data/lib/samagotchi/engine.rb +43 -7
- data/lib/samagotchi/guardrail_wiring.rb +10 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -0
- data/lib/samagotchi/session.rb +40 -3
- data/lib/samagotchi/session_inbox.rb +14 -8
- data/lib/samagotchi/session_manager.rb +11 -4
- data/lib/samagotchi/session_participants.rb +10 -2
- data/lib/samagotchi/verdict.rb +20 -0
- data/lib/samagotchi/verify_anchor.rb +28 -0
- data/lib/samagotchi/verify_fresh.rb +275 -0
- data/lib/samagotchi/verify_fresh_origin.rb +71 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/participant_access.rb +15 -4
- data/lib/samagotchi/web/participant_review.rb +7 -1
- data/lib/samagotchi/web/participant_view.rb +46 -5
- data/lib/samagotchi/web/public/annotate_bar.js +9 -1
- data/lib/samagotchi/web/public/app.js +69 -4
- data/lib/samagotchi/web/public/data.js +2 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/notify.js +13 -2
- data/lib/samagotchi/web/public/participant.html +2 -1
- data/lib/samagotchi/web/public/participant.js +69 -7
- data/lib/samagotchi/web/public/send_turn.js +6 -1
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +3 -0
- data/lib/samagotchi/web/public/verdict_card.js +65 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
- data/lib/samagotchi/web/public/verify_card.css +17 -0
- data/lib/samagotchi/web/push/attention.rb +25 -5
- data/lib/samagotchi/web/push/notifier.rb +4 -3
- data/lib/samagotchi/web/session_control.rb +2 -0
- data/lib/samagotchi/web/session_summary.rb +25 -1
- data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
- data/lib/samagotchi/worker.rb +68 -9
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +9 -1
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
@@ -62,6 +64,12 @@ module Samagotchi
|
|
|
62
64
|
|
|
63
65
|
private
|
|
64
66
|
|
|
67
|
+
def source_id(session)
|
|
68
|
+
return nil unless session
|
|
69
|
+
|
|
70
|
+
session.respond_to?(:participants_source_id) ? session.participants_source_id : session.id
|
|
71
|
+
end
|
|
72
|
+
|
|
65
73
|
def read
|
|
66
74
|
return [] if @session_id.to_s.empty?
|
|
67
75
|
|
|
@@ -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
|
|
@@ -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
|