samagotchi 0.11.0 → 0.13.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -1
  3. data/README.md +20 -7
  4. data/bin/chi +10 -4
  5. data/docs/architecture.md +8 -6
  6. data/docs/cli.md +59 -11
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +34 -8
  9. data/docs/guardrails.md +73 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +4 -4
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge.rb +15 -5
  17. data/lib/samagotchi/bridge_client.rb +8 -3
  18. data/lib/samagotchi/bundle_command.rb +18 -10
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/cli/command.rb +3 -2
  24. data/lib/samagotchi/cli/exit.rb +23 -0
  25. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  26. data/lib/samagotchi/config.rb +50 -18
  27. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  28. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  29. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  30. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  31. data/lib/samagotchi/desktop_command.rb +5 -3
  32. data/lib/samagotchi/engine.rb +480 -491
  33. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  34. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  35. data/lib/samagotchi/host_registry.rb +26 -3
  36. data/lib/samagotchi/idle_reminders.rb +13 -36
  37. data/lib/samagotchi/kernel_loop.rb +82 -120
  38. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  39. data/lib/samagotchi/llm/model_result.rb +14 -20
  40. data/lib/samagotchi/llm/native_backend.rb +3 -21
  41. data/lib/samagotchi/llm/openai_chat.rb +10 -2
  42. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  43. data/lib/samagotchi/llm/usage.rb +2 -57
  44. data/lib/samagotchi/memory_bundle/installer.rb +13 -1
  45. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  46. data/lib/samagotchi/memory_paths.rb +8 -0
  47. data/lib/samagotchi/model_catalog.rb +101 -0
  48. data/lib/samagotchi/model_profile.rb +31 -17
  49. data/lib/samagotchi/models_command.rb +112 -0
  50. data/lib/samagotchi/parent_report.rb +213 -0
  51. data/lib/samagotchi/plugin/api.rb +15 -2
  52. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  53. data/lib/samagotchi/question_desk.rb +29 -2
  54. data/lib/samagotchi/recap_setup.rb +15 -5
  55. data/lib/samagotchi/reminder_queue.rb +73 -0
  56. data/lib/samagotchi/reminder_store.rb +3 -3
  57. data/lib/samagotchi/reply_wait.rb +5 -1
  58. data/lib/samagotchi/self_report.rb +8 -1
  59. data/lib/samagotchi/send_command.rb +51 -76
  60. data/lib/samagotchi/session.rb +15 -0
  61. data/lib/samagotchi/session_commands.rb +5 -5
  62. data/lib/samagotchi/session_manager.rb +36 -13
  63. data/lib/samagotchi/sessions_command.rb +13 -3
  64. data/lib/samagotchi/system_prompt.rb +10 -2
  65. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  66. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  67. data/lib/samagotchi/terminal_ui.rb +23 -29
  68. data/lib/samagotchi/thinking.rb +1 -1
  69. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  70. data/lib/samagotchi/tool_call_parser.rb +5 -6
  71. data/lib/samagotchi/tool_runner.rb +4 -6
  72. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  73. data/lib/samagotchi/tools/builtins.rb +15 -3
  74. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  75. data/lib/samagotchi/tools/execute.rb +8 -8
  76. data/lib/samagotchi/tools/task_create.rb +3 -2
  77. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  78. data/lib/samagotchi/turn_flow.rb +4 -4
  79. data/lib/samagotchi/turn_state.rb +126 -0
  80. data/lib/samagotchi/used_memories.rb +88 -0
  81. data/lib/samagotchi/version.rb +1 -1
  82. data/lib/samagotchi/vision_support.rb +4 -4
  83. data/lib/samagotchi/web/app.rb +17 -35
  84. data/lib/samagotchi/web/message_parts.rb +2 -1
  85. data/lib/samagotchi/web/server.rb +2 -1
  86. data/lib/samagotchi/web/session_hub.rb +0 -4
  87. data/lib/samagotchi/web/session_summary.rb +8 -16
  88. data/lib/samagotchi/worker.rb +1 -1
  89. metadata +14 -1
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "../log"
4
5
  require_relative "../hooks/registry"
5
6
  require_relative "../tools/args"
@@ -298,14 +299,26 @@ module Samagotchi
298
299
  schema: spec[:schema], label: spec[:label],
299
300
  handler: lambda { |call, _kctx|
300
301
  result = block.call(Api.args_of(call, parameters), context)
301
- # A String (a ToolResult too, with its images) as it is.
302
- result.is_a?(String) ? result : result.to_s
302
+ Api.result_text(result)
303
303
  },
304
304
  preview: preview && ->(call) { preview.call(Api.args_of(call, parameters))&.to_s },
305
305
  targets: targets && ->(call) { targets.call(Api.args_of(call, parameters)) }
306
306
  }
307
307
  end
308
308
 
309
+ # A tool's result as the model reads it: a String (a ToolResult too,
310
+ # with its images) as it is, a Hash or Array as JSON (its #to_s is
311
+ # Hash#inspect, which changes with the Ruby version), else #to_s.
312
+ def self.result_text(result)
313
+ case result
314
+ when String then result
315
+ when Hash, Array then JSON.generate(result)
316
+ else result.to_s
317
+ end
318
+ rescue JSON::GeneratorError
319
+ result.to_s
320
+ end
321
+
309
322
  # A tool call's arguments as a plugin sees them: the parsers' args:
310
323
  # (a call built without one, in a spec say: the call without its
311
324
  # name), string keys, typed by +parameters+, frozen.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "model_profile"
4
+
3
5
  module Samagotchi
4
6
  # Escapes literal control-token text when arbitrary source/output is embedded
5
7
  # inside prompts, then restores those placeholders when model text is used.
@@ -13,8 +15,8 @@ module Samagotchi
13
15
  ["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_CLOSE]]", "<tool_response|>"],
14
16
  ["[[SAMAGOTCHI_LITERAL_GEMMA_STRING_DELIM]]", '<|"|>'],
15
17
  ["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<|think|>"],
16
- ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", "<|channel>thought"],
17
- ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", "<channel|>"]
18
+ ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN],
19
+ ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE]
18
20
  ].freeze
19
21
 
20
22
  QWEN_TOKEN_PAIRS = [
@@ -7,6 +7,7 @@ require "time"
7
7
 
8
8
  require_relative "log"
9
9
  require_relative "tools/ask_user_question"
10
+ require_relative "guardrails/parent_approvals"
10
11
 
11
12
  module Samagotchi
12
13
  # An Engine's question flow: the one open question (ask_user_question, a
@@ -21,6 +22,20 @@ module Samagotchi
21
22
  # of ArgumentError for existing callers; transports map it to 409 Conflict.
22
23
  class NotPending < ArgumentError; end
23
24
 
25
+ # Raised by #answer when a parent agent's answer (chi answer's client
26
+ # id) allows an approval further than this worker's
27
+ # guardrails.parent_approvals lets it (Guardrails::ParentApprovals).
28
+ # The question stays open; transports map it to 403.
29
+ class Refused < StandardError
30
+ # @return [Symbol] :off or :once_only
31
+ attr_reader :reason
32
+
33
+ def initialize(reason)
34
+ @reason = reason
35
+ super("a parent may not allow this approval (#{reason})")
36
+ end
37
+ end
38
+
24
39
  DISMISSED_NOTE = "The user dismissed the question without answering. Don't do what you asked about, " \
25
40
  "or anything else that changes files or state. Finish your reply with what you found " \
26
41
  "and what you would do, and wait."
@@ -170,11 +185,16 @@ module Samagotchi
170
185
  # @param id [String] pending id
171
186
  # @param selected [Array<String>] values/labels
172
187
  # @param freeform [String, nil]
188
+ # @param client_id [String, nil] who answers; chi answer's (a parent
189
+ # agent) is held to guardrails.parent_approvals on an approval
173
190
  # @return [Hash] normalized answer
174
- def answer(id:, selected:, freeform: nil)
191
+ # @raise [NotPending, ArgumentError, Refused]
192
+ def answer(id:, selected:, freeform: nil, client_id: nil)
175
193
  sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
176
194
  fm = freeform.to_s.strip
177
195
  fm = nil if fm.empty?
196
+ # Read before the lock (config may touch the disk); this worker's own.
197
+ parent_setting = Guardrails::ParentApprovals.setting if client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID
178
198
  @lock.synchronize do
179
199
  pending = @pending
180
200
  raise NotPending, "no pending question" unless pending
@@ -197,10 +217,17 @@ module Samagotchi
197
217
  if pending[:multi_select] == false && sel.empty? && fm.nil?
198
218
  raise ArgumentError, "selection required"
199
219
  end
220
+ indices = sel.map { |v| opts.index(v) }
221
+ # Checked against the question pending now, under the lock: the one
222
+ # whose answer settles the approval (Approval.settle, by index).
223
+ if parent_setting
224
+ reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
225
+ raise Refused, reason if reason
226
+ end
200
227
  # Persist pending cleared elsewhere; just set answer
201
228
  answer = { id: id.to_s, selected: sel, freeform: fm }
202
229
  # Derive indices for convenience
203
- answer[:selected_indices] = sel.map { |v| opts.index(v) }.compact
230
+ answer[:selected_indices] = indices.compact
204
231
  @answer = answer
205
232
  @cv.broadcast
206
233
  answer
@@ -13,7 +13,8 @@ module Samagotchi
13
13
  # host or model configured it asks the session's current model on its
14
14
  # host, resolved at each attempt the way a turn does (a /model switch
15
15
  # counts). An explicit `recap: {host_ref:, model:}` or `{base_url:,
16
- # model:}` pins it; an incomplete one warns and leaves recap off.
16
+ # model:}` pins it; a model alone goes to its host, or where a bare
17
+ # --model goes; an incomplete one warns and leaves recap off.
17
18
  #
18
19
  # Single precedence path: explicit `recap:` kwarg > Config registry
19
20
  # (CLI > ENV > file > default). An explicit disable (`recap: false` as
@@ -42,14 +43,23 @@ module Samagotchi
42
43
  model = string_config(kwarg_config, :model) || registry_string("recap.model")
43
44
  label = model
44
45
 
46
+ # The model as any model ref (ModelRef): its alias applied once, and
47
+ # its host (own or the alias's) picks the recap host when host_ref
48
+ # doesn't.
49
+ parsed = model && host_registry.model_ref(model)
45
50
  target = nil
46
51
  if base_url.nil? && host_ref.nil? && model.nil?
47
52
  target = -> { session_target.call }
53
+ elsif base_url.nil? && host_ref.nil? && !parsed.host_name
54
+ # A model naming no host goes where a bare --model goes
55
+ # (HostRegistry#host_for_model: the default host unless another's
56
+ # list has it), resolved at each attempt.
57
+ target = lambda do
58
+ resolved = host_registry.resolve(model)
59
+ { base_url: resolved.openai_base_url, api_key_env: resolved.entry.api_key_env,
60
+ model: resolved.bare_model, label: label.to_s.strip }
61
+ end
48
62
  else
49
- # The model as any model ref (ModelRef): its alias applied once, and
50
- # its host (own or the alias's) picks the recap host when host_ref
51
- # doesn't.
52
- parsed = model && host_registry.model_ref(model)
53
63
  host_ref ||= parsed.host_name if parsed && base_url.nil?
54
64
  # If host_ref given, derive base_url (the host's OpenAI base) and its
55
65
  # API key variable from the host_registry entry
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Samagotchi
6
+ # The session's due reminders, on their way into a turn.
7
+ #
8
+ # The ReminderStore is the truth (a reminder is due once its interval
9
+ # passed). This adds the names the idle tick found due and handed to the
10
+ # UI's callback (#note_pending): the worker and the REPL run a reminder
11
+ # turn while they are pending. A turn injects whatever is due then
12
+ # (#inject!), which marks it fired and clears the pending names.
13
+ class ReminderQueue
14
+ # @param store [ReminderStore, nil]
15
+ def initialize(store:)
16
+ @store = store
17
+ @lock = Monitor.new
18
+ @pending = []
19
+ end
20
+
21
+ # @return [Array<Hash>] the reminders due now ({name:, description:, interval_minutes:})
22
+ def due
23
+ @store&.due_reminders || []
24
+ end
25
+
26
+ # @return [Boolean] whether any reminder is due now (the store's view,
27
+ # which #inject! would inject), regardless of the pending names
28
+ def due?
29
+ !due.empty?
30
+ end
31
+
32
+ # @return [Array<String>] the names queued for a reminder turn
33
+ def pending_names
34
+ @lock.synchronize { @pending.dup }
35
+ end
36
+
37
+ # Queue names for a reminder turn (the idle tick's callback).
38
+ def note_pending(names)
39
+ @lock.synchronize { @pending = Array(names).dup }
40
+ end
41
+
42
+ # @return [Boolean] whether names are queued (the idle tick waits meanwhile)
43
+ def pending?
44
+ @lock.synchronize { !@pending.empty? }
45
+ end
46
+
47
+ def clear_pending!
48
+ @lock.synchronize { @pending = [] }
49
+ end
50
+
51
+ # Inject the due reminders into a turn's messages as one tail system
52
+ # message (after the history, before the user's message), mark them
53
+ # all fired under one store lock, and clear the pending names.
54
+ #
55
+ # A tail message, not the head system prompt: changing the head would
56
+ # make the model server re-evaluate the whole conversation's prefix
57
+ # (its KV cache) every interval; a tail costs only the new suffix.
58
+ # @param messages [Array<Hash>] the turn's messages (appended to)
59
+ # @return [Array<Hash>] the reminders injected (maybe none)
60
+ def inject!(messages)
61
+ reminders = due
62
+ return [] if reminders.empty?
63
+
64
+ lines = reminders.map { |r| " #{r[:name]}: #{r[:description]} (interval: #{r[:interval_minutes]}m)" }.join("\n")
65
+ messages << { role: "system", content: "[SYSTEM: REMINDERS DUE]\n#{lines}\n[END REMINDERS]" }
66
+ @store.mark_fired_batch(reminders.map { |r| r[:name] })
67
+ # Without this a turn's injection leaves a stale name, and the next
68
+ # reminder turn would run with nothing due.
69
+ clear_pending!
70
+ reminders
71
+ end
72
+ end
73
+ end
@@ -15,8 +15,8 @@ module Samagotchi
15
15
  #
16
16
  # Reminder lifecycle:
17
17
  # 1. Agent calls register_reminder → store adds entry
18
- # 2. IdleReminders detects due → sets @due_reminder_name on Engine
19
- # 3. Engine#run_turn reads @due_reminder_name, injects [SYSTEM:] message
18
+ # 2. IdleReminders detects due while idle → the UI queues a reminder turn
19
+ # 3. Engine#run_turn injects what is due as a [SYSTEM:] message (ReminderQueue#inject!)
20
20
  # 4. Agent acts on reminder → calls cancel_reminder → store removes entry
21
21
  class ReminderStore
22
22
  # @return [Hash{String => Hash}] id => {id:, name:, description:, interval_minutes:, next_fire_at:}
@@ -90,7 +90,7 @@ module Samagotchi
90
90
 
91
91
  # Get all due reminders whose interval has elapsed.
92
92
  # Returns an Array of {name:, description:, interval_minutes:} hashes.
93
- # Called by Engine#collect_due_reminders (synchronously, at run_turn start).
93
+ # Read by ReminderQueue (a turn's injection, the idle tick).
94
94
  # Thread-safe. Returns a frozen copy so the caller can't mutate internal state.
95
95
  # @return [Array<Hash>] array of due reminder hashes (may be empty)
96
96
  def due_reminders
@@ -72,8 +72,12 @@ module Samagotchi
72
72
  return Result.new(status: :stopped)
73
73
  end
74
74
 
75
+ # Only a question a live worker holds waits for an answer (the
76
+ # lists' rule): one a dead worker left waits for no one, and a chi
77
+ # REPL's is answered there.
75
78
  pending = session.pending_question
76
- if pending && !(baseline && pending[:id] == baseline[:question_id])
79
+ if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
80
+ session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
77
81
  return Result.new(status: :waiting_for_answer, question: pending)
78
82
  end
79
83
 
@@ -111,6 +111,13 @@ module Samagotchi
111
111
  nil
112
112
  end
113
113
 
114
+ # `chi self --model`, the desktop helper's hint: the default as the ref
115
+ # it resolves to (an alias applied, ModelRef), as `chi models` names it.
116
+ def model_ref_name(env: ENV)
117
+ name = model_name
118
+ name && ConfigFile.model_ref(name, env: env).ref
119
+ end
120
+
114
121
  # Routed without model discovery, so an unqualified name may land on
115
122
  # another host at runtime after /models.
116
123
  def loop_for(model, env)
@@ -132,7 +139,7 @@ module Samagotchi
132
139
  return "reported per turn (remote host)" if entry.remote?
133
140
 
134
141
  client = registry.client_for(entry)
135
- props = client.server_props(model: bare) if client.respond_to?(:server_props)
142
+ props = client.server_props(model: bare)
136
143
  return "reported per turn (the server has no /props)" if props.nil?
137
144
  return "unknown (the server didn't answer; is it running?)" if props.status == :network_error
138
145
 
@@ -5,12 +5,14 @@ require_relative "session_inbox"
5
5
  require_relative "session_manager"
6
6
  require_relative "context_quote"
7
7
  require_relative "reply_wait"
8
+ require_relative "parent_report"
8
9
  require_relative "image_store"
9
10
  require_relative "host_registry"
10
11
  require_relative "model_profile"
11
12
  require_relative "vision_support"
12
13
  require_relative "cli/command"
13
14
  require_relative "cli/flags"
15
+ require_relative "cli/parent_wait"
14
16
 
15
17
  module Samagotchi
16
18
  # `chi send`: put text into sessions as the user's message, the same as
@@ -22,20 +24,17 @@ module Samagotchi
22
24
  # prints it: an agent's one-shot the user can watch in the web.
23
25
  class SendCommand
24
26
  include CLI::Command
27
+ include CLI::ParentWait
25
28
 
26
29
  CLIENT_ID = "cli:send"
27
- POLL_INTERVAL = ReplyWait::POLL_INTERVAL
28
- # No live worker this long while waiting: it died before it could mark
29
- # the session (a worker takes well under a second to start).
30
- WORKER_GONE_AFTER = 5
31
30
  # The cap on one turn's images the Bridge checks.
32
31
  MAX_IMAGES = ImageStore::MAX_TURN_REFS
33
32
 
34
33
  USAGE = <<~TEXT
35
34
  Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
36
35
  chi send --new [--dir DIR] [--model M] [-m TEXT] [--image PATH]...
37
- chi send --wait [--timeout S] [-m TEXT] [--image PATH]... (--new | ID)
38
- chi send --wait [--timeout S] ID
36
+ chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
37
+ chi send --wait [--timeout S] [--format json] ID
39
38
  Sends a message to each session, as if typed in it: a turn starts,
40
39
  or a running one picks it up. A stopped session's worker starts.
41
40
  -m TEXT the message; stdin, when piped too, goes above it as a
@@ -53,11 +52,21 @@ module Samagotchi
53
52
  --model M (--new) its model; default the configured one
54
53
  --wait wait for the answer and print it (one session); the
55
54
  other lines go to stderr. Exit 3: it waits for an
56
- answer from you (chi --attach ID or the web). With
57
- no message (no -m, nothing piped) nothing is sent: it
58
- waits for the session's next reply, a running turn's
59
- too (after exit 3 or 130, wait again this way)
60
- --timeout S (--wait) give up after S seconds; default no limit
55
+ answer from you: the question, its options and the
56
+ chi answer command go to stderr (or chi --attach ID,
57
+ the web). With no message (no -m, nothing piped)
58
+ nothing is sent: it waits for the session's next
59
+ reply, a running turn's too (after exit 3, 4 or 130,
60
+ wait again this way)
61
+ --timeout S (--wait) give up after S seconds, exit 4 (the turn
62
+ goes on); default no limit
63
+ --format json
64
+ (--wait) one JSON object on stdout, whatever the end:
65
+ status answered (text), question (question,
66
+ answer_with), running, or failed, canceled,
67
+ no_answer, error, worker_gone, stopped (detail)
68
+ Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
69
+ question waits, 4 still running (--timeout), 130 Ctrl-C.
61
70
  Only sessions on this machine. Answers show in the attached TUI
62
71
  or web page, not here.
63
72
  Find ids with: chi sessions list --live [--scope=all] [--format tsv]
@@ -71,6 +80,7 @@ module Samagotchi
71
80
  f.value "--image", key: :images, repeat: true
72
81
  f.switch "--wait"
73
82
  f.value "--timeout"
83
+ f.value "--format"
74
84
  # Starting a turn in every live session at once is too easy to do
75
85
  # by accident.
76
86
  f.refuse "--all", "there is no --all: name the sessions"
@@ -95,7 +105,7 @@ module Samagotchi
95
105
 
96
106
  profile = ModelProfile.resolve(names: names, entry: entry, client: target.client, bare_model: target.bare_model).profile
97
107
  answer = VisionSupport.for(target, profile: profile, names: names)
98
- props = target.client.respond_to?(:server_props) ? target.client.server_props(model: target.bare_model) : nil
108
+ props = target.client.server_props(model: target.bare_model)
99
109
  return UNKNOWN_VISION if answer.no? && !props&.answered? && !configured_no?(answer)
100
110
 
101
111
  answer
@@ -119,13 +129,13 @@ module Samagotchi
119
129
  @state_dir = state_dir || Session.default_state_dir
120
130
  end
121
131
 
122
- # @return [Integer] exit status: 0 all sent, 1 any refused or failed,
123
- # 2 usage; with --wait 0 answered, 3 waiting for an answer, 130
124
- # Ctrl-C (the turn goes on)
125
- def run
126
- options = parse
127
- return options if options.is_a?(Integer)
132
+ private
128
133
 
134
+ # Under #run (CLI::ParentWait).
135
+ # @return [Integer] exit status: 0 all sent, 1 any refused or failed,
136
+ # 2 usage; with --wait 0 answered, 3 waiting for an answer, 4 still
137
+ # running after --timeout, 130 Ctrl-C (the turn goes on)
138
+ def run_parsed(options)
129
139
  # With --wait stdout is the answer alone.
130
140
  @info = options[:wait] ? @stderr : @stdout
131
141
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
@@ -140,7 +150,9 @@ module Samagotchi
140
150
  @stderr.puts("chi send: #{e.message}")
141
151
  return 1
142
152
  end
143
- return 2 unless ingest_images(options[:images])
153
+ # A file that is not there or not an image: a run-time failure (1),
154
+ # reported in the --format json line too; --image itself was fine.
155
+ return CLI::Exit::FAILED unless ingest_images(options[:images])
144
156
 
145
157
  return run_new(prompt, options) if options[:new]
146
158
  return run_wait(prompt, options) if options[:wait]
@@ -160,8 +172,6 @@ module Samagotchi
160
172
  FileUtils.rm_rf(@image_dir) if @image_dir
161
173
  end
162
174
 
163
- private
164
-
165
175
  def command_name = "chi send"
166
176
 
167
177
  # @return [Hash, Integer] the options, or the exit status after the
@@ -171,6 +181,10 @@ module Samagotchi
171
181
  return parsed if parsed.is_a?(Integer)
172
182
 
173
183
  options = parsed.options.merge(ids: parsed.args)
184
+ if options[:format]
185
+ return usage_error("--format needs --wait") unless options[:wait]
186
+ return usage_error("--format takes text or json") unless FORMATS.include?(options[:format])
187
+ end
174
188
  if options[:timeout]
175
189
  return usage_error("--timeout needs --wait") unless options[:wait]
176
190
 
@@ -220,16 +234,6 @@ module Samagotchi
220
234
  @stdin.read
221
235
  end
222
236
 
223
- def resolve(given)
224
- id = Session.resolve_id(given, state_dir: @state_dir)
225
- Session.load(id, state_dir: @state_dir)
226
- id
227
- rescue ArgumentError => e
228
- message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
229
- error_line("chi send: #{message}")
230
- nil
231
- end
232
-
233
237
  # A worker session like the web start page's: saved as running with the
234
238
  # message before its worker spawns, so lists and the web show it at
235
239
  # once. The full id, so a script can pass it on. Only the model's host
@@ -257,6 +261,7 @@ module Samagotchi
257
261
  else
258
262
  @info.puts("#{session.id} started")
259
263
  end
264
+ @session_id = session.id
260
265
  return 0 unless options[:wait]
261
266
 
262
267
  wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
@@ -272,8 +277,20 @@ module Samagotchi
272
277
  def run_wait(prompt, options)
273
278
  id = resolve(options[:ids].first) or return 1
274
279
  session = Session.load(id, state_dir: @state_dir)
275
- if session.status == Session::STATUS_RUNNING && SessionManager.session_owner(id, state_dir: @state_dir)
276
- error_line("#{id[0, 8]} busy: a turn is running; wait or attach")
280
+ if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir))
281
+ # The turn waits on a question: the message would only queue
282
+ # behind it, and the answer goes in with chi answer.
283
+ if (waiting = session.waiting_question(live: owner.worker?))
284
+ if ParentReport.approval?(waiting)
285
+ error_line("#{id[0, 8]} waiting for an approval: #{ParentReport::DENY_AND_TELL} " \
286
+ "(#{ParentReport.answer_with(id, waiting)})")
287
+ else
288
+ error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, waiting)} (or --text); " \
289
+ "chi --attach #{id} shows it")
290
+ end
291
+ else
292
+ error_line("#{id[0, 8]} busy: a turn is running; wait or attach")
293
+ end
277
294
  return 1
278
295
  end
279
296
 
@@ -298,7 +315,7 @@ module Samagotchi
298
315
  live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
299
316
  wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
300
317
  baseline: baseline_of(session).merge(messages: nil),
301
- timeout: options[:timeout], owner_grace: live ? WORKER_GONE_AFTER : nil)
318
+ timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil)
302
319
  end
303
320
 
304
321
  # The session as it was before the message went in (ReplyWait's
@@ -307,48 +324,6 @@ module Samagotchi
307
324
  ReplyWait.baseline_of(session, **options)
308
325
  end
309
326
 
310
- # @return [Integer] the exit status
311
- def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: WORKER_GONE_AFTER)
312
- result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
313
- owner_grace: owner_grace, poll_interval: POLL_INTERVAL)
314
- return reply(result.text) if result.status == :done
315
-
316
- attach = "chi --attach #{id}"
317
- line, status = case result.status
318
- when :waiting_for_answer
319
- question = result.question[:question].to_s.strip.lines.first.to_s.strip
320
- ["waiting for an answer: #{question}; open it: #{attach} or the web", 3]
321
- when :no_reply then ["#{no_reply_line(result)}; #{attach} shows it", 1]
322
- when :error then ["the worker failed: #{result.text}; #{attach} shows what happened", 1]
323
- when :worker_gone then ["the worker is gone; #{attach} shows what happened", 1]
324
- when :stopped then ["the session was stopped (chi sessions stop)", 1]
325
- else ["still running after #{format("%g", timeout)} s: #{attach}", 1]
326
- end
327
- error_line("chi send: #{line}")
328
- status
329
- rescue Interrupt
330
- error_line("chi send: still running: chi --attach #{id}")
331
- 130
332
- rescue ArgumentError
333
- error_line("chi send: the session is gone (deleted while waiting)")
334
- 1
335
- end
336
-
337
- def no_reply_line(result)
338
- case result.outcome
339
- when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
340
- when "canceled" then "the turn was canceled"
341
- when "completed" then "the turn ended with no visible answer"
342
- else "the turn ended without a reply (canceled, failed or empty)"
343
- end
344
- end
345
-
346
- def reply(text)
347
- @stdout.puts(utf8(text))
348
- @stdout.flush
349
- 0
350
- end
351
-
352
327
  # A new idle session's first turn, with the images. Its worker's Bridge
353
328
  # first: deliver_turn wakes a worker when none owns the session yet,
354
329
  # and the spawned one takes its lock only once it runs.
@@ -99,6 +99,21 @@ module Samagotchi
99
99
  @archived = false
100
100
  end
101
101
 
102
+ # The question this session waits on, as the lists show it: {id:, kind:}
103
+ # with kind "question" (the model's), "approval" or "hook"; nil with
104
+ # none. Only while a worker runs it (+live+): a question saved by a
105
+ # worker that died waits for no one (the next worker drops it). The
106
+ # web's summaries and `chi sessions list` both ask this.
107
+ # @param live [Boolean] a worker owns the session now
108
+ # @return [Hash, nil]
109
+ def waiting_question(live:)
110
+ pending = pending_question
111
+ return nil unless live && pending.is_a?(Hash) && pending[:id]
112
+
113
+ kind = pending[:kind].to_s
114
+ { id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question" }
115
+ end
116
+
102
117
  # The stored project root, else (a file from before the field) the
103
118
  # project of the working directory now: nil when that is in no repo or
104
119
  # gone. +cache+ is ProjectScope.root_for's, shared across one listing.
@@ -404,7 +404,7 @@ module Samagotchi
404
404
 
405
405
  def persist_session_model
406
406
  session = @engine.session
407
- return unless session.respond_to?(:model_name=)
407
+ return unless session
408
408
 
409
409
  @engine.store_model!(session)
410
410
  begin
@@ -416,7 +416,7 @@ module Samagotchi
416
416
 
417
417
  # "; served: <name>" when the server serves another model than asked.
418
418
  def served_note
419
- served, asked = @engine.respond_to?(:served_model) ? @engine.served_model : nil
419
+ served, asked = @engine.served_model
420
420
  ServedModel.differs?(asked, served) ? "; served: #{served}" : ""
421
421
  rescue StandardError
422
422
  ""
@@ -424,7 +424,7 @@ module Samagotchi
424
424
 
425
425
  # "; sampling: temperature=0.6 (hosts.work)" when the model has any.
426
426
  def sampling_note
427
- summary = @engine.respond_to?(:sampling_summary) ? @engine.sampling_summary : nil
427
+ summary = @engine.sampling_summary
428
428
  summary ? "; sampling: #{summary}" : ""
429
429
  rescue StandardError
430
430
  ""
@@ -432,7 +432,7 @@ module Samagotchi
432
432
 
433
433
  # "; thinking: off (models: qwen)" when the model has a level set.
434
434
  def thinking_note
435
- summary = @engine.respond_to?(:thinking_summary) ? @engine.thinking_summary : nil
435
+ summary = @engine.thinking_summary
436
436
  summary ? "; thinking: #{summary}" : ""
437
437
  rescue StandardError
438
438
  ""
@@ -447,7 +447,7 @@ module Samagotchi
447
447
  end
448
448
 
449
449
  def chat_model?
450
- @engine.respond_to?(:chat_model?) && @engine.chat_model?
450
+ @engine.chat_model?
451
451
  rescue StandardError
452
452
  false
453
453
  end