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