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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/docs/architecture.md +9 -1
  4. data/docs/cli.md +9 -1
  5. data/docs/configuration.md +4 -0
  6. data/docs/guardrails.md +26 -3
  7. data/docs/plugins.md +9 -3
  8. data/docs/sessions.md +9 -1
  9. data/docs/sub-agent.md +19 -8
  10. data/docs/testing.md +19 -0
  11. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  12. data/lib/samagotchi/bootstrap_command.rb +1 -1
  13. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
  14. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  15. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/cli/parent_wait.rb +9 -3
  19. data/lib/samagotchi/engine.rb +31 -5
  20. data/lib/samagotchi/guardrails/data_readers.rb +214 -0
  21. data/lib/samagotchi/guardrails/rm_targets.rb +4 -2
  22. data/lib/samagotchi/guardrails/rules.rb +4 -2
  23. data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
  24. data/lib/samagotchi/guardrails/shell_paths.rb +5 -3
  25. data/lib/samagotchi/guardrails/targets.rb +14 -0
  26. data/lib/samagotchi/host_config.rb +8 -8
  27. data/lib/samagotchi/idle_client.rb +36 -11
  28. data/lib/samagotchi/idle_recap.rb +6 -1
  29. data/lib/samagotchi/idle_target.rb +12 -4
  30. data/lib/samagotchi/llm/http.rb +7 -2
  31. data/lib/samagotchi/llm/usage.rb +9 -3
  32. data/lib/samagotchi/model_price.rb +16 -0
  33. data/lib/samagotchi/parent_report.rb +7 -3
  34. data/lib/samagotchi/plugin/context.rb +10 -3
  35. data/lib/samagotchi/plugin/service.rb +24 -1
  36. data/lib/samagotchi/recap_setup.rb +2 -1
  37. data/lib/samagotchi/reply_wait.rb +68 -2
  38. data/lib/samagotchi/send_command.rb +47 -8
  39. data/lib/samagotchi/session_metrics/side_totals.rb +117 -0
  40. data/lib/samagotchi/session_metrics.rb +49 -12
  41. data/lib/samagotchi/session_observer.rb +3 -0
  42. data/lib/samagotchi/side_usage.rb +38 -0
  43. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  44. data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
  45. data/lib/samagotchi/testing/boundary.rb +15 -0
  46. data/lib/samagotchi/testing/bundle.rb +35 -9
  47. data/lib/samagotchi/testing/host.rb +2 -2
  48. data/lib/samagotchi/testing/recorder.rb +7 -4
  49. data/lib/samagotchi/testing/sandbox.rb +5 -3
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/app.rb +4 -1
  52. data/lib/samagotchi/web/public/app.js +3 -1
  53. data/lib/samagotchi/web/public/ctx.js +32 -6
  54. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  55. data/lib/samagotchi/web/public/lists_view.js +1 -1
  56. data/lib/samagotchi/web/public/question_cards.js +10 -5
  57. data/lib/samagotchi/web/public/stream_handlers.js +7 -0
  58. data/lib/samagotchi/web/public/timing.js +6 -1
  59. data/lib/samagotchi/web/session_summary.rb +2 -0
  60. 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
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?)
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: Session.session_dir(id, state_dir: @state_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, turn: turn)
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
- def tokens = to_h.except(:ctx_pct, :memory_index)
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
- if turn.gen_price && (turn.gen_cost.nil? || turn.gen_cost.zero?)
795
- turn.cost_estimate_sum += turn.gen_price.cost(prompt_tokens: turn.gen_prompt_max, cached_tokens: turn.gen_cached_max,
796
- cache_write_tokens: turn.gen_cache_write_max, completion_tokens: completion)
797
- end
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
- # The four public model_segment_* labels are the words the web's ctx
10
- # tooltip shares (ctx.js; spec/shared/labels_matrix.json model_segments).
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
- def initialize(failure)
31
- @failure = failure
32
- error = failure.error
33
- super("#{failure.hook} raised on #{failure.event}: #{error.class}: #{error.message}")
34
- set_backtrace(error.backtrace) if error.backtrace
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 = Hooks::BundleLoader.namespace_for(@name).const_set(:"KitHooks#{object_id}", Module.new)
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 (first = failures.first)
335
- raise HookFailed, first
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
- ModelRequest = Data.define(:request, :timeout, :max_tokens, :cancel, :model)
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