samagotchi 0.19.0 → 0.20.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/docs/architecture.md +6 -0
  4. data/docs/cli.md +14 -3
  5. data/docs/configuration.md +4 -1
  6. data/docs/guardrails.md +92 -14
  7. data/docs/plugins.md +5 -0
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  10. data/lib/samagotchi/bootstrap_command.rb +26 -5
  11. data/lib/samagotchi/bundle_command.rb +22 -4
  12. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  14. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  15. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  16. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  18. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  20. data/lib/samagotchi/commands/registry.rb +4 -3
  21. data/lib/samagotchi/config.rb +3 -0
  22. data/lib/samagotchi/context_usage.rb +3 -3
  23. data/lib/samagotchi/engine.rb +16 -2
  24. data/lib/samagotchi/guardrail_wiring.rb +2 -1
  25. data/lib/samagotchi/guardrails/approval.rb +4 -3
  26. data/lib/samagotchi/guardrails/approvals.rb +11 -5
  27. data/lib/samagotchi/guardrails/context.rb +16 -9
  28. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  29. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  30. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  31. data/lib/samagotchi/guardrails/rules.rb +69 -5
  32. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  33. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  34. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  35. data/lib/samagotchi/guardrails/targets.rb +65 -4
  36. data/lib/samagotchi/guardrails.rb +2 -0
  37. data/lib/samagotchi/hooks/registry.rb +6 -2
  38. data/lib/samagotchi/idle_recap.rb +7 -2
  39. data/lib/samagotchi/llm/usage.rb +1 -1
  40. data/lib/samagotchi/memory_bundle/builder.rb +1 -5
  41. data/lib/samagotchi/memory_bundle/installer.rb +46 -13
  42. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  43. data/lib/samagotchi/memory_bundle/status.rb +12 -4
  44. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
  45. data/lib/samagotchi/memory_paths.rb +6 -0
  46. data/lib/samagotchi/self_report.rb +38 -2
  47. data/lib/samagotchi/session.rb +35 -6
  48. data/lib/samagotchi/session_commands.rb +13 -2
  49. data/lib/samagotchi/session_metrics.rb +224 -19
  50. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  51. data/lib/samagotchi/token_usage.rb +57 -41
  52. data/lib/samagotchi/tool_runner.rb +21 -2
  53. data/lib/samagotchi/tools/execute.rb +9 -8
  54. data/lib/samagotchi/version.rb +1 -1
  55. data/lib/samagotchi/web/app.rb +19 -0
  56. data/lib/samagotchi/web/public/app.js +34 -15
  57. data/lib/samagotchi/web/public/card.js +34 -2
  58. data/lib/samagotchi/web/public/ctx.js +59 -0
  59. data/lib/samagotchi/web/public/format.js +11 -0
  60. data/lib/samagotchi/web/public/index.html +26 -2
  61. data/lib/samagotchi/web/public/model_pick.js +6 -2
  62. data/lib/samagotchi/web/public/model_picker.js +2 -1
  63. data/lib/samagotchi/web/public/question_card.js +2 -1
  64. data/lib/samagotchi/web/public/timing.js +6 -2
  65. data/lib/samagotchi/web/public/turn_events.js +1 -1
  66. data/lib/samagotchi/web/session_summary.rb +6 -2
  67. metadata +5 -1
@@ -20,8 +20,10 @@ module Samagotchi
20
20
  # sums over the records, kept as running counters as records are added, so
21
21
  # a worker that stops and wakes again (a new collector) keeps counting:
22
22
  # #session_id= loads the records already saved.
23
- # Only prompt and completion counts, which every backend reports the same
24
- # way, are kept.
23
+ # Prompt and completion counts come from every backend. Cached and reasoning
24
+ # tokens, cost (OpenRouter's usage.cost) and the server's speeds (llama.cpp's
25
+ # timings) are kept when the server reports them; a generation without a
26
+ # server speed gets an estimated decode speed (see #close_generation).
25
27
  #
26
28
  # A snapshot is surfaced via Engine#session_state_snapshot (the Bridge's
27
29
  # /state, /snapshot, /stats and SSE frames) and persisted to a sibling
@@ -35,6 +37,25 @@ module Samagotchi
35
37
  # unsaved ones of a collector whose saves keep failing stay bounded.
36
38
  LIVE_RECORDS_CAP = 20
37
39
 
40
+ # A generation shorter than this gets no decode speed of its own: one
41
+ # tiny generation would skew the session's average.
42
+ SPEED_MIN_TOKENS = 8
43
+ SPEED_MIN_MS = 100
44
+
45
+ # The session's token totals as a saved analytics.json has them (zeros
46
+ # where an older file lacks a count), with how full the context was.
47
+ # A generation's decode speed (tokens per second) and where it came
48
+ # from: "server" (llama.cpp's timings) or "estimate".
49
+ GenerationSpeed = Data.define(:decode_tps, :source)
50
+
51
+ # What #finish_generation reports: the generation's speed (nil when it
52
+ # had none) and the session's tokens block as the snapshot has it.
53
+ GenerationReport = Data.define(:speed, :tokens)
54
+
55
+ SavedSummary = Data.define(:ctx_pct, :prompt_sum, :completion_sum, :cached_sum, :reasoning_sum, :cost_sum) do
56
+ def tokens = to_h.except(:ctx_pct)
57
+ end
58
+
38
59
  # Per-turn transient state, reset on each turn_started.
39
60
  TurnState = Struct.new(
40
61
  :session_id,
@@ -56,6 +77,17 @@ module Samagotchi
56
77
  # comes cumulative per request) and whether it is still streaming.
57
78
  :gen_prompt_max,
58
79
  :gen_open,
80
+ # The rest of this generation's server report: the largest cached and
81
+ # reasoning counts, the last cost, and the server's speeds and decode
82
+ # time (llama.cpp's timings); and when its first content, reasoning or
83
+ # tool call chunk came (an estimated speed runs from there).
84
+ :gen_cached_max,
85
+ :gen_reasoning_max,
86
+ :gen_cost,
87
+ :gen_decode_tps,
88
+ :gen_prefill_tps,
89
+ :gen_decode_ms,
90
+ :gen_first_chunk_at,
59
91
  # The turn's generations, folded as each one ends: the count, the last
60
92
  # one's prompt and completion, the sums, and which kinds of counts
61
93
  # (server / estimate) they came from.
@@ -65,6 +97,18 @@ module Samagotchi
65
97
  :completion_last,
66
98
  :completion_sum,
67
99
  :token_sources,
100
+ # Summed over the turn's generations: cached and reasoning tokens, the
101
+ # cost (nil until one reports it), and the decode time and completion
102
+ # tokens of the generations with a speed. The last speeds and where the
103
+ # decode one came from (server / estimate).
104
+ :cached_sum,
105
+ :reasoning_sum,
106
+ :cost_sum,
107
+ :decode_ms_sum,
108
+ :decode_tokens_sum,
109
+ :last_decode_tps,
110
+ :last_prefill_tps,
111
+ :tps_source,
68
112
  :retries,
69
113
  :model,
70
114
  :id,
@@ -80,7 +124,27 @@ module Samagotchi
80
124
  # @param session_dir [String]
81
125
  # @return [Float, nil]
82
126
  def self.saved_context_pct(session_dir)
83
- context = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["context"]
127
+ saved_summary(session_dir)&.ctx_pct
128
+ end
129
+
130
+ # The context's fill and the token totals from the session's saved
131
+ # analytics.json, read once; nil without a readable file.
132
+ # @param session_dir [String]
133
+ # @return [SavedSummary, nil]
134
+ def self.saved_summary(session_dir)
135
+ data = JSON.parse(File.read(File.join(session_dir, "analytics.json")))
136
+ return nil unless data.is_a?(Hash)
137
+
138
+ tokens = data["tokens"].is_a?(Hash) ? data["tokens"] : {}
139
+ count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
140
+ SavedSummary.new(ctx_pct: saved_pct(data["context"]), prompt_sum: count.("prompt_sum"),
141
+ completion_sum: count.("completion_sum"), cached_sum: count.("cached_sum"),
142
+ reasoning_sum: count.("reasoning_sum"), cost_sum: count.("cost_sum"))
143
+ rescue JSON::ParserError, SystemCallError, TypeError
144
+ nil
145
+ end
146
+
147
+ def self.saved_pct(context)
84
148
  return nil unless context.is_a?(Hash)
85
149
 
86
150
  used = context["used_tokens"]
@@ -88,9 +152,8 @@ module Samagotchi
88
152
  return nil unless used.is_a?(Numeric) && window.is_a?(Numeric) && window.positive?
89
153
 
90
154
  used * 100.0 / window
91
- rescue JSON::ParserError, SystemCallError, TypeError
92
- nil
93
155
  end
156
+ private_class_method :saved_pct
94
157
 
95
158
  # One turn's tool records from the session's saved analytics.json, in
96
159
  # call order (iteration, then call_index), string-keyed as saved: what a
@@ -236,6 +299,17 @@ module Samagotchi
236
299
  @mutex.synchronize { build_snapshot(:recent) }
237
300
  end
238
301
 
302
+ # Close the open generation now, ahead of its :generation_completed
303
+ # (which then finds it closed), and report its speed with the session's
304
+ # running token totals: what the event carries to the UIs.
305
+ # @return [GenerationReport]
306
+ def finish_generation
307
+ @mutex.synchronize do
308
+ speed = close_generation if @turn&.gen_open
309
+ GenerationReport.new(speed: speed, tokens: tokens_block(@totals, @turn))
310
+ end
311
+ end
312
+
239
313
  # The served model a generation last reported and the name asked for
240
314
  # then, without building a snapshot (Engine#served_model).
241
315
  # @return [Hash] :served_model, :served_model_for
@@ -278,11 +352,7 @@ module Samagotchi
278
352
  session_id: @session_id,
279
353
  turns: @turn_records.size + (turn ? 1 : 0),
280
354
  cancellations: totals[:cancellations],
281
- tokens: {
282
- prompt_sum: totals[:prompt_sum] + (turn&.prompt_sum || 0),
283
- completion_sum: totals[:completion_sum] + (turn&.completion_sum || 0),
284
- source: combined_source(totals[:token_sources] + (turn&.token_sources || []))
285
- },
355
+ tokens: tokens_block(totals, turn),
286
356
  context: context_block(@turn_records),
287
357
  profile: @profile,
288
358
  profile_source: @profile_source,
@@ -304,6 +374,30 @@ module Samagotchi
304
374
  }
305
375
  end
306
376
 
377
+ # The session's token totals, with the running turn's finished
378
+ # generations; the last speeds are the newest a generation reported.
379
+ # Caller holds the mutex.
380
+ def tokens_block(totals, turn)
381
+ add = ->(key) { totals[key] + (turn&.public_send(key) || 0) }
382
+ decode_ms = add.(:decode_ms_sum)
383
+ decode_tokens = add.(:decode_tokens_sum)
384
+ speed_turn = turn&.last_decode_tps ? turn : nil
385
+ {
386
+ prompt_sum: add.(:prompt_sum),
387
+ completion_sum: add.(:completion_sum),
388
+ cached_sum: add.(:cached_sum),
389
+ reasoning_sum: add.(:reasoning_sum),
390
+ cost_sum: add.(:cost_sum),
391
+ decode_ms_sum: decode_ms.round,
392
+ decode_tokens_sum: decode_tokens,
393
+ avg_decode_tps: decode_ms.positive? ? (decode_tokens * 1000.0 / decode_ms).round(1) : nil,
394
+ last_decode_tps: speed_turn ? speed_turn.last_decode_tps : totals[:last_decode_tps],
395
+ last_prefill_tps: turn&.last_prefill_tps || totals[:last_prefill_tps],
396
+ tps_source: speed_turn ? speed_turn.tps_source : totals[:tps_source],
397
+ source: combined_source(totals[:token_sources] + (turn&.token_sources || []))
398
+ }
399
+ end
400
+
307
401
  # The records a snapshot carries, copied. :all is every record; :recent
308
402
  # the newest turn this collector finished (live before its save lands)
309
403
  # and the unsaved ones (capped), and the tool records of those turns and
@@ -340,12 +434,19 @@ module Samagotchi
340
434
  gen_estimate_sum: 0,
341
435
  gen_prompt_max: 0,
342
436
  gen_open: false,
437
+ gen_cached_max: 0,
438
+ gen_reasoning_max: 0,
343
439
  generations: 0,
344
440
  prompt_last: nil,
345
441
  prompt_sum: 0,
346
442
  completion_last: 0,
347
443
  completion_sum: 0,
348
444
  token_sources: [],
445
+ cached_sum: 0,
446
+ reasoning_sum: 0,
447
+ cost_sum: nil,
448
+ decode_ms_sum: 0,
449
+ decode_tokens_sum: 0,
349
450
  retries: 0,
350
451
  model: nil,
351
452
  id: event[:turn_id] || SecureRandom.uuid,
@@ -407,14 +508,17 @@ module Samagotchi
407
508
  # instead (there is no prompt count then). The two paths are mutually
408
509
  # exclusive per generation, so a final chunk carrying timings while
409
510
  # earlier chunks did not will not double count. Caller holds the mutex.
511
+ # @return [GenerationSpeed, nil] the generation's speed, if it had one
410
512
  def close_generation
411
513
  turn = @turn
412
514
  turn.gen_open = false
515
+ speed = nil
413
516
  if turn.gen_had_server
414
517
  completion = turn.gen_completion_max
415
518
  turn.prompt_last = turn.gen_prompt_max
416
519
  turn.prompt_sum += turn.gen_prompt_max
417
520
  turn.token_sources |= ["server"]
521
+ speed = close_server_report(turn, completion)
418
522
  else
419
523
  completion = turn.gen_estimate_sum
420
524
  turn.token_sources |= ["estimate"] if completion.positive?
@@ -422,11 +526,51 @@ module Samagotchi
422
526
  turn.completion_last = completion
423
527
  turn.completion_sum += completion
424
528
  started = turn.gen_started_at
425
- return unless started
529
+ return speed unless started
426
530
 
427
531
  elapsed_ms = (monotonic_time - started) * 1000.0
428
532
  turn.gen_latency_accum += elapsed_ms if elapsed_ms > 0
429
533
  turn.gen_started_at = nil
534
+ speed
535
+ end
536
+
537
+ # The generation's cached/reasoning counts, cost and decode speed, into
538
+ # the turn. The speed is the server's (llama.cpp's predicted_per_second
539
+ # over its predicted_ms) or, without one, an estimate: the completion
540
+ # over the time from the first streamed chunk to now (that leaves out
541
+ # queueing and the prefill). An estimated chars/4 generation never gets
542
+ # here: it has no speed. Caller holds the mutex.
543
+ def close_server_report(turn, completion)
544
+ turn.cached_sum += turn.gen_cached_max
545
+ turn.reasoning_sum += turn.gen_reasoning_max
546
+ turn.cost_sum = turn.cost_sum.to_f + turn.gen_cost if turn.gen_cost
547
+ turn.last_prefill_tps = turn.gen_prefill_tps.round(1) if turn.gen_prefill_tps
548
+ speed = generation_speed(turn, completion)
549
+ return unless speed
550
+
551
+ tps, decode_ms, source = speed
552
+ turn.decode_ms_sum += decode_ms
553
+ turn.decode_tokens_sum += completion
554
+ turn.last_decode_tps = tps.round(1)
555
+ turn.tps_source = source
556
+ GenerationSpeed.new(decode_tps: turn.last_decode_tps, source: source)
557
+ end
558
+
559
+ # [tokens per second, decode ms, "server" | "estimate"], or nil for a
560
+ # generation too short (or without a first chunk) to time.
561
+ def generation_speed(turn, completion)
562
+ if turn.gen_decode_tps && turn.gen_decode_ms
563
+ decode_ms = turn.gen_decode_ms
564
+ tps = turn.gen_decode_tps
565
+ source = "server"
566
+ elsif turn.gen_first_chunk_at
567
+ decode_ms = (monotonic_time - turn.gen_first_chunk_at) * 1000.0
568
+ tps = decode_ms.positive? ? completion * 1000.0 / decode_ms : 0
569
+ source = "estimate"
570
+ end
571
+ return nil unless decode_ms && completion >= SPEED_MIN_TOKENS && decode_ms >= SPEED_MIN_MS
572
+
573
+ [tps, decode_ms, source]
430
574
  end
431
575
 
432
576
  # Caller holds the mutex.
@@ -435,6 +579,11 @@ module Samagotchi
435
579
  @turn.gen_prompt_max = 0
436
580
  @turn.gen_had_server = false
437
581
  @turn.gen_estimate_sum = 0
582
+ @turn.gen_cached_max = 0
583
+ @turn.gen_reasoning_max = 0
584
+ @turn.gen_cost = nil
585
+ @turn.gen_decode_tps = @turn.gen_prefill_tps = @turn.gen_decode_ms = nil
586
+ @turn.gen_first_chunk_at = nil
438
587
  end
439
588
 
440
589
  # The turn record's model and token fields. The context used at the end
@@ -455,7 +604,24 @@ module Samagotchi
455
604
  tool_errors: turn.tool_errors,
456
605
  iterations: turn.iteration_count,
457
606
  retries: turn.retries
607
+ }.merge(turn_usage_fields(turn))
608
+ end
609
+
610
+ # The rest of the server's report: cached and reasoning tokens, the cost,
611
+ # and the speeds (decode_tps is the turn's last generation's, decode_ms
612
+ # and decode_tokens what the session's average is weighted by).
613
+ def turn_usage_fields(turn)
614
+ fields = {
615
+ cached_tokens_sum: turn.cached_sum,
616
+ reasoning_tokens: turn.reasoning_sum,
617
+ decode_ms: turn.decode_ms_sum.round,
618
+ decode_tokens: turn.decode_tokens_sum,
619
+ decode_tps: turn.last_decode_tps,
620
+ prefill_tps: turn.last_prefill_tps,
621
+ tps_source: turn.tps_source
458
622
  }
623
+ fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
624
+ fields
459
625
  end
460
626
 
461
627
  def record_tool_call_start(event)
@@ -519,15 +685,10 @@ module Samagotchi
519
685
  payload = event[:payload]
520
686
  payload = {} unless payload.is_a?(Hash)
521
687
 
688
+ @mutex.synchronize { note_first_chunk(payload, event) }
522
689
  usage = TokenUsage.from_payload(payload)
523
690
  if usage
524
- @mutex.synchronize do
525
- if @turn
526
- @turn.gen_prompt_max = [@turn.gen_prompt_max, usage[:prompt_tokens].to_i].max
527
- @turn.gen_completion_max = [@turn.gen_completion_max, usage[:completion_tokens].to_i].max
528
- @turn.gen_had_server = true
529
- end
530
- end
691
+ @mutex.synchronize { note_server_usage(usage) if @turn }
531
692
  else
532
693
  @mutex.synchronize do
533
694
  chars = event_content_chars(payload, event)
@@ -539,6 +700,37 @@ module Samagotchi
539
700
  end
540
701
  end
541
702
 
703
+ # Caller holds the mutex.
704
+ def note_server_usage(usage)
705
+ turn = @turn
706
+ turn.gen_prompt_max = [turn.gen_prompt_max, usage.prompt_tokens.to_i].max
707
+ turn.gen_completion_max = [turn.gen_completion_max, usage.completion_tokens.to_i].max
708
+ turn.gen_cached_max = [turn.gen_cached_max, usage.cached_tokens.to_i].max
709
+ turn.gen_reasoning_max = [turn.gen_reasoning_max, usage.reasoning_tokens.to_i].max
710
+ turn.gen_cost = usage.cost if usage.cost
711
+ if usage.predicted_per_second && usage.predicted_ms
712
+ turn.gen_decode_tps = usage.predicted_per_second
713
+ turn.gen_decode_ms = usage.predicted_ms
714
+ end
715
+ turn.gen_prefill_tps = usage.prompt_per_second if usage.prompt_per_second
716
+ turn.gen_had_server = true
717
+ end
718
+
719
+ # The first chunk with content, reasoning or a tool call starts the
720
+ # generation's decode clock (an estimated speed runs from it). Caller
721
+ # holds the mutex.
722
+ def note_first_chunk(payload, event)
723
+ return unless @turn && @turn.gen_open && @turn.gen_first_chunk_at.nil?
724
+ return unless !event[:content].to_s.empty? || tool_call_delta?(payload)
725
+
726
+ @turn.gen_first_chunk_at = monotonic_time
727
+ end
728
+
729
+ def tool_call_delta?(payload)
730
+ choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
731
+ choice.is_a?(Hash) && choice["delta"].is_a?(Hash) && !Array(choice["delta"]["tool_calls"]).empty?
732
+ end
733
+
542
734
  def event_content_chars(payload, event)
543
735
  len = payload_content(payload, event).length
544
736
  len.positive? ? len : nil
@@ -663,7 +855,9 @@ module Samagotchi
663
855
  # (#snapshot adds the running turn). Caller holds the mutex.
664
856
  def reset_totals
665
857
  @totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
666
- gen_ms: 0, retries: 0, by_tool: {}, tool_errors: 0 }
858
+ gen_ms: 0, retries: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, reasoning_sum: 0,
859
+ cost_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
860
+ last_prefill_tps: nil, tps_source: nil }
667
861
  end
668
862
 
669
863
  def count_turn(record)
@@ -674,6 +868,17 @@ module Samagotchi
674
868
  totals[:iterations] += number(record[:iterations])
675
869
  totals[:gen_ms] += number(record[:gen_ms])
676
870
  totals[:retries] += number(record[:retries])
871
+ totals[:cached_sum] += number(record[:cached_tokens_sum])
872
+ totals[:reasoning_sum] += number(record[:reasoning_tokens])
873
+ totals[:cost_sum] += number(record[:cost])
874
+ totals[:decode_ms_sum] += number(record[:decode_ms])
875
+ totals[:decode_tokens_sum] += number(record[:decode_tokens])
876
+ # The newest speeds a record has (an older record has none).
877
+ if record[:decode_tps].is_a?(Numeric)
878
+ totals[:last_decode_tps] = record[:decode_tps]
879
+ totals[:tps_source] = record[:tps_source]
880
+ end
881
+ totals[:last_prefill_tps] = record[:prefill_tps] if record[:prefill_tps].is_a?(Numeric)
677
882
  source = record[:token_source]
678
883
  totals[:token_sources] |= [source.to_s] unless source.nil?
679
884
  end
@@ -221,6 +221,26 @@ module Samagotchi
221
221
  duration_ms.nil? ? "" : " · #{format_elapsed_duration(duration_ms)}"
222
222
  end
223
223
 
224
+ # "87 tok/s", "1.9k tok/s"; "~64 tok/s" for an estimate; "" without a
225
+ # speed. The web says the same (ctx.js speedText;
226
+ # spec/shared/labels_matrix.json).
227
+ def speed_text(tps, source)
228
+ value = tps.to_f
229
+ return "" unless value.positive?
230
+
231
+ number = value >= 1000 ? "#{format('%.1f', (value / 100).round / 10.0)}k" : value.round.to_s
232
+ "#{'~' if source.to_s == 'estimate'}#{number} tok/s"
233
+ end
234
+
235
+ # "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "" for
236
+ # none. As ctx.js costText.
237
+ def cost_text(cost)
238
+ value = cost.to_f
239
+ return "" unless value.positive?
240
+
241
+ value >= 0.01 ? format("$%.2f", value) : format("$%.4f", value)
242
+ end
243
+
224
244
  private
225
245
 
226
246
  # The recap shown on return: dim, one "recap>" block, noting how many
@@ -343,7 +363,11 @@ module Samagotchi
343
363
  end
344
364
  lines << "iterations: #{snapshot[:iterations_total]}"
345
365
  # Summed over every request: each prompt is sent in full again.
346
- lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})"
366
+ lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
367
+ "#{token_breakdown_text(tokens)}"
368
+ speed = stats_speed_text(tokens)
369
+ lines << "speed: #{speed}" if speed
370
+ lines << "cost: #{cost_text(tokens[:cost_sum])} (this session only)" if tokens[:cost_sum].to_f.positive?
347
371
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
348
372
  lines << "cancellations: #{snapshot[:cancellations]}"
349
373
  lines << "retries: #{snapshot[:retries]}"
@@ -362,6 +386,33 @@ module Samagotchi
362
386
  lines.join("\n")
363
387
  end
364
388
 
389
+ # ", cached 4864 (93%), reasoning 212" for the tokens line; the counts a
390
+ # server didn't report are left out.
391
+ def token_breakdown_text(tokens)
392
+ cached = tokens[:cached_sum].to_i
393
+ reasoning = tokens[:reasoning_sum].to_i
394
+ prompt = tokens[:prompt_sum].to_i
395
+ text = +""
396
+ text << ", cached #{cached} (#{(cached * 100.0 / prompt).round}%)" if cached.positive? && prompt.positive?
397
+ text << ", reasoning #{reasoning}" if reasoning.positive?
398
+ text
399
+ end
400
+
401
+ # "87 tok/s out, 1.9k tok/s prompt (last, server), avg 81 tok/s", or nil
402
+ # before a generation had a speed. The prompt (prefill) speed only when
403
+ # the server reported it.
404
+ def stats_speed_text(tokens)
405
+ last = tokens[:last_decode_tps]
406
+ return nil unless last
407
+
408
+ source = tokens[:tps_source].to_s
409
+ text = +"#{speed_text(last, source)} out"
410
+ text << ", #{speed_text(tokens[:last_prefill_tps], 'server')} prompt" if tokens[:last_prefill_tps]
411
+ text << " (last, #{source})"
412
+ text << ", avg #{speed_text(tokens[:avg_decode_tps], source)}" if tokens[:avg_decode_tps]
413
+ text
414
+ end
415
+
365
416
  def format_elapsed_duration(duration_ms)
366
417
  duration_ms = duration_ms.to_f
367
418
  return "" if duration_ms.negative?
@@ -1,61 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- # TokenUsage extracts real server-reported token counts from a model API
5
- # stream payload, transport-agnostically.
4
+ # TokenUsage is the server-reported token accounting of one model API stream
5
+ # payload, read transport-agnostically.
6
6
  #
7
7
  # llama.cpp's /completion stream emits per-chunk `timings.prompt_n` /
8
8
  # `timings.predicted_n` (cumulative) and a final top-level
9
- # `tokens_evaluated` / `tokens_predicted`. mlx_lm.server (OpenAI-compatible)
10
- # emits a `usage` object with `prompt_tokens` / `completion_tokens`.
9
+ # `tokens_evaluated` / `tokens_predicted`. OpenAI-compatible servers emit a
10
+ # `usage` object with `prompt_tokens` / `completion_tokens`.
11
11
  #
12
- # Returns nil when no server token data is present, so a caller can fall back
13
- # to a chars/4 heuristic. The reported values are cumulative per request, so
14
- # callers should take the running MAX across chunks (see SessionMetrics).
15
- class TokenUsage
12
+ # The last chunk can say more: llama.cpp adds a `timings` block with exact
13
+ # speeds and `cache_n` (its native `tokens_cached` counts the new tokens too,
14
+ # so it is not read); OpenRouter adds `usage.cost` (USD) and the
15
+ # `prompt_tokens_details.cached_tokens` / `completion_tokens_details.reasoning_tokens`
16
+ # breakdowns. Those fields are nil when a payload doesn't carry them.
17
+ #
18
+ # from_payload returns nil when no server token counts are present, so a
19
+ # caller can fall back to a chars/4 heuristic. The reported values are
20
+ # cumulative per request, so callers should take the running MAX across
21
+ # chunks (see SessionMetrics).
22
+ class TokenUsage < Data.define(:prompt_tokens, :completion_tokens, :cached_tokens, :reasoning_tokens, :cost,
23
+ :predicted_per_second, :prompt_per_second, :predicted_ms, :source)
16
24
  CHARS_PER_TOKEN = 4.0
17
25
 
18
26
  class << self
19
27
  # @param payload [Hash, nil] the raw streamed chunk payload
20
- # @return [Hash, nil] { prompt_tokens:, completion_tokens:, source: :server }
28
+ # @return [TokenUsage, nil]
21
29
  def from_payload(payload)
22
30
  payload_hash = payload.is_a?(Hash) ? payload : {}
23
- usage = payload_hash["usage"] || payload_hash[:usage]
24
- usage = {} unless usage.is_a?(Hash)
31
+ usage = hash_at(payload_hash, :usage)
32
+ timings = hash_at(payload_hash, :timings)
25
33
 
26
34
  prompt_tokens = first_positive(
27
- usage["prompt_tokens"],
28
- usage[:prompt_tokens],
29
- payload_hash["prompt_tokens"],
30
- payload_hash[:prompt_tokens],
31
- payload_hash["prompt_n"],
32
- payload_hash[:prompt_n],
33
- payload_hash["tokens_evaluated"],
34
- payload_hash[:tokens_evaluated],
35
- payload_hash.dig("timings", "prompt_n"),
36
- payload_hash.dig(:timings, :prompt_n)
35
+ *both(usage, :prompt_tokens), *both(payload_hash, :prompt_tokens), *both(payload_hash, :prompt_n),
36
+ *both(payload_hash, :tokens_evaluated), *both(timings, :prompt_n)
37
37
  )
38
-
39
38
  completion_tokens = first_positive(
40
- usage["completion_tokens"],
41
- usage[:completion_tokens],
42
- payload_hash["completion_tokens"],
43
- payload_hash[:completion_tokens],
44
- payload_hash["predicted_n"],
45
- payload_hash[:predicted_n],
46
- payload_hash["tokens_predicted"],
47
- payload_hash[:tokens_predicted],
48
- payload_hash.dig("timings", "predicted_n"),
49
- payload_hash.dig(:timings, :predicted_n)
39
+ *both(usage, :completion_tokens), *both(payload_hash, :completion_tokens),
40
+ *both(payload_hash, :predicted_n), *both(payload_hash, :tokens_predicted), *both(timings, :predicted_n)
50
41
  )
51
-
52
42
  return nil unless prompt_tokens || completion_tokens
53
43
 
54
- {
44
+ new(
55
45
  prompt_tokens: prompt_tokens,
56
46
  completion_tokens: completion_tokens,
47
+ cached_tokens: first_positive(*both(hash_at(usage, :prompt_tokens_details), :cached_tokens),
48
+ *both(timings, :cache_n)),
49
+ reasoning_tokens: first_positive(*both(hash_at(usage, :completion_tokens_details), :reasoning_tokens)),
50
+ cost: number(*both(usage, :cost)),
51
+ predicted_per_second: positive_number(*both(timings, :predicted_per_second)),
52
+ prompt_per_second: positive_number(*both(timings, :prompt_per_second)),
53
+ predicted_ms: positive_number(*both(timings, :predicted_ms)),
57
54
  source: :server
58
- }
55
+ )
59
56
  end
60
57
 
61
58
  # Estimate token count from raw text length using the chars/4 heuristic.
@@ -73,16 +70,35 @@ module Samagotchi
73
70
  # float never raises RangeError; non-numeric values are skipped.
74
71
  def first_positive(*values)
75
72
  values.each do |value|
76
- next if value.nil?
77
-
78
- n = Float(value).to_i
79
- return n if n.positive?
80
- rescue ArgumentError, TypeError
81
- next
73
+ n = float(value)&.to_i
74
+ return n if n&.positive?
82
75
  end
83
76
 
84
77
  nil
85
78
  end
79
+
80
+ private
81
+
82
+ def hash_at(hash, key)
83
+ value = hash[key.to_s] || hash[key]
84
+ value.is_a?(Hash) ? value : {}
85
+ end
86
+
87
+ def both(hash, key) = [hash[key.to_s], hash[key]]
88
+
89
+ def positive_number(*values) = values.filter_map { |value| float(value) }.find(&:positive?)
90
+
91
+ # A cost of 0 is a real answer (a free model), so zero counts here.
92
+ def number(*values) = values.filter_map { |value| float(value) }.find { |n| n >= 0 }
93
+
94
+ def float(value)
95
+ return nil if value.nil?
96
+
97
+ n = Float(value)
98
+ n.finite? ? n : nil
99
+ rescue ArgumentError, TypeError
100
+ nil
101
+ end
86
102
  end
87
103
  end
88
104
  end
@@ -44,6 +44,7 @@ module Samagotchi
44
44
  def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
45
45
  params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
46
46
  # The gate runs first, so tool_call_started shows the call that runs.
47
+ original = call.dup
47
48
  verdict = evaluate(call, iteration, params)
48
49
  call = verdict.call
49
50
  params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
@@ -73,6 +74,11 @@ module Samagotchi
73
74
  result, images = attach_images(call, result) if result[:images]
74
75
 
75
76
  output = scrub(result[:output].to_s)
77
+ # The gate may have replaced the call (known-names corrects a near
78
+ # miss): one leading line tells the model what actually ran.
79
+ if (changed = changed_call_line(original, call))
80
+ output = "#{changed}\n#{output}"
81
+ end
76
82
  capped = output
77
83
  truncated = false
78
84
  if max_tool_output_chars && output.length > max_tool_output_chars
@@ -84,14 +90,16 @@ module Samagotchi
84
90
  # full output: ok, error, blocked, stopped); a dispatcher that raised
85
91
  # left none, an error.
86
92
  status = result[:activity].is_a?(Hash) ? result[:activity][:status] : nil
87
- fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
88
- status: status || "error" })
89
93
  completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
90
94
  tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
91
95
  completed[:images] = images if images&.any?
92
96
  completed[:diff] = diff if diff
93
97
  completed[:waited_ms] = waited_ms if waited_ms
94
98
  emit(on_stream_event, completed)
99
+ # After the completed event: a hook's card (check-in, skills) prints
100
+ # under the call's tool row, not before it.
101
+ fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
102
+ status: status || "error" })
95
103
 
96
104
  run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
97
105
  run[:images] = images if images&.any?
@@ -203,6 +211,17 @@ module Samagotchi
203
211
  # A plugin tool's label, what the UIs show for its raw name.
204
212
  def plugin_label(name) = ToolActivity.plugin_label(name, registry: tools)
205
213
 
214
+ # The one leading line the model gets when the gate changed the call it
215
+ # asked for: "ran as: <tool> <changed args>", or nil when nothing
216
+ # changed. The args are the changed call's activity params, so the line
217
+ # stays short.
218
+ def changed_call_line(original, call)
219
+ return nil if original == call
220
+
221
+ params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
222
+ ["ran as: #{call[:name]}", params].compact.join(" ")
223
+ end
224
+
206
225
  # The Engine sets the kernel's gate (its context, later the approval
207
226
  # flow); a bare kernel (specs) gets one that only runs the hooks.
208
227
  def gate
@@ -199,22 +199,23 @@ module Samagotchi
199
199
  # thread; the text is safe to read afterwards.
200
200
  def finish
201
201
  @thread.join(STOP_POLL_INTERVAL_SEC) unless done?
202
- Execute.send(:close_quietly, @io)
202
+ close_quietly
203
203
  @thread.kill unless @thread.join(STOP_GRACE_SEC)
204
204
  end
205
205
 
206
206
  def text
207
207
  @buffer.dup.force_encoding(@io.external_encoding || Encoding.default_external)
208
208
  end
209
- end
210
- private_constant :OutputReader
211
209
 
212
- def self.close_quietly(io)
213
- io.close unless io.closed?
214
- rescue IOError
215
- nil
210
+ private
211
+
212
+ def close_quietly
213
+ @io.close unless @io.closed?
214
+ rescue IOError
215
+ nil
216
+ end
216
217
  end
217
- private_class_method :close_quietly
218
+ private_constant :OutputReader
218
219
 
219
220
  def self.terminate_process_tree(pid)
220
221
  signal_process(pid, "TERM")