samagotchi 0.18.1 → 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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -1
  3. data/README.md +4 -3
  4. data/docs/architecture.md +21 -2
  5. data/docs/cli.md +48 -11
  6. data/docs/configuration.md +12 -5
  7. data/docs/guardrails.md +92 -14
  8. data/docs/memory.md +4 -0
  9. data/docs/plugins.md +5 -0
  10. data/docs/sessions.md +7 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  12. data/lib/samagotchi/bootstrap_command.rb +26 -5
  13. data/lib/samagotchi/bridge.rb +25 -9
  14. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  15. data/lib/samagotchi/bridge_client.rb +9 -5
  16. data/lib/samagotchi/bundle_command.rb +24 -5
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  21. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  27. data/lib/samagotchi/commands/registry.rb +4 -3
  28. data/lib/samagotchi/config.rb +3 -0
  29. data/lib/samagotchi/context_usage.rb +3 -3
  30. data/lib/samagotchi/engine.rb +25 -5
  31. data/lib/samagotchi/guardrail_wiring.rb +10 -3
  32. data/lib/samagotchi/guardrails/approval.rb +14 -7
  33. data/lib/samagotchi/guardrails/approvals.rb +69 -15
  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/host_registry.rb +22 -4
  46. data/lib/samagotchi/idle_recap.rb +7 -2
  47. data/lib/samagotchi/idle_scheduler.rb +1 -1
  48. data/lib/samagotchi/installed_versions.rb +91 -0
  49. data/lib/samagotchi/live_versions.rb +5 -3
  50. data/lib/samagotchi/llm/usage.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/builder.rb +25 -6
  52. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  53. data/lib/samagotchi/memory_bundle/installer.rb +79 -13
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  55. data/lib/samagotchi/memory_bundle/status.rb +17 -5
  56. data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
  57. data/lib/samagotchi/memory_paths.rb +6 -0
  58. data/lib/samagotchi/model_overlay.rb +39 -0
  59. data/lib/samagotchi/model_profile.rb +4 -0
  60. data/lib/samagotchi/relay_desk.rb +9 -0
  61. data/lib/samagotchi/self_report.rb +38 -2
  62. data/lib/samagotchi/session.rb +35 -6
  63. data/lib/samagotchi/session_commands.rb +13 -2
  64. data/lib/samagotchi/session_manager.rb +166 -11
  65. data/lib/samagotchi/session_metrics.rb +260 -32
  66. data/lib/samagotchi/sessions_command.rb +43 -4
  67. data/lib/samagotchi/system_prompt.rb +10 -1
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
  69. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  70. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  71. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  72. data/lib/samagotchi/token_usage.rb +57 -41
  73. data/lib/samagotchi/tool_runner.rb +21 -2
  74. data/lib/samagotchi/tools/builtins.rb +21 -5
  75. data/lib/samagotchi/tools/execute.rb +9 -8
  76. data/lib/samagotchi/turn_note.rb +3 -2
  77. data/lib/samagotchi/update_command.rb +33 -12
  78. data/lib/samagotchi/version.rb +1 -1
  79. data/lib/samagotchi/web/app.rb +83 -11
  80. data/lib/samagotchi/web/public/app.js +135 -43
  81. data/lib/samagotchi/web/public/card.js +34 -2
  82. data/lib/samagotchi/web/public/ctx.js +59 -0
  83. data/lib/samagotchi/web/public/data.js +6 -0
  84. data/lib/samagotchi/web/public/format.js +15 -2
  85. data/lib/samagotchi/web/public/index.html +37 -2
  86. data/lib/samagotchi/web/public/model_pick.js +6 -2
  87. data/lib/samagotchi/web/public/model_picker.js +2 -1
  88. data/lib/samagotchi/web/public/question_card.js +14 -1
  89. data/lib/samagotchi/web/public/question_cards.js +17 -11
  90. data/lib/samagotchi/web/public/scope.js +15 -6
  91. data/lib/samagotchi/web/public/session_state.js +4 -0
  92. data/lib/samagotchi/web/public/timing.js +6 -2
  93. data/lib/samagotchi/web/public/turn_events.js +108 -4
  94. data/lib/samagotchi/web/public/update_notice.js +87 -14
  95. data/lib/samagotchi/web/server.rb +28 -1
  96. data/lib/samagotchi/web/session_hub.rb +30 -3
  97. data/lib/samagotchi/web/session_summary.rb +11 -2
  98. data/lib/samagotchi/worker.rb +25 -8
  99. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  100. data/lib/samagotchi/worker_sidecar.rb +7 -5
  101. metadata +7 -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,
@@ -296,13 +366,38 @@ module Samagotchi
296
366
  retries: totals[:retries] + (turn&.retries || 0),
297
367
  started_at: @started_at,
298
368
  last_activity_at: @last_activity_at,
299
- session_duration_ms: elapsed_ms(@session_started_monotonic),
369
+ # From the session's first start (an earlier process's too), to now.
370
+ session_duration_ms: ms_since(@started_at),
300
371
  **records_view(records_mode),
301
372
  active_turn: active_turn_snapshot,
302
373
  active_tools: active_tool_snapshots
303
374
  }
304
375
  end
305
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
+
306
401
  # The records a snapshot carries, copied. :all is every record; :recent
307
402
  # the newest turn this collector finished (live before its save lands)
308
403
  # and the unsaved ones (capped), and the tool records of those turns and
@@ -327,7 +422,6 @@ module Samagotchi
327
422
  load_persisted
328
423
  end
329
424
  @started_at ||= now.iso8601(3)
330
- @session_started_monotonic ||= monotonic_time
331
425
  @turn = TurnState.new(
332
426
  session_id: @session_id,
333
427
  iteration_count: 0,
@@ -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,
@@ -364,6 +465,10 @@ module Samagotchi
364
465
  # A turn that failed mid-stream never saw its generation end:
365
466
  # what it got to still counts.
366
467
  close_generation if @turn.gen_open
468
+ # A call still running when the turn ends (a cancel, a failure)
469
+ # never completes: its record says so, as the count already has it.
470
+ @turn.tool_calls_by_id.each_value { |active| finish_tool_record(active, "canceled") }
471
+ @turn.tool_calls_by_id.clear
367
472
  finished_at = now
368
473
  record = {
369
474
  id: @turn.id,
@@ -403,14 +508,17 @@ module Samagotchi
403
508
  # instead (there is no prompt count then). The two paths are mutually
404
509
  # exclusive per generation, so a final chunk carrying timings while
405
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
406
512
  def close_generation
407
513
  turn = @turn
408
514
  turn.gen_open = false
515
+ speed = nil
409
516
  if turn.gen_had_server
410
517
  completion = turn.gen_completion_max
411
518
  turn.prompt_last = turn.gen_prompt_max
412
519
  turn.prompt_sum += turn.gen_prompt_max
413
520
  turn.token_sources |= ["server"]
521
+ speed = close_server_report(turn, completion)
414
522
  else
415
523
  completion = turn.gen_estimate_sum
416
524
  turn.token_sources |= ["estimate"] if completion.positive?
@@ -418,11 +526,51 @@ module Samagotchi
418
526
  turn.completion_last = completion
419
527
  turn.completion_sum += completion
420
528
  started = turn.gen_started_at
421
- return unless started
529
+ return speed unless started
422
530
 
423
531
  elapsed_ms = (monotonic_time - started) * 1000.0
424
532
  turn.gen_latency_accum += elapsed_ms if elapsed_ms > 0
425
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]
426
574
  end
427
575
 
428
576
  # Caller holds the mutex.
@@ -431,6 +579,11 @@ module Samagotchi
431
579
  @turn.gen_prompt_max = 0
432
580
  @turn.gen_had_server = false
433
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
434
587
  end
435
588
 
436
589
  # The turn record's model and token fields. The context used at the end
@@ -451,7 +604,24 @@ module Samagotchi
451
604
  tool_errors: turn.tool_errors,
452
605
  iterations: turn.iteration_count,
453
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
454
622
  }
623
+ fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
624
+ fields
455
625
  end
456
626
 
457
627
  def record_tool_call_start(event)
@@ -486,20 +656,24 @@ module Samagotchi
486
656
  active = @turn.tool_calls_by_id.delete(key)
487
657
  return unless active
488
658
 
489
- finished_at = now
490
- record = active.slice(
491
- :id, :turn_id, :iteration, :call_index, :tool, :started_at
492
- ).merge(
493
- status: status.empty? ? "ok" : status,
494
- completed_at: finished_at.iso8601(3),
495
- # Less a guardrail approval wait (ToolRunner's waited_ms).
496
- duration_ms: [elapsed_ms(active[:started_monotonic]) - event[:waited_ms].to_i, 0].max
497
- )
498
- @tool_records << record
499
- count_tool(record)
659
+ finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
500
660
  end
501
661
  end
502
662
 
663
+ # Caller holds the mutex.
664
+ def finish_tool_record(active, status, waited_ms: 0)
665
+ record = active.slice(
666
+ :id, :turn_id, :iteration, :call_index, :tool, :started_at
667
+ ).merge(
668
+ status: status,
669
+ completed_at: now.iso8601(3),
670
+ # Less a guardrail approval wait (ToolRunner's waited_ms).
671
+ duration_ms: [elapsed_ms(active[:started_monotonic]) - waited_ms, 0].max
672
+ )
673
+ @tool_records << record
674
+ count_tool(record)
675
+ end
676
+
503
677
  # Accumulate token counts from a streamed generation_chunk payload.
504
678
  # Server-first: prefer real timings/usage. Prompt and completion tokens
505
679
  # are tracked as a per-generation MAX (the server's counts are cumulative
@@ -511,15 +685,10 @@ module Samagotchi
511
685
  payload = event[:payload]
512
686
  payload = {} unless payload.is_a?(Hash)
513
687
 
688
+ @mutex.synchronize { note_first_chunk(payload, event) }
514
689
  usage = TokenUsage.from_payload(payload)
515
690
  if usage
516
- @mutex.synchronize do
517
- if @turn
518
- @turn.gen_prompt_max = [@turn.gen_prompt_max, usage[:prompt_tokens].to_i].max
519
- @turn.gen_completion_max = [@turn.gen_completion_max, usage[:completion_tokens].to_i].max
520
- @turn.gen_had_server = true
521
- end
522
- end
691
+ @mutex.synchronize { note_server_usage(usage) if @turn }
523
692
  else
524
693
  @mutex.synchronize do
525
694
  chars = event_content_chars(payload, event)
@@ -531,6 +700,37 @@ module Samagotchi
531
700
  end
532
701
  end
533
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
+
534
734
  def event_content_chars(payload, event)
535
735
  len = payload_content(payload, event).length
536
736
  len.positive? ? len : nil
@@ -558,6 +758,15 @@ module Samagotchi
558
758
  [((monotonic_time - started) * 1000.0).round, 0].max
559
759
  end
560
760
 
761
+ # Wall-clock ms from an ISO 8601 stamp to now (0 without one).
762
+ def ms_since(timestamp)
763
+ return 0 unless timestamp
764
+
765
+ [((now - Time.iso8601(timestamp.to_s)) * 1000.0).round, 0].max
766
+ rescue ArgumentError
767
+ 0
768
+ end
769
+
561
770
  def tool_key(iteration, call_index)
562
771
  "#{iteration}:#{call_index}"
563
772
  end
@@ -609,6 +818,12 @@ module Samagotchi
609
818
  @tool_records.each { |record| count_tool(record) }
610
819
  @started_at = earliest_timestamp(prior["started_at"], @started_at)
611
820
  @last_activity_at ||= prior["last_activity_at"]
821
+ # The served model last reported, with the name asked for then (the
822
+ # Engine shows it only while that name is still the one asked for).
823
+ if @served_model.nil? && prior["served_model"]
824
+ @served_model = prior["served_model"]
825
+ @served_model_for = prior["served_model_for"]
826
+ end
612
827
  # The window last seen, until this process's first generation reports.
613
828
  window = prior["context"].is_a?(Hash) ? prior["context"] : {}
614
829
  if window["window_tokens"] && @context_window_tokens.nil?
@@ -640,7 +855,9 @@ module Samagotchi
640
855
  # (#snapshot adds the running turn). Caller holds the mutex.
641
856
  def reset_totals
642
857
  @totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
643
- 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 }
644
861
  end
645
862
 
646
863
  def count_turn(record)
@@ -651,6 +868,17 @@ module Samagotchi
651
868
  totals[:iterations] += number(record[:iterations])
652
869
  totals[:gen_ms] += number(record[:gen_ms])
653
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)
654
882
  source = record[:token_source]
655
883
  totals[:token_sources] |= [source.to_s] unless source.nil?
656
884
  end
@@ -13,7 +13,7 @@ require_relative "cli/command"
13
13
  require_relative "cli/flags"
14
14
 
15
15
  module Samagotchi
16
- # `chi sessions`: list, stop, archive/unarchive, delete, prune and clean
16
+ # `chi sessions`: list, stop, restart, archive/unarchive, delete, prune and clean
17
17
  # sessions from the shell (bin/chi dispatches here before OptionParser;
18
18
  # the subcommands have their own flags). Not SessionCommands, the REPL's
19
19
  # slash commands for sessions (session_commands.rb).
@@ -21,7 +21,7 @@ module Samagotchi
21
21
  include CLI::Command
22
22
 
23
23
  USAGE = <<~TEXT
24
- Usage: chi sessions <list|stop|archive|unarchive|delete|prune|clean> [options]
24
+ Usage: chi sessions <list|stop|restart|archive|unarchive|delete|prune|clean> [options]
25
25
  list [--sort updated_at|created_at] [--order desc|asc] [--limit N]
26
26
  [--live] [--cwd PATH] [--format text|json|tsv] [--archived]
27
27
  --live: sessions a worker runs now (the ones chi note reaches), 10 unless --limit
@@ -29,6 +29,7 @@ module Samagotchi
29
29
  [--scope=all]: every project's sessions; by default only this git project's (all outside a repo)
30
30
  --archived: archived sessions too (marked [archived]; json: archived: true)
31
31
  stop ID... # stop each session's worker (IDs or unique prefixes); chi --resume ID then starts a fresh one
32
+ restart ID... # hand each session to a new worker on the newest chi installed; refused while a turn, question or reminder would be lost
32
33
  archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back
33
34
  delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
34
35
  prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
@@ -37,7 +38,8 @@ module Samagotchi
37
38
  TEXT
38
39
 
39
40
  STOP_USAGE = "Usage: chi sessions stop ID...\n"
40
- SUBCOMMANDS = %w[list stop archive unarchive delete prune clean].freeze
41
+ RESTART_USAGE = "Usage: chi sessions restart ID...\n"
42
+ SUBCOMMANDS = %w[list stop restart archive unarchive delete prune clean].freeze
41
43
 
42
44
  # Each subcommand's flags ("--flag V" or "--flag=V" for a value): an
43
45
  # unknown flag, a value flag with nothing after it or an argument where
@@ -50,6 +52,7 @@ module Samagotchi
50
52
  f.switch "--archived", key: :include_archived
51
53
  end,
52
54
  "stop" => CLI::Flags.new,
55
+ "restart" => CLI::Flags.new,
53
56
  "prune" => CLI::Flags.new(args: false) do |f|
54
57
  %w[--days --keep --keep-status].each { |name| f.value name }
55
58
  f.switch "--dry-run"
@@ -94,6 +97,7 @@ module Samagotchi
94
97
  case @sub
95
98
  when "list" then list
96
99
  when "stop" then stop(parsed.args)
100
+ when "restart" then restart(parsed.args)
97
101
  else prune(@sub)
98
102
  end
99
103
  end
@@ -101,7 +105,7 @@ module Samagotchi
101
105
  private
102
106
 
103
107
  def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi sessions #{@sub}" : "chi sessions"
104
- def usage_text = @sub == "stop" ? STOP_USAGE : USAGE
108
+ def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE }.fetch(@sub, USAGE)
105
109
 
106
110
  # quirk: a number that isn't one is 0 (--days=abc turns the age limit off)
107
111
  def integers(options)
@@ -257,6 +261,41 @@ module Samagotchi
257
261
  ok.all? ? 0 : 1
258
262
  end
259
263
 
264
+ # Each session in turn: its worker hands over to a new one on the newest
265
+ # chi (SessionManager.restart_session), or says why not.
266
+ def restart(ids)
267
+ return usage_error("give session ids") if ids.empty?
268
+
269
+ ok = ids.uniq.map do |given|
270
+ begin
271
+ id = Samagotchi::Session.resolve_id(given)
272
+ result = Samagotchi::SessionManager.restart_session(id)
273
+ rescue Samagotchi::SessionManager::OwnedByTUI
274
+ @stdout.flush
275
+ @stderr.puts "session #{id} is open in a chi REPL; close it there first"
276
+ next false
277
+ rescue Samagotchi::SessionManager::RestartRefused => e
278
+ @stdout.flush
279
+ @stderr.puts e.reason == :not_running || e.reason == :unsupported ? e.message : "session #{id}: #{e.message}"
280
+ next false
281
+ rescue ArgumentError => e
282
+ @stdout.flush
283
+ @stderr.puts e.message
284
+ next false
285
+ end
286
+ @stdout.puts restarted_line(result)
287
+ true
288
+ end
289
+ ok.all? ? 0 : 1
290
+ end
291
+
292
+ def restarted_line(result)
293
+ return "Restarting session #{result.session_id}; its new worker is still starting." unless result.version
294
+
295
+ from = result.from_version && result.from_version != result.version ? " (was #{result.from_version})" : ""
296
+ "Restarted session #{result.session_id} on chi #{result.version}#{from}."
297
+ end
298
+
260
299
  def prune(sub)
261
300
  days, keep, keep_status, dry_run, test_only = @opts.values_at(:days, :keep, :keep_status, :dry_run, :test_only)
262
301
  test_only = true if sub == "clean" && !@opts[:all]
@@ -310,6 +310,15 @@ module Samagotchi
310
310
  end
311
311
  end
312
312
 
313
+ # Once per name for this prompt (the Engine's, one per process): a
314
+ # rebuild (another level, a /model switch) doesn't repeat it.
315
+ def warn_preload_failed(name, echo)
316
+ @warned_preloads ||= Set.new
317
+ return unless @warned_preloads.add?(name)
318
+
319
+ Log.warn(:memory, "preload_failed", echo: echo, memory: name)
320
+ end
321
+
313
322
  def explicit_memory_section
314
323
  return nil if @requested_memories.empty?
315
324
 
@@ -322,7 +331,7 @@ module Samagotchi
322
331
  body = Tools::MemoryRead.call(actual_name, scope: scope, **overlay_keys)
323
332
  if body.start_with?("Error:")
324
333
  source = Array(@config_memories).include?(raw) ? "memory '#{name}' (from config memories:)" : "--memory '#{name}'"
325
- Log.warn(:memory, "preload_failed", echo: "Warning: #{source} could not be loaded (#{body})", memory: name)
334
+ warn_preload_failed(name, "Warning: #{source} could not be loaded (#{body})")
326
335
  next
327
336
  end
328
337
  # Record activated names so the UI can echo them in the sticky