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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +167 -1
- data/README.md +4 -3
- data/docs/architecture.md +21 -2
- data/docs/cli.md +48 -11
- data/docs/configuration.md +12 -5
- data/docs/guardrails.md +92 -14
- data/docs/memory.md +4 -0
- data/docs/plugins.md +5 -0
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +24 -5
- 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/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- 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 +25 -5
- data/lib/samagotchi/guardrail_wiring.rb +10 -3
- data/lib/samagotchi/guardrails/approval.rb +14 -7
- data/lib/samagotchi/guardrails/approvals.rb +69 -15
- 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/host_registry.rb +22 -4
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +25 -6
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +79 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +17 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +4 -0
- data/lib/samagotchi/relay_desk.rb +9 -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 +166 -11
- data/lib/samagotchi/session_metrics.rb +260 -32
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +83 -11
- data/lib/samagotchi/web/public/app.js +135 -43
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +15 -2
- data/lib/samagotchi/web/public/index.html +37 -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 +14 -1
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +108 -4
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +11 -2
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- 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
|
-
#
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|