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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
349
|
+
@info.puts("#{short} sent#{with_images}#{note}")
|
|
141
350
|
true
|
|
142
351
|
rescue SessionManager::OwnedByTUI
|
|
143
|
-
@
|
|
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
|
-
@
|
|
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.
|