samagotchi 0.80.0 → 0.82.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 +57 -1
- data/docs/architecture.md +9 -1
- data/docs/cli.md +9 -1
- data/docs/configuration.md +4 -0
- data/docs/guardrails.md +26 -3
- data/docs/plugins.md +9 -3
- data/docs/sessions.md +9 -1
- data/docs/sub-agent.md +19 -8
- data/docs/testing.md +19 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +1 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +9 -3
- data/lib/samagotchi/engine.rb +31 -5
- data/lib/samagotchi/guardrails/data_readers.rb +214 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +4 -2
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
- data/lib/samagotchi/guardrails/shell_paths.rb +5 -3
- data/lib/samagotchi/guardrails/targets.rb +14 -0
- data/lib/samagotchi/host_config.rb +8 -8
- data/lib/samagotchi/idle_client.rb +36 -11
- data/lib/samagotchi/idle_recap.rb +6 -1
- data/lib/samagotchi/idle_target.rb +12 -4
- data/lib/samagotchi/llm/http.rb +7 -2
- data/lib/samagotchi/llm/usage.rb +9 -3
- data/lib/samagotchi/model_price.rb +16 -0
- data/lib/samagotchi/parent_report.rb +7 -3
- data/lib/samagotchi/plugin/context.rb +10 -3
- data/lib/samagotchi/plugin/service.rb +24 -1
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +68 -2
- data/lib/samagotchi/send_command.rb +47 -8
- data/lib/samagotchi/session_metrics/side_totals.rb +117 -0
- data/lib/samagotchi/session_metrics.rb +49 -12
- data/lib/samagotchi/session_observer.rb +3 -0
- data/lib/samagotchi/side_usage.rb +38 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
- data/lib/samagotchi/testing/boundary.rb +15 -0
- data/lib/samagotchi/testing/bundle.rb +35 -9
- data/lib/samagotchi/testing/host.rb +2 -2
- data/lib/samagotchi/testing/recorder.rb +7 -4
- data/lib/samagotchi/testing/sandbox.rb +5 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -1
- data/lib/samagotchi/web/public/app.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +32 -6
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/lists_view.js +1 -1
- data/lib/samagotchi/web/public/question_cards.js +10 -5
- data/lib/samagotchi/web/public/stream_handlers.js +7 -0
- data/lib/samagotchi/web/public/timing.js +6 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- metadata +4 -1
|
@@ -43,7 +43,7 @@ module Samagotchi
|
|
|
43
43
|
chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
|
|
44
44
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
45
45
|
chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
|
|
46
|
-
chi send --wait [--timeout S] [--format json] ID
|
|
46
|
+
chi send --wait [--timeout S] [--format json] [--turn T] ID
|
|
47
47
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
48
48
|
or a running one takes it at its next step. A stopped session's
|
|
49
49
|
worker starts.
|
|
@@ -91,7 +91,10 @@ module Samagotchi
|
|
|
91
91
|
the web). With no message (no -m, nothing piped)
|
|
92
92
|
nothing is sent: it waits for the session's next
|
|
93
93
|
reply, a running turn's too (after exit 3, 4 or 130,
|
|
94
|
-
wait again this way)
|
|
94
|
+
wait again this way). To an idle session whose last
|
|
95
|
+
turn already ended, that ended turn is the answer:
|
|
96
|
+
its reply, or its end when it left none. --turn T
|
|
97
|
+
waits for that running, queued or last-ended turn
|
|
95
98
|
--cut ask the running turn to cut the generation it is
|
|
96
99
|
streaming and take the message now (a generation that
|
|
97
100
|
has streamed only thinking for steer.cut_after; else
|
|
@@ -108,10 +111,12 @@ module Samagotchi
|
|
|
108
111
|
than queueing (it would steer; restart it)
|
|
109
112
|
--timeout S (--wait) give up after S seconds, exit 4 (the turn
|
|
110
113
|
goes on); default no limit
|
|
114
|
+
--turn T (--wait, no message) wait for this turn id, from the
|
|
115
|
+
JSON's turn_id; not with -m
|
|
111
116
|
--format json
|
|
112
117
|
(--wait) one JSON object on stdout, whatever the end:
|
|
113
118
|
status answered (text), question (question,
|
|
114
|
-
answer_with), running, or failed, canceled,
|
|
119
|
+
answer_with), turn_id when known, running, or failed, canceled,
|
|
115
120
|
limit, no_answer, error, worker_gone, stopped,
|
|
116
121
|
command (detail); with --cut or --queue also
|
|
117
122
|
delivery (what the worker applied: next_step, cut,
|
|
@@ -142,6 +147,7 @@ module Samagotchi
|
|
|
142
147
|
f.switch "--queue"
|
|
143
148
|
f.value "--timeout"
|
|
144
149
|
f.value "--format"
|
|
150
|
+
f.value "--turn"
|
|
145
151
|
# Starting a turn in every live session at once is too easy to do
|
|
146
152
|
# by accident.
|
|
147
153
|
f.refuse "--all", "there is no --all: name the sessions"
|
|
@@ -204,6 +210,7 @@ module Samagotchi
|
|
|
204
210
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
205
211
|
# Before the wait-only case, which would drop the images.
|
|
206
212
|
return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
|
|
213
|
+
return usage_error("--turn cannot be used with a message") if options[:turn] && prompt
|
|
207
214
|
return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
|
|
208
215
|
|
|
209
216
|
# The next link of a chain may start idle, waiting for its first message.
|
|
@@ -268,6 +275,8 @@ module Samagotchi
|
|
|
268
275
|
options[:timeout] = Float(options[:timeout], exception: false)
|
|
269
276
|
return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
|
|
270
277
|
end
|
|
278
|
+
return usage_error("--turn needs --wait") if options[:turn] && !options[:wait]
|
|
279
|
+
return usage_error("--turn is only for waiting without a message") if options[:turn] && (options[:new] || options[:queue] || options[:cut])
|
|
271
280
|
return usage_error("--wait takes one session") if options[:wait] && options[:ids].uniq.size > 1
|
|
272
281
|
return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
|
|
273
282
|
return new_options(options) if options[:new]
|
|
@@ -388,7 +397,7 @@ module Samagotchi
|
|
|
388
397
|
return report_command(session.id) if @sent_command
|
|
389
398
|
|
|
390
399
|
wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
|
|
391
|
-
timeout: options[:timeout])
|
|
400
|
+
timeout: options[:timeout], report_turn_id: @sent_enqueued_id)
|
|
392
401
|
end
|
|
393
402
|
|
|
394
403
|
# The model the new session will run, for the images check: --model
|
|
@@ -450,6 +459,9 @@ module Samagotchi
|
|
|
450
459
|
|
|
451
460
|
cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
|
|
452
461
|
baseline = baseline_of(session)
|
|
462
|
+
running_turn = if session.status == Session::STATUS_RUNNING
|
|
463
|
+
ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
|
|
464
|
+
end
|
|
453
465
|
return 1 unless deliver(id, prompt)
|
|
454
466
|
return report_command(id) if @sent_command
|
|
455
467
|
|
|
@@ -457,7 +469,15 @@ module Samagotchi
|
|
|
457
469
|
# running at the check ended before it went in (an idle worker's ack
|
|
458
470
|
# says next_step, and the file still runs as that turn).
|
|
459
471
|
turn = @queue ? @sent_enqueued_id : nil
|
|
460
|
-
|
|
472
|
+
report_turn_id = if @queue
|
|
473
|
+
turn
|
|
474
|
+
elsif session.status == Session::STATUS_RUNNING
|
|
475
|
+
running_turn
|
|
476
|
+
else
|
|
477
|
+
@sent_enqueued_id
|
|
478
|
+
end
|
|
479
|
+
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?,
|
|
480
|
+
report_turn_id: report_turn_id)
|
|
461
481
|
end
|
|
462
482
|
|
|
463
483
|
# --wait with no message: the session's next reply, sending nothing,
|
|
@@ -468,16 +488,33 @@ module Samagotchi
|
|
|
468
488
|
# waits for whatever wakes one (the web, chi send) rather than calling
|
|
469
489
|
# it gone. The turn running now, or the one whose continue offer
|
|
470
490
|
# stands, is the one waited for (ReplyWait.own_turn): its reply, not a
|
|
471
|
-
# turn's queued behind it; with none known, the next reply.
|
|
491
|
+
# turn's queued behind it; with none known, the next reply. An idle
|
|
492
|
+
# session whose last turn already ended (no question standing) went
|
|
493
|
+
# idle in the gap between waits: that turn's end is the answer — its
|
|
494
|
+
# reply, or its no-reply outcome — so the wait returns it at once
|
|
495
|
+
# (ReplyWait's ended_before:) instead of timing out "running" again.
|
|
472
496
|
# @return [Integer] the exit status
|
|
473
497
|
def run_wait_only(options)
|
|
474
498
|
id = resolve(options[:ids].first) or return 1
|
|
475
499
|
session = Session.load(id, state_dir: @state_dir)
|
|
500
|
+
session_dir = Session.session_dir(id, state_dir: @state_dir)
|
|
501
|
+
requested_turn = options[:turn]&.to_s
|
|
502
|
+
if requested_turn && !ReplyWait.known_turn?(session, session_dir: session_dir, turn: requested_turn)
|
|
503
|
+
error_line("chi send: turn #{requested_turn} is not a running, queued, or last ended turn")
|
|
504
|
+
return 1
|
|
505
|
+
end
|
|
476
506
|
live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
|
|
477
|
-
turn = ReplyWait.own_turn(session, session_dir:
|
|
507
|
+
turn = requested_turn || ReplyWait.own_turn(session, session_dir: session_dir)
|
|
508
|
+
# The gate for the settled case: idle, a turn ended (last_turn), no
|
|
509
|
+
# question standing (a continue offer waits for its answer).
|
|
510
|
+
settled = session.status == Session::STATUS_IDLE && session.last_turn && session.pending_question.nil?
|
|
511
|
+
ended_before = requested_turn ? session.last_turn&.enqueued_id == requested_turn : settled
|
|
512
|
+
report_turn_id = turn
|
|
513
|
+
report_turn_id ||= session.last_turn&.enqueued_id if settled
|
|
478
514
|
wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
|
|
479
515
|
baseline: baseline_of(session).merge(messages: nil),
|
|
480
|
-
timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil,
|
|
516
|
+
timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil,
|
|
517
|
+
turn: turn, ended_before: ended_before, report_turn_id: report_turn_id)
|
|
481
518
|
end
|
|
482
519
|
|
|
483
520
|
# The session as it was before the message went in (ReplyWait's
|
|
@@ -514,6 +551,8 @@ module Samagotchi
|
|
|
514
551
|
return false
|
|
515
552
|
end
|
|
516
553
|
|
|
554
|
+
ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
|
|
555
|
+
@sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
|
|
517
556
|
@sent_command = command_ack?(result[:ack])
|
|
518
557
|
@info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
|
|
519
558
|
true
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../model_price"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class SessionMetrics
|
|
7
|
+
# The sums over a session's side requests (SideUsage: an idle recap, a
|
|
8
|
+
# plugin's ctx.ask_model such as /btw), kept apart from its turns'
|
|
9
|
+
# Totals: the snapshot's and analytics.json's +side+ block, never in
|
|
10
|
+
# +tokens+ or +model_segments+. They have no records: the block itself
|
|
11
|
+
# is saved and loaded (#to_h, .from_saved).
|
|
12
|
+
# +cost_sum+ is nil until a request reports a cost (a reported 0
|
|
13
|
+
# included), +cost_estimate_sum+ 0 until one is estimated from a price
|
|
14
|
+
# (ModelPrice.estimate, the turns' rule). +by_kind+ is a frozen Hash
|
|
15
|
+
# kind => {requests:, cost_sum:, cost_estimate_sum:} in first-seen order.
|
|
16
|
+
class SideTotals < Data.define(:requests, :prompt_sum, :completion_sum, :cached_sum, :cache_write_sum,
|
|
17
|
+
:reasoning_sum, :cost_sum, :cost_estimate_sum, :by_kind)
|
|
18
|
+
# The counts summed as they are (the costs and by_kind apart).
|
|
19
|
+
COUNTS = %i[requests prompt_sum completion_sum cached_sum cache_write_sum reasoning_sum].freeze
|
|
20
|
+
|
|
21
|
+
def self.zero
|
|
22
|
+
new(**COUNTS.to_h { |key| [key, 0] }, cost_sum: nil, cost_estimate_sum: 0, by_kind: {}.freeze)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The block as analytics.json saved it (string keys): a non-Hash is
|
|
26
|
+
# zero totals, a missing or non-numeric count 0, a missing or null
|
|
27
|
+
# cost_sum nil.
|
|
28
|
+
# @param hash [Hash, nil]
|
|
29
|
+
# @return [SideTotals]
|
|
30
|
+
def self.from_saved(hash)
|
|
31
|
+
return zero unless hash.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
hash = hash.transform_keys(&:to_s)
|
|
34
|
+
new(
|
|
35
|
+
**COUNTS.to_h { |key| [key, count(hash[key.to_s])] },
|
|
36
|
+
cost_sum: cost(hash["cost_sum"]),
|
|
37
|
+
cost_estimate_sum: number(hash["cost_estimate_sum"]),
|
|
38
|
+
by_kind: saved_kinds(hash["by_kind"])
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.saved_kinds(hash)
|
|
43
|
+
kinds = hash.is_a?(Hash) ? hash : {}
|
|
44
|
+
kinds.each_with_object({}) do |(kind, row), rows|
|
|
45
|
+
row = row.is_a?(Hash) ? row.transform_keys(&:to_s) : {}
|
|
46
|
+
rows[kind.to_s] = { requests: count(row["requests"]), cost_sum: cost(row["cost_sum"]),
|
|
47
|
+
cost_estimate_sum: number(row["cost_estimate_sum"]) }.freeze
|
|
48
|
+
end.freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# These totals with one side request counted in.
|
|
52
|
+
# @param usage [SideUsage]
|
|
53
|
+
# @return [SideTotals]
|
|
54
|
+
def add(usage)
|
|
55
|
+
estimate = ModelPrice.estimate(usage.price, reported_cost: usage.cost, prompt_tokens: usage.prompt_tokens,
|
|
56
|
+
cached_tokens: usage.cached_tokens,
|
|
57
|
+
cache_write_tokens: usage.cache_write_tokens,
|
|
58
|
+
completion_tokens: usage.completion_tokens)
|
|
59
|
+
one = { requests: 1, cost_sum: usage.cost&.to_f, cost_estimate_sum: estimate || 0 }
|
|
60
|
+
kind = usage.kind.to_s
|
|
61
|
+
rows = by_kind.merge(kind => sum_row(by_kind[kind], one))
|
|
62
|
+
with(requests: requests + 1, prompt_sum: prompt_sum + usage.prompt_tokens.to_i,
|
|
63
|
+
completion_sum: completion_sum + usage.completion_tokens.to_i,
|
|
64
|
+
cached_sum: cached_sum + usage.cached_tokens.to_i,
|
|
65
|
+
cache_write_sum: cache_write_sum + usage.cache_write_tokens.to_i,
|
|
66
|
+
reasoning_sum: reasoning_sum + usage.reasoning_tokens.to_i,
|
|
67
|
+
cost_sum: add_cost(cost_sum, one[:cost_sum]),
|
|
68
|
+
cost_estimate_sum: cost_estimate_sum + one[:cost_estimate_sum], by_kind: rows.freeze)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# These totals with +other+'s added (a loaded block and the requests
|
|
72
|
+
# counted before the load); the kinds in first-seen order, these first.
|
|
73
|
+
# @param other [SideTotals]
|
|
74
|
+
# @return [SideTotals]
|
|
75
|
+
def merge(other)
|
|
76
|
+
rows = other.by_kind.each_with_object(by_kind.dup) { |(kind, row), all| all[kind] = sum_row(all[kind], row) }
|
|
77
|
+
with(**COUNTS.to_h { |key| [key, public_send(key) + other.public_send(key)] },
|
|
78
|
+
cost_sum: add_cost(cost_sum, other.cost_sum),
|
|
79
|
+
cost_estimate_sum: cost_estimate_sum + other.cost_estimate_sum, by_kind: rows.freeze)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def empty? = requests.zero?
|
|
83
|
+
|
|
84
|
+
# The snapshot's side block (symbol keys; by_kind keyed by kind).
|
|
85
|
+
# @return [Hash]
|
|
86
|
+
def to_h
|
|
87
|
+
counts = COUNTS.to_h { |key| [key, public_send(key)] }
|
|
88
|
+
counts.merge(cost_sum: cost_sum, cost_estimate_sum: cost_estimate_sum, by_kind: by_kind.transform_values(&:dup))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.count(value) = value.is_a?(Numeric) ? value.to_i : 0
|
|
92
|
+
|
|
93
|
+
def self.number(value) = value.is_a?(Numeric) ? value : 0
|
|
94
|
+
|
|
95
|
+
def self.cost(value) = value.is_a?(Numeric) ? value : nil
|
|
96
|
+
|
|
97
|
+
private_class_method :count, :number, :cost, :saved_kinds
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def sum_row(row, one)
|
|
102
|
+
return one.dup.freeze unless row
|
|
103
|
+
|
|
104
|
+
{ requests: row[:requests] + one[:requests], cost_sum: add_cost(row[:cost_sum], one[:cost_sum]),
|
|
105
|
+
cost_estimate_sum: row[:cost_estimate_sum] + one[:cost_estimate_sum] }.freeze
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# nil + nil stays nil (no cost reported yet); a reported one sums.
|
|
109
|
+
def add_cost(left, right)
|
|
110
|
+
return left if right.nil?
|
|
111
|
+
return right if left.nil?
|
|
112
|
+
|
|
113
|
+
left + right
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -12,6 +12,8 @@ require_relative "model_price"
|
|
|
12
12
|
require_relative "session"
|
|
13
13
|
require_relative "session_metrics/totals"
|
|
14
14
|
require_relative "session_metrics/model_segment"
|
|
15
|
+
require_relative "session_metrics/side_totals"
|
|
16
|
+
require_relative "side_usage"
|
|
15
17
|
|
|
16
18
|
module Samagotchi
|
|
17
19
|
# SessionMetrics is a persistent, error-isolated collector of per-session
|
|
@@ -65,11 +67,14 @@ module Samagotchi
|
|
|
65
67
|
# nil when an older file has none.
|
|
66
68
|
# cost_estimate_sum: the estimated cost (hosts.<name>.models prices), 0
|
|
67
69
|
# in an older file.
|
|
70
|
+
# side: the side requests' block (SideTotals#to_h as saved, string
|
|
71
|
+
# keys), nil when the session made none (or an older file).
|
|
68
72
|
SavedSummary = Data.define(:ctx_pct, :prompt_sum, :completion_sum, :cached_sum, :reasoning_sum, :cost_sum,
|
|
69
|
-
:cost_estimate_sum, :memory_index) do
|
|
70
|
-
def initialize(memory_index: nil, cost_estimate_sum: 0, **) = super
|
|
73
|
+
:cost_estimate_sum, :memory_index, :side) do
|
|
74
|
+
def initialize(memory_index: nil, cost_estimate_sum: 0, side: nil, **) = super
|
|
71
75
|
|
|
72
|
-
|
|
76
|
+
# The turns' token totals (the side requests' are #side, apart).
|
|
77
|
+
def tokens = to_h.except(:ctx_pct, :memory_index, :side)
|
|
73
78
|
end
|
|
74
79
|
|
|
75
80
|
# Per-turn transient state, reset on each turn_started.
|
|
@@ -199,7 +204,8 @@ module Samagotchi
|
|
|
199
204
|
completion_sum: count.call("completion_sum"), cached_sum: count.call("cached_sum"),
|
|
200
205
|
reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
|
|
201
206
|
cost_estimate_sum: count.call("cost_estimate_sum"),
|
|
202
|
-
memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil
|
|
207
|
+
memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil,
|
|
208
|
+
side: data["side"].is_a?(Hash) ? data["side"] : nil)
|
|
203
209
|
rescue TypeError
|
|
204
210
|
nil
|
|
205
211
|
end
|
|
@@ -267,6 +273,10 @@ module Samagotchi
|
|
|
267
273
|
|
|
268
274
|
def initialize(clock: nil, wall_clock: nil)
|
|
269
275
|
@mutex = Monitor.new
|
|
276
|
+
# Serializes #persist (snapshot and write), so two threads' saves land
|
|
277
|
+
# in snapshot order. Taken before @mutex, never under it: the write's
|
|
278
|
+
# IO doesn't hold up the events.
|
|
279
|
+
@persist_mutex = Mutex.new
|
|
270
280
|
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
271
281
|
@wall_clock = wall_clock || -> { Time.now }
|
|
272
282
|
@session_id = nil
|
|
@@ -281,6 +291,9 @@ module Samagotchi
|
|
|
281
291
|
# and how many were loaded (a woken collector's live records start empty).
|
|
282
292
|
@persisted_turns = 0
|
|
283
293
|
@loaded_turns = 0
|
|
294
|
+
# The side requests' sums: not in reset_totals, which a load's recount
|
|
295
|
+
# of the turn records calls (side requests have no records).
|
|
296
|
+
@side = SideTotals.zero
|
|
284
297
|
reset_totals
|
|
285
298
|
end
|
|
286
299
|
|
|
@@ -338,6 +351,20 @@ module Samagotchi
|
|
|
338
351
|
end
|
|
339
352
|
end
|
|
340
353
|
|
|
354
|
+
# @return [String, nil] the session id #persist writes under
|
|
355
|
+
def session_id = @mutex.synchronize { @session_id }
|
|
356
|
+
|
|
357
|
+
# Count one side request (a recap, a plugin's ctx.ask_model) in the
|
|
358
|
+
# side block, apart from the turns' totals. Safe from any thread.
|
|
359
|
+
# @param usage [SideUsage]
|
|
360
|
+
# @return [Hash] the side block now (SideTotals#to_h)
|
|
361
|
+
def add_side(usage)
|
|
362
|
+
@mutex.synchronize do
|
|
363
|
+
@side = @side.add(usage)
|
|
364
|
+
@side.to_h
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
341
368
|
# Event sink. Safe to call from any thread; errors are isolated by the
|
|
342
369
|
# caller (SessionObserver / TerminalUI) but we still guard internally.
|
|
343
370
|
# @param event [Hash]
|
|
@@ -454,6 +481,15 @@ module Samagotchi
|
|
|
454
481
|
# turn's own end writes over it. Only the file has it.
|
|
455
482
|
# @return [Boolean] true on success
|
|
456
483
|
def persist(state_dir: nil, running_turn: false)
|
|
484
|
+
@persist_mutex.synchronize { persist_now(state_dir, running_turn) }
|
|
485
|
+
rescue StandardError
|
|
486
|
+
false
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
private
|
|
490
|
+
|
|
491
|
+
# #persist's work, under the persist mutex.
|
|
492
|
+
def persist_now(state_dir, running_turn)
|
|
457
493
|
sid, data, turn_count = @mutex.synchronize do
|
|
458
494
|
data = build_snapshot(:all).merge(active_turn: nil, active_tools: [])
|
|
459
495
|
if running_turn && @turn
|
|
@@ -471,12 +507,8 @@ module Samagotchi
|
|
|
471
507
|
AtomicFile.write(AnalyticsFile.path(dir), JSON.pretty_generate(data) + "\n")
|
|
472
508
|
@mutex.synchronize { @persisted_turns = [@persisted_turns, turn_count].max }
|
|
473
509
|
true
|
|
474
|
-
rescue StandardError
|
|
475
|
-
false
|
|
476
510
|
end
|
|
477
511
|
|
|
478
|
-
private
|
|
479
|
-
|
|
480
512
|
# Caller holds the mutex. +records+: :recent (the live snapshot) or :all
|
|
481
513
|
# (analytics.json).
|
|
482
514
|
def build_snapshot(records_mode)
|
|
@@ -502,6 +534,9 @@ module Samagotchi
|
|
|
502
534
|
# The calls the model made under an alias's name (bash run as
|
|
503
535
|
# execute), by its own spelling; only when there are any.
|
|
504
536
|
**(@tool_tallies[:aliased].empty? ? {} : { tool_calls_aliased: @tool_tallies[:aliased].dup }),
|
|
537
|
+
# The side requests (recap, ctx.ask_model), apart from +tokens+;
|
|
538
|
+
# only when there were any.
|
|
539
|
+
**(@side.empty? ? {} : { side: @side.to_h }),
|
|
505
540
|
tool_errors: @tool_tallies[:errors],
|
|
506
541
|
iterations_total: totals.iterations,
|
|
507
542
|
gen_latency_ms: totals.gen_ms.round,
|
|
@@ -791,10 +826,10 @@ module Samagotchi
|
|
|
791
826
|
turn.cost_sum = turn.cost_sum.to_f + turn.gen_cost if turn.gen_cost
|
|
792
827
|
# A reported cost wins; a reported 0 with a price is taken as no report
|
|
793
828
|
# (a gateway that doesn't bill per call); the 0 stays in cost.
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
829
|
+
estimate = ModelPrice.estimate(turn.gen_price, reported_cost: turn.gen_cost, prompt_tokens: turn.gen_prompt_max,
|
|
830
|
+
cached_tokens: turn.gen_cached_max,
|
|
831
|
+
cache_write_tokens: turn.gen_cache_write_max, completion_tokens: completion)
|
|
832
|
+
turn.cost_estimate_sum += estimate if estimate
|
|
798
833
|
turn.last_prefill_tps = turn.gen_prefill_tps.round(1) if turn.gen_prefill_tps
|
|
799
834
|
speed = generation_speed(turn, completion)
|
|
800
835
|
return unless speed
|
|
@@ -1098,6 +1133,8 @@ module Samagotchi
|
|
|
1098
1133
|
end
|
|
1099
1134
|
# The indexes the prompt last held, until this process builds its own.
|
|
1100
1135
|
@memory_index ||= symbolized(prior["memory_index"]) if prior["memory_index"].is_a?(Hash)
|
|
1136
|
+
# The saved side block, with any counted before the load after it.
|
|
1137
|
+
@side = SideTotals.from_saved(prior["side"]).merge(@side)
|
|
1101
1138
|
# The window last seen, until this process's first generation reports.
|
|
1102
1139
|
window = prior["context"].is_a?(Hash) ? prior["context"] : {}
|
|
1103
1140
|
return unless window["window_tokens"] && @context_window_tokens.nil?
|
|
@@ -22,6 +22,9 @@ module Samagotchi
|
|
|
22
22
|
# * recap_ready — an idle session-recap finished generating
|
|
23
23
|
# (`recap:` prose, `generation:` id). Purely additive: it never mutates
|
|
24
24
|
# session.messages; each UI renders it (or not) however it likes.
|
|
25
|
+
# * side_usage — a side request (the recap, a plugin's ctx.ask_model) was
|
|
26
|
+
# counted (Engine#note_side_usage): `kind:` and `side:`, the session's
|
|
27
|
+
# whole side block (SessionMetrics#add_side), in fold order.
|
|
25
28
|
class SessionObserver
|
|
26
29
|
# Handle returned by #subscribe. Holds the observer reference and carries
|
|
27
30
|
# #unsubscribe so a caller can deregister without keeping the registry.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# One side request's usage (an idle recap, a plugin's ctx.ask_model such
|
|
5
|
+
# as /btw): what IdleClient reports to its on_usage callback and
|
|
6
|
+
# SessionMetrics#add_side folds into the session's side line, apart from
|
|
7
|
+
# its turns' totals.
|
|
8
|
+
# @!attribute kind [String] "recap", or the asking bundle's name ("btw")
|
|
9
|
+
# @!attribute model [String] the target's model, for the log
|
|
10
|
+
# @!attribute prompt_tokens [Integer] 0 when not reported (and the rest)
|
|
11
|
+
# @!attribute completion_tokens [Integer]
|
|
12
|
+
# @!attribute cached_tokens [Integer]
|
|
13
|
+
# @!attribute cache_write_tokens [Integer]
|
|
14
|
+
# @!attribute reasoning_tokens [Integer]
|
|
15
|
+
# @!attribute cost [Float, nil] the provider's reported cost (OpenRouter's
|
|
16
|
+
# usage.cost), nil when not reported
|
|
17
|
+
# @!attribute price [ModelPrice, nil] the target host's price for the
|
|
18
|
+
# model (IdleTarget#price), what the cost is estimated from
|
|
19
|
+
SideUsage = Data.define(:kind, :model, :prompt_tokens, :completion_tokens, :cached_tokens, :cache_write_tokens,
|
|
20
|
+
:reasoning_tokens, :cost, :price) do
|
|
21
|
+
def initialize(kind:, model: nil, prompt_tokens: 0, completion_tokens: 0, cached_tokens: 0, cache_write_tokens: 0,
|
|
22
|
+
reasoning_tokens: 0, cost: nil, price: nil)
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A response's usage (LLM::Usage; Usage.none's zeros when the server
|
|
27
|
+
# reported no counts: still one request) as a side request's.
|
|
28
|
+
# @param usage [LLM::Usage, nil]
|
|
29
|
+
# @return [SideUsage]
|
|
30
|
+
def self.from_usage(usage, kind:, model:, price:)
|
|
31
|
+
count = ->(name) { usage.respond_to?(name) ? usage.public_send(name).to_i : 0 }
|
|
32
|
+
new(kind: kind.to_s, model: model, prompt_tokens: count.call(:prompt_tokens),
|
|
33
|
+
completion_tokens: count.call(:completion_tokens), cached_tokens: count.call(:cached_tokens),
|
|
34
|
+
cache_write_tokens: count.call(:cache_write_tokens), reasoning_tokens: count.call(:reasoning_tokens),
|
|
35
|
+
cost: usage.respond_to?(:cost) ? usage.cost : nil, price: price)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -594,6 +594,9 @@ module Samagotchi
|
|
|
594
594
|
lines << "speed: #{speed}" if speed
|
|
595
595
|
cost = stats_cost_text(tokens, price_host: snapshot[:price_host], per_model: segments.size >= 2)
|
|
596
596
|
lines << "cost: #{cost}" if cost
|
|
597
|
+
# The side requests, never in the totals above.
|
|
598
|
+
side = side_requests_text(snapshot[:side])
|
|
599
|
+
lines << "side requests: #{side} (not in the totals above)" unless side.empty?
|
|
597
600
|
lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
|
|
598
601
|
lines << "cancellations: #{snapshot[:cancellations]}"
|
|
599
602
|
lines << "retries: #{snapshot[:retries]}"
|
|
@@ -5,9 +5,11 @@ require_relative "../memory_bundle/index_size"
|
|
|
5
5
|
|
|
6
6
|
module Samagotchi
|
|
7
7
|
class TerminalUI
|
|
8
|
-
# A session's rows per model in /stats (the snapshot's model_segments)
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# A session's rows per model in /stats (the snapshot's model_segments),
|
|
9
|
+
# and its side requests' line (the snapshot's side block).
|
|
10
|
+
# The four public model_segment_* labels and side_requests_text are the
|
|
11
|
+
# words the web's ctx tooltip shares (ctx.js;
|
|
12
|
+
# spec/shared/labels_matrix.json model_segments, side_requests).
|
|
11
13
|
# Rows come symbol- or string-keyed (the attached TUI's as JSON).
|
|
12
14
|
# Included by Formatting, whose cost_text and speed_text it uses.
|
|
13
15
|
module ModelSegmentsText
|
|
@@ -34,6 +36,28 @@ module Samagotchi
|
|
|
34
36
|
.reject(&:empty?).join(" + ")
|
|
35
37
|
end
|
|
36
38
|
|
|
39
|
+
# "3 (recap 2, btw 1) · 14.2k in / 380 out · $0.0012 + ~$0.0009": the
|
|
40
|
+
# side requests (the snapshot's side block: the recap, /btw and other
|
|
41
|
+
# ctx.ask_model calls), the kinds in first-seen order (one kind: its
|
|
42
|
+
# name alone), then the model rows' tokens and cost words on the
|
|
43
|
+
# block; "" without any (the line is hidden).
|
|
44
|
+
def side_requests_text(side)
|
|
45
|
+
return "" unless side.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
count = segment_value(side, :requests).to_i
|
|
48
|
+
return "" unless count.positive?
|
|
49
|
+
|
|
50
|
+
kinds = segment_value(side, :by_kind)
|
|
51
|
+
kinds = {} unless kinds.is_a?(Hash)
|
|
52
|
+
names = if kinds.size == 1
|
|
53
|
+
kinds.keys.first.to_s
|
|
54
|
+
else
|
|
55
|
+
kinds.map { |kind, row| "#{kind} #{(row.is_a?(Hash) ? segment_value(row, :requests) : 0).to_i}" }.join(", ")
|
|
56
|
+
end
|
|
57
|
+
head = names.empty? ? count.to_s : "#{count} (#{names})"
|
|
58
|
+
[head, model_segment_tokens_text(side), model_segment_cost_text(side)].reject(&:empty?).join(" · ")
|
|
59
|
+
end
|
|
60
|
+
|
|
37
61
|
# "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
|
|
38
62
|
# row's model prefilled (its cost an estimate from the configured
|
|
39
63
|
# price, left out without one); "" for none.
|
|
@@ -9,6 +9,8 @@ module Samagotchi
|
|
|
9
9
|
# why: the APIs to design before that bundle could leave chi's repo
|
|
10
10
|
# (spec/samagotchi/testing/boundary_spec.rb keeps both exact).
|
|
11
11
|
module Boundary
|
|
12
|
+
LIB_DIR = File.expand_path("../..", __dir__)
|
|
13
|
+
|
|
12
14
|
# A name a bundle may use, and the file that defines it (a require of
|
|
13
15
|
# it is fine too). A name allows itself, its methods (".x") and what
|
|
14
16
|
# it holds ("::X"); one ending in a method or constant allows only that.
|
|
@@ -124,6 +126,8 @@ module Samagotchi
|
|
|
124
126
|
REFERENCE = /\bSamagotchi(?:::[A-Z]\w*)+(?:\.[a-z_]\w*[?!]?)?/
|
|
125
127
|
# A require of one of chi's files.
|
|
126
128
|
REQUIRE = %r{\brequire\s*\(?\s*["'](samagotchi(?:/[\w/]+)?)["']}
|
|
129
|
+
# A relative require that may leave the bundle.
|
|
130
|
+
REQUIRE_RELATIVE = /\brequire_relative\s*\(?\s*["']([^"']+)["']/
|
|
127
131
|
|
|
128
132
|
# One reference outside ALLOWED, in a file of the bundle.
|
|
129
133
|
Finding = Data.define(:reference, :file)
|
|
@@ -140,11 +144,22 @@ module Samagotchi
|
|
|
140
144
|
text = File.read(path)
|
|
141
145
|
refs = text.scan(REFERENCE).reject { |name| allowed?(name) }
|
|
142
146
|
refs += text.scan(REQUIRE).flatten.reject { |file| ALLOWED_REQUIRES.include?(file) }.map { |file| %(require "#{file}") }
|
|
147
|
+
refs += text.scan(REQUIRE_RELATIVE).flatten.filter_map do |relative|
|
|
148
|
+
target = File.expand_path(relative, File.dirname(path))
|
|
149
|
+
next unless within?(target, LIB_DIR) && !within?(target, File.expand_path(dir))
|
|
150
|
+
|
|
151
|
+
%(require_relative "#{relative}")
|
|
152
|
+
end
|
|
143
153
|
file = path.delete_prefix("#{File.expand_path(dir)}/")
|
|
144
154
|
refs.uniq.map { |reference| Finding.new(reference: reference, file: file) }
|
|
145
155
|
end
|
|
146
156
|
end
|
|
147
157
|
|
|
158
|
+
def within?(path, dir)
|
|
159
|
+
path == dir || path.start_with?("#{dir}#{File::SEPARATOR}")
|
|
160
|
+
end
|
|
161
|
+
private_class_method :within?
|
|
162
|
+
|
|
148
163
|
# Whether a bundle may use +name+ ("Samagotchi::X::Y", maybe ".method").
|
|
149
164
|
def allowed?(name)
|
|
150
165
|
ALLOWED.any? do |entry|
|
|
@@ -26,12 +26,17 @@ module Samagotchi
|
|
|
26
26
|
class HookFailed < StandardError
|
|
27
27
|
# @return [Hooks::Failure]
|
|
28
28
|
attr_reader :failure
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
# @return [Array<Hooks::Failure>]
|
|
30
|
+
attr_reader :failures
|
|
31
|
+
|
|
32
|
+
def initialize(failures)
|
|
33
|
+
@failures = failures.dup.freeze
|
|
34
|
+
@failure = @failures.first
|
|
35
|
+
super(@failures.map do |failure|
|
|
36
|
+
error = failure.error
|
|
37
|
+
"#{failure.hook} raised on #{failure.event}: #{error.class}: #{error.message}"
|
|
38
|
+
end.join("; "))
|
|
39
|
+
set_backtrace(@failure.error.backtrace) if @failure.error.backtrace
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
def cause = @failure.error
|
|
@@ -142,7 +147,7 @@ module Samagotchi
|
|
|
142
147
|
@plugin = built
|
|
143
148
|
end
|
|
144
149
|
end
|
|
145
|
-
@hooks_namespace =
|
|
150
|
+
@hooks_namespace = Module.new
|
|
146
151
|
@manifest.hooks.each do |basename, hook|
|
|
147
152
|
next if hook.event.empty?
|
|
148
153
|
|
|
@@ -168,6 +173,27 @@ module Samagotchi
|
|
|
168
173
|
payload
|
|
169
174
|
end
|
|
170
175
|
|
|
176
|
+
# Build the before_tool_call payload from the real Gate, without firing
|
|
177
|
+
# this bundle's hooks.
|
|
178
|
+
# @param call [Hash] the parsed tool call
|
|
179
|
+
# @param iteration [Integer]
|
|
180
|
+
# @param params [String] the call's one-line preview
|
|
181
|
+
# @param context [Guardrails::Context, nil]
|
|
182
|
+
# @param verdict [Verdict, nil]
|
|
183
|
+
# @return [Hash]
|
|
184
|
+
def before_tool_call_event(call, params:, iteration: 1, context: nil, verdict: nil)
|
|
185
|
+
context ||= Guardrails::Context.new(cwd: @recorder[:cwd], session_id: @recorder[:session_id],
|
|
186
|
+
interface: @recorder[:frontend])
|
|
187
|
+
dispatched = nil
|
|
188
|
+
capture = Hooks::Registry.new
|
|
189
|
+
capture.register(:before_tool_call) { |event| dispatched = event.dup }
|
|
190
|
+
gate = Guardrails::Gate.new(-> { capture }, context_lookup: -> { context }, tools_lookup: -> { tools })
|
|
191
|
+
gate.evaluate(call, iteration: iteration, params: params, verdict: verdict || Verdict.new(call: call))
|
|
192
|
+
raise ArgumentError, "guardrails did not dispatch a before_tool_call event for #{call[:name].inspect}" unless dispatched
|
|
193
|
+
|
|
194
|
+
dispatched.slice(:type, :iteration, :call, :params, :blocked, :block_reason, :guardrail, :context, :targets)
|
|
195
|
+
end
|
|
196
|
+
|
|
171
197
|
# One tool call through a real Guardrails::Gate: the before_tool_call
|
|
172
198
|
# hooks vote on a Testing::Verdict (pass +verdict+ to start from one,
|
|
173
199
|
# another voter's deny in it), with the context and targets the Gate
|
|
@@ -331,8 +357,8 @@ module Samagotchi
|
|
|
331
357
|
result = Hooks.capture_failures(failures, &)
|
|
332
358
|
if @lenient
|
|
333
359
|
@errors.concat(failures)
|
|
334
|
-
elsif
|
|
335
|
-
raise HookFailed,
|
|
360
|
+
elsif failures.any?
|
|
361
|
+
raise HookFailed, failures
|
|
336
362
|
end
|
|
337
363
|
result
|
|
338
364
|
end
|
|
@@ -31,9 +31,9 @@ module Samagotchi
|
|
|
31
31
|
},
|
|
32
32
|
cancelled: -> { recorder[:cancelled] },
|
|
33
33
|
card: ->(**card) { recorder.card(**card) },
|
|
34
|
-
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil|
|
|
34
|
+
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
|
|
35
35
|
recorder.ask_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
|
|
36
|
-
model: model)
|
|
36
|
+
model: model, source: source)
|
|
37
37
|
},
|
|
38
38
|
model_name: -> { recorder[:model_name] },
|
|
39
39
|
model_key: -> { recorder[:model_key] },
|
|
@@ -16,8 +16,11 @@ module Samagotchi
|
|
|
16
16
|
Question = Data.define(:question, :options, :header, :allow_freeform, :hook)
|
|
17
17
|
# An ask_model request: the request Context built (SideQuestion), its
|
|
18
18
|
# limits, cancel controller and the model: it named (nil: the session's;
|
|
19
|
-
# the kit doesn't check it against any hosts)
|
|
20
|
-
|
|
19
|
+
# the kit doesn't check it against any hosts), and +source+, the asking
|
|
20
|
+
# bundle's name (what chi counts its side request under).
|
|
21
|
+
ModelRequest = Data.define(:request, :timeout, :max_tokens, :cancel, :model, :source) do
|
|
22
|
+
def initialize(source: nil, **) = super
|
|
23
|
+
end
|
|
21
24
|
|
|
22
25
|
# What a bundle did at chi's edges, for a test to read: the Engine side
|
|
23
26
|
# of its Context (Testing::Host) and the hook runtime (Testing::Runtime)
|
|
@@ -104,9 +107,9 @@ module Samagotchi
|
|
|
104
107
|
# The next queued model answer (#model_answers): a String, an
|
|
105
108
|
# exception to raise, or a callable given the ModelRequest. None left:
|
|
106
109
|
# Plugin::ModelError, as an unreachable model.
|
|
107
|
-
def ask_model(request, timeout:, max_tokens:, cancel_controller:, model: nil)
|
|
110
|
+
def ask_model(request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil)
|
|
108
111
|
entry = ModelRequest.new(request: request, timeout: timeout, max_tokens: max_tokens, cancel: cancel_controller,
|
|
109
|
-
model: model)
|
|
112
|
+
model: model, source: source)
|
|
110
113
|
record(@model_requests, entry)
|
|
111
114
|
answer = @mutex.synchronize { @model_answers.empty? ? :none : @model_answers.shift }
|
|
112
115
|
raise Plugin::ModelError, "no model answer queued (bundle.model_answers << \"text\")" if answer == :none
|