samagotchi 0.3.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -7,6 +7,7 @@ require_relative "context_window"
7
7
  require_relative "session"
8
8
  require_relative "log_path"
9
9
  require_relative "model_profile"
10
+ require_relative "thinking"
10
11
  require_relative "served_model"
11
12
  require_relative "host_registry"
12
13
  require_relative "tools/memory"
@@ -15,12 +16,15 @@ require_relative "memory_bundle/provenance"
15
16
  require_relative "memory_bundle/manifest"
16
17
  require_relative "memory_bundle/system_bundle"
17
18
  require_relative "desktop"
19
+ require_relative "live_versions"
20
+ require_relative "web/lan"
18
21
 
19
22
  module Samagotchi
20
23
  # `chi self`: where this chi lives and what it is configured to use.
21
24
  #
22
25
  # Read-only and nearly offline (one short /props GET for the served model,
23
- # 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`
24
28
  # to orient itself — source dir to rg, config path, memory dirs, sessions,
25
29
  # model/host, bundle versions — without guessing from $PATH.
26
30
  module SelfReport
@@ -50,13 +54,29 @@ module Samagotchi
50
54
  ["api key", model ? api_key_for(model, env) : "-"],
51
55
  ["loop", model ? loop_for(model, env) : "-"],
52
56
  ["profile", model ? profile_for(model, env) : "-"],
57
+ ["thinking", model ? thinking_for(model, env) : "-"],
53
58
  ["served model", model ? served_model_for(model, env) : "-"],
54
59
  ["context window", context_window(env)],
55
60
  ["bundles", bundles_summary],
56
- ["desktop", desktop_summary(env)]
61
+ ["desktop", desktop_summary(env)],
62
+ ["chi web", web_summary]
57
63
  ]
58
64
  end
59
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
+
60
80
  # "git checkout" when running from a repo (bin/chi), "installed gem" when
61
81
  # under a gem path; the installed gem's files are not meant to be edited.
62
82
  def install_kind(dir = SOURCE_DIR)
@@ -150,6 +170,15 @@ module Samagotchi
150
170
  end
151
171
  end
152
172
 
173
+ # The model's thinking level and where it came from (Thinking.resolve).
174
+ def thinking_for(model, env)
175
+ target = HostRegistry.new(env: env).resolve(model)
176
+ level, source = Thinking.resolve(target, models: ConfigFile.model_settings(env: env))
177
+ source ? "#{level} (#{source})" : level.to_s
178
+ rescue StandardError => e
179
+ "(unknown: #{e.message})"
180
+ end
181
+
153
182
  def host_for(model, env)
154
183
  entry, bare = HostRegistry.new(env: env).host_for_model(model)
155
184
  return "(no hosts configured)" unless entry
@@ -170,14 +199,17 @@ module Samagotchi
170
199
  end
171
200
 
172
201
  # The Chi Helper app's version against this chi's (read from its
173
- # Info.plist; no process or Services checks: chi desktop status has those).
202
+ # Info.plist and launch file; no process or Services checks: chi desktop
203
+ # status has those). An older app is fine while its sources are unchanged.
174
204
  def desktop_summary(env)
175
205
  return "- (macOS only)" unless Desktop.supported?
176
206
 
177
- version = Desktop::MacOS.new(env: env).app_version
207
+ helper = Desktop::MacOS.new(env: env)
208
+ version = helper.app_version
178
209
  return "not installed" unless version
210
+ return "#{version} (matches)" if version == VERSION
179
211
 
180
- version == VERSION ? "#{version} (matches)" : "#{version} (chi is #{VERSION}: chi desktop upgrade)"
212
+ helper.stale? ? "#{version} (chi is #{VERSION}: chi update)" : "#{version} (up to date for chi #{VERSION})"
181
213
  end
182
214
 
183
215
  # "samagotchi-system 0.1.5 (shipped 0.1.5), other 1.0.0"
@@ -4,6 +4,10 @@ require_relative "session"
4
4
  require_relative "session_manager"
5
5
  require_relative "context_quote"
6
6
  require_relative "reply_wait"
7
+ require_relative "image_store"
8
+ require_relative "host_registry"
9
+ require_relative "model_profile"
10
+ require_relative "vision_support"
7
11
 
8
12
  module Samagotchi
9
13
  # `chi send`: put text into sessions as the user's message, the same as
@@ -19,16 +23,24 @@ module Samagotchi
19
23
  # No live worker this long while waiting: it died before it could mark
20
24
  # the session (a worker takes well under a second to start).
21
25
  WORKER_GONE_AFTER = 5
26
+ # The Bridge's cap on one turn's images (Bridge::MAX_TURN_IMAGES).
27
+ MAX_IMAGES = 20
22
28
 
23
29
  USAGE = <<~TEXT
24
- Usage: chi send [-m TEXT] (ID|PREFIX)...
25
- chi send --new [--dir DIR] [--model M] [-m TEXT]
26
- chi send --wait [--timeout S] [-m TEXT] (--new | ID)
30
+ Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
31
+ chi send --new [--dir DIR] [--model M] [-m TEXT] [--image PATH]...
32
+ chi send --wait [--timeout S] [-m TEXT] [--image PATH]... (--new | ID)
27
33
  chi send --wait [--timeout S] ID
28
34
  Sends a message to each session, as if typed in it: a turn starts,
29
35
  or a running one picks it up. A stopped session's worker starts.
30
36
  -m TEXT the message; stdin, when piped too, goes above it as a
31
37
  quote (context); without -m, stdin is the message
38
+ --image PATH
39
+ an image sent with the message (png, jpeg, gif, webp;
40
+ others converted, large ones downscaled); repeat for
41
+ more, up to 20. Needs text too (-m or stdin) and a
42
+ model that sees images: a session whose model is
43
+ known not to is refused before anything is sent
32
44
  --new start a new session with the message instead, as the
33
45
  web does, and print its id
34
46
  --dir DIR (--new) its folder, the project it belongs to; default
@@ -46,6 +58,40 @@ module Samagotchi
46
58
  Find ids with: chi sessions list --live [--scope=all] [--format tsv]
47
59
  TEXT
48
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
+
49
95
  # @param argv [Array<String>] the arguments after "send"
50
96
  def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil)
51
97
  @argv = argv.dup
@@ -65,6 +111,11 @@ module Samagotchi
65
111
  # With --wait stdout is the answer alone.
66
112
  @info = options[:wait] ? @stderr : @stdout
67
113
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
114
+ # Before the wait-only case, which would drop the images.
115
+ if prompt.nil? && !options[:images].empty?
116
+ usage_error("--image needs a message: pass -m TEXT or pipe it in")
117
+ return 2
118
+ end
68
119
  return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
69
120
  unless prompt
70
121
  usage_error("no message: pass -m TEXT or pipe it in")
@@ -76,6 +127,7 @@ module Samagotchi
76
127
  @stderr.puts("chi send: #{e.message}")
77
128
  return 1
78
129
  end
130
+ return 2 unless ingest_images(options[:images])
79
131
 
80
132
  return run_new(prompt, options) if options[:new]
81
133
  return run_wait(prompt, options) if options[:wait]
@@ -91,12 +143,14 @@ module Samagotchi
91
143
  deliver(id, prompt)
92
144
  end
93
145
  results.all? ? 0 : 1
146
+ ensure
147
+ FileUtils.rm_rf(@image_dir) if @image_dir
94
148
  end
95
149
 
96
150
  private
97
151
 
98
152
  def parse
99
- options = { ids: [] }
153
+ options = { ids: [], images: [] }
100
154
  until @argv.empty?
101
155
  arg = @argv.shift
102
156
  case arg
@@ -110,6 +164,8 @@ module Samagotchi
110
164
  when "--dir", "--model"
111
165
  options[arg.delete_prefix("--").to_sym] = @argv.shift or return usage_error("#{arg} needs a value")
112
166
  when /\A--(dir|model)=(.*)\z/m then options[Regexp.last_match(1).to_sym] = Regexp.last_match(2)
167
+ when "--image" then options[:images] << (@argv.shift or return usage_error("#{arg} needs a value"))
168
+ when /\A--image=(.*)\z/m then options[:images] << Regexp.last_match(1)
113
169
  when "--wait" then options[:wait] = true
114
170
  when "--timeout" then options[:timeout] = @argv.shift or return usage_error("#{arg} needs a value")
115
171
  when /\A--timeout=(.*)\z/ then options[:timeout] = Regexp.last_match(1)
@@ -127,6 +183,7 @@ module Samagotchi
127
183
  return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
128
184
  end
129
185
  return usage_error("--wait takes one session") if options[:wait] && options[:ids].uniq.size > 1
186
+ return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
130
187
  return new_options(options) if options[:new]
131
188
  %i[dir model].each { |key| return usage_error("--#{key} needs --new") if options[key] }
132
189
  return usage_error("give session ids") if options[:ids].empty?
@@ -180,21 +237,34 @@ module Samagotchi
180
237
 
181
238
  # A worker session like the web start page's: saved as running with the
182
239
  # message before its worker spawns, so lists and the web show it at
183
- # once. The full id, so a script can pass it on. The model name isn't
184
- # checked here (nor in the web): a wrong one fails in the worker.
240
+ # once. The full id, so a script can pass it on. Only the model's host
241
+ # is checked here (in spawn_session, as in the web): a wrong model id
242
+ # fails in the worker.
185
243
  # @return [Integer] the exit status
186
244
  def run_new(prompt, options)
245
+ images = !@images.empty?
246
+ if images && (refusal = vision_refusal(options[:model]))
247
+ error_line("chi send: refused: #{refusal}")
248
+ return 1
249
+ end
187
250
  begin
188
- session = SessionManager.spawn_session(prompt: prompt, working_directory: options[:dir],
189
- model_name: options[:model], state_dir: @state_dir)
251
+ # With images the web's way: idle (the message names it in the
252
+ # lists), the images copied in, then the message as a turn.
253
+ start = images ? { prompt: nil, title: prompt } : { prompt: prompt }
254
+ session = SessionManager.spawn_session(**start, working_directory: options[:dir],
255
+ model_name: options[:model], state_dir: @state_dir)
190
256
  rescue StandardError => e
191
257
  error_line("chi send: could not start a session: #{e.message}")
192
258
  return 1
193
259
  end
194
- @info.puts("#{session.id} started")
260
+ if images
261
+ return 1 unless deliver_new(session, prompt)
262
+ else
263
+ @info.puts("#{session.id} started")
264
+ end
195
265
  return 0 unless options[:wait]
196
266
 
197
- 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),
198
268
  timeout: options[:timeout])
199
269
  end
200
270
 
@@ -213,7 +283,7 @@ module Samagotchi
213
283
  end
214
284
 
215
285
  cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
216
- baseline = { messages: session.messages.size, question_id: session.pending_question&.dig(:id) }
286
+ baseline = baseline_of(session)
217
287
  return 1 unless deliver(id, prompt)
218
288
 
219
289
  wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
@@ -232,10 +302,19 @@ module Samagotchi
232
302
  session = Session.load(id, state_dir: @state_dir)
233
303
  live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
234
304
  wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
235
- baseline: { messages: nil, question_id: session.pending_question&.dig(:id) },
305
+ baseline: baseline_of(session).merge(messages: nil),
236
306
  timeout: options[:timeout], owner_grace: live ? WORKER_GONE_AFTER : nil)
237
307
  end
238
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
+
239
318
  # @return [Integer] the exit status
240
319
  def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: WORKER_GONE_AFTER)
241
320
  result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
@@ -247,7 +326,7 @@ module Samagotchi
247
326
  when :waiting_for_answer
248
327
  question = result.question[:question].to_s.strip.lines.first.to_s.strip
249
328
  ["waiting for an answer: #{question}; open it: #{attach} or the web", 3]
250
- 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]
251
330
  when :error then ["the worker failed: #{result.text}; #{attach} shows what happened", 1]
252
331
  when :worker_gone then ["the worker is gone; #{attach} shows what happened", 1]
253
332
  when :stopped then ["the session was stopped (chi sessions stop)", 1]
@@ -263,27 +342,76 @@ module Samagotchi
263
342
  1
264
343
  end
265
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
+
266
354
  def reply(text)
267
355
  @stdout.puts(utf8(text))
268
356
  @stdout.flush
269
357
  0
270
358
  end
271
359
 
360
+ # A new idle session's first turn, with the images. Its worker's Bridge
361
+ # first: deliver_turn wakes a worker when none owns the session yet,
362
+ # and the spawned one takes its lock only once it runs.
363
+ # @return [Boolean] whether the message was queued
364
+ def deliver_new(session, prompt)
365
+ dir = Session.session_dir(session.id, state_dir: @state_dir)
366
+ result = nil
367
+ begin
368
+ refs = copy_images(dir)
369
+ if BridgeClient.wait_for(session.id, session_dir: dir, timeout: SessionManager::TURN_BRIDGE_WAIT)
370
+ result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id: CLIENT_ID, images: refs,
371
+ state_dir: @state_dir)
372
+ end
373
+ rescue StandardError => e
374
+ @info.puts("#{session.id} failed: #{e.message}")
375
+ return false
376
+ end
377
+ # Kept, not deleted: it holds the images, and the message shows as
378
+ # its preview; the user can attach and send it again.
379
+ unless result
380
+ @info.puts("#{session.id} failed: its worker did not start; the session is kept (chi --attach #{session.id})")
381
+ return false
382
+ end
383
+ unless result[:status] == :accepted
384
+ @info.puts("#{session.id} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
385
+ return false
386
+ end
387
+
388
+ @info.puts("#{session.id} started#{with_images}")
389
+ true
390
+ end
391
+
272
392
  # @return [Boolean] whether the message was queued
273
393
  def deliver(id, prompt)
274
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
275
400
  owner = SessionManager.session_owner(id, state_dir: @state_dir)
276
- running = owner && Session.load(id, state_dir: @state_dir).status == Session::STATUS_RUNNING
277
- result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, state_dir: @state_dir)
401
+ running = owner && session.status == Session::STATUS_RUNNING
402
+ refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
403
+ result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir)
278
404
  unless result[:status] == :accepted
279
405
  @info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
280
406
  return false
281
407
  end
282
408
 
409
+ # A busy worker runs a message with images as its own next turn
410
+ # rather than merging it into the running one.
283
411
  note = if owner.nil? then " (started its worker)"
284
- elsif running then " (the running turn picks it up)"
412
+ elsif running then refs.empty? ? " (the running turn picks it up)" : " (runs after the current turn)"
285
413
  end
286
- @info.puts("#{short} sent#{note}")
414
+ @info.puts("#{short} sent#{with_images}#{note}")
287
415
  true
288
416
  rescue SessionManager::OwnedByTUI
289
417
  @info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
@@ -293,6 +421,51 @@ module Samagotchi
293
421
  false
294
422
  end
295
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
+
437
+ # Each --image read, converted and downscaled once, into a scratch
438
+ # session dir; each target then gets copies of the stored files. A file
439
+ # that can't be sent stops everything before anything is sent.
440
+ # @return [Boolean] false after the error line
441
+ def ingest_images(paths)
442
+ @images = []
443
+ return true if paths.empty?
444
+
445
+ @image_dir = Dir.mktmpdir("chi-send-images")
446
+ @images = paths.map { |path| ImageStore.ingest(@image_dir, path: path) }
447
+ true
448
+ rescue ImageStore::Error => e
449
+ error_line("chi send: #{e.message}")
450
+ false
451
+ end
452
+
453
+ # The images' files copied into a session dir, as the refs a turn takes.
454
+ def copy_images(session_dir)
455
+ @images.map do |ref|
456
+ ImageStore.copy_file(ref, from: @image_dir, to: session_dir)
457
+ { file: ref[:file], name: ref[:name] }
458
+ end
459
+ end
460
+
461
+ def with_images
462
+ case @images.size
463
+ when 0 then ""
464
+ when 1 then " with 1 image"
465
+ else " with #{@images.size} images"
466
+ end
467
+ end
468
+
296
469
  # The text as UTF-8 whatever the locale says: with no LANG/LC_* (an app
297
470
  # started from Finder, launchd) stdin reads as US-ASCII and ARGV as
298
471
  # binary. Invalid bytes become U+FFFD rather than an error.
@@ -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
@@ -15,8 +15,10 @@ module Samagotchi
15
15
  # the Engine and its TurnFlow; the host prints the result's output and
16
16
  # runs a continue turn when asked to (#run never runs a turn).
17
17
  #
18
- # /stats, /recap and /exit are the UI's own: they are in the registry
19
- # (local: for Tab and help) but #run never runs them.
18
+ # /stats, /recap, /exit, /quit, /archive and /detach are the terminal
19
+ # UIs' own: they are in the registry (local:) for Tab and help, and so
20
+ # both TUIs read the same words (Registry#lookup_local gives the entry,
21
+ # its id says what to do), but #run never runs them.
20
22
  class SessionCommands
21
23
  MODEL_COMMAND = "/model"
22
24
  MODELS_COMMAND = "/models"
@@ -30,6 +32,8 @@ module Samagotchi
30
32
  SHELL_BANG_PREFIX = "!"
31
33
  RESERVED_MODEL_ARGS = %w[clear default none off].freeze
32
34
  ALIAS_USAGE = "usage /model <model> --alias <name> [--default]"
35
+ # /exit --delete: delete the session on the way out.
36
+ EXIT_DELETE_FLAG = "--delete"
33
37
 
34
38
  # @!attribute status [Symbol] :ok, or :error when the line was refused
35
39
  # @!attribute output [String, nil] what to tell the user
@@ -66,13 +70,28 @@ module Samagotchi
66
70
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
67
71
  registry.register("/stats", "show the session's stats", local: true)
68
72
  registry.register("/recap", "show the session's recap", local: true)
69
- registry.register("/exit", "leave (--delete also deletes the session)", local: true)
70
- registry.register("/archive", "leave and archive the session: hidden from the lists, kept for good", local: true)
71
- registry.register("/quit", "leave, like /exit", local: true, uis: [:attached])
72
- registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached])
73
+ # Bare `exit` too; any case; --delete after it.
74
+ registry.register("/exit", "leave (--delete also deletes the session)", local: true,
75
+ match: exit_match("/?exit"))
76
+ registry.register("/quit", "leave, like /exit", id: :exit, local: true, match: exit_match("/quit"))
77
+ registry.register("/archive", "leave and archive the session: hidden from the lists, kept for good", local: true,
78
+ match: ->(text) { text.casecmp?("/archive") })
79
+ # The REPL owns its session: it answers /detach with a note, and doesn't offer it.
80
+ registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
81
+ match: ->(text) { text.casecmp?("/detach") })
73
82
  registry
74
83
  end
75
84
 
85
+ # @param word [String] the regexp source of the exit word
86
+ def self.exit_match(word)
87
+ pattern = /\A#{word}(?:\s+#{EXIT_DELETE_FLAG})?\z/i
88
+ ->(text) { text.match?(pattern) }
89
+ end
90
+ private_class_method :exit_match
91
+
92
+ # @return [Boolean] an exit line (/exit, /quit, exit) that deletes the session too
93
+ def self.delete_on_exit?(line) = line.to_s.split.last.to_s.casecmp?(EXIT_DELETE_FLAG)
94
+
76
95
  # The built-ins alone, for callers without an Engine (an attached TUI
77
96
  # before its snapshot names the session's commands, specs).
78
97
  # @return [Commands::Registry] frozen
@@ -277,15 +296,18 @@ module Samagotchi
277
296
  def model(text)
278
297
  output, switched = model_command(text)
279
298
  reply(output, changed: switched ? [:model] : [])
299
+ rescue ModelProfile::MissingModel => e
300
+ # A model qualified with an unknown host: nothing switched.
301
+ reply(e.message, status: :error)
280
302
  end
281
303
 
282
304
  # @return [Array(String, Boolean)] the message, and whether the model changed
283
305
  def model_command(input)
284
306
  suffix = input.delete_prefix(MODEL_COMMAND).strip
285
307
  if suffix.empty?
286
- return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}", false] if model_name == @default_model
308
+ return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}", false] if model_name == @default_model
287
309
 
288
- return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}", false]
310
+ return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}", false]
289
311
  end
290
312
 
291
313
  # Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
@@ -420,6 +442,14 @@ module Samagotchi
420
442
  ""
421
443
  end
422
444
 
445
+ # "; thinking: off (models: qwen)" when the model has a level set.
446
+ def thinking_note
447
+ summary = @engine.respond_to?(:thinking_summary) ? @engine.thinking_summary : nil
448
+ summary ? "; thinking: #{summary}" : ""
449
+ rescue StandardError
450
+ ""
451
+ end
452
+
423
453
  # " (default: x, profile=qwen36, name)": +extra+ and the prompt profile,
424
454
  # which a chat host's model doesn't have (its loop uses none); "" when
425
455
  # there is neither.