samagotchi 0.4.0 → 0.5.1

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/README.md +13 -2
  4. data/bin/chi +29 -39
  5. data/docs/cli.md +135 -73
  6. data/docs/configuration.md +15 -20
  7. data/docs/hooks.md +1 -1
  8. data/docs/memory.md +40 -0
  9. data/docs/plugins.md +50 -0
  10. data/docs/releasing.md +9 -6
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +16 -11
  16. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  17. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
  19. data/lib/samagotchi/bundles/system/identity.md +5 -0
  20. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  21. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  22. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  23. data/lib/samagotchi/client.rb +16 -20
  24. data/lib/samagotchi/config.rb +40 -100
  25. data/lib/samagotchi/desktop/macos/Panel.swift +16 -3
  26. data/lib/samagotchi/engine.rb +50 -354
  27. data/lib/samagotchi/kernel_loop.rb +33 -44
  28. data/lib/samagotchi/live_versions.rb +7 -1
  29. data/lib/samagotchi/llm/errors.rb +17 -0
  30. data/lib/samagotchi/llm/http.rb +4 -18
  31. data/lib/samagotchi/llm/openai_chat.rb +29 -2
  32. data/lib/samagotchi/model_profile.rb +4 -10
  33. data/lib/samagotchi/note_command.rb +2 -1
  34. data/lib/samagotchi/reply_wait.rb +57 -4
  35. data/lib/samagotchi/self_report.rb +20 -2
  36. data/lib/samagotchi/send_command.rb +81 -6
  37. data/lib/samagotchi/session.rb +4 -2
  38. data/lib/samagotchi/session_manager.rb +18 -37
  39. data/lib/samagotchi/system_prompt.rb +403 -0
  40. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  41. data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
  42. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  43. data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
  44. data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
  45. data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
  46. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  47. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  48. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  49. data/lib/samagotchi/terminal_ui.rb +95 -690
  50. data/lib/samagotchi/thinking.rb +11 -0
  51. data/lib/samagotchi/tool_activity.rb +52 -2
  52. data/lib/samagotchi/tool_runner.rb +3 -0
  53. data/lib/samagotchi/tools/delegate.rb +2 -1
  54. data/lib/samagotchi/tools/delegate_wait.rb +26 -8
  55. data/lib/samagotchi/tools/execute.rb +3 -3
  56. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  57. data/lib/samagotchi/tools/read.rb +4 -4
  58. data/lib/samagotchi/update_command.rb +2 -1
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +186 -37
  61. data/lib/samagotchi/web/lan.rb +99 -0
  62. data/lib/samagotchi/web/message_parts.rb +15 -11
  63. data/lib/samagotchi/web/public/activity.js +7 -0
  64. data/lib/samagotchi/web/public/app.js +99 -54
  65. data/lib/samagotchi/web/public/chat_view.js +5 -1
  66. data/lib/samagotchi/web/public/index.html +163 -17
  67. data/lib/samagotchi/web/public/model_pick.js +136 -0
  68. data/lib/samagotchi/web/public/model_picker.js +224 -0
  69. data/lib/samagotchi/web/public/notify.js +10 -0
  70. data/lib/samagotchi/web/public/stage_model.js +110 -0
  71. data/lib/samagotchi/web/public/stage_view.js +580 -0
  72. data/lib/samagotchi/web/public/timing.js +6 -2
  73. data/lib/samagotchi/web/public/turn_events.js +9 -5
  74. data/lib/samagotchi/web/public/turn_model.js +11 -3
  75. data/lib/samagotchi/web/public/turn_view.js +86 -19
  76. data/lib/samagotchi/web/qr.rb +40 -0
  77. data/lib/samagotchi/web/server.rb +101 -11
  78. data/lib/samagotchi/web/token.rb +97 -0
  79. metadata +27 -3
  80. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -40,9 +40,15 @@ module Samagotchi
40
40
  # The version a chi web on host:port runs, or nil when nothing (or not
41
41
  # chi web) answers.
42
42
  def web_version(host, port, timeout: WEB_TIMEOUT)
43
+ web_info(host, port, timeout: timeout)&.fetch("version", nil).to_s.then { |v| v.empty? ? nil : v }
44
+ end
45
+
46
+ # The /api/info of a chi web on host:port, or nil when nothing (or not
47
+ # chi web) answers.
48
+ def web_info(host, port, timeout: WEB_TIMEOUT)
43
49
  response = Net::HTTP.start(host, port, open_timeout: timeout, read_timeout: timeout) { |http| http.get("/api/info") }
44
50
  info = response.code.to_i == 200 ? JSON.parse(response.body.to_s) : nil
45
- info.is_a?(Hash) && info["app"] == "chi-web" ? info["version"].to_s : nil
51
+ info.is_a?(Hash) && info["app"] == "chi-web" ? info : nil
46
52
  rescue StandardError
47
53
  nil
48
54
  end
@@ -283,9 +283,26 @@ module Samagotchi
283
283
  return nil
284
284
  end
285
285
 
286
+ from_error_json(raw, host: host)
287
+ end
288
+
289
+ # The error a 200's whole body carries when it is a plain JSON error
290
+ # object instead of a stream (OpenRouter's "503 inside a 200":
291
+ # {"error":{"code":503,"message":"…"}}), or nil for anything else.
292
+ def from_error_body(body, host:)
293
+ parsed = parse_json(body.to_s)
294
+ return nil unless parsed.is_a?(Hash) && parsed.key?("error")
295
+
296
+ from_error_json(body.to_s, host: host)
297
+ end
298
+
299
+ # The ProviderError for a JSON error object, by its error.code (an HTTP
300
+ # status); 500 when it has none.
301
+ def from_error_json(raw, host:)
286
302
  parsed = parse_json(raw)
287
303
  error = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : parsed
288
304
  code = error.is_a?(Hash) ? error["code"] : nil
305
+ code = code.to_i if code.is_a?(String) && code.match?(/\A\d{3}\z/)
289
306
  status = code.is_a?(Integer) && code.between?(400, 599) ? code : 500
290
307
  from_response(status: status, body: raw, host: host)
291
308
  end
@@ -21,9 +21,6 @@ module Samagotchi
21
21
  # the listener falls back to raising RequestCancelled in the requesting
22
22
  # thread, as Client did before.
23
23
  class HTTP
24
- RETRY_MAX_ENV = "SAMAGOTCHI_RETRY_MAX"
25
- RETRY_BASE_DELAY_ENV = "SAMAGOTCHI_RETRY_BASE_DELAY"
26
- RETRY_MAX_DELAY_ENV = "SAMAGOTCHI_RETRY_MAX_DELAY"
27
24
  DEFAULT_RETRY_MAX = 5
28
25
  DEFAULT_RETRY_BASE_DELAY = 0.5
29
26
  DEFAULT_RETRY_MAX_DELAY = 8.0
@@ -46,15 +43,14 @@ module Samagotchi
46
43
  # Exponential backoff: base_delay * 2^(attempt-1), capped at max_delay,
47
44
  # for up to +max+ retries (max + 1 attempts).
48
45
  RetryPolicy = Data.define(:max, :base_delay, :max_delay) do
49
- # retry.* from config, then the SAMAGOTCHI_RETRY_* env, then defaults.
46
+ # retry.* (Config); a value out of range is the default.
50
47
  def self.from_config
51
48
  new(
52
- max: config_value("retry.max") { |v| v.is_a?(Integer) && v >= 0 } ||
53
- env_integer(RETRY_MAX_ENV, DEFAULT_RETRY_MAX),
49
+ max: config_value("retry.max") { |v| v.is_a?(Integer) && v >= 0 } || DEFAULT_RETRY_MAX,
54
50
  base_delay: config_value("retry.base_delay") { |v| v.is_a?(Numeric) && v.positive? }&.to_f ||
55
- env_float(RETRY_BASE_DELAY_ENV, DEFAULT_RETRY_BASE_DELAY),
51
+ DEFAULT_RETRY_BASE_DELAY,
56
52
  max_delay: config_value("retry.max_delay") { |v| v.is_a?(Numeric) && v.positive? }&.to_f ||
57
- env_float(RETRY_MAX_DELAY_ENV, DEFAULT_RETRY_MAX_DELAY)
53
+ DEFAULT_RETRY_MAX_DELAY
58
54
  )
59
55
  end
60
56
 
@@ -67,16 +63,6 @@ module Samagotchi
67
63
  nil
68
64
  end
69
65
 
70
- def self.env_integer(name, default)
71
- value = ENV.fetch(name, default.to_s).to_i
72
- value.negative? ? default : value
73
- end
74
-
75
- def self.env_float(name, default)
76
- value = ENV.fetch(name, default.to_s).to_f
77
- value.positive? ? value : default
78
- end
79
-
80
66
  # Seconds to wait before retrying after failed attempt +attempt+
81
67
  # (1-based), or nil when the budget is spent.
82
68
  def delay_for(attempt)
@@ -44,6 +44,8 @@ module Samagotchi
44
44
  # only; the chat loop owns the conversation and the tools.
45
45
  class OpenAIChat
46
46
  MAX_MODEL_PAGES = 20
47
+ # How much of a 200's non-stream body is read as a possible JSON error.
48
+ PLAIN_ERROR_LIMIT = 64 * 1024
47
49
  DEFAULT_MODELS_TTL = 60
48
50
 
49
51
  attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout
@@ -120,15 +122,18 @@ module Samagotchi
120
122
  assembly = Assembly.new
121
123
  events = 0
122
124
  other = +""
125
+ plain = +""
123
126
  # A retry streams the answer from the start again.
124
127
  restart = lambda do |**event|
125
128
  assembly = Assembly.new
129
+ plain = +""
126
130
  on_retry&.call(**event)
127
131
  end
128
132
  @http.stream_lines(URI(request.uri.to_s), request, cancel_controller: cancel_controller, on_retry: restart,
129
133
  log_fields: log_fields) do |line, shown|
130
134
  events += 1 if line.start_with?("data:")
131
135
  other << line[0, 200] if !line.start_with?("data:") && other.length < 200
136
+ raise_plain_error(plain, line) if events.zero?
132
137
  payload = parse_line(line)
133
138
  next unless payload
134
139
 
@@ -213,7 +218,8 @@ module Samagotchi
213
218
  SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
214
219
  end
215
220
 
216
- # A String stays a String; an Array of parts passes as given.
221
+ # A String stays a String; an Array of parts passes as given, its text
222
+ # parts scrubbed like a String.
217
223
  def wire_message(message)
218
224
  wire = message.to_h.transform_keys(&:to_sym)
219
225
  wire[:content] = format_content(wire[:content]) if wire.key?(:content)
@@ -221,11 +227,20 @@ module Samagotchi
221
227
  end
222
228
 
223
229
  def format_content(content)
224
- return content if content.is_a?(Array) || content.nil?
230
+ return content if content.nil?
231
+ return content.map { |part| scrub_part(part) } if content.is_a?(Array)
225
232
 
226
233
  scrub(content.to_s)
227
234
  end
228
235
 
236
+ def scrub_part(part)
237
+ return part unless part.is_a?(Hash)
238
+
239
+ part.to_h do |key, value|
240
+ [key, key.to_s == "text" && value.is_a?(String) ? scrub(value) : value]
241
+ end
242
+ end
243
+
229
244
  # Invalid UTF-8 (a tool's garbled output) would make to_json raise.
230
245
  def scrub(text)
231
246
  text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
@@ -256,6 +271,18 @@ module Samagotchi
256
271
 
257
272
  # The parsed payload of a `data:` line; nil for blank lines, comments
258
273
  # and [DONE]. Error events raise their ProviderError.
274
+ # A 200 whose body is a plain JSON error instead of a stream (OpenRouter
275
+ # sends an upstream 503 so): raised, from inside the stream so it is
276
+ # retried by its kind, once the lines so far parse as an error object.
277
+ def raise_plain_error(plain, line)
278
+ return if line.empty? || line.start_with?(":") || (plain.empty? && !line.start_with?("{"))
279
+ return if plain.bytesize > PLAIN_ERROR_LIMIT
280
+
281
+ plain << line << "\n"
282
+ error = ProviderErrors.from_error_body(plain, host: @host_name)
283
+ raise error if error
284
+ end
285
+
259
286
  def parse_line(line)
260
287
  error = HTTP.sse_error(line, host: @host_name)
261
288
  raise error if error
@@ -116,18 +116,12 @@ module Samagotchi
116
116
  end
117
117
  end
118
118
 
119
- def self.required_model_name(model_name = nil, env: ENV)
119
+ def self.required_model_name(model_name = nil)
120
120
  value = model_name.to_s.strip
121
121
  if value.empty?
122
- begin
123
- require_relative "config"
124
- cfg = Samagotchi::Config.get("default.model") rescue nil
125
- value = cfg.to_s.strip unless cfg.nil? || cfg.to_s.strip.empty?
126
- rescue StandardError
127
- nil
128
- end
122
+ require_relative "config"
123
+ value = Samagotchi::Config.get("default.model").to_s.strip
129
124
  end
130
- value = env[MODEL_ENV].to_s.strip if value.empty?
131
125
  raise MissingModel, missing_model_message if value.empty?
132
126
 
133
127
  value
@@ -181,7 +175,7 @@ module Samagotchi
181
175
  end
182
176
 
183
177
  def self.from_env
184
- from_model_name(required_model_name(nil, env: ENV))
178
+ from_model_name(required_model_name(nil))
185
179
  end
186
180
 
187
181
  # ── Resolution: which profile a model gets ───────────────────────────
@@ -102,7 +102,8 @@ module Samagotchi
102
102
 
103
103
  # --all: every live session. @return [Integer] exit status
104
104
  def deliver_to_live(text, source)
105
- ids = SessionManager.session_summaries(live: true, include_tests: false, state_dir: @state_dir).map { |s| s[:id] }
105
+ ids = SessionManager.session_summaries(live: true, include_tests: Session.test_session_env?,
106
+ state_dir: @state_dir).map { |s| s[:id] }
106
107
  if ids.empty?
107
108
  error_line("chi note: no live sessions (chi sessions list --live --scope=all)")
108
109
  return 1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "session"
4
+ require_relative "turn_note"
4
5
 
5
6
  module Samagotchi
6
7
  # Loaded on first use: session_manager requires terminal_ui, which
@@ -23,7 +24,11 @@ module Samagotchi
23
24
  # @!attribute file [String, nil] the reply's filename, the next cursor
24
25
  # @!attribute question [Hash, nil] the pending question
25
26
  # (:waiting_for_answer)
26
- Result = Struct.new(:status, :text, :file, :question, keyword_init: true)
27
+ # @!attribute outcome [String, nil] how the turn ended when it left no
28
+ # reply (:no_reply): "failed", "canceled" or "completed" (empty), from
29
+ # the session's last_turn; nil when unknown. With "failed", text is
30
+ # the failure's summary when the turn note has one.
31
+ Result = Struct.new(:status, :text, :file, :question, :outcome, keyword_init: true)
27
32
 
28
33
  module_function
29
34
 
@@ -38,7 +43,10 @@ module Samagotchi
38
43
  # messages (a failed, canceled or empty one leaves its note) and went
39
44
  # idle again ends the wait even if it was never seen running, and a
40
45
  # question already pending then is not the answer's. A nil
41
- # messages: skips the count.
46
+ # messages: skips the count. last_turn: (the session's
47
+ # last_turn["ended_at"] then, maybe nil) ends the wait on any turn
48
+ # that ended since, idle again, however fast it ran: a failure after
49
+ # a failure replaces the note and leaves the count as it was.
42
50
  # @param owner_grace [Numeric, nil] seconds with no live worker before
43
51
  # :worker_gone (one that died before its rescue leaves it running)
44
52
  # @return [Result]
@@ -72,13 +80,13 @@ module Samagotchi
72
80
 
73
81
  if session.status == Session::STATUS_RUNNING
74
82
  seen_running = true
75
- elsif seen_running || (baseline&.dig(:messages) && session.messages.size > baseline[:messages])
83
+ elsif seen_running || turn_ended_since?(session, baseline)
76
84
  # It ran and is idle again with no new reply: canceled, failed or
77
85
  # empty. Before it was ever seen running, idle means a
78
86
  # file-delivered message its worker has not picked up yet.
79
87
  # The worker writes the reply before its idle save, so the read
80
88
  # above has it; one more look costs nothing should that change.
81
- return reply_past(id, state_dir: state_dir, cursor: cursor) || Result.new(status: :no_reply)
89
+ return reply_past(id, state_dir: state_dir, cursor: cursor) || no_reply(session, baseline)
82
90
  end
83
91
 
84
92
  if owner_grace
@@ -95,6 +103,51 @@ module Samagotchi
95
103
  end
96
104
  end
97
105
 
106
+ # The session as it was before a message went in (call's baseline:):
107
+ # its messages, the question pending then, and when its last turn
108
+ # ended, so a turn that ends before the first look still ends the wait.
109
+ # @return [Hash]
110
+ def baseline_of(session, question_id: session.pending_question&.dig(:id))
111
+ last = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
112
+ { messages: session.messages.size, question_id: question_id, last_turn: last }
113
+ end
114
+
115
+ # An idle session whose turn ended after the baseline was taken: its
116
+ # last_turn moved on, or its messages grew (a turn's note).
117
+ def turn_ended_since?(session, baseline)
118
+ return false unless baseline
119
+
120
+ if baseline.key?(:last_turn)
121
+ ended = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
122
+ return true if ended && ended != baseline[:last_turn]
123
+ end
124
+ !!(baseline[:messages] && session.messages.size > baseline[:messages])
125
+ end
126
+
127
+ # @return [Result] :no_reply with the outcome the session's last_turn
128
+ # records, when it is the turn waited for
129
+ def no_reply(session, baseline)
130
+ last = session.last_turn.is_a?(Hash) ? session.last_turn : {}
131
+ fresh = baseline.nil? || !baseline.key?(:last_turn) || (last["ended_at"] && last["ended_at"] != baseline[:last_turn])
132
+ outcome = fresh ? last["outcome"] : nil
133
+ text = outcome == "failed" ? failure_summary(session.messages) : nil
134
+ Result.new(status: :no_reply, outcome: outcome, text: text)
135
+ end
136
+
137
+ # The summary in the failed-turn note at the conversation's tail, nil
138
+ # without one.
139
+ def failure_summary(messages)
140
+ list = Array(messages)
141
+ index = TurnNote.trailing_index(list)
142
+ return nil unless index
143
+
144
+ text = (list[index][:content] || list[index]["content"]).to_s
145
+ prefix = "#{TurnNote::OPEN}#{TurnNote::FAILED}"
146
+ return nil unless text.start_with?(prefix)
147
+
148
+ text[prefix.length..].sub(/\. [^.]*\.\]\z/, "")
149
+ end
150
+
98
151
  # @return [Result, nil] :done with the newest reply past the cursor
99
152
  def reply_past(id, state_dir:, cursor:)
100
153
  file = newest_reply(id, state_dir: state_dir)
@@ -16,12 +16,15 @@ require_relative "memory_bundle/provenance"
16
16
  require_relative "memory_bundle/manifest"
17
17
  require_relative "memory_bundle/system_bundle"
18
18
  require_relative "desktop"
19
+ require_relative "live_versions"
20
+ require_relative "web/lan"
19
21
 
20
22
  module Samagotchi
21
23
  # `chi self`: where this chi lives and what it is configured to use.
22
24
  #
23
25
  # Read-only and nearly offline (one short /props GET for the served model,
24
- # nothing else asks a model server), so the agent can run it via `execute`
26
+ # nothing else asks a model server; one /api/info GET on this machine's
27
+ # chi web port), so the agent can run it via `execute`
25
28
  # to orient itself — source dir to rg, config path, memory dirs, sessions,
26
29
  # model/host, bundle versions — without guessing from $PATH.
27
30
  module SelfReport
@@ -55,10 +58,25 @@ module Samagotchi
55
58
  ["served model", model ? served_model_for(model, env) : "-"],
56
59
  ["context window", context_window(env)],
57
60
  ["bundles", bundles_summary],
58
- ["desktop", desktop_summary(env)]
61
+ ["desktop", desktop_summary(env)],
62
+ ["chi web", web_summary]
59
63
  ]
60
64
  end
61
65
 
66
+ WEB_TIMEOUT = 0.3
67
+
68
+ # Whether a chi web answers on this machine's web port, and whether a
69
+ # phone can reach it (LAN mode: /api/info's lan).
70
+ def web_summary
71
+ port = Config.get("web.port").to_i
72
+ port = 4567 unless port.positive?
73
+ info = LiveVersions.web_info(Web::Lan.local_host(Config.get("web.host")), port, timeout: WEB_TIMEOUT)
74
+ return "not running on port #{port}" unless info
75
+ return "LAN on #{info["lan"]}:#{port} (and 127.0.0.1)" if info["lan"]
76
+
77
+ "on 127.0.0.1:#{port} (this machine only)"
78
+ end
79
+
62
80
  # "git checkout" when running from a repo (bin/chi), "installed gem" when
63
81
  # under a gem path; the installed gem's files are not meant to be edited.
64
82
  def install_kind(dir = SOURCE_DIR)
@@ -5,6 +5,9 @@ require_relative "session_manager"
5
5
  require_relative "context_quote"
6
6
  require_relative "reply_wait"
7
7
  require_relative "image_store"
8
+ require_relative "host_registry"
9
+ require_relative "model_profile"
10
+ require_relative "vision_support"
8
11
 
9
12
  module Samagotchi
10
13
  # `chi send`: put text into sessions as the user's message, the same as
@@ -36,7 +39,8 @@ module Samagotchi
36
39
  an image sent with the message (png, jpeg, gif, webp;
37
40
  others converted, large ones downscaled); repeat for
38
41
  more, up to 20. Needs text too (-m or stdin) and a
39
- model that sees images
42
+ model that sees images: a session whose model is
43
+ known not to is refused before anything is sent
40
44
  --new start a new session with the message instead, as the
41
45
  web does, and print its id
42
46
  --dir DIR (--new) its folder, the project it belongs to; default
@@ -54,6 +58,40 @@ module Samagotchi
54
58
  Find ids with: chi sessions list --live [--scope=all] [--format tsv]
55
59
  TEXT
56
60
 
61
+ # Whether +model_name+ (a session's; blank: the configured default)
62
+ # takes images, decided as its worker decides before a turn with images
63
+ # (Engine#turn_vision: VisionSupport, with models.<name>.vision under
64
+ # the name as given (maybe an alias), the alias's target, the part
65
+ # after a host prefix and the bare name, as after /model). A native
66
+ # host whose /props doesn't answer is unknown here, not a no: the
67
+ # worker asks again when the turn runs.
68
+ # @return [VisionSupport::Answer] value nil when unknown
69
+ def self.vision_answer(model_name, registry: nil)
70
+ registry ||= HostRegistry.new
71
+ name = ModelProfile.required_model_name(model_name)
72
+ aliased = ConfigFile.resolve_model_alias(name)
73
+ target = registry.resolve(aliased)
74
+ names = [name, aliased, registry.parse_qualified_model(name).last, target.bare_model].compact.uniq
75
+ entry = target.entry
76
+ return VisionSupport.for(target, adapter: registry.adapter_for(entry), names: names) if entry.chat?
77
+
78
+ profile = ModelProfile.resolve(names: names, entry: entry, client: target.client, bare_model: target.bare_model).profile
79
+ answer = VisionSupport.for(target, profile: profile, names: names)
80
+ props = target.client.respond_to?(:server_props) ? target.client.server_props(model: target.bare_model) : nil
81
+ return UNKNOWN_VISION if answer.no? && !props&.answered? && !configured_no?(answer)
82
+
83
+ answer
84
+ rescue StandardError
85
+ UNKNOWN_VISION
86
+ end
87
+
88
+ UNKNOWN_VISION = VisionSupport::Answer.new(value: nil, reason: nil)
89
+
90
+ # A no from the config (models: / hosts: vision: false) holds without
91
+ # the server.
92
+ def self.configured_no?(answer) = answer.reason.to_s.include?("sets vision: false")
93
+ private_class_method :configured_no?
94
+
57
95
  # @param argv [Array<String>] the arguments after "send"
58
96
  def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil)
59
97
  @argv = argv.dup
@@ -205,6 +243,10 @@ module Samagotchi
205
243
  # @return [Integer] the exit status
206
244
  def run_new(prompt, options)
207
245
  images = !@images.empty?
246
+ if images && (refusal = vision_refusal(options[:model]))
247
+ error_line("chi send: refused: #{refusal}")
248
+ return 1
249
+ end
208
250
  begin
209
251
  # With images the web's way: idle (the message names it in the
210
252
  # lists), the images copied in, then the message as a turn.
@@ -222,7 +264,7 @@ module Samagotchi
222
264
  end
223
265
  return 0 unless options[:wait]
224
266
 
225
- wait_for_reply(session.id, cursor: nil, baseline: { messages: session.messages.size, question_id: nil },
267
+ wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
226
268
  timeout: options[:timeout])
227
269
  end
228
270
 
@@ -241,7 +283,7 @@ module Samagotchi
241
283
  end
242
284
 
243
285
  cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
244
- baseline = { messages: session.messages.size, question_id: session.pending_question&.dig(:id) }
286
+ baseline = baseline_of(session)
245
287
  return 1 unless deliver(id, prompt)
246
288
 
247
289
  wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
@@ -260,10 +302,16 @@ module Samagotchi
260
302
  session = Session.load(id, state_dir: @state_dir)
261
303
  live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
262
304
  wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
263
- baseline: { messages: nil, question_id: session.pending_question&.dig(:id) },
305
+ baseline: baseline_of(session).merge(messages: nil),
264
306
  timeout: options[:timeout], owner_grace: live ? WORKER_GONE_AFTER : nil)
265
307
  end
266
308
 
309
+ # The session as it was before the message went in (ReplyWait's
310
+ # baseline).
311
+ def baseline_of(session, **options)
312
+ ReplyWait.baseline_of(session, **options)
313
+ end
314
+
267
315
  # @return [Integer] the exit status
268
316
  def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: WORKER_GONE_AFTER)
269
317
  result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
@@ -275,7 +323,7 @@ module Samagotchi
275
323
  when :waiting_for_answer
276
324
  question = result.question[:question].to_s.strip.lines.first.to_s.strip
277
325
  ["waiting for an answer: #{question}; open it: #{attach} or the web", 3]
278
- when :no_reply then ["the turn ended without a reply (canceled, failed or empty); #{attach} shows it", 1]
326
+ when :no_reply then ["#{no_reply_line(result)}; #{attach} shows it", 1]
279
327
  when :error then ["the worker failed: #{result.text}; #{attach} shows what happened", 1]
280
328
  when :worker_gone then ["the worker is gone; #{attach} shows what happened", 1]
281
329
  when :stopped then ["the session was stopped (chi sessions stop)", 1]
@@ -291,6 +339,15 @@ module Samagotchi
291
339
  1
292
340
  end
293
341
 
342
+ def no_reply_line(result)
343
+ case result.outcome
344
+ when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
345
+ when "canceled" then "the turn was canceled"
346
+ when "completed" then "the turn ended with no visible answer"
347
+ else "the turn ended without a reply (canceled, failed or empty)"
348
+ end
349
+ end
350
+
294
351
  def reply(text)
295
352
  @stdout.puts(utf8(text))
296
353
  @stdout.flush
@@ -332,8 +389,13 @@ module Samagotchi
332
389
  # @return [Boolean] whether the message was queued
333
390
  def deliver(id, prompt)
334
391
  short = id[0, 8]
392
+ session = Session.load(id, state_dir: @state_dir)
393
+ if !@images.empty? && (refusal = vision_refusal(session.model_name))
394
+ @info.puts("#{short} refused: #{refusal}")
395
+ return false
396
+ end
335
397
  owner = SessionManager.session_owner(id, state_dir: @state_dir)
336
- running = owner && Session.load(id, state_dir: @state_dir).status == Session::STATUS_RUNNING
398
+ running = owner && session.status == Session::STATUS_RUNNING
337
399
  refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
338
400
  result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir)
339
401
  unless result[:status] == :accepted
@@ -356,6 +418,19 @@ module Samagotchi
356
418
  false
357
419
  end
358
420
 
421
+ # What to say when +model_name+ is known not to take images, or nil
422
+ # (it does, or it is unknown: sent as before). Asked once per model.
423
+ def vision_refusal(model_name)
424
+ @vision ||= {}
425
+ answer = @vision[model_name.to_s] ||= self.class.vision_answer(model_name)
426
+ return nil unless answer.no?
427
+
428
+ name = model_name.to_s.strip
429
+ name = Samagotchi::Config.get("default.model").to_s.strip if name.empty?
430
+ name = "the model" if name.empty?
431
+ "#{name} can't take images (#{answer.reason}); send text only or switch the model (/model)"
432
+ end
433
+
359
434
  # Each --image read, converted and downscaled once, into a scratch
360
435
  # session dir; each target then gets copies of the stored files. A file
361
436
  # that can't be sent stops everything before anything is sent.
@@ -22,11 +22,13 @@ module Samagotchi
22
22
  STATUS_ERROR = "error"
23
23
  STATUS_STOPPED = "stopped"
24
24
 
25
- # Retention / ordering defaults (14 days, 500 sessions) — env overrides in SessionManager.
25
+ # Retention defaults, the session.retention_days / max_count /
26
+ # keep_status defaults (SessionManager resolves the settings).
26
27
  DEFAULT_RETENTION_DAYS = 14
27
28
  DEFAULT_MAX_COUNT = 500
28
29
  # status is turn state; a live owner (the prune alive_check) is what
29
- # protects a session in use, so no status is kept by default.
30
+ # protects a session in use, so no status is kept by default: a
31
+ # "running" left by a crashed worker must not keep it forever.
30
32
  DEFAULT_KEEP_STATUS = [].freeze
31
33
  SORT_KEYS = %w[created_at updated_at].freeze
32
34
  SORT_ORDERS = %w[asc desc].freeze
@@ -173,10 +173,10 @@ module Samagotchi
173
173
  end
174
174
 
175
175
  # Build the opts hash passed to Process.spawn for a forked worker. The
176
- # child inherits this process's ENV; opts[:env] adds to it (merged, not
177
- # replaced) the values a worker can't read from its own config: the
178
- # hosts, default model and log settings as this `chi` resolved them
179
- # (CLI flags included).
176
+ # child inherits this process's ENV and reads config.yml itself (as it
177
+ # is when the worker starts); opts[:env] adds to it (merged, not
178
+ # replaced) what it can't read there: this `chi`'s CLI settings
179
+ # (Config.cli_env), the hosts and the absolute log file.
180
180
  private_class_method def self.spawn_options(session)
181
181
  # Own process group: workers outlive `chi web`, and a Ctrl-C in its
182
182
  # terminal must not reach them.
@@ -198,11 +198,9 @@ module Samagotchi
198
198
  rescue StandardError
199
199
  nil
200
200
  end
201
- # Also propagate current default model (may be host-qualified)
202
- child_env["SAMAGOTCHI_DEFAULT_MODEL"] = ENV["SAMAGOTCHI_DEFAULT_MODEL"] if ENV["SAMAGOTCHI_DEFAULT_MODEL"]
203
- # A worker gets no CLI args: pass on an idle exit set by any layer.
204
- idle_exit = config_idle_exit_minutes
205
- child_env["SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES"] = idle_exit.to_s unless idle_exit.nil?
201
+ # A worker gets no CLI args: --model, --thinking, --log-level and the
202
+ # other flags this chi was started with.
203
+ child_env.merge!(Config.cli_env)
206
204
  # And the spawner's debug log, absolute: a relative log.file would
207
205
  # otherwise land in the worker's (the session's) directory.
208
206
  log_path = begin LogPath.resolve rescue nil end
@@ -211,9 +209,6 @@ module Samagotchi
211
209
  else
212
210
  child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
213
211
  end
214
- # And its level (a --log-level flag isn't in the worker's own config).
215
- level = begin Config.get("log.level") rescue nil end
216
- child_env["SAMAGOTCHI_LOG_LEVEL"] = level.to_s if level
217
212
  opts[:env] = child_env unless child_env.empty?
218
213
  opts
219
214
  end
@@ -246,7 +241,7 @@ module Samagotchi
246
241
  # @param include_archived [Boolean] archived sessions too
247
242
  # @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
248
243
  # updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
249
- # parent_short_id:, archived:, scratch:}; busy = live with
244
+ # parent_short_id:, archived:, scratch:, test_run:}; busy = live with
250
245
  # a turn running, recap = the saved recap's first sentence, project =
251
246
  # Session#project_root
252
247
  def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
@@ -268,7 +263,7 @@ module Samagotchi
268
263
  owner: owner, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
269
264
  ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
270
265
  parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), archived: s.archived,
271
- scratch: s.scratch }
266
+ scratch: s.scratch, test_run: s.test_run }
272
267
  end
273
268
  (limit ? summaries.first(limit) : summaries.to_a)
274
269
  end
@@ -455,12 +450,8 @@ module Samagotchi
455
450
  sd = state_dir || Session.default_state_dir
456
451
  return unless Dir.exist?(sd)
457
452
 
458
- cfg_interval = begin Samagotchi::Config.get("session.sweep_interval_hours") rescue nil end
459
- interval = if cfg_interval && cfg_interval.to_i.positive?
460
- cfg_interval.to_i * 3600
461
- else
462
- (ENV.fetch("SAMAGOTCHI_SESSION_SWEEP_INTERVAL_HOURS", RETENTION_SWEEP_INTERVAL_HOURS.to_s).to_i * 3600)
463
- end
453
+ hours = Samagotchi::Config.get("session.sweep_interval_hours").to_i
454
+ interval = (hours.positive? ? hours : RETENTION_SWEEP_INTERVAL_HOURS) * 3600
464
455
  marker = File.join(sd, RETENTION_MARKER)
465
456
  if File.exist?(marker)
466
457
  age = Time.now - File.mtime(marker)
@@ -477,32 +468,22 @@ module Samagotchi
477
468
  nil
478
469
  end
479
470
 
471
+ # The caller's value (a sessions prune flag) or session.retention_days.
480
472
  private_class_method def self.resolve_retention_days(val)
481
473
  return val.to_i if !val.nil? && val.to_s.strip != ""
482
- cfg = begin Samagotchi::Config.get("session.retention_days") rescue nil end
483
- return cfg.to_i if cfg && !cfg.to_s.strip.empty?
484
- env = ENV["SAMAGOTCHI_SESSION_RETENTION_DAYS"]
485
- return env.to_i if env && !env.strip.empty?
486
- Session::DEFAULT_RETENTION_DAYS
474
+
475
+ Samagotchi::Config.get("session.retention_days").to_i
487
476
  end
488
477
 
489
478
  private_class_method def self.resolve_retention_max_count(val)
490
479
  return val.to_i if !val.nil? && val.to_s.strip != ""
491
- cfg = begin Samagotchi::Config.get("session.max_count") rescue nil end
492
- return cfg.to_i if cfg && !cfg.to_s.strip.empty?
493
- env = ENV["SAMAGOTCHI_SESSION_MAX_COUNT"]
494
- return env.to_i if env && !env.strip.empty?
495
- Session::DEFAULT_MAX_COUNT
480
+
481
+ Samagotchi::Config.get("session.max_count").to_i
496
482
  end
497
483
 
484
+ # A comma list of statuses never pruned; "" keeps none.
498
485
  private_class_method def self.resolve_retention_keep_status(val)
499
- raw = if !val.nil? && val.to_s.strip != ""
500
- val.to_s
501
- else
502
- cfg = begin Samagotchi::Config.get("session.keep_status") rescue nil end
503
- cfg && !cfg.to_s.strip.empty? ? cfg.to_s : (ENV["SAMAGOTCHI_SESSION_KEEP_STATUS"] || ENV["SAMAGOTCHI_SESSION_RETENTION_KEEP_STATUS"])
504
- end
505
- return Session::DEFAULT_KEEP_STATUS if raw.nil? || raw.strip.empty?
486
+ raw = !val.nil? && val.to_s.strip != "" ? val.to_s : Samagotchi::Config.get("session.keep_status").to_s
506
487
  raw.split(",").map(&:strip).reject(&:empty?)
507
488
  end
508
489