samagotchi 0.48.0 → 0.50.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 +73 -1
- data/bin/chi +2 -2
- data/docs/cli.md +12 -1
- data/docs/configuration.md +1 -1
- data/docs/context.md +9 -2
- data/docs/hooks.md +9 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +5 -2
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +39 -6
- 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/child_move.rb +22 -8
- data/lib/samagotchi/engine.rb +3 -2
- data/lib/samagotchi/hooks/stream_watch.rb +9 -2
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/model_profile.rb +11 -4
- data/lib/samagotchi/plugin/context.rb +13 -1
- data/lib/samagotchi/prompt.rb +26 -11
- data/lib/samagotchi/prompt_literal_guard.rb +4 -1
- data/lib/samagotchi/send_command.rb +61 -0
- data/lib/samagotchi/session_commands.rb +1 -2
- data/lib/samagotchi/session_metrics.rb +7 -0
- data/lib/samagotchi/sessions_command.rb +84 -7
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +17 -2
- data/lib/samagotchi/terminal_ui/question_prompt.rb +7 -0
- data/lib/samagotchi/terminal_ui.rb +1 -1
- data/lib/samagotchi/tools/task_runtime.rb +4 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +3 -3
- data/lib/samagotchi/web/public/app.js +12 -3
- data/lib/samagotchi/web/public/live_timing.js +32 -5
- 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/timing.js +11 -0
- data/lib/samagotchi/web/public/turn_events.js +11 -0
- data/lib/samagotchi/web/public/turn_html.js +21 -0
- data/lib/samagotchi/web/public/turn_view.js +9 -3
- data/lib/samagotchi/web/session_hub.rb +11 -3
- metadata +1 -1
|
@@ -40,7 +40,7 @@ module Samagotchi
|
|
|
40
40
|
:system_prefix, :user_prefix, :assistant_prefix,
|
|
41
41
|
:model_prefix,
|
|
42
42
|
:stop_sequences, :tool_decl_format,
|
|
43
|
-
:image_template
|
|
43
|
+
:image_template, :image_check_token
|
|
44
44
|
|
|
45
45
|
def initialize(config)
|
|
46
46
|
@name = config[:name]
|
|
@@ -65,10 +65,12 @@ module Samagotchi
|
|
|
65
65
|
# How the chat template wraps an image's media marker ("%{marker}"),
|
|
66
66
|
# or nil when chi doesn't know it yet (native images are refused).
|
|
67
67
|
@image_template = config[:image_template]
|
|
68
|
+
# A token the server's chat template must contain for images to be
|
|
69
|
+
# sent (VisionSupport); defaults to the template's opening token.
|
|
70
|
+
@image_check_token = config[:image_check_token] || image_open_token
|
|
68
71
|
end
|
|
69
72
|
|
|
70
|
-
# The token the image template opens with, e.g. "<|vision_start|>"
|
|
71
|
-
# server's chat template must use it too (VisionSupport).
|
|
73
|
+
# The token the image template opens with, e.g. "<|vision_start|>".
|
|
72
74
|
def image_open_token
|
|
73
75
|
image_template&.split("%{marker}")&.first.to_s
|
|
74
76
|
end
|
|
@@ -92,7 +94,12 @@ module Samagotchi
|
|
|
92
94
|
assistant_prefix: "",
|
|
93
95
|
model_prefix: "",
|
|
94
96
|
stop_sequences: ["<turn|>", "<|tool_response>"],
|
|
95
|
-
tool_decl_format: :gemma4
|
|
97
|
+
tool_decl_format: :gemma4,
|
|
98
|
+
# The bare marker: llama.cpp's mtmd wraps Gemma 4 images in
|
|
99
|
+
# <|image>…<image|> itself, as its chat path does (V13 spike). The
|
|
100
|
+
# template writes <|image|> for an image part; Gemma 3's doesn't.
|
|
101
|
+
image_template: "%{marker}",
|
|
102
|
+
image_check_token: "<|image|>"
|
|
96
103
|
)
|
|
97
104
|
end
|
|
98
105
|
|
|
@@ -25,9 +25,11 @@ module Samagotchi
|
|
|
25
25
|
# model's memory overlay key (ctx.model, ctx.model_key).
|
|
26
26
|
# +steer+ takes (text, label), +stop_turn+ and +stop_generation+
|
|
27
27
|
# (reason, label), each true when it acted on a running turn.
|
|
28
|
+
# +frontend+ returns the Engine's interface (:repl, :worker,
|
|
29
|
+
# :non_interactive), which ctx.frontend names for plugins.
|
|
28
30
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
29
31
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :llm_context, :steer, :stop_turn, :stop_generation,
|
|
30
|
-
keyword_init: true)
|
|
32
|
+
:frontend, keyword_init: true)
|
|
31
33
|
|
|
32
34
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
33
35
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -103,6 +105,16 @@ module Samagotchi
|
|
|
103
105
|
# Whether the session is a `chi scratch` one (deleted when it ends).
|
|
104
106
|
def scratch? = !!@host.scratch&.call
|
|
105
107
|
|
|
108
|
+
# ctx.frontend's values, by the Engine's interface.
|
|
109
|
+
FRONTENDS = { repl: :repl, non_interactive: :one_shot, worker: :worker }.freeze
|
|
110
|
+
|
|
111
|
+
# What runs the session, read now (the host sets it after the plugins
|
|
112
|
+
# load): :repl (the REPL, -p without --non-interactive), :one_shot
|
|
113
|
+
# (-p --non-interactive: one turn, then exit) or :worker (a session
|
|
114
|
+
# worker: the web, an attached TUI). A host without one is :worker.
|
|
115
|
+
# @return [Symbol]
|
|
116
|
+
def frontend = FRONTENDS.fetch(@host.frontend&.call, :worker)
|
|
117
|
+
|
|
106
118
|
# Whether the session is a delegate child (Session#delegate?): a task
|
|
107
119
|
# another session handed over, not the user's own.
|
|
108
120
|
def delegate? = !!saved_session&.delegate?
|
data/lib/samagotchi/prompt.rb
CHANGED
|
@@ -59,30 +59,45 @@ module Samagotchi
|
|
|
59
59
|
[text + prefill_for(messages, profile, prefill), images]
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
# A message's image lines: +text+ holds them all in order (what goes
|
|
63
|
+
# inside the message); +placeholders+ and +markers+ split the lines of
|
|
64
|
+
# images not sent from the sent ones' media markers, for Gemma's tool
|
|
65
|
+
# response, whose markers go after the block.
|
|
66
|
+
ImageLines = Data.define(:lines) do
|
|
67
|
+
def text = join(lines)
|
|
68
|
+
def placeholders = join(lines.reject(&:last))
|
|
69
|
+
def markers = join(lines.select(&:last))
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def join(subset) = subset.empty? ? "" : "\n#{subset.map(&:first).join("\n")}"
|
|
74
|
+
end
|
|
64
75
|
|
|
76
|
+
def self.image_lines(items, profile, images)
|
|
65
77
|
lines = items.each_with_index.map do |item, index|
|
|
66
|
-
next item.placeholder unless item.sent?
|
|
67
|
-
next ImageRef.placeholder(item.ref, ImagePlan::CANT_SEE) unless profile.image_template
|
|
78
|
+
next [item.placeholder, false] unless item.sent?
|
|
79
|
+
next [ImageRef.placeholder(item.ref, ImagePlan::CANT_SEE), false] unless profile.image_template
|
|
68
80
|
|
|
69
81
|
images << item.data
|
|
70
|
-
"[image #{index + 1}: #{ImageRef.name(item.ref)}] " \
|
|
71
|
-
|
|
82
|
+
["[image #{index + 1}: #{ImageRef.name(item.ref)}] " \
|
|
83
|
+
"#{Kernel.format(profile.image_template, marker: ImagePlan::NATIVE_PLACEHOLDER)}", true]
|
|
72
84
|
end
|
|
73
|
-
|
|
85
|
+
ImageLines.new(lines: lines)
|
|
74
86
|
end
|
|
75
87
|
|
|
76
88
|
def self.format_with_turn_markers(messages, profile, suffixes)
|
|
77
89
|
last_user = messages.rindex { |m| m[:role] == "user" } || -1
|
|
78
90
|
parts = messages.each_with_index.map do |m, index|
|
|
79
|
-
|
|
91
|
+
lines = suffixes[index]
|
|
92
|
+
content = prompt_content_for(m, profile) + (m[:role] == "tool_response" ? lines.placeholders : lines.text)
|
|
80
93
|
previous = index.positive? ? messages[index - 1][:role] : nil
|
|
81
94
|
following = messages[index + 1]&.dig(:role)
|
|
82
95
|
case m[:role]
|
|
83
96
|
when "tool_response"
|
|
84
|
-
# Inside the model's turn; closed when another role follows.
|
|
85
|
-
|
|
97
|
+
# Inside the model's turn; closed when another role follows. Its
|
|
98
|
+
# sent images go after the blocks, outside the quoted value, as
|
|
99
|
+
# Gemma's chat template puts them; placeholder lines stay inside.
|
|
100
|
+
tool_response_blocks(content, profile) + lines.markers +
|
|
86
101
|
(following && following != "tool_response" && following != "model" ? "#{profile.turn_end}\n" : "")
|
|
87
102
|
when "model"
|
|
88
103
|
content = strip_gemma_thought(content) if index < last_user && !content.include?(profile.tool_call_open)
|
|
@@ -136,7 +151,7 @@ module Samagotchi
|
|
|
136
151
|
parts = []
|
|
137
152
|
|
|
138
153
|
messages.each_with_index do |m, index|
|
|
139
|
-
content = prompt_content_for(m, profile) + suffixes[index]
|
|
154
|
+
content = prompt_content_for(m, profile) + suffixes[index].text
|
|
140
155
|
case m[:role]
|
|
141
156
|
when "system"
|
|
142
157
|
parts << "#{profile.system_prefix}#{content}<|im_end|>\n"
|
|
@@ -16,7 +16,10 @@ module Samagotchi
|
|
|
16
16
|
["[[SAMAGOTCHI_LITERAL_GEMMA_STRING_DELIM]]", '<|"|>'],
|
|
17
17
|
["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<|think|>"],
|
|
18
18
|
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN],
|
|
19
|
-
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE]
|
|
19
|
+
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE],
|
|
20
|
+
["[[SAMAGOTCHI_LITERAL_IMAGE_SOFT]]", "<|image|>"],
|
|
21
|
+
["[[SAMAGOTCHI_LITERAL_IMAGE_START]]", "<|image>"],
|
|
22
|
+
["[[SAMAGOTCHI_LITERAL_IMAGE_END]]", "<image|>"]
|
|
20
23
|
].freeze
|
|
21
24
|
|
|
22
25
|
QWEN_TOKEN_PAIRS = [
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "client_id"
|
|
4
4
|
require_relative "session"
|
|
5
|
+
require_relative "session_commands"
|
|
5
6
|
require_relative "session_inbox"
|
|
6
7
|
require_relative "session_manager"
|
|
7
8
|
require_relative "session_chain"
|
|
@@ -175,6 +176,14 @@ module Samagotchi
|
|
|
175
176
|
@stderr.puts("chi send: #{e.message}")
|
|
176
177
|
return 1
|
|
177
178
|
end
|
|
179
|
+
# A UI-only command (/stats, /exit, …) runs in the terminal and the web,
|
|
180
|
+
# not in the worker: sent through, the worker doesn't run it and the
|
|
181
|
+
# line reaches the model as a prompt, costing a whole turn. Refused
|
|
182
|
+
# before anything is sent (before a --new session is created too); the
|
|
183
|
+
# session's plugin commands are never local.
|
|
184
|
+
if prompt && (local = local_command(prompt))
|
|
185
|
+
return refuse_local_command(local, options)
|
|
186
|
+
end
|
|
178
187
|
# A file that is not there or not an image: a run-time failure (1),
|
|
179
188
|
# reported in the --format json line too; --image itself was fine.
|
|
180
189
|
return CLI::Exit::FAILED unless ingest_images(options[:images])
|
|
@@ -440,6 +449,58 @@ module Samagotchi
|
|
|
440
449
|
# (a bundle's too).
|
|
441
450
|
def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
|
|
442
451
|
|
|
452
|
+
# The UI-only built-in command +text+ (stripped) is a line of (/stats,
|
|
453
|
+
# /exit, …), or nil: the worker never runs it, so chi send must not pass
|
|
454
|
+
# it on. What the UIs run: the line as the entry's match takes it — bare
|
|
455
|
+
# "exit" too (/exit's match), but not with arguments ("/exit --delete" is
|
|
456
|
+
# a UI line; "/stats please" is a prompt), as in a terminal.
|
|
457
|
+
# @return [Commands::Registry::Entry, nil]
|
|
458
|
+
def local_command(text)
|
|
459
|
+
line = text.to_s.strip
|
|
460
|
+
return nil unless command_like?(line) || line.match?(/\Aexit\z/i)
|
|
461
|
+
|
|
462
|
+
entry = SessionCommands.builtin_registry.lookup_local(line)
|
|
463
|
+
# Only the exact line the UI runs: "/stats please" is a prompt, and
|
|
464
|
+
# "/exit --delete" is left to the worker (the TUI runs it as /exit, the
|
|
465
|
+
# worker answers its unknown-command way). Bare "exit" is /exit's line
|
|
466
|
+
# as its match takes it; the reply names the command, /exit.
|
|
467
|
+
return nil unless entry
|
|
468
|
+
return entry if line == entry.name
|
|
469
|
+
|
|
470
|
+
line.match?(/\Aexit\z/i) ? entry : nil
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# A UI-only command: nothing is sent (with --new, no session is created
|
|
474
|
+
# either). /stats points at its model-free reader and names the session;
|
|
475
|
+
# the others just say the worker doesn't run them. Exit 1, as other
|
|
476
|
+
# refusals; with --wait --format json the line is the usual error object
|
|
477
|
+
# (ParentWait's end-of-run line when the reply never comes).
|
|
478
|
+
# @return [Integer] 1
|
|
479
|
+
def refuse_local_command(entry, options)
|
|
480
|
+
if options[:new]
|
|
481
|
+
error_line("chi send: refused: #{local_refusal(entry.name, nil)}")
|
|
482
|
+
elsif options[:wait]
|
|
483
|
+
# --wait: the JSON line goes to stdout, @info is stderr; an unknown
|
|
484
|
+
# or ambiguous id gets resolve's own error line.
|
|
485
|
+
id = resolve(options[:ids].first) or return CLI::Exit::FAILED
|
|
486
|
+
@stderr.puts("#{id[0, 8]} refused: #{local_refusal(entry.name, id)}")
|
|
487
|
+
else
|
|
488
|
+
options[:ids].uniq.each do |given|
|
|
489
|
+
id = resolve(given) or next
|
|
490
|
+
@info.puts("#{id[0, 8]} refused: #{local_refusal(entry.name, id)}")
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
CLI::Exit::FAILED
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Why a UI-only command isn't sent: /stats points at its model-free
|
|
497
|
+
# reader (naming the session when there is one).
|
|
498
|
+
def local_refusal(name, id)
|
|
499
|
+
return "#{name} is a terminal and web command; chi send doesn't run it" unless name == "/stats"
|
|
500
|
+
|
|
501
|
+
"#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
|
|
502
|
+
end
|
|
503
|
+
|
|
443
504
|
# --wait after a message that ran as a session command: no reply comes
|
|
444
505
|
# for it. Its output shows where the session is open.
|
|
445
506
|
# @return [Integer] 0
|
|
@@ -71,8 +71,7 @@ module Samagotchi
|
|
|
71
71
|
continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
|
|
72
72
|
end
|
|
73
73
|
# It reads the hosts only (the registry is mutex-guarded): mid-turn too.
|
|
74
|
-
registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)",
|
|
75
|
-
mid_turn: ->(_text) { :anytime }) do |text|
|
|
74
|
+
registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)", anytime: true) do |text|
|
|
76
75
|
reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
|
|
77
76
|
end
|
|
78
77
|
registry.register(GUARDRAILS_COMMAND, "list the guardrail rules and approvals (/guardrails revoke N)",
|
|
@@ -141,6 +141,9 @@ module Samagotchi
|
|
|
141
141
|
:cuts,
|
|
142
142
|
:capped,
|
|
143
143
|
:model,
|
|
144
|
+
# The turn the user started by answering the step-limit question with
|
|
145
|
+
# Continue (:turn_started's +continue+); its record carries it.
|
|
146
|
+
:continue,
|
|
144
147
|
:id,
|
|
145
148
|
:started_at,
|
|
146
149
|
:started_monotonic,
|
|
@@ -539,6 +542,7 @@ module Samagotchi
|
|
|
539
542
|
cuts: 0,
|
|
540
543
|
capped: 0,
|
|
541
544
|
model: nil,
|
|
545
|
+
continue: event[:continue] ? true : false,
|
|
542
546
|
id: event[:turn_id] || SecureRandom.uuid,
|
|
543
547
|
started_at: now.iso8601(3),
|
|
544
548
|
started_monotonic: monotonic_time,
|
|
@@ -575,6 +579,9 @@ module Samagotchi
|
|
|
575
579
|
record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
|
|
576
580
|
record[:cancelled_by] = by.to_s unless by.nil? || by.to_s.empty?
|
|
577
581
|
record[:failure] = failure if failure
|
|
582
|
+
# Every record says, so the report can tell a file with no Continue
|
|
583
|
+
# from one written before the mark.
|
|
584
|
+
record[:continue] = @turn.continue
|
|
578
585
|
record.merge!(turn_token_fields(@turn))
|
|
579
586
|
@turn_records << record
|
|
580
587
|
count_turn(record)
|
|
@@ -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 }.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
|
|
|
@@ -1226,7 +1226,7 @@ module Samagotchi
|
|
|
1226
1226
|
params: part[:params], status: status,
|
|
1227
1227
|
description: ToolView.description_title(part[:view] || {}) }
|
|
1228
1228
|
"#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
|
|
1229
|
-
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
1229
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}"
|
|
1230
1230
|
end
|
|
1231
1231
|
|
|
1232
1232
|
# The context notes that came since the last prompt (all of them in a
|
|
@@ -1320,7 +1320,7 @@ module Samagotchi
|
|
|
1320
1320
|
|
|
1321
1321
|
activity = EventRenderer.described(event) || {}
|
|
1322
1322
|
@screen.commit(snapshot_tool_line({ tool: event[:tool], params: activity[:params], status: activity[:status],
|
|
1323
|
-
description: activity[:description],
|
|
1323
|
+
description: activity[:description], output_truncated: event[:output_truncated],
|
|
1324
1324
|
images: event[:images], diff: event[:diff] }))
|
|
1325
1325
|
end
|
|
1326
1326
|
|
|
@@ -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)})"
|
|
@@ -138,6 +146,12 @@ module Samagotchi
|
|
|
138
146
|
" #{paint("+#{get.call(:added).to_i}", 32)} #{paint("\u2212#{get.call(:removed).to_i}", 31)}"
|
|
139
147
|
end
|
|
140
148
|
|
|
149
|
+
# A row whose output the runner cut (max_tool_output_chars): " [cut]",
|
|
150
|
+
# dim, beside the diff one.
|
|
151
|
+
def format_tool_cut_suffix(output_truncated)
|
|
152
|
+
output_truncated ? " #{paint("[cut]", 90)}" : ""
|
|
153
|
+
end
|
|
154
|
+
|
|
141
155
|
# Prompt labels by the sender's client_id: a whole id, or a prefix
|
|
142
156
|
# (ending in ":") it starts with (turn_events.js CLIENT_LABELS;
|
|
143
157
|
# spec/shared/labels_matrix.json).
|
|
@@ -212,7 +226,7 @@ module Samagotchi
|
|
|
212
226
|
def snapshot_tool_line(part)
|
|
213
227
|
status = part[:status].to_s
|
|
214
228
|
"#{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])}"
|
|
229
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}"
|
|
216
230
|
end
|
|
217
231
|
|
|
218
232
|
# A card (Engine#show_card) as a framed block: the title and its
|
|
@@ -482,7 +496,8 @@ module Samagotchi
|
|
|
482
496
|
end
|
|
483
497
|
|
|
484
498
|
# 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
|
|
499
|
+
# logs (debug-only) are intentionally excluded; this surface is for the REPL
|
|
500
|
+
# and `chi sessions stats` (the module function above).
|
|
486
501
|
def format_session_metrics(snapshot)
|
|
487
502
|
return "(no metrics yet)" unless snapshot.is_a?(Hash)
|
|
488
503
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "question_slot"
|
|
4
4
|
require_relative "../turn_flow"
|
|
5
|
+
require_relative "../question_desk"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
class TerminalUI
|
|
@@ -80,9 +81,15 @@ module Samagotchi
|
|
|
80
81
|
" waiting in parent #{@relayed_to} too (answering here works)" if @relayed_to
|
|
81
82
|
end
|
|
82
83
|
|
|
84
|
+
# The line a question dismissed in a --non-interactive run leaves (no
|
|
85
|
+
# one could answer it there): the REPL's own, and an attached UI's on
|
|
86
|
+
# its cancel (QuestionDesk::UNANSWERABLE_REASON).
|
|
87
|
+
UNANSWERABLE_TEXT = "(dismissed: no one to answer in a non-interactive run)"
|
|
88
|
+
|
|
83
89
|
# What a question closed with no answer here says: a relayed approval
|
|
84
90
|
# names where it went; else "(question cancelled)".
|
|
85
91
|
def closed_text(reason)
|
|
92
|
+
return UNANSWERABLE_TEXT if reason.to_s == QuestionDesk::UNANSWERABLE_REASON
|
|
86
93
|
return CONTINUE_CLOSED.fetch(reason.to_s, "(question cancelled)") if continue?
|
|
87
94
|
return "(question cancelled)" unless @delegate
|
|
88
95
|
|
|
@@ -1356,7 +1356,7 @@ module Samagotchi
|
|
|
1356
1356
|
# dismissed it.
|
|
1357
1357
|
def dismiss_unaskable_question(prompt)
|
|
1358
1358
|
@engine.cancel_question(QuestionDesk::UNANSWERABLE_REASON) rescue nil
|
|
1359
|
-
banner(prompt.summary(
|
|
1359
|
+
banner(prompt.summary(QuestionPrompt::UNANSWERABLE_TEXT, paint: method(:paint)))
|
|
1360
1360
|
false
|
|
1361
1361
|
end
|
|
1362
1362
|
|
|
@@ -213,7 +213,7 @@ module Samagotchi
|
|
|
213
213
|
return record unless record["status"] == "running"
|
|
214
214
|
|
|
215
215
|
pid = record["pid"]
|
|
216
|
-
return record if chis_group?(record)
|
|
216
|
+
return record if chis_group?(record, started: record["pid_started"])
|
|
217
217
|
# Gone: its exit code says how it ended.
|
|
218
218
|
return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
|
|
219
219
|
|
|
@@ -222,8 +222,9 @@ module Samagotchi
|
|
|
222
222
|
|
|
223
223
|
# Whether the record's pid still leads its own group. With +started+
|
|
224
224
|
# (the start time recorded at task_create), also the same process: a
|
|
225
|
-
# ps per call
|
|
226
|
-
#
|
|
225
|
+
# ps per call on macOS (~5 ms), asked by each refresh (task_list,
|
|
226
|
+
# task_get, each task_wait poll) so a reused pid reads as exited, and
|
|
227
|
+
# by task_stop again just before the signal.
|
|
227
228
|
def chis_group?(record, started: nil) = ProcessGroup.leader?(record["pid"], started: started)
|
|
228
229
|
|
|
229
230
|
# A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -73,9 +73,9 @@ module Samagotchi
|
|
|
73
73
|
return no("the server gives no media marker (update llama.cpp)") unless media_marker(props)
|
|
74
74
|
return no("profile #{profile&.name || "?"} has no image template yet") unless profile&.image_template
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
unless
|
|
78
|
-
return no("the server's chat template doesn't use #{
|
|
76
|
+
check = profile.image_check_token
|
|
77
|
+
unless check.empty? || body["chat_template"].to_s.include?(check)
|
|
78
|
+
return no("the server's chat template doesn't use #{check} (profile #{profile.name})")
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
Answer.new(value: true, reason: nil)
|
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
} from "./timing.js";
|
|
63
63
|
import { createLiveTurn } from "./live_turn.js";
|
|
64
64
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
65
|
-
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
65
|
+
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, historyHasCommandBubble, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
66
66
|
|
|
67
67
|
const $ = (s) => document.querySelector(s);
|
|
68
68
|
const topStripEl = $("#topStrip");
|
|
@@ -2265,10 +2265,19 @@ const streamHandlers = {
|
|
|
2265
2265
|
if (view.resync) await resync();
|
|
2266
2266
|
// A card's action that isn't anytime (the step-limit question's
|
|
2267
2267
|
// /continue) has no bubble: nothing to remove. An anytime or queued
|
|
2268
|
-
// one's bubble was drawn at its command_queued: filled in here.
|
|
2268
|
+
// one's bubble was drawn at its command_queued: filled in here. One
|
|
2269
|
+
// queued for after the turn ran after it: its bubble moves from among
|
|
2270
|
+
// the turn's rows to after its answer and timing line.
|
|
2269
2271
|
const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
|
|
2270
2272
|
if (view.hidden) queued?.remove();
|
|
2271
|
-
else if (queued
|
|
2273
|
+
else if (queued && view.queued && !view.anytime) {
|
|
2274
|
+
queued.remove();
|
|
2275
|
+
addCommandBubble(view);
|
|
2276
|
+
} else if (queued) drawCommandBubble(queued, view);
|
|
2277
|
+
// A command that changed the conversation (!cmd): the redrawn history
|
|
2278
|
+
// ends with its command bubble already (the saved "!(…)\n<output>"
|
|
2279
|
+
// message renders as one) — no second bubble, or the output shows twice.
|
|
2280
|
+
else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
|
|
2272
2281
|
else addCommandBubble(view);
|
|
2273
2282
|
},
|
|
2274
2283
|
// An anytime command (/help, a plugin's /btw) runs at once, beside a
|