samagotchi 0.4.0 → 0.5.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -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/engine.rb +50 -354
  26. data/lib/samagotchi/kernel_loop.rb +33 -44
  27. data/lib/samagotchi/live_versions.rb +7 -1
  28. data/lib/samagotchi/llm/errors.rb +17 -0
  29. data/lib/samagotchi/llm/http.rb +4 -18
  30. data/lib/samagotchi/llm/openai_chat.rb +17 -0
  31. data/lib/samagotchi/model_profile.rb +4 -10
  32. data/lib/samagotchi/note_command.rb +2 -1
  33. data/lib/samagotchi/reply_wait.rb +48 -4
  34. data/lib/samagotchi/self_report.rb +20 -2
  35. data/lib/samagotchi/send_command.rb +84 -6
  36. data/lib/samagotchi/session.rb +4 -2
  37. data/lib/samagotchi/session_manager.rb +18 -37
  38. data/lib/samagotchi/system_prompt.rb +403 -0
  39. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
  41. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
  43. data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
  44. data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
  45. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  46. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  47. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  48. data/lib/samagotchi/terminal_ui.rb +95 -690
  49. data/lib/samagotchi/thinking.rb +11 -0
  50. data/lib/samagotchi/tool_activity.rb +52 -2
  51. data/lib/samagotchi/tool_runner.rb +3 -0
  52. data/lib/samagotchi/tools/execute.rb +3 -3
  53. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  54. data/lib/samagotchi/tools/read.rb +4 -4
  55. data/lib/samagotchi/update_command.rb +2 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +170 -35
  58. data/lib/samagotchi/web/lan.rb +99 -0
  59. data/lib/samagotchi/web/message_parts.rb +15 -11
  60. data/lib/samagotchi/web/public/activity.js +7 -0
  61. data/lib/samagotchi/web/public/app.js +99 -54
  62. data/lib/samagotchi/web/public/chat_view.js +5 -1
  63. data/lib/samagotchi/web/public/index.html +163 -17
  64. data/lib/samagotchi/web/public/model_pick.js +136 -0
  65. data/lib/samagotchi/web/public/model_picker.js +224 -0
  66. data/lib/samagotchi/web/public/notify.js +10 -0
  67. data/lib/samagotchi/web/public/stage_model.js +110 -0
  68. data/lib/samagotchi/web/public/stage_view.js +580 -0
  69. data/lib/samagotchi/web/public/timing.js +6 -2
  70. data/lib/samagotchi/web/public/turn_events.js +9 -5
  71. data/lib/samagotchi/web/public/turn_model.js +11 -3
  72. data/lib/samagotchi/web/public/turn_view.js +74 -19
  73. data/lib/samagotchi/web/qr.rb +40 -0
  74. data/lib/samagotchi/web/server.rb +101 -11
  75. data/lib/samagotchi/web/token.rb +97 -0
  76. metadata +27 -3
  77. 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
 
@@ -256,6 +261,18 @@ module Samagotchi
256
261
 
257
262
  # The parsed payload of a `data:` line; nil for blank lines, comments
258
263
  # and [DONE]. Error events raise their ProviderError.
264
+ # A 200 whose body is a plain JSON error instead of a stream (OpenRouter
265
+ # sends an upstream 503 so): raised, from inside the stream so it is
266
+ # retried by its kind, once the lines so far parse as an error object.
267
+ def raise_plain_error(plain, line)
268
+ return if line.empty? || line.start_with?(":") || (plain.empty? && !line.start_with?("{"))
269
+ return if plain.bytesize > PLAIN_ERROR_LIMIT
270
+
271
+ plain << line << "\n"
272
+ error = ProviderErrors.from_error_body(plain, host: @host_name)
273
+ raise error if error
274
+ end
275
+
259
276
  def parse_line(line)
260
277
  error = HTTP.sse_error(line, host: @host_name)
261
278
  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,42 @@ module Samagotchi
95
103
  end
96
104
  end
97
105
 
106
+ # An idle session whose turn ended after the baseline was taken: its
107
+ # last_turn moved on, or its messages grew (a turn's note).
108
+ def turn_ended_since?(session, baseline)
109
+ return false unless baseline
110
+
111
+ if baseline.key?(:last_turn)
112
+ ended = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
113
+ return true if ended && ended != baseline[:last_turn]
114
+ end
115
+ !!(baseline[:messages] && session.messages.size > baseline[:messages])
116
+ end
117
+
118
+ # @return [Result] :no_reply with the outcome the session's last_turn
119
+ # records, when it is the turn waited for
120
+ def no_reply(session, baseline)
121
+ last = session.last_turn.is_a?(Hash) ? session.last_turn : {}
122
+ fresh = baseline.nil? || !baseline.key?(:last_turn) || (last["ended_at"] && last["ended_at"] != baseline[:last_turn])
123
+ outcome = fresh ? last["outcome"] : nil
124
+ text = outcome == "failed" ? failure_summary(session.messages) : nil
125
+ Result.new(status: :no_reply, outcome: outcome, text: text)
126
+ end
127
+
128
+ # The summary in the failed-turn note at the conversation's tail, nil
129
+ # without one.
130
+ def failure_summary(messages)
131
+ list = Array(messages)
132
+ index = TurnNote.trailing_index(list)
133
+ return nil unless index
134
+
135
+ text = (list[index][:content] || list[index]["content"]).to_s
136
+ prefix = "#{TurnNote::OPEN}#{TurnNote::FAILED}"
137
+ return nil unless text.start_with?(prefix)
138
+
139
+ text[prefix.length..].sub(/\. [^.]*\.\]\z/, "")
140
+ end
141
+
98
142
  # @return [Result, nil] :done with the newest reply past the cursor
99
143
  def reply_past(id, state_dir:, cursor:)
100
144
  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,19 @@ 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): its messages, the question pending then, and when its last
311
+ # turn ended, so a turn that ends before the first look still ends the
312
+ # wait.
313
+ def baseline_of(session, question_id: session.pending_question&.dig(:id))
314
+ last = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
315
+ { messages: session.messages.size, question_id: question_id, last_turn: last }
316
+ end
317
+
267
318
  # @return [Integer] the exit status
268
319
  def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: WORKER_GONE_AFTER)
269
320
  result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
@@ -275,7 +326,7 @@ module Samagotchi
275
326
  when :waiting_for_answer
276
327
  question = result.question[:question].to_s.strip.lines.first.to_s.strip
277
328
  ["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]
329
+ when :no_reply then ["#{no_reply_line(result)}; #{attach} shows it", 1]
279
330
  when :error then ["the worker failed: #{result.text}; #{attach} shows what happened", 1]
280
331
  when :worker_gone then ["the worker is gone; #{attach} shows what happened", 1]
281
332
  when :stopped then ["the session was stopped (chi sessions stop)", 1]
@@ -291,6 +342,15 @@ module Samagotchi
291
342
  1
292
343
  end
293
344
 
345
+ def no_reply_line(result)
346
+ case result.outcome
347
+ when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
348
+ when "canceled" then "the turn was canceled"
349
+ when "completed" then "the turn ended with no visible answer"
350
+ else "the turn ended without a reply (canceled, failed or empty)"
351
+ end
352
+ end
353
+
294
354
  def reply(text)
295
355
  @stdout.puts(utf8(text))
296
356
  @stdout.flush
@@ -332,8 +392,13 @@ module Samagotchi
332
392
  # @return [Boolean] whether the message was queued
333
393
  def deliver(id, prompt)
334
394
  short = id[0, 8]
395
+ session = Session.load(id, state_dir: @state_dir)
396
+ if !@images.empty? && (refusal = vision_refusal(session.model_name))
397
+ @info.puts("#{short} refused: #{refusal}")
398
+ return false
399
+ end
335
400
  owner = SessionManager.session_owner(id, state_dir: @state_dir)
336
- running = owner && Session.load(id, state_dir: @state_dir).status == Session::STATUS_RUNNING
401
+ running = owner && session.status == Session::STATUS_RUNNING
337
402
  refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
338
403
  result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir)
339
404
  unless result[:status] == :accepted
@@ -356,6 +421,19 @@ module Samagotchi
356
421
  false
357
422
  end
358
423
 
424
+ # What to say when +model_name+ is known not to take images, or nil
425
+ # (it does, or it is unknown: sent as before). Asked once per model.
426
+ def vision_refusal(model_name)
427
+ @vision ||= {}
428
+ answer = @vision[model_name.to_s] ||= self.class.vision_answer(model_name)
429
+ return nil unless answer.no?
430
+
431
+ name = model_name.to_s.strip
432
+ name = Samagotchi::Config.get("default.model").to_s.strip if name.empty?
433
+ name = "the model" if name.empty?
434
+ "#{name} can't take images (#{answer.reason}); send text only or switch the model (/model)"
435
+ end
436
+
359
437
  # Each --image read, converted and downscaled once, into a scratch
360
438
  # session dir; each target then gets copies of the stored files. A file
361
439
  # 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