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
@@ -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
@@ -176,8 +176,9 @@ module Samagotchi
176
176
  # @param on_stream_event [Proc, nil] optional callback for generation events
177
177
  # @param cancel_controller [CancellationController, nil] optional cancellation source
178
178
  # @param model_name [String, nil] optional per-run model override
179
- # @param max_tool_output_chars [Integer, nil] per-output char cap for the
180
- # :tool_call_completed event's `output:` (nil → max_tool_output_chars)
179
+ # @param max_tool_output_chars [Integer, nil] per-output char cap on what
180
+ # the model gets back and the :tool_call_completed event's `output:`
181
+ # (nil → max_tool_output_chars)
181
182
  # @param pending_input [#call, nil] optional drain proc returning
182
183
  # Array<String> of user steering messages queued while the turn runs.
183
184
  # Drained at iteration boundaries (llama.cpp's /completion cannot accept
@@ -223,7 +224,8 @@ module Samagotchi
223
224
  @model_key_fallback = fallback
224
225
  end
225
226
 
226
- # Resolve the per-output character cap for the emitted tool call events.
227
+ # Resolve the per-output character cap for the tool outputs the model
228
+ # gets back and the emitted tool call events.
227
229
  #
228
230
  # Precedence: an explicit override wins, then max_tool_output_chars
229
231
  # (Config). A non-positive value falls back to
@@ -539,7 +541,7 @@ module Samagotchi
539
541
  runs = ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
540
542
  on_stream_event: turn.on_stream_event, cap: turn.cap)
541
543
  turn.tool_activity.concat(ToolResponse.activities(runs))
542
- # One entry for the batch: the full outputs joined (ToolResponse.joined).
544
+ # One entry for the batch: the capped outputs joined (ToolResponse.joined).
543
545
  turn.conversation << ToolResponse.joined(runs)
544
546
  turn.pending_tool_calls = true
545
547
  :next
@@ -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
@@ -178,9 +178,11 @@ module Samagotchi
178
178
  # down) and returns a short summary ("3 tools") or raises (the UIs
179
179
  # show a warn card). Every UI shows it running (the label) and done.
180
180
  # @param label [String] what it does, shown while it runs
181
- # @param provides_tools [Boolean] it registers tools (chi.replace_tools):
182
- # a turn sent meanwhile waits for it before its first model request,
183
- # up to +timeout+; a Ctrl-C ends the wait
181
+ # @param provides_tools [Boolean] it provides what the plugin's tools
182
+ # need (its own, or through chi.replace_tools; the mcp bundle's
183
+ # index of a server's tools): a turn sent meanwhile waits for it
184
+ # before its first model request, up to +timeout+; a Ctrl-C ends
185
+ # the wait
184
186
  # @param quiet [Boolean] shown only if it fails (a background refresh)
185
187
  # @param timeout [Numeric, nil] seconds a turn waits for it (default 60)
186
188
  # @param failed [String, nil] the warn card's short title if it raises
@@ -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
@@ -4,9 +4,9 @@ module Samagotchi
4
4
  # A tool batch and the conversation entries its results become, for both
5
5
  # loops. The calls run through ToolRunner between the dispatch events;
6
6
  # what the model gets back is per format: the native loop answers a batch
7
- # with ONE tool_response (#joined: the full outputs), the chat loop each
8
- # call with its own, paired by tool_call_id (#single: the capped output,
9
- # D-P2-6).
7
+ # with ONE tool_response (#joined), the chat loop each call with its own,
8
+ # paired by tool_call_id (#single). Both feed the capped outputs
9
+ # (max_tool_output_chars).
10
10
  module ToolResponse
11
11
  SEPARATOR = "\n\n---\n\n"
12
12
  # Saved with a result for the web's reload (a plugin tool's params line
@@ -37,12 +37,12 @@ module Samagotchi
37
37
  runs.filter_map { |run| run[:activity] }
38
38
  end
39
39
 
40
- # The native loop's entry for a batch: the outputs joined, every image
40
+ # The native loop's entry for a batch: the capped outputs joined, every image
41
41
  # in call order with how many each call returned (the web's reload puts
42
42
  # each on its own tool row), and the saved-only fields one per call.
43
43
  def joined(runs)
44
44
  images = runs.flat_map { |run| Array(run[:images]) }
45
- entry = { role: "tool_response", content: runs.map { |run| run[:output] }.join(SEPARATOR) }
45
+ entry = { role: "tool_response", content: runs.map { |run| run[:capped_output] }.join(SEPARATOR) }
46
46
  unless images.empty?
47
47
  entry[:images] = images
48
48
  entry[:image_counts] = runs.map { |run| Array(run[:images]).size }
@@ -13,9 +13,9 @@ module Samagotchi
13
13
  # The single per-call path both loops use: the tool_call_started and
14
14
  # tool_call_completed events, the guardrail gate (before_tool_call hooks
15
15
  # and their veto), dispatch through KernelLoop, the after_tool_call hook
16
- # and the output cap. Each loop picks what the model gets back: native
17
- # feeds the full `output:`, the chat loop feeds `capped_output:` (the cap
18
- # on the event applies to both).
16
+ # and the output cap. Both loops feed the model `capped_output:` (cut to
17
+ # the cap with a "[cut: N of M chars …]" line), the text the event and
18
+ # the hook get too.
19
19
  class ToolRunner
20
20
  # More images in one result are left out, each with a line (a tool
21
21
  # that returns many screenshots can't flood the context).
@@ -83,12 +83,8 @@ module Samagotchi
83
83
  if (changed = changed_call_line(original, call))
84
84
  output = "#{changed}\n#{output}"
85
85
  end
86
- capped = output
87
- truncated = false
88
- if max_tool_output_chars && output.length > max_tool_output_chars
89
- truncated = true
90
- capped = output[0, max_tool_output_chars]
91
- end
86
+ truncated = !max_tool_output_chars.nil? && output.length > max_tool_output_chars
87
+ capped = truncated ? cut(output, max_tool_output_chars) : output
92
88
 
93
89
  # The call's outcome as its activity line has it (worked out from the
94
90
  # full output: ok, error, blocked, stopped); a dispatcher that raised
@@ -118,6 +114,19 @@ module Samagotchi
118
114
 
119
115
  private
120
116
 
117
+ # +output+ cut to +cap+ chars, the cut line included: the model knows
118
+ # what it didn't get. A cap smaller than the line keeps +cap+ chars and
119
+ # the line goes past it.
120
+ def cut(output, cap)
121
+ keep = cap - cut_line(cap, output.length).length
122
+ keep = cap unless keep.positive?
123
+ "#{output[0, keep]}#{cut_line(keep, output.length)}"
124
+ end
125
+
126
+ def cut_line(kept, total)
127
+ "\n[cut: #{kept} of #{total} chars; read it in parts]"
128
+ end
129
+
121
130
  # edit/write only: the file just before the call runs. The row diffs it
122
131
  # with the file after, whatever the result says, so an edit that fails
123
132
  # after writing still shows its change and one that wrote nothing shows
@@ -80,7 +80,7 @@ module Samagotchi
80
80
  @stderr = stderr
81
81
  @gem_spec = gem_spec
82
82
  @shipped_dir = shipped_dir
83
- @platform = platform || ->(register:) { Desktop::MacOS.new(register: register, kitty: Desktop::MacOS.kitty_settings) }
83
+ @platform = platform || Desktop::MacOS.method(:from_config)
84
84
  @supported = supported
85
85
  @state_dir = state_dir
86
86
  @web = web || [Web::Lan.local_host(Config.get("web.host")), Config.get("web.port").to_i]
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.36.0"
4
+ VERSION = "0.38.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.38.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
@@ -234,6 +237,7 @@ files:
234
237
  - lib/samagotchi/desktop.rb
235
238
  - lib/samagotchi/desktop/macos.rb
236
239
  - lib/samagotchi/desktop/macos/App.swift
240
+ - lib/samagotchi/desktop/macos/Broadcast.swift
237
241
  - lib/samagotchi/desktop/macos/ChiRunner.swift
238
242
  - lib/samagotchi/desktop/macos/Hotkey.swift
239
243
  - lib/samagotchi/desktop/macos/Images.swift
@@ -284,6 +288,7 @@ files:
284
288
  - lib/samagotchi/idle_recap.rb
285
289
  - lib/samagotchi/idle_reminders.rb
286
290
  - lib/samagotchi/idle_scheduler.rb
291
+ - lib/samagotchi/idle_target.rb
287
292
  - lib/samagotchi/image_store.rb
288
293
  - lib/samagotchi/installed_gem.rb
289
294
  - lib/samagotchi/installed_versions.rb