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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +10 -6
  5. data/docs/internals/anthropic.md +73 -5
  6. data/docs/internals/engine.md +2 -1
  7. data/docs/internals/prompt-caching.md +3 -1
  8. data/docs/plugins.md +42 -0
  9. data/docs/sessions.md +1 -0
  10. data/lib/samagotchi/answer_command.rb +5 -5
  11. data/lib/samagotchi/bridge.rb +3 -3
  12. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/child_move.rb +2 -2
  17. data/lib/samagotchi/child_reports.rb +3 -3
  18. data/lib/samagotchi/children_status.rb +2 -2
  19. data/lib/samagotchi/engine.rb +21 -12
  20. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  21. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  22. data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
  23. data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
  24. data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
  25. data/lib/samagotchi/llm/chat_loop.rb +49 -2
  26. data/lib/samagotchi/llm/errors.rb +6 -4
  27. data/lib/samagotchi/llm/http.rb +2 -1
  28. data/lib/samagotchi/llm/openai_chat.rb +12 -2
  29. data/lib/samagotchi/memory_bundle/builder.rb +2 -0
  30. data/lib/samagotchi/message_parts.rb +272 -0
  31. data/lib/samagotchi/note_delivery.rb +2 -2
  32. data/lib/samagotchi/parent_report.rb +30 -22
  33. data/lib/samagotchi/pending_question.rb +49 -0
  34. data/lib/samagotchi/plugin/sessions.rb +2 -2
  35. data/lib/samagotchi/question_desk.rb +39 -32
  36. data/lib/samagotchi/relay.rb +8 -9
  37. data/lib/samagotchi/relay_verifier.rb +2 -2
  38. data/lib/samagotchi/relay_watcher.rb +2 -2
  39. data/lib/samagotchi/reply_wait.rb +6 -7
  40. data/lib/samagotchi/row_refs.rb +1 -1
  41. data/lib/samagotchi/session.rb +22 -14
  42. data/lib/samagotchi/session_commands.rb +4 -1
  43. data/lib/samagotchi/session_manager.rb +0 -1
  44. data/lib/samagotchi/session_metrics/totals.rb +4 -3
  45. data/lib/samagotchi/session_metrics.rb +9 -0
  46. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  48. data/lib/samagotchi/terminal_ui/formatting.rb +6 -106
  49. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  50. data/lib/samagotchi/terminal_ui.rb +7 -5
  51. data/lib/samagotchi/tool_output_cut.rb +1 -1
  52. data/lib/samagotchi/tool_response.rb +13 -13
  53. data/lib/samagotchi/tool_row_fields.rb +1 -1
  54. data/lib/samagotchi/tool_runner.rb +16 -9
  55. data/lib/samagotchi/tools/delegate.rb +2 -2
  56. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  57. data/lib/samagotchi/tools/delegate_result.rb +2 -2
  58. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  59. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  60. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +2 -2
  63. data/lib/samagotchi/web/public/app.js +234 -74
  64. data/lib/samagotchi/web/public/composer_fold.js +58 -0
  65. data/lib/samagotchi/web/public/composer_size.js +10 -0
  66. data/lib/samagotchi/web/public/ctx.js +12 -1
  67. data/lib/samagotchi/web/public/index.html +29 -4
  68. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  69. data/lib/samagotchi/web/public/stream_handlers.js +7 -3
  70. data/lib/samagotchi/web/public/toasts.js +69 -0
  71. data/lib/samagotchi/worker.rb +1 -0
  72. metadata +7 -2
  73. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -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 [Hash] the child's pending question (string keys inside
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
- pending = deep_symbolize(pending)
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[:relay].is_a?(Hash) ? pending[:relay] : nil
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[:question].to_s
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[:options]), facts[:scopes], who),
36
+ options: relabel(Array(pending.options), facts[:scopes], who),
38
37
  header: header(who, more),
39
38
  multi_select: false,
40
- allow_freeform: pending.key?(:allow_freeform) ? !!pending[:allow_freeform] : true,
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[:id].to_s,
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[:id].to_s == question_id
73
+ return [] unless pending && pending.id.to_s == question_id
74
74
 
75
- options = Array(pending[:options])
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 && pending[:relayed_to]
41
- return false unless relay.is_a?(Hash) && pending[:id].to_s == question_id.to_s
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: session_manager requires terminal_ui, which
10
- # requires KernelLoop and so the tools that use this (a require cycle
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 [Hash, nil] the pending 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[:id] == baseline[:question_id]) &&
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&.dig(:id))
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[:kind].to_s == "continue"
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
@@ -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 (Web::MessageParts). Display only: the model never sees them.
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
@@ -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
- :pending_question, :used_memory_names
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
- @pending_question = pending_question
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.is_a?(Hash) && pending[:id]
302
+ return nil unless live && pending&.id
293
303
 
294
- kind = pending[:kind].to_s
295
- relayed = pending[:relayed_to]
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[:id], kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
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[:id].to_s
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 and
394
- # pending_question change key type on the way (symbols in memory,
395
- # strings on disk); last_turn and prompt_notes are value objects in
396
- # memory (LastTurn, PromptNote).
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(stringify_message_keys(@pending_question))
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, cached_sum: :cached_tokens_sum,
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 (symbol keys)
91
+ # @param pending [Hash] the pending question as the Bridge sent it
92
92
  def report_unanswered(pending, session_id:, err: $stderr)
93
- err.print("chi: #{ParentReport.question_text(pending, session_id: session_id)}")
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 "../web/message_parts"
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(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
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