samagotchi 0.105.0 → 0.107.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +168 -1
  3. data/bin/chi +2 -0
  4. data/docs/architecture.md +10 -3
  5. data/docs/cli.md +90 -9
  6. data/docs/configuration.md +9 -5
  7. data/docs/guardrails.md +63 -1
  8. data/docs/hooks.md +9 -0
  9. data/docs/internals/engine.md +22 -4
  10. data/docs/internals/participants.md +319 -45
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/internals/remote-sessions.md +12 -5
  13. data/docs/internals/working-set.md +8 -2
  14. data/docs/plugins.md +31 -1
  15. data/docs/releasing.md +2 -0
  16. data/docs/sessions.md +36 -1
  17. data/docs/sub-agent.md +2 -0
  18. data/docs/testing.md +5 -1
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +19 -3
  20. data/lib/samagotchi/bridge.rb +39 -1
  21. data/lib/samagotchi/bridge_client/stream_request.rb +10 -3
  22. data/lib/samagotchi/bridge_client.rb +4 -1
  23. data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -5
  24. data/lib/samagotchi/bundles/coordinator/plugin.rb +85 -5
  25. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +18 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  28. data/lib/samagotchi/client_id.rb +21 -9
  29. data/lib/samagotchi/config.rb +16 -1
  30. data/lib/samagotchi/continue_offer.rb +60 -3
  31. data/lib/samagotchi/delivery.rb +10 -0
  32. data/lib/samagotchi/engine.rb +62 -6
  33. data/lib/samagotchi/guardrail_wiring.rb +13 -3
  34. data/lib/samagotchi/guardrails/review_boundary.rb +97 -0
  35. data/lib/samagotchi/guardrails/review_paths.rb +93 -0
  36. data/lib/samagotchi/guardrails.rb +1 -0
  37. data/lib/samagotchi/hook_routes.rb +5 -2
  38. data/lib/samagotchi/hooks/registry.rb +12 -3
  39. data/lib/samagotchi/idle_recap.rb +9 -0
  40. data/lib/samagotchi/model_profile.rb +5 -3
  41. data/lib/samagotchi/parent_report.rb +39 -5
  42. data/lib/samagotchi/participant_messages.rb +27 -0
  43. data/lib/samagotchi/participants.rb +46 -9
  44. data/lib/samagotchi/participants_watch.rb +90 -0
  45. data/lib/samagotchi/plugin/api.rb +2 -1
  46. data/lib/samagotchi/plugin/context.rb +7 -1
  47. data/lib/samagotchi/plugin/sessions.rb +25 -0
  48. data/lib/samagotchi/plugin/side_question.rb +9 -1
  49. data/lib/samagotchi/remote_command.rb +1 -1
  50. data/lib/samagotchi/remotes.rb +23 -8
  51. data/lib/samagotchi/reply_wait/poll.rb +18 -3
  52. data/lib/samagotchi/review_apply.rb +123 -0
  53. data/lib/samagotchi/review_command.rb +121 -0
  54. data/lib/samagotchi/review_start.rb +161 -0
  55. data/lib/samagotchi/run_options.rb +18 -3
  56. data/lib/samagotchi/send_command/wait_target.rb +5 -0
  57. data/lib/samagotchi/send_command.rb +43 -3
  58. data/lib/samagotchi/session.rb +33 -5
  59. data/lib/samagotchi/session_access/contract.rb +12 -2
  60. data/lib/samagotchi/session_access/dispatch.rb +8 -2
  61. data/lib/samagotchi/session_access/http_transport.rb +10 -1
  62. data/lib/samagotchi/session_access/local.rb +50 -11
  63. data/lib/samagotchi/session_access/remote.rb +34 -3
  64. data/lib/samagotchi/session_access/web_bridge_client.rb +1 -1
  65. data/lib/samagotchi/session_access.rb +29 -13
  66. data/lib/samagotchi/session_chain.rb +10 -2
  67. data/lib/samagotchi/session_commands.rb +6 -0
  68. data/lib/samagotchi/session_continue.rb +4 -3
  69. data/lib/samagotchi/session_inbox.rb +31 -5
  70. data/lib/samagotchi/session_manager.rb +68 -6
  71. data/lib/samagotchi/session_metrics/side_totals.rb +21 -7
  72. data/lib/samagotchi/session_metrics.rb +88 -1
  73. data/lib/samagotchi/session_review.rb +28 -0
  74. data/lib/samagotchi/share_command.rb +68 -23
  75. data/lib/samagotchi/side_usage.rb +4 -2
  76. data/lib/samagotchi/steer.rb +37 -1
  77. data/lib/samagotchi/terminal_ui/attach_link.rb +5 -0
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +58 -8
  79. data/lib/samagotchi/terminal_ui/formatting.rb +66 -5
  80. data/lib/samagotchi/terminal_ui/local_link.rb +7 -0
  81. data/lib/samagotchi/terminal_ui/participant_commands.rb +77 -0
  82. data/lib/samagotchi/terminal_ui/remote_link.rb +6 -2
  83. data/lib/samagotchi/terminal_ui/status_row.rb +11 -2
  84. data/lib/samagotchi/terminal_ui.rb +5 -0
  85. data/lib/samagotchi/testing/host.rb +4 -2
  86. data/lib/samagotchi/testing/recorder.rb +3 -1
  87. data/lib/samagotchi/testing/sessions.rb +10 -2
  88. data/lib/samagotchi/toolkit.rb +21 -3
  89. data/lib/samagotchi/turn_checkpoint.rb +13 -0
  90. data/lib/samagotchi/version.rb +1 -1
  91. data/lib/samagotchi/web/app.rb +151 -41
  92. data/lib/samagotchi/web/grants.rb +172 -18
  93. data/lib/samagotchi/web/participant_access.rb +273 -46
  94. data/lib/samagotchi/web/participant_review.rb +208 -0
  95. data/lib/samagotchi/web/participant_stream_filter.rb +258 -0
  96. data/lib/samagotchi/web/participant_view.rb +426 -0
  97. data/lib/samagotchi/web/public/app.js +75 -6
  98. data/lib/samagotchi/web/public/bubbles.js +3 -3
  99. data/lib/samagotchi/web/public/chip_popover.js +10 -3
  100. data/lib/samagotchi/web/public/data.js +30 -2
  101. data/lib/samagotchi/web/public/delivery_line.js +79 -15
  102. data/lib/samagotchi/web/public/index.html +97 -27
  103. data/lib/samagotchi/web/public/info_bar.js +17 -6
  104. data/lib/samagotchi/web/public/info_bar_model.js +15 -6
  105. data/lib/samagotchi/web/public/live_turn.js +3 -0
  106. data/lib/samagotchi/web/public/participant.html +172 -0
  107. data/lib/samagotchi/web/public/participant.js +447 -0
  108. data/lib/samagotchi/web/public/participant_model.js +220 -0
  109. data/lib/samagotchi/web/public/participant_names.js +53 -0
  110. data/lib/samagotchi/web/public/participants_panel.js +379 -0
  111. data/lib/samagotchi/web/public/question_cards.js +1 -0
  112. data/lib/samagotchi/web/public/review_apply.js +34 -0
  113. data/lib/samagotchi/web/public/review_start.js +101 -0
  114. data/lib/samagotchi/web/public/send_turn.js +25 -1
  115. data/lib/samagotchi/web/public/session_actions.js +44 -3
  116. data/lib/samagotchi/web/public/session_state.js +3 -0
  117. data/lib/samagotchi/web/public/start_page.js +38 -2
  118. data/lib/samagotchi/web/public/stream_handlers.js +18 -1
  119. data/lib/samagotchi/web/public/tokens.css +12 -0
  120. data/lib/samagotchi/web/public/turn_events.js +16 -5
  121. data/lib/samagotchi/web/public/turn_html.js +9 -4
  122. data/lib/samagotchi/web/public/turn_view.js +7 -1
  123. data/lib/samagotchi/web/public/working_set_view.js +3 -3
  124. data/lib/samagotchi/web/qr.rb +14 -0
  125. data/lib/samagotchi/web/running.rb +52 -0
  126. data/lib/samagotchi/web/server.rb +13 -0
  127. data/lib/samagotchi/web/session_access_routes.rb +3 -0
  128. data/lib/samagotchi/web/session_summary.rb +3 -0
  129. data/lib/samagotchi/worker.rb +121 -7
  130. data/lib/samagotchi/worker_inbound.rb +12 -4
  131. metadata +22 -1
@@ -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
@@ -311,6 +311,11 @@ module Samagotchi
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,18 @@ 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 → reason or nil): why a participant's
39
+ # turn may no longer run ("revoked", "cap_reached": Worker's check).
40
+ # @param save [#call] saves the session (a refused Continue's Stop)
41
+ def initialize(engine:, turn_flow:, run_turn:, max_iterations:, queue_command: nil, participant_refused: ->(_) {},
42
+ save: -> {})
38
43
  @engine = engine
44
+ @save = save
39
45
  @turn_flow = turn_flow
40
46
  @run_turn = run_turn
41
47
  @max_iterations = max_iterations
42
48
  @queue_command = queue_command
49
+ @participant_refused = participant_refused
43
50
  # A question was posted since the last close (it may have been
44
51
  # answered or superseded meanwhile).
45
52
  @asked = false
@@ -87,7 +94,14 @@ module Samagotchi
87
94
  # it ends (chi answer --option Stop).
88
95
  # It carries the offer turn's enqueued_id, so a wait on that turn
89
96
  # (ReplyWait's turn:) ends on it too.
97
+ # A Continue on the offer of a participant who may no longer run a turn
98
+ # (revoked, expired, at the cap since) is decided here, before anything
99
+ # says "resume": it ends the offer as a Stop does (#refuse_continue).
90
100
  def announce_resolved(result, command)
101
+ if result.resume && (reason = refusal_of_offer_author)
102
+ return refuse_continue(reason, command)
103
+ end
104
+
91
105
  @engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command.client_id)
92
106
  return if result.resume
93
107
 
@@ -124,12 +138,24 @@ module Samagotchi
124
138
  # never began leaves it to no other.
125
139
  # The continue turn is the offer turn's: its origin carries that turn's
126
140
  # 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.
141
+ # do too, and a wait on that turn (chi answer) gets its reply; and its
142
+ # author, so a participant's turn continues as theirs, inside the
143
+ # review boundary and on their spend (never as the owner's). One whose
144
+ # participant may no longer run a turn isn't continued.
128
145
  def run_continue_turn(command)
146
+ # Refused as it was answered (#announce_resolved): the offer is gone.
147
+ return @continue_steer = nil unless awaiting?
148
+
129
149
  offer = @turn_flow.offer
130
150
  resume_from = @turn_flow.resume_from
131
151
  enqueued_id = offer_turn_id
152
+ author = Steer.sender_author(@engine.session&.last_turn&.author)
132
153
  close("answered")
154
+ if author && (reason = @participant_refused.call(author))
155
+ @continue_steer = nil
156
+ return @engine.synchronize_events { refuse_continue(reason, command) }
157
+ end
158
+
133
159
  steer = @continue_steer
134
160
  @continue_steer = nil
135
161
  begin
@@ -140,7 +166,7 @@ module Samagotchi
140
166
  Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
141
167
  return announce_offer(offer)
142
168
  end
143
- origin = { client_id: command.client_id, enqueued_id: enqueued_id }.compact
169
+ origin = { client_id: command.client_id, enqueued_id: enqueued_id, author: author }.compact
144
170
  @run_turn.call(nil, continue: true, origin: origin, resumed: true, resume_from: resume_from,
145
171
  max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
146
172
  if error
@@ -165,6 +191,37 @@ module Samagotchi
165
191
 
166
192
  private
167
193
 
194
+ def refusal_of_offer_author
195
+ author = Steer.sender_author(@engine.session&.last_turn&.author)
196
+ author && @participant_refused.call(author)
197
+ end
198
+
199
+ # The offer of a participant refused since (+reason+: revoked,
200
+ # cap_reached) ends as a Stop: announced as "refused" (with a notice
201
+ # for the owner), the model reads it wasn't continued, last_turn says
202
+ # not_continued with the offer turn's enqueued_id (a wait on it ends),
203
+ # and awaiting_continue? goes false (idle exit, /exit, the held
204
+ # participant turns go on). The question goes with the next close.
205
+ # Called with the event log held.
206
+ def refuse_continue(reason, command)
207
+ author = @engine.session&.last_turn&.author
208
+ enqueued_id = offer_turn_id
209
+ @engine.announce(type: :continue_resolved, decision: "refused", reason: reason.to_s, client_id: command.client_id)
210
+ @engine.announce(type: :hook_notice, hook: "participants", level: :warn, between_turns: true,
211
+ text: format(REFUSED_NOTICE, who: author.to_s.delete_prefix("p:"), reason: REFUSED_WHY.fetch(reason.to_s, reason.to_s)))
212
+ @turn_flow.abort_continue!
213
+ session = @engine.session
214
+ session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3), enqueued_id: enqueued_id) if session
215
+ @continue_steer = nil
216
+ close("answered")
217
+ @save.call
218
+ nil
219
+ end
220
+
221
+ REFUSED_NOTICE = "Not continued: participant %<who>s's turn %<reason>s."
222
+ REFUSED_WHY = { "revoked" => "is refused (their link was revoked or expired)",
223
+ "cap_reached" => "is refused (they reached their review cap)" }.freeze
224
+
168
225
  # The enqueued_id of the turn the offer stands for: the exhausted
169
226
  # turn's last_turn (a canceled continue turn's carries the same id);
170
227
  # nil when it ran no input (a reminder turn, a delegate's first).
@@ -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,7 @@ 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"
88
89
  require_relative "session_participants"
89
90
 
90
91
  module Samagotchi
@@ -224,6 +225,8 @@ module Samagotchi
224
225
  @participants = SessionParticipants.new(state_dir: -> { session_state_dir })
225
226
  # Whether the system prompt carries the participants line.
226
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 })
227
230
  @session = nil
228
231
  @session_observer = SessionObserver.new
229
232
  @metrics = SessionMetrics.new
@@ -595,9 +598,12 @@ module Samagotchi
595
598
  # recreate a deleted session's folder; the folder itself may not exist
596
599
  # yet before a first turn). Safe from any thread: the recap's, a
597
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.
598
603
  # @param usage [SideUsage]
599
604
  # @return [void]
600
605
  def note_side_usage(usage)
606
+ usage = usage.with(author: side_author(usage.kind)) if usage.author.nil?
601
607
  synchronize_events do
602
608
  block = @metrics.add_side(usage)
603
609
  @session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
@@ -609,6 +615,24 @@ module Samagotchi
609
615
  @metrics.persist(state_dir: session_state_dir)
610
616
  end
611
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
+
612
636
  # @return [SessionMetrics] the per-session analytics collector
613
637
  attr_reader :metrics
614
638
 
@@ -797,7 +821,7 @@ module Samagotchi
797
821
  # UIs need in the event log, emitted outside any turn's stream.
798
822
  ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
799
823
  command_queued command_ran context_added card hook_notice guardrail_warning
800
- plugin_init_started plugin_init_finished working_set_updated].freeze
824
+ plugin_init_started plugin_init_finished working_set_updated participants_changed].freeze
801
825
 
802
826
  # Put a transport-level event into the ordered event log. Unlike turn
803
827
  # events it reaches only persistent observers (no turn sink, no memory
@@ -1186,6 +1210,11 @@ module Samagotchi
1186
1210
  price_host: model.host_name)
1187
1211
  kit_prompt = kit_prompt_stats
1188
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
1189
1218
  # The session's own settings (chi --set), as --set names them.
1190
1219
  settings = @session&.settings&.flat || {}
1191
1220
  snapshot = snapshot.merge(settings: settings) unless settings.empty?
@@ -1302,6 +1331,8 @@ module Samagotchi
1302
1331
  # that served model under served: (#served_expected_by)
1303
1332
  # :recap_enabled [Boolean] whether an idle recap is configured, with
1304
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
1305
1336
  def session_state_snapshot
1306
1337
  served_pair = served_model(probe: false)
1307
1338
  metrics = @metrics.snapshot
@@ -1330,7 +1361,8 @@ module Samagotchi
1330
1361
  **setting_chips(SessionSetting::ALL, model),
1331
1362
  recap_enabled: !@recap.nil?,
1332
1363
  recap_min_user_turns: @recap&.min_user_turns,
1333
- recap_inactivity_seconds: @recap&.inactivity&.to_i
1364
+ recap_inactivity_seconds: @recap&.inactivity&.to_i,
1365
+ participants: @participants_watch.active
1334
1366
  }
1335
1367
  end
1336
1368
 
@@ -1473,6 +1505,17 @@ module Samagotchi
1473
1505
  **stop_facts(event))
1474
1506
  end
1475
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
+
1476
1519
  # Reads the session's participants at a turn's start; the first to join
1477
1520
  # and the last to leave rebuild the system prompt (its participants
1478
1521
  # line), once each: a turn in between sends the same prefix.
@@ -1640,7 +1683,8 @@ module Samagotchi
1640
1683
  event_routes: @event_routes,
1641
1684
  question_desk: -> { @question_desk },
1642
1685
  cancel_controller: -> { active_cancel_controller },
1643
- steer: ->(text, source) { steer(text, source: source) }
1686
+ steer: ->(text, source) { steer(text, source: source) },
1687
+ turn_author: -> { turn_author }
1644
1688
  )
1645
1689
  end
1646
1690
  private :build_hook_routes
@@ -2105,6 +2149,9 @@ module Samagotchi
2105
2149
  turn_started[:resumed] = true if turn.resumed?
2106
2150
  turn_started[:images] = image_refs unless image_refs.empty?
2107
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))
2108
2155
  emit_event(turn.on_event, turn.tag(turn_started))
2109
2156
  raise image_error if image_error
2110
2157
 
@@ -2256,7 +2303,9 @@ module Samagotchi
2256
2303
  # Inject due reminders as a tail system message (after history, before
2257
2304
  # the new user prompt) to preserve prefix KV cache. Mutating the head
2258
2305
  # system prompt invalidates cache for the entire prefix.
2259
- 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)
2260
2309
  if due_reminders.any?
2261
2310
  emit_event(turn.on_event, {
2262
2311
  type: :reminder_injected,
@@ -2617,12 +2666,18 @@ module Samagotchi
2617
2666
  end
2618
2667
 
2619
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.
2620
2673
  # @param checkpoint [Array<Hash>]
2621
2674
  # @return [Array<Hash>] the session's messages
2622
2675
  def rollback_to(checkpoint)
2623
2676
  raise ArgumentError, "no current session" unless @session
2624
2677
 
2625
- 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
2626
2681
  end
2627
2682
 
2628
2683
  # Clone a messages array (shallow dup of each element).
@@ -2826,7 +2881,8 @@ module Samagotchi
2826
2881
  prompt_files: -> { @prompt_builder&.prompt_files || [] },
2827
2882
  frontend: -> { interface },
2828
2883
  cost: -> { @metrics.cost },
2829
- usage: -> { @metrics.usage }
2884
+ usage: -> { @metrics.usage },
2885
+ turn_author: -> { turn_author }
2830
2886
  )
2831
2887
  end
2832
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
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "review_paths"
4
+ require_relative "../toolkit"
5
+ require_relative "../steer"
6
+
7
+ module Samagotchi
8
+ module Guardrails
9
+ # A participant's turn may only read (D3, D4: it proposes, the owner
10
+ # applies). A core check, on in every guardrails mode and with
11
+ # guardrails.enabled: false, active only while the running turn's author
12
+ # is a participant (GuardrailWiring#checks); the owner's turns never get
13
+ # it. It denies, never asks (nobody on the participant's side may
14
+ # approve, and the owner's approval would be the participant's text
15
+ # acting with the owner's power), every call outside the review kit
16
+ # (Toolkit::REVIEW_TOOLS). Not forget_outputs: it would replace the
17
+ # owner's earlier tool outputs with a note of the participant's, which
18
+ # later owner turns read as their own output, with no review header.
19
+ # read: ReviewPaths: only under the session's project root, never a file of
20
+ # the read denylist (chi's state and config dirs, home secret dirs,
21
+ # project secret files), checked on the path as given and as it
22
+ # resolves (a symlink in the project to ~/.ssh is ~/.ssh);
23
+ # memory_read: the project scope only, named (no fallback to system);
24
+ # anything else, call_tool's real target included: a call_tool of a
25
+ # built-in reaches the gate as that built-in (ToolRunner#unwrapped),
26
+ # so what is left of call_tool is a plugin or MCP tool, denied in v1.
27
+ # The declared tools stay the session's (no prompt rebuild per author),
28
+ # so a participant turn's model may still try a write: it is denied
29
+ # here, with the header's "This turn may only read" to keep it rare.
30
+ class ReviewBoundary
31
+ RULE = "review-boundary"
32
+ SOURCE = "core"
33
+ ALLOWED = Toolkit::REVIEW_TOOLS
34
+ PROJECT_SCOPE = "project"
35
+
36
+ ADVICE = "This is a participant's review turn: answer from what you can read and propose any change as " \
37
+ "text or a diff for the owner to apply."
38
+ NOT_IN_KIT = "a participant's review turn may only read (read, memory_read): %s is not allowed"
39
+ OUTSIDE = "a participant's review turn reads only inside the session's project"
40
+ SECRET = "a participant's review turn can't read secrets or chi's own files"
41
+ HOME_ROOT = "a participant's review turn reads only inside a project, and this session's folder is the " \
42
+ "home folder or above it"
43
+ READ_REFUSALS = { ReviewPaths::OUTSIDE => OUTSIDE, ReviewPaths::SECRET => SECRET, ReviewPaths::HOME_ROOT => HOME_ROOT }.freeze
44
+ MEMORY_SCOPE = "a participant's review turn reads project memories only: call memory_read with scope: project"
45
+
46
+ # @param author [#call] → String, nil: the running turn's author
47
+ # (GuardrailWiring#turn_author); the check is off unless it is a
48
+ # participant's (Steer.participant_author?)
49
+ # @param root [#call] → String, nil: the session's project root (the
50
+ # work tree top of its folder); nil denies every read
51
+ # @param denied_dirs [#call] → Array<String>: chi's own dirs (state,
52
+ # config: sessions, grants, the web token, memories and .bundles,
53
+ # config.yml, hooks)
54
+ # @param home [#call] → String, nil
55
+ def initialize(author:, root:, denied_dirs: -> { ReviewPaths.chi_dirs }, home: -> { Dir.home })
56
+ @author = author
57
+ @root = root
58
+ @denied_dirs = denied_dirs
59
+ @home = home
60
+ end
61
+
62
+ # Vote on +verdict+ (its call and targets).
63
+ def check(verdict)
64
+ return verdict unless Steer.participant_author?(@author.call)
65
+
66
+ reason = refusal(verdict)
67
+ return verdict unless reason
68
+
69
+ verdict.deny!(reason, rule: RULE, source: SOURCE, decided_by: "core", advice: ADVICE)
70
+ end
71
+
72
+ private
73
+
74
+ def refusal(verdict)
75
+ call = verdict.call || {}
76
+ tool = call[:name].to_s
77
+ return format(NOT_IN_KIT, tool_label(tool, verdict.targets)) unless ALLOWED.include?(tool)
78
+
79
+ case tool
80
+ when "read" then read_refusal(verdict.targets&.paths || [])
81
+ when "memory_read" then MEMORY_SCOPE unless call[:scope].to_s.strip.downcase == PROJECT_SCOPE
82
+ end
83
+ end
84
+
85
+ # A plugin or MCP tool behind call_tool names what it acts as.
86
+ def tool_label(tool, targets)
87
+ acts_as = targets&.acts_as
88
+ acts_as && acts_as != tool ? "#{tool} (#{acts_as})" : tool
89
+ end
90
+
91
+ def read_refusal(paths)
92
+ paths_check = ReviewPaths.new(root: @root.call, denied_dirs: @denied_dirs.call, home: @home.call)
93
+ READ_REFUSALS[paths_check.refusal(paths)]
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protected_paths"
4
+ require_relative "shell_paths"
5
+ require_relative "../paths"
6
+
7
+ module Samagotchi
8
+ module Guardrails
9
+ # What a participant may read (the shared-sessions plan's §2): a file
10
+ # under the session's project root that isn't on the read denylist
11
+ # (chi's state and config dirs, home secret dirs, project secret files),
12
+ # checked on the path as given and as it resolves (a symlink in the
13
+ # project to ~/.ssh is ~/.ssh). ReviewBoundary asks it for every read of
14
+ # a participant's turn; a review session's subjects (chi review, the
15
+ # participant page's Plan) go through the same check.
16
+ class ReviewPaths
17
+ # Home folders that hold keys and tokens, never read in a review turn.
18
+ HOME_SECRET_DIRS = %w[.ssh .gnupg .aws].freeze
19
+ # Project files that hold secrets, by a path part's name: .env*,
20
+ # netrc, keys and certificates, anything named credentials, package
21
+ # registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
22
+ # secrets file; and a git config (.git/config, a submodule's
23
+ # .git/modules/x/config), checked apart.
24
+ SECRET_FILE = /\A(?:\.env.*|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
25
+ \.npmrc|\.pypirc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|secrets?\.(?:ya?ml|json))\z/xi
26
+ # The .docker/config.json kind: a dot dir's config holding tokens.
27
+ SECRET_DIR_FILES = { ".docker" => "config.json", ".kube" => "config" }.freeze
28
+ GIT_DIR = ".git"
29
+
30
+ # Why a path can't be read (#refusal).
31
+ OUTSIDE = :outside
32
+ SECRET = :secret
33
+ HOME_ROOT = :home_root
34
+
35
+ # chi's state dir and config dir, as Paths has them now.
36
+ def self.chi_dirs = [Paths.state_dir, File.join(Paths.config_home, Paths::APP_DIR)]
37
+
38
+ # @param root [String, nil] the session's project root (the work tree
39
+ # top of its folder); nil or blank refuses every path
40
+ # @param denied_dirs [Array<String>] chi's own dirs (state, config:
41
+ # sessions, grants, the web token, memories and .bundles,
42
+ # config.yml, hooks)
43
+ # @param home [String, nil]
44
+ def initialize(root:, denied_dirs: ReviewPaths.chi_dirs, home: Dir.home)
45
+ @root = root.to_s
46
+ @denied_dirs = denied_dirs
47
+ @home = home.to_s
48
+ end
49
+
50
+ # Why +paths+ can't all be read, or nil when they can: OUTSIDE (no
51
+ # path, no root, or a path outside the root), HOME_ROOT (the root is
52
+ # the home folder or above it) or SECRET.
53
+ # @param paths [Array<String>] absolute, or relative to the cwd
54
+ # @return [Symbol, nil]
55
+ def refusal(paths)
56
+ return OUTSIDE if paths.empty? || @root.empty?
57
+
58
+ real_root = ProtectedPaths.real(@root)
59
+ return HOME_ROOT if !@home.empty? && ShellPaths.within?(ProtectedPaths.real(@home), real_root)
60
+
61
+ paths.each do |path|
62
+ given = File.expand_path(path)
63
+ real = ProtectedPaths.real(given)
64
+ return OUTSIDE unless ShellPaths.within?(real, real_root)
65
+ return SECRET if secret?(given) || secret?(real)
66
+ end
67
+ nil
68
+ end
69
+
70
+ private
71
+
72
+ def secret?(path)
73
+ dirs = @denied_dirs.compact.map { |dir| File.expand_path(dir) }
74
+ dirs += dirs.map { |dir| ProtectedPaths.real(dir) }
75
+ dirs += HOME_SECRET_DIRS.map { |dir| File.join(@home, dir) } unless @home.empty?
76
+ return true if dirs.any? { |dir| ShellPaths.within?(path, dir) }
77
+
78
+ parts = path.split(File::SEPARATOR)
79
+ return true if parts.any? { |part| SECRET_FILE.match?(part) }
80
+ return true if parts.each_cons(2).any? { |dir, file| SECRET_DIR_FILES[dir.downcase] == file.downcase }
81
+
82
+ git_config?(parts)
83
+ end
84
+
85
+ # A config file anywhere under a .git dir (.git/config,
86
+ # .git/modules/<sub>/config, a worktree's config.worktree).
87
+ def git_config?(parts)
88
+ git = parts.index { |part| part.casecmp?(GIT_DIR) } or return false
89
+ parts[(git + 1)..].any? { |part| part.downcase.start_with?("config") }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -13,6 +13,7 @@ require_relative "guardrails/approvals"
13
13
  require_relative "guardrails/protected_paths"
14
14
  require_relative "guardrails/scratch_writes"
15
15
  require_relative "guardrails/child_boundary"
16
+ require_relative "guardrails/review_boundary"
16
17
  require_relative "guardrails/load_failures"
17
18
  require_relative "guardrails/rules"
18
19
  require_relative "guardrails/gate"
@@ -22,11 +22,13 @@ module Samagotchi
22
22
  # @param question_desk [#call] → QuestionDesk
23
23
  # @param cancel_controller [#call] → the running turn's CancellationController, nil
24
24
  # @param steer [#call] (text, source) → steers the running turn
25
- def initialize(event_routes:, question_desk:, cancel_controller:, steer:)
25
+ # @param turn_author [#call] → the running turn's participant author, nil
26
+ def initialize(event_routes:, question_desk:, cancel_controller:, steer:, turn_author: -> {})
26
27
  @event_routes = event_routes
27
28
  @question_desk = question_desk
28
29
  @cancel_controller = cancel_controller
29
30
  @steer = steer
31
+ @turn_author = turn_author
30
32
  @unknown_fallbacks = Set.new
31
33
  end
32
34
 
@@ -40,7 +42,8 @@ module Samagotchi
40
42
  ask_user: ->(question) { ask_user(question) },
41
43
  stop_turn: ->(reason:, hook:) { stop_turn(reason, hook) },
42
44
  steer: ->(text:, hook:) { @steer.call(text, source(hook)) },
43
- stop_generation: ->(reason:, hook:) { stop_generation(reason, hook) }
45
+ stop_generation: ->(reason:, hook:) { stop_generation(reason, hook) },
46
+ turn_author: @turn_author
44
47
  )
45
48
  end
46
49