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
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require_relative "llm/adapters"
5
5
  require_relative "log"
6
+ require_relative "side_usage"
6
7
 
7
8
  module Samagotchi
8
9
  # Standalone summarizer for the idle session-recap feature.
@@ -54,9 +55,12 @@ module Samagotchi
54
55
  # @param api [Symbol] the wire to talk (LLM::Adapters; IdleTarget#api)
55
56
  # @param auth [Symbol, nil] the header the key goes in (IdleTarget#auth)
56
57
  # @param dialect [String, nil] a Messages host's dialect (IdleTarget#dialect)
58
+ # @param price [ModelPrice, nil] the model's price on its host
59
+ # (IdleTarget#price), on each SideUsage the on_usage callbacks get
57
60
  def initialize(model:, base_url: nil, api_key_env: nil, timeout: DEFAULT_TIMEOUT_SECONDS, env: ENV, purpose: "recap",
58
- api: :openai, auth: nil, dialect: nil)
61
+ api: :openai, auth: nil, dialect: nil, price: nil)
59
62
  @model = model
63
+ @price = price
60
64
  # A recap is best-effort: one short attempt, no retries. The idle job
61
65
  # tries again after the next activity, never on its own. Only the
62
66
  # Messages adapter takes a dialect.
@@ -70,7 +74,7 @@ module Samagotchi
70
74
  # @return [IdleClient]
71
75
  def self.for(target, **)
72
76
  new(model: target.model, base_url: target.base_url, api_key_env: target.api_key_env, api: target.api,
73
- auth: target.auth, dialect: target.dialect, **)
77
+ auth: target.auth, dialect: target.dialect, price: target.price, **)
74
78
  end
75
79
 
76
80
  THINK_RE = /<\|think\|.*?\|think\|>/m
@@ -100,11 +104,13 @@ module Samagotchi
100
104
  # @return [Summary, nil]
101
105
  # +session_id+: the session it is about, sent as the turns send it
102
106
  # (Session-Id, a dialect's affinity header), nil for none.
103
- def summarize(prompt, session_id: nil)
107
+ # +on_usage+: called with the request's SideUsage (kind "recap") once a
108
+ # reply came back, see #ask.
109
+ def summarize(prompt, session_id: nil, on_usage: nil)
104
110
  messages = prompt.is_a?(Array) ? prompt : [{ role: "user", content: prompt.to_s.strip }]
105
111
  return nil if messages.all? { |m| m[:content].to_s.strip.empty? }
106
112
 
107
- response = generate(messages, session_id: session_id)
113
+ response = generate(messages, session_id: session_id, on_usage: on_usage)
108
114
  cleaned = response.text.to_s.strip
109
115
  cleaned.empty? ? nil : Summary.new(text: cleaned, model: response.model)
110
116
  rescue SummarizeError
@@ -121,14 +127,18 @@ module Samagotchi
121
127
  # @param options [Hash] more request body fields (logprobs: true,
122
128
  # top_logprobs: 5)
123
129
  # @param session_id [String, nil] the session it is about (#summarize)
130
+ # @param on_usage [#call, nil] called with the request's SideUsage
131
+ # (+kind+, the counts, the reported cost, the client's price) once a
132
+ # reply came back, a billed one with no usable text too; never for a
133
+ # request that got no reply. Its errors are logged, never raised.
124
134
  # @return [Summary] the answer ("" when the model said nothing)
125
135
  # @raise [SummarizeError] any failure but a cancel; its #cause is an
126
136
  # LLM::BadRequest when the host refused the request (+options+ it
127
137
  # doesn't take, say)
128
138
  # @raise [LLM::RequestCancelled] +cancel_controller+ was cancelled
129
- def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {}, session_id: nil)
139
+ def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {}, session_id: nil, on_usage: nil)
130
140
  response = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
131
- kind: kind, options: options, session_id: session_id)
141
+ kind: kind, options: options, session_id: session_id, on_usage: on_usage)
132
142
  Summary.new(text: response.text.to_s.strip, model: response.model, top_logprobs: response.top_logprobs)
133
143
  rescue SummarizeError, LLM::RequestCancelled
134
144
  raise
@@ -146,7 +156,7 @@ module Samagotchi
146
156
  # +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
147
157
  # or all of it, with "…".
148
158
  def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap", options: {},
149
- session_id: nil)
159
+ session_id: nil, on_usage: nil)
150
160
  response = begin
151
161
  request(messages, max_tokens, cancel_controller, options, session_id)
152
162
  rescue LLM::BadRequest => e
@@ -156,6 +166,7 @@ module Samagotchi
156
166
  retry
157
167
  end
158
168
  log_usage(response, kind)
169
+ report_usage(on_usage, response, kind)
159
170
  content = response.text
160
171
  reasoning = response.reasoning
161
172
  raise SummarizeError, "server returned no parseable assistant content" if content.empty? && reasoning.empty?
@@ -176,14 +187,28 @@ module Samagotchi
176
187
  raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
177
188
  end
178
189
 
179
- # The request's prompt-cache counts (a side request on the session's
180
- # host can evict the session's cached prompt there).
190
+ # Log reported request usage when cache counts are present; session
191
+ # side-usage counting is handled separately by #report_usage.
181
192
  def log_usage(response, kind)
182
- fields = response.usage&.cache_fields || {}
193
+ usage = response.usage
194
+ fields = usage&.cache_fields || {}
183
195
  return if fields.empty?
184
196
 
185
197
  Log.info(:recap, "request_usage", kind: kind, model: @model, prompt: fields[:prompt_tokens],
186
- cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens])
198
+ cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens],
199
+ # A cost as written: Log rounds Floats to 3 places.
200
+ completion: usage.completion_tokens, cost: usage.cost&.to_s)
201
+ end
202
+
203
+ # The request's SideUsage to +on_usage+ (a session counts it in its side
204
+ # line); a reply without counts is one request with zero tokens.
205
+ # Counting never fails the side answer: a callback's error is logged.
206
+ def report_usage(on_usage, response, kind)
207
+ return unless on_usage
208
+
209
+ on_usage.call(SideUsage.from_usage(response.usage, kind: kind, model: @model, price: @price))
210
+ rescue StandardError => e
211
+ Log.warn(:recap, "usage_callback_failed", kind: kind, model: @model, error: "#{e.class}: #{e.message}")
187
212
  end
188
213
 
189
214
  # After a host refused this step's thinking fields: the next step, kept
@@ -288,12 +288,17 @@ module Samagotchi
288
288
  client: nil,
289
289
  store: nil,
290
290
  session_id: -> {},
291
+ on_usage: nil,
291
292
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
292
293
  raise ArgumentError, "IdleRecap requires an engine" unless engine
293
294
 
294
295
  @engine = engine
295
296
  # The recapped session's id, sent with the request (IdleClient#summarize).
296
297
  @session_id = session_id
298
+ # Called with each recap request's SideUsage (IdleClient#summarize),
299
+ # a dropped recap's too: it was billed (RecapSetup: the Engine's
300
+ # side line).
301
+ @on_usage = on_usage
297
302
  @target = target || lambda {
298
303
  IdleTarget.new(base_url: base_url, api_key_env: api_key_env, model: model, label: model)
299
304
  }
@@ -547,7 +552,7 @@ module Samagotchi
547
552
 
548
553
  def spawn_summarize(client, prompt)
549
554
  Thread.new do
550
- client.summarize(prompt, session_id: @session_id.call)
555
+ client.summarize(prompt, session_id: @session_id.call, on_usage: @on_usage)
551
556
  rescue StandardError => e
552
557
  # No recap this time (the next idle window tries again); say why.
553
558
  Log.exception(:recap, "summarize_failed", e)
@@ -14,8 +14,14 @@ module Samagotchi
14
14
  # auth:, LLM::ApiKey::SCHEMES); nil: the api's own default
15
15
  # @!attribute dialect [String, nil] an api: anthropic host's dialect:
16
16
  # (LLM::Dialect), so a side request gets its auth and fields
17
- IdleTarget = Data.define(:base_url, :api_key_env, :model, :label, :api, :auth, :dialect) do
18
- def initialize(api: :openai, auth: nil, dialect: nil, **) = super
17
+ # @!attribute price [ModelPrice, nil] the host's models: price for the
18
+ # id asked for (as EffectiveModel#price), what a side request's cost is
19
+ # estimated from; nil for none, and for a bare base_url target
20
+ IdleTarget = Data.define(:base_url, :api_key_env, :model, :label, :api, :auth, :dialect, :price) do
21
+ def initialize(api: :openai, auth: nil, dialect: nil, price: nil, **) = super
22
+
23
+ # +entry+'s models: price for +id+ (the id asked for, case-insensitively).
24
+ def self.price_of(entry, id) = entry.models&.dig(id.to_s.strip.downcase)&.price
19
25
 
20
26
  # The api a side request to +entry+ talks: a chat host's own, else the
21
27
  # OpenAI API (a raw host's llama.cpp serves /v1 too).
@@ -49,7 +55,8 @@ module Samagotchi
49
55
  resolved = host_registry.resolve(name)
50
56
  entry = resolved.entry
51
57
  new(base_url: entry.api_base_url, api_key_env: entry.api_key_env, model: resolved.bare_model,
52
- label: name.to_s.strip, api: api_of(entry), auth: entry.auth, dialect: dialect_of(entry))
58
+ label: name.to_s.strip, api: api_of(entry), auth: entry.auth, dialect: dialect_of(entry),
59
+ price: price_of(entry, resolved.bare_model))
53
60
  end
54
61
 
55
62
  # A side model's settings (recap.*, broadcast.triage_*) as a target:
@@ -74,6 +81,7 @@ module Samagotchi
74
81
  api = :openai
75
82
  auth = nil
76
83
  dialect = nil
84
+ entry = nil
77
85
  if host_ref
78
86
  entry = host_registry.find_entry(host_ref)
79
87
  raise self::Unresolved.new(:host_ref_unknown, model: model, host_ref: host_ref) unless entry
@@ -95,7 +103,7 @@ module Samagotchi
95
103
  raise self::Unresolved.new(:incomplete, model: model, host_ref: host_ref) if blank(base_url).nil? || blank(model).nil?
96
104
 
97
105
  new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip, api: api, auth: auth,
98
- dialect: dialect)
106
+ dialect: dialect, price: entry && price_of(entry, model))
99
107
  end
100
108
 
101
109
  # Whether the settings fix a host (+base_url+, +host_ref+, or a model
@@ -390,7 +390,7 @@ module Samagotchi
390
390
  end
391
391
  Log.public_send(failure_level, :http, event, **log_base(current), status: e.is_a?(ProviderError) ? e.status : nil,
392
392
  ms: elapsed_ms(current), attempts: current[:attempts], error: e.class.name,
393
- msg: (e.respond_to?(:summary) ? e.summary : e.message).to_s[0, 300])
393
+ msg: log_message(e.respond_to?(:summary) ? e.summary : e.message))
394
394
  raise
395
395
  end
396
396
 
@@ -410,13 +410,18 @@ module Samagotchi
410
410
  def retry_after(error, attempts, delay, on_retry, cancel_controller, current)
411
411
  Log.warn(:http, "retry", **log_base(current), attempt: attempts, max_retries: @retry_policy.max, delay_s: delay,
412
412
  status: error.is_a?(ProviderError) ? error.status : nil, error: error.class.name,
413
- msg: error.message.to_s[0, 300])
413
+ msg: log_message(error.message))
414
414
  on_retry&.call(attempt: attempts, max_retries: @retry_policy.max, next_delay: delay,
415
415
  error_class: error.class.name, error_message: error.message,
416
416
  status: error.is_a?(ProviderError) ? error.status : nil)
417
417
  wait(delay, cancel_controller)
418
418
  end
419
419
 
420
+ def log_message(value)
421
+ value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
422
+ .gsub(/[[:cntrl:]\u2028\u2029]+/, " ").split.join(" ")[0, 300]
423
+ end
424
+
420
425
  def check_status!(response)
421
426
  status = response.code.to_i
422
427
  return if status.between?(200, 299)
@@ -8,7 +8,11 @@ module Samagotchi
8
8
  # (:server), else zeros (:none). +cached_tokens+ is the prompt the server
9
9
  # reused from its cache, +cache_write_tokens+ what it wrote to it
10
10
  # (OpenRouter reports those for Anthropic models); 0 when not reported.
11
- Usage = Data.define(:prompt_tokens, :completion_tokens, :source, :cached_tokens, :cache_write_tokens) do
11
+ # +reasoning_tokens+ is the completion's reasoning part (0 when not
12
+ # reported), +cost+ the provider's reported cost in USD (OpenRouter's
13
+ # usage.cost; nil when not reported, a reported 0 stays 0).
14
+ Usage = Data.define(:prompt_tokens, :completion_tokens, :source, :cached_tokens, :cache_write_tokens,
15
+ :reasoning_tokens, :cost) do
12
16
  # @return [Usage, nil] nil when the payload carries no counts
13
17
  def self.from_payload(payload)
14
18
  counts = TokenUsage.from_payload(payload)
@@ -16,12 +20,14 @@ module Samagotchi
16
20
 
17
21
  new(prompt_tokens: counts.prompt_tokens.to_i, completion_tokens: counts.completion_tokens.to_i,
18
22
  source: :server, cached_tokens: counts.cached_tokens.to_i,
19
- cache_write_tokens: counts.cache_write_tokens.to_i)
23
+ cache_write_tokens: counts.cache_write_tokens.to_i, reasoning_tokens: counts.reasoning_tokens.to_i,
24
+ cost: counts.cost)
20
25
  end
21
26
 
22
27
  def self.none = new(prompt_tokens: 0, completion_tokens: 0, source: :none)
23
28
 
24
- def initialize(prompt_tokens:, completion_tokens:, source:, cached_tokens: 0, cache_write_tokens: 0)
29
+ def initialize(prompt_tokens:, completion_tokens:, source:, cached_tokens: 0, cache_write_tokens: 0,
30
+ reasoning_tokens: 0, cost: nil)
25
31
  super
26
32
  end
27
33
 
@@ -34,6 +34,22 @@ module Samagotchi
34
34
  end
35
35
  private_class_method :warn_once
36
36
 
37
+ # The cost rule, one place for a turn's generation (SessionMetrics) and
38
+ # a side request (SessionMetrics::SideTotals): a reported cost wins; a
39
+ # reported 0, or none, with a price is estimated from the price (a
40
+ # gateway that doesn't bill per call reports 0). The reported cost
41
+ # itself is summed apart, a 0 included.
42
+ # @param price [ModelPrice, nil]
43
+ # @param reported_cost [Numeric, nil] the provider's cost (usage.cost)
44
+ # @return [Float, nil] the estimate, nil when none applies (no price, or
45
+ # a positive reported cost), so a sum without one stays as it was
46
+ def self.estimate(price, reported_cost:, prompt_tokens:, cached_tokens:, cache_write_tokens:, completion_tokens:)
47
+ return nil unless price && (reported_cost.nil? || reported_cost.zero?)
48
+
49
+ price.cost(prompt_tokens: prompt_tokens, cached_tokens: cached_tokens, cache_write_tokens: cache_write_tokens,
50
+ completion_tokens: completion_tokens)
51
+ end
52
+
37
53
  # USD for one generation, OpenAI usage semantics: +prompt_tokens+ holds
38
54
  # the cached reads and the cache writes (OpenRouter keeps that for
39
55
  # Anthropic models), and reasoning is inside +completion_tokens+.
@@ -76,6 +76,7 @@ module Samagotchi
76
76
  # @return [Hash] the --format json object
77
77
  def json(result, session_id:, timeout: nil, extra: {})
78
78
  report = { status: status(result), session_id: session_id }
79
+ report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
79
80
  case report[:status]
80
81
  when "answered" then report[:text] = result.text.to_s
81
82
  when "question"
@@ -122,7 +123,7 @@ module Samagotchi
122
123
  text = if result.status == :waiting_for_answer
123
124
  question_text(result.question, session_id: session_id)
124
125
  else
125
- "#{detail(result, session_id: session_id, timeout: timeout)}\n"
126
+ "#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id)}\n"
126
127
  end
127
128
  stderr.print("#{command}: #{text}")
128
129
  end
@@ -200,7 +201,7 @@ module Samagotchi
200
201
  end
201
202
 
202
203
  # What a wait that ended without an answer says, one line.
203
- def detail(result, session_id:, timeout: nil)
204
+ def detail(result, session_id:, timeout: nil, turn_id: nil)
204
205
  attach = "chi --attach #{session_id}"
205
206
  case result.status
206
207
  when :waiting_for_answer
@@ -217,7 +218,10 @@ module Samagotchi
217
218
  when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
218
219
  when :worker_gone then "the worker is gone; #{attach} shows what happened"
219
220
  when :stopped then "the session was stopped (chi sessions stop)"
220
- else timeout ? "still running after #{format("%g", timeout)} s: #{attach}" : "still running: #{attach}"
221
+ else
222
+ wait_again = "chi send --wait --turn #{turn_id} #{session_id}" unless turn_id.to_s.empty?
223
+ hint = wait_again || attach
224
+ timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
221
225
  end
222
226
  end
223
227
 
@@ -5,6 +5,7 @@ require_relative "../log"
5
5
  require_relative "../paths"
6
6
  require_relative "../guardrails/context"
7
7
  require_relative "../idle_client"
8
+ require_relative "service"
8
9
  require_relative "side_question"
9
10
 
10
11
  module Samagotchi
@@ -76,7 +77,7 @@ module Samagotchi
76
77
  # handler started) they act on the session now, through the Host.
77
78
  class Context
78
79
  # Thread.current key: {Context => the event its handler runs for}.
79
- CURRENT_EVENTS = :samagotchi_plugin_current_events
80
+ CURRENT_EVENTS = Service::CURRENT_EVENTS
80
81
  # Debug-log records tagged plugins, with bundle=<bundle> (tags are a
81
82
  # closed list, LogLine::TAGS).
82
83
  class Logger
@@ -294,7 +295,7 @@ module Samagotchi
294
295
  request = SideQuestion.request(messages: messages, prompt: prompt, system: system)
295
296
  limit = max_tokens.nil? ? ASK_MAX_TOKENS : Integer(max_tokens)
296
297
  @host.ask_model.call(request, timeout: Float(timeout), max_tokens: limit, cancel_controller: cancel,
297
- model: model&.strip).to_s
298
+ model: model&.strip, source: @bundle).to_s
298
299
  rescue IdleClient::SummarizeError => e
299
300
  raise ModelError, e.message
300
301
  rescue LLM::RequestCancelled
@@ -318,7 +319,7 @@ module Samagotchi
318
319
  # @return [Http]
319
320
  def http
320
321
  @http ||= Http.new(bundle: @bundle, transport: @http_transport,
321
- cancel_lookup: -> { @host.cancel_controller&.call(handler: in_handler?) })
322
+ cancel_lookup: -> { http_cancel_controller })
322
323
  end
323
324
 
324
325
  # This session's attached context: attach a URL or a command, list
@@ -387,6 +388,12 @@ module Samagotchi
387
388
  # Whether this plugin's handler runs on this thread (#with_event).
388
389
  def in_handler? = !!Thread.current[CURRENT_EVENTS]&.key?(self)
389
390
 
391
+ # The controller a ctx.http call without cancel: stops with: nil inside
392
+ # a service's start block, even on the turn's thread.
393
+ def http_cancel_controller
394
+ Service.starting? ? nil : @host.cancel_controller&.call(handler: in_handler?)
395
+ end
396
+
390
397
  # The event[:x] helper of the event this plugin's handler runs for on
391
398
  # this thread, or nil (not in a handler).
392
399
  def event_helper(name)
@@ -15,6 +15,16 @@ module Samagotchi
15
15
  # again.
16
16
  class Stopped < StandardError; end
17
17
 
18
+ # The thread-local Context#with_event keeps its handler events in;
19
+ # the start block runs with it cleared.
20
+ CURRENT_EVENTS = :samagotchi_plugin_current_events
21
+ # Thread.current key: true while a start block runs (the turn's own
22
+ # thread too: a ctx.http call there isn't the turn's).
23
+ STARTING = :samagotchi_plugin_service_starting
24
+
25
+ # Whether a service's start block runs on this thread.
26
+ def self.starting? = !!Thread.current[STARTING]
27
+
18
28
  # @return [String] "<bundle>:<name>"
19
29
  attr_reader :name
20
30
 
@@ -37,7 +47,7 @@ module Samagotchi
37
47
  return @value if @state == :running
38
48
 
39
49
  begin
40
- @value = @start.call(self)
50
+ @value = start_outside_handler
41
51
  @state = :running
42
52
  Log.info(:plugins, "service_started", service: @name)
43
53
  @value
@@ -81,6 +91,19 @@ module Samagotchi
81
91
 
82
92
  private
83
93
 
94
+ # The start block is the service's, not the handler's whose first
95
+ # #value started it: no event helper or ctx.http turn cancel inside.
96
+ def start_outside_handler
97
+ events = Thread.current[CURRENT_EVENTS]
98
+ starting = Thread.current[STARTING]
99
+ Thread.current[CURRENT_EVENTS] = nil
100
+ Thread.current[STARTING] = true
101
+ @start.call(self)
102
+ ensure
103
+ Thread.current[CURRENT_EVENTS] = events
104
+ Thread.current[STARTING] = starting
105
+ end
106
+
84
107
  def run_on_stop
85
108
  callbacks = @on_stop.reverse
86
109
  @on_stop = []
@@ -66,7 +66,8 @@ module Samagotchi
66
66
  timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
67
67
  sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
68
68
  store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call }),
69
- session_id: -> { session_id.call }
69
+ session_id: -> { session_id.call },
70
+ on_usage: ->(usage) { engine.note_side_usage(usage) }
70
71
  )
71
72
  end
72
73
 
@@ -52,8 +52,10 @@ module Samagotchi
52
52
  # @!attribute session [Session, nil] the session as the look that
53
53
  # decided the outcome loaded it (a caller's next baseline); nil for
54
54
  # :canceled and :timeout
55
+ # @!attribute turn_id [String, nil] the id of the turn this result is
56
+ # about, when known
55
57
  Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
56
- :stopped_by, :kept_steps, :session, keyword_init: true)
58
+ :stopped_by, :kept_steps, :session, :turn_id, keyword_init: true)
57
59
 
58
60
  module_function
59
61
 
@@ -94,10 +96,18 @@ module Samagotchi
94
96
  # @param queued [Boolean] the turn: input was sent as a queue (or with
95
97
  # images), which a running turn never merges: no fallback, even when
96
98
  # its file was taken before the first look
99
+ # @param ended_before [Boolean] a last_turn that ended before the
100
+ # baseline (chi send --wait with no message, on a session that went
101
+ # idle in the gap between waits) is the turn's end too: with the
102
+ # session idle and its last_turn still the baseline's, its end is
103
+ # the answer — its reply, or its no-reply outcome. Its reply file
104
+ # sorts at the cursor, not past it, and its end precedes the
105
+ # baseline, so without it the wait would time out "running" again.
106
+ # Not for a turn: wait (chi answer) whose own end it waits for.
97
107
  # @return [Result]
98
108
  # @raise [ArgumentError] no such session
99
109
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
100
- baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false)
110
+ baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false)
101
111
  deadline = timeout && (monotonic + timeout.to_f)
102
112
  seen_running = false
103
113
  gone_since = nil
@@ -159,6 +169,34 @@ module Samagotchi
159
169
 
160
170
  return Result.new(status: :canceled) if cancelled.call
161
171
 
172
+ # The turn that ended before this wait (ended_before: chi send
173
+ # --wait with no message, on a session that went idle in the gap
174
+ # between waits): its last_turn still stands, the session is idle,
175
+ # so its end is the answer, not a wait that times out "running"
176
+ # again. Its reply file sorts at the cursor (the newest), not past
177
+ # it: it is the turn's when it is at least as new as the turn's
178
+ # end (the worker writes it after recording the ended_at), else the
179
+ # turn left no reply and an earlier turn's file is not its.
180
+ if ended_before && turn.nil? && session.status == Session::STATUS_IDLE &&
181
+ (ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
182
+ baseline && ended == baseline[:last_turn]
183
+ file = newest_reply(id, state_dir: state_dir)
184
+ if file && reply_is_turns?(file, ended)
185
+ reply = Result.new(status: :done, text: File.read(File.join(reply_dir(id, state_dir: state_dir), file)), file: file)
186
+ reply.session = settled(session, id, state_dir: state_dir)
187
+ return reply
188
+ end
189
+
190
+ return no_reply(session, baseline, fresh: true)
191
+ end
192
+
193
+ if ended_before && turn && session.last_turn&.enqueued_id == turn &&
194
+ !(session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn) &&
195
+ (ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
196
+ baseline && ended == baseline[:last_turn]
197
+ return no_reply(session, baseline, fresh: true)
198
+ end
199
+
162
200
  if turn
163
201
  # Only the turn's own end: the session runs another turn when the
164
202
  # wait starts, and that one's end is not the answer. A last_turn
@@ -257,6 +295,18 @@ module Samagotchi
257
295
  session.last_turn&.enqueued_id
258
296
  end
259
297
 
298
+ # Whether +turn+ is a turn the caller can wait for: running now, queued
299
+ # as its own turn, or the last one to have ended.
300
+ def known_turn?(session, session_dir:, turn:)
301
+ id = turn.to_s
302
+ return false if id.empty?
303
+
304
+ running = session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == id
305
+ queued = SessionInbox.waiting_input(session_dir, id) == :turn_end
306
+ ended = session.last_turn&.enqueued_id == id
307
+ running || queued || ended
308
+ end
309
+
260
310
  # Whether the session's worker sidecar names TURN_IDS_FEATURE.
261
311
  def turn_ids?(session_dir)
262
312
  WorkerSidecar.read(session_dir)&.features&.include?(TURN_IDS_FEATURE) || false
@@ -390,6 +440,22 @@ module Samagotchi
390
440
  cursor.nil? || file > cursor
391
441
  end
392
442
 
443
+ # Whether the reply file +file+ is the turn that ended at +ended_at+'s,
444
+ # not an earlier turn's: the worker records the ended_at, then writes
445
+ # the reply (SessionInbox.write_output: the name's timestamp is the
446
+ # write time, at least the ended_at); every earlier turn's file is
447
+ # older than the turn's end. The wait and the session run on one
448
+ # machine, so the name's local-time read is the writer's. A name that
449
+ # doesn't read as a time isn't the turn's.
450
+ def reply_is_turns?(file, ended_at)
451
+ name = file.to_s
452
+ return false if name.length < 23
453
+
454
+ Time.parse(ended_at.to_s) <= Time.strptime(name[0, 14], "%Y%m%d%H%M%S") + (name[14, 9].to_f / (10**9))
455
+ rescue ArgumentError
456
+ false
457
+ end
458
+
393
459
  def monotonic
394
460
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
395
461
  end