samagotchi 0.35.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +2 -2
  4. data/bin/chi +1 -1
  5. data/docs/broadcast.md +91 -12
  6. data/docs/configuration.md +8 -1
  7. data/docs/context.md +37 -0
  8. data/docs/guardrails.md +26 -3
  9. data/docs/hooks.md +1 -1
  10. data/docs/memory.md +3 -0
  11. data/docs/plugins.md +51 -4
  12. data/lib/samagotchi/broadcast/triage.rb +267 -0
  13. data/lib/samagotchi/broadcast/triage_log.rb +165 -0
  14. data/lib/samagotchi/broadcast/triage_model.rb +56 -0
  15. data/lib/samagotchi/broadcast_command.rb +273 -69
  16. data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
  17. data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
  18. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
  19. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  20. data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  23. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/mcp/plugin.rb +72 -27
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
  27. data/lib/samagotchi/cli/command.rb +27 -3
  28. data/lib/samagotchi/config.rb +9 -0
  29. data/lib/samagotchi/context_command.rb +0 -17
  30. data/lib/samagotchi/engine.rb +4 -6
  31. data/lib/samagotchi/guardrail_wiring.rb +7 -2
  32. data/lib/samagotchi/guardrails/approval.rb +5 -2
  33. data/lib/samagotchi/guardrails/approvals.rb +9 -5
  34. data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
  35. data/lib/samagotchi/guardrails/rules.rb +19 -6
  36. data/lib/samagotchi/guardrails/targets.rb +83 -11
  37. data/lib/samagotchi/idle_client.rb +48 -28
  38. data/lib/samagotchi/idle_recap.rb +6 -5
  39. data/lib/samagotchi/idle_target.rb +95 -0
  40. data/lib/samagotchi/llm/openai_chat.rb +29 -3
  41. data/lib/samagotchi/log_line.rb +1 -1
  42. data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
  43. data/lib/samagotchi/note_command.rb +0 -21
  44. data/lib/samagotchi/recap_setup.rb +38 -52
  45. data/lib/samagotchi/send_command.rb +0 -22
  46. data/lib/samagotchi/session_commands.rb +1 -0
  47. data/lib/samagotchi/tool_activity.rb +14 -3
  48. data/lib/samagotchi/tool_declarations.rb +8 -4
  49. data/lib/samagotchi/tools/builtins.rb +1 -1
  50. data/lib/samagotchi/tools/memory.rb +103 -2
  51. data/lib/samagotchi/version.rb +1 -1
  52. data/lib/samagotchi/web/public/question_card.js +4 -3
  53. metadata +5 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../tools/tool_path"
4
4
  require_relative "../tools/memory"
5
+ require_relative "../tool_declarations"
5
6
  require_relative "../log"
6
7
  require_relative "model_size"
7
8
  require_relative "outside"
@@ -21,6 +22,9 @@ module Samagotchi
21
22
  SHELL_TOOLS = %w[execute task_create].freeze
22
23
  PATH_TOOLS = %w[write edit read memory_write].freeze
23
24
 
25
+ # chi's own tools, by name, whether this session has them or not.
26
+ CORE_TOOLS = ToolDeclarations::TOOL_SCHEMAS.map { |schema| schema[:name] }.freeze
27
+
24
28
  attr_reader :tool, :command, :paths, :cwd, :repo_root
25
29
 
26
30
  # @return [String, nil] the call's repository (Context#repo: its
@@ -37,11 +41,30 @@ module Samagotchi
37
41
  # prefix) and its key (ModelOverlay.key_for), for a rule's models:
38
42
  attr_reader :model_name, :model_key
39
43
 
40
- # @return [Hash, nil] a plugin tool's arguments (call[:args]), for an
41
- # approval of a call whose targets name no command or path; nil for
42
- # chi's own tools
44
+ # @return [Hash, nil] a plugin tool's arguments, for an approval of a
45
+ # call whose targets name no command or path: its targets:' args:
46
+ # when given (the arguments it acts with, a dispatcher's inner
47
+ # ones), else call[:args]; nil for chi's own tools
43
48
  attr_reader :args
44
49
 
50
+ # @return [String, nil] the tool a plugin tool's call acts as (its
51
+ # targets:' acts_as:, an MCP tool behind mcp_call): a rule's tool:
52
+ # matches it as well as the call's name. Never a core tool's or
53
+ # another bundle's (.plugin_targets drops those). #tool stays the
54
+ # call's name.
55
+ attr_reader :acts_as
56
+
57
+ # @return [String, nil] what a plugin tool's targets: says to name the
58
+ # call by in the approval question (label:, "github: x"), in place of
59
+ # the tool's registered label; display only (no rule or approval key
60
+ # reads it)
61
+ attr_reader :label
62
+
63
+ # @return [Boolean] a memory_write call that removes a memory (remove:
64
+ # true), for a rule's memory: remove
65
+ attr_reader :memory_remove
66
+ alias memory_remove? memory_remove
67
+
45
68
  # @param call [Hash] the parsed tool call
46
69
  # @param context [Context]
47
70
  # @param model_key [String, nil] for a memory_write model overlay, and
@@ -56,6 +79,8 @@ module Samagotchi
56
79
  paths = []
57
80
  cwd = base
58
81
  args = nil
82
+ acts_as = nil
83
+ label = nil
59
84
  case tool
60
85
  when *SHELL_TOOLS
61
86
  command = call[:content].to_s
@@ -74,20 +99,27 @@ module Samagotchi
74
99
  cwd = File.expand_path(given_cwd, base) unless given_cwd.empty?
75
100
  paths.concat(given[:paths].map { |path| absolute(path, cwd) })
76
101
  entry = registry && registry[tool]
77
- args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
102
+ args = given[:args] || call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
103
+ acts_as = given[:acts_as]
104
+ label = given[:label]
78
105
  end
106
+ memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
79
107
  new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
80
- repo: context.repo(cwd), args: args,
108
+ repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, memory_remove: memory_remove,
81
109
  model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
82
110
  chi_dirs: -> { chi_dirs(context, base) })
83
111
  end
84
112
 
85
113
  # What a plugin tool's targets: callable says the call acts on:
86
- # {command:, paths:, cwd:}, each optional; a callable that raises or
87
- # gives something else counts as nothing (and is logged).
114
+ # {command:, paths:, cwd:, acts_as:, args:, label:}, each optional; a callable
115
+ # that raises or gives something else counts as nothing (and is
116
+ # logged). args: counts when it is a Hash; acts_as: is dropped (and
117
+ # logged) when it names a core tool or another bundle's tool, so a
118
+ # call can't pass for one and match (or reuse the approvals of) its
119
+ # rules. label: counts when it is one short line (.label_of).
88
120
  def self.plugin_targets(call, registry)
89
121
  entry = registry && registry[call[:name].to_s]
90
- none = { command: nil, paths: [], cwd: nil }
122
+ none = { command: nil, paths: [], cwd: nil, acts_as: nil, args: nil, label: nil }
91
123
  return none unless entry && !entry.core? && entry.targets
92
124
 
93
125
  given = entry.targets.call(call)
@@ -95,13 +127,50 @@ module Samagotchi
95
127
 
96
128
  given = given.transform_keys(&:to_sym)
97
129
  command = given[:command].to_s
98
- { command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), cwd: given[:cwd] }
130
+ { command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), cwd: given[:cwd],
131
+ acts_as: acts_as_of(given[:acts_as], entry, registry), args: given[:args].is_a?(Hash) ? given[:args] : nil,
132
+ label: label_of(given[:label], entry) }
99
133
  rescue StandardError => e
100
134
  Log.warn(:plugins, "plugin_targets_failed", tool: call[:name].to_s, error: e.class.name,
101
135
  msg: "#{call[:name]} targets failed: #{e.message}")
102
136
  none
103
137
  end
104
138
 
139
+ # +name+ as the acts_as of +entry+'s calls: nil for none, and for a
140
+ # core tool or a tool another bundle registered (logged); the tool's
141
+ # own name is no alias either.
142
+ def self.acts_as_of(name, entry, registry)
143
+ name = name.to_s.strip
144
+ return nil if name.empty? || name == entry.name
145
+
146
+ other = registry[name]
147
+ whose = if CORE_TOOLS.include?(name) || other&.core? then "a core tool"
148
+ elsif other && other.source != entry.source then "bundle #{other.source}'s tool"
149
+ end
150
+ return name unless whose
151
+
152
+ Log.warn(:plugins, "plugin_acts_as_dropped", tool: entry.name, acts_as: name,
153
+ msg: "#{entry.name} targets: acts_as #{name} dropped: it is #{whose}")
154
+ nil
155
+ end
156
+
157
+ # The longest label: the question takes from targets:.
158
+ LABEL_CHARS = 80
159
+
160
+ # +label+ as a call's question label: stripped; nil for none, and for
161
+ # anything but a String of one line up to LABEL_CHARS (logged).
162
+ def self.label_of(label, entry)
163
+ return nil if label.nil?
164
+
165
+ text = label.is_a?(String) ? label.strip : nil
166
+ return nil if text && text.empty?
167
+ return text if text && !text.include?("\n") && text.length <= LABEL_CHARS
168
+
169
+ Log.warn(:plugins, "plugin_label_ignored", tool: entry.name,
170
+ msg: "#{entry.name} targets: label ignored: not one line of at most #{LABEL_CHARS} characters")
171
+ nil
172
+ end
173
+
105
174
  # chi's own dirs (ParentApprovals.chi_dirs) and the session repo's
106
175
  # git hooks dir.
107
176
  def self.chi_dirs(context, base)
@@ -127,8 +196,11 @@ module Samagotchi
127
196
  end
128
197
 
129
198
  # @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
130
- def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, model_name: nil, model_key: nil,
131
- session_root: nil, chi_dirs: nil)
199
+ def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, acts_as: nil, label: nil,
200
+ model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil, memory_remove: false)
201
+ @acts_as = acts_as
202
+ @label = label
203
+ @memory_remove = memory_remove
132
204
  @repo = repo
133
205
  @chi_dirs = chi_dirs
134
206
  @session_root = session_root || repo_root || cwd
@@ -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)
@@ -25,7 +25,7 @@ module Samagotchi
25
25
  pattern = managed_pattern(entry_name)
26
26
  if content.match?(pattern)
27
27
  existing = content[pattern].to_s.chomp
28
- resolved_desc = description&.to_s&.strip || extract_description(existing)
28
+ resolved_desc = blank_description?(description) ? extract_description(existing) : description
29
29
  resolved_source = source.nil? ? extract_source(existing) : (source || nil)
30
30
  new_line = managed_line(entry_name, scope, byte_count, resolved_desc, resolved_source)
31
31
  next content.sub(pattern) { new_line + $1 }
@@ -72,11 +72,14 @@ module Samagotchi
72
72
  def self.managed_line(name, scope, byte_count, description, source = nil)
73
73
  line = "- **#{name}** · #{scope} · #{date_str} · #{byte_count}"
74
74
  line += " · from #{source}" unless source.to_s.strip.empty?
75
- desc = description&.to_s&.strip
76
- line += " — #{desc}" unless desc.to_s.empty?
75
+ desc = description.to_s.gsub(/\s+/, " ").strip
76
+ line += " — #{desc}" unless desc.empty?
77
77
  line
78
78
  end
79
79
 
80
+ # A blank description (nil, "", whitespace) keeps the line's own.
81
+ def self.blank_description?(description) = description.to_s.strip.empty?
82
+
80
83
  def self.extract_description(line)
81
84
  if line =~ /\s—\s(.*)\s*\z/
82
85
  $1
@@ -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