samagotchi 0.36.0 → 0.37.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.
@@ -24,10 +24,14 @@ module Samagotchi
24
24
  # Raised when summarization fails (server down, timeout, malformed body…).
25
25
  class SummarizeError < StandardError; end
26
26
 
27
- # What #summarize returns: the recap text and the model that answered
28
- # (the server's name for it; nil when the reply names none). #to_s is
29
- # the text.
30
- Summary = Data.define(:text, :model) do
27
+ # What #summarize and #ask return: the text and the model that
28
+ # answered (the server's name for it; nil when the reply names none).
29
+ # +top_logprobs+: the first token's alternatives (LLM::TokenLogprob)
30
+ # when #ask asked for logprobs and the server sent them, else [].
31
+ # #to_s is the text.
32
+ Summary = Data.define(:text, :model, :top_logprobs) do
33
+ def initialize(top_logprobs: [].freeze, **) = super
34
+
31
35
  def to_s = text
32
36
  end
33
37
 
@@ -40,12 +44,21 @@ module Samagotchi
40
44
  # @param timeout [Numeric] HTTP request timeout. Kept to the recap's own
41
45
  # wait budget (not the chat's global request_timeout) so an abandoned
42
46
  # summarize thread can't outlive the recap attempt by minutes.
43
- def initialize(model:, base_url: nil, api_key_env: nil, timeout: DEFAULT_TIMEOUT_SECONDS, env: ENV)
47
+ # @param purpose [String] what the requests are for, in the HTTP log
48
+ # lines and error messages ("recap", "broadcast")
49
+ def initialize(model:, base_url: nil, api_key_env: nil, timeout: DEFAULT_TIMEOUT_SECONDS, env: ENV, purpose: "recap")
44
50
  @model = model
45
51
  # A recap is best-effort: one short attempt, no retries. The idle job
46
52
  # tries again after the next activity, never on its own.
47
- @chat = LLM::OpenAIChat.new(base_url: base_url.to_s, host_name: "recap", api_key_env: api_key_env,
48
- stream: false, retries: false, timeout: timeout, env: env, purpose: "recap")
53
+ @chat = LLM::OpenAIChat.new(base_url: base_url.to_s, host_name: purpose, api_key_env: api_key_env,
54
+ stream: false, retries: false, timeout: timeout, env: env, purpose: purpose)
55
+ end
56
+
57
+ # A client for +target+ (an IdleTarget: a recap's, ctx.ask_model's, a
58
+ # broadcast's triage model).
59
+ # @return [IdleClient]
60
+ def self.for(target, **)
61
+ new(model: target.model, base_url: target.base_url, api_key_env: target.api_key_env, **)
49
62
  end
50
63
 
51
64
  THINK_RE = /<\|think\|.*?\|think\|>/m
@@ -77,25 +90,31 @@ module Samagotchi
77
90
  messages = prompt.is_a?(Array) ? prompt : [{ role: "user", content: prompt.to_s.strip }]
78
91
  return nil if messages.all? { |m| m[:content].to_s.strip.empty? }
79
92
 
80
- content, served = generate(messages)
81
- cleaned = content.to_s.strip
82
- cleaned.empty? ? nil : Summary.new(text: cleaned, model: served)
93
+ response = generate(messages)
94
+ cleaned = response.text.to_s.strip
95
+ cleaned.empty? ? nil : Summary.new(text: cleaned, model: response.model)
83
96
  rescue SummarizeError
84
97
  raise
85
98
  rescue StandardError => e
86
99
  raise SummarizeError, "recap summarization failed: #{e.class}: #{e.message}"
87
100
  end
88
101
 
89
- # One side answer (a plugin's ctx.ask_model): +messages+ as they are, no
90
- # tools, thinking off. An answer cut off by +max_tokens+ is kept as it
91
- # is, marked with "…". Cancelling +cancel_controller+ aborts the request.
102
+ # One side answer (a plugin's ctx.ask_model, a broadcast's triage):
103
+ # +messages+ as they are, no tools, thinking off. An answer cut off by
104
+ # +max_tokens+ is kept as it is, marked with "…". Cancelling
105
+ # +cancel_controller+ aborts the request.
106
+ # @param kind [String] what it is for, in the usage log line
107
+ # @param options [Hash] more request body fields (logprobs: true,
108
+ # top_logprobs: 5)
92
109
  # @return [Summary] the answer ("" when the model said nothing)
93
- # @raise [SummarizeError] any failure but a cancel
110
+ # @raise [SummarizeError] any failure but a cancel; its #cause is an
111
+ # LLM::BadRequest when the host refused the request (+options+ it
112
+ # doesn't take, say)
94
113
  # @raise [LLM::RequestCancelled] +cancel_controller+ was cancelled
95
- def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil)
96
- content, served = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
97
- kind: "ask")
98
- Summary.new(text: content.to_s.strip, model: served)
114
+ def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {})
115
+ response = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
116
+ kind: kind, options: options)
117
+ Summary.new(text: response.text.to_s.strip, model: response.model, top_logprobs: response.top_logprobs)
99
118
  rescue SummarizeError, LLM::RequestCancelled
100
119
  raise
101
120
  rescue StandardError => e
@@ -104,15 +123,16 @@ module Samagotchi
104
123
 
105
124
  private
106
125
 
107
- # One plain /chat/completions request. Returns the cleaned assistant text
108
- # ("" when there is nothing after stripping) and the served model's name
109
- # (nil when the reply names none). Raises SummarizeError when
126
+ # One plain /chat/completions request. Returns a Summary of the cleaned
127
+ # assistant text ("" when there is nothing after stripping), the served
128
+ # model's name (nil when the reply names none) and the first token's
129
+ # logprobs. Raises SummarizeError when
110
130
  # the reply has neither content nor reasoning_content. Cut off by
111
131
  # +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
112
132
  # or all of it, with "…".
113
- def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap")
133
+ def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap", options: {})
114
134
  response = begin
115
- request(messages, max_tokens, cancel_controller)
135
+ request(messages, max_tokens, cancel_controller, options)
116
136
  rescue LLM::BadRequest => e
117
137
  raise unless e.reasoning_refused? && !@thinking_refused
118
138
 
@@ -120,7 +140,7 @@ module Samagotchi
120
140
  # fields, and leave them out from now on.
121
141
  @thinking_refused = true
122
142
  Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
123
- request(messages, max_tokens, cancel_controller)
143
+ request(messages, max_tokens, cancel_controller, options)
124
144
  end
125
145
  log_usage(response, kind)
126
146
  content = response.text
@@ -130,7 +150,7 @@ module Samagotchi
130
150
  cut_off = response.finish_reason == "length"
131
151
  # Reasoning cut off by max_tokens is the model's thinking, not a recap
132
152
  # (a server that ignores the thinking switch thinks until the limit).
133
- return ["", response.model] if content.empty? && cut_off
153
+ return Summary.new(text: "", model: response.model, top_logprobs: response.top_logprobs) if content.empty? && cut_off
134
154
 
135
155
  # Prefer content, fall back to a finished reasoning_content (e.g.
136
156
  # Qwen3.6), and strip thinking tokens some models (Qwen, Gemma) leave
@@ -138,7 +158,7 @@ module Samagotchi
138
158
  text = self.class.strip_thinking(content.empty? ? reasoning : content)
139
159
  # Cut off by max_tokens: keep the sentences that finished ("" if none).
140
160
  text = whole_sentences ? self.class.full_sentences(text) : "#{text}…" if cut_off && !text.empty?
141
- [text, response.model]
161
+ Summary.new(text: text, model: response.model, top_logprobs: response.top_logprobs)
142
162
  rescue LLM::ProtocolError => e
143
163
  raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
144
164
  end
@@ -153,9 +173,9 @@ module Samagotchi
153
173
  cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens])
154
174
  end
155
175
 
156
- def request(messages, max_tokens, cancel_controller)
176
+ def request(messages, max_tokens, cancel_controller, options = {})
157
177
  @chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
158
- options: { max_tokens: max_tokens, **thinking_fields })
178
+ options: { max_tokens: max_tokens, **thinking_fields, **options })
159
179
  end
160
180
 
161
181
  # A recap or side answer is short and tool-less: thinking off, whatever
@@ -6,6 +6,7 @@ require "monitor"
6
6
  require "time"
7
7
 
8
8
  require_relative "idle_client"
9
+ require_relative "idle_target"
9
10
  require_relative "output_formatter"
10
11
  require_relative "steer"
11
12
  require_relative "recap_store"
@@ -279,7 +280,7 @@ module Samagotchi
279
280
  # attempt (the session's current model, so a /model switch counts).
280
281
  # @param base_url [String] the OpenAI API base the recap asks
281
282
  # @param api_key_env [String, nil] the variable holding its key
282
- # @param target [#call, nil] -> {base_url:, api_key_env:, model:, label:}
283
+ # @param target [#call, nil] -> IdleTarget
283
284
  def initialize(engine:, model: nil, base_url: nil, api_key_env: nil, target: nil,
284
285
  inactivity: DEFAULT_INACTIVITY_SECONDS,
285
286
  min_user_turns: DEFAULT_MIN_USER_TURNS,
@@ -292,7 +293,7 @@ module Samagotchi
292
293
 
293
294
  @engine = engine
294
295
  @target = target || lambda {
295
- { base_url: base_url, api_key_env: api_key_env, model: model, label: model }
296
+ IdleTarget.new(base_url: base_url, api_key_env: api_key_env, model: model, label: model)
296
297
  }
297
298
  @inactivity = inactivity
298
299
  @min_user_turns = min_user_turns
@@ -455,7 +456,7 @@ module Samagotchi
455
456
 
456
457
  asked = target
457
458
  @in_flight = { thread: spawn_summarize(client_for(asked), prompt), generation: gen, deadline: @clock.call + @timeout,
458
- covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked[:label] }
459
+ covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked.label }
459
460
  :started
460
461
  rescue StandardError
461
462
  :failed
@@ -534,9 +535,9 @@ module Samagotchi
534
535
  def client_for(asked)
535
536
  return @client_override if @client_override
536
537
 
537
- key = asked.values_at(:base_url, :api_key_env, :model)
538
+ key = [asked.base_url, asked.api_key_env, asked.model]
538
539
  unless @client && @client_key == key
539
- @client = IdleClient.new(model: asked[:model], base_url: asked[:base_url], api_key_env: asked[:api_key_env], timeout: @timeout)
540
+ @client = IdleClient.for(asked, timeout: @timeout)
540
541
  @client_key = key
541
542
  end
542
543
  @client
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Where a side request goes (a recap, a plugin's ctx.ask_model, a
5
+ # broadcast's triage): a model on a host's OpenAI API, asked through its
6
+ # own IdleClient (IdleClient.for), apart from the session's turns.
7
+ # @!attribute base_url [String] the host's OpenAI API base
8
+ # @!attribute api_key_env [String, nil] the variable holding its key
9
+ # @!attribute model [String] the model id sent to the server
10
+ # @!attribute label [String] the model as configured, for logs
11
+ IdleTarget = Data.define(:base_url, :api_key_env, :model, :label) do
12
+ # A configured side model can't be resolved.
13
+ # @!attribute reason [Symbol] :host_ref_unknown (no such host),
14
+ # :model_host_mismatch (the model names another host than host_ref)
15
+ # or :incomplete (no base_url or no model)
16
+ class self::Unresolved < StandardError
17
+ attr_reader :reason, :model, :host_ref, :named
18
+
19
+ def initialize(reason, model: nil, host_ref: nil, named: nil)
20
+ @reason = reason
21
+ @model = model
22
+ @host_ref = host_ref
23
+ @named = named
24
+ super("side model #{model.inspect} can't be resolved: #{reason}")
25
+ end
26
+ end
27
+
28
+ # +name+ (a model ref) as a turn resolves it: its alias applied, its
29
+ # host, or where a bare --model goes (HostRegistry#resolve, which may
30
+ # list a host's models).
31
+ # @param host_registry [HostRegistry]
32
+ # @return [IdleTarget]
33
+ def self.of_model(host_registry, name)
34
+ resolved = host_registry.resolve(name)
35
+ new(base_url: resolved.openai_base_url, api_key_env: resolved.entry.api_key_env, model: resolved.bare_model,
36
+ label: name.to_s.strip)
37
+ end
38
+
39
+ # A side model's settings (recap.*, broadcast.triage_*) as a target:
40
+ # +base_url+ with +model+; +host_ref+ (a hosts: name) with +model+;
41
+ # +model+ alone (its own host, its alias's, or where a bare --model
42
+ # goes). Blank values count as unset.
43
+ # @param host_registry [HostRegistry]
44
+ # @return [IdleTarget, nil] nil when all three are unset (the caller
45
+ # picks its default)
46
+ # @raise [Unresolved]
47
+ def self.resolve(model:, host_ref:, base_url:, host_registry:)
48
+ model, host_ref, base_url = [model, host_ref, base_url].map { |value| blank(value) }
49
+ return nil if model.nil? && host_ref.nil? && base_url.nil?
50
+
51
+ label = model.to_s
52
+ parsed = model && host_registry.model_ref(model)
53
+ return of_model(host_registry, model) unless host_named?(model: model, host_ref: host_ref, base_url: base_url,
54
+ host_registry: host_registry)
55
+
56
+ host_ref ||= parsed.host_name if parsed && base_url.nil?
57
+ api_key_env = nil
58
+ if host_ref
59
+ entry = host_registry.find_entry(host_ref)
60
+ raise self::Unresolved.new(:host_ref_unknown, model: model, host_ref: host_ref) unless entry
61
+
62
+ base_url = entry.openai_base_url
63
+ api_key_env = entry.api_key_env
64
+ named = parsed&.host_conflict || parsed&.host_name
65
+ if named && named != entry.name
66
+ raise self::Unresolved.new(:model_host_mismatch, model: model, host_ref: host_ref, named: named)
67
+ end
68
+
69
+ model = parsed.id if parsed
70
+ elsif parsed && !parsed.host_name
71
+ model = parsed.id
72
+ end
73
+ raise self::Unresolved.new(:incomplete, model: model, host_ref: host_ref) if blank(base_url).nil? || blank(model).nil?
74
+
75
+ new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip)
76
+ end
77
+
78
+ # Whether the settings fix a host (+base_url+, +host_ref+, or a model
79
+ # ref naming one), so resolving them once holds; a model alone that
80
+ # names none goes where a bare --model goes, which a host's model list
81
+ # decides (resolve it at each request).
82
+ def self.host_named?(model:, host_ref:, base_url:, host_registry:)
83
+ return true if blank(base_url) || blank(host_ref)
84
+
85
+ model = blank(model)
86
+ !model.nil? && !host_registry.model_ref(model).host_name.nil?
87
+ end
88
+
89
+ def self.blank(value)
90
+ text = value.to_s.strip
91
+ text.empty? ? nil : text
92
+ end
93
+ private_class_method :blank
94
+ end
95
+ end
@@ -40,8 +40,34 @@ module Samagotchi
40
40
  # OpenRouter's routing name); nil when the server says nothing.
41
41
  # +cut+ is set on the empty response the chat loop makes for a
42
42
  # generation a plugin cut (stop_generation): {by:, reason:}.
43
- ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut) do
44
- def initialize(model: nil, provider: nil, cut: nil, **fields) = super
43
+ # +top_logprobs+: the first answer token's likeliest alternatives
44
+ # (TokenLogprob) when the request asked for logprobs and the server
45
+ # sent them (a non-streamed answer only); [] otherwise.
46
+ ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut,
47
+ :top_logprobs) do
48
+ def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze, **fields) = super
49
+ end
50
+
51
+ # One alternative for an answer token: its text and log probability.
52
+ TokenLogprob = Data.define(:token, :logprob) do
53
+ # The first token's alternatives in a chat completion body's
54
+ # choices[0].logprobs (OpenAI's shape); [] when there are none or the
55
+ # body has another shape (it runs on every non-streamed answer: it
56
+ # never raises).
57
+ def self.first_of(body)
58
+ top = [%w[choices], [0], %w[logprobs], %w[content], [0], %w[top_logprobs]].reduce(body) do |node, (key)|
59
+ break nil unless key.is_a?(Integer) ? node.is_a?(Array) : node.is_a?(Hash)
60
+
61
+ node[key]
62
+ end
63
+ return [].freeze unless top.is_a?(Array)
64
+
65
+ top.filter_map do |entry|
66
+ next unless entry.is_a?(Hash) && entry["token"].is_a?(String) && entry["logprob"].is_a?(Numeric)
67
+
68
+ new(token: entry["token"], logprob: entry["logprob"].to_f)
69
+ end.freeze
70
+ end
45
71
  end
46
72
 
47
73
  # The OpenAI Chat Completions API (llama.cpp's /v1, and any compatible
@@ -334,7 +360,7 @@ module Samagotchi
334
360
  reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
335
361
  tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
336
362
  finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body),
337
- provider: self.class.served_provider(body))
363
+ provider: self.class.served_provider(body), top_logprobs: TokenLogprob.first_of(body))
338
364
  end
339
365
 
340
366
  def served_model(payload) = self.class.served_model(payload)
@@ -16,7 +16,7 @@ module Samagotchi
16
16
  # (debug dumps) are indented by four spaces and belong to the record above.
17
17
  module LogLine
18
18
  LEVELS = %w[DEBUG INFO WARN ERROR].freeze
19
- TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context].freeze
19
+ TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast].freeze
20
20
  INDENT = " "
21
21
 
22
22
  Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
@@ -91,20 +91,6 @@ module Samagotchi
91
91
  options
92
92
  end
93
93
 
94
- # Only a pipe or a file is read, as chi send does. A terminal means
95
- # nobody piped a note in, and a socket a launcher or an agent's shell
96
- # passes down may never close: waiting on either would hang a script.
97
- def read_stdin
98
- return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
99
-
100
- if @stdin.respond_to?(:stat)
101
- stat = @stdin.stat
102
- return nil unless stat.pipe? || stat.file?
103
- end
104
-
105
- @stdin.read
106
- end
107
-
108
94
  # --all: every live session. @return [Integer] exit status
109
95
  def deliver_to_live(text, source)
110
96
  ids = SessionManager.session_summaries(live: true, include_tests: Session.test_session_env?,
@@ -141,12 +127,5 @@ module Samagotchi
141
127
  else "waits for the session's next start (#{result.queued} #{result.queued == 1 ? "note" : "notes"} queued)"
142
128
  end
143
129
  end
144
-
145
- # The note as UTF-8 whatever the locale says: with no LANG/LC_* (an app
146
- # started from Finder, launchd) stdin reads as US-ASCII and ARGV as
147
- # binary. Invalid bytes become U+FFFD rather than an error.
148
- def utf8(text)
149
- text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
150
- end
151
130
  end
152
131
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "config"
4
4
  require_relative "log"
5
5
  require_relative "idle_recap"
6
+ require_relative "idle_target"
6
7
  require_relative "recap_store"
7
8
 
8
9
  module Samagotchi
@@ -41,60 +42,21 @@ module Samagotchi
41
42
  base_url = string_config(kwarg_config, :base_url) || registry_string("recap.base_url")
42
43
  host_ref = string_config(kwarg_config, :host_ref) || string_config(kwarg_config, :host) || registry_string("recap.host_ref")
43
44
  model = string_config(kwarg_config, :model) || registry_string("recap.model")
44
- label = model
45
45
 
46
- # The model as any model ref (ModelRef): its alias applied once, and
47
- # its host (own or the alias's) picks the recap host when host_ref
48
- # doesn't.
49
- parsed = model && host_registry.model_ref(model)
50
- target = nil
51
- if base_url.nil? && host_ref.nil? && model.nil?
52
- target = -> { session_target.call }
53
- elsif base_url.nil? && host_ref.nil? && !parsed.host_name
54
- # A model naming no host goes where a bare --model goes
55
- # (HostRegistry#host_for_model: the default host unless another's
56
- # list has it), resolved at each attempt.
57
- target = lambda do
58
- resolved = host_registry.resolve(model)
59
- { base_url: resolved.openai_base_url, api_key_env: resolved.entry.api_key_env,
60
- model: resolved.bare_model, label: label.to_s.strip }
61
- end
62
- else
63
- host_ref ||= parsed.host_name if parsed && base_url.nil?
64
- # If host_ref given, derive base_url (the host's OpenAI base) and its
65
- # API key variable from the host_registry entry
66
- api_key_env = nil
67
- if host_ref && !host_ref.empty?
68
- entry = host_registry.find_entry(host_ref)
69
- if entry
70
- base_url = entry.openai_base_url
71
- api_key_env = entry.api_key_env
72
- named = parsed&.host_conflict || parsed&.host_name
73
- if named && named != entry.name
74
- Log.warn(:recap, "model_host_mismatch",
75
- echo: "Warning: recap model '#{model}' names host '#{named}', not recap.host_ref '#{host_ref}'; recap disabled.",
76
- model: model, host_ref: host_ref)
77
- return nil
78
- end
79
- model = parsed.id if parsed
80
- else
81
- Log.warn(:recap, "host_ref_unknown", echo: "Warning: recap host_ref '#{host_ref}' not found in hosts:; recap disabled.", host_ref: host_ref)
82
- return nil
83
- end
84
- elsif parsed && !parsed.host_name
85
- model = parsed.id
86
- end
46
+ settings = { model: model, host_ref: host_ref, base_url: base_url, host_registry: host_registry }
47
+ target = if base_url.nil? && host_ref.nil? && model.nil?
48
+ -> { session_target.call }
49
+ elsif !IdleTarget.host_named?(**settings)
50
+ # A model naming no host goes where a bare --model goes
51
+ # (HostRegistry#host_for_model: the default host unless
52
+ # another's list has it), resolved at each attempt.
53
+ -> { IdleTarget.resolve(**settings) }
54
+ else
55
+ fixed = fixed_target(settings)
56
+ return nil unless fixed
87
57
 
88
- if base_url.to_s.strip.empty? || model.to_s.strip.empty?
89
- Log.warn(:recap, "recap_unconfigured",
90
- echo: "Warning: SAMAGOTCHI session recap is enabled but base_url/model are missing; recap disabled. " \
91
- "Set recap: {host_ref:, model:} or SAMAGOTCHI_RECAP_BASE_URL and SAMAGOTCHI_RECAP_MODEL (or pass recap: {base_url:, model:}), " \
92
- "or leave them all out to recap with the session's own model.")
93
- return nil
94
- end
95
- fixed = { base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.to_s.strip }
96
- target = -> { fixed }
97
- end
58
+ -> { fixed }
59
+ end
98
60
 
99
61
  IdleRecap.new(
100
62
  engine: engine,
@@ -107,6 +69,30 @@ module Samagotchi
107
69
  )
108
70
  end
109
71
 
72
+ # The recap's pinned host and model (IdleTarget.resolve), or nil after
73
+ # a warning when they can't be resolved (recap stays off).
74
+ # @return [IdleTarget, nil]
75
+ def self.fixed_target(settings)
76
+ IdleTarget.resolve(**settings)
77
+ rescue IdleTarget::Unresolved => e
78
+ case e.reason
79
+ when :host_ref_unknown
80
+ Log.warn(:recap, "host_ref_unknown", echo: "Warning: recap host_ref '#{e.host_ref}' not found in hosts:; recap disabled.",
81
+ host_ref: e.host_ref)
82
+ when :model_host_mismatch
83
+ Log.warn(:recap, "model_host_mismatch",
84
+ echo: "Warning: recap model '#{e.model}' names host '#{e.named}', not recap.host_ref '#{e.host_ref}'; recap disabled.",
85
+ model: e.model, host_ref: e.host_ref)
86
+ else
87
+ Log.warn(:recap, "recap_unconfigured",
88
+ echo: "Warning: SAMAGOTCHI session recap is enabled but base_url/model are missing; recap disabled. " \
89
+ "Set recap: {host_ref:, model:} or SAMAGOTCHI_RECAP_BASE_URL and SAMAGOTCHI_RECAP_MODEL (or pass recap: {base_url:, model:}), " \
90
+ "or leave them all out to recap with the session's own model.")
91
+ end
92
+ nil
93
+ end
94
+ private_class_method :fixed_target
95
+
110
96
  # recap.sentences as [min, max]; an invalid value warns and falls back to
111
97
  # the default range (the recap stays on).
112
98
  def self.recap_sentences(value)
@@ -225,21 +225,6 @@ module Samagotchi
225
225
  "#{ContextQuote.block(context)}#{message}"
226
226
  end
227
227
 
228
- # Only a pipe or a file is read. A terminal means nobody piped anything
229
- # in, and a socket a launcher or an agent's shell passes down may never
230
- # close: with -m, waiting on either would hang a script. (A pipe the
231
- # caller never closes still hangs, as it would for cat.)
232
- def read_stdin
233
- return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
234
-
235
- if @stdin.respond_to?(:stat)
236
- stat = @stdin.stat
237
- return nil unless stat.pipe? || stat.file?
238
- end
239
-
240
- @stdin.read
241
- end
242
-
243
228
  # A worker session like the web start page's: saved as running with the
244
229
  # message before its worker spawns, so lists and the web show it at
245
230
  # once. The full id, so a script can pass it on. The model's host is
@@ -477,12 +462,5 @@ module Samagotchi
477
462
  else " with #{@images.size} images"
478
463
  end
479
464
  end
480
-
481
- # The text as UTF-8 whatever the locale says: with no LANG/LC_* (an app
482
- # started from Finder, launchd) stdin reads as US-ASCII and ARGV as
483
- # binary. Invalid bytes become U+FFFD rather than an error.
484
- def utf8(text)
485
- text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
486
- end
487
465
  end
488
466
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.36.0"
4
+ VERSION = "0.37.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -61,8 +61,9 @@ export function approvalView(pq) {
61
61
  const who = a.rule ? [`rule ${a.rule}`, a.source].filter(Boolean).join(", ") : (a.source || "hook");
62
62
  const reason = a.reason ? String(a.reason) : "(no reason given)";
63
63
  return {
64
- // A plugin tool's label ("chrome: screenshot"), as its row shows it.
65
- tool: a.label || a.tool || "",
64
+ // A plugin tool's label ("chrome: screenshot"), as its row shows it;
65
+ // else the tool the call acts as (an MCP tool behind mcp_call).
66
+ tool: a.label || a.acts_as || a.tool || "",
66
67
  // A tool without a command or path (an MCP tool): its compact args.
67
68
  what: command || paths.join("\n") || a.args || "",
68
69
  isCommand: !!command,
@@ -142,7 +143,7 @@ export function summaryText(pq, { answer = null, cancelled = false, reason = ""
142
143
  const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
143
144
  return truncate(relay ? `${relay.who}: ${text}` : text, 120);
144
145
  }
145
- const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?`
146
+ const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.acts_as || pq.approval?.tool || "call"}?`
146
147
  : isContinue(pq) ? "Step limit" : "Question";
147
148
  return truncate(pq.header || label);
148
149
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.36.0
4
+ version: 0.37.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -167,6 +167,9 @@ files:
167
167
  - lib/samagotchi/broadcast/recipients.rb
168
168
  - lib/samagotchi/broadcast/scope_card.rb
169
169
  - lib/samagotchi/broadcast/tags.rb
170
+ - lib/samagotchi/broadcast/triage.rb
171
+ - lib/samagotchi/broadcast/triage_log.rb
172
+ - lib/samagotchi/broadcast/triage_model.rb
170
173
  - lib/samagotchi/broadcast_command.rb
171
174
  - lib/samagotchi/bundle_command.rb
172
175
  - lib/samagotchi/bundle_needs.rb
@@ -284,6 +287,7 @@ files:
284
287
  - lib/samagotchi/idle_recap.rb
285
288
  - lib/samagotchi/idle_reminders.rb
286
289
  - lib/samagotchi/idle_scheduler.rb
290
+ - lib/samagotchi/idle_target.rb
287
291
  - lib/samagotchi/image_store.rb
288
292
  - lib/samagotchi/installed_gem.rb
289
293
  - lib/samagotchi/installed_versions.rb