samagotchi 0.2.0 → 0.3.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +40 -4
  4. data/bin/chi +86 -19
  5. data/docs/cli.md +108 -5
  6. data/docs/configuration.md +229 -45
  7. data/docs/desktop.md +6 -0
  8. data/docs/hooks.md +126 -5
  9. data/docs/plugins.md +68 -2
  10. data/docs/releasing.md +18 -8
  11. data/docs/sessions.md +30 -4
  12. data/lib/samagotchi/answer_display.rb +95 -0
  13. data/lib/samagotchi/archive_store.rb +90 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  16. data/lib/samagotchi/bootstrap_command.rb +347 -0
  17. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
  19. data/lib/samagotchi/bridge.rb +9 -0
  20. data/lib/samagotchi/bridge_client.rb +6 -2
  21. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  22. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  25. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  29. data/lib/samagotchi/client.rb +72 -13
  30. data/lib/samagotchi/config.rb +196 -36
  31. data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
  32. data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
  33. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  34. data/lib/samagotchi/engine.rb +233 -36
  35. data/lib/samagotchi/guardrails/approval.rb +9 -0
  36. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  37. data/lib/samagotchi/guardrails.rb +1 -0
  38. data/lib/samagotchi/hooks/registry.rb +24 -5
  39. data/lib/samagotchi/host_registry.rb +4 -3
  40. data/lib/samagotchi/idle_recap.rb +5 -1
  41. data/lib/samagotchi/kernel_loop.rb +47 -15
  42. data/lib/samagotchi/llm/chat_loop.rb +59 -20
  43. data/lib/samagotchi/llm/errors.rb +21 -3
  44. data/lib/samagotchi/llm/http.rb +42 -13
  45. data/lib/samagotchi/llm/openai_chat.rb +12 -4
  46. data/lib/samagotchi/log_subscriber.rb +18 -3
  47. data/lib/samagotchi/model_profile.rb +1 -1
  48. data/lib/samagotchi/plugin/context.rb +22 -1
  49. data/lib/samagotchi/plugin/sessions.rb +3 -1
  50. data/lib/samagotchi/reply_wait.rb +126 -0
  51. data/lib/samagotchi/sampling_settings.rb +58 -0
  52. data/lib/samagotchi/self_report.rb +1 -0
  53. data/lib/samagotchi/send_command.rb +153 -7
  54. data/lib/samagotchi/session.rb +52 -11
  55. data/lib/samagotchi/session_archive_command.rb +107 -0
  56. data/lib/samagotchi/session_commands.rb +11 -2
  57. data/lib/samagotchi/session_manager.rb +114 -9
  58. data/lib/samagotchi/session_metrics.rb +222 -106
  59. data/lib/samagotchi/steer.rb +72 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
  62. data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
  63. data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
  64. data/lib/samagotchi/terminal_ui.rb +77 -4
  65. data/lib/samagotchi/tool_activity.rb +3 -1
  66. data/lib/samagotchi/tools/builtins.rb +15 -4
  67. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  68. data/lib/samagotchi/tools/execute.rb +52 -14
  69. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  70. data/lib/samagotchi/tools/task_wait.rb +27 -3
  71. data/lib/samagotchi/turn_note.rb +60 -6
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +2 -6
  74. data/lib/samagotchi/web/app.rb +88 -4
  75. data/lib/samagotchi/web/public/activity.js +10 -1
  76. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  77. data/lib/samagotchi/web/public/annotations.js +13 -0
  78. data/lib/samagotchi/web/public/app.js +437 -88
  79. data/lib/samagotchi/web/public/card.js +5 -3
  80. data/lib/samagotchi/web/public/chat_view.js +10 -1
  81. data/lib/samagotchi/web/public/copy.js +20 -4
  82. data/lib/samagotchi/web/public/ctx.js +15 -0
  83. data/lib/samagotchi/web/public/data.js +21 -6
  84. data/lib/samagotchi/web/public/format.js +9 -0
  85. data/lib/samagotchi/web/public/index.html +38 -2
  86. data/lib/samagotchi/web/public/notify.js +175 -0
  87. data/lib/samagotchi/web/public/question_card.js +2 -1
  88. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  89. data/lib/samagotchi/web/public/timing.js +39 -14
  90. data/lib/samagotchi/web/public/turn_events.js +46 -0
  91. data/lib/samagotchi/web/public/turn_view.js +47 -7
  92. data/lib/samagotchi/web/server.rb +8 -4
  93. data/lib/samagotchi/web/session_hub.rb +2 -1
  94. data/lib/samagotchi/web/session_summary.rb +24 -1
  95. data/lib/samagotchi/worker.rb +11 -0
  96. metadata +20 -1
@@ -27,8 +27,8 @@ module Samagotchi
27
27
  question_requested: [],
28
28
  question_answered: %i[id],
29
29
  question_cancelled: %i[id reason],
30
- pending_input_merged: %i[iteration count],
31
- generation_cancelled: %i[iteration]
30
+ generation_cancelled: %i[iteration],
31
+ empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars]
32
32
  }.freeze
33
33
 
34
34
  # @param session_id [#call] the session the events are about (the
@@ -71,6 +71,15 @@ module Samagotchi
71
71
  exhausted: summary[:exhausted] || nil, **origin(event))
72
72
  end
73
73
 
74
+ # A plugin's steers by count and source, never their text.
75
+ def on_pending_input_merged(event)
76
+ steers = Array(event[:steers])
77
+ log(:info, :pending_input_merged, **event.slice(:iteration, :count),
78
+ steers: steers.empty? ? nil : steers.size,
79
+ steer_sources: steers.empty? ? nil : steers.map { |s| s[:source] }.uniq.join(","),
80
+ **origin(event))
81
+ end
82
+
74
83
  def on_turn_canceled(event)
75
84
  log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason], **origin(event))
76
85
  end
@@ -92,7 +101,7 @@ module Samagotchi
92
101
  ms: since(@generation_started_at.delete(event[:iteration])),
93
102
  served_model: event[:served_model], requested_model: event[:requested_model],
94
103
  content_length: event[:content_length],
95
- thinking_chars: event[:thinking_chars])
104
+ thinking_chars: event[:thinking_chars], finish_reason: event[:finish_reason])
96
105
  end
97
106
 
98
107
  def on_generation_retrying(event)
@@ -125,6 +134,12 @@ module Samagotchi
125
134
  log(level, :hook_notice, hook: event[:hook], msg: event[:text].to_s[0, 300])
126
135
  end
127
136
 
137
+ # after_turn hooks presented the answer (AnswerDisplay): its size only
138
+ # (none for the display: nil that says nothing was presented).
139
+ def on_answer_display(event)
140
+ log(:info, :answer_display, chars: event[:display].to_s.length) if event[:display]
141
+ end
142
+
128
143
  # A card shown to the user (Engine#show_card): whose, which, and its
129
144
  # title (the body is the plugin's text; not logged).
130
145
  def on_card(event)
@@ -139,7 +139,7 @@ module Samagotchi
139
139
  "~/.config/samagotchi/config.yml"
140
140
  end
141
141
  "no model configured: set default.model in #{path} to the model id your server serves " \
142
- "(or #{MODEL_ENV}, or pass --model ID); see docs/configuration.md"
142
+ "(or #{MODEL_ENV}, or pass --model ID); see docs/configuration.md; or run: chi bootstrap HOST[:PORT]"
143
143
  end
144
144
 
145
145
  def self.from_model_name(model_name)
@@ -18,8 +18,10 @@ module Samagotchi
18
18
  # the answer text.
19
19
  # +messages_partial+ says whether +messages+ leaves out a running turn;
20
20
  # +model_name+ and +state_dir+ are what ctx.sessions forks with.
21
+ # +steer+ takes (text, source) and +stop_turn+ (reason, label), each
22
+ # true when it acted on a running turn.
21
23
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
22
- :ask_model, :model_name, :state_dir, keyword_init: true)
24
+ :ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, keyword_init: true)
23
25
 
24
26
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
25
27
  # sent nothing usable. The message says why, for the user.
@@ -178,6 +180,25 @@ module Samagotchi
178
180
  # Whether the running turn was cancelled (a long tool should stop).
179
181
  def cancelled? = !!@host.cancelled.call
180
182
 
183
+ # Put +text+ into the running turn, as a user's steering does: at the
184
+ # loop's next boundary (after the tool calls in flight) it joins the
185
+ # conversation as its own user message, shown in every UI as a nudge
186
+ # from this bundle. It never starts a turn. True means queued: if the
187
+ # model answers first, or the turn ends, it is dropped (logged).
188
+ # Callable from a hook, a command (an anytime one runs beside the
189
+ # turn) or your own thread.
190
+ # @return [Boolean] whether a turn was running and the text queued
191
+ def steer(text)
192
+ !!@host.steer&.call(text.to_s, @bundle)
193
+ end
194
+
195
+ # Stop the running turn, after a notice with +reason+, as a hook's
196
+ # event[:stop_turn] does (for commands and threads).
197
+ # @return [Boolean] whether a running turn was stopped now
198
+ def stop_turn(reason)
199
+ !!@host.stop_turn&.call(reason.to_s, @label)
200
+ end
201
+
181
202
  private
182
203
 
183
204
  def deep_freeze(value)
@@ -33,9 +33,11 @@ module Samagotchi
33
33
  # the first user message, by default)
34
34
  # @param prompt [String, nil] the child's first turn
35
35
  # @return [String] the child's id
36
- # @raise [Error] no session yet, or too many children running
36
+ # @raise [Error] no session yet, a scratch session (the child would
37
+ # outlive it), or too many children running
37
38
  def fork(messages:, title: nil, prompt: nil)
38
39
  parent_id = @host.session_id.call or raise Error, "this session has no id yet"
40
+ raise Error, "a scratch session starts no other sessions: they would outlive it" if @host.scratch&.call
39
41
  state_dir = self.state_dir
40
42
  prompt = prompt.to_s.strip.empty? ? nil : prompt.to_s
41
43
  check_children(parent_id, state_dir) if prompt
@@ -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
@@ -116,6 +116,7 @@ module Samagotchi
116
116
  client = registry.client_for(entry)
117
117
  props = client.server_props(model: bare) if client.respond_to?(:server_props)
118
118
  return "reported per turn (the server has no /props)" if props.nil?
119
+ return "unknown (the server didn't answer; is it running?)" if props.status == :network_error
119
120
 
120
121
  served = ServedModel.from_props(props)
121
122
  return "unknown (no answer from the server's /props)" unless served
@@ -3,6 +3,7 @@
3
3
  require_relative "session"
4
4
  require_relative "session_manager"
5
5
  require_relative "context_quote"
6
+ require_relative "reply_wait"
6
7
 
7
8
  module Samagotchi
8
9
  # `chi send`: put text into sessions as the user's message, the same as
@@ -10,15 +11,36 @@ module Samagotchi
10
11
  # `chi note`. Fire and forget: it returns once the message is queued, and
11
12
  # the answer shows in whatever is attached. For a script:
12
13
  # pbpaste | chi send -m "is this the same bug?" 3fa2
14
+ # --new starts a session instead, and --wait blocks for the reply and
15
+ # prints it: an agent's one-shot the user can watch in the web.
13
16
  class SendCommand
14
17
  CLIENT_ID = "cli:send"
18
+ POLL_INTERVAL = ReplyWait::POLL_INTERVAL
19
+ # No live worker this long while waiting: it died before it could mark
20
+ # the session (a worker takes well under a second to start).
21
+ WORKER_GONE_AFTER = 5
15
22
 
16
23
  USAGE = <<~TEXT
17
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)
27
+ chi send --wait [--timeout S] ID
18
28
  Sends a message to each session, as if typed in it: a turn starts,
19
29
  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
30
+ -m TEXT the message; stdin, when piped too, goes above it as a
31
+ quote (context); without -m, stdin is the message
32
+ --new start a new session with the message instead, as the
33
+ web does, and print its id
34
+ --dir DIR (--new) its folder, the project it belongs to; default
35
+ the current one
36
+ --model M (--new) its model; default the configured one
37
+ --wait wait for the answer and print it (one session); the
38
+ other lines go to stderr. Exit 3: it waits for an
39
+ answer from you (chi --attach ID or the web). With
40
+ no message (no -m, nothing piped) nothing is sent: it
41
+ waits for the session's next reply, a running turn's
42
+ too (after exit 3 or 130, wait again this way)
43
+ --timeout S (--wait) give up after S seconds; default no limit
22
44
  Only sessions on this machine. Answers show in the attached TUI
23
45
  or web page, not here.
24
46
  Find ids with: chi sessions list --live [--scope=all] [--format tsv]
@@ -34,12 +56,16 @@ module Samagotchi
34
56
  end
35
57
 
36
58
  # @return [Integer] exit status: 0 all sent, 1 any refused or failed,
37
- # 2 usage
59
+ # 2 usage; with --wait 0 answered, 3 waiting for an answer, 130
60
+ # Ctrl-C (the turn goes on)
38
61
  def run
39
62
  options = parse or return 2
40
63
  return 0 if options[:help]
41
64
 
65
+ # With --wait stdout is the answer alone.
66
+ @info = options[:wait] ? @stderr : @stdout
42
67
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
68
+ return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
43
69
  unless prompt
44
70
  usage_error("no message: pass -m TEXT or pipe it in")
45
71
  return 2
@@ -51,6 +77,9 @@ module Samagotchi
51
77
  return 1
52
78
  end
53
79
 
80
+ return run_new(prompt, options) if options[:new]
81
+ return run_wait(prompt, options) if options[:wait]
82
+
54
83
  # One id at a time, so each line follows the order of the ids given.
55
84
  seen = {}
56
85
  results = options[:ids].uniq.map do |given|
@@ -77,6 +106,13 @@ module Samagotchi
77
106
  when "-m", "--message"
78
107
  options[:message] = @argv.shift or return usage_error("#{arg} needs a value")
79
108
  when /\A--message=(.*)\z/m then options[:message] = Regexp.last_match(1)
109
+ when "--new" then options[:new] = true
110
+ when "--dir", "--model"
111
+ options[arg.delete_prefix("--").to_sym] = @argv.shift or return usage_error("#{arg} needs a value")
112
+ when /\A--(dir|model)=(.*)\z/m then options[Regexp.last_match(1).to_sym] = Regexp.last_match(2)
113
+ when "--wait" then options[:wait] = true
114
+ when "--timeout" then options[:timeout] = @argv.shift or return usage_error("#{arg} needs a value")
115
+ when /\A--timeout=(.*)\z/ then options[:timeout] = Regexp.last_match(1)
80
116
  # Starting a turn in every live session at once is too easy to do
81
117
  # by accident.
82
118
  when "--all" then return usage_error("there is no --all: name the sessions")
@@ -84,11 +120,30 @@ module Samagotchi
84
120
  else options[:ids] << arg
85
121
  end
86
122
  end
123
+ if options[:timeout]
124
+ return usage_error("--timeout needs --wait") unless options[:wait]
125
+
126
+ options[:timeout] = Float(options[:timeout], exception: false)
127
+ return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
128
+ end
129
+ return usage_error("--wait takes one session") if options[:wait] && options[:ids].uniq.size > 1
130
+ return new_options(options) if options[:new]
131
+ %i[dir model].each { |key| return usage_error("--#{key} needs --new") if options[key] }
87
132
  return usage_error("give session ids") if options[:ids].empty?
88
133
 
89
134
  options
90
135
  end
91
136
 
137
+ def new_options(options)
138
+ return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
139
+
140
+ if options[:dir]
141
+ options[:dir] = File.expand_path(options[:dir])
142
+ return usage_error("no folder #{options[:dir]}") unless File.directory?(options[:dir])
143
+ end
144
+ options
145
+ end
146
+
92
147
  # With both, stdin is the context quoted above the message; with one,
93
148
  # it goes in as is. nil when both are blank.
94
149
  def compose(context, message)
@@ -123,6 +178,97 @@ module Samagotchi
123
178
  nil
124
179
  end
125
180
 
181
+ # A worker session like the web start page's: saved as running with the
182
+ # 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.
185
+ # @return [Integer] the exit status
186
+ def run_new(prompt, options)
187
+ begin
188
+ session = SessionManager.spawn_session(prompt: prompt, working_directory: options[:dir],
189
+ model_name: options[:model], state_dir: @state_dir)
190
+ rescue StandardError => e
191
+ error_line("chi send: could not start a session: #{e.message}")
192
+ return 1
193
+ end
194
+ @info.puts("#{session.id} started")
195
+ return 0 unless options[:wait]
196
+
197
+ wait_for_reply(session.id, cursor: nil, baseline: { messages: session.messages.size, question_id: nil },
198
+ timeout: options[:timeout])
199
+ end
200
+
201
+ # One existing session, then its next reply. The cursor and baseline
202
+ # are taken before the message goes in, so neither an older reply nor a
203
+ # turn that ends before the first look is mistaken for the answer. A
204
+ # running turn is refused: a message that misses it runs next, and the
205
+ # running one's reply would come back as the answer.
206
+ # @return [Integer] the exit status
207
+ def run_wait(prompt, options)
208
+ id = resolve(options[:ids].first) or return 1
209
+ session = Session.load(id, state_dir: @state_dir)
210
+ if session.status == Session::STATUS_RUNNING && SessionManager.session_owner(id, state_dir: @state_dir)
211
+ error_line("#{id[0, 8]} busy: a turn is running; wait or attach")
212
+ return 1
213
+ end
214
+
215
+ cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
216
+ baseline = { messages: session.messages.size, question_id: session.pending_question&.dig(:id) }
217
+ return 1 unless deliver(id, prompt)
218
+
219
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
220
+ end
221
+
222
+ # --wait with no message: the session's next reply, sending nothing,
223
+ # so an agent can go back to waiting after exit 3 or 130. A running
224
+ # turn is fine here (nothing joins its queue); a question pending now
225
+ # was already reported. No message count: a note landing between turns
226
+ # grows the messages without a turn. An idle session with no worker
227
+ # waits for whatever wakes one (the web, chi send) rather than calling
228
+ # it gone.
229
+ # @return [Integer] the exit status
230
+ def run_wait_only(options)
231
+ id = resolve(options[:ids].first) or return 1
232
+ session = Session.load(id, state_dir: @state_dir)
233
+ live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
234
+ wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
235
+ baseline: { messages: nil, question_id: session.pending_question&.dig(:id) },
236
+ timeout: options[:timeout], owner_grace: live ? WORKER_GONE_AFTER : nil)
237
+ end
238
+
239
+ # @return [Integer] the exit status
240
+ def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: WORKER_GONE_AFTER)
241
+ result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
242
+ owner_grace: owner_grace, poll_interval: POLL_INTERVAL)
243
+ return reply(result.text) if result.status == :done
244
+
245
+ attach = "chi --attach #{id}"
246
+ line, status = case result.status
247
+ when :waiting_for_answer
248
+ question = result.question[:question].to_s.strip.lines.first.to_s.strip
249
+ ["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]
251
+ when :error then ["the worker failed: #{result.text}; #{attach} shows what happened", 1]
252
+ when :worker_gone then ["the worker is gone; #{attach} shows what happened", 1]
253
+ when :stopped then ["the session was stopped (chi sessions stop)", 1]
254
+ else ["still running after #{format("%g", timeout)} s: #{attach}", 1]
255
+ end
256
+ error_line("chi send: #{line}")
257
+ status
258
+ rescue Interrupt
259
+ error_line("chi send: still running: chi --attach #{id}")
260
+ 130
261
+ rescue ArgumentError
262
+ error_line("chi send: the session is gone (deleted while waiting)")
263
+ 1
264
+ end
265
+
266
+ def reply(text)
267
+ @stdout.puts(utf8(text))
268
+ @stdout.flush
269
+ 0
270
+ end
271
+
126
272
  # @return [Boolean] whether the message was queued
127
273
  def deliver(id, prompt)
128
274
  short = id[0, 8]
@@ -130,20 +276,20 @@ module Samagotchi
130
276
  running = owner && Session.load(id, state_dir: @state_dir).status == Session::STATUS_RUNNING
131
277
  result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, state_dir: @state_dir)
132
278
  unless result[:status] == :accepted
133
- @stdout.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
279
+ @info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
134
280
  return false
135
281
  end
136
282
 
137
283
  note = if owner.nil? then " (started its worker)"
138
284
  elsif running then " (the running turn picks it up)"
139
285
  end
140
- @stdout.puts("#{short} sent#{note}")
286
+ @info.puts("#{short} sent#{note}")
141
287
  true
142
288
  rescue SessionManager::OwnedByTUI
143
- @stdout.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
289
+ @info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
144
290
  false
145
291
  rescue StandardError => e
146
- @stdout.puts("#{short} failed: #{e.message}")
292
+ @info.puts("#{short} failed: #{e.message}")
147
293
  false
148
294
  end
149
295