samagotchi 0.107.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/internals/engine.md +17 -6
  5. data/docs/internals/participants.md +5 -2
  6. data/docs/internals/working-set.md +2 -1
  7. data/docs/memory.md +20 -0
  8. data/docs/plugins.md +9 -2
  9. data/docs/releasing.md +11 -6
  10. data/docs/sessions.md +3 -3
  11. data/docs/sub-agent.md +1 -1
  12. data/docs/testing.md +5 -1
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -2
  14. data/lib/samagotchi/bridge.rb +3 -2
  15. data/lib/samagotchi/bundles/cost-cap/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/cost-cap/plugin.rb +51 -9
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  19. data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/work-cap/plugin.rb +50 -14
  21. data/lib/samagotchi/child_reports.rb +2 -1
  22. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  23. data/lib/samagotchi/context_sources.rb +1 -6
  24. data/lib/samagotchi/continue_offer.rb +8 -6
  25. data/lib/samagotchi/engine.rb +36 -25
  26. data/lib/samagotchi/guardrail_wiring.rb +9 -6
  27. data/lib/samagotchi/idle_scheduler.rb +17 -2
  28. data/lib/samagotchi/last_turn.rb +7 -0
  29. data/lib/samagotchi/log_subscriber.rb +2 -1
  30. data/lib/samagotchi/memory_orphans.rb +74 -0
  31. data/lib/samagotchi/memory_paths.rb +4 -4
  32. data/lib/samagotchi/parent_report.rb +11 -3
  33. data/lib/samagotchi/reply_wait.rb +7 -2
  34. data/lib/samagotchi/send_command/busy.rb +5 -2
  35. data/lib/samagotchi/send_command/wait_target.rb +16 -11
  36. data/lib/samagotchi/session_inbox.rb +12 -8
  37. data/lib/samagotchi/session_metrics.rb +7 -6
  38. data/lib/samagotchi/session_participants.rb +3 -1
  39. data/lib/samagotchi/steer.rb +11 -1
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -26
  41. data/lib/samagotchi/terminal_ui/formatting.rb +2 -2
  42. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +5 -1
  43. data/lib/samagotchi/tools/memory.rb +16 -9
  44. data/lib/samagotchi/turn_origin.rb +68 -0
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +2 -0
  47. data/lib/samagotchi/web/grants.rb +2 -1
  48. data/lib/samagotchi/web/participant_access.rb +6 -116
  49. data/lib/samagotchi/web/participant_view.rb +7 -13
  50. data/lib/samagotchi/web/participants_admin.rb +129 -0
  51. data/lib/samagotchi/web/public/participant.js +2 -2
  52. data/lib/samagotchi/web/public/participant_model.js +5 -0
  53. data/lib/samagotchi/web/public/participant_names.js +2 -2
  54. data/lib/samagotchi/web/public/participants_panel.js +2 -2
  55. data/lib/samagotchi/worker.rb +30 -25
  56. data/lib/samagotchi/worker_inbound.rb +12 -9
  57. data/lib/samagotchi/working_set.rb +3 -2
  58. metadata +4 -1
@@ -221,10 +221,15 @@ module Samagotchi
221
221
  # merged): that turn runs (its mark has another id, or none from a
222
222
  # worker that writes ids), or, idle, a turn not its ended since the
223
223
  # baseline. last_turn or a reply file being its own is never that, nor
224
- # an input with a tombstone (a removal's, written before its claim).
224
+ # an input with a tombstone (a removal's, written before its claim),
225
+ # nor one whose claimed file is still there: a drain deletes the file
226
+ # it merges as it reads it, while a turn of its own keeps it until the
227
+ # turn ends (WorkerInbound#take_input), so a look between that claim
228
+ # and the turn's running save (idle, an earlier turn's end since the
229
+ # baseline) doesn't take the earlier turn's reply for it.
225
230
  def merged_elsewhere?(session, turn, baseline, session_dir)
226
231
  return false if session.last_turn&.enqueued_id == turn || SessionInbox.reply_for(session_dir, turn)
227
- return false if SessionInbox.removed?(session_dir, turn)
232
+ return false if SessionInbox.removed?(session_dir, turn) || SessionInbox.claimed_input?(session_dir, turn)
228
233
 
229
234
  if session.status == Session::STATUS_RUNNING
230
235
  running_id = session.interrupted_turn&.enqueued_id
@@ -40,9 +40,12 @@ module Samagotchi
40
40
  # What the user is told, after the session's short id.
41
41
  # @param via [ChiInvocation] how its commands are spelled
42
42
  def message(via: ChiInvocation::HERE)
43
- return "busy: a turn is running; wait or #{via.remote? ? "open it in #{via.web}" : "attach"}" unless error == "waiting"
44
-
45
43
  id = session_id
44
+ unless error == "waiting"
45
+ return "busy: a turn is running; --queue runs the message after it, " \
46
+ "#{via.command("send --wait #{id}")} waits for it, #{via.attach(id)} shows it"
47
+ end
48
+
46
49
  # The whole question (its options, its tags) for what a parent may
47
50
  # give.
48
51
  q = ParentReport.question(question, strategy: strategy)
@@ -13,30 +13,35 @@ module Samagotchi
13
13
  WaitTarget = Data.define(:turn, :report_turn_id, :queued, :ended_before) do
14
14
  def initialize(turn: nil, report_turn_id: nil, queued: false, ended_before: false) = super
15
15
 
16
- # A --new session's first message. A file-delivered one's own turn
17
- # (its id also names the outcome of a session command that runs no
18
- # turn); through the Bridge, the next reply.
16
+ # A --new session's first message: its own turn, the ack's id (a
17
+ # file-delivered one's also names the outcome of a session command
18
+ # that runs no turn), so a worker that dies in it and the next one's
19
+ # settle end the wait (TurnCheckpoint.settled_turn). A message merged
20
+ # into a turn starting then falls back to that turn's reply
21
+ # (ReplyWait's turn: without queued:); no id (an older worker), the
22
+ # next reply.
19
23
  # @param delivered [SessionManager::DeliverResult, nil] nil when the
20
24
  # message started the session itself (no delivery)
21
25
  def self.first_message(delivered)
22
26
  return new unless delivered
23
27
 
24
- new(turn: delivered.file? ? delivered.enqueued_id : nil, report_turn_id: delivered.enqueued_id)
28
+ new(turn: delivered.enqueued_id, report_turn_id: delivered.enqueued_id)
25
29
  end
26
30
 
27
31
  # A message to an existing session. --queue: its own turn, the ack's
28
32
  # id, also when the turn running at the check ended before it went in
29
33
  # (an idle worker's ack says next_step, and the file still runs as
30
- # that turn). A file fallback's id names its turn too. Otherwise the
31
- # next reply, reported as the turn that was running when it went in
32
- # (+running_turn+, ReplyWait.own_turn), or as its own on an idle one.
33
- # A plain message the worker queued (its ack says queue: a
34
- # participant's review turn was running, which takes no steer) is a
35
- # --queue's.
34
+ # that turn). A file fallback's id names its turn too, and so does a
35
+ # plain message to an idle session, as #first_message's. Otherwise
36
+ # (a session left running by a dead worker, the busy check's owner
37
+ # gone) the next reply, reported as the turn that was running when it
38
+ # went in (+running_turn+, ReplyWait.own_turn). A plain message the
39
+ # worker queued (its ack says queue: a participant's review turn was
40
+ # running, which takes no steer) is a --queue's.
36
41
  # @param running [Boolean] the session was running when it went in
37
42
  def self.message(delivered, queue:, running:, running_turn:)
38
43
  queue ||= delivered.delivery == Delivery::QUEUE
39
- turn = delivered.enqueued_id if queue || delivered.file?
44
+ turn = delivered.enqueued_id if queue || delivered.file? || !running
40
45
  report = if queue then turn
41
46
  elsif running then running_turn
42
47
  else delivered.enqueued_id
@@ -9,6 +9,7 @@ require_relative "delivery"
9
9
  require_relative "log"
10
10
  require_relative "session"
11
11
  require_relative "steer"
12
+ require_relative "turn_origin"
12
13
 
13
14
  module Samagotchi
14
15
  # A session directory's file inbox and outbox (see SessionManager for the
@@ -221,13 +222,16 @@ module Samagotchi
221
222
  Dir.glob(File.join(input_dir, "*.json"))
222
223
  end
223
224
 
224
- # A claimed input file as read: its +prompt+, +origin+ ({client_id:,
225
- # enqueued_id:}, nil when it names no sender), whether its turn runs
226
- # with the raised iteration limit (+no_interrupt+, --no-interrupt), its
227
- # +images+ ({file:, name:} refs), and its +delivery+ ("queue" or nil; a
228
- # file without the key is one an older chi wrote, so nil).
225
+ # A claimed input file as read: its +prompt+, +origin+ (a TurnOrigin
226
+ # from the file's client_id, enqueued_id and author; nil when it names
227
+ # no sender), whether its turn runs with the raised iteration limit
228
+ # (+no_interrupt+, --no-interrupt), its +images+ ({file:, name:} refs),
229
+ # and its +delivery+ ("queue" or nil; a file without the key is one an
230
+ # older chi wrote, so nil).
229
231
  Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery) do
230
- def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil) = super
232
+ def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil)
233
+ super(prompt:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:)
234
+ end
231
235
  end
232
236
  # A file that doesn't parse, or isn't a JSON object: no prompt.
233
237
  UNREADABLE_INPUT = Input.new(prompt: nil)
@@ -239,9 +243,9 @@ module Samagotchi
239
243
  return UNREADABLE_INPUT unless data.is_a?(Hash)
240
244
 
241
245
  # author: only a file that names one (an owner's names none).
242
- origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"], author: data["author"] }.compact
246
+ origin = TurnOrigin.from(data.slice("client_id", "enqueued_id", "author"))
243
247
  images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
244
- Input.new(prompt: data["prompt"].to_s, origin: origin.empty? ? nil : origin, no_interrupt: data["no_interrupt"] == true,
248
+ Input.new(prompt: data["prompt"].to_s, origin: origin, no_interrupt: data["no_interrupt"] == true,
245
249
  images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil)
246
250
  rescue JSON::ParserError
247
251
  UNREADABLE_INPUT
@@ -19,6 +19,7 @@ require_relative "session_metrics/usage"
19
19
  require_relative "session_metrics/side_totals"
20
20
  require_relative "side_usage"
21
21
  require_relative "steer"
22
+ require_relative "turn_origin"
22
23
 
23
24
  module Samagotchi
24
25
  # SessionMetrics is a persistent, error-isolated collector of per-session
@@ -727,6 +728,7 @@ module Samagotchi
727
728
  load_persisted
728
729
  end
729
730
  @started_at ||= now.iso8601(3)
731
+ origin = TurnOrigin.from(event[:origin])
730
732
  @turn = TurnState.new(
731
733
  session_id: @session_id,
732
734
  iteration_count: 0,
@@ -766,8 +768,8 @@ module Samagotchi
766
768
  gen_switch: false,
767
769
  continue: event[:continue] ? true : false,
768
770
  resumed: event[:resumed] ? true : false,
769
- author: Steer.sender_author(event[:origin].is_a?(Hash) ? event[:origin][:author] : nil),
770
- automatic: automatic_origin?(event[:origin]),
771
+ author: origin&.participant_author,
772
+ automatic: automatic_origin?(origin),
771
773
  id: event[:turn_id] || SecureRandom.uuid,
772
774
  started_at: now.iso8601(3),
773
775
  started_monotonic: monotonic_time,
@@ -818,11 +820,10 @@ module Samagotchi
818
820
  end
819
821
  end
820
822
 
821
- # Whether a turn's +origin+ names a client that is no person's: false
822
- # without one (an in-process turn is the owner's).
823
+ # Whether a turn's +origin+ (a TurnOrigin) names a client that is no
824
+ # person's: false without one (an in-process turn is the owner's).
823
825
  def automatic_origin?(origin)
824
- client_id = origin.is_a?(Hash) ? origin[:client_id] : nil
825
- !client_id.nil? && !ClientId.human?(client_id)
826
+ !origin&.client_id.nil? && !origin.human?
826
827
  end
827
828
 
828
829
  # The running turn's record so far, as end_turn would make it for a
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "steer"
4
+
3
5
  module Samagotchi
4
6
  # Loaded only when a session is read: it brings Web::Grants (and Rack).
5
7
  autoload :Participants, File.expand_path("participants", __dir__)
@@ -38,7 +40,7 @@ module Samagotchi
38
40
  return nil unless Steer.participant_author?(author)
39
41
 
40
42
  refresh!(session) if session && session.id != @session_id
41
- id = author.to_s.delete_prefix("p:")
43
+ id = Steer.participant_id(author)
42
44
  entry = @entries.find { |e| e.id == id } || (@entries = read).find { |e| e.id == id }
43
45
  entry&.name
44
46
  end
@@ -128,6 +128,9 @@ module Samagotchi
128
128
  PARTICIPANT_SOURCE = "participant"
129
129
  # The owner's author, when one is written out; absent means the same.
130
130
  OWNER_AUTHOR = "owner"
131
+ # A participant's author: this prefix, then its id (Web::Grants'
132
+ # grant id), "p:<id>" (#author_for, #participant_id).
133
+ PARTICIPANT_PREFIX = "p:"
131
134
  # A participant's message's keys, which every message copy keeps.
132
135
  AUTHOR_KEYS = %i[author author_name].freeze
133
136
 
@@ -187,7 +190,7 @@ module Samagotchi
187
190
  # again (ParticipantName), or the participant's id when it has none.
188
191
  def review_header(message)
189
192
  name = ParticipantName.clean(message[:author_name] || message["author_name"])
190
- name = ParticipantName.clean((message[:author] || message["author"]).to_s.delete_prefix("p:")) if name.empty?
193
+ name = ParticipantName.clean(participant_id(message[:author] || message["author"])) if name.empty?
191
194
  format(REVIEW_HEADER, name.empty? ? "a participant" : name)
192
195
  end
193
196
 
@@ -219,6 +222,13 @@ module Samagotchi
219
222
  # +author+ when it is a participant's, else nil (the owner).
220
223
  def sender_author(author) = participant_author?(author) ? author.to_s : nil
221
224
 
225
+ # The author a participant's turns and messages carry: "p:<id>".
226
+ def author_for(participant_id) = "#{PARTICIPANT_PREFIX}#{participant_id}"
227
+
228
+ # The participant id in +author+ ("p:<id>" → "<id>"); a string without
229
+ # the prefix as it is ("" for nil).
230
+ def participant_id(author) = author.to_s.delete_prefix(PARTICIPANT_PREFIX)
231
+
222
232
  # The text a steer or merged input goes out as: its header, a newline,
223
233
  # then +text+ (the message's content by default). Other messages: +text+.
224
234
  def wire_text(message, text = (message[:content] || message["content"]).to_s)
@@ -22,6 +22,7 @@ require_relative "../delivery"
22
22
  require_relative "../log"
23
23
  require_relative "../context_note"
24
24
  require_relative "../steer"
25
+ require_relative "../turn_origin"
25
26
  require_relative "../model_profile"
26
27
  require_relative "../output_formatter"
27
28
  require_relative "../session_commands"
@@ -317,7 +318,7 @@ module Samagotchi
317
318
  when :input_merged
318
319
  merged_own_prompts(event)
319
320
  # A delegate's report: a child's state changed.
320
- refresh_children if Array(event[:origins]).any? { |origin| report_origin?(origin) }
321
+ refresh_children if Array(event[:origins]).any? { |origin| report_origin?(origin_of(origin)) }
321
322
  when :question_requested
322
323
  # Nothing is left to answer it with.
323
324
  return unanswered_question(event[:pending_question]) if @input_ended
@@ -973,7 +974,7 @@ module Samagotchi
973
974
  def own_turn_ended(event)
974
975
  return unless @wait_at_eof
975
976
 
976
- id = (event[:origin] || {})[:enqueued_id]
977
+ id = origin_of(event[:origin]).enqueued_id
977
978
  ours = @open_ids.include?(id) || !@merged_ids.empty?
978
979
  @own_failed = true if ours && event[:type] == :turn_failed
979
980
  @own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
@@ -986,9 +987,10 @@ module Samagotchi
986
987
 
987
988
  # Ours merged into the running turn: they end with it.
988
989
  def merged_own_prompts(event)
989
- Array(event[:origins]).each do |origin|
990
- id = origin[:enqueued_id]
991
- @merged_ids << id if own?(origin[:client_id]) && @open_ids.include?(id)
990
+ Array(event[:origins]).each do |value|
991
+ origin = origin_of(value)
992
+ id = origin.enqueued_id
993
+ @merged_ids << id if own?(origin.client_id) && @open_ids.include?(id)
992
994
  end
993
995
  nil
994
996
  end
@@ -1188,8 +1190,8 @@ module Samagotchi
1188
1190
  # The worker rolled a failed turn back and handed its prompt back: ours
1189
1191
  # goes back into the input, as the REPL restores it for a retry.
1190
1192
  def restore_prompt(event)
1191
- origin = event[:origin] || {}
1192
- return unless own?(origin[:client_id]) && @sent_ids.include?(origin[:enqueued_id])
1193
+ origin = origin_of(event[:origin])
1194
+ return unless own?(origin.client_id) && @sent_ids.include?(origin.enqueued_id)
1193
1195
  # Lines from a pipe: nobody to retry it (the REPL's rule).
1194
1196
  return if @wait_at_eof
1195
1197
 
@@ -1342,7 +1344,7 @@ module Samagotchi
1342
1344
  next if own?(entry[:client_id])
1343
1345
 
1344
1346
  @shown_enqueued << entry[:enqueued_id]
1345
- @screen.commit("queued #{prompt_line(entry[:client_id], entry[:prompt], **author_of(entry))}")
1347
+ @screen.commit("queued #{prompt_line(entry[:client_id], entry[:prompt], **author_of(entry[:author]))}")
1346
1348
  end
1347
1349
  # Commands waiting for the turn's end (their command_ran prints no
1348
1350
  # line); a resync prints only the ones it hadn't shown.
@@ -1606,9 +1608,9 @@ module Samagotchi
1606
1608
  return unless turn
1607
1609
 
1608
1610
  continues = turn[:prompt].nil?
1609
- unless continues && reminder_origin?(turn[:origin] || {})
1610
- origin = turn[:origin] || {}
1611
- @screen.commit(prompt_line(origin[:client_id], turn[:prompt] || continuing_text(origin), **author_of(origin)))
1611
+ origin = origin_of(turn[:origin])
1612
+ unless continues && reminder_origin?(origin)
1613
+ @screen.commit(prompt_line(origin.client_id, turn[:prompt] || continuing_text(origin), **author_of(origin.author)))
1612
1614
  end
1613
1615
  tail = nil
1614
1616
  lane = :writing
@@ -1657,7 +1659,7 @@ module Samagotchi
1657
1659
  return if own?(event[:client_id])
1658
1660
 
1659
1661
  @shown_enqueued << event[:enqueued_id]
1660
- line = prompt_line(event[:client_id], event[:prompt], **author_of(event))
1662
+ line = prompt_line(event[:client_id], event[:prompt], **author_of(event[:author]))
1661
1663
  @screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
1662
1664
  end
1663
1665
 
@@ -1684,18 +1686,18 @@ module Samagotchi
1684
1686
  def start_turn(event)
1685
1687
  @running = true
1686
1688
  @joined_mid_turn = false
1687
- origin = event[:origin] || {}
1688
- @my_queued.delete(origin[:enqueued_id])
1689
+ origin = origin_of(event[:origin])
1690
+ @my_queued.delete(origin.enqueued_id)
1689
1691
  if event[:prompt].nil?
1690
1692
  # A continue turn (after the offer's yes) has no prompt to show; a
1691
1693
  # reminder turn shows its reminders (:reminder_injected).
1692
1694
  # A turn run for delegate reports shows them as they merge; one
1693
1695
  # an attached context change started, its label (the note is above).
1694
1696
  unless reminder_origin?(origin) || report_origin?(origin)
1695
- @screen.commit(prompt_line(origin[:client_id], continuing_text(origin), **author_of(origin)))
1697
+ @screen.commit(prompt_line(origin.client_id, continuing_text(origin), **author_of(origin.author)))
1696
1698
  end
1697
- elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
1698
- @screen.commit(prompt_line(origin[:client_id], event[:prompt], **author_of(origin)))
1699
+ elsif !(own?(origin.client_id) || @shown_enqueued.include?(origin.enqueued_id))
1700
+ @screen.commit(prompt_line(origin.client_id, event[:prompt], **author_of(origin.author)))
1699
1701
  end
1700
1702
  # The own-send echo already names an image-only prompt; another
1701
1703
  # client's prompt line does too, here or at its enqueue.
@@ -1755,14 +1757,14 @@ module Samagotchi
1755
1757
  end
1756
1758
 
1757
1759
  def learn_participants(rows)
1758
- Array(rows).each { |row| @participant_names["p:#{row[:id]}"] = row[:name] if row.is_a?(Hash) && row[:id] }
1760
+ Array(rows).each { |row| @participant_names[Steer.author_for(row[:id])] = row[:name] if row.is_a?(Hash) && row[:id] }
1759
1761
  end
1760
1762
 
1761
- # The prompt line's author keywords for an event, an origin or a
1762
- # queued row ({author: "p:<id>"}): their name as the worker named
1763
+ # The prompt line's author keywords for the +author+ of an event, an
1764
+ # origin or a queued row ("p:<id>"): their name as the worker named
1763
1765
  # them; {} for the owner's.
1764
- def author_of(row)
1765
- author = Steer.sender_author(row[:author]) or return {}
1766
+ def author_of(author)
1767
+ author = Steer.sender_author(author) or return {}
1766
1768
 
1767
1769
  { author: author, author_name: @participant_names[author] }
1768
1770
  end
@@ -1784,10 +1786,12 @@ module Samagotchi
1784
1786
  @default_model_name ||= @link.default_model_name
1785
1787
  end
1786
1788
 
1787
- def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
1788
- def report_origin?(origin) = ClientId.origin(origin[:client_id]) == "delegate_report"
1789
- def context_origin?(origin) = ClientId.origin(origin[:client_id]) == "context"
1790
- def task_origin?(origin) = ClientId.origin(origin[:client_id]) == "task"
1789
+ # An event's origin (its wire Hash) as a TurnOrigin, NONE for none.
1790
+ def origin_of(value) = TurnOrigin.of(value)
1791
+ def reminder_origin?(origin) = origin.client_id.to_s.start_with?(ClientId::SYSTEM_PREFIX)
1792
+ def report_origin?(origin) = origin.source == "delegate_report"
1793
+ def context_origin?(origin) = origin.source == "context"
1794
+ def task_origin?(origin) = origin.source == "task"
1791
1795
  # A turn with no prompt: "(continuing)"; a context wake's or a task
1792
1796
  # finish wake's label alone.
1793
1797
  def continuing_text(origin) = context_origin?(origin) || task_origin?(origin) ? nil : "(continuing)"
@@ -251,7 +251,7 @@ module Samagotchi
251
251
  def participant_prefix(author, name)
252
252
  return "" unless Steer.participant_author?(author)
253
253
 
254
- id = author.to_s.delete_prefix("p:")
254
+ id = Steer.participant_id(author)
255
255
  "#{paint("#{participant_display_name(id, name)} ▸", participant_colour(id))} "
256
256
  end
257
257
 
@@ -632,7 +632,7 @@ module Samagotchi
632
632
  return nil unless rows.is_a?(Hash) && !rows.empty?
633
633
 
634
634
  rows.map do |author, row|
635
- name = row[:name].to_s.empty? ? author.to_s.delete_prefix("p:") : row[:name]
635
+ name = row[:name].to_s.empty? ? Steer.participant_id(author) : row[:name]
636
636
  turns = row[:turns].to_i
637
637
  format("%<name>s $%<cost>.2f (%<turns>d turn%<s>s)", name: name, cost: row[:cost].to_f, turns: turns,
638
638
  s: turns == 1 ? "" : "s")
@@ -15,7 +15,10 @@ module Samagotchi
15
15
  # a failed request (:generation_retrying). And the hooks' notices
16
16
  # (:hook_notice): one during the turn as it comes, one the after_turn
17
17
  # hooks give (after :turn_completed; source-links' `sources:`) kept for
18
- # #flush, which the caller runs after it has printed the answer.
18
+ # #flush, which the caller runs after it has printed the answer. And
19
+ # the load warnings the first turn announces (:guardrail_warning: the
20
+ # guardrails, the plugins, an orphaned project memory folder), as the
21
+ # REPL's "<label>> …" lines.
19
22
  class OneShotSink
20
23
  include Formatting
21
24
 
@@ -33,6 +36,7 @@ module Samagotchi
33
36
  when :llm_context_edited then line(format_llm_context_line(event))
34
37
  when :generation_retrying then line(format_generation_retry_line(event))
35
38
  when :turn_completed, :turn_canceled then @turn_over = true
39
+ when :guardrail_warning then line(EventRenderer.load_warning_line(event))
36
40
  when :hook_notice
37
41
  text = EventRenderer.hook_notice_line(event)
38
42
  @turn_over ? @held << text : line(text)
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "digest"
5
5
  require "date"
6
6
  require_relative "../atomic_file"
7
+ require_relative "../memory_orphans"
7
8
  require_relative "../memory_paths"
8
9
  require_relative "../model_match"
9
10
 
@@ -30,9 +31,9 @@ module Samagotchi
30
31
  scope = normalize_scope(scope)
31
32
 
32
33
  if entry_name.empty?
33
- return scoped_index(scope) if scope
34
+ return scoped_index(scope) + (scope == "project" ? orphan_hint : "") if scope
34
35
 
35
- project = scoped_index("project")
36
+ project = scoped_index("project") + orphan_hint
36
37
  system = scoped_index("system")
37
38
  return [
38
39
  "Project memories:",
@@ -79,19 +80,25 @@ module Samagotchi
79
80
  missing << name unless found
80
81
  end
81
82
 
82
- if results.empty? && !missing.empty?
83
- "Error: memory not found: #{missing.join(", ")}"
84
- elsif missing.empty?
85
- results.join(SEPARATOR)
86
- else
87
- results.join(SEPARATOR) + SEPARATOR + "Error: memory not found: #{missing.join(", ")}"
88
- end
83
+ return results.join(SEPARATOR) if missing.empty?
84
+
85
+ # The hint goes after the first line: WorkingSet reads the missing
86
+ # names off it (WorkingSet::MISSING) and a failure off its "Error".
87
+ miss = "Error: memory not found: #{missing.join(", ")}#{orphan_hint if resolved_scopes.include?("project")}"
88
+ results.empty? ? miss : results.join(SEPARATOR) + SEPARATOR + miss
89
89
  rescue Errno::ENOENT => e
90
90
  "Error: #{e.message}"
91
91
  rescue StandardError => e
92
92
  "Error: #{e.message}"
93
93
  end
94
94
 
95
+ # "\nHint: …" when this project's memory folder looks orphaned
96
+ # (MemoryOrphans), else "".
97
+ def self.orphan_hint
98
+ message = MemoryOrphans.message
99
+ message ? "\nHint: #{message}" : ""
100
+ end
101
+
95
102
  # A memory is a flat file in its scope dir: a name with a path in it
96
103
  # ("../../x") would read or write outside the memories.
97
104
  def self.invalid_name?(name)
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_id"
4
+ require_relative "steer"
5
+
6
+ module Samagotchi
7
+ # Who queued a turn: the client that sent it (+client_id+, ClientId), the
8
+ # input file's id (+enqueued_id+, the reply's and the events' key) and,
9
+ # for a participant's, its +author+ ("p:<id>"; none for the owner's).
10
+ # Engine#run_turn's, the Worker's and GuardrailWiring's. On the wire (a
11
+ # turn's boundary events, Bridge SSE, the inbox JSON's own fields) an
12
+ # origin stays the Hash it always was: #to_h writes it, .from reads it
13
+ # back, so a value that crosses a boundary is turned there, once.
14
+ TurnOrigin = Data.define(:client_id, :enqueued_id, :author) do
15
+ def initialize(client_id: nil, enqueued_id: nil, author: nil) = super
16
+
17
+ # An origin from what a boundary carries: a TurnOrigin as it is, a Hash
18
+ # (symbol or string keys: an event, an SSE line, a spec) as one; nil for
19
+ # nil, an empty Hash and anything else (no sender named).
20
+ # @return [TurnOrigin, nil]
21
+ def self.from(value)
22
+ return value if value.is_a?(TurnOrigin)
23
+ return nil unless value.is_a?(Hash)
24
+
25
+ field = ->(key) { value.key?(key) ? value[key] : value[key.to_s] }
26
+ origin = new(client_id: field.call(:client_id), enqueued_id: field.call(:enqueued_id), author: field.call(:author))
27
+ origin.empty? ? nil : origin
28
+ end
29
+
30
+ # An origin either way: .from's, or NONE when the value names no
31
+ # sender (nil, {}). In-process an owner's turn has NONE, never nil.
32
+ # @return [TurnOrigin]
33
+ def self.of(value) = from(value) || self::NONE
34
+
35
+ # Whether it names no sender (NONE): its wire form is left out.
36
+ def empty? = to_h.empty?
37
+
38
+ # The wire form where a field may carry no origin: #to_h, nil for NONE
39
+ # (an event's origin:, a hook's context).
40
+ # @return [Hash, nil]
41
+ def wire = empty? ? nil : to_h
42
+
43
+ # Whether a participant queued the turn (Steer.participant_author?: any
44
+ # author but none or the owner's).
45
+ def participant? = Steer.participant_author?(author)
46
+
47
+ # The author when a participant's (Steer.sender_author), else nil.
48
+ def participant_author = Steer.sender_author(author)
49
+
50
+ # The participant's id (Steer.participant_id: its author without "p:"),
51
+ # nil for the owner's.
52
+ def participant_id = participant_author && Steer.participant_id(participant_author)
53
+
54
+ # What sent the turn, by its client (ClientId.origin): "client",
55
+ # "reminder", "delegate", "review"...
56
+ def source = ClientId.origin(client_id)
57
+
58
+ # Whether a human sent the turn (ClientId.human?: a missing client is).
59
+ def human? = ClientId.human?(client_id)
60
+
61
+ # The wire form: only the fields set, as a turn's origin always went out.
62
+ def to_h = super.compact
63
+ end
64
+
65
+ # No sender named: an owner's turn with no client, run_turn's default
66
+ # (TurnOrigin.of).
67
+ TurnOrigin::NONE = TurnOrigin.new
68
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.107.0"
4
+ VERSION = "0.108.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -57,6 +57,7 @@ require_relative "session_summary"
57
57
  require_relative "session_access_routes"
58
58
  require_relative "principal"
59
59
  require_relative "participant_access"
60
+ require_relative "participants_admin"
60
61
  require_relative "participant_review"
61
62
  require_relative "../log"
62
63
  require_relative "../paths"
@@ -76,6 +77,7 @@ module Samagotchi
76
77
  include RedactedInspect
77
78
  include SessionAccessRoutes
78
79
  include ParticipantAccess
80
+ include ParticipantsAdmin
79
81
  include ParticipantReview
80
82
 
81
83
  DEFAULT_HOST = BridgeClient::HOST
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "fileutils"
5
5
  require "json"
6
+ require_relative "../steer"
6
7
  require "securerandom"
7
8
  require "time"
8
9
  require "rack/utils"
@@ -104,7 +105,7 @@ module Samagotchi
104
105
  def queue_limit = queue.is_a?(Integer) ? queue : Grants.default_queue
105
106
 
106
107
  # The author a participant's turn carries (Principal#author).
107
- def author = "p:#{id}"
108
+ def author = Steer.author_for(id)
108
109
 
109
110
  # :active, :revoked or :expired at +now+ (a Time).
110
111
  def state(now)