samagotchi 0.106.0 → 0.108.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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -1
  3. data/bin/chi +2 -0
  4. data/docs/architecture.md +6 -1
  5. data/docs/cli.md +45 -5
  6. data/docs/guardrails.md +8 -2
  7. data/docs/internals/engine.md +17 -6
  8. data/docs/internals/participants.md +129 -30
  9. data/docs/internals/remote-sessions.md +12 -5
  10. data/docs/internals/working-set.md +2 -1
  11. data/docs/memory.md +20 -0
  12. data/docs/plugins.md +38 -3
  13. data/docs/releasing.md +13 -6
  14. data/docs/sessions.md +39 -4
  15. data/docs/sub-agent.md +3 -1
  16. data/docs/testing.md +7 -1
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -1
  18. data/lib/samagotchi/bridge.rb +7 -2
  19. data/lib/samagotchi/bridge_client/stream_request.rb +10 -3
  20. data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -5
  21. data/lib/samagotchi/bundles/coordinator/plugin.rb +85 -5
  22. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +18 -0
  23. data/lib/samagotchi/bundles/cost-cap/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/cost-cap/plugin.rb +51 -9
  25. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  28. data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/work-cap/plugin.rb +50 -14
  30. data/lib/samagotchi/child_reports.rb +2 -1
  31. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  32. data/lib/samagotchi/context_sources.rb +1 -6
  33. data/lib/samagotchi/continue_offer.rb +56 -7
  34. data/lib/samagotchi/engine.rb +36 -25
  35. data/lib/samagotchi/guardrail_wiring.rb +9 -6
  36. data/lib/samagotchi/guardrails/review_boundary.rb +6 -57
  37. data/lib/samagotchi/guardrails/review_paths.rb +93 -0
  38. data/lib/samagotchi/idle_scheduler.rb +17 -2
  39. data/lib/samagotchi/last_turn.rb +7 -0
  40. data/lib/samagotchi/log_subscriber.rb +2 -1
  41. data/lib/samagotchi/memory_orphans.rb +74 -0
  42. data/lib/samagotchi/memory_paths.rb +4 -4
  43. data/lib/samagotchi/model_profile.rb +5 -3
  44. data/lib/samagotchi/parent_report.rb +34 -4
  45. data/lib/samagotchi/participant_messages.rb +11 -0
  46. data/lib/samagotchi/participants.rb +20 -6
  47. data/lib/samagotchi/plugin/api.rb +2 -1
  48. data/lib/samagotchi/plugin/sessions.rb +25 -0
  49. data/lib/samagotchi/remote_command.rb +1 -1
  50. data/lib/samagotchi/remotes.rb +23 -8
  51. data/lib/samagotchi/reply_wait.rb +7 -2
  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 +17 -2
  56. data/lib/samagotchi/send_command/busy.rb +5 -2
  57. data/lib/samagotchi/send_command/wait_target.rb +18 -8
  58. data/lib/samagotchi/send_command.rb +2 -0
  59. data/lib/samagotchi/session.rb +33 -5
  60. data/lib/samagotchi/session_access/contract.rb +10 -2
  61. data/lib/samagotchi/session_access/dispatch.rb +8 -2
  62. data/lib/samagotchi/session_access/http_transport.rb +10 -1
  63. data/lib/samagotchi/session_access/local.rb +19 -2
  64. data/lib/samagotchi/session_access/remote.rb +15 -1
  65. data/lib/samagotchi/session_access/web_bridge_client.rb +1 -1
  66. data/lib/samagotchi/session_access.rb +23 -10
  67. data/lib/samagotchi/session_chain.rb +10 -2
  68. data/lib/samagotchi/session_continue.rb +4 -3
  69. data/lib/samagotchi/session_inbox.rb +12 -8
  70. data/lib/samagotchi/session_manager.rb +30 -8
  71. data/lib/samagotchi/session_metrics.rb +7 -6
  72. data/lib/samagotchi/session_participants.rb +3 -1
  73. data/lib/samagotchi/session_review.rb +28 -0
  74. data/lib/samagotchi/share_command.rb +7 -1
  75. data/lib/samagotchi/steer.rb +11 -1
  76. data/lib/samagotchi/terminal_ui/attached_loop.rb +34 -27
  77. data/lib/samagotchi/terminal_ui/formatting.rb +2 -2
  78. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +5 -1
  79. data/lib/samagotchi/testing/sessions.rb +10 -2
  80. data/lib/samagotchi/tools/memory.rb +16 -9
  81. data/lib/samagotchi/turn_checkpoint.rb +13 -0
  82. data/lib/samagotchi/turn_origin.rb +68 -0
  83. data/lib/samagotchi/version.rb +1 -1
  84. data/lib/samagotchi/web/app.rb +108 -21
  85. data/lib/samagotchi/web/grants.rb +92 -17
  86. data/lib/samagotchi/web/participant_access.rb +56 -110
  87. data/lib/samagotchi/web/participant_review.rb +45 -2
  88. data/lib/samagotchi/web/participant_stream_filter.rb +118 -17
  89. data/lib/samagotchi/web/participant_view.rb +19 -14
  90. data/lib/samagotchi/web/participants_admin.rb +129 -0
  91. data/lib/samagotchi/web/public/app.js +60 -5
  92. data/lib/samagotchi/web/public/chip_popover.js +10 -3
  93. data/lib/samagotchi/web/public/data.js +10 -2
  94. data/lib/samagotchi/web/public/delivery_line.js +79 -15
  95. data/lib/samagotchi/web/public/index.html +26 -0
  96. data/lib/samagotchi/web/public/info_bar.js +7 -3
  97. data/lib/samagotchi/web/public/info_bar_model.js +7 -1
  98. data/lib/samagotchi/web/public/live_turn.js +3 -0
  99. data/lib/samagotchi/web/public/participant.html +58 -5
  100. data/lib/samagotchi/web/public/participant.js +172 -25
  101. data/lib/samagotchi/web/public/participant_model.js +89 -24
  102. data/lib/samagotchi/web/public/participant_names.js +37 -5
  103. data/lib/samagotchi/web/public/participants_panel.js +86 -18
  104. data/lib/samagotchi/web/public/question_cards.js +1 -0
  105. data/lib/samagotchi/web/public/review_apply.js +34 -0
  106. data/lib/samagotchi/web/public/review_start.js +101 -0
  107. data/lib/samagotchi/web/public/send_turn.js +25 -1
  108. data/lib/samagotchi/web/public/session_actions.js +44 -3
  109. data/lib/samagotchi/web/public/session_state.js +3 -0
  110. data/lib/samagotchi/web/public/start_page.js +38 -2
  111. data/lib/samagotchi/web/public/stream_handlers.js +10 -0
  112. data/lib/samagotchi/web/public/turn_html.js +2 -1
  113. data/lib/samagotchi/web/public/turn_view.js +5 -0
  114. data/lib/samagotchi/web/session_summary.rb +3 -0
  115. data/lib/samagotchi/worker.rb +44 -27
  116. data/lib/samagotchi/worker_inbound.rb +12 -9
  117. data/lib/samagotchi/working_set.rb +3 -2
  118. metadata +11 -1
@@ -63,6 +63,7 @@ class Plugin
63
63
  @resolved = nil # {session:, settings:, meters: {meter => {cap:, step:, key:}}, unreadable: {name => value}}
64
64
  @warned = {} # meter => [session id, cap] the share warning was given for
65
65
  @unread_warned = nil # the session id the "isn't a number" notice was given for
66
+ @corrupt_warned = nil # the session id the "state file doesn't read" notice was given for
66
67
  end
67
68
 
68
69
  def register(chi)
@@ -126,6 +127,7 @@ class Plugin
126
127
  def check(ctx)
127
128
  resolved = resolve(ctx)
128
129
  unreadable(ctx, resolved)
130
+ corrupt(ctx, resolved)
129
131
  capped = resolved[:meters].select { |_meter, meter_cap| meter_cap[:cap] }
130
132
  return if capped.empty?
131
133
 
@@ -161,11 +163,26 @@ class Plugin
161
163
  return if bad.empty? || !first_time?(:@unread_warned, resolved[:session])
162
164
 
163
165
  named = bad.map { |key, value| "#{key}: #{value.inspect}" }.join(" and ")
166
+ ctx.notify("work-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{using(resolved)}",
167
+ level: :warn)
168
+ end
169
+
170
+ # Once per session: a state file that is there but doesn't read (not
171
+ # JSON, or not a mapping) holds no /workcap caps, so the settings' are
172
+ # used; said rather than a /workcap cap silently gone.
173
+ def corrupt(ctx, resolved)
174
+ path = resolved[:corrupt]
175
+ return unless path && first_time?(:@corrupt_warned, resolved[:session])
176
+
177
+ ctx.notify("work-cap: #{path} doesn't read, its /workcap caps are ignored; #{using(resolved)}", level: :warn)
178
+ end
179
+
180
+ # "using tool_calls: (400), minutes: (60)", or "no cap".
181
+ def using(resolved)
164
182
  used = resolved[:meters].values.filter_map do |meter_cap|
165
183
  "#{meter_cap[:key]}: (#{count(meter_cap[:cap])})" if meter_cap[:cap]
166
184
  end
167
- using = used.empty? ? "no cap" : "using #{used.join(", ")}"
168
- ctx.notify("work-cap: #{named} #{bad.size == 1 ? "isn't" : "aren't"} a number or off; #{using}", level: :warn)
185
+ used.empty? ? "no cap" : "using #{used.join(", ")}"
169
186
  end
170
187
 
171
188
  def at_cap(ctx, resolved, reached, usage, used)
@@ -280,29 +297,37 @@ class Plugin
280
297
  cached = @mutex.synchronize { @resolved }
281
298
  return cached if cached && cached[:session] == session && cached[:settings] == settings
282
299
 
283
- saved = state(ctx, session)
300
+ saved, corrupt = state(ctx, session)
284
301
  configured = configured(ctx)
285
302
  meters = METERS.to_h { |meter| [meter, saved[meter] || configured[:meters][meter]] }
286
- resolved = { session: session, settings: settings, meters: meters, unreadable: configured[:unreadable] }
303
+ resolved = { session: session, settings: settings, meters: meters, unreadable: configured[:unreadable],
304
+ corrupt: corrupt }
287
305
  @mutex.synchronize { @resolved = resolved }
288
306
  end
289
307
 
290
- # The state file's meters ({meter => {cap:, step:}}), {} without one.
308
+ # The state file's meters ({meter => {cap:, step:}}), {} without one,
309
+ # and the file's path when it is there but doesn't read (logged; the
310
+ # user is told by #corrupt), else nil.
291
311
  def state(ctx, session)
292
- return {} unless session
312
+ return [{}, nil] unless session
293
313
 
294
- saved = JSON.parse(File.read(state_path(ctx, session)))
295
- return {} unless saved.is_a?(Hash)
314
+ path = state_path(ctx, session)
315
+ saved = JSON.parse(File.read(path))
316
+ raise JSON::ParserError, "not a JSON object" unless saved.is_a?(Hash)
296
317
 
297
- METERS.each_with_object({}) do |meter, meters|
318
+ meters = METERS.each_with_object({}) do |meter, found|
298
319
  entry = saved[meter]
299
320
  next unless entry.is_a?(Hash) && entry.key?("cap")
300
321
 
301
322
  cap = self.class.amount(entry["cap"], meter)
302
- meters[meter] = { cap: cap, step: self.class.amount(entry["step"], meter) || cap, key: "/workcap" }
323
+ found[meter] = { cap: cap, step: self.class.amount(entry["step"], meter) || cap, key: "/workcap" }
303
324
  end
304
- rescue Errno::ENOENT, JSON::ParserError
305
- {}
325
+ [meters, nil]
326
+ rescue Errno::ENOENT
327
+ [{}, nil]
328
+ rescue JSON::ParserError => e
329
+ ctx.log.warn(:workcap_state_unreadable, path: path, error: e.message.to_s[0, 200])
330
+ [{}, path]
306
331
  end
307
332
 
308
333
  # Per meter the first of its own value, child:'s (a delegate's) and
@@ -358,21 +383,32 @@ class Plugin
358
383
  end
359
384
 
360
385
  # Write one meter's cap into the session's state file (the other kept).
386
+ # One there that doesn't read (#state) is written over: logged and said,
387
+ # never a silent replace.
361
388
  def save_meter(ctx, session, meter, data)
362
389
  path = state_path(ctx, session)
363
390
  FileUtils.mkdir_p(File.dirname(path))
364
391
  saved = begin
365
392
  JSON.parse(File.read(path))
366
- rescue Errno::ENOENT, JSON::ParserError
393
+ rescue Errno::ENOENT
367
394
  {}
395
+ rescue JSON::ParserError
396
+ nil
368
397
  end
369
- saved = {} unless saved.is_a?(Hash)
398
+ replacing = !saved.is_a?(Hash)
399
+ saved = {} if replacing
370
400
  saved[meter] = data.transform_values { |value| whole(value) }
371
401
  Samagotchi::AtomicFile.write(path, "#{JSON.generate(saved)}\n")
402
+ replaced(ctx, path) if replacing
372
403
  # Resolved again at the next use, from the file now holding the meter.
373
404
  @mutex.synchronize { @resolved = nil }
374
405
  end
375
406
 
407
+ def replaced(ctx, path)
408
+ ctx.log.warn(:workcap_state_replaced, path: path)
409
+ ctx.notify("work-cap: #{path} didn't read; replaced with the new cap", level: :warn)
410
+ end
411
+
376
412
  # 800.0 as 800 (a whole cap is saved as an Integer), 2.5 as it is.
377
413
  def whole(value) = value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : value
378
414
 
@@ -7,6 +7,7 @@ require_relative "session"
7
7
  require_relative "session_inbox"
8
8
  require_relative "reply_wait"
9
9
  require_relative "steer"
10
+ require_relative "turn_origin"
10
11
  require_relative "log"
11
12
  require_relative "config"
12
13
  require_relative "tools/delegate_wait"
@@ -138,7 +139,7 @@ module Samagotchi
138
139
  def initialize(relay: nil, **) = super
139
140
 
140
141
  def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
141
- def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
142
+ def origin = TurnOrigin.new(client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}")
142
143
  end
143
144
 
144
145
  # @param predecessor [String, nil] the link this session continues
@@ -96,7 +96,7 @@ module Samagotchi
96
96
  @stdout.flush
97
97
  @reported = true
98
98
  end
99
- error_line("#{command_name}: still running: #{@access.invocation.shows(id)}")
99
+ error_line("#{command_name}: still running: #{ParentReport.still_running_hint(id, report_turn_id, via: @access.invocation)}")
100
100
  Exit::INTERRUPTED
101
101
  rescue ArgumentError
102
102
  error_line("#{command_name}: the session is gone (deleted while waiting)")
@@ -359,12 +359,7 @@ module Samagotchi
359
359
  def project_location_for(project_root, state_dir: nil)
360
360
  return nil if project_root.to_s.empty?
361
361
 
362
- project_location(project_key_of(project_root), state_dir: state_dir)
363
- end
364
-
365
- # MemoryPaths.project_key for a root already resolved.
366
- def project_key_of(root)
367
- "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
362
+ project_location(MemoryPaths.key_for_root(project_root), state_dir: state_dir)
368
363
  end
369
364
 
370
365
  # A session's sources: its own, then the project's it doesn't shadow
@@ -4,6 +4,7 @@ require "time"
4
4
  require_relative "last_turn"
5
5
  require_relative "log"
6
6
  require_relative "steer"
7
+ require_relative "turn_origin"
7
8
 
8
9
  module Samagotchi
9
10
  # A session worker's continue offer: what it announces when a turn runs out
@@ -35,10 +36,13 @@ module Samagotchi
35
36
  # @param max_iterations [#call] (no_interrupt) → Integer
36
37
  # @param queue_command [#call] (line, client_id:) queues a session
37
38
  # command as a card's (Bridge#queue_command, card: true)
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 })
39
+ # +participant_refused+ (author → reason or nil): why a participant's
40
+ # turn may no longer run ("revoked", "cap_reached": Worker's check).
41
+ # @param save [#call] saves the session (a refused Continue's Stop)
42
+ def initialize(engine:, turn_flow:, run_turn:, max_iterations:, queue_command: nil, participant_refused: ->(_) {},
43
+ save: -> {})
41
44
  @engine = engine
45
+ @save = save
42
46
  @turn_flow = turn_flow
43
47
  @run_turn = run_turn
44
48
  @max_iterations = max_iterations
@@ -68,6 +72,7 @@ module Samagotchi
68
72
 
69
73
  # A prompt (or a reminder turn) taken while a continue is offered
70
74
  # replaces the answer (D2): the offer goes, the partial turn stays.
75
+ # @param origin [TurnOrigin] the taken turn's
71
76
  def drop(origin)
72
77
  return unless awaiting?
73
78
 
@@ -75,7 +80,7 @@ module Samagotchi
75
80
  close("dropped")
76
81
  @engine.synchronize_events do
77
82
  @turn_flow.drop_offer!
78
- @engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin&.dig(:client_id))
83
+ @engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin.client_id)
79
84
  end
80
85
  @engine.record_activity
81
86
  end
@@ -91,7 +96,14 @@ module Samagotchi
91
96
  # it ends (chi answer --option Stop).
92
97
  # It carries the offer turn's enqueued_id, so a wait on that turn
93
98
  # (ReplyWait's turn:) ends on it too.
99
+ # A Continue on the offer of a participant who may no longer run a turn
100
+ # (revoked, expired, at the cap since) is decided here, before anything
101
+ # says "resume": it ends the offer as a Stop does (#refuse_continue).
94
102
  def announce_resolved(result, command)
103
+ if result.resume && (reason = refusal_of_offer_author)
104
+ return refuse_continue(reason, command)
105
+ end
106
+
95
107
  @engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command.client_id)
96
108
  return if result.resume
97
109
 
@@ -133,12 +145,18 @@ module Samagotchi
133
145
  # review boundary and on their spend (never as the owner's). One whose
134
146
  # participant may no longer run a turn isn't continued.
135
147
  def run_continue_turn(command)
148
+ # Refused as it was answered (#announce_resolved): the offer is gone.
149
+ return @continue_steer = nil unless awaiting?
150
+
136
151
  offer = @turn_flow.offer
137
152
  resume_from = @turn_flow.resume_from
138
153
  enqueued_id = offer_turn_id
139
- author = Steer.sender_author(@engine.session&.last_turn&.author)
154
+ author = @engine.session&.last_turn&.participant_author
140
155
  close("answered")
141
- return if author && @participant_refused.call(author)
156
+ if author && (reason = @participant_refused.call(author))
157
+ @continue_steer = nil
158
+ return @engine.synchronize_events { refuse_continue(reason, command) }
159
+ end
142
160
 
143
161
  steer = @continue_steer
144
162
  @continue_steer = nil
@@ -150,7 +168,7 @@ module Samagotchi
150
168
  Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
151
169
  return announce_offer(offer)
152
170
  end
153
- origin = { client_id: command.client_id, enqueued_id: enqueued_id, author: author }.compact
171
+ origin = TurnOrigin.new(client_id: command.client_id, enqueued_id: enqueued_id, author: author)
154
172
  @run_turn.call(nil, continue: true, origin: origin, resumed: true, resume_from: resume_from,
155
173
  max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
156
174
  if error
@@ -175,6 +193,37 @@ module Samagotchi
175
193
 
176
194
  private
177
195
 
196
+ def refusal_of_offer_author
197
+ author = @engine.session&.last_turn&.participant_author
198
+ author && @participant_refused.call(author)
199
+ end
200
+
201
+ # The offer of a participant refused since (+reason+: revoked,
202
+ # cap_reached) ends as a Stop: announced as "refused" (with a notice
203
+ # for the owner), the model reads it wasn't continued, last_turn says
204
+ # not_continued with the offer turn's enqueued_id (a wait on it ends),
205
+ # and awaiting_continue? goes false (idle exit, /exit, the held
206
+ # participant turns go on). The question goes with the next close.
207
+ # Called with the event log held.
208
+ def refuse_continue(reason, command)
209
+ author = @engine.session&.last_turn&.participant_author
210
+ enqueued_id = offer_turn_id
211
+ @engine.announce(type: :continue_resolved, decision: "refused", reason: reason.to_s, client_id: command.client_id)
212
+ @engine.announce(type: :hook_notice, hook: "participants", level: :warn, between_turns: true,
213
+ text: format(REFUSED_NOTICE, who: Steer.participant_id(author), reason: REFUSED_WHY.fetch(reason.to_s, reason.to_s)))
214
+ @turn_flow.abort_continue!
215
+ session = @engine.session
216
+ session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3), enqueued_id: enqueued_id) if session
217
+ @continue_steer = nil
218
+ close("answered")
219
+ @save.call
220
+ nil
221
+ end
222
+
223
+ REFUSED_NOTICE = "Not continued: participant %<who>s's turn %<reason>s."
224
+ REFUSED_WHY = { "revoked" => "is refused (their link was revoked or expired)",
225
+ "cap_reached" => "is refused (they reached their review cap)" }.freeze
226
+
178
227
  # The enqueued_id of the turn the offer stands for: the exhausted
179
228
  # turn's last_turn (a canceled continue turn's carries the same id);
180
229
  # nil when it ran no input (a reminder turn, a delegate's first).
@@ -13,6 +13,7 @@ require_relative "context_note"
13
13
  require_relative "context_status"
14
14
  require_relative "steer"
15
15
  require_relative "turn_note"
16
+ require_relative "turn_origin"
16
17
  require_relative "model_profile"
17
18
  require_relative "effective_model"
18
19
  require_relative "session_setting"
@@ -57,6 +58,7 @@ require_relative "hook_routes"
57
58
  require_relative "guardrails"
58
59
  require_relative "reminder_store"
59
60
  require_relative "reminder_queue"
61
+ require_relative "memory_orphans"
60
62
  require_relative "tools/memory"
61
63
  require_relative "muted_memories"
62
64
  require_relative "used_memories"
@@ -623,7 +625,7 @@ module Samagotchi
623
625
  def side_author(kind)
624
626
  return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
625
627
 
626
- Steer.sender_author(@guardrail_wiring.turn_author)
628
+ @guardrail_wiring.turn_author
627
629
  end
628
630
 
629
631
  RECAP_SIDE_KIND = "recap"
@@ -631,7 +633,7 @@ module Samagotchi
631
633
  # The running turn's author when a participant wrote it, else nil
632
634
  # (ctx.turn_author, the hooks' author).
633
635
  # @return [String, nil]
634
- def turn_author = turn_running? ? Steer.sender_author(@guardrail_wiring.turn_author) : nil
636
+ def turn_author = turn_running? ? @guardrail_wiring.turn_author : nil
635
637
 
636
638
  # @return [SessionMetrics] the per-session analytics collector
637
639
  attr_reader :metrics
@@ -984,7 +986,8 @@ module Samagotchi
984
986
 
985
987
  # Announce what failed to load and what plugins showed while loading,
986
988
  # once, as soon as the owner can show it (the worker's Bridge is up, the
987
- # REPL renders events): the guardrail and plugin warnings, then the
989
+ # REPL renders events): the guardrail and plugin warnings, an orphaned
990
+ # project memory folder (label: "memory", MemoryOrphans), then the
988
991
  # held notices (between_turns) and cards (in_turn: false, so late
989
992
  # joiners get them). Without this call the first turn announces them
990
993
  # (#announce_guardrail_failures).
@@ -997,6 +1000,8 @@ module Samagotchi
997
1000
  announce({ type: :guardrail_warning, message: message }) if message
998
1001
  plugins = @plugin_failures.message
999
1002
  announce({ type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
1003
+ memory = MemoryOrphans.message
1004
+ announce({ type: :guardrail_warning, message: memory, label: "memory" }) if memory
1000
1005
  @extension_load.held_events.each do |event|
1001
1006
  announce(event[:type] == :card ? event.merge(in_turn: false) : event.merge(between_turns: true))
1002
1007
  end
@@ -1448,8 +1453,8 @@ module Samagotchi
1448
1453
 
1449
1454
  # Once per Engine, on its first turn: what failed to load, so every UI
1450
1455
  # (REPL, attached TUI, web) shows it: the guardrails, then the plugins
1451
- # (label: "plugins"; the UIs say guardrails without one), then the
1452
- # notices and cards plugins showed as they loaded.
1456
+ # (label: "plugins"; the UIs say guardrails without one), an orphaned
1457
+ # project memory folder (label: "memory"), then the notices and cards plugins showed as they loaded.
1453
1458
  def announce_guardrail_failures(on_event)
1454
1459
  return if @guardrail_failures_announced
1455
1460
 
@@ -1458,6 +1463,8 @@ module Samagotchi
1458
1463
  emit_event(on_event, { type: :guardrail_warning, message: message }) if message
1459
1464
  plugins = @plugin_failures.message
1460
1465
  emit_event(on_event, { type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
1466
+ memory = MemoryOrphans.message
1467
+ emit_event(on_event, { type: :guardrail_warning, message: memory, label: "memory" }) if memory
1461
1468
  @extension_load.held_events.each { |event| emit_event(on_event, event) }
1462
1469
  end
1463
1470
  private :announce_guardrail_failures
@@ -1494,14 +1501,14 @@ module Samagotchi
1494
1501
  # @param limit [Integer, nil] the turn ran out of iterations at this
1495
1502
  # limit: "exhausted" => true, "limit" => N, so a wait with nobody to
1496
1503
  # answer the continue offer says so (ReplyWait, ParentReport "limit")
1504
+ # @param origin [TurnOrigin] the turn's (TurnOrigin::NONE for no sender)
1497
1505
  # @param event [Hash] the turn's end event (#stop_facts)
1498
1506
  def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
1499
- source = ClientId.origin(origin.is_a?(Hash) ? origin[:client_id] : nil)
1500
- enqueued_id = origin.is_a?(Hash) ? origin[:enqueued_id] : nil
1507
+ enqueued_id = origin.enqueued_id
1501
1508
  session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
1502
- origin: source, exhausted: limit ? true : nil, limit: limit,
1509
+ origin: origin.source, exhausted: limit ? true : nil, limit: limit,
1503
1510
  enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
1504
- author: Steer.sender_author(origin.is_a?(Hash) ? origin[:author] : nil),
1511
+ author: origin.participant_author,
1505
1512
  **stop_facts(event))
1506
1513
  end
1507
1514
 
@@ -1532,10 +1539,9 @@ module Samagotchi
1532
1539
  # author:, and author_name: from participants.json), {} for the owner.
1533
1540
  # The worker's drain names a participant's merged lines with it, and
1534
1541
  # its running save a participant's prompt.
1535
- # @param origin [Hash, String, nil] a turn's origin, or an author
1542
+ # @param author [String, nil] a turn's or a line's author (TurnOrigin#author)
1536
1543
  # @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
1544
+ def author_fields(author, session: @session)
1539
1545
  return {} unless Steer.participant_author?(author)
1540
1546
 
1541
1547
  Steer.author_fields(author, @participants.name_for(author, session: session))
@@ -1995,9 +2001,11 @@ module Samagotchi
1995
2001
  :settings, :id, :limit, :resumed, :resume_from) do
1996
2002
  def resumed? = resumed ? true : false
1997
2003
 
1998
- # The turn's boundary events carry the origin only when there is one,
1999
- # so payloads stay unchanged for callers that don't pass it.
2000
- def tag(event) = origin ? event.merge(origin: origin) : event
2004
+ # The turn's boundary events carry the origin only when it names a
2005
+ # sender (not TurnOrigin::NONE), so payloads stay unchanged for callers
2006
+ # that don't pass one; as its wire Hash (TurnOrigin#to_h), which the
2007
+ # Bridge and the UIs read.
2008
+ def tag(event) = origin.empty? ? event : event.merge(origin: origin.to_h)
2001
2009
 
2002
2010
  # Seconds since the turn started (the cancel note says how long it ran).
2003
2011
  def elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
@@ -2032,11 +2040,13 @@ module Samagotchi
2032
2040
  # run it resumes started (ModelResult#run_start, kept in the offer by
2033
2041
  # TurnFlow), so it keeps that run's reasoning; nil starts cold (a
2034
2042
  # model change while the offer stood, every other turn).
2035
- # @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:},
2036
- # and author: when someone other than the session's owner wrote it, from
2037
- # its input file; absent for the owner); when given, the turn's boundary
2038
- # events (:turn_started, :turn_completed, :turn_canceled, :turn_failed)
2039
- # carry it as `origin:`, and GuardrailWiring#begin_turn gets it
2043
+ # @param origin [TurnOrigin, Hash, nil] who queued the turn (client_id,
2044
+ # enqueued_id, and author when someone other than the session's owner
2045
+ # wrote it, from its input file; none for the owner; a Hash is read as
2046
+ # one, TurnOrigin.of; TurnOrigin::NONE, the default, names no sender
2047
+ # and so does nil or {}); when it names one, the turn's boundary events
2048
+ # (:turn_started, :turn_completed, :turn_canceled, :turn_failed) carry
2049
+ # it as `origin:` (its wire Hash), and GuardrailWiring#begin_turn gets it
2040
2050
  # @param images [Array<Hash>] the prompt's images: {path:} (a file on this
2041
2051
  # machine; in-process and attached-TUI callers only) or {file:, name:}
2042
2052
  # (already in the session's images/, e.g. a web upload). They are
@@ -2056,8 +2066,8 @@ module Samagotchi
2056
2066
  # other error (e.g. an LLM::ProviderError) emits :turn_failed and
2057
2067
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
2058
2068
  # one-line summary:.
2059
- def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
2060
- images: [], id: nil, resumed: false, resume_from: nil)
2069
+ def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
2070
+ origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil)
2061
2071
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
2062
2072
  continue: continue, id: id)
2063
2073
  if continue
@@ -2108,6 +2118,7 @@ module Samagotchi
2108
2118
  # the turn flag, its cancel controller and sink. The cross-thread state
2109
2119
  # is set here and cleared in #release_turn only.
2110
2120
  def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil)
2121
+ origin = TurnOrigin.of(origin)
2111
2122
  # Track the active session for recap and status snapshot.
2112
2123
  @session = session
2113
2124
  # status is turn state: running now, idle again before the turn's end
@@ -2151,7 +2162,7 @@ module Samagotchi
2151
2162
  turn_started[:cut_after] = steer_cut_after
2152
2163
  # A participant's turn names them (author, author_name) for the UIs'
2153
2164
  # badge; the owner's turn_started stays as it was.
2154
- turn_started.merge!(author_fields(turn.origin, session: session))
2165
+ turn_started.merge!(author_fields(turn.origin.author, session: session))
2155
2166
  emit_event(turn.on_event, turn.tag(turn_started))
2156
2167
  raise image_error if image_error
2157
2168
 
@@ -2305,7 +2316,7 @@ module Samagotchi
2305
2316
  # system prompt invalidates cache for the entire prefix.
2306
2317
  # Not into a participant's turn: the owner's reminders are the owner's,
2307
2318
  # 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)
2319
+ due_reminders = turn.origin.participant? ? [] : @reminder_queue.inject!(turn.messages)
2309
2320
  if due_reminders.any?
2310
2321
  emit_event(turn.on_event, {
2311
2322
  type: :reminder_injected,
@@ -2314,7 +2325,7 @@ module Samagotchi
2314
2325
  end
2315
2326
 
2316
2327
  unless turn.continue
2317
- user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin) }
2328
+ user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author) }
2318
2329
  user_message[:images] = image_refs unless image_refs.empty?
2319
2330
  turn.messages << user_message
2320
2331
  session.last_prompt = turn.prompt
@@ -11,6 +11,7 @@ require_relative "version"
11
11
  require_relative "hooks"
12
12
  require_relative "edit_preview"
13
13
  require_relative "tool_activity"
14
+ require_relative "turn_origin"
14
15
 
15
16
  module Samagotchi
16
17
  # An Engine's tool guardrails: who can approve (the interface), the YAML
@@ -55,6 +56,7 @@ module Samagotchi
55
56
  @failures = Guardrails::LoadFailures.new
56
57
  @rules_mutex = Mutex.new
57
58
  @git = Guardrails::GitInfo.new
59
+ @origin = TurnOrigin::NONE
58
60
  end
59
61
 
60
62
  # Who can answer an approval: :repl, :worker or :non_interactive (the
@@ -83,17 +85,18 @@ module Samagotchi
83
85
  @protected = nil
84
86
  end
85
87
 
86
- # A turn starts: its origin (Engine#run_turn's: {client_id:,
87
- # enqueued_id:, author:}, each optional), and the git facts read afresh.
88
+ # A turn starts: its origin (Engine#run_turn's TurnOrigin; nil or a
89
+ # Hash is read as one, TurnOrigin.of), and the git facts read afresh.
88
90
  def begin_turn(origin)
89
- @origin = origin
91
+ @origin = TurnOrigin.of(origin)
90
92
  @git = Guardrails::GitInfo.new
91
93
  end
92
94
 
93
95
  # Who wrote the running turn when it isn't the session's owner (its
94
- # origin's author); nil for the owner's turns and every other origin.
96
+ # origin's participant author); nil for the owner's turns and every
97
+ # other origin.
95
98
  # @return [String, nil]
96
- def turn_author = @origin.is_a?(Hash) ? @origin[:author] : nil
99
+ def turn_author = @origin.participant_author
97
100
 
98
101
  # The gate every tool call asks first.
99
102
  # @return [Guardrails::Gate]
@@ -229,7 +232,7 @@ module Samagotchi
229
232
  # @return [Guardrails::Context]
230
233
  def context
231
234
  Guardrails::Context.new(cwd: Dir.pwd, session_id: @session_lookup.call&.id, interface: interface,
232
- origin: @origin, git: @git)
235
+ origin: @origin.wire, git: @git)
233
236
  end
234
237
 
235
238
  # Ask the user to approve a call the gate voted `ask` on, through the
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "protected_paths"
4
- require_relative "shell_paths"
5
- require_relative "../paths"
3
+ require_relative "review_paths"
6
4
  require_relative "../toolkit"
7
5
  require_relative "../steer"
8
6
 
@@ -18,7 +16,7 @@ module Samagotchi
18
16
  # (Toolkit::REVIEW_TOOLS). Not forget_outputs: it would replace the
19
17
  # owner's earlier tool outputs with a note of the participant's, which
20
18
  # later owner turns read as their own output, with no review header.
21
- # read: only under the session's project root, and never a file of
19
+ # read: ReviewPaths: only under the session's project root, never a file of
22
20
  # the read denylist (chi's state and config dirs, home secret dirs,
23
21
  # project secret files), checked on the path as given and as it
24
22
  # resolves (a symlink in the project to ~/.ssh is ~/.ssh);
@@ -35,19 +33,6 @@ module Samagotchi
35
33
  ALLOWED = Toolkit::REVIEW_TOOLS
36
34
  PROJECT_SCOPE = "project"
37
35
 
38
- # Home folders that hold keys and tokens, never read in a review turn.
39
- HOME_SECRET_DIRS = %w[.ssh .gnupg .aws].freeze
40
- # Project files that hold secrets, by a path part's name: .env*,
41
- # netrc, keys and certificates, anything named credentials, package
42
- # registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
43
- # secrets file; and a git config (.git/config, a submodule's
44
- # .git/modules/x/config), checked apart.
45
- SECRET_FILE = /\A(?:\.env.*|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
46
- \.npmrc|\.pypirc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|secrets?\.(?:ya?ml|json))\z/xi
47
- # The .docker/config.json kind: a dot dir's config holding tokens.
48
- SECRET_DIR_FILES = { ".docker" => "config.json", ".kube" => "config" }.freeze
49
- GIT_DIR = ".git"
50
-
51
36
  ADVICE = "This is a participant's review turn: answer from what you can read and propose any change as " \
52
37
  "text or a diff for the owner to apply."
53
38
  NOT_IN_KIT = "a participant's review turn may only read (read, memory_read): %s is not allowed"
@@ -55,6 +40,7 @@ module Samagotchi
55
40
  SECRET = "a participant's review turn can't read secrets or chi's own files"
56
41
  HOME_ROOT = "a participant's review turn reads only inside a project, and this session's folder is the " \
57
42
  "home folder or above it"
43
+ READ_REFUSALS = { ReviewPaths::OUTSIDE => OUTSIDE, ReviewPaths::SECRET => SECRET, ReviewPaths::HOME_ROOT => HOME_ROOT }.freeze
58
44
  MEMORY_SCOPE = "a participant's review turn reads project memories only: call memory_read with scope: project"
59
45
 
60
46
  # @param author [#call] → String, nil: the running turn's author
@@ -66,16 +52,13 @@ module Samagotchi
66
52
  # config: sessions, grants, the web token, memories and .bundles,
67
53
  # config.yml, hooks)
68
54
  # @param home [#call] → String, nil
69
- def initialize(author:, root:, denied_dirs: -> { ReviewBoundary.chi_dirs }, home: -> { Dir.home })
55
+ def initialize(author:, root:, denied_dirs: -> { ReviewPaths.chi_dirs }, home: -> { Dir.home })
70
56
  @author = author
71
57
  @root = root
72
58
  @denied_dirs = denied_dirs
73
59
  @home = home
74
60
  end
75
61
 
76
- # chi's state dir and config dir, as Paths has them now.
77
- def self.chi_dirs = [Paths.state_dir, File.join(Paths.config_home, Paths::APP_DIR)]
78
-
79
62
  # Vote on +verdict+ (its call and targets).
80
63
  def check(verdict)
81
64
  return verdict unless Steer.participant_author?(@author.call)
@@ -106,42 +89,8 @@ module Samagotchi
106
89
  end
107
90
 
108
91
  def read_refusal(paths)
109
- return OUTSIDE if paths.empty?
110
-
111
- root = @root.call.to_s
112
- return OUTSIDE if root.empty?
113
-
114
- real_root = ProtectedPaths.real(root)
115
- home = @home.call.to_s
116
- return HOME_ROOT if !home.empty? && ShellPaths.within?(ProtectedPaths.real(home), real_root)
117
-
118
- paths.each do |path|
119
- given = File.expand_path(path)
120
- real = ProtectedPaths.real(given)
121
- return OUTSIDE unless ShellPaths.within?(real, real_root)
122
- return SECRET if secret?(given, home) || secret?(real, home)
123
- end
124
- nil
125
- end
126
-
127
- def secret?(path, home)
128
- dirs = @denied_dirs.call.compact.map { |dir| File.expand_path(dir) }
129
- dirs += dirs.map { |dir| ProtectedPaths.real(dir) }
130
- dirs += HOME_SECRET_DIRS.map { |dir| File.join(home, dir) } unless home.empty?
131
- return true if dirs.any? { |dir| ShellPaths.within?(path, dir) }
132
-
133
- parts = path.split(File::SEPARATOR)
134
- return true if parts.any? { |part| SECRET_FILE.match?(part) }
135
- return true if parts.each_cons(2).any? { |dir, file| SECRET_DIR_FILES[dir.downcase] == file.downcase }
136
-
137
- git_config?(parts)
138
- end
139
-
140
- # A config file anywhere under a .git dir (.git/config,
141
- # .git/modules/<sub>/config, a worktree's config.worktree).
142
- def git_config?(parts)
143
- git = parts.index { |part| part.casecmp?(GIT_DIR) } or return false
144
- parts[(git + 1)..].any? { |part| part.downcase.start_with?("config") }
92
+ paths_check = ReviewPaths.new(root: @root.call, denied_dirs: @denied_dirs.call, home: @home.call)
93
+ READ_REFUSALS[paths_check.refusal(paths)]
145
94
  end
146
95
  end
147
96
  end