samagotchi 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -1
- data/README.md +13 -2
- data/bin/chi +29 -39
- data/docs/cli.md +135 -73
- data/docs/configuration.md +15 -20
- data/docs/hooks.md +1 -1
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +9 -6
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +16 -11
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +16 -20
- data/lib/samagotchi/config.rb +40 -100
- data/lib/samagotchi/engine.rb +50 -354
- data/lib/samagotchi/kernel_loop.rb +33 -44
- data/lib/samagotchi/live_versions.rb +7 -1
- data/lib/samagotchi/llm/errors.rb +17 -0
- data/lib/samagotchi/llm/http.rb +4 -18
- data/lib/samagotchi/llm/openai_chat.rb +17 -0
- data/lib/samagotchi/model_profile.rb +4 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +20 -2
- data/lib/samagotchi/send_command.rb +84 -6
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_manager.rb +18 -37
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
- data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +95 -690
- data/lib/samagotchi/thinking.rb +11 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +3 -0
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/update_command.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +170 -35
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +15 -11
- data/lib/samagotchi/web/public/activity.js +7 -0
- data/lib/samagotchi/web/public/app.js +99 -54
- data/lib/samagotchi/web/public/chat_view.js +5 -1
- data/lib/samagotchi/web/public/index.html +163 -17
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +9 -5
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +74 -19
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- metadata +27 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
|
@@ -40,9 +40,15 @@ module Samagotchi
|
|
|
40
40
|
# The version a chi web on host:port runs, or nil when nothing (or not
|
|
41
41
|
# chi web) answers.
|
|
42
42
|
def web_version(host, port, timeout: WEB_TIMEOUT)
|
|
43
|
+
web_info(host, port, timeout: timeout)&.fetch("version", nil).to_s.then { |v| v.empty? ? nil : v }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The /api/info of a chi web on host:port, or nil when nothing (or not
|
|
47
|
+
# chi web) answers.
|
|
48
|
+
def web_info(host, port, timeout: WEB_TIMEOUT)
|
|
43
49
|
response = Net::HTTP.start(host, port, open_timeout: timeout, read_timeout: timeout) { |http| http.get("/api/info") }
|
|
44
50
|
info = response.code.to_i == 200 ? JSON.parse(response.body.to_s) : nil
|
|
45
|
-
info.is_a?(Hash) && info["app"] == "chi-web" ? info
|
|
51
|
+
info.is_a?(Hash) && info["app"] == "chi-web" ? info : nil
|
|
46
52
|
rescue StandardError
|
|
47
53
|
nil
|
|
48
54
|
end
|
|
@@ -283,9 +283,26 @@ module Samagotchi
|
|
|
283
283
|
return nil
|
|
284
284
|
end
|
|
285
285
|
|
|
286
|
+
from_error_json(raw, host: host)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# The error a 200's whole body carries when it is a plain JSON error
|
|
290
|
+
# object instead of a stream (OpenRouter's "503 inside a 200":
|
|
291
|
+
# {"error":{"code":503,"message":"…"}}), or nil for anything else.
|
|
292
|
+
def from_error_body(body, host:)
|
|
293
|
+
parsed = parse_json(body.to_s)
|
|
294
|
+
return nil unless parsed.is_a?(Hash) && parsed.key?("error")
|
|
295
|
+
|
|
296
|
+
from_error_json(body.to_s, host: host)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The ProviderError for a JSON error object, by its error.code (an HTTP
|
|
300
|
+
# status); 500 when it has none.
|
|
301
|
+
def from_error_json(raw, host:)
|
|
286
302
|
parsed = parse_json(raw)
|
|
287
303
|
error = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : parsed
|
|
288
304
|
code = error.is_a?(Hash) ? error["code"] : nil
|
|
305
|
+
code = code.to_i if code.is_a?(String) && code.match?(/\A\d{3}\z/)
|
|
289
306
|
status = code.is_a?(Integer) && code.between?(400, 599) ? code : 500
|
|
290
307
|
from_response(status: status, body: raw, host: host)
|
|
291
308
|
end
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -21,9 +21,6 @@ module Samagotchi
|
|
|
21
21
|
# the listener falls back to raising RequestCancelled in the requesting
|
|
22
22
|
# thread, as Client did before.
|
|
23
23
|
class HTTP
|
|
24
|
-
RETRY_MAX_ENV = "SAMAGOTCHI_RETRY_MAX"
|
|
25
|
-
RETRY_BASE_DELAY_ENV = "SAMAGOTCHI_RETRY_BASE_DELAY"
|
|
26
|
-
RETRY_MAX_DELAY_ENV = "SAMAGOTCHI_RETRY_MAX_DELAY"
|
|
27
24
|
DEFAULT_RETRY_MAX = 5
|
|
28
25
|
DEFAULT_RETRY_BASE_DELAY = 0.5
|
|
29
26
|
DEFAULT_RETRY_MAX_DELAY = 8.0
|
|
@@ -46,15 +43,14 @@ module Samagotchi
|
|
|
46
43
|
# Exponential backoff: base_delay * 2^(attempt-1), capped at max_delay,
|
|
47
44
|
# for up to +max+ retries (max + 1 attempts).
|
|
48
45
|
RetryPolicy = Data.define(:max, :base_delay, :max_delay) do
|
|
49
|
-
# retry.*
|
|
46
|
+
# retry.* (Config); a value out of range is the default.
|
|
50
47
|
def self.from_config
|
|
51
48
|
new(
|
|
52
|
-
max: config_value("retry.max") { |v| v.is_a?(Integer) && v >= 0 } ||
|
|
53
|
-
env_integer(RETRY_MAX_ENV, DEFAULT_RETRY_MAX),
|
|
49
|
+
max: config_value("retry.max") { |v| v.is_a?(Integer) && v >= 0 } || DEFAULT_RETRY_MAX,
|
|
54
50
|
base_delay: config_value("retry.base_delay") { |v| v.is_a?(Numeric) && v.positive? }&.to_f ||
|
|
55
|
-
|
|
51
|
+
DEFAULT_RETRY_BASE_DELAY,
|
|
56
52
|
max_delay: config_value("retry.max_delay") { |v| v.is_a?(Numeric) && v.positive? }&.to_f ||
|
|
57
|
-
|
|
53
|
+
DEFAULT_RETRY_MAX_DELAY
|
|
58
54
|
)
|
|
59
55
|
end
|
|
60
56
|
|
|
@@ -67,16 +63,6 @@ module Samagotchi
|
|
|
67
63
|
nil
|
|
68
64
|
end
|
|
69
65
|
|
|
70
|
-
def self.env_integer(name, default)
|
|
71
|
-
value = ENV.fetch(name, default.to_s).to_i
|
|
72
|
-
value.negative? ? default : value
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def self.env_float(name, default)
|
|
76
|
-
value = ENV.fetch(name, default.to_s).to_f
|
|
77
|
-
value.positive? ? value : default
|
|
78
|
-
end
|
|
79
|
-
|
|
80
66
|
# Seconds to wait before retrying after failed attempt +attempt+
|
|
81
67
|
# (1-based), or nil when the budget is spent.
|
|
82
68
|
def delay_for(attempt)
|
|
@@ -44,6 +44,8 @@ module Samagotchi
|
|
|
44
44
|
# only; the chat loop owns the conversation and the tools.
|
|
45
45
|
class OpenAIChat
|
|
46
46
|
MAX_MODEL_PAGES = 20
|
|
47
|
+
# How much of a 200's non-stream body is read as a possible JSON error.
|
|
48
|
+
PLAIN_ERROR_LIMIT = 64 * 1024
|
|
47
49
|
DEFAULT_MODELS_TTL = 60
|
|
48
50
|
|
|
49
51
|
attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout
|
|
@@ -120,15 +122,18 @@ module Samagotchi
|
|
|
120
122
|
assembly = Assembly.new
|
|
121
123
|
events = 0
|
|
122
124
|
other = +""
|
|
125
|
+
plain = +""
|
|
123
126
|
# A retry streams the answer from the start again.
|
|
124
127
|
restart = lambda do |**event|
|
|
125
128
|
assembly = Assembly.new
|
|
129
|
+
plain = +""
|
|
126
130
|
on_retry&.call(**event)
|
|
127
131
|
end
|
|
128
132
|
@http.stream_lines(URI(request.uri.to_s), request, cancel_controller: cancel_controller, on_retry: restart,
|
|
129
133
|
log_fields: log_fields) do |line, shown|
|
|
130
134
|
events += 1 if line.start_with?("data:")
|
|
131
135
|
other << line[0, 200] if !line.start_with?("data:") && other.length < 200
|
|
136
|
+
raise_plain_error(plain, line) if events.zero?
|
|
132
137
|
payload = parse_line(line)
|
|
133
138
|
next unless payload
|
|
134
139
|
|
|
@@ -256,6 +261,18 @@ module Samagotchi
|
|
|
256
261
|
|
|
257
262
|
# The parsed payload of a `data:` line; nil for blank lines, comments
|
|
258
263
|
# and [DONE]. Error events raise their ProviderError.
|
|
264
|
+
# A 200 whose body is a plain JSON error instead of a stream (OpenRouter
|
|
265
|
+
# sends an upstream 503 so): raised, from inside the stream so it is
|
|
266
|
+
# retried by its kind, once the lines so far parse as an error object.
|
|
267
|
+
def raise_plain_error(plain, line)
|
|
268
|
+
return if line.empty? || line.start_with?(":") || (plain.empty? && !line.start_with?("{"))
|
|
269
|
+
return if plain.bytesize > PLAIN_ERROR_LIMIT
|
|
270
|
+
|
|
271
|
+
plain << line << "\n"
|
|
272
|
+
error = ProviderErrors.from_error_body(plain, host: @host_name)
|
|
273
|
+
raise error if error
|
|
274
|
+
end
|
|
275
|
+
|
|
259
276
|
def parse_line(line)
|
|
260
277
|
error = HTTP.sse_error(line, host: @host_name)
|
|
261
278
|
raise error if error
|
|
@@ -116,18 +116,12 @@ module Samagotchi
|
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
def self.required_model_name(model_name = nil
|
|
119
|
+
def self.required_model_name(model_name = nil)
|
|
120
120
|
value = model_name.to_s.strip
|
|
121
121
|
if value.empty?
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
cfg = Samagotchi::Config.get("default.model") rescue nil
|
|
125
|
-
value = cfg.to_s.strip unless cfg.nil? || cfg.to_s.strip.empty?
|
|
126
|
-
rescue StandardError
|
|
127
|
-
nil
|
|
128
|
-
end
|
|
122
|
+
require_relative "config"
|
|
123
|
+
value = Samagotchi::Config.get("default.model").to_s.strip
|
|
129
124
|
end
|
|
130
|
-
value = env[MODEL_ENV].to_s.strip if value.empty?
|
|
131
125
|
raise MissingModel, missing_model_message if value.empty?
|
|
132
126
|
|
|
133
127
|
value
|
|
@@ -181,7 +175,7 @@ module Samagotchi
|
|
|
181
175
|
end
|
|
182
176
|
|
|
183
177
|
def self.from_env
|
|
184
|
-
from_model_name(required_model_name(nil
|
|
178
|
+
from_model_name(required_model_name(nil))
|
|
185
179
|
end
|
|
186
180
|
|
|
187
181
|
# ── Resolution: which profile a model gets ───────────────────────────
|
|
@@ -102,7 +102,8 @@ module Samagotchi
|
|
|
102
102
|
|
|
103
103
|
# --all: every live session. @return [Integer] exit status
|
|
104
104
|
def deliver_to_live(text, source)
|
|
105
|
-
ids = SessionManager.session_summaries(live: true, include_tests:
|
|
105
|
+
ids = SessionManager.session_summaries(live: true, include_tests: Session.test_session_env?,
|
|
106
|
+
state_dir: @state_dir).map { |s| s[:id] }
|
|
106
107
|
if ids.empty?
|
|
107
108
|
error_line("chi note: no live sessions (chi sessions list --live --scope=all)")
|
|
108
109
|
return 1
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "session"
|
|
4
|
+
require_relative "turn_note"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
# Loaded on first use: session_manager requires terminal_ui, which
|
|
@@ -23,7 +24,11 @@ module Samagotchi
|
|
|
23
24
|
# @!attribute file [String, nil] the reply's filename, the next cursor
|
|
24
25
|
# @!attribute question [Hash, nil] the pending question
|
|
25
26
|
# (:waiting_for_answer)
|
|
26
|
-
|
|
27
|
+
# @!attribute outcome [String, nil] how the turn ended when it left no
|
|
28
|
+
# reply (:no_reply): "failed", "canceled" or "completed" (empty), from
|
|
29
|
+
# the session's last_turn; nil when unknown. With "failed", text is
|
|
30
|
+
# the failure's summary when the turn note has one.
|
|
31
|
+
Result = Struct.new(:status, :text, :file, :question, :outcome, keyword_init: true)
|
|
27
32
|
|
|
28
33
|
module_function
|
|
29
34
|
|
|
@@ -38,7 +43,10 @@ module Samagotchi
|
|
|
38
43
|
# messages (a failed, canceled or empty one leaves its note) and went
|
|
39
44
|
# idle again ends the wait even if it was never seen running, and a
|
|
40
45
|
# question already pending then is not the answer's. A nil
|
|
41
|
-
# messages: skips the count.
|
|
46
|
+
# messages: skips the count. last_turn: (the session's
|
|
47
|
+
# last_turn["ended_at"] then, maybe nil) ends the wait on any turn
|
|
48
|
+
# that ended since, idle again, however fast it ran: a failure after
|
|
49
|
+
# a failure replaces the note and leaves the count as it was.
|
|
42
50
|
# @param owner_grace [Numeric, nil] seconds with no live worker before
|
|
43
51
|
# :worker_gone (one that died before its rescue leaves it running)
|
|
44
52
|
# @return [Result]
|
|
@@ -72,13 +80,13 @@ module Samagotchi
|
|
|
72
80
|
|
|
73
81
|
if session.status == Session::STATUS_RUNNING
|
|
74
82
|
seen_running = true
|
|
75
|
-
elsif seen_running || (
|
|
83
|
+
elsif seen_running || turn_ended_since?(session, baseline)
|
|
76
84
|
# It ran and is idle again with no new reply: canceled, failed or
|
|
77
85
|
# empty. Before it was ever seen running, idle means a
|
|
78
86
|
# file-delivered message its worker has not picked up yet.
|
|
79
87
|
# The worker writes the reply before its idle save, so the read
|
|
80
88
|
# above has it; one more look costs nothing should that change.
|
|
81
|
-
return reply_past(id, state_dir: state_dir, cursor: cursor) ||
|
|
89
|
+
return reply_past(id, state_dir: state_dir, cursor: cursor) || no_reply(session, baseline)
|
|
82
90
|
end
|
|
83
91
|
|
|
84
92
|
if owner_grace
|
|
@@ -95,6 +103,42 @@ module Samagotchi
|
|
|
95
103
|
end
|
|
96
104
|
end
|
|
97
105
|
|
|
106
|
+
# An idle session whose turn ended after the baseline was taken: its
|
|
107
|
+
# last_turn moved on, or its messages grew (a turn's note).
|
|
108
|
+
def turn_ended_since?(session, baseline)
|
|
109
|
+
return false unless baseline
|
|
110
|
+
|
|
111
|
+
if baseline.key?(:last_turn)
|
|
112
|
+
ended = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
|
|
113
|
+
return true if ended && ended != baseline[:last_turn]
|
|
114
|
+
end
|
|
115
|
+
!!(baseline[:messages] && session.messages.size > baseline[:messages])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [Result] :no_reply with the outcome the session's last_turn
|
|
119
|
+
# records, when it is the turn waited for
|
|
120
|
+
def no_reply(session, baseline)
|
|
121
|
+
last = session.last_turn.is_a?(Hash) ? session.last_turn : {}
|
|
122
|
+
fresh = baseline.nil? || !baseline.key?(:last_turn) || (last["ended_at"] && last["ended_at"] != baseline[:last_turn])
|
|
123
|
+
outcome = fresh ? last["outcome"] : nil
|
|
124
|
+
text = outcome == "failed" ? failure_summary(session.messages) : nil
|
|
125
|
+
Result.new(status: :no_reply, outcome: outcome, text: text)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The summary in the failed-turn note at the conversation's tail, nil
|
|
129
|
+
# without one.
|
|
130
|
+
def failure_summary(messages)
|
|
131
|
+
list = Array(messages)
|
|
132
|
+
index = TurnNote.trailing_index(list)
|
|
133
|
+
return nil unless index
|
|
134
|
+
|
|
135
|
+
text = (list[index][:content] || list[index]["content"]).to_s
|
|
136
|
+
prefix = "#{TurnNote::OPEN}#{TurnNote::FAILED}"
|
|
137
|
+
return nil unless text.start_with?(prefix)
|
|
138
|
+
|
|
139
|
+
text[prefix.length..].sub(/\. [^.]*\.\]\z/, "")
|
|
140
|
+
end
|
|
141
|
+
|
|
98
142
|
# @return [Result, nil] :done with the newest reply past the cursor
|
|
99
143
|
def reply_past(id, state_dir:, cursor:)
|
|
100
144
|
file = newest_reply(id, state_dir: state_dir)
|
|
@@ -16,12 +16,15 @@ require_relative "memory_bundle/provenance"
|
|
|
16
16
|
require_relative "memory_bundle/manifest"
|
|
17
17
|
require_relative "memory_bundle/system_bundle"
|
|
18
18
|
require_relative "desktop"
|
|
19
|
+
require_relative "live_versions"
|
|
20
|
+
require_relative "web/lan"
|
|
19
21
|
|
|
20
22
|
module Samagotchi
|
|
21
23
|
# `chi self`: where this chi lives and what it is configured to use.
|
|
22
24
|
#
|
|
23
25
|
# Read-only and nearly offline (one short /props GET for the served model,
|
|
24
|
-
# nothing else asks a model server
|
|
26
|
+
# nothing else asks a model server; one /api/info GET on this machine's
|
|
27
|
+
# chi web port), so the agent can run it via `execute`
|
|
25
28
|
# to orient itself — source dir to rg, config path, memory dirs, sessions,
|
|
26
29
|
# model/host, bundle versions — without guessing from $PATH.
|
|
27
30
|
module SelfReport
|
|
@@ -55,10 +58,25 @@ module Samagotchi
|
|
|
55
58
|
["served model", model ? served_model_for(model, env) : "-"],
|
|
56
59
|
["context window", context_window(env)],
|
|
57
60
|
["bundles", bundles_summary],
|
|
58
|
-
["desktop", desktop_summary(env)]
|
|
61
|
+
["desktop", desktop_summary(env)],
|
|
62
|
+
["chi web", web_summary]
|
|
59
63
|
]
|
|
60
64
|
end
|
|
61
65
|
|
|
66
|
+
WEB_TIMEOUT = 0.3
|
|
67
|
+
|
|
68
|
+
# Whether a chi web answers on this machine's web port, and whether a
|
|
69
|
+
# phone can reach it (LAN mode: /api/info's lan).
|
|
70
|
+
def web_summary
|
|
71
|
+
port = Config.get("web.port").to_i
|
|
72
|
+
port = 4567 unless port.positive?
|
|
73
|
+
info = LiveVersions.web_info(Web::Lan.local_host(Config.get("web.host")), port, timeout: WEB_TIMEOUT)
|
|
74
|
+
return "not running on port #{port}" unless info
|
|
75
|
+
return "LAN on #{info["lan"]}:#{port} (and 127.0.0.1)" if info["lan"]
|
|
76
|
+
|
|
77
|
+
"on 127.0.0.1:#{port} (this machine only)"
|
|
78
|
+
end
|
|
79
|
+
|
|
62
80
|
# "git checkout" when running from a repo (bin/chi), "installed gem" when
|
|
63
81
|
# under a gem path; the installed gem's files are not meant to be edited.
|
|
64
82
|
def install_kind(dir = SOURCE_DIR)
|
|
@@ -5,6 +5,9 @@ require_relative "session_manager"
|
|
|
5
5
|
require_relative "context_quote"
|
|
6
6
|
require_relative "reply_wait"
|
|
7
7
|
require_relative "image_store"
|
|
8
|
+
require_relative "host_registry"
|
|
9
|
+
require_relative "model_profile"
|
|
10
|
+
require_relative "vision_support"
|
|
8
11
|
|
|
9
12
|
module Samagotchi
|
|
10
13
|
# `chi send`: put text into sessions as the user's message, the same as
|
|
@@ -36,7 +39,8 @@ module Samagotchi
|
|
|
36
39
|
an image sent with the message (png, jpeg, gif, webp;
|
|
37
40
|
others converted, large ones downscaled); repeat for
|
|
38
41
|
more, up to 20. Needs text too (-m or stdin) and a
|
|
39
|
-
model that sees images
|
|
42
|
+
model that sees images: a session whose model is
|
|
43
|
+
known not to is refused before anything is sent
|
|
40
44
|
--new start a new session with the message instead, as the
|
|
41
45
|
web does, and print its id
|
|
42
46
|
--dir DIR (--new) its folder, the project it belongs to; default
|
|
@@ -54,6 +58,40 @@ module Samagotchi
|
|
|
54
58
|
Find ids with: chi sessions list --live [--scope=all] [--format tsv]
|
|
55
59
|
TEXT
|
|
56
60
|
|
|
61
|
+
# Whether +model_name+ (a session's; blank: the configured default)
|
|
62
|
+
# takes images, decided as its worker decides before a turn with images
|
|
63
|
+
# (Engine#turn_vision: VisionSupport, with models.<name>.vision under
|
|
64
|
+
# the name as given (maybe an alias), the alias's target, the part
|
|
65
|
+
# after a host prefix and the bare name, as after /model). A native
|
|
66
|
+
# host whose /props doesn't answer is unknown here, not a no: the
|
|
67
|
+
# worker asks again when the turn runs.
|
|
68
|
+
# @return [VisionSupport::Answer] value nil when unknown
|
|
69
|
+
def self.vision_answer(model_name, registry: nil)
|
|
70
|
+
registry ||= HostRegistry.new
|
|
71
|
+
name = ModelProfile.required_model_name(model_name)
|
|
72
|
+
aliased = ConfigFile.resolve_model_alias(name)
|
|
73
|
+
target = registry.resolve(aliased)
|
|
74
|
+
names = [name, aliased, registry.parse_qualified_model(name).last, target.bare_model].compact.uniq
|
|
75
|
+
entry = target.entry
|
|
76
|
+
return VisionSupport.for(target, adapter: registry.adapter_for(entry), names: names) if entry.chat?
|
|
77
|
+
|
|
78
|
+
profile = ModelProfile.resolve(names: names, entry: entry, client: target.client, bare_model: target.bare_model).profile
|
|
79
|
+
answer = VisionSupport.for(target, profile: profile, names: names)
|
|
80
|
+
props = target.client.respond_to?(:server_props) ? target.client.server_props(model: target.bare_model) : nil
|
|
81
|
+
return UNKNOWN_VISION if answer.no? && !props&.answered? && !configured_no?(answer)
|
|
82
|
+
|
|
83
|
+
answer
|
|
84
|
+
rescue StandardError
|
|
85
|
+
UNKNOWN_VISION
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
UNKNOWN_VISION = VisionSupport::Answer.new(value: nil, reason: nil)
|
|
89
|
+
|
|
90
|
+
# A no from the config (models: / hosts: vision: false) holds without
|
|
91
|
+
# the server.
|
|
92
|
+
def self.configured_no?(answer) = answer.reason.to_s.include?("sets vision: false")
|
|
93
|
+
private_class_method :configured_no?
|
|
94
|
+
|
|
57
95
|
# @param argv [Array<String>] the arguments after "send"
|
|
58
96
|
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil)
|
|
59
97
|
@argv = argv.dup
|
|
@@ -205,6 +243,10 @@ module Samagotchi
|
|
|
205
243
|
# @return [Integer] the exit status
|
|
206
244
|
def run_new(prompt, options)
|
|
207
245
|
images = !@images.empty?
|
|
246
|
+
if images && (refusal = vision_refusal(options[:model]))
|
|
247
|
+
error_line("chi send: refused: #{refusal}")
|
|
248
|
+
return 1
|
|
249
|
+
end
|
|
208
250
|
begin
|
|
209
251
|
# With images the web's way: idle (the message names it in the
|
|
210
252
|
# lists), the images copied in, then the message as a turn.
|
|
@@ -222,7 +264,7 @@ module Samagotchi
|
|
|
222
264
|
end
|
|
223
265
|
return 0 unless options[:wait]
|
|
224
266
|
|
|
225
|
-
wait_for_reply(session.id, cursor: nil, baseline:
|
|
267
|
+
wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
|
|
226
268
|
timeout: options[:timeout])
|
|
227
269
|
end
|
|
228
270
|
|
|
@@ -241,7 +283,7 @@ module Samagotchi
|
|
|
241
283
|
end
|
|
242
284
|
|
|
243
285
|
cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
|
|
244
|
-
baseline =
|
|
286
|
+
baseline = baseline_of(session)
|
|
245
287
|
return 1 unless deliver(id, prompt)
|
|
246
288
|
|
|
247
289
|
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
|
|
@@ -260,10 +302,19 @@ module Samagotchi
|
|
|
260
302
|
session = Session.load(id, state_dir: @state_dir)
|
|
261
303
|
live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
|
|
262
304
|
wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
|
|
263
|
-
baseline:
|
|
305
|
+
baseline: baseline_of(session).merge(messages: nil),
|
|
264
306
|
timeout: options[:timeout], owner_grace: live ? WORKER_GONE_AFTER : nil)
|
|
265
307
|
end
|
|
266
308
|
|
|
309
|
+
# The session as it was before the message went in (ReplyWait's
|
|
310
|
+
# baseline): its messages, the question pending then, and when its last
|
|
311
|
+
# turn ended, so a turn that ends before the first look still ends the
|
|
312
|
+
# wait.
|
|
313
|
+
def baseline_of(session, question_id: session.pending_question&.dig(:id))
|
|
314
|
+
last = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
|
|
315
|
+
{ messages: session.messages.size, question_id: question_id, last_turn: last }
|
|
316
|
+
end
|
|
317
|
+
|
|
267
318
|
# @return [Integer] the exit status
|
|
268
319
|
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: WORKER_GONE_AFTER)
|
|
269
320
|
result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
|
|
@@ -275,7 +326,7 @@ module Samagotchi
|
|
|
275
326
|
when :waiting_for_answer
|
|
276
327
|
question = result.question[:question].to_s.strip.lines.first.to_s.strip
|
|
277
328
|
["waiting for an answer: #{question}; open it: #{attach} or the web", 3]
|
|
278
|
-
when :no_reply then ["
|
|
329
|
+
when :no_reply then ["#{no_reply_line(result)}; #{attach} shows it", 1]
|
|
279
330
|
when :error then ["the worker failed: #{result.text}; #{attach} shows what happened", 1]
|
|
280
331
|
when :worker_gone then ["the worker is gone; #{attach} shows what happened", 1]
|
|
281
332
|
when :stopped then ["the session was stopped (chi sessions stop)", 1]
|
|
@@ -291,6 +342,15 @@ module Samagotchi
|
|
|
291
342
|
1
|
|
292
343
|
end
|
|
293
344
|
|
|
345
|
+
def no_reply_line(result)
|
|
346
|
+
case result.outcome
|
|
347
|
+
when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
|
|
348
|
+
when "canceled" then "the turn was canceled"
|
|
349
|
+
when "completed" then "the turn ended with no visible answer"
|
|
350
|
+
else "the turn ended without a reply (canceled, failed or empty)"
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
294
354
|
def reply(text)
|
|
295
355
|
@stdout.puts(utf8(text))
|
|
296
356
|
@stdout.flush
|
|
@@ -332,8 +392,13 @@ module Samagotchi
|
|
|
332
392
|
# @return [Boolean] whether the message was queued
|
|
333
393
|
def deliver(id, prompt)
|
|
334
394
|
short = id[0, 8]
|
|
395
|
+
session = Session.load(id, state_dir: @state_dir)
|
|
396
|
+
if !@images.empty? && (refusal = vision_refusal(session.model_name))
|
|
397
|
+
@info.puts("#{short} refused: #{refusal}")
|
|
398
|
+
return false
|
|
399
|
+
end
|
|
335
400
|
owner = SessionManager.session_owner(id, state_dir: @state_dir)
|
|
336
|
-
running = owner &&
|
|
401
|
+
running = owner && session.status == Session::STATUS_RUNNING
|
|
337
402
|
refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
|
|
338
403
|
result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir)
|
|
339
404
|
unless result[:status] == :accepted
|
|
@@ -356,6 +421,19 @@ module Samagotchi
|
|
|
356
421
|
false
|
|
357
422
|
end
|
|
358
423
|
|
|
424
|
+
# What to say when +model_name+ is known not to take images, or nil
|
|
425
|
+
# (it does, or it is unknown: sent as before). Asked once per model.
|
|
426
|
+
def vision_refusal(model_name)
|
|
427
|
+
@vision ||= {}
|
|
428
|
+
answer = @vision[model_name.to_s] ||= self.class.vision_answer(model_name)
|
|
429
|
+
return nil unless answer.no?
|
|
430
|
+
|
|
431
|
+
name = model_name.to_s.strip
|
|
432
|
+
name = Samagotchi::Config.get("default.model").to_s.strip if name.empty?
|
|
433
|
+
name = "the model" if name.empty?
|
|
434
|
+
"#{name} can't take images (#{answer.reason}); send text only or switch the model (/model)"
|
|
435
|
+
end
|
|
436
|
+
|
|
359
437
|
# Each --image read, converted and downscaled once, into a scratch
|
|
360
438
|
# session dir; each target then gets copies of the stored files. A file
|
|
361
439
|
# that can't be sent stops everything before anything is sent.
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -22,11 +22,13 @@ module Samagotchi
|
|
|
22
22
|
STATUS_ERROR = "error"
|
|
23
23
|
STATUS_STOPPED = "stopped"
|
|
24
24
|
|
|
25
|
-
# Retention
|
|
25
|
+
# Retention defaults, the session.retention_days / max_count /
|
|
26
|
+
# keep_status defaults (SessionManager resolves the settings).
|
|
26
27
|
DEFAULT_RETENTION_DAYS = 14
|
|
27
28
|
DEFAULT_MAX_COUNT = 500
|
|
28
29
|
# status is turn state; a live owner (the prune alive_check) is what
|
|
29
|
-
# protects a session in use, so no status is kept by default
|
|
30
|
+
# protects a session in use, so no status is kept by default: a
|
|
31
|
+
# "running" left by a crashed worker must not keep it forever.
|
|
30
32
|
DEFAULT_KEEP_STATUS = [].freeze
|
|
31
33
|
SORT_KEYS = %w[created_at updated_at].freeze
|
|
32
34
|
SORT_ORDERS = %w[asc desc].freeze
|
|
@@ -173,10 +173,10 @@ module Samagotchi
|
|
|
173
173
|
end
|
|
174
174
|
|
|
175
175
|
# Build the opts hash passed to Process.spawn for a forked worker. The
|
|
176
|
-
# child inherits this process's ENV
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
# (
|
|
176
|
+
# child inherits this process's ENV and reads config.yml itself (as it
|
|
177
|
+
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
178
|
+
# replaced) what it can't read there: this `chi`'s CLI settings
|
|
179
|
+
# (Config.cli_env), the hosts and the absolute log file.
|
|
180
180
|
private_class_method def self.spawn_options(session)
|
|
181
181
|
# Own process group: workers outlive `chi web`, and a Ctrl-C in its
|
|
182
182
|
# terminal must not reach them.
|
|
@@ -198,11 +198,9 @@ module Samagotchi
|
|
|
198
198
|
rescue StandardError
|
|
199
199
|
nil
|
|
200
200
|
end
|
|
201
|
-
#
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
idle_exit = config_idle_exit_minutes
|
|
205
|
-
child_env["SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES"] = idle_exit.to_s unless idle_exit.nil?
|
|
201
|
+
# A worker gets no CLI args: --model, --thinking, --log-level and the
|
|
202
|
+
# other flags this chi was started with.
|
|
203
|
+
child_env.merge!(Config.cli_env)
|
|
206
204
|
# And the spawner's debug log, absolute: a relative log.file would
|
|
207
205
|
# otherwise land in the worker's (the session's) directory.
|
|
208
206
|
log_path = begin LogPath.resolve rescue nil end
|
|
@@ -211,9 +209,6 @@ module Samagotchi
|
|
|
211
209
|
else
|
|
212
210
|
child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
|
|
213
211
|
end
|
|
214
|
-
# And its level (a --log-level flag isn't in the worker's own config).
|
|
215
|
-
level = begin Config.get("log.level") rescue nil end
|
|
216
|
-
child_env["SAMAGOTCHI_LOG_LEVEL"] = level.to_s if level
|
|
217
212
|
opts[:env] = child_env unless child_env.empty?
|
|
218
213
|
opts
|
|
219
214
|
end
|
|
@@ -246,7 +241,7 @@ module Samagotchi
|
|
|
246
241
|
# @param include_archived [Boolean] archived sessions too
|
|
247
242
|
# @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
|
|
248
243
|
# updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
|
|
249
|
-
# parent_short_id:, archived:, scratch:}; busy = live with
|
|
244
|
+
# parent_short_id:, archived:, scratch:, test_run:}; busy = live with
|
|
250
245
|
# a turn running, recap = the saved recap's first sentence, project =
|
|
251
246
|
# Session#project_root
|
|
252
247
|
def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
@@ -268,7 +263,7 @@ module Samagotchi
|
|
|
268
263
|
owner: owner, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
|
|
269
264
|
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
|
|
270
265
|
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), archived: s.archived,
|
|
271
|
-
scratch: s.scratch }
|
|
266
|
+
scratch: s.scratch, test_run: s.test_run }
|
|
272
267
|
end
|
|
273
268
|
(limit ? summaries.first(limit) : summaries.to_a)
|
|
274
269
|
end
|
|
@@ -455,12 +450,8 @@ module Samagotchi
|
|
|
455
450
|
sd = state_dir || Session.default_state_dir
|
|
456
451
|
return unless Dir.exist?(sd)
|
|
457
452
|
|
|
458
|
-
|
|
459
|
-
interval =
|
|
460
|
-
cfg_interval.to_i * 3600
|
|
461
|
-
else
|
|
462
|
-
(ENV.fetch("SAMAGOTCHI_SESSION_SWEEP_INTERVAL_HOURS", RETENTION_SWEEP_INTERVAL_HOURS.to_s).to_i * 3600)
|
|
463
|
-
end
|
|
453
|
+
hours = Samagotchi::Config.get("session.sweep_interval_hours").to_i
|
|
454
|
+
interval = (hours.positive? ? hours : RETENTION_SWEEP_INTERVAL_HOURS) * 3600
|
|
464
455
|
marker = File.join(sd, RETENTION_MARKER)
|
|
465
456
|
if File.exist?(marker)
|
|
466
457
|
age = Time.now - File.mtime(marker)
|
|
@@ -477,32 +468,22 @@ module Samagotchi
|
|
|
477
468
|
nil
|
|
478
469
|
end
|
|
479
470
|
|
|
471
|
+
# The caller's value (a sessions prune flag) or session.retention_days.
|
|
480
472
|
private_class_method def self.resolve_retention_days(val)
|
|
481
473
|
return val.to_i if !val.nil? && val.to_s.strip != ""
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
env = ENV["SAMAGOTCHI_SESSION_RETENTION_DAYS"]
|
|
485
|
-
return env.to_i if env && !env.strip.empty?
|
|
486
|
-
Session::DEFAULT_RETENTION_DAYS
|
|
474
|
+
|
|
475
|
+
Samagotchi::Config.get("session.retention_days").to_i
|
|
487
476
|
end
|
|
488
477
|
|
|
489
478
|
private_class_method def self.resolve_retention_max_count(val)
|
|
490
479
|
return val.to_i if !val.nil? && val.to_s.strip != ""
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
env = ENV["SAMAGOTCHI_SESSION_MAX_COUNT"]
|
|
494
|
-
return env.to_i if env && !env.strip.empty?
|
|
495
|
-
Session::DEFAULT_MAX_COUNT
|
|
480
|
+
|
|
481
|
+
Samagotchi::Config.get("session.max_count").to_i
|
|
496
482
|
end
|
|
497
483
|
|
|
484
|
+
# A comma list of statuses never pruned; "" keeps none.
|
|
498
485
|
private_class_method def self.resolve_retention_keep_status(val)
|
|
499
|
-
raw =
|
|
500
|
-
val.to_s
|
|
501
|
-
else
|
|
502
|
-
cfg = begin Samagotchi::Config.get("session.keep_status") rescue nil end
|
|
503
|
-
cfg && !cfg.to_s.strip.empty? ? cfg.to_s : (ENV["SAMAGOTCHI_SESSION_KEEP_STATUS"] || ENV["SAMAGOTCHI_SESSION_RETENTION_KEEP_STATUS"])
|
|
504
|
-
end
|
|
505
|
-
return Session::DEFAULT_KEEP_STATUS if raw.nil? || raw.strip.empty?
|
|
486
|
+
raw = !val.nil? && val.to_s.strip != "" ? val.to_s : Samagotchi::Config.get("session.keep_status").to_s
|
|
506
487
|
raw.split(",").map(&:strip).reject(&:empty?)
|
|
507
488
|
end
|
|
508
489
|
|