samagotchi 0.3.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 +83 -1
- data/README.md +16 -0
- data/bin/chi +32 -31
- data/docs/cli.md +77 -5
- data/docs/configuration.md +104 -2
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +88 -6
- data/docs/releasing.md +6 -6
- data/docs/sessions.md +19 -17
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +4 -1
- 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 +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/client.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +64 -20
- 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 +201 -104
- 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 +26 -79
- data/lib/samagotchi/live_versions.rb +59 -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 +21 -7
- data/lib/samagotchi/llm/http.rb +15 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -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 +23 -0
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/self_report.rb +17 -3
- data/lib/samagotchi/send_command.rb +107 -12
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +1 -16
- data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +62 -248
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- 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 +6 -4
- data/lib/samagotchi/web/app.rb +3 -3
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +36 -24
- data/lib/samagotchi/web/public/chat_view.js +3 -0
- 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 +22 -1
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/turn_events.js +29 -5
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/worker.rb +5 -4
- metadata +12 -1
|
@@ -4,6 +4,7 @@ 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"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
# `chi send`: put text into sessions as the user's message, the same as
|
|
@@ -19,16 +20,23 @@ module Samagotchi
|
|
|
19
20
|
# No live worker this long while waiting: it died before it could mark
|
|
20
21
|
# the session (a worker takes well under a second to start).
|
|
21
22
|
WORKER_GONE_AFTER = 5
|
|
23
|
+
# The Bridge's cap on one turn's images (Bridge::MAX_TURN_IMAGES).
|
|
24
|
+
MAX_IMAGES = 20
|
|
22
25
|
|
|
23
26
|
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)
|
|
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)
|
|
27
30
|
chi send --wait [--timeout S] ID
|
|
28
31
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
29
32
|
or a running one picks it up. A stopped session's worker starts.
|
|
30
33
|
-m TEXT the message; stdin, when piped too, goes above it as a
|
|
31
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
|
|
32
40
|
--new start a new session with the message instead, as the
|
|
33
41
|
web does, and print its id
|
|
34
42
|
--dir DIR (--new) its folder, the project it belongs to; default
|
|
@@ -65,6 +73,11 @@ module Samagotchi
|
|
|
65
73
|
# With --wait stdout is the answer alone.
|
|
66
74
|
@info = options[:wait] ? @stderr : @stdout
|
|
67
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
|
|
68
81
|
return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
|
|
69
82
|
unless prompt
|
|
70
83
|
usage_error("no message: pass -m TEXT or pipe it in")
|
|
@@ -76,6 +89,7 @@ module Samagotchi
|
|
|
76
89
|
@stderr.puts("chi send: #{e.message}")
|
|
77
90
|
return 1
|
|
78
91
|
end
|
|
92
|
+
return 2 unless ingest_images(options[:images])
|
|
79
93
|
|
|
80
94
|
return run_new(prompt, options) if options[:new]
|
|
81
95
|
return run_wait(prompt, options) if options[:wait]
|
|
@@ -91,12 +105,14 @@ module Samagotchi
|
|
|
91
105
|
deliver(id, prompt)
|
|
92
106
|
end
|
|
93
107
|
results.all? ? 0 : 1
|
|
108
|
+
ensure
|
|
109
|
+
FileUtils.rm_rf(@image_dir) if @image_dir
|
|
94
110
|
end
|
|
95
111
|
|
|
96
112
|
private
|
|
97
113
|
|
|
98
114
|
def parse
|
|
99
|
-
options = { ids: [] }
|
|
115
|
+
options = { ids: [], images: [] }
|
|
100
116
|
until @argv.empty?
|
|
101
117
|
arg = @argv.shift
|
|
102
118
|
case arg
|
|
@@ -110,6 +126,8 @@ module Samagotchi
|
|
|
110
126
|
when "--dir", "--model"
|
|
111
127
|
options[arg.delete_prefix("--").to_sym] = @argv.shift or return usage_error("#{arg} needs a value")
|
|
112
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)
|
|
113
131
|
when "--wait" then options[:wait] = true
|
|
114
132
|
when "--timeout" then options[:timeout] = @argv.shift or return usage_error("#{arg} needs a value")
|
|
115
133
|
when /\A--timeout=(.*)\z/ then options[:timeout] = Regexp.last_match(1)
|
|
@@ -127,6 +145,7 @@ module Samagotchi
|
|
|
127
145
|
return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
|
|
128
146
|
end
|
|
129
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
|
|
130
149
|
return new_options(options) if options[:new]
|
|
131
150
|
%i[dir model].each { |key| return usage_error("--#{key} needs --new") if options[key] }
|
|
132
151
|
return usage_error("give session ids") if options[:ids].empty?
|
|
@@ -180,18 +199,27 @@ module Samagotchi
|
|
|
180
199
|
|
|
181
200
|
# A worker session like the web start page's: saved as running with the
|
|
182
201
|
# 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 (
|
|
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.
|
|
185
205
|
# @return [Integer] the exit status
|
|
186
206
|
def run_new(prompt, options)
|
|
207
|
+
images = !@images.empty?
|
|
187
208
|
begin
|
|
188
|
-
|
|
189
|
-
|
|
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)
|
|
190
214
|
rescue StandardError => e
|
|
191
215
|
error_line("chi send: could not start a session: #{e.message}")
|
|
192
216
|
return 1
|
|
193
217
|
end
|
|
194
|
-
|
|
218
|
+
if images
|
|
219
|
+
return 1 unless deliver_new(session, prompt)
|
|
220
|
+
else
|
|
221
|
+
@info.puts("#{session.id} started")
|
|
222
|
+
end
|
|
195
223
|
return 0 unless options[:wait]
|
|
196
224
|
|
|
197
225
|
wait_for_reply(session.id, cursor: nil, baseline: { messages: session.messages.size, question_id: nil },
|
|
@@ -269,21 +297,56 @@ module Samagotchi
|
|
|
269
297
|
0
|
|
270
298
|
end
|
|
271
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
|
+
|
|
272
332
|
# @return [Boolean] whether the message was queued
|
|
273
333
|
def deliver(id, prompt)
|
|
274
334
|
short = id[0, 8]
|
|
275
335
|
owner = SessionManager.session_owner(id, state_dir: @state_dir)
|
|
276
336
|
running = owner && Session.load(id, state_dir: @state_dir).status == Session::STATUS_RUNNING
|
|
277
|
-
|
|
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)
|
|
278
339
|
unless result[:status] == :accepted
|
|
279
340
|
@info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
|
|
280
341
|
return false
|
|
281
342
|
end
|
|
282
343
|
|
|
344
|
+
# A busy worker runs a message with images as its own next turn
|
|
345
|
+
# rather than merging it into the running one.
|
|
283
346
|
note = if owner.nil? then " (started its worker)"
|
|
284
|
-
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)"
|
|
285
348
|
end
|
|
286
|
-
@info.puts("#{short} sent#{note}")
|
|
349
|
+
@info.puts("#{short} sent#{with_images}#{note}")
|
|
287
350
|
true
|
|
288
351
|
rescue SessionManager::OwnedByTUI
|
|
289
352
|
@info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
|
|
@@ -293,6 +356,38 @@ module Samagotchi
|
|
|
293
356
|
false
|
|
294
357
|
end
|
|
295
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}")
|
|
372
|
+
false
|
|
373
|
+
end
|
|
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
|
+
|
|
296
391
|
# The text as UTF-8 whatever the locale says: with no LANG/LC_* (an app
|
|
297
392
|
# started from Finder, launchd) stdin reads as US-ASCII and ARGV as
|
|
298
393
|
# binary. Invalid bytes become U+FFFD rather than an error.
|
|
@@ -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.
|
|
@@ -148,7 +148,7 @@ module Samagotchi
|
|
|
148
148
|
sd = state_dir || Session.default_state_dir
|
|
149
149
|
session = Session.new_session(
|
|
150
150
|
mode: mode,
|
|
151
|
-
model_name:
|
|
151
|
+
model_name: Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name)),
|
|
152
152
|
working_directory: working_directory || Dir.pwd,
|
|
153
153
|
preloaded_memory_names: memories,
|
|
154
154
|
muted_memory_names: muted_memories,
|
|
@@ -697,21 +697,6 @@ module Samagotchi
|
|
|
697
697
|
true
|
|
698
698
|
end
|
|
699
699
|
|
|
700
|
-
# Wait for a session to reach a terminal state (completed, error, stopped).
|
|
701
|
-
# Returns true if the session finished, false if the timeout elapsed.
|
|
702
|
-
def self.wait_for_session(session_id, timeout: 30, state_dir: nil)
|
|
703
|
-
sd = state_dir || Session.default_state_dir
|
|
704
|
-
elapsed = 0
|
|
705
|
-
while elapsed < timeout
|
|
706
|
-
session = Session.load(session_id, state_dir: sd)
|
|
707
|
-
return true if %w[completed error stopped].include?(session.status)
|
|
708
|
-
|
|
709
|
-
sleep(0.5)
|
|
710
|
-
elapsed += 0.5
|
|
711
|
-
end
|
|
712
|
-
false
|
|
713
|
-
end
|
|
714
|
-
|
|
715
700
|
# Run the session loop inside the forked process.
|
|
716
701
|
# This is the entry point called by Process.spawn.
|
|
717
702
|
#
|
|
@@ -31,19 +31,9 @@ module Samagotchi
|
|
|
31
31
|
include Formatting
|
|
32
32
|
include InputSupport
|
|
33
33
|
|
|
34
|
-
STATS_COMMAND = "/stats"
|
|
35
|
-
RECAP_COMMAND = "/recap"
|
|
36
34
|
PROMPT = "> "
|
|
37
|
-
# Detach and leave the worker up.
|
|
38
|
-
DETACH_COMMANDS = %w[/detach].freeze
|
|
39
|
-
# Detach and ask the worker to exit (bare `exit` too, as in the REPL).
|
|
40
|
-
EXIT_COMMANDS = %w[/exit /quit].freeze
|
|
41
|
-
# After an exit command: delete the session once the worker has gone.
|
|
42
|
-
DELETE_FLAG = "--delete"
|
|
43
35
|
# How long /exit --delete waits for the worker to let go.
|
|
44
36
|
DELETE_WAIT = 10
|
|
45
|
-
# Leave, and archive the session (hidden from the lists, kept for good).
|
|
46
|
-
ARCHIVE_COMMAND = "/archive"
|
|
47
37
|
# A second Ctrl-C at an empty idle prompt within this many seconds detaches.
|
|
48
38
|
DETACH_WINDOW = 2.0
|
|
49
39
|
DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
|
|
@@ -269,23 +259,26 @@ module Samagotchi
|
|
|
269
259
|
|
|
270
260
|
text = line.strip
|
|
271
261
|
return answer_question(text) if @question
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
262
|
+
|
|
263
|
+
# The terminal's own commands (SessionCommands registers them, the
|
|
264
|
+
# REPL reads the same words). Before the continue offer: the
|
|
265
|
+
# leaving ones are no answer to it.
|
|
266
|
+
local = command_registry.lookup_local(text)&.id
|
|
267
|
+
return submit(nil) if local == :detach
|
|
268
|
+
return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
|
|
269
|
+
return exit_and_archive if local == :archive
|
|
270
|
+
|
|
278
271
|
if @continue_offer
|
|
279
|
-
return answer_continue(text) unless command_registry.command?(text)
|
|
272
|
+
return answer_continue(text) unless local || command_registry.command?(text)
|
|
280
273
|
|
|
281
274
|
# Not an answer: the read left no echo, so show what ran.
|
|
282
275
|
echo_answer(text)
|
|
283
276
|
end
|
|
284
277
|
return if text.empty?
|
|
285
278
|
|
|
286
|
-
if
|
|
279
|
+
if local == :stats
|
|
287
280
|
show_stats
|
|
288
|
-
elsif
|
|
281
|
+
elsif local == :recap
|
|
289
282
|
show_recap
|
|
290
283
|
elsif command_registry.command?(text)
|
|
291
284
|
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
@@ -314,8 +307,6 @@ module Samagotchi
|
|
|
314
307
|
detach(exit_failed_line(e.message))
|
|
315
308
|
end
|
|
316
309
|
|
|
317
|
-
def exit_command?(text) = EXIT_COMMANDS.include?(text.downcase) || text.casecmp?("exit")
|
|
318
|
-
|
|
319
310
|
# /exit --delete: the worker exits as for /exit, then the session is
|
|
320
311
|
# deleted. When the worker stays up (another UI, queued input, ...),
|
|
321
312
|
# nothing is deleted.
|
|
@@ -498,12 +489,10 @@ module Samagotchi
|
|
|
498
489
|
|
|
499
490
|
def send_prompt(text)
|
|
500
491
|
persist_recent_history(text)
|
|
501
|
-
# #memory shorthand becomes words the model reads, as in the REPL.
|
|
502
|
-
prompt = normalize_model_input(text)
|
|
503
492
|
images = attach_images(text)
|
|
504
493
|
return if images.nil?
|
|
505
494
|
|
|
506
|
-
options = { prompt:
|
|
495
|
+
options = { prompt: text, client_id: @client_id }
|
|
507
496
|
options[:no_interrupt] = true if @no_interrupt
|
|
508
497
|
options[:images] = images unless images.empty?
|
|
509
498
|
reply = @client.post_turn(**options)
|
|
@@ -595,6 +584,13 @@ module Samagotchi
|
|
|
595
584
|
# What was typed at the prompt waits for the question to close.
|
|
596
585
|
@set_aside = @reader&.typed_text unless @question
|
|
597
586
|
@question = QuestionPrompt.new(pending)
|
|
587
|
+
# An edit's diff goes above, into the scrollback, once per question
|
|
588
|
+
# (a join gets it from the snapshot and may get the event too).
|
|
589
|
+
unless @previewed_id == @question.id
|
|
590
|
+
@previewed_id = @question.id
|
|
591
|
+
preview = @question.preview_lines(paint: method(:paint))
|
|
592
|
+
@screen.commit(preview.join("\n")) unless preview.empty?
|
|
593
|
+
end
|
|
598
594
|
# The prompt first, so the choices never show under the typed text.
|
|
599
595
|
sync_prompt(keep_text: false)
|
|
600
596
|
@screen.set_slot(:notes, @question.slot(paint: method(:paint)))
|
|
@@ -1063,7 +1059,7 @@ module Samagotchi
|
|
|
1063
1059
|
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
1064
1060
|
status = part[:status].to_s
|
|
1065
1061
|
"#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
|
|
1066
|
-
"#{format_tool_image_suffix(part[:images])}"
|
|
1062
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
1067
1063
|
end
|
|
1068
1064
|
end
|
|
1069
1065
|
end
|
|
@@ -55,10 +55,13 @@ module Samagotchi
|
|
|
55
55
|
when :tool_call_started
|
|
56
56
|
@tool_started_at[tool_call_key(event)] = @clock.call
|
|
57
57
|
@view.tool_call_feedback_started(event)
|
|
58
|
+
when :used_memories_updated
|
|
59
|
+
@view.used_memories_updated(event) if @view.respond_to?(:used_memories_updated)
|
|
58
60
|
when :tool_call_completed
|
|
59
61
|
@view.clear_generation_retry
|
|
60
62
|
@view.tool_call_feedback_completed(event)
|
|
61
|
-
render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images]
|
|
63
|
+
render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images],
|
|
64
|
+
diff: event[:diff])
|
|
62
65
|
when :generation_completed, :generation_cancelled, :tool_dispatch_started
|
|
63
66
|
@view.generation_feedback_finished
|
|
64
67
|
when :pending_input_merged
|
|
@@ -104,7 +107,8 @@ module Samagotchi
|
|
|
104
107
|
# @param event [Hash] :hook_notice event or a snapshot part with
|
|
105
108
|
# hook:, text:, level:
|
|
106
109
|
def self.hook_notice_line(event)
|
|
107
|
-
|
|
110
|
+
# chi's own notices name themselves with a bare word (thinking).
|
|
111
|
+
label = event[:hook].to_s[/\(bundle (.+)\)\z/, 1] || event[:hook].to_s[/\A[a-z][a-z0-9-]*\z/] || "hook"
|
|
108
112
|
text = event[:text].to_s
|
|
109
113
|
text = "warning: #{text}" if event[:level].to_s == "warn"
|
|
110
114
|
"#{label}> #{text}"
|
|
@@ -162,12 +166,13 @@ module Samagotchi
|
|
|
162
166
|
|
|
163
167
|
private
|
|
164
168
|
|
|
165
|
-
def render_streamed_tool_activity(activity, duration_ms:, images: nil)
|
|
169
|
+
def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
|
|
166
170
|
return if activity.nil?
|
|
167
171
|
|
|
168
172
|
@streamed_tool_activity[tool_activity_key(activity)] += 1
|
|
169
173
|
line = @view.format_tool_activity_line(activity, duration_ms: duration_ms)
|
|
170
174
|
line += @view.format_tool_image_suffix(images) if images&.any?
|
|
175
|
+
line += @view.format_tool_diff_suffix(diff) if diff
|
|
171
176
|
@view.print_line(line)
|
|
172
177
|
end
|
|
173
178
|
|
|
@@ -50,6 +50,15 @@ module Samagotchi
|
|
|
50
50
|
" #{paint(refs.map { |ref| "→ image #{ref[:width]}×#{ref[:height]}" }.join(", "), 90)}"
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# An edit/write row's change, after the row: " +3 −1" (green, red).
|
|
54
|
+
# +diff+ is EditPreview's hash, symbol or string keys (SSE, snapshot).
|
|
55
|
+
def format_tool_diff_suffix(diff)
|
|
56
|
+
return "" unless diff.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
get = ->(key) { diff[key] || diff[key.to_s] }
|
|
59
|
+
" #{paint("+#{get.(:added).to_i}", 32)} #{paint("\u2212#{get.(:removed).to_i}", 31)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
53
62
|
# A card (Engine#show_card) as a framed block: the title and its
|
|
54
63
|
# source, the body as wrapped plain text (no terminal markdown), and
|
|
55
64
|
# one `→ <command>` line per action, the label after it when it says
|
|
@@ -11,10 +11,10 @@ require_relative "../session_commands"
|
|
|
11
11
|
module Samagotchi
|
|
12
12
|
class TerminalUI
|
|
13
13
|
# Input the REPL and the attached TUI share: the persistent prompt
|
|
14
|
-
# history, Tab completion (/commands, @path in the cwd, #memory
|
|
15
|
-
# #
|
|
16
|
-
# one-shot prefill of the next read (the default
|
|
17
|
-
# back after a failed turn).
|
|
14
|
+
# history, Tab completion (/commands, @path in the cwd, #memory, which
|
|
15
|
+
# inserts #name and the model reads it as typed), multiline reads at the
|
|
16
|
+
# main prompt, and a one-shot prefill of the next read (the default
|
|
17
|
+
# input, a prompt given back after a failed turn).
|
|
18
18
|
#
|
|
19
19
|
# Included for private use; it keeps state in @next_input_prefill and
|
|
20
20
|
# reads @no_default_input.
|
|
@@ -204,21 +204,6 @@ module Samagotchi
|
|
|
204
204
|
}
|
|
205
205
|
end
|
|
206
206
|
|
|
207
|
-
def normalize_model_input(input)
|
|
208
|
-
input.to_s.gsub(/(^|[^\w\/])#((?:project|system)\/)?([a-zA-Z0-9][a-zA-Z0-9_-]*)/) do
|
|
209
|
-
prefix = Regexp.last_match(1)
|
|
210
|
-
scoped = Regexp.last_match(2).to_s
|
|
211
|
-
name = Regexp.last_match(3)
|
|
212
|
-
scope = scoped.delete_suffix("/")
|
|
213
|
-
normalized = if scope.empty?
|
|
214
|
-
"memory \"#{name}\""
|
|
215
|
-
else
|
|
216
|
-
"memory \"#{name}\" in #{scope} scope"
|
|
217
|
-
end
|
|
218
|
-
"#{prefix}#{normalized}"
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
|
|
222
207
|
def history_file_path
|
|
223
208
|
explicit = ENV[PROMPT_HISTORY_ENV].to_s.strip
|
|
224
209
|
return explicit unless explicit.empty?
|
|
@@ -14,6 +14,8 @@ module Samagotchi
|
|
|
14
14
|
# an optional "; reason"; a substring would pick the wrong one ("y" is
|
|
15
15
|
# in "Deny").
|
|
16
16
|
class QuestionPrompt
|
|
17
|
+
DIFF_CODES = { "+" => 32, "-" => 31, "@" => 2, "\\" => 2 }.freeze
|
|
18
|
+
|
|
17
19
|
# A parsed answer. +error+ set: re-ask. +note+: print, then accept.
|
|
18
20
|
Answer = Struct.new(:selected, :freeform, :error, :note, keyword_init: true) do
|
|
19
21
|
def ok? = error.nil?
|
|
@@ -24,6 +26,8 @@ module Samagotchi
|
|
|
24
26
|
# @param pending [Hash] Engine#pending_question (symbol or string keys)
|
|
25
27
|
def initialize(pending)
|
|
26
28
|
field = ->(key) { pending[key] || pending[key.to_s] }
|
|
29
|
+
approval = field.(:approval)
|
|
30
|
+
@preview = approval.is_a?(Hash) ? (approval[:preview] || approval["preview"]) : nil
|
|
27
31
|
@id = field.(:id).to_s
|
|
28
32
|
@question = field.(:question).to_s
|
|
29
33
|
@options = Array(field.(:options)).map { |v| v.to_s.strip }.reject(&:empty?)
|
|
@@ -49,6 +53,34 @@ module Samagotchi
|
|
|
49
53
|
hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint)
|
|
50
54
|
end
|
|
51
55
|
|
|
56
|
+
# The most diff lines #preview_lines prints; the web shows them all.
|
|
57
|
+
PREVIEW_LINES = 40
|
|
58
|
+
|
|
59
|
+
# An edit/write approval's dry-run diff (approval.preview, symbol or
|
|
60
|
+
# string keys: nested keys are strings after a reload or SSE), as
|
|
61
|
+
# lines to print above the slot: coloured +/-, dim @@, cut at
|
|
62
|
+
# PREVIEW_LINES. [] for none.
|
|
63
|
+
def preview_lines(paint: ->(text, _code) { text })
|
|
64
|
+
preview = @preview
|
|
65
|
+
return [] unless preview.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
get = ->(key) { preview[key] || preview[key.to_s] }
|
|
68
|
+
return [paint.("this edit would fail: #{get.(:error)}", 33)] if get.(:error)
|
|
69
|
+
return [paint.("diff not shown: #{get.(:skipped)}", 2)] if get.(:skipped)
|
|
70
|
+
|
|
71
|
+
lines = get.(:text).to_s.split("\n")
|
|
72
|
+
return [] if lines.empty?
|
|
73
|
+
|
|
74
|
+
# TextDiff's own cut note ("… N more lines") counts toward the rest.
|
|
75
|
+
cut = lines.last.to_s[/\A… (\d+) more lines\z/, 1]
|
|
76
|
+
lines.pop if cut
|
|
77
|
+
hidden = [lines.size - PREVIEW_LINES, 0].max + cut.to_i
|
|
78
|
+
shown = lines.first(PREVIEW_LINES).map { |line| paint.(line, diff_code(line)) }
|
|
79
|
+
shown.unshift(paint.("new file", 2)) if get.(:new_file)
|
|
80
|
+
shown << paint.("… #{hidden} more lines (full diff on the web)", 2) if hidden.positive?
|
|
81
|
+
shown
|
|
82
|
+
end
|
|
83
|
+
|
|
52
84
|
# The one line that stays in the scrollback once the question closes:
|
|
53
85
|
# the question and what became of it.
|
|
54
86
|
# @param outcome [String] the answer (#answer_text) or what closed it
|
|
@@ -148,6 +180,9 @@ module Samagotchi
|
|
|
148
180
|
found = options.find { |o| o.downcase == tok.downcase || o.downcase.include?(tok.downcase) }
|
|
149
181
|
found ? [found, nil] : [nil, "Unknown option '#{tok}'. Use numbers 1-#{options.size} or exact labels."]
|
|
150
182
|
end
|
|
183
|
+
|
|
184
|
+
# "+" green, "-" red, "@@" and "\ No newline" dim, context plain.
|
|
185
|
+
def diff_code(line) = DIFF_CODES.fetch(line[0], 0)
|
|
151
186
|
end
|
|
152
187
|
end
|
|
153
188
|
end
|