samagotchi 0.36.0 → 0.38.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -1
  3. data/README.md +3 -3
  4. data/bin/chi +1 -1
  5. data/docs/broadcast.md +95 -13
  6. data/docs/configuration.md +9 -2
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +17 -2
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/engine.md +4 -2
  11. data/docs/plugins.md +107 -34
  12. data/lib/samagotchi/broadcast/scope_card.rb +15 -2
  13. data/lib/samagotchi/broadcast/triage.rb +284 -0
  14. data/lib/samagotchi/broadcast/triage_log.rb +170 -0
  15. data/lib/samagotchi/broadcast/triage_model.rb +56 -0
  16. data/lib/samagotchi/broadcast_command.rb +275 -69
  17. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -4
  18. data/lib/samagotchi/bundles/mcp/plugin.rb +439 -92
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  21. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  22. data/lib/samagotchi/cli/command.rb +27 -3
  23. data/lib/samagotchi/config.rb +9 -0
  24. data/lib/samagotchi/context_command.rb +0 -17
  25. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  26. data/lib/samagotchi/desktop/macos/Broadcast.swift +97 -0
  27. data/lib/samagotchi/desktop/macos/ChiRunner.swift +12 -3
  28. data/lib/samagotchi/desktop/macos/Panel.swift +123 -26
  29. data/lib/samagotchi/desktop/macos.rb +27 -5
  30. data/lib/samagotchi/desktop_command.rb +1 -1
  31. data/lib/samagotchi/engine.rb +7 -8
  32. data/lib/samagotchi/guardrail_wiring.rb +7 -2
  33. data/lib/samagotchi/guardrails/approval.rb +5 -2
  34. data/lib/samagotchi/guardrails/approvals.rb +9 -5
  35. data/lib/samagotchi/guardrails/rules.rb +4 -2
  36. data/lib/samagotchi/guardrails/targets.rb +76 -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/kernel_loop.rb +6 -4
  41. data/lib/samagotchi/llm/openai_chat.rb +29 -3
  42. data/lib/samagotchi/log_line.rb +1 -1
  43. data/lib/samagotchi/note_command.rb +0 -21
  44. data/lib/samagotchi/plugin/api.rb +5 -3
  45. data/lib/samagotchi/recap_setup.rb +38 -52
  46. data/lib/samagotchi/send_command.rb +0 -22
  47. data/lib/samagotchi/tool_response.rb +5 -5
  48. data/lib/samagotchi/tool_runner.rb +18 -9
  49. data/lib/samagotchi/update_command.rb +1 -1
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/public/question_card.js +4 -3
  52. metadata +6 -1
@@ -41,6 +41,10 @@ module Samagotchi
41
41
  ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME GEM_HOME GEM_PATH RUBYLIB].freeze
42
42
  # Without it Ruby reads stdin as US-ASCII and non-ASCII notes break.
43
43
  LANG = "en_US.UTF-8"
44
+ # chi broadcast's default triage deadline, and the helper's seconds on
45
+ # top of it (MacOS.broadcast_timeout).
46
+ BROADCAST_TRIAGE_DEADLINE = 20
47
+ BROADCAST_MARGIN = 10
44
48
 
45
49
  # Runs a command without a shell; [output, success].
46
50
  class Runner
@@ -62,11 +66,25 @@ module Samagotchi
62
66
  { "listen_on" => listen_on, "binary" => get.call("kitty.binary").to_s, "agents" => agents }
63
67
  end
64
68
 
69
+ # The helper as chi desktop and chi update make it: the settings it
70
+ # bakes into the launch file read from the config.
71
+ def self.from_config(register: true)
72
+ new(register: register, kitty: kitty_settings, broadcast_timeout: broadcast_timeout)
73
+ end
74
+
75
+ # Seconds the helper gives `chi broadcast` before it stops it: the
76
+ # triage deadline, then the deliveries and chi's own start. Baked into
77
+ # the launch file: a changed deadline reaches the helper with chi update.
78
+ def self.broadcast_timeout(get = Config.method(:get))
79
+ (get.call("broadcast.triage_deadline") || BROADCAST_TRIAGE_DEADLINE).to_f + BROADCAST_MARGIN
80
+ end
81
+
65
82
  attr_reader :app_dir, :support_dir
66
83
 
67
84
  def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
68
85
  source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
69
- arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, kitty: nil)
86
+ arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, kitty: nil,
87
+ broadcast_timeout: nil)
70
88
  home = env["HOME"] || Dir.home
71
89
  @app_dir = app_dir || File.join(home, "Applications")
72
90
  @support_dir = support_dir || File.join(home, "Library", "Application Support", APP_NAME)
@@ -80,6 +98,7 @@ module Samagotchi
80
98
  @sources_dir = sources_dir
81
99
  @register = register
82
100
  @kitty = kitty
101
+ @broadcast_timeout = broadcast_timeout
83
102
  end
84
103
 
85
104
  def app_path = File.join(@app_dir, "#{APP_NAME}.app")
@@ -93,12 +112,13 @@ module Samagotchi
93
112
  # shell's PATH), and the allowlisted env. chi is an installed gem's
94
113
  # RubyGems wrapper (it activates the gem and outlives upgrades and
95
114
  # `gem cleanup`), else this checkout's bin/chi. The kitty section only
96
- # when kitty.listen_on is set.
115
+ # when kitty.listen_on is set; the broadcast timeout when given.
97
116
  def launch_config
98
117
  env = { "LANG" => LANG }
99
118
  ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
100
119
  config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
101
120
  config["kitty"] = @kitty if @kitty
121
+ config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
102
122
  config
103
123
  end
104
124
 
@@ -122,13 +142,15 @@ module Samagotchi
122
142
  end
123
143
 
124
144
  # The launch file names another chi version or path than this one, or
125
- # other kitty settings: refresh_launch_file fixes that without a rebuild.
145
+ # other kitty settings or broadcast timeout: refresh_launch_file fixes
146
+ # that without a rebuild.
126
147
  def launch_outdated?
127
148
  launch = read_launch
128
- launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] || launch["kitty"] != @kitty
149
+ launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] || launch["kitty"] != @kitty ||
150
+ launch["broadcast_timeout"] != @broadcast_timeout
129
151
  end
130
152
 
131
- # Rewrite the launch file for this chi and the kitty settings, keeping
153
+ # Rewrite the launch file for this chi and its settings, keeping
132
154
  # the env it was installed with. The app reads it at each send: no restart.
133
155
  def refresh_launch_file
134
156
  config = launch_config
@@ -41,7 +41,7 @@ module Samagotchi
41
41
  @argv = argv.dup
42
42
  @stdout = stdout
43
43
  @stderr = stderr
44
- @platform = platform || ->(register:) { Desktop::MacOS.new(register: register, kitty: Desktop::MacOS.kitty_settings) }
44
+ @platform = platform || Desktop::MacOS.method(:from_config)
45
45
  @supported = supported
46
46
  end
47
47
 
@@ -1531,8 +1531,9 @@ module Samagotchi
1531
1531
  # @param on_event [Proc, nil] receives event hashes
1532
1532
  # @param max_iterations [Integer, nil] max kernel iterations (nil: IterationLimit.for)
1533
1533
  # @param cancel_controller [CancellationController, nil]
1534
- # @param max_tool_output_chars [Integer, nil] per-output char cap for the
1535
- # :tool_call_completed event's `output:` (nil → max_tool_output_chars)
1534
+ # @param max_tool_output_chars [Integer, nil] per-output char cap on what
1535
+ # the model gets back and the :tool_call_completed event's `output:`
1536
+ # (nil → max_tool_output_chars)
1536
1537
  # @return [LLM::ModelResult]
1537
1538
  # @param pending_input [#call, nil] optional drain proc returning
1538
1539
  # Array<String> of steering messages queued while the turn runs; drained
@@ -2352,11 +2353,10 @@ module Samagotchi
2352
2353
  # @return [String] the answer
2353
2354
  def ask_side_model(request, timeout:, max_tokens:, cancel_controller:)
2354
2355
  target = session_model_recap_target
2355
- client = IdleClient.new(model: target[:model], base_url: target[:base_url], api_key_env: target[:api_key_env],
2356
- timeout: timeout)
2356
+ client = IdleClient.for(target, timeout: timeout)
2357
2357
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
2358
2358
  answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller)
2359
- Log.info(:plugins, "ask_model", model: target[:label], answer_model: answer.model, chars: answer.text.length,
2359
+ Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
2360
2360
  ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
2361
2361
  answer.text
2362
2362
  end
@@ -2372,10 +2372,9 @@ module Samagotchi
2372
2372
  # The session's current model as a recap target: its host's OpenAI API
2373
2373
  # (native llama.cpp hosts serve /v1/chat/completions too), key variable
2374
2374
  # and bare model name, as a turn resolves them.
2375
+ # @return [IdleTarget]
2375
2376
  def session_model_recap_target
2376
- target = @host_registry.resolve(@effective_model_name)
2377
- { base_url: target.openai_base_url, api_key_env: target.entry.api_key_env,
2378
- model: target.bare_model, label: @effective_model_name.to_s }
2377
+ IdleTarget.of_model(@host_registry, @effective_model_name)
2379
2378
  end
2380
2379
 
2381
2380
  # Build the idle reminders job. Always created (reminders are opt-in
@@ -226,8 +226,13 @@ module Samagotchi
226
226
  return verdict.settle!(:deny, decided_by: "no one", note: "No one to approve it (non-interactive run).")
227
227
  end
228
228
 
229
- # A plugin tool is asked about by its label, as its row shows it.
230
- label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools_lookup.call)
229
+ # A plugin tool is asked about by its targets:' label:, else its
230
+ # label, as its row shows it; a call that acts as another tool (an MCP
231
+ # tool behind mcp_call) by that tool's label, else (none registered)
232
+ # by its name (Approval.payload).
233
+ acts_as = verdict.targets&.acts_as
234
+ label = verdict.targets&.label ||
235
+ ToolActivity.plugin_label(acts_as || verdict.call[:name].to_s, registry: @tools_lookup.call)
231
236
  # verdict.call is the call that will run (a hook may have replaced it).
232
237
  payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call),
233
238
  only: (SCRATCH_SCOPES if @scratch))
@@ -18,7 +18,8 @@ module Samagotchi
18
18
 
19
19
  # @param verdict [Verdict] an ask, with its targets and context
20
20
  # @param label [String, nil] a plugin tool's label ("chrome:
21
- # screenshot"): the question names the tool by it, as its row does;
21
+ # screenshot"): the question names the tool by it, as its row does
22
+ # (without one, by the tool the call acts as, else its name);
22
23
  # approval[:tool] stays the raw name
23
24
  # @param preview [Hash, nil] EditPreview.for(verdict.call) for edit and
24
25
  # write: the card shows its diff, the question text one change line
@@ -37,6 +38,8 @@ module Samagotchi
37
38
  kind: KIND,
38
39
  approval: {
39
40
  tool: targets&.tool || verdict.call[:name].to_s,
41
+ # The tool the call acts as (an MCP tool behind mcp_call).
42
+ acts_as: targets&.acts_as,
40
43
  label: label,
41
44
  command: targets&.command,
42
45
  paths: targets && !targets.paths.empty? ? targets.paths : nil,
@@ -126,7 +129,7 @@ module Samagotchi
126
129
  # change: +3 −1 (edit/write, from the preview)
127
130
  def question_text(verdict, label: nil, preview: nil)
128
131
  targets = verdict.targets
129
- tool = label || targets&.tool || verdict.call[:name].to_s
132
+ tool = label || targets&.acts_as || targets&.tool || verdict.call[:name].to_s
130
133
  what = targets&.command || (targets && targets.paths.join(", "))
131
134
  what = Approval.args_text(targets&.args, limit: ARGS_CHARS) if what.to_s.empty?
132
135
  lines = ["#{tool}: #{what}"]
@@ -90,7 +90,7 @@ module Samagotchi
90
90
  return nil unless STORED_SCOPES.include?(scope)
91
91
 
92
92
  entry = { "scope" => scope, "tool" => verdict.targets&.tool || verdict.call[:name].to_s,
93
- "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
93
+ "acts_as" => verdict.targets&.acts_as, "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
94
94
  case scope
95
95
  when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
96
96
  when "repo"
@@ -110,14 +110,18 @@ module Samagotchi
110
110
 
111
111
  # tool + the normalized command, or the sorted paths; for a plugin
112
112
  # tool with neither (an MCP tool), its arguments, so "this call" is
113
- # this call.
113
+ # this call. A call that acts as another tool keys as
114
+ # `mcp_call>mcp_github_x`: the real name stays in, so it never reuses
115
+ # that tool's own approvals (nor they its).
114
116
  def self.key_for(verdict)
115
117
  t = verdict.targets
116
118
  return "#{verdict.call[:name]}:" unless t
117
- return "#{t.tool}:#{t.command.to_s.strip.gsub(/\s+/, " ")}" if t.command
118
- return "#{t.tool}:#{Approval.args_text(t.args)}" if t.paths.empty?
119
119
 
120
- "#{t.tool}:#{t.paths.sort.join("\n")}"
120
+ tool = t.acts_as ? "#{t.tool}>#{t.acts_as}" : t.tool
121
+ return "#{tool}:#{t.command.to_s.strip.gsub(/\s+/, " ")}" if t.command
122
+ return "#{tool}:#{Approval.args_text(t.args)}" if t.paths.empty?
123
+
124
+ "#{tool}:#{t.paths.sort.join("\n")}"
121
125
  end
122
126
 
123
127
  def self.place_for(verdict)
@@ -9,7 +9,9 @@ module Samagotchi
9
9
  # Declarative rules from config.yml's `guardrails:` section (and, later,
10
10
  # installed bundles). All the fields a rule gives must match:
11
11
  # tool: a tool name, a list, or "shell" (execute + task_create);
12
- # a name may be a glob ("mcp_*", "mcp_{git,gh}_*")
12
+ # a name may be a glob ("mcp_*", "mcp_{git,gh}_*"); it
13
+ # matches the call's name or the tool a plugin tool's call
14
+ # acts as (Targets#acts_as: an MCP tool behind mcp_call)
13
15
  # command: a Ruby regex on a shell tool's command
14
16
  # path: "outside_repo", or a glob on the resolved paths (absolute
15
17
  # or "**/…" globs match the absolute path; others the path
@@ -51,7 +53,7 @@ module Samagotchi
51
53
  def matches?(targets)
52
54
  return false unless targets
53
55
  return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
54
- return false if tools && !tool_matches?(targets.tool)
56
+ return false if tools && !tool_matches?(targets.tool) && !(targets.acts_as && tool_matches?(targets.acts_as))
55
57
  return false if command && !(targets.command && command.match?(targets.command))
56
58
  return false if path && !path_matches?(targets)
57
59
  return false if git && !targets.git_outside_repo?
@@ -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,25 @@ 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
+
45
63
  # @return [Boolean] a memory_write call that removes a memory (remove:
46
64
  # true), for a rule's memory: remove
47
65
  attr_reader :memory_remove
@@ -61,6 +79,8 @@ module Samagotchi
61
79
  paths = []
62
80
  cwd = base
63
81
  args = nil
82
+ acts_as = nil
83
+ label = nil
64
84
  case tool
65
85
  when *SHELL_TOOLS
66
86
  command = call[:content].to_s
@@ -79,21 +99,27 @@ module Samagotchi
79
99
  cwd = File.expand_path(given_cwd, base) unless given_cwd.empty?
80
100
  paths.concat(given[:paths].map { |path| absolute(path, cwd) })
81
101
  entry = registry && registry[tool]
82
- 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]
83
105
  end
84
106
  memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
85
107
  new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
86
- repo: context.repo(cwd), args: args, memory_remove: memory_remove,
108
+ repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, memory_remove: memory_remove,
87
109
  model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
88
110
  chi_dirs: -> { chi_dirs(context, base) })
89
111
  end
90
112
 
91
113
  # What a plugin tool's targets: callable says the call acts on:
92
- # {command:, paths:, cwd:}, each optional; a callable that raises or
93
- # 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).
94
120
  def self.plugin_targets(call, registry)
95
121
  entry = registry && registry[call[:name].to_s]
96
- none = { command: nil, paths: [], cwd: nil }
122
+ none = { command: nil, paths: [], cwd: nil, acts_as: nil, args: nil, label: nil }
97
123
  return none unless entry && !entry.core? && entry.targets
98
124
 
99
125
  given = entry.targets.call(call)
@@ -101,13 +127,50 @@ module Samagotchi
101
127
 
102
128
  given = given.transform_keys(&:to_sym)
103
129
  command = given[:command].to_s
104
- { 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) }
105
133
  rescue StandardError => e
106
134
  Log.warn(:plugins, "plugin_targets_failed", tool: call[:name].to_s, error: e.class.name,
107
135
  msg: "#{call[:name]} targets failed: #{e.message}")
108
136
  none
109
137
  end
110
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
+
111
174
  # chi's own dirs (ParentApprovals.chi_dirs) and the session repo's
112
175
  # git hooks dir.
113
176
  def self.chi_dirs(context, base)
@@ -133,8 +196,10 @@ module Samagotchi
133
196
  end
134
197
 
135
198
  # @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
136
- def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, model_name: nil, model_key: nil,
137
- session_root: nil, chi_dirs: nil, memory_remove: false)
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
138
203
  @memory_remove = memory_remove
139
204
  @repo = repo
140
205
  @chi_dirs = chi_dirs
@@ -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