samagotchi 0.76.0 → 0.77.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 +26 -1
- data/docs/internals/engine.md +1 -1
- data/docs/plugins.md +42 -0
- data/lib/samagotchi/answer_command.rb +5 -5
- data/lib/samagotchi/bridge.rb +3 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +1 -1
- data/lib/samagotchi/engine.rb +21 -12
- data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
- data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
- data/lib/samagotchi/memory_bundle/builder.rb +2 -0
- data/lib/samagotchi/message_parts.rb +272 -0
- data/lib/samagotchi/parent_report.rb +30 -22
- data/lib/samagotchi/pending_question.rb +49 -0
- data/lib/samagotchi/question_desk.rb +39 -32
- data/lib/samagotchi/relay.rb +8 -9
- data/lib/samagotchi/relay_verifier.rb +2 -2
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +4 -4
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/session.rb +22 -14
- data/lib/samagotchi/session_commands.rb +4 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -106
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
- data/lib/samagotchi/terminal_ui.rb +5 -3
- data/lib/samagotchi/tool_output_cut.rb +1 -1
- data/lib/samagotchi/tool_response.rb +13 -13
- data/lib/samagotchi/tool_row_fields.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +16 -9
- data/lib/samagotchi/tools/delegate_relay.rb +7 -7
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/web_fetch.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -2
- data/lib/samagotchi/web/public/app.js +17 -55
- data/lib/samagotchi/web/public/ctx.js +12 -1
- data/lib/samagotchi/web/public/index.html +2 -1
- data/lib/samagotchi/web/public/markdown_place.js +53 -0
- data/lib/samagotchi/web/public/stream_handlers.js +7 -3
- data/lib/samagotchi/web/public/toasts.js +69 -0
- metadata +6 -2
- data/lib/samagotchi/web/message_parts.rb +0 -274
|
@@ -70,9 +70,9 @@ module Samagotchi
|
|
|
70
70
|
# of range; [] for a freeform-only deny.
|
|
71
71
|
def labels(indices, question_id)
|
|
72
72
|
pending = @engine.pending_question
|
|
73
|
-
return [] unless pending && pending
|
|
73
|
+
return [] unless pending && pending.id.to_s == question_id
|
|
74
74
|
|
|
75
|
-
options = Array(pending
|
|
75
|
+
options = Array(pending.options)
|
|
76
76
|
indices = Array(indices)
|
|
77
77
|
return nil unless indices.all? { |index| index.is_a?(Integer) && index >= 0 && index < options.size }
|
|
78
78
|
|
|
@@ -37,8 +37,8 @@ module Samagotchi
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def relayed?(pending, question_id, relay_id)
|
|
40
|
-
relay = pending
|
|
41
|
-
return false unless relay.is_a?(Hash) && pending
|
|
40
|
+
relay = pending&.relayed_to
|
|
41
|
+
return false unless relay.is_a?(Hash) && pending.id.to_s == question_id.to_s
|
|
42
42
|
|
|
43
43
|
(relay[:relay_id] || relay["relay_id"]).to_s == relay_id.to_s
|
|
44
44
|
end
|
|
@@ -30,7 +30,7 @@ module Samagotchi
|
|
|
30
30
|
# @!attribute text [String, nil] the reply (:done) or the worker's error
|
|
31
31
|
# (:error)
|
|
32
32
|
# @!attribute file [String, nil] the reply's filename, the next cursor
|
|
33
|
-
# @!attribute question [
|
|
33
|
+
# @!attribute question [PendingQuestion, nil] the pending question
|
|
34
34
|
# (:waiting_for_answer)
|
|
35
35
|
# @!attribute outcome [String, nil] how the turn ended when it left no
|
|
36
36
|
# reply (:no_reply): "failed", "canceled", "completed" (empty),
|
|
@@ -152,7 +152,7 @@ module Samagotchi
|
|
|
152
152
|
# lists' rule): one a dead worker left waits for no one, and a chi
|
|
153
153
|
# REPL's is answered there.
|
|
154
154
|
pending = session.pending_question
|
|
155
|
-
if pending && !(baseline && pending
|
|
155
|
+
if pending && !(baseline && pending.id == baseline[:question_id]) &&
|
|
156
156
|
(turn.nil? || own_question?(session, turn, claimed)) &&
|
|
157
157
|
session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
|
|
158
158
|
return Result.new(status: :waiting_for_answer, question: pending, session: session)
|
|
@@ -217,7 +217,7 @@ module Samagotchi
|
|
|
217
217
|
# its messages, the question pending then, and when its last turn
|
|
218
218
|
# ended, so a turn that ends before the first look still ends the wait.
|
|
219
219
|
# @return [Hash]
|
|
220
|
-
def baseline_of(session, question_id: session.pending_question&.
|
|
220
|
+
def baseline_of(session, question_id: session.pending_question&.id)
|
|
221
221
|
{ messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at }
|
|
222
222
|
end
|
|
223
223
|
|
|
@@ -252,7 +252,7 @@ module Samagotchi
|
|
|
252
252
|
end
|
|
253
253
|
|
|
254
254
|
pending = session.pending_question
|
|
255
|
-
return nil unless pending && pending
|
|
255
|
+
return nil unless pending && pending.kind.to_s == "continue"
|
|
256
256
|
return nil unless turn_ids?(session_dir)
|
|
257
257
|
|
|
258
258
|
session.last_turn&.enqueued_id
|
data/lib/samagotchi/row_refs.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Samagotchi
|
|
|
10
10
|
# whose +to_h+ the row carries as +refs+ on tool_call_started and
|
|
11
11
|
# tool_call_completed (ToolRowFields::EVENT_KEYS), saved with the result
|
|
12
12
|
# as tool_refs (ToolResponse::SAVED_KEYS) for a reload, which has no
|
|
13
|
-
# plugins (
|
|
13
|
+
# plugins (MessageParts). Display only: the model never sees them.
|
|
14
14
|
#
|
|
15
15
|
# A file ref is any file under the session's folder (plan D7): a relative
|
|
16
16
|
# path is resolved against it, a path outside it is dropped. The folder
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "atomic_file"
|
|
|
8
8
|
require_relative "context_note"
|
|
9
9
|
require_relative "interrupted_turn"
|
|
10
10
|
require_relative "last_turn"
|
|
11
|
+
require_relative "pending_question"
|
|
11
12
|
require_relative "llm_context_override"
|
|
12
13
|
require_relative "session_setup"
|
|
13
14
|
require_relative "prompt_note"
|
|
@@ -49,7 +50,7 @@ module Samagotchi
|
|
|
49
50
|
|
|
50
51
|
attr_accessor :id, :metadata_version, :mode, :model_name, :working_directory, :messages,
|
|
51
52
|
:created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
|
|
52
|
-
:
|
|
53
|
+
:used_memory_names
|
|
53
54
|
# Memories the session was started with (--memory) and memories hidden
|
|
54
55
|
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
55
56
|
# Names as given; the engine normalizes them.
|
|
@@ -98,6 +99,15 @@ module Samagotchi
|
|
|
98
99
|
# A `chi scratch` session: deleted when its REPL ends, and by the next
|
|
99
100
|
# sweep (or `chi sessions clean`) when the process died first.
|
|
100
101
|
attr_accessor :scratch
|
|
102
|
+
# The question this session waits on (PendingQuestion), saved as
|
|
103
|
+
# session.json's "pending_question"; nil with none. A Hash (a saved
|
|
104
|
+
# record, string or symbol keys) is read with PendingQuestion.from_file.
|
|
105
|
+
attr_reader :pending_question
|
|
106
|
+
|
|
107
|
+
def pending_question=(value)
|
|
108
|
+
@pending_question = PendingQuestion.from_file(value)
|
|
109
|
+
end
|
|
110
|
+
|
|
101
111
|
# How the last turn ended (LastTurn), for a parent's wait (ReplyWait)
|
|
102
112
|
# and the web's notifications (the hub sends it in the summary); nil
|
|
103
113
|
# before the first turn.
|
|
@@ -246,7 +256,7 @@ module Samagotchi
|
|
|
246
256
|
@last_prompt = last_prompt
|
|
247
257
|
@first_preview = first_preview
|
|
248
258
|
@test_run = !!test_run
|
|
249
|
-
|
|
259
|
+
self.pending_question = pending_question
|
|
250
260
|
@used_memory_names = self.class.name_list(used_memory_names)
|
|
251
261
|
@preloaded_memory_names = self.class.name_list(preloaded_memory_names)
|
|
252
262
|
@muted_memory_names = self.class.name_list(muted_memory_names)
|
|
@@ -289,12 +299,12 @@ module Samagotchi
|
|
|
289
299
|
# @return [Hash, nil]
|
|
290
300
|
def waiting_question(live:)
|
|
291
301
|
pending = pending_question
|
|
292
|
-
return nil unless live && pending
|
|
302
|
+
return nil unless live && pending&.id
|
|
293
303
|
|
|
294
|
-
kind = pending
|
|
295
|
-
relayed = pending
|
|
304
|
+
kind = pending.kind.to_s
|
|
305
|
+
relayed = pending.relayed_to
|
|
296
306
|
relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
|
|
297
|
-
{ id: pending
|
|
307
|
+
{ id: pending.id, kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
|
|
298
308
|
end
|
|
299
309
|
|
|
300
310
|
# A question saved by a process that no longer runs this session (the
|
|
@@ -306,7 +316,7 @@ module Samagotchi
|
|
|
306
316
|
return nil unless pending
|
|
307
317
|
|
|
308
318
|
@pending_question = nil
|
|
309
|
-
pending
|
|
319
|
+
pending.id.to_s
|
|
310
320
|
end
|
|
311
321
|
|
|
312
322
|
# The stored project root, else (a file from before the field) the
|
|
@@ -390,10 +400,10 @@ module Samagotchi
|
|
|
390
400
|
|
|
391
401
|
# The fields of <id>.json, in the order #save writes them: key => the
|
|
392
402
|
# value for a file without it (REQUIRED for the ones it must have).
|
|
393
|
-
# .from_h and #to_h both go by this table; messages
|
|
394
|
-
#
|
|
395
|
-
#
|
|
396
|
-
#
|
|
403
|
+
# .from_h and #to_h both go by this table; messages change key type on
|
|
404
|
+
# the way (symbols in memory, strings on disk); last_turn, prompt_notes
|
|
405
|
+
# and pending_question are value objects in memory (LastTurn,
|
|
406
|
+
# PromptNote, PendingQuestion).
|
|
397
407
|
FIELDS = {
|
|
398
408
|
"metadata_version" => 1,
|
|
399
409
|
"id" => REQUIRED,
|
|
@@ -435,8 +445,6 @@ module Samagotchi
|
|
|
435
445
|
[key.to_sym, default.equal?(REQUIRED) ? data.fetch(key) : data.fetch(key, default)]
|
|
436
446
|
end
|
|
437
447
|
attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
|
|
438
|
-
pending = attrs[:pending_question]
|
|
439
|
-
attrs[:pending_question] = pending.is_a?(Hash) ? symbolize_message_keys(pending) : nil
|
|
440
448
|
new(**attrs)
|
|
441
449
|
end
|
|
442
450
|
|
|
@@ -557,7 +565,7 @@ module Samagotchi
|
|
|
557
565
|
when "messages"
|
|
558
566
|
(checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
|
|
559
567
|
when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
|
|
560
|
-
when "pending_question" then @pending_question && scrub_utf8(
|
|
568
|
+
when "pending_question" then @pending_question && scrub_utf8(@pending_question.to_file)
|
|
561
569
|
when "test_run" then !!@test_run
|
|
562
570
|
when "delegate" then @delegate
|
|
563
571
|
when "llm_context" then llm_context_file
|
|
@@ -592,8 +592,11 @@ module Samagotchi
|
|
|
592
592
|
@engine.switch_model!(name, persist_default: persist_default)
|
|
593
593
|
@default_model = @engine.default_model_name if persist_default
|
|
594
594
|
persist_session_model
|
|
595
|
-
# The ctx % on the new model's window, before its first turn
|
|
595
|
+
# The ctx % on the new model's window, before its first turn; saved
|
|
596
|
+
# too, so the session's card (the hub's summary reads analytics.json)
|
|
597
|
+
# stops showing the old model's % before a turn ends.
|
|
596
598
|
@engine.announce_switched_context!
|
|
599
|
+
@engine.metrics.persist(state_dir: @engine.session_state_dir)
|
|
597
600
|
end
|
|
598
601
|
|
|
599
602
|
def persist_session_model
|
|
@@ -88,9 +88,10 @@ module Samagotchi
|
|
|
88
88
|
# A question left waiting (exit 3), in full on stderr as `chi send
|
|
89
89
|
# --wait` prints it (ParentReport), so a script or a parent agent
|
|
90
90
|
# can answer it with chi answer.
|
|
91
|
-
# @param pending [Hash] the pending question
|
|
91
|
+
# @param pending [Hash] the pending question as the Bridge sent it
|
|
92
92
|
def report_unanswered(pending, session_id:, err: $stderr)
|
|
93
|
-
|
|
93
|
+
question = PendingQuestion.from_file(pending)
|
|
94
|
+
err.print("chi: #{ParentReport.question_text(question, session_id: session_id)}")
|
|
94
95
|
err.flush
|
|
95
96
|
end
|
|
96
97
|
|
|
@@ -28,7 +28,7 @@ require_relative "../children_status"
|
|
|
28
28
|
require_relative "../guardrails/parent_approvals"
|
|
29
29
|
require_relative "../tool_activity"
|
|
30
30
|
require_relative "../tool_view"
|
|
31
|
-
require_relative "../
|
|
31
|
+
require_relative "../message_parts"
|
|
32
32
|
|
|
33
33
|
module Samagotchi
|
|
34
34
|
class TerminalUI
|
|
@@ -1274,7 +1274,7 @@ module Samagotchi
|
|
|
1274
1274
|
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
1275
1275
|
elsif %w[model assistant].include?(m[:role].to_s)
|
|
1276
1276
|
responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
|
|
1277
|
-
Array(
|
|
1277
|
+
Array(MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
|
|
1278
1278
|
record = join_record(records, part)
|
|
1279
1279
|
duration = record&.dig("duration_ms")
|
|
1280
1280
|
@screen.commit(join_tool_line(part, duration_ms: duration.is_a?(Numeric) ? duration : nil,
|
|
@@ -9,6 +9,7 @@ require_relative "../steer_cut"
|
|
|
9
9
|
require_relative "../memory_bundle/index_size"
|
|
10
10
|
require_relative "../prompt_note"
|
|
11
11
|
require_relative "../prompt_files"
|
|
12
|
+
require_relative "model_segments_text"
|
|
12
13
|
|
|
13
14
|
module Samagotchi
|
|
14
15
|
class TerminalUI
|
|
@@ -16,6 +17,8 @@ module Samagotchi
|
|
|
16
17
|
# `tool>` line, elapsed durations. Pure apart from reading whether
|
|
17
18
|
# $stdout is a colour terminal.
|
|
18
19
|
module Formatting
|
|
20
|
+
include ModelSegmentsText
|
|
21
|
+
|
|
19
22
|
# The /stats text for a metrics snapshot, for the REPL and attached
|
|
20
23
|
# views (an instance includes the module) and for `chi sessions stats`
|
|
21
24
|
# (no UI: the same text from the CLI).
|
|
@@ -451,45 +454,6 @@ module Samagotchi
|
|
|
451
454
|
"#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
|
|
452
455
|
end
|
|
453
456
|
|
|
454
|
-
# ── A session's rows per model (the snapshot's model_segments) ──────
|
|
455
|
-
# The words the web's ctx tooltip shares (ctx.js; spec/shared/
|
|
456
|
-
# labels_matrix.json model_segments). Rows come symbol- or
|
|
457
|
-
# string-keyed (the attached TUI's as JSON).
|
|
458
|
-
|
|
459
|
-
# A row's model: its ref; the rows folded past the cap ("3 earlier
|
|
460
|
-
# segments", told by folded_segments); a session's turns from before
|
|
461
|
-
# turns named their model ("earlier turns").
|
|
462
|
-
def model_segment_label(row)
|
|
463
|
-
folded = segment_value(row, :folded_segments).to_i
|
|
464
|
-
return "#{folded} earlier segments" if folded.positive?
|
|
465
|
-
|
|
466
|
-
ref = segment_value(row, :model_ref).to_s
|
|
467
|
-
ref.empty? ? "earlier turns" : ref
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
# "6.0k in / 40 out": the row's prompt and completion sums.
|
|
471
|
-
def model_segment_tokens_text(row)
|
|
472
|
-
count = ->(key) { MemoryBundle::IndexSize.count_text(segment_value(row, key).to_i) }
|
|
473
|
-
"#{count.call(:prompt_sum)} in / #{count.call(:completion_sum)} out"
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
# "$0.42", "~$0.12", "$0.42 + ~$0.12" (reported + estimated); "" for none.
|
|
477
|
-
def model_segment_cost_text(row)
|
|
478
|
-
[cost_text(segment_value(row, :cost_sum)), cost_text(segment_value(row, :cost_estimate_sum), estimate: true)]
|
|
479
|
-
.reject(&:empty?).join(" + ")
|
|
480
|
-
end
|
|
481
|
-
|
|
482
|
-
# "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
|
|
483
|
-
# row's model prefilled (its cost an estimate from the configured
|
|
484
|
-
# price, left out without one); "" for none.
|
|
485
|
-
def model_segment_prefill_text(row)
|
|
486
|
-
tokens = segment_value(row, :prefill_tokens).to_i
|
|
487
|
-
return "" unless tokens.positive?
|
|
488
|
-
|
|
489
|
-
cost = cost_text(segment_value(row, :prefill_cost_estimate))
|
|
490
|
-
"switch prefill #{MemoryBundle::IndexSize.count_text(tokens)} uncached prompt#{" ≈#{cost}" unless cost.empty?}"
|
|
491
|
-
end
|
|
492
|
-
|
|
493
457
|
private
|
|
494
458
|
|
|
495
459
|
# The recap shown on return: dim, one "recap>" block, noting how many
|
|
@@ -745,73 +709,6 @@ module Samagotchi
|
|
|
745
709
|
text
|
|
746
710
|
end
|
|
747
711
|
|
|
748
|
-
def segment_value(row, key)
|
|
749
|
-
row[key] || row[key.to_s]
|
|
750
|
-
end
|
|
751
|
-
|
|
752
|
-
# /stats' models: block, in place of the tokens line: a row per
|
|
753
|
-
# segment (model, when, turns, tokens, its average speed and cost), a
|
|
754
|
-
# switch's prefill under the row of the model switched to, and the
|
|
755
|
-
# total (the tokens line's sums and breakdown).
|
|
756
|
-
def models_block_lines(segments, turns, tokens, token_src_label)
|
|
757
|
-
dated = segment_dates_differ?(segments)
|
|
758
|
-
cells = segments.map do |row|
|
|
759
|
-
speed = speed_text(segment_value(row, :avg_decode_tps), segment_value(row, :tps_source))
|
|
760
|
-
[model_segment_label(row), segment_span_text(row, dated: dated), turns_text(segment_value(row, :turns)),
|
|
761
|
-
[model_segment_tokens_text(row), speed, model_segment_cost_text(row)].reject(&:empty?).join(" ")]
|
|
762
|
-
end
|
|
763
|
-
total = ["total", "", turns_text(turns),
|
|
764
|
-
"#{MemoryBundle::IndexSize.count_text(tokens[:prompt_sum].to_i)} in / " \
|
|
765
|
-
"#{MemoryBundle::IndexSize.count_text(tokens[:completion_sum].to_i)} out " \
|
|
766
|
-
"(all requests, #{token_src_label}#{token_breakdown_text(tokens)})"]
|
|
767
|
-
widths = (0..2).map { |i| (cells + [total]).map { |cell| cell[i].length }.max }
|
|
768
|
-
row_text = ->(cell) { cell.each_with_index.map { |text, i| i < 3 ? text.ljust(widths[i]) : text }.join(" ") }
|
|
769
|
-
lines = []
|
|
770
|
-
segments.each_with_index do |row, i|
|
|
771
|
-
lines << "#{i.zero? ? "models:".ljust(18) : " " * 18}#{row_text.call(cells[i])}"
|
|
772
|
-
prefill = model_segment_prefill_text(row)
|
|
773
|
-
lines << "#{" " * 20}└ #{prefill}" unless prefill.empty?
|
|
774
|
-
end
|
|
775
|
-
lines << "#{" " * 18}#{row_text.call(total)}"
|
|
776
|
-
end
|
|
777
|
-
|
|
778
|
-
def turns_text(count)
|
|
779
|
-
"#{count.to_i} #{count.to_i == 1 ? "turn" : "turns"}"
|
|
780
|
-
end
|
|
781
|
-
|
|
782
|
-
# "10:02–10:41", "10:43–now" (a row with a turn running); with the
|
|
783
|
-
# day ("Oct 16 10:02") when the rows span more than one day.
|
|
784
|
-
def segment_span_text(row, dated:)
|
|
785
|
-
started = segment_time(segment_value(row, :started_at))
|
|
786
|
-
ended = segment_time(segment_value(row, :ended_at))
|
|
787
|
-
format_time = lambda { |time|
|
|
788
|
-
if time
|
|
789
|
-
time.strftime(dated ? "%b %-d %H:%M" : "%H:%M")
|
|
790
|
-
else
|
|
791
|
-
"?"
|
|
792
|
-
end
|
|
793
|
-
}
|
|
794
|
-
"#{format_time.call(started)}–#{ended ? format_time.call(ended) : "now"}"
|
|
795
|
-
end
|
|
796
|
-
|
|
797
|
-
def segment_dates_differ?(segments)
|
|
798
|
-
days = segments.flat_map { |row| [segment_value(row, :started_at), segment_value(row, :ended_at)] }
|
|
799
|
-
.filter_map { |stamp| segment_time(stamp)&.strftime("%F") }
|
|
800
|
-
days.uniq.size > 1
|
|
801
|
-
end
|
|
802
|
-
|
|
803
|
-
def segment_time(stamp)
|
|
804
|
-
stamp ? Time.iso8601(stamp.to_s).localtime : nil
|
|
805
|
-
rescue ArgumentError
|
|
806
|
-
nil
|
|
807
|
-
end
|
|
808
|
-
|
|
809
|
-
# The row the session's last speed came from: the newest with one.
|
|
810
|
-
def last_speed_label(segments)
|
|
811
|
-
row = segments.reverse_each.find { |segment| segment_value(segment, :last_decode_tps) }
|
|
812
|
-
row && model_segment_label(row)
|
|
813
|
-
end
|
|
814
|
-
|
|
815
712
|
def format_elapsed_duration(duration_ms)
|
|
816
713
|
duration_ms = duration_ms.to_f
|
|
817
714
|
return "" if duration_ms.negative?
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../memory_bundle/index_size"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class TerminalUI
|
|
8
|
+
# A session's rows per model in /stats (the snapshot's model_segments).
|
|
9
|
+
# The four public model_segment_* labels are the words the web's ctx
|
|
10
|
+
# tooltip shares (ctx.js; spec/shared/labels_matrix.json model_segments).
|
|
11
|
+
# Rows come symbol- or string-keyed (the attached TUI's as JSON).
|
|
12
|
+
# Included by Formatting, whose cost_text and speed_text it uses.
|
|
13
|
+
module ModelSegmentsText
|
|
14
|
+
# A row's model: its ref; the rows folded past the cap ("3 earlier
|
|
15
|
+
# segments", told by folded_segments); a session's turns from before
|
|
16
|
+
# turns named their model ("earlier turns").
|
|
17
|
+
def model_segment_label(row)
|
|
18
|
+
folded = segment_value(row, :folded_segments).to_i
|
|
19
|
+
return "#{folded} earlier segments" if folded.positive?
|
|
20
|
+
|
|
21
|
+
ref = segment_value(row, :model_ref).to_s
|
|
22
|
+
ref.empty? ? "earlier turns" : ref
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# "6.0k in / 40 out": the row's prompt and completion sums.
|
|
26
|
+
def model_segment_tokens_text(row)
|
|
27
|
+
count = ->(key) { MemoryBundle::IndexSize.count_text(segment_value(row, key).to_i) }
|
|
28
|
+
"#{count.call(:prompt_sum)} in / #{count.call(:completion_sum)} out"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# "$0.42", "~$0.12", "$0.42 + ~$0.12" (reported + estimated); "" for none.
|
|
32
|
+
def model_segment_cost_text(row)
|
|
33
|
+
[cost_text(segment_value(row, :cost_sum)), cost_text(segment_value(row, :cost_estimate_sum), estimate: true)]
|
|
34
|
+
.reject(&:empty?).join(" + ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
|
|
38
|
+
# row's model prefilled (its cost an estimate from the configured
|
|
39
|
+
# price, left out without one); "" for none.
|
|
40
|
+
def model_segment_prefill_text(row)
|
|
41
|
+
tokens = segment_value(row, :prefill_tokens).to_i
|
|
42
|
+
return "" unless tokens.positive?
|
|
43
|
+
|
|
44
|
+
cost = cost_text(segment_value(row, :prefill_cost_estimate))
|
|
45
|
+
"switch prefill #{MemoryBundle::IndexSize.count_text(tokens)} uncached prompt#{" ≈#{cost}" unless cost.empty?}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def segment_value(row, key)
|
|
51
|
+
row[key] || row[key.to_s]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# /stats' models: block, in place of the tokens line: a row per
|
|
55
|
+
# segment (model, when, turns, tokens, its average speed and cost), a
|
|
56
|
+
# switch's prefill under the row of the model switched to, and the
|
|
57
|
+
# total (the tokens line's sums and breakdown).
|
|
58
|
+
def models_block_lines(segments, turns, tokens, token_src_label)
|
|
59
|
+
dated = segment_dates_differ?(segments)
|
|
60
|
+
cells = segments.map do |row|
|
|
61
|
+
speed = speed_text(segment_value(row, :avg_decode_tps), segment_value(row, :tps_source))
|
|
62
|
+
[model_segment_label(row), segment_span_text(row, dated: dated), turns_text(segment_value(row, :turns)),
|
|
63
|
+
[model_segment_tokens_text(row), speed, model_segment_cost_text(row)].reject(&:empty?).join(" ")]
|
|
64
|
+
end
|
|
65
|
+
total = ["total", "", turns_text(turns),
|
|
66
|
+
"#{MemoryBundle::IndexSize.count_text(tokens[:prompt_sum].to_i)} in / " \
|
|
67
|
+
"#{MemoryBundle::IndexSize.count_text(tokens[:completion_sum].to_i)} out " \
|
|
68
|
+
"(all requests, #{token_src_label}#{token_breakdown_text(tokens)})"]
|
|
69
|
+
widths = (0..2).map { |i| (cells + [total]).map { |cell| cell[i].length }.max }
|
|
70
|
+
row_text = ->(cell) { cell.each_with_index.map { |text, i| i < 3 ? text.ljust(widths[i]) : text }.join(" ") }
|
|
71
|
+
lines = []
|
|
72
|
+
segments.each_with_index do |row, i|
|
|
73
|
+
lines << "#{i.zero? ? "models:".ljust(18) : " " * 18}#{row_text.call(cells[i])}"
|
|
74
|
+
prefill = model_segment_prefill_text(row)
|
|
75
|
+
lines << "#{" " * 20}└ #{prefill}" unless prefill.empty?
|
|
76
|
+
end
|
|
77
|
+
lines << "#{" " * 18}#{row_text.call(total)}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def turns_text(count)
|
|
81
|
+
"#{count.to_i} #{count.to_i == 1 ? "turn" : "turns"}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# "10:02–10:41", "10:43–now" (a row with a turn running); with the
|
|
85
|
+
# day ("Oct 16 10:02") when the rows span more than one day.
|
|
86
|
+
def segment_span_text(row, dated:)
|
|
87
|
+
started = segment_time(segment_value(row, :started_at))
|
|
88
|
+
ended = segment_time(segment_value(row, :ended_at))
|
|
89
|
+
format_time = lambda { |time|
|
|
90
|
+
if time
|
|
91
|
+
time.strftime(dated ? "%b %-d %H:%M" : "%H:%M")
|
|
92
|
+
else
|
|
93
|
+
"?"
|
|
94
|
+
end
|
|
95
|
+
}
|
|
96
|
+
"#{format_time.call(started)}–#{ended ? format_time.call(ended) : "now"}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def segment_dates_differ?(segments)
|
|
100
|
+
days = segments.flat_map { |row| [segment_value(row, :started_at), segment_value(row, :ended_at)] }
|
|
101
|
+
.filter_map { |stamp| segment_time(stamp)&.strftime("%F") }
|
|
102
|
+
days.uniq.size > 1
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def segment_time(stamp)
|
|
106
|
+
stamp ? Time.iso8601(stamp.to_s).localtime : nil
|
|
107
|
+
rescue ArgumentError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The row the session's last speed came from: the newest with one.
|
|
112
|
+
def last_speed_label(segments)
|
|
113
|
+
row = segments.reverse_each.find { |segment| segment_value(segment, :last_decode_tps) }
|
|
114
|
+
row && model_segment_label(row)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -873,6 +873,7 @@ module Samagotchi
|
|
|
873
873
|
end
|
|
874
874
|
if command
|
|
875
875
|
show_command_result(command)
|
|
876
|
+
refresh_status_row(clear_unknown_context: true) if command.changed.include?(:model)
|
|
876
877
|
persist_recent_history(input) if command.shell
|
|
877
878
|
return
|
|
878
879
|
end
|
|
@@ -1400,9 +1401,10 @@ module Samagotchi
|
|
|
1400
1401
|
|
|
1401
1402
|
# The status row under the prompt, from the Engine's state (what a
|
|
1402
1403
|
# worker hands an attached TUI when it joins): drawn when it changed.
|
|
1403
|
-
def refresh_status_row
|
|
1404
|
+
def refresh_status_row(clear_unknown_context: false)
|
|
1404
1405
|
state = @engine.session_state_snapshot.merge(model_name: @effective_model_name)
|
|
1405
1406
|
@status_row.take_state(state, default_model: @default_model_name)
|
|
1407
|
+
@status_row.update(context: nil) if clear_unknown_context && state.key?(:context_status) && state[:context_status].nil?
|
|
1406
1408
|
refresh_children_status
|
|
1407
1409
|
end
|
|
1408
1410
|
|
|
@@ -1463,13 +1465,13 @@ module Samagotchi
|
|
|
1463
1465
|
def drain_pending_question?
|
|
1464
1466
|
pq = @pending_question_event
|
|
1465
1467
|
# Also check Engine's persisted pending (covers resume)
|
|
1466
|
-
pq ||= @engine.pending_question
|
|
1468
|
+
pq ||= @engine.pending_question&.to_h
|
|
1467
1469
|
return false unless pq
|
|
1468
1470
|
|
|
1469
1471
|
# If the pending no longer matches the engine's current pending (stale
|
|
1470
1472
|
# event after sync handler already answered and cleared), discard.
|
|
1471
1473
|
current = @engine.pending_question
|
|
1472
|
-
if current && (pq[:id] || pq["id"]).to_s !=
|
|
1474
|
+
if current && (pq[:id] || pq["id"]).to_s != current.id.to_s
|
|
1473
1475
|
@pending_question_event = nil
|
|
1474
1476
|
return false
|
|
1475
1477
|
end
|
|
@@ -38,7 +38,7 @@ module Samagotchi
|
|
|
38
38
|
HEAD_CUT = /\[cut: \d+ of \d+ chars; read it in parts\]\z/
|
|
39
39
|
|
|
40
40
|
# What a web row keeps of a tool's output, on a reload
|
|
41
|
-
# (
|
|
41
|
+
# (MessageParts) and a mid-turn join (Bridge::TurnAccumulator): the
|
|
42
42
|
# row shows 300 chars and the rest on hover. A longer one keeps its head
|
|
43
43
|
# and tail (preview).
|
|
44
44
|
WEB_PREVIEW_CHARS = 2000
|
|
@@ -29,7 +29,7 @@ module Samagotchi
|
|
|
29
29
|
# Runs +calls+ in order (ToolRunner#run each), yielding each run and its
|
|
30
30
|
# index as it finishes. +emit+ takes the dispatch events, the runner's
|
|
31
31
|
# own go to +on_stream_event+.
|
|
32
|
-
# @return [Array<
|
|
32
|
+
# @return [Array<ToolRun>] the runs
|
|
33
33
|
def run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
|
|
34
34
|
emit.call({ type: :tool_dispatch_started, iteration: iteration, call_count: calls.length })
|
|
35
35
|
runs = calls.each_with_index.map do |call, index|
|
|
@@ -45,7 +45,7 @@ module Samagotchi
|
|
|
45
45
|
# The runs' activity, for the turn's tool list (none for a call whose
|
|
46
46
|
# dispatcher raised).
|
|
47
47
|
def activities(runs)
|
|
48
|
-
runs.filter_map
|
|
48
|
+
runs.filter_map(&:activity)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
# The native loop's entry for a batch: the capped outputs joined, every image
|
|
@@ -53,14 +53,14 @@ module Samagotchi
|
|
|
53
53
|
# each on its own tool row), and the saved-only fields one per call:
|
|
54
54
|
# +ids+ (ToolIds.next_ids) as tool_ids.
|
|
55
55
|
def joined(runs, ids:)
|
|
56
|
-
images = runs.flat_map { |run| Array(run
|
|
57
|
-
entry = { role: "tool_response", content: runs.map
|
|
56
|
+
images = runs.flat_map { |run| Array(run.images) }
|
|
57
|
+
entry = { role: "tool_response", content: runs.map(&:capped_output).join(SEPARATOR) }
|
|
58
58
|
unless images.empty?
|
|
59
59
|
entry[:images] = images
|
|
60
|
-
entry[:image_counts] = runs.map { |run| Array(run
|
|
60
|
+
entry[:image_counts] = runs.map { |run| Array(run.images).size }
|
|
61
61
|
end
|
|
62
|
-
saved = { tool_params: runs.map
|
|
63
|
-
tool_diffs: runs.map
|
|
62
|
+
saved = { tool_params: runs.map(&:shown_params), tool_labels: runs.map(&:shown_label),
|
|
63
|
+
tool_diffs: runs.map(&:diff), tool_refs: runs.map(&:refs) }
|
|
64
64
|
saved.each { |key, values| entry[key] = values if values.any? }
|
|
65
65
|
entry[:tool_ids] = ids
|
|
66
66
|
entry
|
|
@@ -70,12 +70,12 @@ module Samagotchi
|
|
|
70
70
|
# call by +tool_call_id+, and its chi-owned id (a one-id +ids+, as
|
|
71
71
|
# #joined's tool_ids).
|
|
72
72
|
def single(run, tool_call_id:, ids:)
|
|
73
|
-
entry = { role: "tool_response", content: run
|
|
74
|
-
entry[:images] = run
|
|
75
|
-
entry[:tool_params] = run
|
|
76
|
-
entry[:tool_labels] = run
|
|
77
|
-
entry[:tool_diffs] = run
|
|
78
|
-
entry[:tool_refs] = run
|
|
73
|
+
entry = { role: "tool_response", content: run.capped_output, tool_call_id: tool_call_id }
|
|
74
|
+
entry[:images] = run.images if run.images&.any?
|
|
75
|
+
entry[:tool_params] = run.shown_params if run.shown_params
|
|
76
|
+
entry[:tool_labels] = run.shown_label if run.shown_label
|
|
77
|
+
entry[:tool_diffs] = run.diff if run.diff
|
|
78
|
+
entry[:tool_refs] = run.refs if run.refs
|
|
79
79
|
entry[:tool_ids] = ids
|
|
80
80
|
entry
|
|
81
81
|
end
|
|
@@ -7,7 +7,7 @@ require_relative "file_ref"
|
|
|
7
7
|
module Samagotchi
|
|
8
8
|
# The fields a tool row shows beyond its params, built from the call in
|
|
9
9
|
# one place for every path: the live tool_call_started (ToolRunner#run)
|
|
10
|
-
# and a reload from the saved call (
|
|
10
|
+
# and a reload from the saved call (MessageParts#tool_part). The
|
|
11
11
|
# bridge's snapshot and replay (Bridge::TurnAccumulator) copy them by
|
|
12
12
|
# these lists, so a new field is added here and travels everywhere.
|
|
13
13
|
#
|
|
@@ -15,6 +15,17 @@ require_relative "memory_bundle/index_sync"
|
|
|
15
15
|
require_relative "memory_bundle/index_size"
|
|
16
16
|
|
|
17
17
|
module Samagotchi
|
|
18
|
+
# One call's result from ToolRunner#run, read by ToolResponse (the loops'
|
|
19
|
+
# tool_response entries) and the loops' activity list. Fields a call didn't
|
|
20
|
+
# produce (images, shown_params, shown_label, refs, diff, output_file) are nil.
|
|
21
|
+
ToolRun = Data.define(:output, :capped_output, :truncated, :activity, :images, :shown_params, :shown_label, :refs, :diff,
|
|
22
|
+
:output_file) do
|
|
23
|
+
def initialize(output: nil, capped_output: nil, truncated: nil, activity: nil, images: nil, shown_params: nil,
|
|
24
|
+
shown_label: nil, refs: nil, diff: nil, output_file: nil)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
18
29
|
# The single per-call path both loops use: the tool_call_started and
|
|
19
30
|
# tool_call_completed events, the guardrail gate (before_tool_call hooks
|
|
20
31
|
# and their veto), dispatch through KernelLoop, the after_tool_call hook
|
|
@@ -40,7 +51,7 @@ module Samagotchi
|
|
|
40
51
|
end
|
|
41
52
|
|
|
42
53
|
# @param call_index [Integer] 1-based position of the call in its batch
|
|
43
|
-
# @return [
|
|
54
|
+
# @return [ToolRun] output:, capped_output:, truncated:, activity:,
|
|
44
55
|
# output_file: (the saved whole output of a cut execute result),
|
|
45
56
|
# images: (refs) when the tool returned images the model gets to see, and
|
|
46
57
|
# shown_params: the params line the live row showed, and shown_label:
|
|
@@ -135,14 +146,10 @@ module Samagotchi
|
|
|
135
146
|
fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
|
|
136
147
|
status: status || "error", call: call.dup, elapsed_ms: elapsed_ms, result: tool_text })
|
|
137
148
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
run[:refs] = refs unless refs.empty?
|
|
143
|
-
run[:diff] = diff if diff
|
|
144
|
-
run[:output_file] = output_file if output_file
|
|
145
|
-
run
|
|
149
|
+
ToolRun.new(output: output, capped_output: capped, truncated: truncated, activity: result[:activity],
|
|
150
|
+
images: images&.any? ? images : nil,
|
|
151
|
+
shown_params: params && plugin_tool?(call[:name]) ? params : nil,
|
|
152
|
+
shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file)
|
|
146
153
|
end
|
|
147
154
|
|
|
148
155
|
private
|