samagotchi 0.19.0 → 0.21.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +130 -1
  3. data/docs/architecture.md +8 -1
  4. data/docs/cli.md +16 -4
  5. data/docs/configuration.md +4 -1
  6. data/docs/guardrails.md +92 -14
  7. data/docs/plugins.md +27 -8
  8. data/docs/sessions.md +3 -3
  9. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  10. data/lib/samagotchi/bootstrap_command.rb +26 -5
  11. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  13. data/lib/samagotchi/bridge.rb +16 -4
  14. data/lib/samagotchi/bridge_client.rb +33 -9
  15. data/lib/samagotchi/bundle_command.rb +28 -4
  16. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  20. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/commands/registry.rb +4 -3
  27. data/lib/samagotchi/config.rb +3 -0
  28. data/lib/samagotchi/context_usage.rb +3 -3
  29. data/lib/samagotchi/engine.rb +31 -4
  30. data/lib/samagotchi/events.rb +1 -1
  31. data/lib/samagotchi/guardrail_wiring.rb +2 -1
  32. data/lib/samagotchi/guardrails/approval.rb +4 -3
  33. data/lib/samagotchi/guardrails/approvals.rb +11 -5
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/idle_recap.rb +7 -2
  46. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  47. data/lib/samagotchi/llm/openai_chat.rb +19 -5
  48. data/lib/samagotchi/llm/usage.rb +1 -1
  49. data/lib/samagotchi/log_subscriber.rb +4 -2
  50. data/lib/samagotchi/memory_bundle/builder.rb +1 -5
  51. data/lib/samagotchi/memory_bundle/installer.rb +46 -13
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  53. data/lib/samagotchi/memory_bundle/status.rb +12 -4
  54. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
  55. data/lib/samagotchi/memory_paths.rb +6 -0
  56. data/lib/samagotchi/self_report.rb +38 -2
  57. data/lib/samagotchi/session.rb +35 -6
  58. data/lib/samagotchi/session_commands.rb +13 -2
  59. data/lib/samagotchi/session_manager.rb +26 -9
  60. data/lib/samagotchi/session_metrics.rb +230 -23
  61. data/lib/samagotchi/steer.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  63. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  64. data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
  65. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  66. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  67. data/lib/samagotchi/token_usage.rb +57 -41
  68. data/lib/samagotchi/tool_runner.rb +21 -2
  69. data/lib/samagotchi/tools/execute.rb +9 -8
  70. data/lib/samagotchi/version.rb +1 -1
  71. data/lib/samagotchi/web/app.rb +19 -0
  72. data/lib/samagotchi/web/public/app.js +68 -22
  73. data/lib/samagotchi/web/public/card.js +34 -2
  74. data/lib/samagotchi/web/public/ctx.js +59 -0
  75. data/lib/samagotchi/web/public/format.js +11 -0
  76. data/lib/samagotchi/web/public/index.html +33 -2
  77. data/lib/samagotchi/web/public/model_pick.js +6 -2
  78. data/lib/samagotchi/web/public/model_picker.js +2 -1
  79. data/lib/samagotchi/web/public/notifications.js +8 -3
  80. data/lib/samagotchi/web/public/notify.js +49 -7
  81. data/lib/samagotchi/web/public/question_card.js +2 -1
  82. data/lib/samagotchi/web/public/timing.js +11 -5
  83. data/lib/samagotchi/web/public/turn_events.js +17 -1
  84. data/lib/samagotchi/web/session_summary.rb +6 -2
  85. 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
@@ -214,7 +277,7 @@ module Samagotchi
214
277
  when :turn_completed
215
278
  end_turn(status: "completed")
216
279
  when :turn_canceled
217
- end_turn(status: "canceled", reason: event[:cancellation_reason])
280
+ end_turn(status: "canceled", reason: event[:cancellation_reason], by: event[:cancelled_by])
218
281
  when :turn_failed
219
282
  end_turn(status: "failed")
220
283
  end
@@ -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,
@@ -356,9 +457,10 @@ module Samagotchi
356
457
  end
357
458
  end
358
459
 
359
- # A canceled turn's record keeps why (+reason+: "user", "ctrl_c", ...), so
360
- # a reloaded web history can show the cancel line the live one did.
361
- def end_turn(status: "completed", reason: nil)
460
+ # A canceled turn's record keeps why (+reason+: "user", "ctrl_c", ...)
461
+ # and who stopped it (+by+: a hook's bundle), so a reloaded web history
462
+ # can show the cancel line the live one did.
463
+ def end_turn(status: "completed", reason: nil, by: nil)
362
464
  @mutex.synchronize do
363
465
  if @turn
364
466
  # A turn that failed mid-stream never saw its generation end:
@@ -377,6 +479,7 @@ module Samagotchi
377
479
  duration_ms: elapsed_ms(@turn.started_monotonic)
378
480
  }
379
481
  record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
482
+ record[:cancelled_by] = by.to_s unless by.nil? || by.to_s.empty?
380
483
  record.merge!(turn_token_fields(@turn))
381
484
  @turn_records << record
382
485
  count_turn(record)
@@ -407,14 +510,17 @@ module Samagotchi
407
510
  # instead (there is no prompt count then). The two paths are mutually
408
511
  # exclusive per generation, so a final chunk carrying timings while
409
512
  # earlier chunks did not will not double count. Caller holds the mutex.
513
+ # @return [GenerationSpeed, nil] the generation's speed, if it had one
410
514
  def close_generation
411
515
  turn = @turn
412
516
  turn.gen_open = false
517
+ speed = nil
413
518
  if turn.gen_had_server
414
519
  completion = turn.gen_completion_max
415
520
  turn.prompt_last = turn.gen_prompt_max
416
521
  turn.prompt_sum += turn.gen_prompt_max
417
522
  turn.token_sources |= ["server"]
523
+ speed = close_server_report(turn, completion)
418
524
  else
419
525
  completion = turn.gen_estimate_sum
420
526
  turn.token_sources |= ["estimate"] if completion.positive?
@@ -422,11 +528,51 @@ module Samagotchi
422
528
  turn.completion_last = completion
423
529
  turn.completion_sum += completion
424
530
  started = turn.gen_started_at
425
- return unless started
531
+ return speed unless started
426
532
 
427
533
  elapsed_ms = (monotonic_time - started) * 1000.0
428
534
  turn.gen_latency_accum += elapsed_ms if elapsed_ms > 0
429
535
  turn.gen_started_at = nil
536
+ speed
537
+ end
538
+
539
+ # The generation's cached/reasoning counts, cost and decode speed, into
540
+ # the turn. The speed is the server's (llama.cpp's predicted_per_second
541
+ # over its predicted_ms) or, without one, an estimate: the completion
542
+ # over the time from the first streamed chunk to now (that leaves out
543
+ # queueing and the prefill). An estimated chars/4 generation never gets
544
+ # here: it has no speed. Caller holds the mutex.
545
+ def close_server_report(turn, completion)
546
+ turn.cached_sum += turn.gen_cached_max
547
+ turn.reasoning_sum += turn.gen_reasoning_max
548
+ turn.cost_sum = turn.cost_sum.to_f + turn.gen_cost if turn.gen_cost
549
+ turn.last_prefill_tps = turn.gen_prefill_tps.round(1) if turn.gen_prefill_tps
550
+ speed = generation_speed(turn, completion)
551
+ return unless speed
552
+
553
+ tps, decode_ms, source = speed
554
+ turn.decode_ms_sum += decode_ms
555
+ turn.decode_tokens_sum += completion
556
+ turn.last_decode_tps = tps.round(1)
557
+ turn.tps_source = source
558
+ GenerationSpeed.new(decode_tps: turn.last_decode_tps, source: source)
559
+ end
560
+
561
+ # [tokens per second, decode ms, "server" | "estimate"], or nil for a
562
+ # generation too short (or without a first chunk) to time.
563
+ def generation_speed(turn, completion)
564
+ if turn.gen_decode_tps && turn.gen_decode_ms
565
+ decode_ms = turn.gen_decode_ms
566
+ tps = turn.gen_decode_tps
567
+ source = "server"
568
+ elsif turn.gen_first_chunk_at
569
+ decode_ms = (monotonic_time - turn.gen_first_chunk_at) * 1000.0
570
+ tps = decode_ms.positive? ? completion * 1000.0 / decode_ms : 0
571
+ source = "estimate"
572
+ end
573
+ return nil unless decode_ms && completion >= SPEED_MIN_TOKENS && decode_ms >= SPEED_MIN_MS
574
+
575
+ [tps, decode_ms, source]
430
576
  end
431
577
 
432
578
  # Caller holds the mutex.
@@ -435,6 +581,11 @@ module Samagotchi
435
581
  @turn.gen_prompt_max = 0
436
582
  @turn.gen_had_server = false
437
583
  @turn.gen_estimate_sum = 0
584
+ @turn.gen_cached_max = 0
585
+ @turn.gen_reasoning_max = 0
586
+ @turn.gen_cost = nil
587
+ @turn.gen_decode_tps = @turn.gen_prefill_tps = @turn.gen_decode_ms = nil
588
+ @turn.gen_first_chunk_at = nil
438
589
  end
439
590
 
440
591
  # The turn record's model and token fields. The context used at the end
@@ -455,7 +606,24 @@ module Samagotchi
455
606
  tool_errors: turn.tool_errors,
456
607
  iterations: turn.iteration_count,
457
608
  retries: turn.retries
609
+ }.merge(turn_usage_fields(turn))
610
+ end
611
+
612
+ # The rest of the server's report: cached and reasoning tokens, the cost,
613
+ # and the speeds (decode_tps is the turn's last generation's, decode_ms
614
+ # and decode_tokens what the session's average is weighted by).
615
+ def turn_usage_fields(turn)
616
+ fields = {
617
+ cached_tokens_sum: turn.cached_sum,
618
+ reasoning_tokens: turn.reasoning_sum,
619
+ decode_ms: turn.decode_ms_sum.round,
620
+ decode_tokens: turn.decode_tokens_sum,
621
+ decode_tps: turn.last_decode_tps,
622
+ prefill_tps: turn.last_prefill_tps,
623
+ tps_source: turn.tps_source
458
624
  }
625
+ fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
626
+ fields
459
627
  end
460
628
 
461
629
  def record_tool_call_start(event)
@@ -519,15 +687,10 @@ module Samagotchi
519
687
  payload = event[:payload]
520
688
  payload = {} unless payload.is_a?(Hash)
521
689
 
690
+ @mutex.synchronize { note_first_chunk(payload, event) }
522
691
  usage = TokenUsage.from_payload(payload)
523
692
  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
693
+ @mutex.synchronize { note_server_usage(usage) if @turn }
531
694
  else
532
695
  @mutex.synchronize do
533
696
  chars = event_content_chars(payload, event)
@@ -539,6 +702,37 @@ module Samagotchi
539
702
  end
540
703
  end
541
704
 
705
+ # Caller holds the mutex.
706
+ def note_server_usage(usage)
707
+ turn = @turn
708
+ turn.gen_prompt_max = [turn.gen_prompt_max, usage.prompt_tokens.to_i].max
709
+ turn.gen_completion_max = [turn.gen_completion_max, usage.completion_tokens.to_i].max
710
+ turn.gen_cached_max = [turn.gen_cached_max, usage.cached_tokens.to_i].max
711
+ turn.gen_reasoning_max = [turn.gen_reasoning_max, usage.reasoning_tokens.to_i].max
712
+ turn.gen_cost = usage.cost if usage.cost
713
+ if usage.predicted_per_second && usage.predicted_ms
714
+ turn.gen_decode_tps = usage.predicted_per_second
715
+ turn.gen_decode_ms = usage.predicted_ms
716
+ end
717
+ turn.gen_prefill_tps = usage.prompt_per_second if usage.prompt_per_second
718
+ turn.gen_had_server = true
719
+ end
720
+
721
+ # The first chunk with content, reasoning or a tool call starts the
722
+ # generation's decode clock (an estimated speed runs from it). Caller
723
+ # holds the mutex.
724
+ def note_first_chunk(payload, event)
725
+ return unless @turn && @turn.gen_open && @turn.gen_first_chunk_at.nil?
726
+ return unless !event[:content].to_s.empty? || tool_call_delta?(payload)
727
+
728
+ @turn.gen_first_chunk_at = monotonic_time
729
+ end
730
+
731
+ def tool_call_delta?(payload)
732
+ choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
733
+ choice.is_a?(Hash) && choice["delta"].is_a?(Hash) && !Array(choice["delta"]["tool_calls"]).empty?
734
+ end
735
+
542
736
  def event_content_chars(payload, event)
543
737
  len = payload_content(payload, event).length
544
738
  len.positive? ? len : nil
@@ -663,7 +857,9 @@ module Samagotchi
663
857
  # (#snapshot adds the running turn). Caller holds the mutex.
664
858
  def reset_totals
665
859
  @totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
666
- gen_ms: 0, retries: 0, by_tool: {}, tool_errors: 0 }
860
+ gen_ms: 0, retries: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, reasoning_sum: 0,
861
+ cost_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
862
+ last_prefill_tps: nil, tps_source: nil }
667
863
  end
668
864
 
669
865
  def count_turn(record)
@@ -674,6 +870,17 @@ module Samagotchi
674
870
  totals[:iterations] += number(record[:iterations])
675
871
  totals[:gen_ms] += number(record[:gen_ms])
676
872
  totals[:retries] += number(record[:retries])
873
+ totals[:cached_sum] += number(record[:cached_tokens_sum])
874
+ totals[:reasoning_sum] += number(record[:reasoning_tokens])
875
+ totals[:cost_sum] += number(record[:cost])
876
+ totals[:decode_ms_sum] += number(record[:decode_ms])
877
+ totals[:decode_tokens_sum] += number(record[:decode_tokens])
878
+ # The newest speeds a record has (an older record has none).
879
+ if record[:decode_tps].is_a?(Numeric)
880
+ totals[:last_decode_tps] = record[:decode_tps]
881
+ totals[:tps_source] = record[:tps_source]
882
+ end
883
+ totals[:last_prefill_tps] = record[:prefill_tps] if record[:prefill_tps].is_a?(Numeric)
677
884
  source = record[:token_source]
678
885
  totals[:token_sources] |= [source.to_s] unless source.nil?
679
886
  end
@@ -80,7 +80,7 @@ module Samagotchi
80
80
  # :pending_input_merged. After a cancel the input stays queued: it runs
81
81
  # as the next turn instead of dying with this one. +answer+ (a String,
82
82
  # or a Proc called only on a merge) is the answer the merge follows, for
83
- # the UIs; given, the drain is told it is the after-answer site (plugin
83
+ # the TUI's line (the web has it as the step's streamed text); given, the drain is told it is the after-answer site (plugin
84
84
  # steers are dropped there). A blank answer is none.
85
85
  # @return [Boolean] whether anything was injected
86
86
  def inject!(conversation, pending_input, iteration:, emit:, cancel_controller:, answer: nil)
@@ -47,6 +47,10 @@ module Samagotchi
47
47
  DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
48
48
  # Why a request the Bridge read after its deadline didn't go through.
49
49
  LATE = "worker not answering in time"
50
+ # An exit the worker agreed to in an empty session. Said before it has
51
+ # left: it checks again once it lets go, and a note or prompt arriving
52
+ # first keeps the session (SessionManager.discard_left_session).
53
+ EMPTY_EXIT = "Detached; the session is empty: it will be discarded if nothing arrives before its worker stops."
50
54
  # Why the worker stayed up after /exit, by the reason it gave.
51
55
  HELD_REASONS = {
52
56
  "turn_running" => "a turn is running", "input_queued" => "prompts are queued",
@@ -420,7 +424,9 @@ module Samagotchi
420
424
  return detach("#{line} Not deleted.")
421
425
  end
422
426
  # An empty session: the worker deletes it as it leaves.
423
- @delete_session.call(id) unless discards?(reply)
427
+ return detach(EMPTY_EXIT) if discards?(reply)
428
+
429
+ @delete_session.call(id)
424
430
  detach("Detached; deleted session #{id}.")
425
431
  rescue SystemCallError, IOError => e
426
432
  detach("#{exit_failed_line(e.message)} Not deleted.")
@@ -429,14 +435,15 @@ module Samagotchi
429
435
  end
430
436
 
431
437
  # /archive: the worker is asked to exit as for /exit, then the session
432
- # is archived. A worker staying up for another UI (or reminders, a
433
- # queued prompt) is stopped by the archive, as the web's archive
434
- # stops it; a turn running refuses it. An empty session: the worker
438
+ # is archived. A worker staying up for another UI (or reminders) is
439
+ # stopped by the archive, as the web's archive stops it; a turn
440
+ # running, a queued prompt or an open continue offer refuses it
441
+ # (SessionManager.archive_session). An empty session: the worker
435
442
  # deletes it as it leaves.
436
443
  def exit_and_archive
437
444
  reply = @client.request_exit(client_id: @client_id)
438
445
  id = @client.session_id
439
- return detach("Detached; the session was empty, so it is discarded.") if reply.status == 200 && discards?(reply)
446
+ return detach(EMPTY_EXIT) if reply.status == 200 && discards?(reply)
440
447
  return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
441
448
 
442
449
  result = @archive_session.call(id)
@@ -454,15 +461,14 @@ module Samagotchi
454
461
  error = reply.json&.fetch("error", nil)
455
462
  case reply.status
456
463
  when 200
457
- # Said as the worker agreed to leave; a note coming in before it
458
- # does keeps the session after all (rare, left as is).
459
- return "Detached; the session was empty, so it is discarded." if discards?(reply)
464
+ return EMPTY_EXIT if discards?(reply)
460
465
 
461
466
  "Detached; the session's worker is stopping. Resume with: chi --resume #{id}"
462
467
  when 409
463
468
  reason = reply.json&.fetch("reason", nil).to_s
464
469
  "Detached; the session keeps running (#{HELD_REASONS.fetch(reason, reason)}). Re-attach with: chi --attach #{id}"
465
470
  when 0 then exit_failed_line("no reply")
471
+ when 408 then exit_failed_line(LATE)
466
472
  else
467
473
  # A worker from before the route answers the Bridge's plain 404.
468
474
  return "Detached; this worker runs an older chi and can't be stopped from here: chi sessions stop #{id}" if error == "not_found"
@@ -1029,7 +1035,8 @@ module Samagotchi
1029
1035
  exchange = messages.select { |m| %w[user model assistant].include?(m[:role].to_s) }
1030
1036
  # The id stays findable: the detach line and chi sessions list show it.
1031
1037
  Log.session_id = @client.session_id
1032
- Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size)
1038
+ Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size,
1039
+ notes: messages.count { |m| ContextNote.note?(m) })
1033
1040
  last_user = exchange.rindex { |m| Steer.prompt?(m) }
1034
1041
  render_join_notes(messages)
1035
1042
  return unless last_user
@@ -1166,6 +1173,8 @@ module Samagotchi
1166
1173
  running_tool = nil
1167
1174
  Bridge::TurnAccumulator.replay_events(turn).each do |event|
1168
1175
  case event[:type]
1176
+ # A new step starts its line over, as live (a join during a hold).
1177
+ when :generation_started then tail, lane = nil, :writing
1169
1178
  when :generation_chunk
1170
1179
  tail, lane = event[:thinking].to_s.empty? ? [event[:text], :writing] : [event[:thinking], :thinking]
1171
1180
  when :tool_call_started
@@ -84,7 +84,7 @@ module Samagotchi
84
84
  render_turn_summary(event[:turn_summary]) if event[:turn_summary]
85
85
  when :turn_canceled
86
86
  @view.finish_thinking_spinner
87
- @view.print_line(@view.turn_canceled_line(event[:cancellation_reason], event[:duration_ms]))
87
+ @view.print_line(@view.turn_canceled_line(event[:cancellation_reason], event[:duration_ms], by: event[:cancelled_by]))
88
88
  @view.print_line(@view.turn_end_hint(Formatting::ROLLBACK_HINT)) unless @turn_continues
89
89
  when :turn_failed
90
90
  # A provider error's one-line summary, else the message (an image
@@ -203,8 +203,13 @@ module Samagotchi
203
203
  # started): its partial progress stays in the conversation.
204
204
  ROLLBACK_HINT = "partial progress kept; !rollback restores the pre-turn state"
205
205
 
206
- # "✕ turn canceled (Ctrl-C) · 3.1s"
207
- def turn_canceled_line(reason, duration_ms)
206
+ # "✕ turn canceled (Ctrl-C) · 3.1s"; a hook's stop that names who
207
+ # (+by+, turn_canceled's cancelled_by): "✕ turn stopped by loop-guard"
208
+ def turn_canceled_line(reason, duration_ms, by: nil)
209
+ if reason.to_s == "hook" && !by.to_s.empty?
210
+ return "#{paint("✕", 33)} turn stopped by #{by}#{turn_duration_suffix(duration_ms)}"
211
+ end
212
+
208
213
  label = reason.to_s.empty? ? nil : CANCEL_REASONS.fetch(reason.to_s, reason.to_s)
209
214
  "#{paint("✕", 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
210
215
  end
@@ -221,6 +226,26 @@ module Samagotchi
221
226
  duration_ms.nil? ? "" : " · #{format_elapsed_duration(duration_ms)}"
222
227
  end
223
228
 
229
+ # "87 tok/s", "1.9k tok/s"; "~64 tok/s" for an estimate; "" without a
230
+ # speed. The web says the same (ctx.js speedText;
231
+ # spec/shared/labels_matrix.json).
232
+ def speed_text(tps, source)
233
+ value = tps.to_f
234
+ return "" unless value.positive?
235
+
236
+ number = value >= 1000 ? "#{format('%.1f', (value / 100).round / 10.0)}k" : value.round.to_s
237
+ "#{'~' if source.to_s == 'estimate'}#{number} tok/s"
238
+ end
239
+
240
+ # "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "" for
241
+ # none. As ctx.js costText.
242
+ def cost_text(cost)
243
+ value = cost.to_f
244
+ return "" unless value.positive?
245
+
246
+ value >= 0.01 ? format("$%.2f", value) : format("$%.4f", value)
247
+ end
248
+
224
249
  private
225
250
 
226
251
  # The recap shown on return: dim, one "recap>" block, noting how many
@@ -343,7 +368,11 @@ module Samagotchi
343
368
  end
344
369
  lines << "iterations: #{snapshot[:iterations_total]}"
345
370
  # 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})"
371
+ lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
372
+ "#{token_breakdown_text(tokens)}"
373
+ speed = stats_speed_text(tokens)
374
+ lines << "speed: #{speed}" if speed
375
+ lines << "cost: #{cost_text(tokens[:cost_sum])} (this session only)" if tokens[:cost_sum].to_f.positive?
347
376
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
348
377
  lines << "cancellations: #{snapshot[:cancellations]}"
349
378
  lines << "retries: #{snapshot[:retries]}"
@@ -362,6 +391,33 @@ module Samagotchi
362
391
  lines.join("\n")
363
392
  end
364
393
 
394
+ # ", cached 4864 (93%), reasoning 212" for the tokens line; the counts a
395
+ # server didn't report are left out.
396
+ def token_breakdown_text(tokens)
397
+ cached = tokens[:cached_sum].to_i
398
+ reasoning = tokens[:reasoning_sum].to_i
399
+ prompt = tokens[:prompt_sum].to_i
400
+ text = +""
401
+ text << ", cached #{cached} (#{(cached * 100.0 / prompt).round}%)" if cached.positive? && prompt.positive?
402
+ text << ", reasoning #{reasoning}" if reasoning.positive?
403
+ text
404
+ end
405
+
406
+ # "87 tok/s out, 1.9k tok/s prompt (last, server), avg 81 tok/s", or nil
407
+ # before a generation had a speed. The prompt (prefill) speed only when
408
+ # the server reported it.
409
+ def stats_speed_text(tokens)
410
+ last = tokens[:last_decode_tps]
411
+ return nil unless last
412
+
413
+ source = tokens[:tps_source].to_s
414
+ text = +"#{speed_text(last, source)} out"
415
+ text << ", #{speed_text(tokens[:last_prefill_tps], 'server')} prompt" if tokens[:last_prefill_tps]
416
+ text << " (last, #{source})"
417
+ text << ", avg #{speed_text(tokens[:avg_decode_tps], source)}" if tokens[:avg_decode_tps]
418
+ text
419
+ end
420
+
365
421
  def format_elapsed_duration(duration_ms)
366
422
  duration_ms = duration_ms.to_f
367
423
  return "" if duration_ms.negative?
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "io/console"
3
4
  require_relative "screen"
4
5
  require_relative "reline_seam"
5
6
 
@@ -22,10 +23,48 @@ module Samagotchi
22
23
  screen
23
24
  end
24
25
 
25
- # Hand Reline its own drawing back, then close the surface.
26
- def close(surface)
26
+ # How long #drain_input waits for input still on its way.
27
+ DRAIN_WINDOW = 0.05
28
+ # How long Reline waits for the reply to its cursor query (Reline::ANSI#cursor_pos).
29
+ CURSOR_REPLY_WAIT = 0.5
30
+ CURSOR_REPLY = /\e\[\d+;\d+R/
31
+
32
+ # Hand Reline its own drawing back, close the surface, then drop the
33
+ # input nobody read (#drain_input).
34
+ def close(surface, input: $stdin)
27
35
  RelineSeam.detach(surface)
28
36
  surface.close
37
+ query_at = RelineSeam.unanswered_query_at
38
+ RelineSeam.unanswered_query_at = nil
39
+ drain_input(input, query_at: query_at) if surface.is_a?(Screen)
40
+ end
41
+
42
+ # Reline asks the terminal for the cursor row (ESC[6n) as each read
43
+ # starts; the reply to one cut short at exit, read by nobody, would
44
+ # land at the shell's prompt. What came, or comes within DRAIN_WINDOW,
45
+ # is read and dropped (keys typed in that moment too), in raw mode, so
46
+ # a reply with no newline is readable. With a query left unanswered
47
+ # (RelineSeam.unanswered_query_at), until its reply, up to as long as
48
+ # Reline would have waited for it.
49
+ def drain_input(input, query_at: nil)
50
+ return unless input.is_a?(IO) && input.tty?
51
+
52
+ now = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
53
+ deadline = now.call + DRAIN_WINDOW
54
+ deadline = [deadline, query_at + CURSOR_REPLY_WAIT].max if query_at
55
+ dropped = +""
56
+ input.raw do
57
+ while (left = deadline - now.call).positive? && input.wait_readable(left)
58
+ read = input.read_nonblock(1024, exception: false)
59
+ break if read.nil?
60
+
61
+ dropped << read if read.is_a?(String)
62
+ # The reply came: no need to wait out the rest.
63
+ deadline = [now.call + DRAIN_WINDOW, deadline].min if query_at && dropped.match?(CURSOR_REPLY)
64
+ end
65
+ end
66
+ rescue IOError, SystemCallError
67
+ nil
29
68
  end
30
69
 
31
70
  def available?(out:, input:, env:)