samagotchi 0.32.0 → 0.33.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/context.md +3 -1
  4. data/docs/hooks.md +6 -3
  5. data/docs/plugins.md +2 -1
  6. data/docs/releasing.md +2 -1
  7. data/lib/samagotchi/answer_command.rb +2 -1
  8. data/lib/samagotchi/archive_store.rb +1 -15
  9. data/lib/samagotchi/bridge.rb +8 -3
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/child_reports.rb +31 -10
  12. data/lib/samagotchi/client_id.rb +52 -0
  13. data/lib/samagotchi/context_absorber.rb +2 -1
  14. data/lib/samagotchi/context_fetch.rb +14 -45
  15. data/lib/samagotchi/context_sources.rb +20 -10
  16. data/lib/samagotchi/engine.rb +10 -7
  17. data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
  18. data/lib/samagotchi/memory_bundle/builder.rb +22 -1
  19. data/lib/samagotchi/memory_bundle/manifest.rb +3 -1
  20. data/lib/samagotchi/plugin/sessions.rb +2 -1
  21. data/lib/samagotchi/process_group.rb +160 -0
  22. data/lib/samagotchi/prompt_warmup.rb +20 -1
  23. data/lib/samagotchi/question_desk.rb +2 -1
  24. data/lib/samagotchi/relay_verifier.rb +2 -4
  25. data/lib/samagotchi/reply_wait.rb +9 -4
  26. data/lib/samagotchi/send_command.rb +2 -1
  27. data/lib/samagotchi/session_manager.rb +2 -4
  28. data/lib/samagotchi/steer.rb +27 -19
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
  30. data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
  31. data/lib/samagotchi/terminal_ui.rb +2 -1
  32. data/lib/samagotchi/tools/delegate.rb +2 -2
  33. data/lib/samagotchi/tools/execute.rb +10 -81
  34. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/app.rb +2 -1
  37. data/lib/samagotchi/web/public/app.js +19 -20
  38. data/lib/samagotchi/web/public/data.js +3 -0
  39. data/lib/samagotchi/web/public/question_card.js +3 -4
  40. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  41. data/lib/samagotchi/web/public/stable_html.js +18 -0
  42. data/lib/samagotchi/web/public/turn_events.js +9 -2
  43. data/lib/samagotchi/web/public/turn_view.js +1 -1
  44. data/lib/samagotchi/worker.rb +33 -11
  45. metadata +5 -1
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../client_id"
3
4
  require_relative "../session"
4
5
  require_relative "../bridge_client"
5
6
  require_relative "../bridge/turn_accumulator"
@@ -62,7 +63,7 @@ module Samagotchi
62
63
 
63
64
  state_dir = self.state_dir
64
65
  sid = resolve(id, state_dir)
65
- delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: "plugin", state_dir: state_dir)
66
+ delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir)
66
67
  case delivered[:status]
67
68
  when :accepted then sid
68
69
  when :refused then raise Error, "session #{sid[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A command run in a process group of its own, so a stop reaches what it
5
+ # started too: TERM the group, a grace, then KILL it. Execute, TaskRuntime
6
+ # and ContextFetch spawn and stop their commands here, each with its own
7
+ # grace and its own "has it stopped?" check (the leader gone, the pipes
8
+ # closed, the pid reaped).
9
+ #
10
+ # The mcp bundle's client keeps its own copy (stdin closed first, then
11
+ # the group): a bundle may use chi's classes only from the chi its
12
+ # requires_chi names, and no released chi has this one yet.
13
+ module ProcessGroup
14
+ # A group that is gone, or one that isn't ours to signal.
15
+ QUIET = [Errno::ESRCH, Errno::EPERM].freeze
16
+ READ_CHUNK_BYTES = 65_536
17
+
18
+ module_function
19
+
20
+ # Process.spawn in a new process group, whose id is the pid.
21
+ # @return [Integer] the pid
22
+ def spawn(env, *argv, **) = Process.spawn(env, *argv, pgroup: true, **)
23
+
24
+ # Sends +sig+ to every process in the group +pgid+.
25
+ # @param leader [Boolean] when the group can't be signalled, signal the
26
+ # process +pgid+ alone (a leader that left its group)
27
+ # @param quiet [Array<Class>] the errors that mean there is nothing to
28
+ # signal; any other is raised
29
+ # @return [Boolean] whether a signal was sent
30
+ def signal(pgid, sig, leader: false, quiet: QUIET)
31
+ check!(pgid)
32
+ Process.kill(sig, -pgid)
33
+ true
34
+ rescue *quiet
35
+ return false unless leader
36
+
37
+ begin
38
+ Process.kill(sig, pgid)
39
+ true
40
+ rescue *quiet
41
+ false
42
+ end
43
+ end
44
+
45
+ # A pid chi may signal or ask about: an Integer above 1. kill(0) and
46
+ # kill(-1) reach chi's own group and every process of the user, so a
47
+ # pid that isn't one (a broken or edited record) never gets that far.
48
+ def signalable?(pid) = pid.is_a?(Integer) && pid > 1
49
+
50
+ # @raise [ArgumentError] unless #signalable?
51
+ def check!(pid)
52
+ raise ArgumentError, "not a process group chi may signal: #{pid.inspect}" unless signalable?(pid)
53
+ end
54
+
55
+ # Whether +pid+ runs and leads its own group, as #spawn's process does
56
+ # (not, then, a reused pid in some other group).
57
+ def leader?(pid)
58
+ signalable?(pid) && Process.getpgid(pid) == pid
59
+ rescue Errno::ESRCH, Errno::EPERM
60
+ false
61
+ end
62
+
63
+ # Whether +pid+ is still running (or a zombie not reaped yet).
64
+ # @raise [ArgumentError] unless #signalable?
65
+ def alive?(pid)
66
+ check!(pid)
67
+ Process.kill(0, pid)
68
+ true
69
+ rescue Errno::ESRCH
70
+ false
71
+ rescue Errno::EPERM
72
+ true
73
+ end
74
+
75
+ # TERMs the group, waits up to +grace+ for +stopped+, then KILLs it.
76
+ # @param stopped [#call] true once it has stopped (default: the leader
77
+ # is gone)
78
+ # @param wait [#call] (seconds) between two checks (default: a sleep)
79
+ # @param signal_options [Hash] for #signal (leader:, quiet:)
80
+ # @return [Boolean] true when it stopped within the grace (no KILL sent)
81
+ # @raise [ArgumentError] unless #signalable?
82
+ def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) }, **signal_options)
83
+ check!(pgid)
84
+ signal(pgid, "TERM", **signal_options)
85
+ deadline = monotonic + grace
86
+ until (done = stopped.call) || monotonic >= deadline
87
+ wait.call(poll)
88
+ end
89
+ return true if done
90
+
91
+ signal(pgid, "KILL", **signal_options)
92
+ false
93
+ end
94
+
95
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
96
+
97
+ # Reads a pipe in chunks on a thread of its own, so the output read so
98
+ # far survives the pipe being closed under it (or the thread killed).
99
+ class PipeReader
100
+ # @param cap [Integer, nil] the most bytes it keeps
101
+ # @param keep [:head, :tail] past the cap: :head stops reading (and
102
+ # #over? is true), :tail keeps the last +cap+ bytes
103
+ def initialize(io, cap: nil, keep: :head, chunk: READ_CHUNK_BYTES)
104
+ @io = io
105
+ @buffer = String.new(encoding: Encoding::BINARY)
106
+ @over = false
107
+ @thread = Thread.new do
108
+ Thread.current.report_on_exception = false
109
+ loop { break unless take(io.readpartial(chunk), cap, keep) }
110
+ rescue IOError # EOFError is one
111
+ nil
112
+ end
113
+ end
114
+
115
+ def done? = !@thread.alive?
116
+
117
+ # Whether a :head reader stopped at its cap.
118
+ def over? = @over
119
+
120
+ # @return [Thread, nil] the thread when it ended within +seconds+
121
+ def wait(seconds) = @thread.join(seconds)
122
+
123
+ def kill = @thread.kill
124
+
125
+ # Closes the pipe once the thread had +poll+ to end by itself (a
126
+ # reader still blocked gets IOError), then ends the thread, killing it
127
+ # after +grace+. The text is safe to read afterwards.
128
+ def finish(poll:, grace:)
129
+ @thread.join(poll) unless done?
130
+ close_quietly
131
+ @thread.kill unless @thread.join(grace)
132
+ end
133
+
134
+ # What it read, in +encoding+ (default: the pipe's).
135
+ def text(encoding = @io.external_encoding || Encoding.default_external)
136
+ @buffer.dup.force_encoding(encoding)
137
+ end
138
+
139
+ private
140
+
141
+ # @return [Boolean] whether to read on
142
+ def take(chunk, cap, keep)
143
+ if cap && keep == :head && @buffer.bytesize + chunk.bytesize > cap
144
+ @over = true
145
+ return false
146
+ end
147
+
148
+ @buffer << chunk
149
+ @buffer = @buffer.byteslice(-cap, cap) if cap && keep == :tail && @buffer.bytesize > cap
150
+ true
151
+ end
152
+
153
+ def close_quietly
154
+ @io.close unless @io.closed?
155
+ rescue IOError
156
+ nil
157
+ end
158
+ end
159
+ end
160
+ end
@@ -32,6 +32,8 @@ module Samagotchi
32
32
  # Seconds #take_pin waits for the busy-slot check (Client#slot_status
33
33
  # gives up within 3 s).
34
34
  DECIDE_TIMEOUT = 3
35
+ # Seconds #stop waits for a killed warm-up's thread to end.
36
+ STOP_JOIN_SECONDS = 1
35
37
 
36
38
  # Whether cache.warmup allows a warm-up (auto, the default; off, false,
37
39
  # no or 0 turn it off; anything else warns once and is auto).
@@ -54,13 +56,16 @@ module Samagotchi
54
56
  # The pin (#take_pin forgets it) and the last warm-up's thread.
55
57
  @state = nil
56
58
  @thread = nil
59
+ @stopped = false
57
60
  end
58
61
 
59
62
  # Start a warm-up of +prompt+ on +client+ in its own thread.
60
63
  # @param slot [Integer, nil] the last request's slot (nil: unpinned, and
61
64
  # nothing to pin the next request to)
62
- # @return [Thread]
65
+ # @return [Thread, nil] nil once #stop ran
63
66
  def start(client:, prompt:, model:, slot:, images: [])
67
+ return nil if @mutex.synchronize { @stopped }
68
+
64
69
  # Whether the warm-up went to its slot (true) or was skipped: the
65
70
  # first value pushed is the answer.
66
71
  sent = Queue.new
@@ -111,6 +116,20 @@ module Samagotchi
111
116
  thread&.alive? || false
112
117
  end
113
118
 
119
+ # Ends a warm-up still running and starts none after (Engine#shutdown:
120
+ # no next turn will use it). Its request is cut, which frees the slot.
121
+ def stop(timeout = STOP_JOIN_SECONDS)
122
+ thread = @mutex.synchronize do
123
+ @stopped = true
124
+ @state = nil
125
+ @thread
126
+ end
127
+ return unless thread&.alive?
128
+
129
+ thread.kill
130
+ thread.join(timeout)
131
+ end
132
+
114
133
  # Wait up to +timeout+ seconds for the running warm-up (specs).
115
134
  def wait(timeout = nil)
116
135
  thread = @mutex.synchronize { @thread }
@@ -5,6 +5,7 @@ require "monitor"
5
5
  require "securerandom"
6
6
  require "time"
7
7
 
8
+ require_relative "client_id"
8
9
  require_relative "log"
9
10
  require_relative "tools/ask_user_question"
10
11
  require_relative "guardrails/parent_approvals"
@@ -266,7 +267,7 @@ module Samagotchi
266
267
  sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
267
268
  fm = freeform.to_s.strip
268
269
  fm = nil if fm.empty?
269
- parent_agent = client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID if parent_agent.nil?
270
+ parent_agent = client_id.to_s == ClientId::CLI_ANSWER if parent_agent.nil?
270
271
  # Read before the lock (config may touch the disk); this worker's own.
271
272
  parent_setting = Guardrails::ParentApprovals.setting if parent_agent
272
273
  parent_continue = Guardrails::ParentContinue.allowed? if parent_agent
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "client_id"
3
4
  require_relative "session"
4
5
  require_relative "bridge_client"
5
6
  require_relative "worker_sidecar"
@@ -15,9 +16,6 @@ module Samagotchi
15
16
  # answers its own question with that, only when it is about this session
16
17
  # and the question pending now, and is answered.
17
18
  class RelayVerifier
18
- # The client id a relayed answer is recorded with: relay:<parent8>.
19
- CLIENT_PREFIX = "relay"
20
-
21
19
  # @param engine [Engine] this (the child's) worker's Engine
22
20
  def initialize(engine:, state_dir:, session_id:)
23
21
  @engine = engine
@@ -44,7 +42,7 @@ module Samagotchi
44
42
  def apply(relay, parent_id:, question_id:, relay_id:)
45
43
  answer = relay["answer"].is_a?(Hash) ? relay["answer"] : {}
46
44
  parent_agent = relay["by"] != "user"
47
- client_id = "#{CLIENT_PREFIX}:#{parent_id[0, 8]}"
45
+ client_id = "#{ClientId::RELAY_PREFIX}#{parent_id[0, 8]}"
48
46
  if answer["dismissed"]
49
47
  return not_pending(question_id) unless @engine.cancel_question("dismissed", id: question_id)
50
48
 
@@ -62,8 +62,9 @@ module Samagotchi
62
62
  # question already pending then is not the answer's. A nil
63
63
  # messages: skips the count. last_turn: (the session's
64
64
  # last_turn["ended_at"] then, maybe nil) ends the wait on any turn
65
- # that ended since, idle again, however fast it ran: a failure after
66
- # a failure replaces the note and leaves the count as it was.
65
+ # that ended since, idle again, however fast it ran (a failure after
66
+ # a failure replaces the note and leaves the count as it was), and
67
+ # with it the count isn't used: a note between turns grows it too.
67
68
  # @param owner_grace [Numeric, nil] seconds with no live worker before
68
69
  # :worker_gone (one that died before its rescue leaves it running)
69
70
  # @param interject [#call, nil] called once a poll, before the sleep
@@ -145,13 +146,17 @@ module Samagotchi
145
146
  end
146
147
 
147
148
  # An idle session whose turn ended after the baseline was taken: its
148
- # last_turn moved on, or its messages grew (a turn's note).
149
+ # last_turn moved on. Every turn's end records last_turn as it goes
150
+ # idle (Engine#end_turn), so with one in the baseline that alone
151
+ # decides: messages also grow between turns (a new worker's context
152
+ # note, a chi note) with no turn run. A baseline without it falls back
153
+ # to the count (a turn's note).
149
154
  def turn_ended_since?(session, baseline)
150
155
  return false unless baseline
151
156
 
152
157
  if baseline.key?(:last_turn)
153
158
  ended = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
154
- return true if ended && ended != baseline[:last_turn]
159
+ return !ended.nil? && ended != baseline[:last_turn]
155
160
  end
156
161
  !!(baseline[:messages] && session.messages.size > baseline[:messages])
157
162
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "client_id"
3
4
  require_relative "session"
4
5
  require_relative "session_inbox"
5
6
  require_relative "session_manager"
@@ -26,7 +27,7 @@ module Samagotchi
26
27
  include CLI::Command
27
28
  include CLI::ParentWait
28
29
 
29
- CLIENT_ID = "cli:send"
30
+ CLIENT_ID = ClientId::CLI_SEND
30
31
  # The cap on one turn's images the Bridge checks.
31
32
  MAX_IMAGES = ImageStore::MAX_TURN_REFS
32
33
 
@@ -5,6 +5,7 @@ require "json"
5
5
  require "time"
6
6
  require "securerandom"
7
7
  require "rbconfig"
8
+ require_relative "client_id"
8
9
  require_relative "atomic_file"
9
10
  require_relative "config"
10
11
 
@@ -59,9 +60,6 @@ module Samagotchi
59
60
  autoload :SessionRetention, File.expand_path("session_retention", __dir__)
60
61
 
61
62
  class SessionManager
62
- # Origin of the synthetic turn queued when reminders are due.
63
- REMINDER_CLIENT_ID = "system:reminder"
64
-
65
63
  # Raised when the interactive TUI owns the session: it runs its own Engine
66
64
  # and reads no input files, so a worker must not be spawned or signalled.
67
65
  class OwnedByTUI < StandardError
@@ -583,7 +581,7 @@ module Samagotchi
583
581
  # @raise [OwnedByTUI] a chi REPL owns the session
584
582
  # @raise [RestartRefused]
585
583
  # @raise [ArgumentError] no such session
586
- def self.restart_session(session_id, state_dir: nil, wait: RESTART_WAIT, client_id: "cli:restart")
584
+ def self.restart_session(session_id, state_dir: nil, wait: RESTART_WAIT, client_id: ClientId::CLI_RESTART)
587
585
  sd = state_dir || Session.default_state_dir
588
586
  Session.load(session_id, state_dir: sd) # ArgumentError for none
589
587
  owner = refuse_tui!(session_id, state_dir: sd)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "client_id"
4
+
3
5
  module Samagotchi
4
6
  # A plugin's steer: text a plugin puts into the running turn (Engine#steer),
5
7
  # the way a UI's steering does. The drain a loop gets returns items: a
@@ -12,8 +14,9 @@ module Samagotchi
12
14
  # "input": they are part of that turn, not a turn of their own, so a
13
15
  # reloaded history keeps the turn in one piece. A line can be a Line
14
16
  # naming who sent it (a worker's input from chi send, a delegating parent,
15
- # a plugin); its input message then carries that source:. The user's own
16
- # lines carry none, so their saved bytes are as they always were.
17
+ # a plugin, a client chi doesn't know); its input message then carries
18
+ # that source:. The user's own lines carry none, so their saved bytes are
19
+ # as they always were.
17
20
  module Steer
18
21
  KIND = "steer"
19
22
  INPUT_KIND = "input"
@@ -27,18 +30,10 @@ module Samagotchi
27
30
  def initialize(text:, source:, mark: nil) = super
28
31
  end
29
32
 
30
- # The client ids whose input is not the user's own words. Literals, not
31
- # SendCommand::CLIENT_ID / Tools::Delegate::CLIENT_PREFIX, so this file
32
- # needs no requires (a spec pins them equal).
33
- CHI_SEND_CLIENT = "cli:send"
34
- DELEGATE_CLIENT_PREFIX = "delegate:"
35
- # A delegate child's report (ChildReports::CLIENT_PREFIX).
36
- CHILD_CLIENT_PREFIX = "child:"
37
- # A turn chi ran because an attached context source changed
38
- # (context:<name>, Worker's context wake).
39
- CONTEXT_CLIENT_PREFIX = "context:"
40
33
  DELEGATE_REPORT = "delegate_report"
41
- PLUGIN_CLIENT = "plugin"
34
+ # The source of a line from a client that is not a human's
35
+ # (ClientId.human?) and not one chi names: automatic:<client id>.
36
+ AUTOMATIC_SOURCE_PREFIX = "automatic:"
42
37
 
43
38
  # What one drain brought: the merged user text (nil when none) and how
44
39
  # many lines made it, the input messages' text grouped by sender
@@ -98,35 +93,48 @@ module Samagotchi
98
93
  "Don't call delegate_result for it; to retry or redirect that child, use delegate with " \
99
94
  "its session:.]"
100
95
  }.freeze
96
+ # A line from a client chi doesn't know (AUTOMATIC_SOURCE_PREFIX):
97
+ # never taken for the user's.
98
+ AUTOMATIC_HEADER = "[Automatic input from %s, sent mid-task; not your user's message. " \
99
+ "If it asks for nothing, carry on with the task.]"
101
100
 
102
101
  module_function
103
102
 
104
103
  # The saved source of a worker input line from +client_id+: nil for the
105
- # user (no client id, the web, an attached TUI, anything unknown).
104
+ # user (no client id, the web, an attached TUI); automatic:<client id>
105
+ # for a client that is no human's and chi doesn't name (an unknown id is
106
+ # not the user).
106
107
  def source_for_client(client_id)
107
108
  id = client_id.to_s
108
- if id == CHI_SEND_CLIENT then "chi_send"
109
- elsif id.start_with?(DELEGATE_CLIENT_PREFIX) then "parent_agent"
110
- elsif id.start_with?(CHILD_CLIENT_PREFIX) then DELEGATE_REPORT
111
- elsif id == PLUGIN_CLIENT then "plugin_send"
109
+ if id == ClientId::CLI_SEND then "chi_send"
110
+ elsif id.start_with?(ClientId::DELEGATE_PREFIX) then "parent_agent"
111
+ elsif id.start_with?(ClientId::CHILD_PREFIX) then DELEGATE_REPORT
112
+ elsif id == ClientId::PLUGIN then "plugin_send"
113
+ elsif !ClientId.human?(client_id) then "#{AUTOMATIC_SOURCE_PREFIX}#{id}"
112
114
  end
113
115
  end
114
116
 
115
117
  # The header for a steer or merged input message, else nil. No source
116
118
  # (or "") is the user's; an input with an unknown source too; a steer
117
- # with an unknown source is a plugin's (its bundle or hook label).
119
+ # with an unknown source is a plugin's (its bundle or hook label); an
120
+ # automatic:<client id> source names that client, not the user.
118
121
  def header(message)
119
122
  kind = (message[:kind] || message["kind"]).to_s
120
123
  return unless [KIND, INPUT_KIND].include?(kind)
121
124
 
122
125
  source = (message[:source] || message["source"]).to_s
123
126
  return HEADERS[source] if HEADERS.key?(source)
127
+ return automatic_header(source.delete_prefix(AUTOMATIC_SOURCE_PREFIX)) if source.start_with?(AUTOMATIC_SOURCE_PREFIX)
124
128
 
125
129
  source = "user" if source.empty?
126
130
  sender = SENDERS[source] || (kind == KIND ? "from the #{source} plugin" : SENDERS["user"])
127
131
  "[Steer #{sender}, #{HEADER_TAIL}"
128
132
  end
129
133
 
134
+ def automatic_header(client_id)
135
+ format(AUTOMATIC_HEADER, client_id.empty? ? "an unnamed client" : client_id)
136
+ end
137
+
130
138
  # The text a steer or merged input goes out as: its header, a newline,
131
139
  # then +text+ (the message's content by default). Other messages: +text+.
132
140
  def wire_text(message, text = (message[:content] || message["content"]).to_s)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../client_id"
3
4
  require_relative "event_renderer"
4
5
  require_relative "formatting"
5
6
  require_relative "../prompt_history"
@@ -773,7 +774,7 @@ module Samagotchi
773
774
  return @screen.commit(answer.error)
774
775
  end
775
776
 
776
- parent = @parent_answers ? { client_id: Guardrails::ParentApprovals::CLIENT_ID } : {}
777
+ parent = @parent_answers ? { client_id: ClientId::CLI_ANSWER } : {}
777
778
  reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
778
779
  case reply.status
779
780
  when 200
@@ -1268,9 +1269,9 @@ module Samagotchi
1268
1269
  nil
1269
1270
  end
1270
1271
 
1271
- def reminder_origin?(origin) = origin[:client_id].to_s.start_with?("system:")
1272
- def report_origin?(origin) = origin[:client_id].to_s.start_with?(Steer::CHILD_CLIENT_PREFIX)
1273
- def context_origin?(origin) = origin[:client_id].to_s.start_with?(Steer::CONTEXT_CLIENT_PREFIX)
1272
+ def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
1273
+ def report_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CHILD_PREFIX)
1274
+ def context_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CONTEXT_PREFIX)
1274
1275
  # A turn with no prompt: "(continuing)"; a context wake's label alone.
1275
1276
  def continuing_text(origin) = context_origin?(origin) ? nil : "(continuing)"
1276
1277
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../client_id"
3
4
  require_relative "../served_model"
4
5
  require_relative "../image_store"
5
6
  require_relative "../turn_note"
@@ -113,21 +114,29 @@ module Samagotchi
113
114
  " #{paint("+#{get.call(:added).to_i}", 32)} #{paint("\u2212#{get.call(:removed).to_i}", 31)}"
114
115
  end
115
116
 
116
- # Prompt labels by the sender's client_id prefix (turn_events.js
117
- # CLIENT_LABELS; spec/shared/labels_matrix.json).
118
- CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate",
119
- "child" => "delegate report" }.freeze
120
-
121
- # Who sent a line, by its client_id: CLIENT_LABELS by prefix, and a
122
- # turn an attached context source started (context:<name>) as
123
- # "context <name> changed"; +fallback+ for anything else.
117
+ # Prompt labels by the sender's client_id: a whole id, or a prefix
118
+ # (ending in ":") it starts with (turn_events.js CLIENT_LABELS;
119
+ # spec/shared/labels_matrix.json).
120
+ CLIENT_LABELS = { ClientId::WEB_PREFIX => "web", ClientId::TUI_PREFIX => "tui", ClientId::SYSTEM_PREFIX => "reminder",
121
+ ClientId::DELEGATE_PREFIX => "delegate", ClientId::CHILD_PREFIX => "delegate report",
122
+ ClientId::CLI_SEND => "chi send", ClientId::CLI_ANSWER => "chi answer",
123
+ ClientId::PLUGIN => "plugin" }.freeze
124
+ # A client id chi doesn't know: not the user (ClientId.human?).
125
+ AUTOMATIC_LABEL = "automatic"
126
+
127
+ # Who sent a line, by its client_id: CLIENT_LABELS by the whole id or
128
+ # its prefix, a turn an attached context source started
129
+ # (context:<name>) as "context <name> changed", AUTOMATIC_LABEL for
130
+ # an id chi doesn't know; +fallback+ for none (the user's own).
124
131
  def client_label(client_id, fallback = "user")
125
132
  return fallback unless client_id
126
133
 
127
- prefix, rest = client_id.to_s.split(":", 2)
128
- return "context #{rest} changed" if prefix == Steer::CONTEXT_CLIENT_PREFIX.delete_suffix(":") && !rest.to_s.empty?
134
+ id = client_id.to_s
135
+ name = id.delete_prefix(ClientId::CONTEXT_PREFIX)
136
+ return "context #{name} changed" if id.start_with?(ClientId::CONTEXT_PREFIX) && !name.empty?
129
137
 
130
- CLIENT_LABELS.fetch(prefix, fallback)
138
+ prefix = id[/\A[^:]*:/]
139
+ CLIENT_LABELS[id] || (prefix && CLIENT_LABELS[prefix]) || AUTOMATIC_LABEL
131
140
  end
132
141
 
133
142
  # "web> <prompt>": a prompt, labelled by who sent it; nil: the label alone.
@@ -142,7 +151,7 @@ module Samagotchi
142
151
  status = report[/^status: (.*)$/, 1]
143
152
  rest = report.split("\n").drop(2).reject { |line| line.strip.empty? || line == "---" }.first.to_s
144
153
  rest = "#{rest[0, STEER_PREVIEW - 1]}…" if rest.length > STEER_PREVIEW
145
- "#{paint("#{CLIENT_LABELS["child"]}>", 35)} #{[id, status].compact.join(" ")}#{": #{rest}" unless rest.empty?}"
154
+ "#{paint("#{CLIENT_LABELS[ClientId::CHILD_PREFIX]}>", 35)} #{[id, status].compact.join(" ")}#{": #{rest}" unless rest.empty?}"
146
155
  end
147
156
 
148
157
  # The last prompt as a join shows it: a delegate report (a wake
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "io/console"
6
6
  require "reline"
7
7
 
8
+ require_relative "client_id"
8
9
  require_relative "iteration_limit"
9
10
  require_relative "model_profile"
10
11
  require_relative "turn_note"
@@ -1281,7 +1282,7 @@ module Samagotchi
1281
1282
  end
1282
1283
 
1283
1284
  begin
1284
- parent = parent_answers? ? { client_id: Guardrails::ParentApprovals::CLIENT_ID } : {}
1285
+ parent = parent_answers? ? { client_id: ClientId::CLI_ANSWER } : {}
1285
1286
  @engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform, **parent)
1286
1287
  close_question_widget(prompt, prompt.answer_text(answer))
1287
1288
  return true
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../client_id"
3
4
  require_relative "../session"
4
5
  require_relative "../config"
5
6
  require_relative "../model_profile"
@@ -21,7 +22,6 @@ module Samagotchi
21
22
  # reply comes back (DelegateWait), never its trace.
22
23
  class Delegate
23
24
  NAME = "delegate"
24
- CLIENT_PREFIX = "delegate"
25
25
  MAX_CHILDREN_KEY = "session.max_children"
26
26
  MAX_CHILDREN_DEFAULT = 4
27
27
  # A child spawned this many seconds ago counts as running even before
@@ -113,7 +113,7 @@ module Samagotchi
113
113
 
114
114
  # Only a reply after this delivery counts as the follow-up's.
115
115
  DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
116
- delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{CLIENT_PREFIX}:#{parent.id[0, 8]}",
116
+ delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
117
117
  state_dir: state_dir)
118
118
  case delivered[:status]
119
119
  when :accepted then id