samagotchi 0.76.0 → 0.78.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 +51 -1
- data/docs/cli.md +2 -1
- data/docs/configuration.md +10 -6
- data/docs/internals/anthropic.md +73 -5
- data/docs/internals/engine.md +2 -1
- data/docs/internals/prompt-caching.md +3 -1
- data/docs/plugins.md +42 -0
- data/docs/sessions.md +1 -0
- data/lib/samagotchi/answer_command.rb +5 -5
- data/lib/samagotchi/bridge.rb +3 -3
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- 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_move.rb +2 -2
- data/lib/samagotchi/child_reports.rb +3 -3
- data/lib/samagotchi/children_status.rb +2 -2
- 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/llm/anthropic_assembly.rb +14 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
- data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
- data/lib/samagotchi/llm/chat_loop.rb +49 -2
- data/lib/samagotchi/llm/errors.rb +6 -4
- data/lib/samagotchi/llm/http.rb +2 -1
- data/lib/samagotchi/llm/openai_chat.rb +12 -2
- data/lib/samagotchi/memory_bundle/builder.rb +2 -0
- data/lib/samagotchi/message_parts.rb +272 -0
- data/lib/samagotchi/note_delivery.rb +2 -2
- data/lib/samagotchi/parent_report.rb +30 -22
- data/lib/samagotchi/pending_question.rb +49 -0
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- 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 +6 -7
- 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/session_manager.rb +0 -1
- data/lib/samagotchi/session_metrics/totals.rb +4 -3
- data/lib/samagotchi/session_metrics.rb +9 -0
- 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 +6 -106
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
- data/lib/samagotchi/terminal_ui.rb +7 -5
- 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.rb +2 -2
- data/lib/samagotchi/tools/delegate_relay.rb +7 -7
- data/lib/samagotchi/tools/delegate_result.rb +2 -2
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/list_sessions.rb +2 -2
- 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 +234 -74
- data/lib/samagotchi/web/public/composer_fold.js +58 -0
- data/lib/samagotchi/web/public/composer_size.js +10 -0
- data/lib/samagotchi/web/public/ctx.js +12 -1
- data/lib/samagotchi/web/public/index.html +29 -4
- 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
- data/lib/samagotchi/worker.rb +1 -0
- metadata +7 -2
- data/lib/samagotchi/web/message_parts.rb +0 -274
data/lib/samagotchi/relay.rb
CHANGED
|
@@ -18,29 +18,28 @@ module Samagotchi
|
|
|
18
18
|
module_function
|
|
19
19
|
|
|
20
20
|
# @param child [Session] the child as loaded (its id, its first prompt)
|
|
21
|
-
# @param pending [
|
|
22
|
-
# when it came from a saved session)
|
|
21
|
+
# @param pending [PendingQuestion] the child's pending question (string
|
|
22
|
+
# keys inside its approval and relay when it came from a saved session)
|
|
23
23
|
# @param relay_id [String] RelayDesk's id
|
|
24
24
|
# @param more [Integer] other delegates' approvals waiting behind this one
|
|
25
25
|
# @return [Hash] Engine#open_question fields
|
|
26
26
|
def card(child, pending, relay_id:, more: 0)
|
|
27
|
-
|
|
28
|
-
facts = pending[:approval].is_a?(Hash) ? pending[:approval] : {}
|
|
27
|
+
facts = pending.approval.is_a?(Hash) ? deep_symbolize(pending.approval) : {}
|
|
29
28
|
short = child.id.to_s[0, 8]
|
|
30
|
-
inner = pending
|
|
29
|
+
inner = pending.relay.is_a?(Hash) ? deep_symbolize(pending.relay) : nil
|
|
31
30
|
chain = inner ? [*Array(inner[:chain]), short] : [short]
|
|
32
|
-
asked = inner ? inner[:asked].to_s : pending
|
|
31
|
+
asked = inner ? inner[:asked].to_s : pending.question.to_s
|
|
33
32
|
task = inner ? inner[:task].to_s : task_of(child)
|
|
34
33
|
who = chain.reverse.join(" → ")
|
|
35
34
|
{
|
|
36
35
|
question: question_text(who, task, asked),
|
|
37
|
-
options: relabel(Array(pending
|
|
36
|
+
options: relabel(Array(pending.options), facts[:scopes], who),
|
|
38
37
|
header: header(who, more),
|
|
39
38
|
multi_select: false,
|
|
40
|
-
allow_freeform: pending.
|
|
39
|
+
allow_freeform: pending.allow_freeform.nil? || !!pending.allow_freeform,
|
|
41
40
|
kind: Guardrails::Approval::KIND,
|
|
42
41
|
approval: facts,
|
|
43
|
-
relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending
|
|
42
|
+
relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
|
|
44
43
|
task: task, chain: chain, asked: asked, more: more.to_i }
|
|
45
44
|
}
|
|
46
45
|
end
|
|
@@ -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
|
|
@@ -6,9 +6,8 @@ require_relative "session_inbox"
|
|
|
6
6
|
require_relative "worker_sidecar"
|
|
7
7
|
|
|
8
8
|
module Samagotchi
|
|
9
|
-
# Loaded on first use:
|
|
10
|
-
#
|
|
11
|
-
# otherwise).
|
|
9
|
+
# Loaded on first use: this autoload keeps SessionManager and its ~80
|
|
10
|
+
# files unloaded until the delegate/session path needs them.
|
|
12
11
|
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
13
12
|
|
|
14
13
|
# Waiting for a session's next reply. A reply is the worker's
|
|
@@ -30,7 +29,7 @@ module Samagotchi
|
|
|
30
29
|
# @!attribute text [String, nil] the reply (:done) or the worker's error
|
|
31
30
|
# (:error)
|
|
32
31
|
# @!attribute file [String, nil] the reply's filename, the next cursor
|
|
33
|
-
# @!attribute question [
|
|
32
|
+
# @!attribute question [PendingQuestion, nil] the pending question
|
|
34
33
|
# (:waiting_for_answer)
|
|
35
34
|
# @!attribute outcome [String, nil] how the turn ended when it left no
|
|
36
35
|
# reply (:no_reply): "failed", "canceled", "completed" (empty),
|
|
@@ -152,7 +151,7 @@ module Samagotchi
|
|
|
152
151
|
# lists' rule): one a dead worker left waits for no one, and a chi
|
|
153
152
|
# REPL's is answered there.
|
|
154
153
|
pending = session.pending_question
|
|
155
|
-
if pending && !(baseline && pending
|
|
154
|
+
if pending && !(baseline && pending.id == baseline[:question_id]) &&
|
|
156
155
|
(turn.nil? || own_question?(session, turn, claimed)) &&
|
|
157
156
|
session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
|
|
158
157
|
return Result.new(status: :waiting_for_answer, question: pending, session: session)
|
|
@@ -217,7 +216,7 @@ module Samagotchi
|
|
|
217
216
|
# its messages, the question pending then, and when its last turn
|
|
218
217
|
# ended, so a turn that ends before the first look still ends the wait.
|
|
219
218
|
# @return [Hash]
|
|
220
|
-
def baseline_of(session, question_id: session.pending_question&.
|
|
219
|
+
def baseline_of(session, question_id: session.pending_question&.id)
|
|
221
220
|
{ messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at }
|
|
222
221
|
end
|
|
223
222
|
|
|
@@ -252,7 +251,7 @@ module Samagotchi
|
|
|
252
251
|
end
|
|
253
252
|
|
|
254
253
|
pending = session.pending_question
|
|
255
|
-
return nil unless pending && pending
|
|
254
|
+
return nil unless pending && pending.kind.to_s == "continue"
|
|
256
255
|
return nil unless turn_ids?(session_dir)
|
|
257
256
|
|
|
258
257
|
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
|
|
@@ -37,7 +37,6 @@ require_relative "plugin_session_state"
|
|
|
37
37
|
require_relative "context_sources"
|
|
38
38
|
require_relative "session_chain"
|
|
39
39
|
require_relative "child_move"
|
|
40
|
-
require_relative "terminal_ui"
|
|
41
40
|
|
|
42
41
|
module Samagotchi
|
|
43
42
|
# SessionManager coordinates background session processes.
|
|
@@ -8,7 +8,7 @@ module Samagotchi
|
|
|
8
8
|
# mid-turn. The tool tallies (by tool, aliased, errors) are counted per
|
|
9
9
|
# tool record, not per turn: SessionMetrics keeps those apart.
|
|
10
10
|
class Totals < Data.define(
|
|
11
|
-
:cancellations, :iterations, :gen_ms, :retries, :cuts, :capped,
|
|
11
|
+
:cancellations, :iterations, :gen_ms, :retries, :cuts, :capped, :thinking_dropped,
|
|
12
12
|
:prompt_sum, :completion_sum, :cached_sum, :cache_write_sum, :reprefill_sum, :reasoning_sum,
|
|
13
13
|
:cost_sum, :cost_estimate_sum, :decode_ms_sum, :decode_tokens_sum,
|
|
14
14
|
# The newest speeds a record has, and where the decode one came from.
|
|
@@ -19,7 +19,8 @@ module Samagotchi
|
|
|
19
19
|
# The record keys each sum adds up (a turn record's own names).
|
|
20
20
|
RECORD_SUMS = {
|
|
21
21
|
prompt_sum: :prompt_tokens_sum, completion_sum: :completion_tokens, iterations: :iterations,
|
|
22
|
-
gen_ms: :gen_ms, retries: :retries, cuts: :cuts, capped: :capped,
|
|
22
|
+
gen_ms: :gen_ms, retries: :retries, cuts: :cuts, capped: :capped, thinking_dropped: :thinking_dropped,
|
|
23
|
+
cached_sum: :cached_tokens_sum,
|
|
23
24
|
cache_write_sum: :cache_write_tokens_sum, reprefill_sum: :reprefill_tokens_sum,
|
|
24
25
|
reasoning_sum: :reasoning_tokens, cost_sum: :cost, cost_estimate_sum: :cost_estimate,
|
|
25
26
|
decode_ms_sum: :decode_ms, decode_tokens_sum: :decode_tokens
|
|
@@ -28,7 +29,7 @@ module Samagotchi
|
|
|
28
29
|
# The running turn's (TurnState) counters each sum adds while it runs.
|
|
29
30
|
TURN_SUMS = {
|
|
30
31
|
iterations: :iteration_count, gen_ms: :gen_latency_accum, retries: :retries, cuts: :cuts,
|
|
31
|
-
capped: :capped, prompt_sum: :prompt_sum, completion_sum: :completion_sum, cached_sum: :cached_sum,
|
|
32
|
+
capped: :capped, thinking_dropped: :thinking_dropped, prompt_sum: :prompt_sum, completion_sum: :completion_sum, cached_sum: :cached_sum,
|
|
32
33
|
cache_write_sum: :cache_write_sum, reprefill_sum: :reprefill_sum, reasoning_sum: :reasoning_sum,
|
|
33
34
|
cost_sum: :cost_sum, cost_estimate_sum: :cost_estimate_sum, decode_ms_sum: :decode_ms_sum,
|
|
34
35
|
decode_tokens_sum: :decode_tokens_sum
|
|
@@ -144,6 +144,9 @@ module Samagotchi
|
|
|
144
144
|
# (finish_reason length). `retries` counts the network's only.
|
|
145
145
|
:cuts,
|
|
146
146
|
:capped,
|
|
147
|
+
# Replayed thinking blocks a preserved-thinking recovery dropped
|
|
148
|
+
# (:generation_completed's thinking_dropped, ChatLoop).
|
|
149
|
+
:thinking_dropped,
|
|
147
150
|
:model,
|
|
148
151
|
# The model the turn ran on (asked host:model, :generation_started's
|
|
149
152
|
# model_ref; the first generation's), and whether its open generation
|
|
@@ -505,6 +508,7 @@ module Samagotchi
|
|
|
505
508
|
retries: totals.retries,
|
|
506
509
|
cuts: totals.cuts,
|
|
507
510
|
capped: totals.capped,
|
|
511
|
+
thinking_dropped: totals.thinking_dropped,
|
|
508
512
|
started_at: @started_at,
|
|
509
513
|
last_activity_at: @last_activity_at,
|
|
510
514
|
# From the session's first start (an earlier process's too), to now.
|
|
@@ -589,6 +593,7 @@ module Samagotchi
|
|
|
589
593
|
retries: 0,
|
|
590
594
|
cuts: 0,
|
|
591
595
|
capped: 0,
|
|
596
|
+
thinking_dropped: 0,
|
|
592
597
|
model: nil,
|
|
593
598
|
model_ref: nil,
|
|
594
599
|
gen_switch: false,
|
|
@@ -679,12 +684,15 @@ module Samagotchi
|
|
|
679
684
|
end
|
|
680
685
|
end
|
|
681
686
|
|
|
687
|
+
# A generation's cut, output cap and dropped thinking blocks, into the turn.
|
|
682
688
|
def count_cut(event)
|
|
683
689
|
@mutex.synchronize do
|
|
684
690
|
next unless @turn
|
|
685
691
|
|
|
686
692
|
@turn.cuts += 1 if event[:stopped_by] && event[:stopped_by].to_s != "steer"
|
|
687
693
|
@turn.capped += 1 if event[:finish_reason].to_s == "length"
|
|
694
|
+
dropped = event[:thinking_dropped]
|
|
695
|
+
@turn.thinking_dropped += dropped if dropped.is_a?(Integer) && dropped.positive?
|
|
688
696
|
end
|
|
689
697
|
end
|
|
690
698
|
|
|
@@ -872,6 +880,7 @@ module Samagotchi
|
|
|
872
880
|
}
|
|
873
881
|
fields[:cache_write_tokens_sum] = turn.cache_write_sum if turn.cache_write_sum.positive?
|
|
874
882
|
fields[:reprefill_tokens_sum] = turn.reprefill_sum if turn.reprefill_sum.positive?
|
|
883
|
+
fields[:thinking_dropped] = turn.thinking_dropped if turn.thinking_dropped.positive?
|
|
875
884
|
fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
|
|
876
885
|
fields[:cost_estimate] = turn.cost_estimate_sum if turn.cost_estimate_sum.positive?
|
|
877
886
|
fields[:switch_prefill_tokens] = turn.switch_prefill_tokens if turn.switch_prefill_tokens
|
|
@@ -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
|
|
@@ -636,6 +600,9 @@ module Samagotchi
|
|
|
636
600
|
# A looped session: loop-guard's cuts, or thinking that ran to the cap.
|
|
637
601
|
lines << "thinking cuts: #{snapshot[:cuts].to_i}"
|
|
638
602
|
lines << "output cap hits: #{snapshot[:capped].to_i}"
|
|
603
|
+
# Claude 5.x: replayed thinking a prefix change made the API drop.
|
|
604
|
+
dropped = snapshot[:thinking_dropped].to_i
|
|
605
|
+
lines << "thinking dropped: #{dropped} blocks (preserved-thinking recovery)" if dropped.positive?
|
|
639
606
|
context = snapshot[:context] || {}
|
|
640
607
|
if context[:used_tokens]
|
|
641
608
|
pct = context_pct_text(context, snapshot[:llm_context])
|
|
@@ -745,73 +712,6 @@ module Samagotchi
|
|
|
745
712
|
text
|
|
746
713
|
end
|
|
747
714
|
|
|
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
715
|
def format_elapsed_duration(duration_ms)
|
|
816
716
|
duration_ms = duration_ms.to_f
|
|
817
717
|
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
|