samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+
5
+ module Samagotchi
6
+ # Loaded on first use: session_manager requires terminal_ui, which
7
+ # requires KernelLoop and so the tools that use this (a require cycle
8
+ # otherwise).
9
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
10
+
11
+ # Waiting for a session's next reply. A reply is the worker's
12
+ # output/<timestamp>.txt file (one per turn that ended with visible text,
13
+ # written just before its idle save), so "next" means a filename past the
14
+ # cursor the caller holds: the newest one it already has, or nil for any.
15
+ # The delegate tools and `chi send --wait` wrap it in their own words.
16
+ module ReplyWait
17
+ POLL_INTERVAL = 0.5
18
+
19
+ # @!attribute status [Symbol] :done, :waiting_for_answer, :no_reply,
20
+ # :error, :stopped, :canceled, :timeout or :worker_gone
21
+ # @!attribute text [String, nil] the reply (:done) or the worker's error
22
+ # (:error)
23
+ # @!attribute file [String, nil] the reply's filename, the next cursor
24
+ # @!attribute question [Hash, nil] the pending question
25
+ # (:waiting_for_answer)
26
+ Result = Struct.new(:status, :text, :file, :question, keyword_init: true)
27
+
28
+ module_function
29
+
30
+ # @param id [String] the session's full id
31
+ # @param state_dir [String]
32
+ # @param cursor [String, nil] the newest reply filename already had
33
+ # @param timeout [Numeric, nil] seconds; nil waits with no limit
34
+ # @param poll_interval [Float]
35
+ # @param cancelled [#call] true stops the wait (:canceled)
36
+ # @param baseline [Hash, nil] {messages:, question_id:} as the session
37
+ # was before the message went in. With it, a turn that grew the
38
+ # messages (a failed, canceled or empty one leaves its note) and went
39
+ # idle again ends the wait even if it was never seen running, and a
40
+ # question already pending then is not the answer's. A nil
41
+ # messages: skips the count.
42
+ # @param owner_grace [Numeric, nil] seconds with no live worker before
43
+ # :worker_gone (one that died before its rescue leaves it running)
44
+ # @return [Result]
45
+ # @raise [ArgumentError] no such session
46
+ def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
47
+ baseline: nil, owner_grace: nil)
48
+ deadline = timeout && (monotonic + timeout.to_f)
49
+ seen_running = false
50
+ gone_since = nil
51
+
52
+ loop do
53
+ session = Session.load(id, state_dir: state_dir)
54
+
55
+ if (reply = reply_past(id, state_dir: state_dir, cursor: cursor))
56
+ return reply
57
+ end
58
+
59
+ case session.status
60
+ when Session::STATUS_ERROR
61
+ return Result.new(status: :error, text: session.last_prompt.to_s.strip)
62
+ when Session::STATUS_STOPPED
63
+ return Result.new(status: :stopped)
64
+ end
65
+
66
+ pending = session.pending_question
67
+ if pending && !(baseline && pending[:id] == baseline[:question_id])
68
+ return Result.new(status: :waiting_for_answer, question: pending)
69
+ end
70
+
71
+ return Result.new(status: :canceled) if cancelled.call
72
+
73
+ if session.status == Session::STATUS_RUNNING
74
+ seen_running = true
75
+ elsif seen_running || (baseline&.dig(:messages) && session.messages.size > baseline[:messages])
76
+ # It ran and is idle again with no new reply: canceled, failed or
77
+ # empty. Before it was ever seen running, idle means a
78
+ # file-delivered message its worker has not picked up yet.
79
+ # The worker writes the reply before its idle save, so the read
80
+ # 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)
82
+ end
83
+
84
+ if owner_grace
85
+ if SessionManager.session_owner(id, state_dir: state_dir)
86
+ gone_since = nil
87
+ elsif monotonic - (gone_since ||= monotonic) > owner_grace
88
+ return Result.new(status: :worker_gone)
89
+ end
90
+ end
91
+
92
+ return Result.new(status: :timeout) if deadline && monotonic > deadline
93
+
94
+ sleep(poll_interval)
95
+ end
96
+ end
97
+
98
+ # @return [Result, nil] :done with the newest reply past the cursor
99
+ def reply_past(id, state_dir:, cursor:)
100
+ file = newest_reply(id, state_dir: state_dir)
101
+ return nil unless file && newer?(file, cursor)
102
+
103
+ Result.new(status: :done, text: File.read(File.join(reply_dir(id, state_dir: state_dir), file)), file: file)
104
+ end
105
+
106
+ # @return [String, nil] the newest reply filename (sortable timestamps)
107
+ def newest_reply(id, state_dir:)
108
+ dir = reply_dir(id, state_dir: state_dir)
109
+ return nil unless Dir.exist?(dir)
110
+
111
+ Dir.children(dir).select { |f| f.end_with?(".txt") }.max
112
+ end
113
+
114
+ def reply_dir(id, state_dir:)
115
+ File.join(Session.session_dir(id, state_dir: state_dir), SessionManager::OUTPUT_DIR)
116
+ end
117
+
118
+ def newer?(file, cursor)
119
+ cursor.nil? || file > cursor
120
+ end
121
+
122
+ def monotonic
123
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "config"
5
+
6
+ module Samagotchi
7
+ # The request parameters (temperature, penalties, …) a model's generations
8
+ # go out with: hosts.<name>.sampling merged with models.<key>.sampling, the
9
+ # model's keys winning per key (a host can set a penalty and a model move
10
+ # only the temperature). Recap and side clients keep their own options.
11
+ module SamplingSettings
12
+ EMPTY = {}.freeze
13
+
14
+ # @param target [HostRegistry::ModelTarget]
15
+ # @param names [Array<String>, nil] the model as typed, alias-resolved,
16
+ # bare (the Engine's lookup names); default: the target's model and bare model
17
+ # @param models [Hash, nil] ConfigFile.model_settings (specs)
18
+ # @return [Hash] frozen, symbol keys; may be empty
19
+ def self.for(target, names: nil, models: nil)
20
+ resolve(target, names, models).first
21
+ end
22
+
23
+ # "temperature=0.6 presence_penalty=1.5 (hosts.work, models: qwen)" for
24
+ # /model, or nil when nothing is configured.
25
+ def self.summary(target, names: nil, models: nil)
26
+ params, sources = resolve(target, names, models)
27
+ return nil if params.empty?
28
+
29
+ pairs = params.map { |key, value| "#{key}=#{value.nil? ? "(not sent)" : value.inspect}" }
30
+ "#{pairs.join(" ")} (#{sources.join(", ")})"
31
+ end
32
+
33
+ # Request fields as the http log line shows them:
34
+ # "temperature=0.6 presence_penalty=1.5", nil for none.
35
+ def self.log_text(fields)
36
+ return nil if fields.nil? || fields.empty?
37
+
38
+ fields.map { |key, value| "#{key}=#{value.is_a?(String) ? value : JSON.generate(value)}" }.join(" ")
39
+ end
40
+
41
+ def self.resolve(target, names, models)
42
+ models ||= begin
43
+ ConfigFile.model_settings
44
+ rescue StandardError
45
+ {}
46
+ end
47
+ names ||= [target.model, target.bare_model]
48
+ host = target.entry.sampling || EMPTY
49
+ key, model = ConfigFile.model_setting(names, :sampling, models: models)
50
+ sources = []
51
+ sources << "hosts.#{target.entry.name}" unless host.empty?
52
+ sources << "models: #{key}" if key
53
+ [host.merge(model || EMPTY).freeze, sources]
54
+ end
55
+
56
+ private_class_method :resolve
57
+ end
58
+ end
@@ -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"
@@ -50,6 +51,7 @@ module Samagotchi
50
51
  ["api key", model ? api_key_for(model, env) : "-"],
51
52
  ["loop", model ? loop_for(model, env) : "-"],
52
53
  ["profile", model ? profile_for(model, env) : "-"],
54
+ ["thinking", model ? thinking_for(model, env) : "-"],
53
55
  ["served model", model ? served_model_for(model, env) : "-"],
54
56
  ["context window", context_window(env)],
55
57
  ["bundles", bundles_summary],
@@ -116,6 +118,7 @@ module Samagotchi
116
118
  client = registry.client_for(entry)
117
119
  props = client.server_props(model: bare) if client.respond_to?(:server_props)
118
120
  return "reported per turn (the server has no /props)" if props.nil?
121
+ return "unknown (the server didn't answer; is it running?)" if props.status == :network_error
119
122
 
120
123
  served = ServedModel.from_props(props)
121
124
  return "unknown (no answer from the server's /props)" unless served
@@ -149,6 +152,15 @@ module Samagotchi
149
152
  end
150
153
  end
151
154
 
155
+ # The model's thinking level and where it came from (Thinking.resolve).
156
+ def thinking_for(model, env)
157
+ target = HostRegistry.new(env: env).resolve(model)
158
+ level, source = Thinking.resolve(target, models: ConfigFile.model_settings(env: env))
159
+ source ? "#{level} (#{source})" : level.to_s
160
+ rescue StandardError => e
161
+ "(unknown: #{e.message})"
162
+ end
163
+
152
164
  def host_for(model, env)
153
165
  entry, bare = HostRegistry.new(env: env).host_for_model(model)
154
166
  return "(no hosts configured)" unless entry
@@ -169,14 +181,17 @@ module Samagotchi
169
181
  end
170
182
 
171
183
  # The Chi Helper app's version against this chi's (read from its
172
- # Info.plist; no process or Services checks: chi desktop status has those).
184
+ # Info.plist and launch file; no process or Services checks: chi desktop
185
+ # status has those). An older app is fine while its sources are unchanged.
173
186
  def desktop_summary(env)
174
187
  return "- (macOS only)" unless Desktop.supported?
175
188
 
176
- version = Desktop::MacOS.new(env: env).app_version
189
+ helper = Desktop::MacOS.new(env: env)
190
+ version = helper.app_version
177
191
  return "not installed" unless version
192
+ return "#{version} (matches)" if version == VERSION
178
193
 
179
- version == VERSION ? "#{version} (matches)" : "#{version} (chi is #{VERSION}: chi desktop upgrade)"
194
+ helper.stale? ? "#{version} (chi is #{VERSION}: chi update)" : "#{version} (up to date for chi #{VERSION})"
180
195
  end
181
196
 
182
197
  # "samagotchi-system 0.1.5 (shipped 0.1.5), other 1.0.0"
@@ -3,6 +3,8 @@
3
3
  require_relative "session"
4
4
  require_relative "session_manager"
5
5
  require_relative "context_quote"
6
+ require_relative "reply_wait"
7
+ require_relative "image_store"
6
8
 
7
9
  module Samagotchi
8
10
  # `chi send`: put text into sessions as the user's message, the same as
@@ -10,15 +12,43 @@ module Samagotchi
10
12
  # `chi note`. Fire and forget: it returns once the message is queued, and
11
13
  # the answer shows in whatever is attached. For a script:
12
14
  # pbpaste | chi send -m "is this the same bug?" 3fa2
15
+ # --new starts a session instead, and --wait blocks for the reply and
16
+ # prints it: an agent's one-shot the user can watch in the web.
13
17
  class SendCommand
14
18
  CLIENT_ID = "cli:send"
19
+ POLL_INTERVAL = ReplyWait::POLL_INTERVAL
20
+ # No live worker this long while waiting: it died before it could mark
21
+ # the session (a worker takes well under a second to start).
22
+ WORKER_GONE_AFTER = 5
23
+ # The Bridge's cap on one turn's images (Bridge::MAX_TURN_IMAGES).
24
+ MAX_IMAGES = 20
15
25
 
16
26
  USAGE = <<~TEXT
17
- Usage: chi send [-m TEXT] (ID|PREFIX)...
27
+ Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
28
+ chi send --new [--dir DIR] [--model M] [-m TEXT] [--image PATH]...
29
+ chi send --wait [--timeout S] [-m TEXT] [--image PATH]... (--new | ID)
30
+ chi send --wait [--timeout S] ID
18
31
  Sends a message to each session, as if typed in it: a turn starts,
19
32
  or a running one picks it up. A stopped session's worker starts.
20
- -m TEXT the message; stdin, when piped too, goes above it as a
21
- quote (context); without -m, stdin is the message
33
+ -m TEXT the message; stdin, when piped too, goes above it as a
34
+ quote (context); without -m, stdin is the message
35
+ --image PATH
36
+ an image sent with the message (png, jpeg, gif, webp;
37
+ others converted, large ones downscaled); repeat for
38
+ more, up to 20. Needs text too (-m or stdin) and a
39
+ model that sees images
40
+ --new start a new session with the message instead, as the
41
+ web does, and print its id
42
+ --dir DIR (--new) its folder, the project it belongs to; default
43
+ the current one
44
+ --model M (--new) its model; default the configured one
45
+ --wait wait for the answer and print it (one session); the
46
+ other lines go to stderr. Exit 3: it waits for an
47
+ answer from you (chi --attach ID or the web). With
48
+ no message (no -m, nothing piped) nothing is sent: it
49
+ waits for the session's next reply, a running turn's
50
+ too (after exit 3 or 130, wait again this way)
51
+ --timeout S (--wait) give up after S seconds; default no limit
22
52
  Only sessions on this machine. Answers show in the attached TUI
23
53
  or web page, not here.
24
54
  Find ids with: chi sessions list --live [--scope=all] [--format tsv]
@@ -34,12 +64,21 @@ module Samagotchi
34
64
  end
35
65
 
36
66
  # @return [Integer] exit status: 0 all sent, 1 any refused or failed,
37
- # 2 usage
67
+ # 2 usage; with --wait 0 answered, 3 waiting for an answer, 130
68
+ # Ctrl-C (the turn goes on)
38
69
  def run
39
70
  options = parse or return 2
40
71
  return 0 if options[:help]
41
72
 
73
+ # With --wait stdout is the answer alone.
74
+ @info = options[:wait] ? @stderr : @stdout
42
75
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
76
+ # Before the wait-only case, which would drop the images.
77
+ if prompt.nil? && !options[:images].empty?
78
+ usage_error("--image needs a message: pass -m TEXT or pipe it in")
79
+ return 2
80
+ end
81
+ return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
43
82
  unless prompt
44
83
  usage_error("no message: pass -m TEXT or pipe it in")
45
84
  return 2
@@ -50,6 +89,10 @@ module Samagotchi
50
89
  @stderr.puts("chi send: #{e.message}")
51
90
  return 1
52
91
  end
92
+ return 2 unless ingest_images(options[:images])
93
+
94
+ return run_new(prompt, options) if options[:new]
95
+ return run_wait(prompt, options) if options[:wait]
53
96
 
54
97
  # One id at a time, so each line follows the order of the ids given.
55
98
  seen = {}
@@ -62,12 +105,14 @@ module Samagotchi
62
105
  deliver(id, prompt)
63
106
  end
64
107
  results.all? ? 0 : 1
108
+ ensure
109
+ FileUtils.rm_rf(@image_dir) if @image_dir
65
110
  end
66
111
 
67
112
  private
68
113
 
69
114
  def parse
70
- options = { ids: [] }
115
+ options = { ids: [], images: [] }
71
116
  until @argv.empty?
72
117
  arg = @argv.shift
73
118
  case arg
@@ -77,6 +122,15 @@ module Samagotchi
77
122
  when "-m", "--message"
78
123
  options[:message] = @argv.shift or return usage_error("#{arg} needs a value")
79
124
  when /\A--message=(.*)\z/m then options[:message] = Regexp.last_match(1)
125
+ when "--new" then options[:new] = true
126
+ when "--dir", "--model"
127
+ options[arg.delete_prefix("--").to_sym] = @argv.shift or return usage_error("#{arg} needs a value")
128
+ when /\A--(dir|model)=(.*)\z/m then options[Regexp.last_match(1).to_sym] = Regexp.last_match(2)
129
+ when "--image" then options[:images] << (@argv.shift or return usage_error("#{arg} needs a value"))
130
+ when /\A--image=(.*)\z/m then options[:images] << Regexp.last_match(1)
131
+ when "--wait" then options[:wait] = true
132
+ when "--timeout" then options[:timeout] = @argv.shift or return usage_error("#{arg} needs a value")
133
+ when /\A--timeout=(.*)\z/ then options[:timeout] = Regexp.last_match(1)
80
134
  # Starting a turn in every live session at once is too easy to do
81
135
  # by accident.
82
136
  when "--all" then return usage_error("there is no --all: name the sessions")
@@ -84,11 +138,31 @@ module Samagotchi
84
138
  else options[:ids] << arg
85
139
  end
86
140
  end
141
+ if options[:timeout]
142
+ return usage_error("--timeout needs --wait") unless options[:wait]
143
+
144
+ options[:timeout] = Float(options[:timeout], exception: false)
145
+ return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
146
+ end
147
+ return usage_error("--wait takes one session") if options[:wait] && options[:ids].uniq.size > 1
148
+ return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
149
+ return new_options(options) if options[:new]
150
+ %i[dir model].each { |key| return usage_error("--#{key} needs --new") if options[key] }
87
151
  return usage_error("give session ids") if options[:ids].empty?
88
152
 
89
153
  options
90
154
  end
91
155
 
156
+ def new_options(options)
157
+ return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
158
+
159
+ if options[:dir]
160
+ options[:dir] = File.expand_path(options[:dir])
161
+ return usage_error("no folder #{options[:dir]}") unless File.directory?(options[:dir])
162
+ end
163
+ options
164
+ end
165
+
92
166
  # With both, stdin is the context quoted above the message; with one,
93
167
  # it goes in as is. nil when both are blank.
94
168
  def compose(context, message)
@@ -123,30 +197,197 @@ module Samagotchi
123
197
  nil
124
198
  end
125
199
 
200
+ # A worker session like the web start page's: saved as running with the
201
+ # message before its worker spawns, so lists and the web show it at
202
+ # once. The full id, so a script can pass it on. Only the model's host
203
+ # is checked here (in spawn_session, as in the web): a wrong model id
204
+ # fails in the worker.
205
+ # @return [Integer] the exit status
206
+ def run_new(prompt, options)
207
+ images = !@images.empty?
208
+ begin
209
+ # With images the web's way: idle (the message names it in the
210
+ # lists), the images copied in, then the message as a turn.
211
+ start = images ? { prompt: nil, title: prompt } : { prompt: prompt }
212
+ session = SessionManager.spawn_session(**start, working_directory: options[:dir],
213
+ model_name: options[:model], state_dir: @state_dir)
214
+ rescue StandardError => e
215
+ error_line("chi send: could not start a session: #{e.message}")
216
+ return 1
217
+ end
218
+ if images
219
+ return 1 unless deliver_new(session, prompt)
220
+ else
221
+ @info.puts("#{session.id} started")
222
+ end
223
+ return 0 unless options[:wait]
224
+
225
+ wait_for_reply(session.id, cursor: nil, baseline: { messages: session.messages.size, question_id: nil },
226
+ timeout: options[:timeout])
227
+ end
228
+
229
+ # One existing session, then its next reply. The cursor and baseline
230
+ # are taken before the message goes in, so neither an older reply nor a
231
+ # turn that ends before the first look is mistaken for the answer. A
232
+ # running turn is refused: a message that misses it runs next, and the
233
+ # running one's reply would come back as the answer.
234
+ # @return [Integer] the exit status
235
+ def run_wait(prompt, options)
236
+ id = resolve(options[:ids].first) or return 1
237
+ session = Session.load(id, state_dir: @state_dir)
238
+ if session.status == Session::STATUS_RUNNING && SessionManager.session_owner(id, state_dir: @state_dir)
239
+ error_line("#{id[0, 8]} busy: a turn is running; wait or attach")
240
+ return 1
241
+ end
242
+
243
+ cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
244
+ baseline = { messages: session.messages.size, question_id: session.pending_question&.dig(:id) }
245
+ return 1 unless deliver(id, prompt)
246
+
247
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
248
+ end
249
+
250
+ # --wait with no message: the session's next reply, sending nothing,
251
+ # so an agent can go back to waiting after exit 3 or 130. A running
252
+ # turn is fine here (nothing joins its queue); a question pending now
253
+ # was already reported. No message count: a note landing between turns
254
+ # grows the messages without a turn. An idle session with no worker
255
+ # waits for whatever wakes one (the web, chi send) rather than calling
256
+ # it gone.
257
+ # @return [Integer] the exit status
258
+ def run_wait_only(options)
259
+ id = resolve(options[:ids].first) or return 1
260
+ session = Session.load(id, state_dir: @state_dir)
261
+ live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
262
+ wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
263
+ baseline: { messages: nil, question_id: session.pending_question&.dig(:id) },
264
+ timeout: options[:timeout], owner_grace: live ? WORKER_GONE_AFTER : nil)
265
+ end
266
+
267
+ # @return [Integer] the exit status
268
+ def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: WORKER_GONE_AFTER)
269
+ result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
270
+ owner_grace: owner_grace, poll_interval: POLL_INTERVAL)
271
+ return reply(result.text) if result.status == :done
272
+
273
+ attach = "chi --attach #{id}"
274
+ line, status = case result.status
275
+ when :waiting_for_answer
276
+ question = result.question[:question].to_s.strip.lines.first.to_s.strip
277
+ ["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]
279
+ when :error then ["the worker failed: #{result.text}; #{attach} shows what happened", 1]
280
+ when :worker_gone then ["the worker is gone; #{attach} shows what happened", 1]
281
+ when :stopped then ["the session was stopped (chi sessions stop)", 1]
282
+ else ["still running after #{format("%g", timeout)} s: #{attach}", 1]
283
+ end
284
+ error_line("chi send: #{line}")
285
+ status
286
+ rescue Interrupt
287
+ error_line("chi send: still running: chi --attach #{id}")
288
+ 130
289
+ rescue ArgumentError
290
+ error_line("chi send: the session is gone (deleted while waiting)")
291
+ 1
292
+ end
293
+
294
+ def reply(text)
295
+ @stdout.puts(utf8(text))
296
+ @stdout.flush
297
+ 0
298
+ end
299
+
300
+ # A new idle session's first turn, with the images. Its worker's Bridge
301
+ # first: deliver_turn wakes a worker when none owns the session yet,
302
+ # and the spawned one takes its lock only once it runs.
303
+ # @return [Boolean] whether the message was queued
304
+ def deliver_new(session, prompt)
305
+ dir = Session.session_dir(session.id, state_dir: @state_dir)
306
+ result = nil
307
+ begin
308
+ refs = copy_images(dir)
309
+ if BridgeClient.wait_for(session.id, session_dir: dir, timeout: SessionManager::TURN_BRIDGE_WAIT)
310
+ result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id: CLIENT_ID, images: refs,
311
+ state_dir: @state_dir)
312
+ end
313
+ rescue StandardError => e
314
+ @info.puts("#{session.id} failed: #{e.message}")
315
+ return false
316
+ end
317
+ # Kept, not deleted: it holds the images, and the message shows as
318
+ # its preview; the user can attach and send it again.
319
+ unless result
320
+ @info.puts("#{session.id} failed: its worker did not start; the session is kept (chi --attach #{session.id})")
321
+ return false
322
+ end
323
+ unless result[:status] == :accepted
324
+ @info.puts("#{session.id} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
325
+ return false
326
+ end
327
+
328
+ @info.puts("#{session.id} started#{with_images}")
329
+ true
330
+ end
331
+
126
332
  # @return [Boolean] whether the message was queued
127
333
  def deliver(id, prompt)
128
334
  short = id[0, 8]
129
335
  owner = SessionManager.session_owner(id, state_dir: @state_dir)
130
336
  running = owner && Session.load(id, state_dir: @state_dir).status == Session::STATUS_RUNNING
131
- result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, state_dir: @state_dir)
337
+ refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
338
+ result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir)
132
339
  unless result[:status] == :accepted
133
- @stdout.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
340
+ @info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
134
341
  return false
135
342
  end
136
343
 
344
+ # A busy worker runs a message with images as its own next turn
345
+ # rather than merging it into the running one.
137
346
  note = if owner.nil? then " (started its worker)"
138
- elsif running then " (the running turn picks it up)"
347
+ elsif running then refs.empty? ? " (the running turn picks it up)" : " (runs after the current turn)"
139
348
  end
140
- @stdout.puts("#{short} sent#{note}")
349
+ @info.puts("#{short} sent#{with_images}#{note}")
141
350
  true
142
351
  rescue SessionManager::OwnedByTUI
143
- @stdout.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
352
+ @info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
144
353
  false
145
354
  rescue StandardError => e
146
- @stdout.puts("#{short} failed: #{e.message}")
355
+ @info.puts("#{short} failed: #{e.message}")
356
+ false
357
+ end
358
+
359
+ # Each --image read, converted and downscaled once, into a scratch
360
+ # session dir; each target then gets copies of the stored files. A file
361
+ # that can't be sent stops everything before anything is sent.
362
+ # @return [Boolean] false after the error line
363
+ def ingest_images(paths)
364
+ @images = []
365
+ return true if paths.empty?
366
+
367
+ @image_dir = Dir.mktmpdir("chi-send-images")
368
+ @images = paths.map { |path| ImageStore.ingest(@image_dir, path: path) }
369
+ true
370
+ rescue ImageStore::Error => e
371
+ error_line("chi send: #{e.message}")
147
372
  false
148
373
  end
149
374
 
375
+ # The images' files copied into a session dir, as the refs a turn takes.
376
+ def copy_images(session_dir)
377
+ @images.map do |ref|
378
+ ImageStore.copy_file(ref, from: @image_dir, to: session_dir)
379
+ { file: ref[:file], name: ref[:name] }
380
+ end
381
+ end
382
+
383
+ def with_images
384
+ case @images.size
385
+ when 0 then ""
386
+ when 1 then " with 1 image"
387
+ else " with #{@images.size} images"
388
+ end
389
+ end
390
+
150
391
  # The text as UTF-8 whatever the locale says: with no LANG/LC_* (an app
151
392
  # started from Finder, launchd) stdin reads as US-ASCII and ARGV as
152
393
  # binary. Invalid bytes become U+FFFD rather than an error.