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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
3
4
  require_relative "config"
4
5
  require_relative "session"
5
6
  require_relative "session_inbox"
@@ -7,6 +8,9 @@ require_relative "note_delivery"
7
8
  require_relative "broadcast/recipients"
8
9
  require_relative "broadcast/scope_card"
9
10
  require_relative "broadcast/tags"
11
+ require_relative "broadcast/triage"
12
+ require_relative "broadcast/triage_model"
13
+ require_relative "broadcast/triage_log"
10
14
  require_relative "cli/command"
11
15
  require_relative "cli/flags"
12
16
 
@@ -14,8 +18,11 @@ module Samagotchi
14
18
  # `chi broadcast`: share a note with every session it may concern,
15
19
  # without picking them. Each recipient (Broadcast::Recipients) that shares
16
20
  # a tag with the note (Broadcast::Tags) gets it as a context note from
17
- # "broadcast"; --all gives it to every recipient. The rest are listed as
18
- # skipped, with why. For the user only: refused inside a chi session.
21
+ # "broadcast", and a triage model judges the rest (Broadcast::Triage);
22
+ # --all gives it to every recipient. The rest are listed as skipped, with
23
+ # why. Each broadcast's verdicts go to Broadcast::TriageLog (`chi
24
+ # broadcast log`), and `chi broadcast deliver` gives one to sessions it
25
+ # skipped. For the user only: refused inside a chi session.
19
26
  class BroadcastCommand
20
27
  include CLI::Command
21
28
 
@@ -31,6 +38,8 @@ module Samagotchi
31
38
 
32
39
  USAGE = <<~TEXT
33
40
  Usage: chi broadcast [-m TEXT] [--all] [--dry-run]
41
+ chi broadcast log [--last N] [--format json]
42
+ chi broadcast deliver BROADCAST_ID (ID|PREFIX)...
34
43
  Shares TEXT (or stdin) with the sessions it may concern, as a context
35
44
  note from "broadcast": background the model sees on its next turn,
36
45
  not a prompt. It starts no turn.
@@ -39,10 +48,20 @@ module Samagotchi
39
48
  the last broadcast.active_hours (8). One gets the note when it shares
40
49
  a tag with it: a ticket id (PAY-123) in its branch or your prompts
41
50
  there, a pull request (PR #42, its URL) attached to it, a link in
42
- your prompts there or its attached context. The rest are skipped.
51
+ your prompts there or its attached context. A triage model
52
+ (broadcast.triage_model, else the recap's, else default.model) reads
53
+ the note and each other session's scope card and says whether it
54
+ concerns it; one it can't judge in broadcast.triage_deadline (20 s)
55
+ gets it unchecked. A first line naming one project keeps the note to
56
+ that project's sessions.
43
57
  -m TEXT the note; without it, stdin is read
44
- --all every recipient, tag or not
58
+ --all every recipient, no tags or triage
45
59
  --dry-run show each recipient's scope card, tags and verdict; deliver nothing
60
+ It prints the broadcast's id (b-7f3a1c9e), then a line per recipient.
61
+ log: the last N broadcasts (5) and each recipient's verdict, from
62
+ the triage log; --format json for a script.
63
+ deliver: the broadcast to sessions it skipped, after all (kept in
64
+ the log as a correction).
46
65
  For you, not for an agent: refused inside a chi session.
47
66
  One session or a few by id: chi note. See docs/broadcast.md.
48
67
  TEXT
@@ -53,17 +72,33 @@ module Samagotchi
53
72
  f.value "-m", "--message", key: :text
54
73
  end
55
74
 
56
- # One recipient's verdict. +match+: the tag that delivers it (nil with
57
- # --all or none); +skip+: why it is skipped, else nil.
58
- Verdict = Data.define(:recipient, :match, :skip) do
59
- def deliver? = skip.nil?
75
+ LOG_FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS, args: false) do |f|
76
+ f.value "--last"
77
+ f.value "--format"
78
+ end
79
+
80
+ DELIVER_FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS)
81
+
82
+ # The broadcasts `chi broadcast log` shows by default.
83
+ LOG_LAST = 5
84
+
85
+ # One recipient and its Broadcast::Triage::Verdict.
86
+ Decision = Data.define(:recipient, :verdict) do
87
+ def deliver? = verdict.relevant
60
88
  end
61
89
 
62
90
  # @param argv [Array<String>] the arguments after "broadcast"
63
91
  # @param active_hours [Numeric, nil] broadcast.active_hours (config)
64
92
  # @param ticket_pattern [String, nil] broadcast.ticket_pattern (config)
93
+ # @param triage [#call, nil] (CancellationController) → a triage
94
+ # backend (#judge(note, card) → Verdict); nil: Triage::LLM on
95
+ # Broadcast::TriageModel's model
96
+ # @param log [Broadcast::TriageLog, nil] nil: the default path's
65
97
  def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, env: ENV, now: Time.now,
66
- active_hours: Config.get("broadcast.active_hours"), ticket_pattern: Config.get("broadcast.ticket_pattern"))
98
+ active_hours: Config.get("broadcast.active_hours"), ticket_pattern: Config.get("broadcast.ticket_pattern"),
99
+ triage: nil, triage_parallel: Config.get("broadcast.triage_parallel"),
100
+ triage_deadline: Config.get("broadcast.triage_deadline"), threshold: Config.get("broadcast.threshold"),
101
+ log: nil)
67
102
  @argv = argv.dup
68
103
  @stdin = stdin
69
104
  @stdout = stdout
@@ -73,18 +108,24 @@ module Samagotchi
73
108
  @now = now
74
109
  @active_hours = active_hours || 8
75
110
  @ticket = Broadcast::Tags.ticket_regexp(ticket_pattern, warn: ->(line) { @stderr.puts("chi broadcast: #{line}") })
111
+ @triage = triage
112
+ @triage_parallel = (triage_parallel || Broadcast::Triage::DEFAULT_PARALLEL).to_i
113
+ @triage_deadline = (triage_deadline || Broadcast::Triage::DEFAULT_DEADLINE).to_f
114
+ @threshold = (threshold || Broadcast::Triage::DEFAULT_THRESHOLD).to_f
115
+ @log = log || Broadcast::TriageLog.new
76
116
  end
77
117
 
78
118
  # @return [Integer] exit status: 0 done (skipped sessions included),
79
119
  # 1 refused, nothing to send to or a delivery failed, 2 usage
80
120
  def run
121
+ case @argv.first
122
+ when "log" then return run_log(@argv.drop(1))
123
+ when "deliver" then return run_deliver(@argv.drop(1))
124
+ end
125
+
81
126
  options = parse
82
127
  return options if options.is_a?(Integer)
83
-
84
- unless @env[PARENT_ENV].to_s.empty?
85
- error_line("chi broadcast: chi broadcast is for your user, not an agent")
86
- return 1
87
- end
128
+ return refused if agent?
88
129
 
89
130
  text = utf8(options[:text] || read_stdin)
90
131
  return usage_error("no note text: pass -m TEXT or pipe it in") unless text
@@ -113,6 +154,15 @@ module Samagotchi
113
154
  parsed.is_a?(Integer) ? parsed : parsed.options
114
155
  end
115
156
 
157
+ # Inside a chi session (any SAMAGOTCHI_PARENT_SESSION, "chi" included):
158
+ # an agent runs it.
159
+ def agent? = !@env[PARENT_ENV].to_s.empty?
160
+
161
+ def refused
162
+ error_line("chi broadcast: chi broadcast is for your user, not an agent")
163
+ 1
164
+ end
165
+
116
166
  # @return [Integer] exit status
117
167
  def broadcast(text, all:, dry_run:)
118
168
  recipients = Broadcast::Recipients.list(state_dir: @state_dir, active_hours: @active_hours, now: @now)
@@ -124,73 +174,247 @@ module Samagotchi
124
174
 
125
175
  note_tags = Broadcast::Tags.of_text(text, from: "note", ticket: @ticket)
126
176
  cards = recipients.to_h { |r| [r.id, Broadcast::ScopeCards.build(r, state_dir: @state_dir, ticket: @ticket)] }
127
- verdicts = recipients.map { |r| verdict(r, cards.fetch(r.id), note_tags, all: all) }
128
- verdicts = verdicts.select(&:deliver?) + verdicts.reject(&:deliver?)
129
-
130
- @stdout.puts("broadcast#{" (dry run: nothing is delivered)" if dry_run} #{headline(text)}")
177
+ id = "b-#{SecureRandom.hex(4)}" unless dry_run
178
+ @stdout.puts("broadcast #{id || "(dry run: nothing is delivered)"} #{headline(text)}")
131
179
  @stdout.puts("note tags: #{note_tags.empty? ? "none" : note_tags.map(&:label).join(" · ")}") if dry_run
132
- return dry_run(verdicts, cards) if dry_run
180
+ @stdout.flush
181
+ decisions = decide(text, recipients, cards, note_tags, all: all)
182
+ decisions = decisions.select(&:deliver?) + decisions.reject(&:deliver?)
183
+ @stdout.puts("triage model: #{@triage_choice.target&.label || "none"} (#{@triage_choice.setting})") if dry_run && @triage_choice
184
+ return dry_run(decisions, cards) if dry_run
185
+
186
+ deliver_all(Broadcast::TriageLog::Record.new(id: id, at: @now, text: text, note_tags: note_tags.map(&:label),
187
+ triage_model: @triage_choice&.target&.label, recipients: [],
188
+ corrections: []), decisions, cards)
189
+ end
190
+
191
+ # Every recipient's verdict: a chi REPL takes no notes, --all takes the
192
+ # rest, else Broadcast::Triage (tags, the scope line, the model).
193
+ # @return [Array<Decision>] in +recipients+' order
194
+ def decide(text, recipients, cards, note_tags, all:)
195
+ fixed = recipients.to_h do |r|
196
+ verdict = if r.repl?
197
+ Broadcast::Triage::Verdict.new(relevant: false, p: nil, reason: "open in a chi REPL", by: "repl")
198
+ elsif all
199
+ Broadcast::Triage::Verdict.new(relevant: true, p: nil, reason: "--all", by: "all")
200
+ end
201
+ [r.id, verdict]
202
+ end
203
+ judged = recipients.reject { |r| fixed[r.id] }.map { |r| cards.fetch(r.id) }
204
+ triaged = Broadcast::Triage.verdicts(text, judged, note_tags: note_tags, new_backend: method(:triage_backend),
205
+ parallel: @triage_parallel, deadline: @triage_deadline)
206
+ recipients.map { |r| Decision.new(recipient: r, verdict: fixed[r.id] || triaged.fetch(r.id)) }
207
+ end
208
+
209
+ # One triage backend for a thread (Triage.judge_all): the injected
210
+ # one, or Triage::LLM on the triage model, resolved once; with no
211
+ # model to ask, one that delivers unchecked and says why.
212
+ def triage_backend(cancel)
213
+ return @triage.call(cancel) if @triage
133
214
 
134
- deliver_all(verdicts, text)
215
+ choice = triage_choice
216
+ return UncheckedBackend.new("no triage model: #{choice.problem}") unless choice.target
217
+
218
+ Broadcast::Triage::LLM.new(target: choice.target, timeout: @triage_deadline, threshold: @threshold,
219
+ cancel_controller: cancel)
135
220
  end
136
221
 
137
- def verdict(recipient, card, note_tags, all:)
138
- return Verdict.new(recipient: recipient, match: nil, skip: "open in a chi REPL") if recipient.repl?
222
+ def triage_choice
223
+ (@triage_lock ||= Mutex.new).synchronize do
224
+ @triage_choice ||= Broadcast::TriageModel.resolve.tap do |choice|
225
+ error_line("chi broadcast: no triage model: #{choice.problem}; delivering unchecked") unless choice.target
226
+ end
227
+ end
228
+ end
139
229
 
140
- match = Broadcast::Tags.match(note_tags, card.tags)
141
- skip = "no tag match (not checked: no triage yet)" unless all || match
142
- Verdict.new(recipient: recipient, match: match, skip: skip)
230
+ # A triage backend with no model: every card it gets is delivered
231
+ # unchecked, +why+ in the reason.
232
+ UncheckedBackend = Data.define(:why) do
233
+ def judge(_note, _card) = Broadcast::Triage.unchecked(why)
143
234
  end
144
235
 
145
- def dry_run(verdicts, cards)
146
- verdicts.each do |v|
147
- @stdout.puts(line(v.recipient, v.deliver? ? "would get it" : "skipped", reason(v), width: 12))
148
- @stdout.puts(cards.fetch(v.recipient.id).to_s.gsub(/^/, " "))
236
+ def dry_run(decisions, cards)
237
+ decisions.each do |d|
238
+ @stdout.puts(line(d.recipient, d.deliver? ? "would get it" : "skipped", d.verdict.reason, width: 12))
239
+ @stdout.puts(cards.fetch(d.recipient.id).to_s.gsub(/^/, " "))
149
240
  end
150
- delivered = verdicts.count(&:deliver?)
151
- @stdout.puts("would deliver #{delivered} · skipped #{verdicts.size - delivered}")
241
+ delivered = decisions.count(&:deliver?)
242
+ @stdout.puts("would deliver #{delivered} · skipped #{decisions.size - delivered}#{unchecked_summary(decisions)}")
152
243
  0
153
244
  end
154
245
 
155
- def deliver_all(verdicts, text)
246
+ # Delivers +broadcast+ (a TriageLog::Record with no recipients yet) per
247
+ # +decisions+, prints a line per recipient and the summary, and logs it.
248
+ def deliver_all(broadcast, decisions, cards)
156
249
  ok = true
157
- lines = verdicts.map do |v|
158
- next [v.recipient, false, "skipped", v.skip] unless v.deliver?
250
+ lines = decisions.map do |d|
251
+ next [d, "skipped", d.verdict.reason] unless d.deliver?
159
252
 
160
- result = NoteDelivery.deliver(v.recipient.id, text: body(text, v.match), source: SOURCE, state_dir: @state_dir)
161
- next [v.recipient, false, "skipped", "open in a chi REPL"] unless result.delivered?
253
+ result = NoteDelivery.deliver(d.recipient.id, text: body(broadcast.text, d.verdict), source: SOURCE,
254
+ state_dir: @state_dir)
255
+ next [d, "skipped", "open in a chi REPL"] unless result.delivered?
162
256
 
163
- [v.recipient, true, "delivered", "#{reason(v)}#{"; waits for its next start" if result.status == :waits}"]
257
+ [d, "delivered", "#{d.verdict.reason}#{"; waits for its next start" if result.status == :waits}"]
164
258
  rescue SessionInbox::NoteRejected, SystemCallError => e
165
259
  ok = false
166
- [v.recipient, false, "failed", e.message]
260
+ [d, "failed", e.message]
167
261
  end
168
- lines.sort_by.with_index { |(_, delivered), i| [delivered ? 0 : 1, i] }.each do |recipient, _, word, why|
169
- @stdout.puts(line(recipient, word, why))
262
+ lines.sort_by.with_index { |(_, word), i| [word == "delivered" ? 0 : 1, i] }.each do |d, word, why|
263
+ @stdout.puts(line(d.recipient, word, why))
170
264
  end
171
- delivered = lines.count { |_, d| d }
172
- @stdout.puts("delivered #{delivered} · skipped #{lines.size - delivered}")
265
+ delivered = lines.count { |_, word| word == "delivered" }
266
+ @stdout.puts("delivered #{delivered} · skipped #{lines.size - delivered}#{unchecked_summary(decisions)}")
267
+ write_log(broadcast, lines, cards)
173
268
  ok ? 0 : 1
174
269
  end
175
270
 
271
+ # The broadcast's verdicts into the triage log; a log that can't be
272
+ # written is a warning, not a failed broadcast.
273
+ def write_log(broadcast, lines, cards)
274
+ entries = lines.map do |d, word, why|
275
+ verdict = d.verdict
276
+ Broadcast::TriageLog::Entry.new(session: d.recipient.id, result: word, p: verdict.p, reason: why, by: verdict.by,
277
+ tags: cards.fetch(d.recipient.id).tags.map(&:label))
278
+ end
279
+ @log.append_broadcast(id: broadcast.id, at: broadcast.at, text: broadcast.text, note_tags: broadcast.note_tags,
280
+ triage_model: broadcast.triage_model, recipients: entries)
281
+ rescue SystemCallError => e
282
+ error_line("chi broadcast: the triage log #{@log.path} couldn't be written: #{e.message}")
283
+ end
284
+
285
+ # `chi broadcast log`. @return [Integer] exit status
286
+ def run_log(argv)
287
+ parsed = parse_flags(LOG_FLAGS, argv)
288
+ return parsed if parsed.is_a?(Integer)
289
+ return refused if agent?
290
+
291
+ options = parsed.options
292
+ last = options[:last] ? Integer(options[:last], exception: false) : LOG_LAST
293
+ return usage_error("--last takes a positive number") unless last&.positive?
294
+ return usage_error("--format takes json") unless [nil, "json"].include?(options[:format])
295
+
296
+ records = @log.records.last(last)
297
+ if options[:format] == "json"
298
+ @stdout.puts(JSON.pretty_generate(records.map(&:to_json_hash)))
299
+ return 0
300
+ end
301
+ @stdout.puts("no broadcasts in the log yet (#{@log.path})") if records.empty?
302
+ records.each { |record| print_record(record) }
303
+ 0
304
+ end
305
+
306
+ def print_record(record)
307
+ @stdout.puts("#{record.id} #{record.at.localtime.strftime("%Y-%m-%d %H:%M")} #{headline(record.text)}")
308
+ record.recipients.each { |e| @stdout.puts(" #{e.session[0, 8]} #{e.result.ljust(9)} #{e.reason}") }
309
+ delivered = record.recipients.count { |e| e.result == "delivered" }
310
+ @stdout.puts(" delivered #{delivered} · skipped #{record.recipients.size - delivered}")
311
+ record.corrections.each do |c|
312
+ sessions = c.sessions.map { |d| "#{d.session[0, 8]}#{" (#{d.result})" unless d.result == "delivered"}" }
313
+ @stdout.puts(" #{c.at.localtime.strftime("%Y-%m-%d %H:%M")} delivered anyway: #{sessions.join(", ")}")
314
+ end
315
+ end
316
+
317
+ # `chi broadcast deliver BROADCAST_ID SESSION...`: the logged note to
318
+ # sessions it skipped. @return [Integer] exit status
319
+ def run_deliver(argv)
320
+ parsed = parse_flags(DELIVER_FLAGS, argv)
321
+ return parsed if parsed.is_a?(Integer)
322
+ return refused if agent?
323
+
324
+ given, *sessions = parsed.args
325
+ return usage_error("give a broadcast id and the sessions to deliver it to") if given.nil? || sessions.empty?
326
+
327
+ record = begin
328
+ @log.find(given)
329
+ rescue Broadcast::TriageLog::NotFound => e
330
+ error_line("chi broadcast: #{e.message}")
331
+ return 1
332
+ end
333
+ ids = sessions.map { |given| resolve_session(given) }
334
+ results = ids.compact.uniq.map { |id| deliver_anyway(record, id) }
335
+ unless results.empty?
336
+ begin
337
+ @log.append_correction(id: record.id, at: @now, sessions: results)
338
+ rescue SystemCallError => e
339
+ error_line("chi broadcast: the triage log #{@log.path} couldn't be written: #{e.message}")
340
+ end
341
+ end
342
+ # Done when every session named has the note now.
343
+ ids.all? && results.all? { |d| ["delivered", "had it"].include?(d.result) } ? 0 : 1
344
+ end
345
+
346
+ # The full id +given+ names (an id or the start of one), or nil after
347
+ # saying why there is none.
348
+ def resolve_session(given)
349
+ id = Session.resolve_id(given, state_dir: @state_dir)
350
+ Session.load(id, state_dir: @state_dir) # raises ArgumentError when there is no such session
351
+ id
352
+ rescue ArgumentError => e
353
+ error_line("chi broadcast: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
354
+ nil
355
+ rescue SystemCallError => e
356
+ error_line("chi broadcast: session #{given} can't be read: #{e.message}")
357
+ nil
358
+ end
359
+
360
+ # @param id [String] a full session id (#resolve_session)
361
+ # @return [Broadcast::TriageLog::Delivery]
362
+ def deliver_anyway(record, id)
363
+ if record.delivered?(id)
364
+ @stdout.puts(line_for(id, "skipped", "it got #{record.id} already"))
365
+ return Broadcast::TriageLog::Delivery.new(session: id, result: "had it")
366
+ end
367
+
368
+ result = NoteDelivery.deliver(id, text: passed_on_body(record), source: SOURCE, state_dir: @state_dir)
369
+ unless result.delivered?
370
+ @stdout.puts(line_for(id, "skipped", "open in a chi REPL"))
371
+ return Broadcast::TriageLog::Delivery.new(session: id, result: "refused")
372
+ end
373
+
374
+ @stdout.puts(line_for(id, "delivered", "by hand#{"; waits for its next start" if result.status == :waits}"))
375
+ Broadcast::TriageLog::Delivery.new(session: id, result: "delivered")
376
+ rescue SessionInbox::NoteRejected, SystemCallError => e
377
+ @stdout.puts(line_for(id, "failed", e.message))
378
+ Broadcast::TriageLog::Delivery.new(session: id, result: "failed")
379
+ end
380
+
381
+ # A logged broadcast passed on by hand: as #body words it, dated when
382
+ # it was shared.
383
+ def passed_on_body(record)
384
+ shared = "Shared by your user on #{record.at.localtime.strftime("%Y-%m-%d %H:%M")}"
385
+ "#{record.text}\n(#{shared} with the sessions it may concern; your user passed it on to this session.)"
386
+ end
387
+
388
+ def line_for(id, word, why) = "#{id[0, 8]} #{word.ljust(9)} #{why}"
389
+
390
+ # " · 2 unchecked: triage deadline" when some were delivered without a
391
+ # verdict (the desktop helper shows only this line), else "". Each
392
+ # reason ends before its details: a parenthesis, a quote, or a single
393
+ # quote after a space ("host_ref 'lan' is …", not "isn't").
394
+ def unchecked_summary(decisions)
395
+ unchecked = decisions.map(&:verdict).select(&:unchecked?)
396
+ return "" if unchecked.empty?
397
+
398
+ whys = unchecked.map { |v| v.reason.delete_prefix("unchecked: ").sub(/(?:\s*[("]|\s+').*\z/m, "") }.uniq
399
+ " · #{unchecked.size} unchecked: #{whys.join(", ")}"
400
+ end
401
+
176
402
  # What a recipient gets: the user's text first (the terminal's "note
177
403
  # from broadcast: …" line shows its start), then a line saying when it
178
404
  # was shared (a session with no worker may read it days later; the
179
- # note's header has the time only) and why it reached this session. No
180
- # instruction: the system prompt says what a broadcast note asks.
181
- def body(text, match)
405
+ # note's header has the time only) and, for a tag match, why it reached
406
+ # this session. No instruction: the system prompt says what a broadcast
407
+ # note asks.
408
+ def body(text, verdict)
182
409
  shared = "Shared by your user on #{@now.localtime.strftime("%Y-%m-%d %H:%M")}"
183
- return "#{text}\n(#{shared} with every active session.)" unless match
410
+ return "#{text}\n(#{shared} with every active session.)" if verdict.by == "all"
411
+ return "#{text}\n(#{shared} with the sessions it may concern.)" unless verdict.match
184
412
 
185
- because = match.because
413
+ because = verdict.match.because
186
414
  because = "#{because[0, BECAUSE_CHARS - 1]}…" if because.length > BECAUSE_CHARS
187
415
  "#{text}\n(#{shared} with the sessions it may concern; it reached you because #{because}.)"
188
416
  end
189
417
 
190
- def reason(verdict)
191
- verdict.skip || verdict.match&.reason || "--all"
192
- end
193
-
194
418
  def line(recipient, word, why, width: 9) = "#{recipient.short_id} #{word.ljust(width)} #{why}"
195
419
 
196
420
  # The note's first line, quoted and cut, for the output's head.
@@ -206,23 +430,5 @@ module Samagotchi
206
430
  value = hours.to_f
207
431
  "#{value == value.round ? value.round : value} #{value == 1 ? "hour" : "hours"}"
208
432
  end
209
-
210
- # Only a pipe or a file is read, as chi note does: a terminal or a
211
- # socket a launcher passes down may never close.
212
- def read_stdin
213
- return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
214
-
215
- if @stdin.respond_to?(:stat)
216
- stat = @stdin.stat
217
- return nil unless stat.pipe? || stat.file?
218
- end
219
-
220
- @stdin.read
221
- end
222
-
223
- # The note as UTF-8 whatever the locale says (see NoteCommand#utf8).
224
- def utf8(text)
225
- text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
226
- end
227
433
  end
228
434
  end
@@ -1,11 +1,12 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.5.0
3
+ version: 0.6.0
4
4
  scope: system
5
- description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
5
+ description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; the model searches their tools with find_mcp_tools and calls one with mcp_call (<server>/<tool>); /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:b946862b2c74734dd5af9ffba12f14a79b760b993a1248630f9fc5ecaaa740f5
11
- requires_chi: ">= 0.33.0"
10
+ sha256: sha256:561dfc3a765d6a72913a7bc416ac0120e14f5747fdfc8f692e3d4dfb241ff6a6
11
+ # 0.37.0 (published 2026-10-07) is the release whose targets: take acts_as:, args: and label:.
12
+ requires_chi: ">= 0.37.0"