samagotchi 0.49.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 +84 -1
- data/bin/chi +2 -2
- data/docs/cli.md +33 -8
- data/docs/configuration.md +11 -9
- data/docs/hooks.md +12 -3
- data/docs/internals/engine.md +4 -2
- data/docs/sessions.md +8 -4
- data/docs/testing.md +2 -1
- 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/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +13 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/delivery.rb +33 -0
- data/lib/samagotchi/engine.rb +1 -1
- data/lib/samagotchi/hooks/stream_watch.rb +9 -2
- data/lib/samagotchi/host_registry.rb +17 -4
- data/lib/samagotchi/kernel_loop.rb +28 -2
- data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -2
- data/lib/samagotchi/send_command.rb +124 -10
- data/lib/samagotchi/session_commands.rb +10 -2
- data/lib/samagotchi/session_inbox.rb +27 -7
- data/lib/samagotchi/session_manager.rb +13 -3
- data/lib/samagotchi/session_metrics.rb +32 -6
- data/lib/samagotchi/sessions_command.rb +84 -7
- data/lib/samagotchi/steer_cut.rb +12 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +50 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +68 -6
- 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 +71 -29
- data/lib/samagotchi/web/public/index.html +17 -15
- data/lib/samagotchi/web/public/live_timing.js +32 -5
- data/lib/samagotchi/web/public/session_title.js +26 -0
- data/lib/samagotchi/web/public/stage_model.js +15 -0
- data/lib/samagotchi/web/public/stage_view.js +10 -4
- data/lib/samagotchi/web/public/strip.js +4 -6
- data/lib/samagotchi/web/public/timing.js +11 -0
- data/lib/samagotchi/web/public/turn_events.js +14 -2
- data/lib/samagotchi/web/public/turn_html.js +24 -1
- data/lib/samagotchi/web/public/turn_view.js +9 -3
- data/lib/samagotchi/web/session_hub.rb +11 -3
- data/lib/samagotchi/worker.rb +1 -1
- data/lib/samagotchi/worker_inbound.rb +8 -5
- data/lib/samagotchi.rb +1 -0
- metadata +3 -1
|
@@ -9,19 +9,22 @@ require_relative "session_metrics"
|
|
|
9
9
|
require_relative "recap_store"
|
|
10
10
|
require_relative "session_delete_command"
|
|
11
11
|
require_relative "session_archive_command"
|
|
12
|
+
require_relative "bridge_client"
|
|
13
|
+
require_relative "terminal_ui/formatting"
|
|
14
|
+
require_relative "terminal_ui/event_renderer"
|
|
12
15
|
require_relative "cli/command"
|
|
13
16
|
require_relative "cli/flags"
|
|
14
17
|
|
|
15
18
|
module Samagotchi
|
|
16
|
-
# `chi sessions`: list, stop, restart, archive/unarchive, delete, prune
|
|
17
|
-
# sessions from the shell (bin/chi dispatches here before
|
|
18
|
-
# the subcommands have their own flags). Not SessionCommands,
|
|
19
|
-
# slash commands for sessions (session_commands.rb).
|
|
19
|
+
# `chi sessions`: list, stop, restart, archive/unarchive, delete, prune,
|
|
20
|
+
# clean and stats sessions from the shell (bin/chi dispatches here before
|
|
21
|
+
# OptionParser; the subcommands have their own flags). Not SessionCommands,
|
|
22
|
+
# the REPL's slash commands for sessions (session_commands.rb).
|
|
20
23
|
class SessionsCommand
|
|
21
24
|
include CLI::Command
|
|
22
25
|
|
|
23
26
|
USAGE = <<~TEXT
|
|
24
|
-
Usage: chi sessions <list|stop|restart|archive|unarchive|delete|prune|clean> [options]
|
|
27
|
+
Usage: chi sessions <list|stop|restart|archive|unarchive|delete|prune|clean|stats> [options]
|
|
25
28
|
list [--sort updated_at|created_at] [--order desc|asc] [--limit N]
|
|
26
29
|
[--live] [--cwd PATH] [--format text|json|tsv] [--archived]
|
|
27
30
|
--live: sessions a worker runs now (the ones chi note reaches), 10 unless --limit
|
|
@@ -34,12 +37,18 @@ module Samagotchi
|
|
|
34
37
|
delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
|
|
35
38
|
prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
|
|
36
39
|
clean [--dry-run] [--days N] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
|
|
40
|
+
stats ID [--format text|json] # a session's cost, tokens and progress, without running a model turn:
|
|
41
|
+
a live worker's GET stats, else the snapshot its saved analytics.json rebuilds (never starts a worker)
|
|
37
42
|
Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)
|
|
38
43
|
TEXT
|
|
39
44
|
|
|
40
45
|
STOP_USAGE = "Usage: chi sessions stop ID...\n"
|
|
41
46
|
RESTART_USAGE = "Usage: chi sessions restart ID...\n"
|
|
42
|
-
|
|
47
|
+
STATS_USAGE = "Usage: chi sessions stats ID [--format text|json]\n"
|
|
48
|
+
# How long `stats` waits for a live worker's GET stats before reading the
|
|
49
|
+
# saved analytics.json instead (a stuck worker must not hold a driver).
|
|
50
|
+
STATS_TIMEOUT = 3
|
|
51
|
+
SUBCOMMANDS = %w[list stop restart archive unarchive delete prune clean stats].freeze
|
|
43
52
|
|
|
44
53
|
# Each subcommand's flags ("--flag V" or "--flag=V" for a value): an
|
|
45
54
|
# unknown flag, a value flag with nothing after it or an argument where
|
|
@@ -53,6 +62,9 @@ module Samagotchi
|
|
|
53
62
|
end,
|
|
54
63
|
"stop" => CLI::Flags.new,
|
|
55
64
|
"restart" => CLI::Flags.new,
|
|
65
|
+
"stats" => CLI::Flags.new do |f|
|
|
66
|
+
f.value "--format"
|
|
67
|
+
end,
|
|
56
68
|
"prune" => CLI::Flags.new(args: false) do |f|
|
|
57
69
|
%w[--days --keep --keep-status].each { |name| f.value name }
|
|
58
70
|
f.switch "--dry-run"
|
|
@@ -98,6 +110,7 @@ module Samagotchi
|
|
|
98
110
|
when "list" then list
|
|
99
111
|
when "stop" then stop(parsed.args)
|
|
100
112
|
when "restart" then restart(parsed.args)
|
|
113
|
+
when "stats" then stats(parsed.args)
|
|
101
114
|
else prune(@sub)
|
|
102
115
|
end
|
|
103
116
|
end
|
|
@@ -105,7 +118,7 @@ module Samagotchi
|
|
|
105
118
|
private
|
|
106
119
|
|
|
107
120
|
def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi sessions #{@sub}" : "chi sessions"
|
|
108
|
-
def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE }.fetch(@sub, USAGE)
|
|
121
|
+
def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE, "stats" => STATS_USAGE }.fetch(@sub, USAGE)
|
|
109
122
|
|
|
110
123
|
# quirk: a number that isn't one is 0 (--days=abc turns the age limit off)
|
|
111
124
|
def integers(options)
|
|
@@ -269,6 +282,70 @@ module Samagotchi
|
|
|
269
282
|
0
|
|
270
283
|
end
|
|
271
284
|
|
|
285
|
+
# One session's cost, tokens and progress, for a script or an agent that
|
|
286
|
+
# drives sessions (chi send --wait): a live worker's GET stats, else the
|
|
287
|
+
# snapshot its saved analytics.json rebuilds. Never starts a worker, and
|
|
288
|
+
# never sends the model anything.
|
|
289
|
+
def stats(ids)
|
|
290
|
+
return usage_error("give a session id") if ids.empty?
|
|
291
|
+
return usage_error("stats takes one session id") if ids.size > 1
|
|
292
|
+
|
|
293
|
+
format = @opts[:format]
|
|
294
|
+
unless format.nil? || %w[text json].include?(format)
|
|
295
|
+
return usage_error("unknown format #{format.inspect}: use --format text|json")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
id = begin
|
|
299
|
+
Samagotchi::Session.resolve_id(ids.first)
|
|
300
|
+
rescue Samagotchi::Session::AmbiguousId => e
|
|
301
|
+
@stdout.flush
|
|
302
|
+
@stderr.puts e.message
|
|
303
|
+
return 1
|
|
304
|
+
end
|
|
305
|
+
session = begin
|
|
306
|
+
Samagotchi::Session.load(id)
|
|
307
|
+
rescue ArgumentError => e
|
|
308
|
+
@stdout.flush
|
|
309
|
+
@stderr.puts e.message
|
|
310
|
+
return 1
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
metrics, live, note = stats_snapshot_for(session)
|
|
314
|
+
if format == "json"
|
|
315
|
+
@stdout.puts JSON.generate({ session_id: session.id, status: session.status, live: live, metrics: metrics })
|
|
316
|
+
else
|
|
317
|
+
@stdout.puts "#{session.id[0, 8]} #{session.status.ljust(7)} #{session.model_name}"
|
|
318
|
+
@stdout.puts TerminalUI::Formatting.format_session_metrics(metrics)
|
|
319
|
+
@stdout.puts note if note
|
|
320
|
+
end
|
|
321
|
+
0
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# [snapshot, live, note]: a live worker answers GET stats (no model call);
|
|
325
|
+
# without one — or one that does not answer in time — the snapshot comes
|
|
326
|
+
# from the session's saved analytics.json, rebuilt by SessionMetrics (the
|
|
327
|
+
# same collector a worker runs, loaded from disk). The note says when the
|
|
328
|
+
# worker did not answer. nil when the session has no analytics.json yet.
|
|
329
|
+
# Never starts a worker.
|
|
330
|
+
# @return [Array(Hash, nil, Boolean, String, nil)]
|
|
331
|
+
def stats_snapshot_for(session)
|
|
332
|
+
dir = Samagotchi::Session.session_dir(session.id)
|
|
333
|
+
if (port = Samagotchi::BridgeClient.sidecar_port(dir))
|
|
334
|
+
client = Samagotchi::BridgeClient.new(session_id: session.id, port: port, read_timeout: STATS_TIMEOUT)
|
|
335
|
+
body = client.get_json("stats")&.dig("metrics")
|
|
336
|
+
if body.is_a?(Hash)
|
|
337
|
+
return [TerminalUI::EventRenderer.deep_symbolize_keys(body), true, nil]
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
return [nil, false, nil] unless File.file?(File.join(dir, "analytics.json"))
|
|
341
|
+
|
|
342
|
+
snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.saved_snapshot
|
|
343
|
+
note = live_worker?(session.id) ? "(no live worker answered; from the saved analytics.json)" : nil
|
|
344
|
+
[snapshot, false, note]
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def live_worker?(session_id) = Samagotchi::SessionManager.worker_live?(session_id)
|
|
348
|
+
|
|
272
349
|
def stop(ids)
|
|
273
350
|
return usage_error("give session ids") if ids.empty?
|
|
274
351
|
|
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
|
|
|
@@ -1226,7 +1273,7 @@ module Samagotchi
|
|
|
1226
1273
|
params: part[:params], status: status,
|
|
1227
1274
|
description: ToolView.description_title(part[:view] || {}) }
|
|
1228
1275
|
"#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
|
|
1229
|
-
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
1276
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}"
|
|
1230
1277
|
end
|
|
1231
1278
|
|
|
1232
1279
|
# The context notes that came since the last prompt (all of them in a
|
|
@@ -1320,7 +1367,7 @@ module Samagotchi
|
|
|
1320
1367
|
|
|
1321
1368
|
activity = EventRenderer.described(event) || {}
|
|
1322
1369
|
@screen.commit(snapshot_tool_line({ tool: event[:tool], params: activity[:params], status: activity[:status],
|
|
1323
|
-
description: activity[:description],
|
|
1370
|
+
description: activity[:description], output_truncated: event[:output_truncated],
|
|
1324
1371
|
images: event[:images], diff: event[:diff] }))
|
|
1325
1372
|
end
|
|
1326
1373
|
|
|
@@ -72,7 +72,8 @@ module Samagotchi
|
|
|
72
72
|
# A replayed row (a join) carries its duration (nil: unknown).
|
|
73
73
|
measured = tool_duration_ms(event)
|
|
74
74
|
render_streamed_tool_activity(self.class.described(event), duration_ms: event.fetch(:duration_ms, measured),
|
|
75
|
-
images: event[:images], diff: event[:diff]
|
|
75
|
+
images: event[:images], diff: event[:diff],
|
|
76
|
+
output_truncated: event[:output_truncated])
|
|
76
77
|
when :generation_completed, :generation_cancelled, :tool_dispatch_started
|
|
77
78
|
@view.generation_feedback_finished
|
|
78
79
|
when :pending_input_merged
|
|
@@ -211,13 +212,14 @@ module Samagotchi
|
|
|
211
212
|
|
|
212
213
|
private
|
|
213
214
|
|
|
214
|
-
def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
|
|
215
|
+
def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil, output_truncated: nil)
|
|
215
216
|
return if activity.nil?
|
|
216
217
|
|
|
217
218
|
@streamed_tool_activity[tool_activity_key(activity)] += 1
|
|
218
219
|
line = @view.format_tool_activity_line(activity, duration_ms: duration_ms)
|
|
219
220
|
line += @view.format_tool_image_suffix(images) if images&.any?
|
|
220
221
|
line += @view.format_tool_diff_suffix(diff) if diff
|
|
222
|
+
line += @view.format_tool_cut_suffix(output_truncated) if output_truncated
|
|
221
223
|
@view.print_line(line)
|
|
222
224
|
end
|
|
223
225
|
|
|
@@ -14,6 +14,14 @@ module Samagotchi
|
|
|
14
14
|
# `tool>` line, elapsed durations. Pure apart from reading whether
|
|
15
15
|
# $stdout is a colour terminal.
|
|
16
16
|
module Formatting
|
|
17
|
+
# The /stats text for a metrics snapshot, for the REPL and attached
|
|
18
|
+
# views (an instance includes the module) and for `chi sessions stats`
|
|
19
|
+
# (no UI: the same text from the CLI).
|
|
20
|
+
# @return [String] "(no metrics yet)" without a snapshot
|
|
21
|
+
def self.format_session_metrics(snapshot)
|
|
22
|
+
(@formatter ||= Object.new.extend(Formatting)).send(:format_session_metrics, snapshot)
|
|
23
|
+
end
|
|
24
|
+
|
|
17
25
|
def format_tool_activity_line(activity, duration_ms: nil)
|
|
18
26
|
status = activity[:status].to_s
|
|
19
27
|
elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
|
|
@@ -23,13 +31,17 @@ module Samagotchi
|
|
|
23
31
|
|
|
24
32
|
# What a tool line says the call did: the model's description of a
|
|
25
33
|
# command (": List the specs") in place of its cut params, else the
|
|
26
|
-
# params, dim
|
|
34
|
+
# params, dim; then, dim, the name the model called it by when that was
|
|
35
|
+
# an alias's (" (called as bash)").
|
|
27
36
|
def tool_params_suffix(activity)
|
|
28
37
|
description = activity[:description].to_s.strip
|
|
29
|
-
return ": #{description}" unless description.empty?
|
|
30
|
-
|
|
31
38
|
params = activity[:params].to_s.strip
|
|
32
|
-
|
|
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)}"
|
|
33
45
|
end
|
|
34
46
|
|
|
35
47
|
# Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
|
|
@@ -83,6 +95,45 @@ module Samagotchi
|
|
|
83
95
|
paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
|
|
84
96
|
end
|
|
85
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
|
+
|
|
86
137
|
# "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
|
|
87
138
|
# off)": a batch of LLM context edits went in (LLMContextNotice builds
|
|
88
139
|
# the line; the web prints the same), dim like the retry row.
|
|
@@ -138,6 +189,12 @@ module Samagotchi
|
|
|
138
189
|
" #{paint("+#{get.call(:added).to_i}", 32)} #{paint("\u2212#{get.call(:removed).to_i}", 31)}"
|
|
139
190
|
end
|
|
140
191
|
|
|
192
|
+
# A row whose output the runner cut (max_tool_output_chars): " [cut]",
|
|
193
|
+
# dim, beside the diff one.
|
|
194
|
+
def format_tool_cut_suffix(output_truncated)
|
|
195
|
+
output_truncated ? " #{paint("[cut]", 90)}" : ""
|
|
196
|
+
end
|
|
197
|
+
|
|
141
198
|
# Prompt labels by the sender's client_id: a whole id, or a prefix
|
|
142
199
|
# (ending in ":") it starts with (turn_events.js CLIENT_LABELS;
|
|
143
200
|
# spec/shared/labels_matrix.json).
|
|
@@ -212,7 +269,7 @@ module Samagotchi
|
|
|
212
269
|
def snapshot_tool_line(part)
|
|
213
270
|
status = part[:status].to_s
|
|
214
271
|
"#{paint("tool>", 36)} #{part[:tool]}#{tool_params_suffix(part)}: #{paint(status, status_color(status))}" \
|
|
215
|
-
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
272
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}"
|
|
216
273
|
end
|
|
217
274
|
|
|
218
275
|
# A card (Engine#show_card) as a framed block: the title and its
|
|
@@ -482,7 +539,8 @@ module Samagotchi
|
|
|
482
539
|
end
|
|
483
540
|
|
|
484
541
|
# Render the analytics snapshot as a compact, user-facing report. Raw event
|
|
485
|
-
# logs (debug-only) are intentionally excluded; this surface is for the REPL
|
|
542
|
+
# logs (debug-only) are intentionally excluded; this surface is for the REPL
|
|
543
|
+
# and `chi sessions stats` (the module function above).
|
|
486
544
|
def format_session_metrics(snapshot)
|
|
487
545
|
return "(no metrics yet)" unless snapshot.is_a?(Hash)
|
|
488
546
|
|
|
@@ -501,6 +559,10 @@ module Samagotchi
|
|
|
501
559
|
by_tool = snapshot[:tool_calls_by_tool].sort_by { |_k, v| -v }
|
|
502
560
|
lines << " by tool: #{by_tool.map { |k, v| "#{k}=#{v}" }.join(", ")}"
|
|
503
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
|
|
504
566
|
lines << "iterations: #{snapshot[:iterations_total]}"
|
|
505
567
|
# Summed over every request: each prompt is sent in full again.
|
|
506
568
|
lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
|
|
@@ -752,6 +752,14 @@ module Samagotchi
|
|
|
752
752
|
def run_input_line(session, input)
|
|
753
753
|
return if input.empty?
|
|
754
754
|
|
|
755
|
+
# /cut TEXT and /queue TEXT are deliveries only while a turn runs; at
|
|
756
|
+
# the prompt both run the text as a normal turn, as their usage says.
|
|
757
|
+
if (choice = steer_choice(input))
|
|
758
|
+
return @surface.commit(STEER_CHOICE_USAGE) if choice.last.empty?
|
|
759
|
+
|
|
760
|
+
input = choice.last
|
|
761
|
+
end
|
|
762
|
+
|
|
755
763
|
# /model, /models, !rollback, !cmd, /continue (shared with workers);
|
|
756
764
|
# an anytime command's cards print as it shows them (btw's
|
|
757
765
|
# "thinking…" before the answer).
|
|
@@ -1083,6 +1091,10 @@ module Samagotchi
|
|
|
1083
1091
|
# plugin's /btw, /model alone) starts now on its own thread; one that
|
|
1084
1092
|
# runs after the turn (/model X, !cmd) waits in the queue, in arrival
|
|
1085
1093
|
# order with the other lines that wait; others go back in the prompt.
|
|
1094
|
+
#
|
|
1095
|
+
# A plain line goes in at the running turn's next step boundary and
|
|
1096
|
+
# never cuts; /cut TEXT asks for a cut, /queue TEXT waits for the turn
|
|
1097
|
+
# to end and runs as a turn of its own.
|
|
1086
1098
|
# @return [Boolean, :back] whether the turn took it, :back to put it back
|
|
1087
1099
|
def steer_line(line)
|
|
1088
1100
|
local = local_command(line) unless line.nil?
|
|
@@ -1091,6 +1103,12 @@ module Samagotchi
|
|
|
1091
1103
|
return detach_note if local == :detach
|
|
1092
1104
|
return false if @active_cancel_controller&.cancelled?
|
|
1093
1105
|
|
|
1106
|
+
# Before every command check: /cut and /queue are the UIs' own words
|
|
1107
|
+
# for a delivery (they are listed for Tab and /help, never run).
|
|
1108
|
+
if (choice = steer_choice(line))
|
|
1109
|
+
return submit_steer_choice(*choice)
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1094
1112
|
policy = command_registry.mid_turn(line)
|
|
1095
1113
|
return start_anytime_command(line) if policy == :anytime
|
|
1096
1114
|
return command_queued(line) if policy == :queue
|
|
@@ -1108,12 +1126,37 @@ module Samagotchi
|
|
|
1108
1126
|
# Behind a command that waits for the turn's end, a line waits too.
|
|
1109
1127
|
return wait_for_turn_end(line) if @command_waits
|
|
1110
1128
|
|
|
1111
|
-
epoch = @engine.input_epoch
|
|
1112
1129
|
@pending_input_queue.push(line.strip)
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1130
|
+
@surface.commit(NEXT_STEP_NOTE)
|
|
1131
|
+
true
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
# /cut TEXT and /queue TEXT during a turn, which the REPL would
|
|
1135
|
+
# otherwise read as prompts (SessionCommands registers them for Tab and
|
|
1136
|
+
# help only). A cut asks the engine to cut the generation only thinking
|
|
1137
|
+
# too long; a queue waits for the turn's end (the REPL's own after-turn
|
|
1138
|
+
# list). Empty text gets the usage line. A line with images never steers
|
|
1139
|
+
# (as a plain one doesn't).
|
|
1140
|
+
# @return [Boolean] whether the turn took it
|
|
1141
|
+
def submit_steer_choice(choice, text)
|
|
1142
|
+
if text.empty?
|
|
1143
|
+
@surface.commit(STEER_CHOICE_USAGE)
|
|
1144
|
+
return true
|
|
1145
|
+
end
|
|
1146
|
+
return image_line_waits(text) if ImageInput.extract(text).any?
|
|
1147
|
+
|
|
1148
|
+
# A /queue waits for the turn's end; a /cut behind a command that
|
|
1149
|
+
# already waits there goes after it too.
|
|
1150
|
+
if choice == :queue || @command_waits
|
|
1151
|
+
@surface.commit(COMMAND_QUEUED)
|
|
1152
|
+
return wait_for_turn_end(text)
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
epoch = @engine.input_epoch
|
|
1156
|
+
@pending_input_queue.push(text)
|
|
1157
|
+
# With no session running the cut is :off; the text still waits for
|
|
1158
|
+
# the running turn's next step.
|
|
1159
|
+
@surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch)))
|
|
1117
1160
|
true
|
|
1118
1161
|
end
|
|
1119
1162
|
|
|
@@ -47,6 +47,9 @@ module Samagotchi
|
|
|
47
47
|
}
|
|
48
48
|
title = tool_title(tool_name, call, cwd: cwd)
|
|
49
49
|
event[:title] = title if title
|
|
50
|
+
# The name the model used when an alias ran a built-in (called_as: "bash"
|
|
51
|
+
# for an execute), for a UI that shows it.
|
|
52
|
+
event[:called_as] = call[:called_as] if call.is_a?(Hash) && call[:called_as]
|
|
50
53
|
# The model's own words for a command, which the TUI's tool line
|
|
51
54
|
# shows in place of the cut params.
|
|
52
55
|
description = command_description(tool_name, call)
|
|
@@ -57,6 +57,8 @@ module Samagotchi
|
|
|
57
57
|
# The worker runs in its session's working directory.
|
|
58
58
|
title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
|
|
59
59
|
started[:title] = title if title
|
|
60
|
+
# The name the model called it by when that was an alias's (bash).
|
|
61
|
+
started[:called_as] = call[:called_as] if call[:called_as]
|
|
60
62
|
# The full command for a richer UI (params stays cut at 80).
|
|
61
63
|
view = ToolView.for(call[:name], call)&.to_h
|
|
62
64
|
started[:view] = view if view
|
|
@@ -56,6 +56,13 @@ module Samagotchi
|
|
|
56
56
|
"forget_outputs" => { content: "note", also: %w[note], verbatim: %w[ids keep restore] }
|
|
57
57
|
}.freeze
|
|
58
58
|
|
|
59
|
+
# A name the model uses for a built-in, mapped to that built-in (a model
|
|
60
|
+
# that keeps calling `bash` with execute's arguments; before this such a
|
|
61
|
+
# call died as an unknown tool). It only takes effect when the
|
|
62
|
+
# arguments really are the target's shape; the call then carries
|
|
63
|
+
# called_as: with the model's own spelling.
|
|
64
|
+
ALIASES = { "bash" => "execute" }.freeze
|
|
65
|
+
|
|
59
66
|
# One built-in's mapping, resolved from its schema and OVERRIDES.
|
|
60
67
|
Row = Data.define(:name, :content_keys, :path_key, :fields, :aliases, :verbatim, :fallback, :fallback_key,
|
|
61
68
|
:options) do
|
|
@@ -109,6 +116,14 @@ module Samagotchi
|
|
|
109
116
|
def build(name, args, raw: nil)
|
|
110
117
|
name = name.to_s
|
|
111
118
|
args = args.is_a?(Hash) ? args.transform_keys(&:to_s) : {}
|
|
119
|
+
# An alias runs its target (bash → execute); the row is the target's,
|
|
120
|
+
# and the call keeps the model's own spelling on called_as:.
|
|
121
|
+
# Not the target's shape (a String main argument, only its keys) and
|
|
122
|
+
# the alias doesn't take: the call passes through as an unknown tool.
|
|
123
|
+
if (target = ALIASES[name.downcase])
|
|
124
|
+
return aliasable?(row(target), args) ? aliased(name, target, args, raw) : passthrough(name, args, raw)
|
|
125
|
+
end
|
|
126
|
+
|
|
112
127
|
row = row(name)
|
|
113
128
|
return passthrough(name, args, raw) unless row
|
|
114
129
|
|
|
@@ -120,6 +135,24 @@ module Samagotchi
|
|
|
120
135
|
call
|
|
121
136
|
end
|
|
122
137
|
|
|
138
|
+
# An execute-shaped call the model spelled +name+: built as its +target+,
|
|
139
|
+
# tagged with the model's spelling for the activity event and the log.
|
|
140
|
+
def aliased(name, target, args, raw)
|
|
141
|
+
call = build(target, args, raw: raw)
|
|
142
|
+
call[:called_as] = name
|
|
143
|
+
call
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The alias gate: a String main argument, and every key one the target's
|
|
147
|
+
# schema knows (its aliases included). More, or differently shaped, the
|
|
148
|
+
# call is the model's own and passes through as an unknown tool.
|
|
149
|
+
def aliasable?(row, args)
|
|
150
|
+
return false unless row
|
|
151
|
+
|
|
152
|
+
row.content_keys.map { |key| value(row, args, key) }.any?(String) &&
|
|
153
|
+
(args.keys - (row.content_keys + [row.path_key].compact + row.fields + row.aliases.keys)).empty?
|
|
154
|
+
end
|
|
155
|
+
|
|
123
156
|
def content_value(row, args)
|
|
124
157
|
found = row.content_keys.lazy.map { |key| value(row, args, key) }.find { |v| !v.nil? }
|
|
125
158
|
found.nil? ? "" : found.to_s
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1065,8 +1065,12 @@ module Samagotchi
|
|
|
1065
1065
|
images = turn_images(id, body["images"])
|
|
1066
1066
|
return error_response(400, "bad_images", images) if images.is_a?(String)
|
|
1067
1067
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1068
|
+
delivery = body["delivery"] || body[:delivery]
|
|
1069
|
+
return error_response(400, "bad_delivery", "delivery must be a string") unless delivery.nil? || delivery.is_a?(String)
|
|
1070
|
+
|
|
1071
|
+
result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
|
|
1072
|
+
delivery: delivery, state_dir: @state_dir, manager: @manager,
|
|
1073
|
+
bridge: -> { live_bridge_client(id) })
|
|
1070
1074
|
case result[:status]
|
|
1071
1075
|
when :accepted
|
|
1072
1076
|
record_history(body, prompt)
|
|
@@ -233,6 +233,9 @@ module Samagotchi
|
|
|
233
233
|
part = { tool: name, params: params.to_s }
|
|
234
234
|
title = ToolActivity.tool_title(name, call, cwd: cwd)
|
|
235
235
|
part[:title] = title if title
|
|
236
|
+
# The name the model called it by when that was an alias's (the
|
|
237
|
+
# saved call is rebuilt by BuiltinCalls, which sets it again).
|
|
238
|
+
part[:called_as] = call[:called_as] if call[:called_as]
|
|
236
239
|
# Built from the saved call (never saved itself), so older sessions
|
|
237
240
|
# get it too.
|
|
238
241
|
view = ToolView.for(name, call)&.to_h
|