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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/docs/architecture.md +6 -3
  4. data/docs/cli.md +62 -19
  5. data/docs/configuration.md +10 -6
  6. data/docs/guardrails.md +69 -0
  7. data/docs/hooks.md +9 -0
  8. data/docs/internals/anthropic.md +2 -1
  9. data/docs/internals/engine.md +37 -1
  10. data/docs/internals/participants.md +229 -21
  11. data/docs/internals/prompt-caching.md +9 -0
  12. data/docs/internals/working-set.md +12 -5
  13. data/docs/plugins.md +2 -0
  14. data/docs/testing.md +23 -0
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -4
  16. data/lib/samagotchi/bridge.rb +45 -8
  17. data/lib/samagotchi/bridge_client.rb +4 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/client_id.rb +21 -9
  20. data/lib/samagotchi/config.rb +17 -2
  21. data/lib/samagotchi/continue_offer.rb +13 -3
  22. data/lib/samagotchi/delivery.rb +10 -0
  23. data/lib/samagotchi/engine.rb +98 -8
  24. data/lib/samagotchi/guardrail_wiring.rb +13 -3
  25. data/lib/samagotchi/guardrails/review_boundary.rb +148 -0
  26. data/lib/samagotchi/guardrails.rb +1 -0
  27. data/lib/samagotchi/hook_routes.rb +5 -2
  28. data/lib/samagotchi/hooks/registry.rb +12 -3
  29. data/lib/samagotchi/idle_recap.rb +9 -0
  30. data/lib/samagotchi/last_turn.rb +5 -2
  31. data/lib/samagotchi/llm/chat_loop.rb +1 -0
  32. data/lib/samagotchi/parent_report.rb +17 -5
  33. data/lib/samagotchi/participant_messages.rb +16 -0
  34. data/lib/samagotchi/participant_name.rb +21 -0
  35. data/lib/samagotchi/participants.rb +29 -6
  36. data/lib/samagotchi/participants_watch.rb +90 -0
  37. data/lib/samagotchi/plugin/context.rb +7 -1
  38. data/lib/samagotchi/plugin/side_question.rb +9 -1
  39. data/lib/samagotchi/queued_command.rb +9 -5
  40. data/lib/samagotchi/reply_wait/poll.rb +18 -3
  41. data/lib/samagotchi/run_options.rb +1 -1
  42. data/lib/samagotchi/send_command.rb +41 -3
  43. data/lib/samagotchi/session_access/contract.rb +2 -0
  44. data/lib/samagotchi/session_access/local.rb +31 -9
  45. data/lib/samagotchi/session_access/remote.rb +19 -2
  46. data/lib/samagotchi/session_access/web_bridge_client.rb +4 -2
  47. data/lib/samagotchi/session_access.rb +6 -3
  48. data/lib/samagotchi/session_commands.rb +6 -0
  49. data/lib/samagotchi/session_inbox.rb +31 -5
  50. data/lib/samagotchi/session_manager.rb +43 -3
  51. data/lib/samagotchi/session_metrics/side_totals.rb +21 -7
  52. data/lib/samagotchi/session_metrics.rb +94 -1
  53. data/lib/samagotchi/session_participants.rb +59 -0
  54. data/lib/samagotchi/sessions_command.rb +10 -7
  55. data/lib/samagotchi/share_command.rb +61 -22
  56. data/lib/samagotchi/side_usage.rb +4 -2
  57. data/lib/samagotchi/steer.rb +120 -11
  58. data/lib/samagotchi/system_prompt.rb +21 -2
  59. data/lib/samagotchi/terminal_ui/attach_link.rb +5 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +94 -22
  61. data/lib/samagotchi/terminal_ui/formatting.rb +66 -5
  62. data/lib/samagotchi/terminal_ui/local_link.rb +7 -0
  63. data/lib/samagotchi/terminal_ui/participant_commands.rb +77 -0
  64. data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
  65. data/lib/samagotchi/terminal_ui/remote_link.rb +6 -2
  66. data/lib/samagotchi/terminal_ui/status_row.rb +11 -2
  67. data/lib/samagotchi/terminal_ui.rb +5 -0
  68. data/lib/samagotchi/testing/host.rb +4 -2
  69. data/lib/samagotchi/testing/recorder.rb +3 -1
  70. data/lib/samagotchi/toolkit.rb +21 -3
  71. data/lib/samagotchi/turn_checkpoint.rb +5 -2
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/web/app.rb +45 -20
  74. data/lib/samagotchi/web/grants.rb +89 -8
  75. data/lib/samagotchi/web/participant_access.rb +206 -35
  76. data/lib/samagotchi/web/participant_review.rb +165 -0
  77. data/lib/samagotchi/web/participant_stream_filter.rb +157 -0
  78. data/lib/samagotchi/web/participant_view.rb +415 -0
  79. data/lib/samagotchi/web/public/app.js +15 -1
  80. data/lib/samagotchi/web/public/bubbles.js +3 -3
  81. data/lib/samagotchi/web/public/data.js +20 -0
  82. data/lib/samagotchi/web/public/index.html +75 -30
  83. data/lib/samagotchi/web/public/info_bar.js +12 -5
  84. data/lib/samagotchi/web/public/info_bar_model.js +8 -5
  85. data/lib/samagotchi/web/public/participant.html +119 -0
  86. data/lib/samagotchi/web/public/participant.js +300 -0
  87. data/lib/samagotchi/web/public/participant_model.js +160 -0
  88. data/lib/samagotchi/web/public/participant_names.js +21 -0
  89. data/lib/samagotchi/web/public/participants_panel.js +311 -0
  90. data/lib/samagotchi/web/public/stream_handlers.js +8 -1
  91. data/lib/samagotchi/web/public/tokens.css +12 -0
  92. data/lib/samagotchi/web/public/turn_events.js +16 -5
  93. data/lib/samagotchi/web/public/turn_html.js +8 -4
  94. data/lib/samagotchi/web/public/turn_view.js +2 -1
  95. data/lib/samagotchi/web/public/working_set.js +6 -4
  96. data/lib/samagotchi/web/public/working_set_view.js +4 -4
  97. data/lib/samagotchi/web/qr.rb +14 -0
  98. data/lib/samagotchi/web/running.rb +52 -0
  99. data/lib/samagotchi/web/server.rb +13 -0
  100. data/lib/samagotchi/web/session_access_routes.rb +3 -0
  101. data/lib/samagotchi/worker.rb +125 -11
  102. data/lib/samagotchi/worker_inbound.rb +12 -1
  103. 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.waiting_input(@session_dir, @turn)
82
- @own_turn_input ||= waiting == :turn_end
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, 4 still running (--timeout), 130 Ctrl-C.
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
- @info.puts("#{short} sent#{with_images}#{delivery_note(result, delivery: delivery, running: running, owner: owner)}")
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 when one
343
- # runs on the LAN (web.port), else 127.0.0.1 (+lan+ false).
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
- port = Config.get("web.port").to_s.to_i
373
- port = WEB_PORT unless port.positive?
374
- info = Web::InfoProbe.call("127.0.0.1", port, timeout: LOCAL_PROBE_TIMEOUT)
375
- [info.is_a?(Hash) ? info["lan"] : nil, port]
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}: this participant has too many turns waiting: wait for one to finish")
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}: this participant's spending cap is reached: ask the session's owner to raise it")
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`) or an image message (steering merges text only).
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) && (Array(data["images"]).any? || Delivery.queue?(data["delivery"]))
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
- next unless data.is_a?(Hash) && data["enqueued_id"] == id
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?(Delivery::QUEUE_FEATURE)
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