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
@@ -22,6 +22,7 @@ require_relative "engine"
22
22
  require_relative "session_commands"
23
23
  require_relative "queued_command"
24
24
  require_relative "steer"
25
+ require_relative "participants"
25
26
  require_relative "image_store"
26
27
  require_relative "log"
27
28
  require_relative "version"
@@ -322,11 +323,12 @@ module Samagotchi
322
323
  # @param after_file [String, nil] the input file it came in (its
323
324
  # basename): it runs once that file is taken (Worker#on_command)
324
325
  # @return [String] its command_id
325
- def queue_command(line, client_id: nil, card: false, after_file: nil, enqueued_id: nil)
326
+ def queue_command(line, client_id: nil, card: false, after_file: nil, enqueued_id: nil, author: nil)
326
327
  # A card's action (the step-limit question's answer): the UIs leave
327
328
  # its line out. +enqueued_id+: a file-delivered command's input, whose
328
- # sender's wait its command_ran settles.
329
- command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card).with(enqueued_id: enqueued_id)
329
+ # sender's wait its command_ran settles. +author+: its input's.
330
+ command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card, author: author)
331
+ .with(enqueued_id: enqueued_id)
330
332
  @engine.synchronize_events { queue_command_locked(command, after_file: after_file) }
331
333
  command.command_id
332
334
  end
@@ -542,8 +544,11 @@ module Samagotchi
542
544
  # Stop's last_turn carry the turn's enqueued_id (ReplyWait.own_turn).
543
545
  # unqueue: POST turn/dequeue (#handle_dequeue_turn) takes a queued
544
546
  # message back before it runs.
547
+ # review_turns: a participant's turn runs as its own, inside the review
548
+ # boundary (Guardrails::ReviewBoundary); a worker without it has none,
549
+ # so chi web sends it no participant's turn (Delivery::REVIEW_FEATURE).
545
550
  FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
546
- SessionInbox::UNQUEUE_FEATURE].freeze
551
+ SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE].freeze
547
552
 
548
553
  # The routes, by method and what follows /session/:id/: a handler takes
549
554
  # the session id and the request body and returns [headers, status, body].
@@ -792,10 +797,11 @@ module Samagotchi
792
797
 
793
798
  # Queues +line+ (a session command) for the worker and announces its
794
799
  # :command_queued, unless +deadline+ has passed. Returns [headers,
795
- # status, body]: 202 with its command_id, or 408.
796
- def queue_command_request(session_id, line, client_id:, deadline: nil, card: false)
800
+ # status, body]: 202 with its command_id, or 408. +author+: a POST
801
+ # /turn's (a /word sent as a message).
802
+ def queue_command_request(session_id, line, client_id:, deadline: nil, card: false, author: nil)
797
803
  # A card's action: the UIs leave its line out (the card is the echo).
798
- command = QueuedCommand.mint(line, client_id: client_id, card: card)
804
+ command = QueuedCommand.mint(line, client_id: client_id, card: card, author: author)
799
805
  queued = @engine.synchronize_events do
800
806
  raise IntakeClosed if stopped?
801
807
  next false if expired?("command_expired", deadline, sid: session_id, client_id: command.client_id)
@@ -914,13 +920,26 @@ module Samagotchi
914
920
  end
915
921
 
916
922
  delivery = Delivery.parse(raw_delivery)
923
+ # How many of a participant's turns may be running or waiting
924
+ # (chi web's, from their grant: its queue limit plus the running one).
925
+ pending_limit = nil
926
+ if Steer.participant_author?(author)
927
+ # A participant's message is a turn of its own, whatever the body
928
+ # asks (D6), and never a command or a shell line: chi web refuses
929
+ # those first, and this is the worker's own layer.
930
+ return participant_refused(sid, author, "command") if command_registry.command?(prompt.to_s)
931
+
932
+ delivery = Delivery::QUEUE
933
+ pending_limit = fetched(parsed, "pending_limit")
934
+ pending_limit = nil unless pending_limit.is_a?(Integer) && pending_limit.positive?
935
+ end
917
936
 
918
937
  # A session command sent as a message (chi send -m "/model x", chi -p,
919
938
  # a UI whose command list lags) runs as the command, as typed in a
920
939
  # TUI; an unknown /word stays a prompt. Not with images: those are
921
940
  # for the model.
922
941
  if images.empty? && @on_command && command_registry.command?(prompt.to_s)
923
- return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline)
942
+ return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline, author: author)
924
943
  end
925
944
 
926
945
  enqueued_id = SecureRandom.uuid
@@ -936,6 +955,7 @@ module Samagotchi
936
955
  enqueued = @engine.synchronize_events do
937
956
  raise IntakeClosed if stopped?
938
957
  next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
958
+ next :too_many if pending_limit && pending_turns(author) >= pending_limit
939
959
 
940
960
  queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
941
961
  enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
@@ -945,11 +965,13 @@ module Samagotchi
945
965
  enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
946
966
  enqueued_event[:images] = images unless images.empty?
947
967
  enqueued_event[:delivery] = Delivery::QUEUE if queued_behind_turn
968
+ enqueued_event[:author] = author if author
948
969
  @engine.announce(enqueued_event)
949
970
  @on_input&.call
950
971
  end
951
972
  end
952
973
  return deadline_passed("turn") if enqueued == :expired
974
+ return [{}, 429, { error: "too_many_turns", detail: ParticipantMessages::TOO_MANY_TURNS }] if enqueued == :too_many
953
975
  return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
954
976
 
955
977
  # Queued (outside the event lock): a message sent as +cut+ cuts a
@@ -1003,6 +1025,21 @@ module Samagotchi
1003
1025
  dequeue_reply(taken, id, input)
1004
1026
  end
1005
1027
 
1028
+ # +author+'s turns running or waiting: the running turn when it is
1029
+ # theirs, and their queued ones. Read with the event log held.
1030
+ def pending_turns(author)
1031
+ running = Steer.sender_author(@accumulator.current_turn&.dig(:origin, :author)) == author ? 1 : 0
1032
+ running + @accumulator.queued.count { |row| row[:author] == author }
1033
+ end
1034
+
1035
+ # 403 not_permitted for a participant's +what+ (a command, a shell
1036
+ # line), with its audit line.
1037
+ def participant_refused(sid, author, what)
1038
+ Participants.log(sid, :refused, state_dir: File.dirname(session_dir), participant: author.delete_prefix("p:"), what: what,
1039
+ reason: "not_permitted")
1040
+ [{}, 403, { error: "not_permitted", detail: Participants::NOT_PERMITTED }]
1041
+ end
1042
+
1006
1043
  def dequeue_reply(taken, id, input)
1007
1044
  case taken
1008
1045
  when :removed then [{}, 200, { status: "removed", enqueued_id: id, prompt: input.prompt.to_s, session_id: @session_id }]
@@ -167,10 +167,13 @@ module Samagotchi
167
167
  # the session's images/
168
168
  # @param author [String, nil] who wrote it when it isn't the session's
169
169
  # owner; sent only when given (SessionManager.deliver_turn)
170
- def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil)
170
+ # @param pending_limit [Integer, nil] with a participant's +author+: how
171
+ # many of their turns may be running or waiting (429 too_many_turns past it)
172
+ def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil, pending_limit: nil)
171
173
  body = { session_id: @session_id, prompt: prompt, client_id: client_id }
172
174
  body[:no_interrupt] = true if no_interrupt
173
175
  body[:author] = author if author
176
+ body[:pending_limit] = pending_limit if author && pending_limit
174
177
  body[:images] = images if images && !images.empty?
175
178
  body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
176
179
  body[:deadline] = deadline
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.104.0
3
+ version: 0.106.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -34,12 +34,16 @@ module Samagotchi
34
34
  PLUGIN = "plugin"
35
35
  # A relayed answer (RelayVerifier): relay:<parent8>.
36
36
  RELAY_PREFIX = "relay:"
37
+ # A participant's review turn: review:<participant id> (chi web stamps
38
+ # it from the participant's token, never from the request's body).
39
+ REVIEW_PREFIX = "review:"
37
40
 
38
- # Input a human typed: a web tab, a chi TUI, chi send, and nil (a
39
- # worker's initial prompt; a web client may send none). An allowlist:
40
- # delegates, plugins, reminders, context changes and any id chi doesn't
41
- # know are not.
42
- HUMAN_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
41
+ # Input a human typed: a web tab, a chi TUI, chi send, a participant
42
+ # (review:, a person or an agent they run: never automatic input), and
43
+ # nil (a worker's initial prompt; a web client may send none). An
44
+ # allowlist: delegates, plugins, reminders, context changes and any id
45
+ # chi doesn't know are not.
46
+ HUMAN_PREFIXES = [WEB_PREFIX, TUI_PREFIX, REVIEW_PREFIX].freeze
43
47
  HUMAN_IDS = [CLI_SEND].freeze
44
48
 
45
49
  # A person at a screen that shows a command's reply and can answer it
@@ -47,16 +51,17 @@ module Samagotchi
47
51
  INTERACTIVE_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
48
52
 
49
53
  # A turn's origin by its sender (#origin, LastTurn#origin): the ids chi
50
- # runs turns for on its own, by prefix; system:reminder is "reminder",
51
- # anything else "client".
54
+ # runs turns for on its own, by prefix, and a participant's ("review");
55
+ # system:reminder is "reminder", anything else "client".
52
56
  ORIGIN_PREFIXES = { DELEGATE_PREFIX => "delegate", CHILD_PREFIX => "delegate_report",
53
- CONTEXT_PREFIX => "context", TASK_PREFIX => "task" }.freeze
57
+ CONTEXT_PREFIX => "context", TASK_PREFIX => "task", REVIEW_PREFIX => "review" }.freeze
54
58
 
55
59
  module_function
56
60
 
57
61
  # What sent a turn: "delegate" (a delegating parent), "delegate_report"
58
62
  # (a child's report), "context" (a context wake), "task" (a task finish
59
- # wake), "reminder", else "client" (a person, chi send, a plugin, nil).
63
+ # wake), "review" (a participant), "reminder", else "client" (the
64
+ # owner, chi send, a plugin, nil).
60
65
  # @return [String]
61
66
  def origin(client_id)
62
67
  id = client_id.to_s
@@ -81,5 +86,12 @@ module Samagotchi
81
86
  id = client_id.to_s
82
87
  HUMAN_IDS.include?(id) || HUMAN_PREFIXES.any? { |prefix| id.start_with?(prefix) }
83
88
  end
89
+
90
+ # The client id a participant's turn carries (chi web's, from their
91
+ # grant): review:<participant id>.
92
+ def review(participant_id) = "#{REVIEW_PREFIX}#{participant_id}"
93
+
94
+ # Whether +client_id+ is a participant's (review:).
95
+ def review?(client_id) = client_id.to_s.start_with?(REVIEW_PREFIX)
84
96
  end
85
97
  end
@@ -306,11 +306,16 @@ module Samagotchi
306
306
  Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn], lifecycle: :web_start),
307
307
  # The stage view's working set (files, memories and URLs the turn brought into context): a rail in the stage, a
308
308
  # footprint under each finished turn and a "set N" chip in place of the mem chip; ?working_set=1|0 overrides it per page load.
309
- Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: false, expose: %i[env config cli], lifecycle: :web_start),
309
+ Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: true, expose: %i[env config cli], lifecycle: :web_start),
310
310
  # Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
311
311
  Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli], lifecycle: :web_start),
312
312
  # What a file ref (a tool row's file, a diff line) opens on this machine: a preset (vscode, vscode-insiders, cursor, zed, none) or a URL template with {path} and an optional {line} (Web::Editor).
313
313
  Entry.new(key: "web.editor", yaml_path: %w[web editor], type: :string, default: "vscode", expose: %i[env config cli], lifecycle: :web_start),
314
+ # Participants (chi share): a new grant's lifetime (Nh or Nd, 1h to 30d), and what a grant without its own
315
+ # takes: its spending cap in dollars ($0 to $1000) and how many of its turns may wait (0 to 20). Read when used.
316
+ Entry.new(key: "web.participants.expires", yaml_path: %w[web participants expires], type: :string, default: "7d", expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
317
+ Entry.new(key: "web.participants.cap", yaml_path: %w[web participants cap], type: :float, default: 2.0, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
318
+ Entry.new(key: "web.participants.queue", yaml_path: %w[web participants queue], type: :integer, default: 3, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
314
319
 
315
320
  Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
316
321
  Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
@@ -609,7 +614,7 @@ module Samagotchi
609
614
  elsif MAP_ENTRY_KEYS.key?(key)
610
615
  map_entry_problems(key, value)
611
616
  elsif value.is_a?(Hash) && (known_sections.include?(key) || ENTRIES.any? { |e| e.yaml_path.size > 1 && e.section == key })
612
- value.keys.filter_map { |leaf| key_problem("#{key}.#{leaf}") }
617
+ value.flat_map { |leaf, inner| leaf_problems("#{key}.#{leaf}", inner) }
613
618
  elsif known_sections.include?(key)
614
619
  # An all-commented section is nil; recap: false turns recaps off.
615
620
  value.nil? || (key == "recap" && value == false) ? [] : ["config: '#{key}' must be a mapping of settings; ignored"]
@@ -619,6 +624,16 @@ module Samagotchi
619
624
  end + model_ref_problems(data)
620
625
  end
621
626
 
627
+ # A section's leaf: a key the registry knows, or a mapping of a
628
+ # third level (web.participants.cap) whose keys it knows.
629
+ def leaf_problems(path, value)
630
+ if value.is_a?(Hash) && ENTRIES.any? { |e| e.key.start_with?("#{path}.") }
631
+ value.keys.filter_map { |leaf| key_problem("#{path}.#{leaf}") }
632
+ else
633
+ [key_problem(path)].compact
634
+ end
635
+ end
636
+
622
637
  # What a model ref in config.yml won't do as written: an alias whose
623
638
  # target is another alias (aliases apply once, ModelRef), a ref that
624
639
  # starts with "<host>/" (only ':' names a host).
@@ -3,6 +3,7 @@
3
3
  require "time"
4
4
  require_relative "last_turn"
5
5
  require_relative "log"
6
+ require_relative "steer"
6
7
 
7
8
  module Samagotchi
8
9
  # A session worker's continue offer: what it announces when a turn runs out
@@ -34,12 +35,15 @@ module Samagotchi
34
35
  # @param max_iterations [#call] (no_interrupt) → Integer
35
36
  # @param queue_command [#call] (line, client_id:) queues a session
36
37
  # command as a card's (Bridge#queue_command, card: true)
37
- def initialize(engine:, turn_flow:, run_turn:, max_iterations:, queue_command: nil)
38
+ # +participant_refused+ (author → Boolean): whether a participant's
39
+ # turn may no longer run (revoked, expired, at the cap: Worker's check).
40
+ def initialize(engine:, turn_flow:, run_turn:, max_iterations:, queue_command: nil, participant_refused: ->(_) { false })
38
41
  @engine = engine
39
42
  @turn_flow = turn_flow
40
43
  @run_turn = run_turn
41
44
  @max_iterations = max_iterations
42
45
  @queue_command = queue_command
46
+ @participant_refused = participant_refused
43
47
  # A question was posted since the last close (it may have been
44
48
  # answered or superseded meanwhile).
45
49
  @asked = false
@@ -124,12 +128,18 @@ module Samagotchi
124
128
  # never began leaves it to no other.
125
129
  # The continue turn is the offer turn's: its origin carries that turn's
126
130
  # enqueued_id, so its last_turn, its reply file and its checkpoint mark
127
- # do too, and a wait on that turn (chi answer) gets its reply.
131
+ # do too, and a wait on that turn (chi answer) gets its reply; and its
132
+ # author, so a participant's turn continues as theirs, inside the
133
+ # review boundary and on their spend (never as the owner's). One whose
134
+ # participant may no longer run a turn isn't continued.
128
135
  def run_continue_turn(command)
129
136
  offer = @turn_flow.offer
130
137
  resume_from = @turn_flow.resume_from
131
138
  enqueued_id = offer_turn_id
139
+ author = Steer.sender_author(@engine.session&.last_turn&.author)
132
140
  close("answered")
141
+ return if author && @participant_refused.call(author)
142
+
133
143
  steer = @continue_steer
134
144
  @continue_steer = nil
135
145
  begin
@@ -140,7 +150,7 @@ module Samagotchi
140
150
  Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
141
151
  return announce_offer(offer)
142
152
  end
143
- origin = { client_id: command.client_id, enqueued_id: enqueued_id }.compact
153
+ origin = { client_id: command.client_id, enqueued_id: enqueued_id, author: author }.compact
144
154
  @run_turn.call(nil, continue: true, origin: origin, resumed: true, resume_from: resume_from,
145
155
  max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
146
156
  if error
@@ -48,6 +48,16 @@ module Samagotchi
48
48
  "restart it (chi sessions restart ID, or the web's update badge) and send again"
49
49
  }.freeze
50
50
 
51
+ # A worker that runs participants' turns inside the review boundary
52
+ # (Bridge::FEATURES): one without it would run theirs with the owner's
53
+ # tools, so none is sent to it.
54
+ REVIEW_FEATURE = "review_turns"
55
+ REVIEW_UNAVAILABLE = {
56
+ "error" => "review_unavailable",
57
+ "detail" => "this session's worker predates participants' turns, so it has no review boundary; " \
58
+ "the owner must restart it (chi sessions restart ID, or the web's update badge)"
59
+ }.freeze
60
+
51
61
  # Whether it is the one every sender defaulting to a step boundary uses.
52
62
  def self.next_step?(value) = parse(value) == NEXT_STEP
53
63
 
@@ -85,6 +85,8 @@ require_relative "guardrail_wiring"
85
85
  require_relative "plugin_tasks"
86
86
  require_relative "prompt_warmup"
87
87
  require_relative "working_set"
88
+ require_relative "participants_watch"
89
+ require_relative "session_participants"
88
90
 
89
91
  module Samagotchi
90
92
  # Engine owns the core agent logic: system prompt construction, tool
@@ -218,6 +220,13 @@ module Samagotchi
218
220
  Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
219
221
  @resume_session = session_id ? Session.load(session_id) : nil
220
222
  @prompt_builder = build_system_prompt(memories, appended_prompt_files)
223
+ # The session's participants (participants.json), read at each turn's
224
+ # start: their names for the messages they wrote.
225
+ @participants = SessionParticipants.new(state_dir: -> { session_state_dir })
226
+ # Whether the system prompt carries the participants line.
227
+ @participants_line = false
228
+ # Who is in the review now, and who came or went (#announce_participants).
229
+ @participants_watch = ParticipantsWatch.new(session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
221
230
  @session = nil
222
231
  @session_observer = SessionObserver.new
223
232
  @metrics = SessionMetrics.new
@@ -414,7 +423,8 @@ module Samagotchi
414
423
  SystemPrompt.new(profile: -> { profile }, tools: -> { @tools }, session: -> { @session },
415
424
  thinking: -> { turn_thinking }, model: -> { model_identity },
416
425
  model_notes: -> { model_notes }, memories: memories,
417
- muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
426
+ muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files,
427
+ participants: -> { @participants_line })
418
428
  end
419
429
  private :build_system_prompt
420
430
 
@@ -588,9 +598,12 @@ module Samagotchi
588
598
  # recreate a deleted session's folder; the folder itself may not exist
589
599
  # yet before a first turn). Safe from any thread: the recap's, a
590
600
  # plugin's, the turn's.
601
+ # A side request counts against the participant who caused it
602
+ # (#side_author), its usage's own author when it names one.
591
603
  # @param usage [SideUsage]
592
604
  # @return [void]
593
605
  def note_side_usage(usage)
606
+ usage = usage.with(author: side_author(usage.kind)) if usage.author.nil?
594
607
  synchronize_events do
595
608
  block = @metrics.add_side(usage)
596
609
  @session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
@@ -602,6 +615,24 @@ module Samagotchi
602
615
  @metrics.persist(state_dir: session_state_dir)
603
616
  end
604
617
 
618
+ # Who caused a side request of +kind+ now: the running turn's author (a
619
+ # plugin's ctx.ask_model in it), the last turn's for the idle recap
620
+ # (it recaps that turn); nil, the owner's, for anything else (an owner
621
+ # command's ask_model between turns).
622
+ # @return [String, nil]
623
+ def side_author(kind)
624
+ return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
625
+
626
+ Steer.sender_author(@guardrail_wiring.turn_author)
627
+ end
628
+
629
+ RECAP_SIDE_KIND = "recap"
630
+
631
+ # The running turn's author when a participant wrote it, else nil
632
+ # (ctx.turn_author, the hooks' author).
633
+ # @return [String, nil]
634
+ def turn_author = turn_running? ? Steer.sender_author(@guardrail_wiring.turn_author) : nil
635
+
605
636
  # @return [SessionMetrics] the per-session analytics collector
606
637
  attr_reader :metrics
607
638
 
@@ -790,7 +821,7 @@ module Samagotchi
790
821
  # UIs need in the event log, emitted outside any turn's stream.
791
822
  ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
792
823
  command_queued command_ran context_added card hook_notice guardrail_warning
793
- plugin_init_started plugin_init_finished working_set_updated].freeze
824
+ plugin_init_started plugin_init_finished working_set_updated participants_changed].freeze
794
825
 
795
826
  # Put a transport-level event into the ordered event log. Unlike turn
796
827
  # events it reaches only persistent observers (no turn sink, no memory
@@ -1179,6 +1210,11 @@ module Samagotchi
1179
1210
  price_host: model.host_name)
1180
1211
  kit_prompt = kit_prompt_stats
1181
1212
  snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
1213
+ # Each participant's spend under their name (participants.json's).
1214
+ if (spend = snapshot[:spend_by_author])
1215
+ named = spend.to_h { |author, row| [author, row.merge(name: @participants.name_for(author, session: @session))] }
1216
+ snapshot = snapshot.merge(spend_by_author: named)
1217
+ end
1182
1218
  # The session's own settings (chi --set), as --set names them.
1183
1219
  settings = @session&.settings&.flat || {}
1184
1220
  snapshot = snapshot.merge(settings: settings) unless settings.empty?
@@ -1295,6 +1331,8 @@ module Samagotchi
1295
1331
  # that served model under served: (#served_expected_by)
1296
1332
  # :recap_enabled [Boolean] whether an idle recap is configured, with
1297
1333
  # :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
1334
+ # :participants [Array<Hash>] who is in the review now ({id:, name:,
1335
+ # kind:}; ParticipantsWatch#active), [] for nobody
1298
1336
  def session_state_snapshot
1299
1337
  served_pair = served_model(probe: false)
1300
1338
  metrics = @metrics.snapshot
@@ -1323,7 +1361,8 @@ module Samagotchi
1323
1361
  **setting_chips(SessionSetting::ALL, model),
1324
1362
  recap_enabled: !@recap.nil?,
1325
1363
  recap_min_user_turns: @recap&.min_user_turns,
1326
- recap_inactivity_seconds: @recap&.inactivity&.to_i
1364
+ recap_inactivity_seconds: @recap&.inactivity&.to_i,
1365
+ participants: @participants_watch.active
1327
1366
  }
1328
1367
  end
1329
1368
 
@@ -1462,9 +1501,46 @@ module Samagotchi
1462
1501
  session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
1463
1502
  origin: source, exhausted: limit ? true : nil, limit: limit,
1464
1503
  enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
1504
+ author: Steer.sender_author(origin.is_a?(Hash) ? origin[:author] : nil),
1465
1505
  **stop_facts(event))
1466
1506
  end
1467
1507
 
1508
+ # A participant joined (invited) or left (revoked, expired) since the
1509
+ # last look: :participants_changed to the observers (ParticipantsWatch;
1510
+ # the worker looks on its idle tick). The first look announces nobody.
1511
+ # @return [ParticipantsWatch::Change, nil]
1512
+ def announce_participants
1513
+ change = @participants_watch.changes or return nil
1514
+
1515
+ announce(change.to_event)
1516
+ change
1517
+ end
1518
+
1519
+ # Reads the session's participants at a turn's start; the first to join
1520
+ # and the last to leave rebuild the system prompt (its participants
1521
+ # line), once each: a turn in between sends the same prefix.
1522
+ def refresh_participants!(session)
1523
+ active = @participants.refresh!(session).active?
1524
+ return if active == @participants_line
1525
+
1526
+ @participants_line = active
1527
+ @prompt_builder.reset!
1528
+ end
1529
+ private :refresh_participants!
1530
+
1531
+ # The author fields a message +author+ wrote carries (Steer.author_fields:
1532
+ # author:, and author_name: from participants.json), {} for the owner.
1533
+ # The worker's drain names a participant's merged lines with it, and
1534
+ # its running save a participant's prompt.
1535
+ # @param origin [Hash, String, nil] a turn's origin, or an author
1536
+ # @param session [Session, nil] the session it is for (the turn's)
1537
+ def author_fields(origin, session: @session)
1538
+ author = origin.is_a?(Hash) ? origin[:author] : origin
1539
+ return {} unless Steer.participant_author?(author)
1540
+
1541
+ Steer.author_fields(author, @participants.name_for(author, session: session))
1542
+ end
1543
+
1468
1544
  # Why a turn stopped, from its end event (LastTurn::STOP_FACTS), for a
1469
1545
  # parent agent's wait (ReplyWait): a provider error's kind and whether
1470
1546
  # a retry may help, a cancel's reason and the hook that stopped it.
@@ -1607,7 +1683,8 @@ module Samagotchi
1607
1683
  event_routes: @event_routes,
1608
1684
  question_desk: -> { @question_desk },
1609
1685
  cancel_controller: -> { active_cancel_controller },
1610
- steer: ->(text, source) { steer(text, source: source) }
1686
+ steer: ->(text, source) { steer(text, source: source) },
1687
+ turn_author: -> { turn_author }
1611
1688
  )
1612
1689
  end
1613
1690
  private :build_hook_routes
@@ -2072,6 +2149,9 @@ module Samagotchi
2072
2149
  turn_started[:resumed] = true if turn.resumed?
2073
2150
  turn_started[:images] = image_refs unless image_refs.empty?
2074
2151
  turn_started[:cut_after] = steer_cut_after
2152
+ # A participant's turn names them (author, author_name) for the UIs'
2153
+ # badge; the owner's turn_started stays as it was.
2154
+ turn_started.merge!(author_fields(turn.origin, session: session))
2075
2155
  emit_event(turn.on_event, turn.tag(turn_started))
2076
2156
  raise image_error if image_error
2077
2157
 
@@ -2103,6 +2183,7 @@ module Samagotchi
2103
2183
  # prompt built next declares, unchanged until the next turn's start.
2104
2184
  apply_staged_tools!
2105
2185
  apply_toolkit!(session, turn.on_event)
2186
+ refresh_participants!(session)
2106
2187
  # A Stop during the wait above: the turn ends here,
2107
2188
  # before the hooks run or a reminder is used up.
2108
2189
  return false if turn.controller.cancelled?
@@ -2222,7 +2303,9 @@ module Samagotchi
2222
2303
  # Inject due reminders as a tail system message (after history, before
2223
2304
  # the new user prompt) to preserve prefix KV cache. Mutating the head
2224
2305
  # system prompt invalidates cache for the entire prefix.
2225
- due_reminders = @reminder_queue.inject!(turn.messages)
2306
+ # Not into a participant's turn: the owner's reminders are the owner's,
2307
+ # and would be used up there under the review boundary.
2308
+ due_reminders = Steer.participant_author?(turn.origin&.dig(:author)) ? [] : @reminder_queue.inject!(turn.messages)
2226
2309
  if due_reminders.any?
2227
2310
  emit_event(turn.on_event, {
2228
2311
  type: :reminder_injected,
@@ -2231,7 +2314,7 @@ module Samagotchi
2231
2314
  end
2232
2315
 
2233
2316
  unless turn.continue
2234
- user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
2317
+ user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin) }
2235
2318
  user_message[:images] = image_refs unless image_refs.empty?
2236
2319
  turn.messages << user_message
2237
2320
  session.last_prompt = turn.prompt
@@ -2583,12 +2666,18 @@ module Samagotchi
2583
2666
  end
2584
2667
 
2585
2668
  # Restore the current session's conversation to a #messages_checkpoint.
2669
+ # Between turns the working set is announced when that changed it (a
2670
+ # !rollback or a canceled continue dropping the turn's tool calls, the
2671
+ # set emptied too), so the page doesn't keep the dropped items until
2672
+ # the next request.
2586
2673
  # @param checkpoint [Array<Hash>]
2587
2674
  # @return [Array<Hash>] the session's messages
2588
2675
  def rollback_to(checkpoint)
2589
2676
  raise ArgumentError, "no current session" unless @session
2590
2677
 
2591
- replace_session_messages(@session, clone_messages(checkpoint))
2678
+ messages = replace_session_messages(@session, clone_messages(checkpoint))
2679
+ announce_working_set unless turn_running?
2680
+ messages
2592
2681
  end
2593
2682
 
2594
2683
  # Clone a messages array (shallow dup of each element).
@@ -2792,7 +2881,8 @@ module Samagotchi
2792
2881
  prompt_files: -> { @prompt_builder&.prompt_files || [] },
2793
2882
  frontend: -> { interface },
2794
2883
  cost: -> { @metrics.cost },
2795
- usage: -> { @metrics.usage }
2884
+ usage: -> { @metrics.usage },
2885
+ turn_author: -> { turn_author }
2796
2886
  )
2797
2887
  end
2798
2888
 
@@ -113,11 +113,21 @@ module Samagotchi
113
113
 
114
114
  # The gate's core checks, in order. A delegate child's boundary comes
115
115
  # before the rules, so its ask keeps the child-boundary rule id (and
116
- # its once/session scopes) when a strict rule asks too.
116
+ # its once/session scopes) when a strict rule asks too. The review
117
+ # boundary comes before both: a participant's turn is denied what it
118
+ # may not do, never asked about it (deny is sticky, so no later vote
119
+ # can turn it into a question).
117
120
  def checks
118
121
  rules = self.rules
119
- [@failures, rules.hook_asks, protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
120
- child_boundary, rules].compact
122
+ [@failures, review_boundary, rules.hook_asks, protected_paths,
123
+ (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch), child_boundary, rules].compact
124
+ end
125
+
126
+ # A participant's turn's boundary (Guardrails::ReviewBoundary): in
127
+ # every session, a no-op unless the running turn's author is a
128
+ # participant's.
129
+ def review_boundary
130
+ @review_boundary ||= Guardrails::ReviewBoundary.new(author: -> { turn_author }, root: -> { boundary_root })
121
131
  end
122
132
 
123
133
  # The delegate child's boundary (Guardrails::ChildBoundary); nil for