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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -1
- data/docs/architecture.md +6 -0
- data/docs/cli.md +14 -3
- data/docs/configuration.md +4 -1
- data/docs/guardrails.md +92 -14
- data/docs/plugins.md +5 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bundle_command.rb +22 -4
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +16 -2
- data/lib/samagotchi/guardrail_wiring.rb +2 -1
- data/lib/samagotchi/guardrails/approval.rb +4 -3
- data/lib/samagotchi/guardrails/approvals.rb +11 -5
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +1 -5
- data/lib/samagotchi/memory_bundle/installer.rb +46 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +12 -4
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_metrics.rb +224 -19
- data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -0
- data/lib/samagotchi/web/public/app.js +34 -15
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/format.js +11 -0
- data/lib/samagotchi/web/public/index.html +26 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/session_summary.rb +6 -2
- 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
|
-
#
|
|
24
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
5
|
-
#
|
|
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`.
|
|
10
|
-
#
|
|
9
|
+
# `tokens_evaluated` / `tokens_predicted`. OpenAI-compatible servers emit a
|
|
10
|
+
# `usage` object with `prompt_tokens` / `completion_tokens`.
|
|
11
11
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
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 [
|
|
28
|
+
# @return [TokenUsage, nil]
|
|
21
29
|
def from_payload(payload)
|
|
22
30
|
payload_hash = payload.is_a?(Hash) ? payload : {}
|
|
23
|
-
usage = payload_hash
|
|
24
|
-
|
|
31
|
+
usage = hash_at(payload_hash, :usage)
|
|
32
|
+
timings = hash_at(payload_hash, :timings)
|
|
25
33
|
|
|
26
34
|
prompt_tokens = first_positive(
|
|
27
|
-
usage
|
|
28
|
-
|
|
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
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def close_quietly
|
|
213
|
+
@io.close unless @io.closed?
|
|
214
|
+
rescue IOError
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
216
217
|
end
|
|
217
|
-
|
|
218
|
+
private_constant :OutputReader
|
|
218
219
|
|
|
219
220
|
def self.terminate_process_tree(pid)
|
|
220
221
|
signal_process(pid, "TERM")
|