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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +130 -1
- data/docs/architecture.md +8 -1
- data/docs/cli.md +16 -4
- data/docs/configuration.md +4 -1
- data/docs/guardrails.md +92 -14
- data/docs/plugins.md +27 -8
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bridge/sse_writer.rb +32 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
- data/lib/samagotchi/bridge.rb +16 -4
- data/lib/samagotchi/bridge_client.rb +33 -9
- data/lib/samagotchi/bundle_command.rb +28 -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/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
- 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 +31 -4
- data/lib/samagotchi/events.rb +1 -1
- 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/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/openai_chat.rb +19 -5
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/log_subscriber.rb +4 -2
- 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_manager.rb +26 -9
- data/lib/samagotchi/session_metrics.rb +230 -23
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
- data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
- data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
- 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 +68 -22
- 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 +33 -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/notifications.js +8 -3
- data/lib/samagotchi/web/public/notify.js +49 -7
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/timing.js +11 -5
- data/lib/samagotchi/web/public/turn_events.js +17 -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
|
|
@@ -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", ...)
|
|
360
|
-
#
|
|
361
|
-
|
|
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
|
|
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
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
433
|
-
#
|
|
434
|
-
#
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
26
|
-
|
|
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:)
|