samagotchi 0.104.0 → 0.106.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 +70 -1
- data/docs/architecture.md +6 -3
- data/docs/cli.md +62 -19
- data/docs/configuration.md +10 -6
- data/docs/guardrails.md +69 -0
- data/docs/hooks.md +9 -0
- data/docs/internals/anthropic.md +2 -1
- data/docs/internals/engine.md +37 -1
- data/docs/internals/participants.md +229 -21
- data/docs/internals/prompt-caching.md +9 -0
- data/docs/internals/working-set.md +12 -5
- data/docs/plugins.md +2 -0
- data/docs/testing.md +23 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -4
- data/lib/samagotchi/bridge.rb +45 -8
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/client_id.rb +21 -9
- data/lib/samagotchi/config.rb +17 -2
- data/lib/samagotchi/continue_offer.rb +13 -3
- data/lib/samagotchi/delivery.rb +10 -0
- data/lib/samagotchi/engine.rb +98 -8
- data/lib/samagotchi/guardrail_wiring.rb +13 -3
- data/lib/samagotchi/guardrails/review_boundary.rb +148 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hook_routes.rb +5 -2
- data/lib/samagotchi/hooks/registry.rb +12 -3
- data/lib/samagotchi/idle_recap.rb +9 -0
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/llm/chat_loop.rb +1 -0
- data/lib/samagotchi/parent_report.rb +17 -5
- data/lib/samagotchi/participant_messages.rb +16 -0
- data/lib/samagotchi/participant_name.rb +21 -0
- data/lib/samagotchi/participants.rb +29 -6
- data/lib/samagotchi/participants_watch.rb +90 -0
- data/lib/samagotchi/plugin/context.rb +7 -1
- data/lib/samagotchi/plugin/side_question.rb +9 -1
- data/lib/samagotchi/queued_command.rb +9 -5
- data/lib/samagotchi/reply_wait/poll.rb +18 -3
- data/lib/samagotchi/run_options.rb +1 -1
- data/lib/samagotchi/send_command.rb +41 -3
- data/lib/samagotchi/session_access/contract.rb +2 -0
- data/lib/samagotchi/session_access/local.rb +31 -9
- data/lib/samagotchi/session_access/remote.rb +19 -2
- data/lib/samagotchi/session_access/web_bridge_client.rb +4 -2
- data/lib/samagotchi/session_access.rb +6 -3
- data/lib/samagotchi/session_commands.rb +6 -0
- data/lib/samagotchi/session_inbox.rb +31 -5
- data/lib/samagotchi/session_manager.rb +43 -3
- data/lib/samagotchi/session_metrics/side_totals.rb +21 -7
- data/lib/samagotchi/session_metrics.rb +94 -1
- data/lib/samagotchi/session_participants.rb +59 -0
- data/lib/samagotchi/sessions_command.rb +10 -7
- data/lib/samagotchi/share_command.rb +61 -22
- data/lib/samagotchi/side_usage.rb +4 -2
- data/lib/samagotchi/steer.rb +120 -11
- data/lib/samagotchi/system_prompt.rb +21 -2
- data/lib/samagotchi/terminal_ui/attach_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +94 -22
- data/lib/samagotchi/terminal_ui/formatting.rb +66 -5
- data/lib/samagotchi/terminal_ui/local_link.rb +7 -0
- data/lib/samagotchi/terminal_ui/participant_commands.rb +77 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
- data/lib/samagotchi/terminal_ui/remote_link.rb +6 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +11 -2
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/testing/host.rb +4 -2
- data/lib/samagotchi/testing/recorder.rb +3 -1
- data/lib/samagotchi/toolkit.rb +21 -3
- data/lib/samagotchi/turn_checkpoint.rb +5 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +45 -20
- data/lib/samagotchi/web/grants.rb +89 -8
- data/lib/samagotchi/web/participant_access.rb +206 -35
- data/lib/samagotchi/web/participant_review.rb +165 -0
- data/lib/samagotchi/web/participant_stream_filter.rb +157 -0
- data/lib/samagotchi/web/participant_view.rb +415 -0
- data/lib/samagotchi/web/public/app.js +15 -1
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/data.js +20 -0
- data/lib/samagotchi/web/public/index.html +75 -30
- data/lib/samagotchi/web/public/info_bar.js +12 -5
- data/lib/samagotchi/web/public/info_bar_model.js +8 -5
- data/lib/samagotchi/web/public/participant.html +119 -0
- data/lib/samagotchi/web/public/participant.js +300 -0
- data/lib/samagotchi/web/public/participant_model.js +160 -0
- data/lib/samagotchi/web/public/participant_names.js +21 -0
- data/lib/samagotchi/web/public/participants_panel.js +311 -0
- data/lib/samagotchi/web/public/stream_handlers.js +8 -1
- data/lib/samagotchi/web/public/tokens.css +12 -0
- data/lib/samagotchi/web/public/turn_events.js +16 -5
- data/lib/samagotchi/web/public/turn_html.js +8 -4
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/web/public/working_set.js +6 -4
- data/lib/samagotchi/web/public/working_set_view.js +4 -4
- data/lib/samagotchi/web/qr.rb +14 -0
- data/lib/samagotchi/web/running.rb +52 -0
- data/lib/samagotchi/web/server.rb +13 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -0
- data/lib/samagotchi/worker.rb +125 -11
- data/lib/samagotchi/worker_inbound.rb +12 -1
- metadata +17 -1
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "../session"
|
|
4
4
|
require_relative "../session_inbox"
|
|
5
5
|
require_relative "../turn_checkpoint"
|
|
6
|
+
require_relative "../continue_offer"
|
|
7
|
+
require_relative "../steer"
|
|
6
8
|
|
|
7
9
|
module Samagotchi
|
|
8
10
|
module ReplyWait
|
|
@@ -70,7 +72,7 @@ module Samagotchi
|
|
|
70
72
|
|
|
71
73
|
note_own_running(session)
|
|
72
74
|
follow_merge(session)
|
|
73
|
-
reply(session) || settled_turn(session) || status_end(session) || question(session) || canceled ||
|
|
75
|
+
reply(session) || settled_turn(session) || status_end(session) || question(session) || held(session) || canceled ||
|
|
74
76
|
ended_before_reply(session) || ended_before_turn(session) || turn_end(session) || owner_gone(session) ||
|
|
75
77
|
timed_out
|
|
76
78
|
end
|
|
@@ -78,8 +80,9 @@ module Samagotchi
|
|
|
78
80
|
def look_at_input
|
|
79
81
|
return unless @turn && !@claimed
|
|
80
82
|
|
|
81
|
-
waiting = SessionInbox.
|
|
82
|
-
@own_turn_input ||= waiting
|
|
83
|
+
waiting = SessionInbox.waiting_input_data(@session_dir, @turn)
|
|
84
|
+
@own_turn_input ||= !waiting.nil? && SessionInbox.turn_end_data?(waiting)
|
|
85
|
+
@participant_input ||= !waiting.nil? && Steer.participant_author?(waiting["author"])
|
|
83
86
|
@claimed = waiting.nil?
|
|
84
87
|
end
|
|
85
88
|
|
|
@@ -161,6 +164,18 @@ module Samagotchi
|
|
|
161
164
|
Result.new(status: :waiting_for_answer, question: pending, session: session)
|
|
162
165
|
end
|
|
163
166
|
|
|
167
|
+
# A participant's turn its worker holds while the owner's Continue
|
|
168
|
+
# offer stands (Worker#run_input_files): it waits for the owner's
|
|
169
|
+
# answer to that question, whenever it was asked (the baseline's
|
|
170
|
+
# question too). An owner's turn is never held: it drops the offer.
|
|
171
|
+
def held(session)
|
|
172
|
+
pending = session.pending_question
|
|
173
|
+
return nil unless @participant_input && !@claimed && pending&.kind.to_s == ContinueOffer::KIND &&
|
|
174
|
+
session.waiting_question(live: SessionManager.worker_live?(@id, state_dir: @state_dir))
|
|
175
|
+
|
|
176
|
+
Result.new(status: :waiting_for_answer, question: pending, session: session)
|
|
177
|
+
end
|
|
178
|
+
|
|
164
179
|
def canceled = @cancelled.call ? Result.new(status: :canceled) : nil
|
|
165
180
|
|
|
166
181
|
# The turn that ended before this wait (ended_before: chi send --wait
|
|
@@ -146,7 +146,7 @@ module Samagotchi
|
|
|
146
146
|
chi context <add|push|ls|show|refresh|rm|mute|unmute> live external text attached to sessions
|
|
147
147
|
chi send [-m TEXT] ID...|--new a message into sessions, as if typed there
|
|
148
148
|
chi answer ID --question QID --option N answer a question a session waits on
|
|
149
|
-
chi share ID --name NAME | ls [ID] | revoke ID [P]|--all participants who review a session
|
|
149
|
+
chi share ID --name NAME | ls [ID] | cap ID P D | revoke ID [P]|--all participants who review a session
|
|
150
150
|
chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
|
|
151
151
|
chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
|
|
152
152
|
chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
|
|
@@ -143,11 +143,12 @@ module Samagotchi
|
|
|
143
143
|
status answered (text), question (question,
|
|
144
144
|
answer_with), turn_id when known, running, or failed, canceled,
|
|
145
145
|
limit, no_answer, error, worker_gone, stopped,
|
|
146
|
-
command (detail); with --cut or --queue also
|
|
146
|
+
command, waiting_for_owner (detail); with --cut or --queue also
|
|
147
147
|
delivery (what the worker applied: next_step, cut,
|
|
148
148
|
queue), with --cut also cut (now, waits, off)
|
|
149
149
|
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
150
|
-
question waits,
|
|
150
|
+
question waits (or the owner, for a participant's token), 4
|
|
151
|
+
still running (--timeout), 130 Ctrl-C.
|
|
151
152
|
Sessions on this machine, or another's: chi --remote NAME send …
|
|
152
153
|
(chi remote). Answers show in the attached TUI or web page, not
|
|
153
154
|
here.
|
|
@@ -259,6 +260,7 @@ module Samagotchi
|
|
|
259
260
|
def run_parsed(options)
|
|
260
261
|
# With --wait stdout is the answer alone.
|
|
261
262
|
@info = options[:wait] ? @stderr : @stdout
|
|
263
|
+
@waiting = options[:wait] == true
|
|
262
264
|
@cut = options[:cut] == true
|
|
263
265
|
@queue = options[:queue] == true
|
|
264
266
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
@@ -581,6 +583,8 @@ module Samagotchi
|
|
|
581
583
|
# @return [Integer] the exit status
|
|
582
584
|
def run_wait(prompt, options)
|
|
583
585
|
id = resolve(options[:ids].first) or return 1
|
|
586
|
+
return run_participant_wait(id, prompt, options) if @access.participant?
|
|
587
|
+
|
|
584
588
|
# Local: one read for the check and the baseline; remotely the
|
|
585
589
|
# server's wait prepare route reads both (Busy's words).
|
|
586
590
|
session = @access.show(id) unless @access.remote?
|
|
@@ -598,6 +602,27 @@ module Samagotchi
|
|
|
598
602
|
wait_for_reply(id, cursor: start.cursor, baseline: start.baseline, timeout: options[:timeout], **target.to_h)
|
|
599
603
|
end
|
|
600
604
|
|
|
605
|
+
# A participant's --wait -m (a review turn through chi --remote with
|
|
606
|
+
# their token): their message always runs as a queued turn of its own
|
|
607
|
+
# (chi web's review turns), so a running turn doesn't refuse it and
|
|
608
|
+
# there is no busy check (the owner's route, refused to them). Their
|
|
609
|
+
# wait routes take only a turn of theirs: the wait is on the one just
|
|
610
|
+
# queued, as --wait --turn T.
|
|
611
|
+
# @return [Integer] the exit status
|
|
612
|
+
def run_participant_wait(id, prompt, options)
|
|
613
|
+
return 1 unless deliver(id, prompt)
|
|
614
|
+
|
|
615
|
+
turn = @delivered.enqueued_id.to_s
|
|
616
|
+
if turn.empty?
|
|
617
|
+
error_line("chi send: #{id[0, 8]}: the review turn's id didn't come back; chi --remote … send --wait --turn T waits once you have it")
|
|
618
|
+
return 1
|
|
619
|
+
end
|
|
620
|
+
start = @access.prepare_wait(id, turn: turn)
|
|
621
|
+
wait_for_reply(id, cursor: start.cursor, baseline: start.baseline, timeout: options[:timeout],
|
|
622
|
+
owner_grace: start.live ? self.class::WORKER_GONE_AFTER : nil,
|
|
623
|
+
**WaitTarget.after(start, requested: turn).to_h)
|
|
624
|
+
end
|
|
625
|
+
|
|
601
626
|
# --wait with no message: the session's next reply, sending nothing,
|
|
602
627
|
# so an agent can go back to waiting after exit 3 or 130. A running
|
|
603
628
|
# turn is fine here (nothing joins its queue); a question pending now
|
|
@@ -765,7 +790,10 @@ module Samagotchi
|
|
|
765
790
|
# (next_step for an idle session) and a cut's outcome.
|
|
766
791
|
@sent_delivery = result.delivery || delivery
|
|
767
792
|
@sent_cut = result.cut
|
|
768
|
-
@
|
|
793
|
+
note = if @access.participant? then participant_note(id, result)
|
|
794
|
+
else delivery_note(result, delivery: delivery, running: running, owner: owner)
|
|
795
|
+
end
|
|
796
|
+
@info.puts("#{short} sent#{with_images}#{note}")
|
|
769
797
|
true
|
|
770
798
|
rescue SessionManager::OwnedByTUI
|
|
771
799
|
@info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
|
|
@@ -801,6 +829,16 @@ module Samagotchi
|
|
|
801
829
|
" #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym, result.cut_after)}"
|
|
802
830
|
end
|
|
803
831
|
|
|
832
|
+
# A participant's message: queued as a review turn of its own (chi
|
|
833
|
+
# web's), its id the one their wait takes.
|
|
834
|
+
def participant_note(id, result)
|
|
835
|
+
turn = result.enqueued_id.to_s
|
|
836
|
+
return " (queued as a review turn)" if turn.empty?
|
|
837
|
+
return " (queued as review turn #{turn})" if @waiting
|
|
838
|
+
|
|
839
|
+
" (queued as review turn #{turn}; #{@access.invocation.command("send --wait --turn #{turn} #{id}")} waits for its reply)"
|
|
840
|
+
end
|
|
841
|
+
|
|
804
842
|
# A --queue, worded from the Bridge's ack: queue when a turn ran as it
|
|
805
843
|
# went in (one another client started too), next_step when none did
|
|
806
844
|
# (it runs now). The input file (no Bridge) carries the queue to
|
|
@@ -122,6 +122,8 @@ module Samagotchi
|
|
|
122
122
|
"share --name" => SUPPORTED,
|
|
123
123
|
"share --agent" => SUPPORTED,
|
|
124
124
|
"share --expires" => SUPPORTED,
|
|
125
|
+
"share --cap" => SUPPORTED,
|
|
126
|
+
"share cap" => SUPPORTED,
|
|
125
127
|
"share ls" => SUPPORTED,
|
|
126
128
|
"share ls --format" => SUPPORTED,
|
|
127
129
|
"share revoke" => SUPPORTED,
|
|
@@ -17,6 +17,7 @@ require_relative "../participants"
|
|
|
17
17
|
require_relative "../config"
|
|
18
18
|
require_relative "../web/grants"
|
|
19
19
|
require_relative "../web/info_probe"
|
|
20
|
+
require_relative "../web/running"
|
|
20
21
|
|
|
21
22
|
module Samagotchi
|
|
22
23
|
# Loaded on first use: SessionManager and its ~80 files only when a
|
|
@@ -38,6 +39,9 @@ module Samagotchi
|
|
|
38
39
|
|
|
39
40
|
def remote? = false
|
|
40
41
|
|
|
42
|
+
# This machine's sessions are the owner's.
|
|
43
|
+
def participant? = false
|
|
44
|
+
|
|
41
45
|
# The printed commands' spelling: this machine's chi.
|
|
42
46
|
# @return [ChiInvocation]
|
|
43
47
|
def invocation = ChiInvocation::HERE
|
|
@@ -339,13 +343,14 @@ module Samagotchi
|
|
|
339
343
|
grants.list(session_id: id).map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
|
|
340
344
|
end
|
|
341
345
|
|
|
342
|
-
# The link names this machine's chi web: its LAN address
|
|
343
|
-
# runs on the LAN (
|
|
346
|
+
# The link names this machine's chi web: its LAN address and port
|
|
347
|
+
# when one runs on the LAN (the port it recorded, else web.port),
|
|
348
|
+
# else 127.0.0.1 (+lan+ false).
|
|
344
349
|
# @return [Invitation]
|
|
345
350
|
# @raise [ArgumentError] no such session
|
|
346
351
|
# @raise [BadRequest] a bad name, kind or ttl, an archived session
|
|
347
|
-
def invite(id, name:, kind:, ttl: nil)
|
|
348
|
-
issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, state_dir: state_dir!)
|
|
352
|
+
def invite(id, name:, kind:, ttl: nil, cap: nil)
|
|
353
|
+
issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, cap: cap, state_dir: state_dir!)
|
|
349
354
|
ip, port = local_web
|
|
350
355
|
Invitation.new(participant: ParticipantView.from_h(issued.grant.public_h(grants.now)), token: issued.token,
|
|
351
356
|
link: "http://#{ip || "127.0.0.1"}:#{port}/?token=#{issued.token}#s=#{id}", lan: !ip.nil?)
|
|
@@ -359,6 +364,17 @@ module Samagotchi
|
|
|
359
364
|
.map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
|
|
360
365
|
end
|
|
361
366
|
|
|
367
|
+
# @return [ParticipantView] the participant with their new cap
|
|
368
|
+
# @raise [BadRequest] a cap out of range, or no such active participant
|
|
369
|
+
def set_participant_cap(id, participant:, cap:)
|
|
370
|
+
grant = Participants.set_cap(grants, session_id: id, id: participant, cap: cap, state_dir: state_dir!)
|
|
371
|
+
raise BadRequest.new(Participants.no_participant(participant, id), code: "invalid_participant") unless grant
|
|
372
|
+
|
|
373
|
+
ParticipantView.from_h(grant.public_h(grants.now))
|
|
374
|
+
rescue Web::Grants::Invalid => e
|
|
375
|
+
raise BadRequest.new(e.message, code: "invalid_participant")
|
|
376
|
+
end
|
|
377
|
+
|
|
362
378
|
private
|
|
363
379
|
|
|
364
380
|
def grants = @grants ||= Web::Grants.new(Web::Grants.path(state_dir: state_dir!))
|
|
@@ -367,12 +383,18 @@ module Samagotchi
|
|
|
367
383
|
WEB_PORT = 4567
|
|
368
384
|
LOCAL_PROBE_TIMEOUT = 1.0
|
|
369
385
|
|
|
370
|
-
# [LAN ip or nil, port] of this machine's chi web (GET /api/info)
|
|
386
|
+
# [LAN ip or nil, port] of this machine's chi web (GET /api/info):
|
|
387
|
+
# the port the running one recorded (Web::Running) when a chi web
|
|
388
|
+
# answers there, else config's web.port.
|
|
371
389
|
def local_web
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
[
|
|
390
|
+
configured = Config.get("web.port").to_s.to_i
|
|
391
|
+
configured = WEB_PORT unless configured.positive?
|
|
392
|
+
recorded = Web::Running.port(path: Web::Running.path(state_dir: state_dir!))
|
|
393
|
+
[recorded, configured].compact.uniq.each do |port|
|
|
394
|
+
info = Web::InfoProbe.call("127.0.0.1", port, timeout: LOCAL_PROBE_TIMEOUT)
|
|
395
|
+
return [info["lan"], port] if info.is_a?(Hash)
|
|
396
|
+
end
|
|
397
|
+
[nil, configured]
|
|
376
398
|
end
|
|
377
399
|
|
|
378
400
|
def dir = @state_dir ? { state_dir: @state_dir } : {}
|
|
@@ -58,6 +58,14 @@ module Samagotchi
|
|
|
58
58
|
def remote? = true
|
|
59
59
|
def name = @entry.name
|
|
60
60
|
|
|
61
|
+
# Web::Grants::PREFIX, without loading chi web.
|
|
62
|
+
PARTICIPANT_PREFIX = "chp_"
|
|
63
|
+
|
|
64
|
+
# Whether the token is a participant's (chi share's link), not the
|
|
65
|
+
# owner's: their turns are queued review turns, their waits take only
|
|
66
|
+
# a turn of theirs.
|
|
67
|
+
def participant? = @entry.token.to_s.start_with?(PARTICIPANT_PREFIX)
|
|
68
|
+
|
|
61
69
|
# The printed commands' spelling: chi --remote NAME.
|
|
62
70
|
# @return [ChiInvocation]
|
|
63
71
|
def invocation = ChiInvocation.new(remote: name)
|
|
@@ -245,8 +253,8 @@ module Samagotchi
|
|
|
245
253
|
# @param ttl [Integer, nil] seconds; nil: the grant's default
|
|
246
254
|
# @return [Invitation]
|
|
247
255
|
# @raise [BadRequest] a bad name, kind or ttl, an archived session
|
|
248
|
-
def invite(id, name:, kind:, ttl: nil)
|
|
249
|
-
reply = request(:post, "#{session_path(id)}/participants", body: { name: name, kind: kind, ttl: ttl }.compact)
|
|
256
|
+
def invite(id, name:, kind:, ttl: nil, cap: nil)
|
|
257
|
+
reply = request(:post, "#{session_path(id)}/participants", body: { name: name, kind: kind, ttl: ttl, cap: cap }.compact)
|
|
250
258
|
raise_refusal(reply) unless reply.status == 201
|
|
251
259
|
|
|
252
260
|
json = reply.json
|
|
@@ -265,6 +273,15 @@ module Samagotchi
|
|
|
265
273
|
Array(reply.json["revoked"]).map { |row| ParticipantView.from_h(row) }
|
|
266
274
|
end
|
|
267
275
|
|
|
276
|
+
# @return [ParticipantView] the participant with their new cap
|
|
277
|
+
# @raise [BadRequest] a cap out of range, or no such active participant
|
|
278
|
+
def set_participant_cap(id, participant:, cap:)
|
|
279
|
+
reply = request(:post, "#{session_path(id)}/participants/#{participant}/cap", body: { cap: cap })
|
|
280
|
+
raise_refusal(reply) unless reply.status == 200
|
|
281
|
+
|
|
282
|
+
ParticipantView.from_h(reply.json.fetch("participant"))
|
|
283
|
+
end
|
|
284
|
+
|
|
268
285
|
def note(id, text:, source:)
|
|
269
286
|
reply = request(:post, "#{session_path(id)}/notes", body: { text: text, source: source })
|
|
270
287
|
json = reply.json.is_a?(Hash) ? reply.json : {}
|
|
@@ -92,9 +92,11 @@ module Samagotchi
|
|
|
92
92
|
def port = nil
|
|
93
93
|
|
|
94
94
|
# POST …/turn. history: false (chi web's prompt history is that
|
|
95
|
-
# machine's page's).
|
|
95
|
+
# machine's page's). +author+ is never sent: the remote chi web stamps
|
|
96
|
+
# a turn's author from the token this client holds (Web::Principal),
|
|
97
|
+
# never from the body.
|
|
96
98
|
# @return [BridgeClient::Response]
|
|
97
|
-
def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil)
|
|
99
|
+
def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil)
|
|
98
100
|
body = { prompt: prompt, client_id: client_id, history: false }
|
|
99
101
|
body[:no_interrupt] = true if no_interrupt
|
|
100
102
|
body[:images] = images if images && !images.empty?
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "participant_messages"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
# The seam between chi's session commands (chi send, answer, note,
|
|
5
7
|
# session-scope context, sessions list/stop/restart/archive/unarchive/
|
|
@@ -60,13 +62,13 @@ module Samagotchi
|
|
|
60
62
|
# queue up to their limit.
|
|
61
63
|
class TooManyTurns < RemoteFailed
|
|
62
64
|
CODE = "too_many_turns"
|
|
63
|
-
def initialize(label) = super("#{label}:
|
|
65
|
+
def initialize(label) = super("#{label}: #{ParticipantMessages::TOO_MANY_TURNS}")
|
|
64
66
|
end
|
|
65
67
|
|
|
66
68
|
# 429 cap_reached (P3b): the participant's grant spent its cap.
|
|
67
69
|
class CapReached < RemoteFailed
|
|
68
70
|
CODE = "cap_reached"
|
|
69
|
-
def initialize(label) = super("#{label}:
|
|
71
|
+
def initialize(label) = super("#{label}: #{ParticipantMessages::CAP_REACHED}")
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
# A remote's chi web refused a request for a reason this machine checks
|
|
@@ -91,8 +93,9 @@ module Samagotchi
|
|
|
91
93
|
# One participant's grant as chi share ls shows it
|
|
92
94
|
# (Web::Grants::Grant#public_h): never its token or digest. +state+
|
|
93
95
|
# "active", "revoked" or "expired".
|
|
96
|
+
# +cap+ dollars and +queue+ turns (nil from a chi web before them).
|
|
94
97
|
ParticipantView = Data.define(:id, :session_id, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
|
|
95
|
-
:revoked_by, :last_used_at, :state) do
|
|
98
|
+
:revoked_by, :last_used_at, :state, :cap, :queue) do
|
|
96
99
|
def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key] || hash[key.to_s]] })
|
|
97
100
|
end
|
|
98
101
|
|
|
@@ -158,6 +158,12 @@ module Samagotchi
|
|
|
158
158
|
# runs (the worker's POST turn/dequeue); the web has × on the bubble.
|
|
159
159
|
registry.register("/unqueue", "remove your newest queued message before it runs", local: true, uis: [:attached],
|
|
160
160
|
match: ->(text) { text.casecmp?("/unqueue") })
|
|
161
|
+
# chi share for the attached session (TerminalUI::ParticipantCommands):
|
|
162
|
+
# a participant's turns run on its worker, so the REPL points there.
|
|
163
|
+
registry.register("/share", "invite someone to review this session (/share NAME [--agent] [--expires 3d] [--cap 2])",
|
|
164
|
+
local: true, uis: [:attached], match: ->(text) { text.match?(%r{\A/share(?:\s.*)?\z}i) })
|
|
165
|
+
registry.register("/participants", "list this session's participants (revoke [P], cap P DOLLARS)",
|
|
166
|
+
local: true, uis: [:attached], match: ->(text) { text.match?(%r{\A/participants(?:\s.*)?\z}i) })
|
|
161
167
|
registry
|
|
162
168
|
end
|
|
163
169
|
|
|
@@ -8,6 +8,7 @@ require_relative "atomic_file"
|
|
|
8
8
|
require_relative "delivery"
|
|
9
9
|
require_relative "log"
|
|
10
10
|
require_relative "session"
|
|
11
|
+
require_relative "steer"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
13
14
|
# A session directory's file inbox and outbox (see SessionManager for the
|
|
@@ -248,31 +249,56 @@ module Samagotchi
|
|
|
248
249
|
|
|
249
250
|
# Whether a mid-turn drain leaves this unclaimed input file for a turn
|
|
250
251
|
# of its own instead of merging it at a step boundary: a queued message
|
|
251
|
-
# (`delivery: queue`)
|
|
252
|
+
# (`delivery: queue`), an image message (steering merges text only), or
|
|
253
|
+
# a participant's, whatever its delivery says: theirs always runs as its
|
|
254
|
+
# own turn, never inside another author's (D6; chi web queues it, and
|
|
255
|
+
# this is the worker's own layer).
|
|
252
256
|
# The drain skips such a file without stopping at it, so a later text
|
|
253
257
|
# line still merges; the drain's file filter reads this one predicate,
|
|
254
258
|
# so what a boundary leaves changes in one place.
|
|
255
259
|
def self.waits_for_turn_end?(input_file)
|
|
256
260
|
data = JSON.parse(File.read(input_file))
|
|
257
|
-
data.is_a?(Hash) &&
|
|
261
|
+
data.is_a?(Hash) && turn_end_data?(data)
|
|
258
262
|
rescue JSON::ParserError, SystemCallError
|
|
259
263
|
false
|
|
260
264
|
end
|
|
261
265
|
|
|
266
|
+
# Whether an unclaimed input file may be a participant's (it names a
|
|
267
|
+
# participant author): what the worker holds while the owner's Continue
|
|
268
|
+
# offer stands (Worker#run_input_files). An unreadable file counts:
|
|
269
|
+
# holding it costs nothing (it runs no turn), running it unread could.
|
|
270
|
+
def self.participant_input?(input_file)
|
|
271
|
+
data = JSON.parse(File.read(input_file))
|
|
272
|
+
!data.is_a?(Hash) || Steer.participant_author?(data["author"])
|
|
273
|
+
rescue JSON::ParserError, SystemCallError
|
|
274
|
+
true
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# A parsed input file's #waits_for_turn_end?.
|
|
278
|
+
def self.turn_end_data?(data)
|
|
279
|
+
Array(data["images"]).any? || Delivery.queue?(data["delivery"]) || Steer.participant_author?(data["author"])
|
|
280
|
+
end
|
|
281
|
+
|
|
262
282
|
# How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
|
|
263
283
|
# (a queue or an image message: it runs as a turn of its own, never
|
|
264
284
|
# merged, #waits_for_turn_end?), :merges (a running turn's drain may
|
|
265
285
|
# take it as a steer), or nil when none waits.
|
|
266
286
|
# @return [Symbol, nil]
|
|
267
287
|
def self.waiting_input(session_dir, enqueued_id)
|
|
288
|
+
data = waiting_input_data(session_dir, enqueued_id) or return nil
|
|
289
|
+
turn_end_data?(data) ? :turn_end : :merges
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# The unclaimed input file carrying +enqueued_id+, as parsed; nil when
|
|
293
|
+
# none waits.
|
|
294
|
+
# @return [Hash, nil]
|
|
295
|
+
def self.waiting_input_data(session_dir, enqueued_id)
|
|
268
296
|
id = enqueued_id.to_s
|
|
269
297
|
return nil if id.empty?
|
|
270
298
|
|
|
271
299
|
find_new_input_files(session_dir).each do |file|
|
|
272
300
|
data = JSON.parse(File.read(file))
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return Array(data["images"]).any? || Delivery.queue?(data["delivery"]) ? :turn_end : :merges
|
|
301
|
+
return data if data.is_a?(Hash) && data["enqueued_id"] == id
|
|
276
302
|
rescue JSON::ParserError, SystemCallError
|
|
277
303
|
next
|
|
278
304
|
end
|
|
@@ -11,6 +11,8 @@ require_relative "analytics_file"
|
|
|
11
11
|
require_relative "atomic_file"
|
|
12
12
|
require_relative "config"
|
|
13
13
|
require_relative "delivery"
|
|
14
|
+
require_relative "steer"
|
|
15
|
+
require_relative "participant_messages"
|
|
14
16
|
|
|
15
17
|
require_relative "session"
|
|
16
18
|
require_relative "session_inbox"
|
|
@@ -1216,6 +1218,9 @@ module Samagotchi
|
|
|
1216
1218
|
# owner (Web::Principal#author: chi web stamps it, never the body);
|
|
1217
1219
|
# to the Bridge or the file only when given, so an owner's turn is
|
|
1218
1220
|
# sent and saved as before
|
|
1221
|
+
# @param pending_limit [Integer, nil] with +author+: how many of their
|
|
1222
|
+
# turns may be running or waiting (the Bridge counts them; with no
|
|
1223
|
+
# worker, their waiting files): :refused 429 too_many_turns past it
|
|
1219
1224
|
# @param manager [#resume_session, #write_turn_input] this class, or a
|
|
1220
1225
|
# stand-in (the web's specs); #session_owner is optional
|
|
1221
1226
|
# @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
|
|
@@ -1229,7 +1234,7 @@ module Samagotchi
|
|
|
1229
1234
|
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
1230
1235
|
# @raise [ArgumentError] no such session
|
|
1231
1236
|
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
|
|
1232
|
-
author: nil, state_dir: nil, manager: self, bridge: nil)
|
|
1237
|
+
author: nil, pending_limit: nil, state_dir: nil, manager: self, bridge: nil)
|
|
1233
1238
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1234
1239
|
bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
|
|
1235
1240
|
manager.resume_session(session_id, state_dir: state_dir)
|
|
@@ -1237,6 +1242,8 @@ module Samagotchi
|
|
|
1237
1242
|
# A worker from before queueing (no "delivery" feature) ignores the
|
|
1238
1243
|
# field or refuses it; either way a file would steer the running turn.
|
|
1239
1244
|
return queue_unavailable if Delivery.queue?(delivery) && !worker_queues?(session_dir)
|
|
1245
|
+
# A participant's turn only to a worker with the review boundary.
|
|
1246
|
+
return review_unavailable if Steer.participant_author?(author) && !worker_has?(session_dir, Delivery::REVIEW_FEATURE)
|
|
1240
1247
|
|
|
1241
1248
|
begin
|
|
1242
1249
|
options = { prompt: prompt, client_id: client_id }
|
|
@@ -1245,6 +1252,7 @@ module Samagotchi
|
|
|
1245
1252
|
options[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1246
1253
|
options[:no_interrupt] = true if no_interrupt
|
|
1247
1254
|
options[:author] = author if author
|
|
1255
|
+
options[:pending_limit] = pending_limit if author && pending_limit
|
|
1248
1256
|
reply = client.post_turn(**options)
|
|
1249
1257
|
ack = reply.json
|
|
1250
1258
|
return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
|
|
@@ -1267,6 +1275,20 @@ module Samagotchi
|
|
|
1267
1275
|
nil # the worker closed its Bridge on the way out: queue the file
|
|
1268
1276
|
end
|
|
1269
1277
|
end
|
|
1278
|
+
# A participant's file only for a worker started after it: a worker
|
|
1279
|
+
# still holding the session with its Bridge gone (exiting) may be an
|
|
1280
|
+
# older one, which would run it without the review boundary.
|
|
1281
|
+
# (A chi REPL holding it is refused as for anyone, below.)
|
|
1282
|
+
if Steer.participant_author?(author) && (holder = delivery_owner(manager, session_id, state_dir)) && !holder.tui?
|
|
1283
|
+
return DeliverResult.new(status: :timeout, via: :file,
|
|
1284
|
+
ack: { "error" => "worker_timeout",
|
|
1285
|
+
"detail" => "the session's worker is exiting, so the message was not sent; send it again" })
|
|
1286
|
+
end
|
|
1287
|
+
# No worker: the participant's waiting files are all their turns.
|
|
1288
|
+
if author && pending_limit && pending_files(session_dir, author) >= pending_limit
|
|
1289
|
+
return DeliverResult.new(status: :refused, code: 429,
|
|
1290
|
+
ack: { "error" => "too_many_turns", "detail" => ParticipantMessages::TOO_MANY_TURNS })
|
|
1291
|
+
end
|
|
1270
1292
|
enqueued_id = SecureRandom.uuid
|
|
1271
1293
|
input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
|
|
1272
1294
|
input[:images] = images unless images.empty?
|
|
@@ -1294,15 +1316,33 @@ module Samagotchi
|
|
|
1294
1316
|
# Whether the session's live worker runs delivery: queue (its sidecar
|
|
1295
1317
|
# names the feature). No sidecar to read (the client came from a stand-in,
|
|
1296
1318
|
# or it went between the wait and here): true, the post finds out.
|
|
1297
|
-
private_class_method def self.worker_queues?(session_dir)
|
|
1319
|
+
private_class_method def self.worker_queues?(session_dir) = worker_has?(session_dir, Delivery::QUEUE_FEATURE)
|
|
1320
|
+
|
|
1321
|
+
# Whether the session's live worker names +feature+ in its sidecar; no
|
|
1322
|
+
# sidecar to read: true, as for #worker_queues?.
|
|
1323
|
+
private_class_method def self.worker_has?(session_dir, feature)
|
|
1298
1324
|
sidecar = WorkerSidecar.read(session_dir)
|
|
1299
|
-
sidecar.nil? || sidecar.features.include?(
|
|
1325
|
+
sidecar.nil? || sidecar.features.include?(feature)
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
# The unclaimed input files +author+ wrote.
|
|
1329
|
+
private_class_method def self.pending_files(session_dir, author)
|
|
1330
|
+
SessionInbox.find_new_input_files(session_dir).count do |file|
|
|
1331
|
+
data = JSON.parse(File.read(file))
|
|
1332
|
+
data.is_a?(Hash) && data["author"] == author
|
|
1333
|
+
rescue JSON::ParserError, SystemCallError
|
|
1334
|
+
false
|
|
1335
|
+
end
|
|
1300
1336
|
end
|
|
1301
1337
|
|
|
1302
1338
|
private_class_method def self.queue_unavailable
|
|
1303
1339
|
DeliverResult.new(status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE)
|
|
1304
1340
|
end
|
|
1305
1341
|
|
|
1342
|
+
private_class_method def self.review_unavailable
|
|
1343
|
+
DeliverResult.new(status: :refused, code: 409, ack: Delivery::REVIEW_UNAVAILABLE)
|
|
1344
|
+
end
|
|
1345
|
+
|
|
1306
1346
|
private_class_method def self.turn_timeout
|
|
1307
1347
|
DeliverResult.new(status: :timeout, via: :bridge,
|
|
1308
1348
|
ack: { "error" => "worker_timeout",
|
|
@@ -12,12 +12,16 @@ module Samagotchi
|
|
|
12
12
|
# +cost_sum+ is nil until a request reports a cost (a reported 0
|
|
13
13
|
# included), +cost_estimate_sum+ 0 until one is estimated from a price
|
|
14
14
|
# (ModelPrice.estimate, the turns' rule). +by_kind+ is a frozen Hash
|
|
15
|
-
# kind => {requests:, cost_sum:, cost_estimate_sum:} in first-seen order
|
|
15
|
+
# kind => {requests:, cost_sum:, cost_estimate_sum:} in first-seen order;
|
|
16
|
+
# +by_author+ the same rows by the participant who caused them ("p:<id>":
|
|
17
|
+
# their turn's ctx.ask_model, the recap after it), the owner's not kept.
|
|
16
18
|
class SideTotals < Data.define(:requests, :prompt_sum, :completion_sum, :cached_sum, :cache_write_sum,
|
|
17
|
-
:reasoning_sum, :cost_sum, :cost_estimate_sum, :by_kind)
|
|
19
|
+
:reasoning_sum, :cost_sum, :cost_estimate_sum, :by_kind, :by_author)
|
|
18
20
|
# The counts summed as they are (the costs and by_kind apart).
|
|
19
21
|
COUNTS = %i[requests prompt_sum completion_sum cached_sum cache_write_sum reasoning_sum].freeze
|
|
20
22
|
|
|
23
|
+
def initialize(by_author: {}.freeze, **) = super
|
|
24
|
+
|
|
21
25
|
def self.zero
|
|
22
26
|
new(**COUNTS.to_h { |key| [key, 0] }, cost_sum: nil, cost_estimate_sum: 0, by_kind: {}.freeze)
|
|
23
27
|
end
|
|
@@ -35,7 +39,7 @@ module Samagotchi
|
|
|
35
39
|
**COUNTS.to_h { |key| [key, count(hash[key.to_s])] },
|
|
36
40
|
cost_sum: cost(hash["cost_sum"]),
|
|
37
41
|
cost_estimate_sum: number(hash["cost_estimate_sum"]),
|
|
38
|
-
by_kind: saved_kinds(hash["by_kind"])
|
|
42
|
+
by_kind: saved_kinds(hash["by_kind"]), by_author: saved_kinds(hash["by_author"])
|
|
39
43
|
)
|
|
40
44
|
end
|
|
41
45
|
|
|
@@ -59,13 +63,14 @@ module Samagotchi
|
|
|
59
63
|
one = { requests: 1, cost_sum: usage.cost&.to_f, cost_estimate_sum: estimate || 0 }
|
|
60
64
|
kind = usage.kind.to_s
|
|
61
65
|
rows = by_kind.merge(kind => sum_row(by_kind[kind], one))
|
|
66
|
+
authors = usage.author ? by_author.merge(usage.author => sum_row(by_author[usage.author], one)).freeze : by_author
|
|
62
67
|
with(requests: requests + 1, prompt_sum: prompt_sum + usage.prompt_tokens.to_i,
|
|
63
68
|
completion_sum: completion_sum + usage.completion_tokens.to_i,
|
|
64
69
|
cached_sum: cached_sum + usage.cached_tokens.to_i,
|
|
65
70
|
cache_write_sum: cache_write_sum + usage.cache_write_tokens.to_i,
|
|
66
71
|
reasoning_sum: reasoning_sum + usage.reasoning_tokens.to_i,
|
|
67
72
|
cost_sum: add_cost(cost_sum, one[:cost_sum]),
|
|
68
|
-
cost_estimate_sum: cost_estimate_sum + one[:cost_estimate_sum], by_kind: rows.freeze)
|
|
73
|
+
cost_estimate_sum: cost_estimate_sum + one[:cost_estimate_sum], by_kind: rows.freeze, by_author: authors)
|
|
69
74
|
end
|
|
70
75
|
|
|
71
76
|
# These totals with +other+'s added (a loaded block and the requests
|
|
@@ -74,18 +79,27 @@ module Samagotchi
|
|
|
74
79
|
# @return [SideTotals]
|
|
75
80
|
def merge(other)
|
|
76
81
|
rows = other.by_kind.each_with_object(by_kind.dup) { |(kind, row), all| all[kind] = sum_row(all[kind], row) }
|
|
82
|
+
authors = other.by_author.each_with_object(by_author.dup) { |(who, row), all| all[who] = sum_row(all[who], row) }
|
|
77
83
|
with(**COUNTS.to_h { |key| [key, public_send(key) + other.public_send(key)] },
|
|
78
84
|
cost_sum: add_cost(cost_sum, other.cost_sum),
|
|
79
|
-
cost_estimate_sum: cost_estimate_sum + other.cost_estimate_sum, by_kind: rows.freeze)
|
|
85
|
+
cost_estimate_sum: cost_estimate_sum + other.cost_estimate_sum, by_kind: rows.freeze, by_author: authors.freeze)
|
|
80
86
|
end
|
|
81
87
|
|
|
82
88
|
def empty? = requests.zero?
|
|
83
89
|
|
|
84
|
-
# The snapshot's side block (symbol keys; by_kind keyed by kind
|
|
90
|
+
# The snapshot's side block (symbol keys; by_kind keyed by kind;
|
|
91
|
+
# by_author only when a participant caused any).
|
|
85
92
|
# @return [Hash]
|
|
86
93
|
def to_h
|
|
87
94
|
counts = COUNTS.to_h { |key| [key, public_send(key)] }
|
|
88
|
-
counts.merge(cost_sum: cost_sum, cost_estimate_sum: cost_estimate_sum, by_kind: by_kind.transform_values(&:dup))
|
|
95
|
+
block = counts.merge(cost_sum: cost_sum, cost_estimate_sum: cost_estimate_sum, by_kind: by_kind.transform_values(&:dup))
|
|
96
|
+
by_author.empty? ? block : block.merge(by_author: by_author.transform_values(&:dup))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The dollars +author+'s side requests cost (reported and estimated).
|
|
100
|
+
def spent_by(author)
|
|
101
|
+
row = by_author[author] or return 0.0
|
|
102
|
+
row[:cost_sum].to_f + row[:cost_estimate_sum].to_f
|
|
89
103
|
end
|
|
90
104
|
|
|
91
105
|
def self.count(value) = value.is_a?(Numeric) ? value.to_i : 0
|