samagotchi 0.50.0 → 0.51.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 +44 -1
- data/docs/cli.md +21 -7
- data/docs/configuration.md +11 -9
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/sessions.md +5 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +24 -9
- data/lib/samagotchi/bridge_client.rb +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/delivery.rb +33 -0
- data/lib/samagotchi/host_registry.rb +16 -3
- data/lib/samagotchi/kernel_loop.rb +28 -2
- data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -2
- data/lib/samagotchi/send_command.rb +63 -10
- data/lib/samagotchi/session_commands.rb +9 -0
- data/lib/samagotchi/session_inbox.rb +27 -7
- data/lib/samagotchi/session_manager.rb +13 -3
- data/lib/samagotchi/session_metrics.rb +25 -6
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/steer_cut.rb +12 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -4
- data/lib/samagotchi/terminal_ui.rb +48 -5
- data/lib/samagotchi/tool_activity.rb +3 -0
- data/lib/samagotchi/tool_runner.rb +2 -0
- data/lib/samagotchi/tools/builtin_calls.rb +33 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -2
- data/lib/samagotchi/web/message_parts.rb +3 -0
- data/lib/samagotchi/web/public/activity.js +6 -0
- data/lib/samagotchi/web/public/app.js +66 -28
- data/lib/samagotchi/web/public/index.html +17 -15
- data/lib/samagotchi/web/public/session_title.js +26 -0
- data/lib/samagotchi/web/public/strip.js +4 -6
- data/lib/samagotchi/web/public/turn_events.js +3 -2
- data/lib/samagotchi/web/public/turn_html.js +3 -1
- data/lib/samagotchi/worker.rb +1 -1
- data/lib/samagotchi/worker_inbound.rb +8 -5
- data/lib/samagotchi.rb +1 -0
- metadata +3 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "client_id"
|
|
4
|
+
require_relative "delivery"
|
|
4
5
|
require_relative "session"
|
|
5
6
|
require_relative "session_commands"
|
|
6
7
|
require_relative "session_inbox"
|
|
@@ -34,13 +35,14 @@ module Samagotchi
|
|
|
34
35
|
MAX_IMAGES = ImageStore::MAX_TURN_REFS
|
|
35
36
|
|
|
36
37
|
USAGE = <<~TEXT
|
|
37
|
-
Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
|
|
38
|
+
Usage: chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
|
|
38
39
|
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
|
|
39
40
|
chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
|
|
40
41
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
41
42
|
chi send --wait [--timeout S] [--format json] ID
|
|
42
43
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
43
|
-
or a running one
|
|
44
|
+
or a running one takes it at its next step. A stopped session's
|
|
45
|
+
worker starts.
|
|
44
46
|
A session command (/model x, !cmd) runs as the command; --wait
|
|
45
47
|
then has no reply to wait for (exit 0, status command).
|
|
46
48
|
-m TEXT the message; stdin, when piped too, goes above it as a
|
|
@@ -75,6 +77,14 @@ module Samagotchi
|
|
|
75
77
|
nothing is sent: it waits for the session's next
|
|
76
78
|
reply, a running turn's too (after exit 3, 4 or 130,
|
|
77
79
|
wait again this way)
|
|
80
|
+
--cut ask the running turn to cut the generation it is
|
|
81
|
+
streaming and take the message now (a generation that
|
|
82
|
+
has streamed only thinking for steer.cut_after; else
|
|
83
|
+
the message waits for the thinking to pass it). A
|
|
84
|
+
message without --cut goes in at the running turn's
|
|
85
|
+
next step and cuts nothing. To an idle session it
|
|
86
|
+
sends normally. Refused with --wait into a running
|
|
87
|
+
turn (the steer's turn is the running one)
|
|
78
88
|
--timeout S (--wait) give up after S seconds, exit 4 (the turn
|
|
79
89
|
goes on); default no limit
|
|
80
90
|
--format json
|
|
@@ -82,7 +92,8 @@ module Samagotchi
|
|
|
82
92
|
status answered (text), question (question,
|
|
83
93
|
answer_with), running, or failed, canceled,
|
|
84
94
|
limit, no_answer, error, worker_gone, stopped,
|
|
85
|
-
command (detail)
|
|
95
|
+
command (detail); with --cut also delivery (what
|
|
96
|
+
the worker applied) and cut (now, waits, off)
|
|
86
97
|
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
87
98
|
question waits, 4 still running (--timeout), 130 Ctrl-C.
|
|
88
99
|
Only sessions on this machine. Answers show in the attached TUI
|
|
@@ -101,6 +112,7 @@ module Samagotchi
|
|
|
101
112
|
f.value "--llm-context-budget", key: :llm_budget
|
|
102
113
|
f.value "--image", key: :images, repeat: true
|
|
103
114
|
f.switch "--wait"
|
|
115
|
+
f.switch "--cut"
|
|
104
116
|
f.value "--timeout"
|
|
105
117
|
f.value "--format"
|
|
106
118
|
# Starting a turn in every live session at once is too easy to do
|
|
@@ -160,6 +172,7 @@ module Samagotchi
|
|
|
160
172
|
def run_parsed(options)
|
|
161
173
|
# With --wait stdout is the answer alone.
|
|
162
174
|
@info = options[:wait] ? @stderr : @stdout
|
|
175
|
+
@cut = options[:cut] == true
|
|
163
176
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
164
177
|
# Before the wait-only case, which would drop the images.
|
|
165
178
|
return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
|
|
@@ -527,7 +540,11 @@ module Samagotchi
|
|
|
527
540
|
owner = SessionManager.session_owner(id, state_dir: @state_dir)
|
|
528
541
|
running = owner && session.status == Session::STATUS_RUNNING
|
|
529
542
|
refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
|
|
530
|
-
|
|
543
|
+
# --cut asks the running turn for a cut; to an idle session it is an
|
|
544
|
+
# ordinary message (there is nothing to cut).
|
|
545
|
+
delivery = @cut && running ? Delivery::CUT : nil
|
|
546
|
+
result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
|
|
547
|
+
delivery: delivery)
|
|
531
548
|
unless result[:status] == :accepted
|
|
532
549
|
@info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
|
|
533
550
|
return false
|
|
@@ -539,12 +556,12 @@ module Samagotchi
|
|
|
539
556
|
return true
|
|
540
557
|
end
|
|
541
558
|
|
|
542
|
-
|
|
543
|
-
#
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
@info.puts("#{short} sent#{with_images}#{
|
|
559
|
+
ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
|
|
560
|
+
# What the worker applied, for --format json: its ack's delivery
|
|
561
|
+
# (next_step for an idle session) and a cut's outcome.
|
|
562
|
+
@sent_delivery = ack["delivery"] || delivery
|
|
563
|
+
@sent_cut = ack["cut"]
|
|
564
|
+
@info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
|
|
548
565
|
true
|
|
549
566
|
rescue SessionManager::OwnedByTUI
|
|
550
567
|
@info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
|
|
@@ -554,6 +571,42 @@ module Samagotchi
|
|
|
554
571
|
false
|
|
555
572
|
end
|
|
556
573
|
|
|
574
|
+
# What the line says became of the message: a plain one goes in at the
|
|
575
|
+
# running turn's next step (nothing cuts by default); a --cut one names
|
|
576
|
+
# the outcome from the ack (now, waits, off); images keep their own
|
|
577
|
+
# wording; an idle session's turn simply runs.
|
|
578
|
+
# @param ack [Hash] the Bridge's 202 body ({} for the input file)
|
|
579
|
+
# @return [String] "" or " (...)"
|
|
580
|
+
def delivery_note(ack, delivery:, running:, owner:)
|
|
581
|
+
return " (started its worker)" if owner.nil?
|
|
582
|
+
return "" unless running
|
|
583
|
+
|
|
584
|
+
# A busy worker runs a message with images as its own next turn
|
|
585
|
+
# rather than merging it into the running one.
|
|
586
|
+
return " (runs after the current turn)" unless @images.empty?
|
|
587
|
+
|
|
588
|
+
case (Delivery.cut?(delivery) ? ack["cut"].to_s : "")
|
|
589
|
+
when "now" then " (cut in now)"
|
|
590
|
+
when "waits" then " (cuts in once the thinking passes #{Config.get("steer.cut_after").to_i} s)"
|
|
591
|
+
when "off" then " (cutting is off; goes in at the next step)"
|
|
592
|
+
# No outcome: a plain message, or the input file (the Bridge went
|
|
593
|
+
# away), which the next step's drain takes.
|
|
594
|
+
else " (goes in at the running turn's next step)"
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# More keys for --format json, with --cut only (the line is unchanged
|
|
599
|
+
# otherwise): the delivery the worker applied (next_step to an idle
|
|
600
|
+
# session) and a cut's outcome (now, waits, off). In P1 --cut --wait is
|
|
601
|
+
# refused for a running turn, so the json line says next_step.
|
|
602
|
+
def report_extra
|
|
603
|
+
return {} unless @cut
|
|
604
|
+
|
|
605
|
+
extra = { delivery: @sent_delivery || Delivery::NEXT_STEP }
|
|
606
|
+
extra[:cut] = @sent_cut if @sent_cut
|
|
607
|
+
extra
|
|
608
|
+
end
|
|
609
|
+
|
|
557
610
|
# What to say when +model_name+ is known not to take images, or nil
|
|
558
611
|
# (it does, or it is unknown: sent as before). Asked once per model.
|
|
559
612
|
def vision_refusal(model_name, typed: nil)
|
|
@@ -100,6 +100,15 @@ module Samagotchi
|
|
|
100
100
|
# The REPL owns its session: it answers /detach with a note, and doesn't offer it.
|
|
101
101
|
registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
|
|
102
102
|
match: ->(text) { text.casecmp?("/detach") })
|
|
103
|
+
# /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
|
|
104
|
+
# themselves (Formatting#steer_choice) before any command check; the
|
|
105
|
+
# entries exist for Tab completion and /help. /queue is a real
|
|
106
|
+
# after-the-turn list only in the local REPL (the attached TUI's
|
|
107
|
+
# worker doesn't skip queued files yet), so it is offered there.
|
|
108
|
+
registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
|
|
109
|
+
local: true, match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
|
|
110
|
+
registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
|
|
111
|
+
local: true, uis: [:repl], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
|
|
103
112
|
registry
|
|
104
113
|
end
|
|
105
114
|
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
|
+
require_relative "delivery"
|
|
8
9
|
require_relative "session"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -34,12 +35,18 @@ module Samagotchi
|
|
|
34
35
|
class NoteRejected < ArgumentError; end
|
|
35
36
|
|
|
36
37
|
# Queue a turn's input file: JSON with the prompt and the sender's ids.
|
|
38
|
+
# +delivery+ is only written when it is "queue": a step-boundary
|
|
39
|
+
# message is the default an older worker assumes, and a cut is done by
|
|
40
|
+
# the sender (the Bridge) at once, not by the file.
|
|
37
41
|
# @param client_id [String, nil] the sending UI
|
|
38
42
|
# @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
|
|
43
|
+
# @param delivery [String, nil] the wire value: "next_step" (default),
|
|
44
|
+
# "cut" or "queue"
|
|
39
45
|
# @param images [Array<Hash>] image refs ({file:, name:}) in the
|
|
40
46
|
# session's images/
|
|
41
47
|
# @return [String, false] the input file's path, or false
|
|
42
|
-
def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: []
|
|
48
|
+
def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [],
|
|
49
|
+
delivery: nil)
|
|
43
50
|
images = Array(images)
|
|
44
51
|
input_dir = File.join(session_dir, INPUT_DIR)
|
|
45
52
|
FileUtils.mkdir_p(input_dir)
|
|
@@ -47,6 +54,7 @@ module Samagotchi
|
|
|
47
54
|
path = File.join(input_dir, "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}.json")
|
|
48
55
|
record = { "prompt" => prompt.to_s, "client_id" => client_id, "enqueued_id" => enqueued_id,
|
|
49
56
|
"no_interrupt" => (no_interrupt ? true : nil),
|
|
57
|
+
"delivery" => (Delivery.queue?(delivery) ? Delivery::QUEUE : nil),
|
|
50
58
|
"images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }) }.compact
|
|
51
59
|
AtomicFile.write(path, JSON.generate(record))
|
|
52
60
|
path
|
|
@@ -175,17 +183,20 @@ module Samagotchi
|
|
|
175
183
|
Dir.glob(File.join(input_dir, "*.json"))
|
|
176
184
|
end
|
|
177
185
|
|
|
178
|
-
# @return [Array(String, Hash|nil, Boolean, Array<Hash
|
|
179
|
-
# file's prompt, origin ({client_id:, enqueued_id:},
|
|
180
|
-
# no sender), whether its turn runs with the raised
|
|
181
|
-
# (--no-interrupt),
|
|
186
|
+
# @return [Array(String, Hash|nil, Boolean, Array<Hash>, String|nil)] a
|
|
187
|
+
# claimed input file's prompt, origin ({client_id:, enqueued_id:},
|
|
188
|
+
# nil when it names no sender), whether its turn runs with the raised
|
|
189
|
+
# iteration limit (--no-interrupt), its image refs ({file:, name:}),
|
|
190
|
+
# and its delivery ("queue" or nil; a file without the key is one an
|
|
191
|
+
# older chi wrote, so nil)
|
|
182
192
|
def self.read_input(claimed_file)
|
|
183
193
|
data = JSON.parse(File.read(claimed_file).to_s)
|
|
184
194
|
origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
|
|
185
195
|
images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
|
|
186
|
-
[data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images
|
|
196
|
+
[data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images,
|
|
197
|
+
Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil]
|
|
187
198
|
rescue JSON::ParserError
|
|
188
|
-
[nil, nil, false, []]
|
|
199
|
+
[nil, nil, false, [], nil]
|
|
189
200
|
end
|
|
190
201
|
|
|
191
202
|
# Whether an unclaimed input file carries images (a mid-turn drain
|
|
@@ -197,6 +208,15 @@ module Samagotchi
|
|
|
197
208
|
false
|
|
198
209
|
end
|
|
199
210
|
|
|
211
|
+
# Whether a mid-turn drain leaves this unclaimed input file for a turn
|
|
212
|
+
# of its own instead of merging it at a step boundary. Today that is an
|
|
213
|
+
# image message (steering merges text only); the drain's file filter
|
|
214
|
+
# reads this one predicate, so what a boundary refuses changes in one
|
|
215
|
+
# place.
|
|
216
|
+
def self.waits_for_turn_end?(input_file)
|
|
217
|
+
input_has_images?(input_file)
|
|
218
|
+
end
|
|
219
|
+
|
|
200
220
|
def self.claim_input_file(input_file)
|
|
201
221
|
processing_path = "#{input_file}.processing"
|
|
202
222
|
File.rename(input_file, processing_path)
|
|
@@ -8,6 +8,7 @@ require "rbconfig"
|
|
|
8
8
|
require_relative "client_id"
|
|
9
9
|
require_relative "atomic_file"
|
|
10
10
|
require_relative "config"
|
|
11
|
+
require_relative "delivery"
|
|
11
12
|
|
|
12
13
|
require_relative "session"
|
|
13
14
|
require_relative "session_inbox"
|
|
@@ -1288,14 +1289,16 @@ module Samagotchi
|
|
|
1288
1289
|
# boundary. The file is JSON carrying the sender's ids.
|
|
1289
1290
|
# @param client_id [String, nil] the sending UI
|
|
1290
1291
|
# @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
|
|
1292
|
+
# @param delivery [String, nil] the wire value: "next_step" (default),
|
|
1293
|
+
# "cut" or "queue"
|
|
1291
1294
|
# @param images [Array<Hash>] image refs ({file:, name:}) in the
|
|
1292
1295
|
# session's images/
|
|
1293
1296
|
# @return [String, false] the input file's path, or false.
|
|
1294
1297
|
def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
|
|
1295
|
-
images: [])
|
|
1298
|
+
images: [], delivery: nil)
|
|
1296
1299
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1297
1300
|
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
1298
|
-
no_interrupt: no_interrupt, images: images)
|
|
1301
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery)
|
|
1299
1302
|
end
|
|
1300
1303
|
|
|
1301
1304
|
# How long a turn waits for the Bridge of a worker a resume just spawned.
|
|
@@ -1308,6 +1311,8 @@ module Samagotchi
|
|
|
1308
1311
|
# exit). Race-safe against a TUI taking the session, or the worker
|
|
1309
1312
|
# exiting, between the resume and the write.
|
|
1310
1313
|
# @param images [Array<Hash>] refs ({file:, name:}) already in images/
|
|
1314
|
+
# @param delivery [String, nil] what the message asks the running turn
|
|
1315
|
+
# for: "next_step" (default), "cut" or "queue"
|
|
1311
1316
|
# @param manager [#resume_session, #write_turn_input] this class, or a
|
|
1312
1317
|
# stand-in (the web's specs); #session_owner is optional
|
|
1313
1318
|
# @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
|
|
@@ -1319,7 +1324,8 @@ module Samagotchi
|
|
|
1319
1324
|
# {status: :failed} when the input file couldn't be written
|
|
1320
1325
|
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
1321
1326
|
# @raise [ArgumentError] no such session
|
|
1322
|
-
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], state_dir: nil, manager: self,
|
|
1327
|
+
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
|
|
1328
|
+
bridge: nil)
|
|
1323
1329
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1324
1330
|
bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
|
|
1325
1331
|
manager.resume_session(session_id, state_dir: state_dir)
|
|
@@ -1327,6 +1333,9 @@ module Samagotchi
|
|
|
1327
1333
|
begin
|
|
1328
1334
|
options = { prompt: prompt, client_id: client_id }
|
|
1329
1335
|
options[:images] = images unless images.empty?
|
|
1336
|
+
# An old worker ignores the field: it cuts anyway (its own default)
|
|
1337
|
+
# and its file never carries a queue, so a queue would steer there.
|
|
1338
|
+
options[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1330
1339
|
reply = client.post_turn(**options)
|
|
1331
1340
|
ack = reply.json
|
|
1332
1341
|
return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
|
|
@@ -1347,6 +1356,7 @@ module Samagotchi
|
|
|
1347
1356
|
enqueued_id = SecureRandom.uuid
|
|
1348
1357
|
input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
|
|
1349
1358
|
input[:images] = images unless images.empty?
|
|
1359
|
+
input[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1350
1360
|
path = manager.write_turn_input(session_id, **input)
|
|
1351
1361
|
return { status: :failed } unless path
|
|
1352
1362
|
|
|
@@ -379,6 +379,16 @@ module Samagotchi
|
|
|
379
379
|
@mutex.synchronize { build_snapshot(:recent) }
|
|
380
380
|
end
|
|
381
381
|
|
|
382
|
+
# The snapshot of a session no worker runs, read from disk (`chi
|
|
383
|
+
# sessions stats`): #snapshot's, but its records are the newest saved
|
|
384
|
+
# turn's (and its tool calls), as a live worker's snapshot carries its
|
|
385
|
+
# newest finished turn; #snapshot would list none, this collector having
|
|
386
|
+
# finished no turn of its own.
|
|
387
|
+
# @return [Hash]
|
|
388
|
+
def saved_snapshot
|
|
389
|
+
@mutex.synchronize { build_snapshot(:last) }
|
|
390
|
+
end
|
|
391
|
+
|
|
382
392
|
# Close the open generation now, ahead of its :generation_completed
|
|
383
393
|
# (which then finds it closed), and report its speed with the session's
|
|
384
394
|
# running token totals: what the event carries to the UIs.
|
|
@@ -441,6 +451,9 @@ module Samagotchi
|
|
|
441
451
|
served_model_for: @served_model_for,
|
|
442
452
|
tool_calls_total: @tool_records.size + in_flight.size,
|
|
443
453
|
tool_calls_by_tool: tally_into(totals[:by_tool].dup, in_flight),
|
|
454
|
+
# The calls the model made under an alias's name (bash run as
|
|
455
|
+
# execute), by its own spelling; only when there are any.
|
|
456
|
+
**(totals[:aliased].empty? ? {} : { tool_calls_aliased: totals[:aliased].dup }),
|
|
444
457
|
tool_errors: totals[:tool_errors],
|
|
445
458
|
iterations_total: totals[:iterations] + (turn&.iteration_count || 0),
|
|
446
459
|
gen_latency_ms: (totals[:gen_ms] + (turn&.gen_latency_accum || 0)).round,
|
|
@@ -486,14 +499,16 @@ module Samagotchi
|
|
|
486
499
|
|
|
487
500
|
# The records a snapshot carries, copied. :all is every record; :recent
|
|
488
501
|
# the newest turn this collector finished (live before its save lands)
|
|
489
|
-
# and the unsaved ones (capped)
|
|
490
|
-
#
|
|
491
|
-
# from the tails. Caller holds
|
|
502
|
+
# and the unsaved ones (capped); :last the newest turn, loaded or not;
|
|
503
|
+
# and the tool records of those turns and of the running one. Records
|
|
504
|
+
# are appended in turn order, so both come from the tails. Caller holds
|
|
505
|
+
# the mutex.
|
|
492
506
|
def records_view(mode)
|
|
493
507
|
return { turn_records: @turn_records.map(&:dup), tool_records: @tool_records.map(&:dup) } if mode == :all
|
|
494
508
|
|
|
495
509
|
unsaved = @turn_records.size - @persisted_turns
|
|
496
|
-
|
|
510
|
+
fresh = @turn_records.size > @loaded_turns ? 1 : 0
|
|
511
|
+
count = mode == :last ? 1 : unsaved.clamp(fresh, LIVE_RECORDS_CAP)
|
|
497
512
|
turns = @turn_records.last(count)
|
|
498
513
|
ids = turns.to_set { |record| record[:id] }
|
|
499
514
|
ids << @turn.id if @turn
|
|
@@ -807,6 +822,8 @@ module Samagotchi
|
|
|
807
822
|
active = @turn.tool_calls_by_id.delete(key)
|
|
808
823
|
return unless active
|
|
809
824
|
|
|
825
|
+
called_as = event.dig(:activity, :called_as)
|
|
826
|
+
active[:called_as] = called_as.to_s if called_as
|
|
810
827
|
finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
|
|
811
828
|
end
|
|
812
829
|
end
|
|
@@ -814,7 +831,7 @@ module Samagotchi
|
|
|
814
831
|
# Caller holds the mutex.
|
|
815
832
|
def finish_tool_record(active, status, waited_ms: 0)
|
|
816
833
|
record = active.slice(
|
|
817
|
-
:id, :turn_id, :iteration, :call_index, :tool, :started_at
|
|
834
|
+
:id, :turn_id, :iteration, :call_index, :tool, :called_as, :started_at
|
|
818
835
|
).merge(
|
|
819
836
|
status: status,
|
|
820
837
|
completed_at: now.iso8601(3),
|
|
@@ -1020,7 +1037,7 @@ module Samagotchi
|
|
|
1020
1037
|
# (#snapshot adds the running turn). Caller holds the mutex.
|
|
1021
1038
|
def reset_totals
|
|
1022
1039
|
@totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
|
|
1023
|
-
gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
|
|
1040
|
+
gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, aliased: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
|
|
1024
1041
|
reprefill_sum: 0, reasoning_sum: 0,
|
|
1025
1042
|
cost_sum: 0, cost_estimate_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
|
|
1026
1043
|
last_prefill_tps: nil, tps_source: nil }
|
|
@@ -1057,6 +1074,8 @@ module Samagotchi
|
|
|
1057
1074
|
def count_tool(record)
|
|
1058
1075
|
@totals[:tool_errors] += 1 if record[:status] == "error"
|
|
1059
1076
|
tally_into(@totals[:by_tool], [record])
|
|
1077
|
+
called_as = record[:called_as].to_s
|
|
1078
|
+
@totals[:aliased][called_as] = @totals[:aliased].fetch(called_as, 0) + 1 unless called_as.empty?
|
|
1060
1079
|
end
|
|
1061
1080
|
|
|
1062
1081
|
def tally_into(counts, tools)
|
|
@@ -339,7 +339,7 @@ module Samagotchi
|
|
|
339
339
|
end
|
|
340
340
|
return [nil, false, nil] unless File.file?(File.join(dir, "analytics.json"))
|
|
341
341
|
|
|
342
|
-
snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.
|
|
342
|
+
snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.saved_snapshot
|
|
343
343
|
note = live_worker?(session.id) ? "(no live worker answered; from the saved analytics.json)" : nil
|
|
344
344
|
[snapshot, false, note]
|
|
345
345
|
end
|
data/lib/samagotchi/steer_cut.rb
CHANGED
|
@@ -44,21 +44,25 @@ module Samagotchi
|
|
|
44
44
|
# Call it after the message is queued, with the #input_epoch read
|
|
45
45
|
# before queueing it. Outside any lock; a cut that lands just after the
|
|
46
46
|
# generation ended is harmless (the loop re-asks).
|
|
47
|
-
# @return [
|
|
47
|
+
# @return [Symbol] :now (a generation was cut now), :waits (the message
|
|
48
|
+
# waits for the thinking to pass steer.cut_after), or :off (cutting
|
|
49
|
+
# off for this source: a plugin, no turn, cut_after 0, a generation
|
|
50
|
+
# that isn't thinking-only, or a drain already took the message)
|
|
48
51
|
def cut_for_steer(source, epoch: nil)
|
|
49
|
-
return
|
|
52
|
+
return :off unless Steer.cuts?(source)
|
|
50
53
|
|
|
51
54
|
after = Config.get("steer.cut_after").to_i
|
|
52
|
-
return
|
|
55
|
+
return :off unless after.positive?
|
|
53
56
|
|
|
54
57
|
ctrl = @controller.call
|
|
55
|
-
return
|
|
56
|
-
return cut!(ctrl, source.to_s) if @phase.cuttable?(after)
|
|
58
|
+
return :off unless ctrl
|
|
59
|
+
return cut!(ctrl, source.to_s) ? :now : :off if @phase.cuttable?(after)
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
waited = @waiting.wait!(source, epoch: epoch)
|
|
62
|
+
Log.info(:turn, "steer_cut_waits", source: source.to_s) if waited
|
|
63
|
+
waited ? :waits : :off
|
|
60
64
|
rescue StandardError
|
|
61
|
-
|
|
65
|
+
:off
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
# The drains that took input so far (WaitingSteer#epoch): read it before
|
|
@@ -14,6 +14,7 @@ require_relative "reline_seam"
|
|
|
14
14
|
require_relative "version_lines"
|
|
15
15
|
require_relative "../bridge/turn_accumulator"
|
|
16
16
|
require_relative "../bridge_client"
|
|
17
|
+
require_relative "../delivery"
|
|
17
18
|
require_relative "../log"
|
|
18
19
|
require_relative "../context_note"
|
|
19
20
|
require_relative "../steer"
|
|
@@ -380,6 +381,14 @@ module Samagotchi
|
|
|
380
381
|
return answer_question(text)
|
|
381
382
|
end
|
|
382
383
|
|
|
384
|
+
# /cut TEXT and /queue TEXT are this UI's own deliveries, read before
|
|
385
|
+
# every command check (SessionCommands registers them for Tab and
|
|
386
|
+
# /help only). A plain line sends no delivery: it goes in at the
|
|
387
|
+
# running turn's next step, and never cuts.
|
|
388
|
+
if (choice = steer_choice(text))
|
|
389
|
+
return submit_delivery(*choice)
|
|
390
|
+
end
|
|
391
|
+
|
|
383
392
|
# The terminal's own commands (SessionCommands registers them, the
|
|
384
393
|
# REPL reads the same words). Before the continue offer: the
|
|
385
394
|
# leaving ones are no answer to it.
|
|
@@ -414,6 +423,20 @@ module Samagotchi
|
|
|
414
423
|
nil
|
|
415
424
|
end
|
|
416
425
|
|
|
426
|
+
# /cut TEXT and /queue TEXT while a turn runs. Only /cut goes in P1:
|
|
427
|
+
# the worker's drain doesn't skip queued files yet, so a queued
|
|
428
|
+
# message would steer the running turn; /queue says so and sends
|
|
429
|
+
# nothing. At the prompt both run the text as a normal turn.
|
|
430
|
+
# @return [nil]
|
|
431
|
+
def submit_delivery(choice, text)
|
|
432
|
+
return @screen.commit(STEER_CHOICE_USAGE) if text.empty?
|
|
433
|
+
return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue
|
|
434
|
+
|
|
435
|
+
# To an idle session it is an ordinary prompt: no delivery to send.
|
|
436
|
+
send_prompt(text, delivery: @running ? Delivery::CUT : nil)
|
|
437
|
+
nil
|
|
438
|
+
end
|
|
439
|
+
|
|
417
440
|
# @return [Symbol] :detach
|
|
418
441
|
def detach(line)
|
|
419
442
|
@view.finish_thinking_spinner
|
|
@@ -573,6 +596,9 @@ module Samagotchi
|
|
|
573
596
|
end
|
|
574
597
|
|
|
575
598
|
QUEUED_NOTE = "(queued: runs after this turn)"
|
|
599
|
+
# /queue needs a worker whose drain skips queued input files (P3):
|
|
600
|
+
# until then a queued message would steer the running turn.
|
|
601
|
+
QUEUE_UNAVAILABLE = "(/queue isn't available in an attached session yet)"
|
|
576
602
|
|
|
577
603
|
# A command queued for after the running turn (command_queued with
|
|
578
604
|
# waits): its line now (another client's) and a note; its command_ran
|
|
@@ -661,19 +687,40 @@ module Samagotchi
|
|
|
661
687
|
end
|
|
662
688
|
end
|
|
663
689
|
|
|
664
|
-
|
|
690
|
+
# What a prompt's 202 says became of it, or nil when there is nothing
|
|
691
|
+
# to say: the dim line both TUIs print for a delivery. A plain line
|
|
692
|
+
# for a running turn goes in at its next step; a cut's outcome comes
|
|
693
|
+
# from the ack (the worker knows whether cutting is on for its model).
|
|
694
|
+
# An idle session's prompt simply runs, and a prompt with images runs
|
|
695
|
+
# as its own next turn (never merging mid-turn), so nothing is said.
|
|
696
|
+
# @param ack [Hash, nil] the Bridge's 202 body
|
|
697
|
+
# @param delivery [String, nil] what was asked for
|
|
698
|
+
# @return [String, nil]
|
|
699
|
+
def sent_note(ack, delivery: nil, images: [], running: false)
|
|
700
|
+
return nil unless running && images.empty? && ack.is_a?(Hash)
|
|
701
|
+
|
|
702
|
+
return NEXT_STEP_NOTE unless delivery == Delivery::CUT
|
|
703
|
+
|
|
704
|
+
steer_cut_note(ack["cut"].to_s.to_sym)
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def send_prompt(text, delivery: nil)
|
|
665
708
|
persist_recent_history(text)
|
|
666
709
|
images = attach_images(text)
|
|
667
710
|
return own_prompt_failed if images.nil?
|
|
668
711
|
|
|
712
|
+
running = @running
|
|
669
713
|
options = { prompt: text, client_id: @client_id }
|
|
670
714
|
options[:no_interrupt] = true if @no_interrupt
|
|
671
715
|
options[:images] = images unless images.empty?
|
|
716
|
+
options[:delivery] = delivery if delivery
|
|
672
717
|
reply = @client.post_turn(**options)
|
|
673
718
|
if reply.status == 202
|
|
674
719
|
enqueued_id = reply.json&.fetch("enqueued_id", nil)
|
|
675
720
|
@sent_ids << enqueued_id if enqueued_id
|
|
676
721
|
@open_ids << enqueued_id if enqueued_id && @wait_at_eof
|
|
722
|
+
note = sent_note(reply.json, delivery: delivery, images: images, running: running)
|
|
723
|
+
@screen.commit(note) if note
|
|
677
724
|
return
|
|
678
725
|
end
|
|
679
726
|
|
|
@@ -31,13 +31,17 @@ module Samagotchi
|
|
|
31
31
|
|
|
32
32
|
# What a tool line says the call did: the model's description of a
|
|
33
33
|
# command (": List the specs") in place of its cut params, else the
|
|
34
|
-
# params, dim
|
|
34
|
+
# params, dim; then, dim, the name the model called it by when that was
|
|
35
|
+
# an alias's (" (called as bash)").
|
|
35
36
|
def tool_params_suffix(activity)
|
|
36
37
|
description = activity[:description].to_s.strip
|
|
37
|
-
return ": #{description}" unless description.empty?
|
|
38
|
-
|
|
39
38
|
params = activity[:params].to_s.strip
|
|
40
|
-
|
|
39
|
+
suffix = if !description.empty? then ": #{description}"
|
|
40
|
+
elsif !params.empty? then " #{paint(params, 90)}"
|
|
41
|
+
else ""
|
|
42
|
+
end
|
|
43
|
+
called_as = activity[:called_as].to_s
|
|
44
|
+
called_as.empty? ? suffix : "#{suffix}#{paint(" (called as #{called_as})", 90)}"
|
|
41
45
|
end
|
|
42
46
|
|
|
43
47
|
# Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
|
|
@@ -91,6 +95,45 @@ module Samagotchi
|
|
|
91
95
|
paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
|
|
92
96
|
end
|
|
93
97
|
|
|
98
|
+
# What a line submitted at the open prompt says became of it: a plain
|
|
99
|
+
# line goes in at the running turn's next step boundary (nothing cuts
|
|
100
|
+
# unless the message asked for it), /cut's cutting now or once the
|
|
101
|
+
# thinking passes steer.cut_after, and /queue's waiting for the end.
|
|
102
|
+
# Both TUIs print the same words (SteerCut#cut_for_steer's answer).
|
|
103
|
+
NEXT_STEP_NOTE = "(goes in at the next step)"
|
|
104
|
+
CUT_NOW_NOTE = "(cut in now)"
|
|
105
|
+
CUT_WAITS_NOTE = "(cuts in once the thinking passes %d s)"
|
|
106
|
+
CUT_OFF_NOTE = "(cutting is off; goes in at the next step)"
|
|
107
|
+
STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)"
|
|
108
|
+
# The UIs' own words for a delivery, listed for Tab and /help only
|
|
109
|
+
# (a local: entry, so no command or bundle may use the names).
|
|
110
|
+
CUT_COMMAND = "/cut"
|
|
111
|
+
QUEUE_COMMAND = "/queue"
|
|
112
|
+
# The word, then any whitespace and the text (as the registry's
|
|
113
|
+
# entries match them, so a line Tab or /help knows is never a prompt).
|
|
114
|
+
STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
|
|
115
|
+
|
|
116
|
+
# @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
|
|
117
|
+
# @return [String] the dim line to show for a /cut
|
|
118
|
+
def steer_cut_note(outcome)
|
|
119
|
+
case outcome
|
|
120
|
+
when :now then CUT_NOW_NOTE
|
|
121
|
+
when :waits then format(CUT_WAITS_NOTE, Config.get("steer.cut_after").to_i)
|
|
122
|
+
else CUT_OFF_NOTE
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The choice a mid-turn line carries, as [:cut, "TEXT"] or
|
|
127
|
+
# [:queue, "TEXT"]; nil for any other line. The text is "" when the
|
|
128
|
+
# line named the choice without one.
|
|
129
|
+
# @param line [String, nil]
|
|
130
|
+
# @return [Array(Symbol, String), nil]
|
|
131
|
+
def steer_choice(line)
|
|
132
|
+
match = STEER_CHOICE.match(line.to_s.strip) or return nil
|
|
133
|
+
|
|
134
|
+
[match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
|
|
135
|
+
end
|
|
136
|
+
|
|
94
137
|
# "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
|
|
95
138
|
# off)": a batch of LLM context edits went in (LLMContextNotice builds
|
|
96
139
|
# the line; the web prints the same), dim like the retry row.
|
|
@@ -516,6 +559,10 @@ module Samagotchi
|
|
|
516
559
|
by_tool = snapshot[:tool_calls_by_tool].sort_by { |_k, v| -v }
|
|
517
560
|
lines << " by tool: #{by_tool.map { |k, v| "#{k}=#{v}" }.join(", ")}"
|
|
518
561
|
end
|
|
562
|
+
unless snapshot[:tool_calls_aliased].to_a.empty?
|
|
563
|
+
aliased = snapshot[:tool_calls_aliased].sort_by { |_k, v| -v }
|
|
564
|
+
lines << " called as: #{aliased.map { |k, v| "#{k}=#{v}" }.join(", ")}"
|
|
565
|
+
end
|
|
519
566
|
lines << "iterations: #{snapshot[:iterations_total]}"
|
|
520
567
|
# Summed over every request: each prompt is sent in full again.
|
|
521
568
|
lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
|