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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -1
- data/docs/architecture.md +4 -2
- data/docs/cli.md +20 -8
- data/docs/guardrails.md +22 -0
- data/docs/internals/participants.md +96 -3
- data/docs/internals/remote-sessions.md +2 -0
- data/docs/internals/web-push.md +10 -6
- data/docs/sessions.md +5 -2
- 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/process_group.rb +10 -1
- data/lib/samagotchi/reminder_carry.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +49 -0
- data/lib/samagotchi/session.rb +40 -3
- data/lib/samagotchi/session_inbox.rb +14 -8
- data/lib/samagotchi/session_manager.rb +39 -8
- data/lib/samagotchi/session_participants.rb +10 -2
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
- data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
- data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
- 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 +10 -3
- 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/participants_admin.rb +15 -4
- data/lib/samagotchi/web/public/annotate_bar.css +8 -4
- data/lib/samagotchi/web/public/annotate_bar.js +43 -7
- data/lib/samagotchi/web/public/app.js +134 -8
- data/lib/samagotchi/web/public/claim_link.js +347 -0
- 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 +3 -1
- data/lib/samagotchi/web/public/participant.js +113 -9
- data/lib/samagotchi/web/public/participants_panel.js +15 -0
- data/lib/samagotchi/web/public/route.js +3 -2
- 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/tokens.css +2 -1
- 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/public/verify_preset.js +18 -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 +125 -12
- data/lib/samagotchi/worker_idle_exit.rb +7 -6
- data/lib/samagotchi/worker_inbound.rb +4 -4
- 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
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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),
|
|
1882
|
-
# folder in a repo, that project's name and root. A
|
|
1883
|
-
# nothing here: the list call answers 400 and the page
|
|
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
|
|
295
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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,
|
|
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
|
-
|
|
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}
|
|
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),
|
|
2
|
-
wrap on a narrow page). Both pages link it: the owner's (app.js) and the
|
|
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}
|