samagotchi 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+ require_relative "../paths"
7
+
8
+ module Samagotchi
9
+ module Broadcast
10
+ # What each broadcast decided, one JSON line per broadcast in
11
+ # <state>/samagotchi/broadcast/log.jsonl: its id, time, note text, the
12
+ # note's tags and every recipient's verdict (no scope cards: they quote
13
+ # the user's prompts). `chi broadcast deliver` adds a "corrected" line
14
+ # for the sessions it delivered to anyway: labels for the threshold and
15
+ # a future classifier. Not a list sessions read: broadcasts stay
16
+ # fire-and-forget. Over MAX_BYTES the file becomes log.jsonl.1 (one old
17
+ # file kept).
18
+ class TriageLog
19
+ MAX_BYTES = 2 * 1024 * 1024
20
+
21
+ # One recipient's line in a broadcast.
22
+ # @!attribute result [String] "delivered", "skipped" or "failed"
23
+ # @!attribute p [Float, nil] the triage model's P(yes), nil when no
24
+ # model judged it
25
+ # @!attribute by [String] Triage::Verdict#by
26
+ # @!attribute tags [Array<String>] the session's tags (Tag#label)
27
+ Entry = Data.define(:session, :result, :p, :reason, :by, :tags)
28
+
29
+ # A `chi broadcast deliver`: when, and each session's result.
30
+ # @!attribute sessions [Array<Delivery>]
31
+ Correction = Data.define(:at, :sessions)
32
+
33
+ # One session a correction delivered to (or tried).
34
+ Delivery = Data.define(:session, :result)
35
+
36
+ # A broadcast as read back, with the corrections made to it since.
37
+ # @!attribute at [Time]
38
+ # @!attribute recipients [Array<Entry>]
39
+ # @!attribute corrections [Array<Correction>]
40
+ Record = Data.define(:id, :at, :text, :note_tags, :triage_model, :recipients, :corrections) do
41
+ # @return [Entry, nil]
42
+ def entry(session) = recipients.find { |e| e.session == session }
43
+
44
+ # Whether +session+ got it, from the broadcast or a correction.
45
+ def delivered?(session)
46
+ entry(session)&.result == "delivered" ||
47
+ corrections.any? { |c| c.sessions.any? { |d| d.session == session && d.result == "delivered" } }
48
+ end
49
+
50
+ def to_json_hash
51
+ { id: id, at: at.iso8601, text: text, note_tags: note_tags, triage_model: triage_model,
52
+ recipients: recipients.map(&:to_h),
53
+ corrections: corrections.map { |c| { at: c.at.iso8601, sessions: c.sessions.map(&:to_h) } } }
54
+ end
55
+ end
56
+
57
+ # No broadcast with that id, or several start with it.
58
+ class NotFound < StandardError; end
59
+
60
+ # @return [String] <state>/samagotchi/broadcast/log.jsonl
61
+ def self.default_path(env: ENV) = File.join(Paths.state_dir(env: env), "broadcast", "log.jsonl")
62
+
63
+ attr_reader :path
64
+
65
+ def initialize(path = self.class.default_path)
66
+ @path = path
67
+ end
68
+
69
+ # @param at [Time]
70
+ # @param recipients [Array<Entry>]
71
+ def append_broadcast(id:, at:, text:, note_tags:, triage_model:, recipients:)
72
+ append(type: "broadcast", id: id, at: at.iso8601, text: text, note_tags: note_tags, triage_model: triage_model,
73
+ recipients: recipients.map(&:to_h))
74
+ end
75
+
76
+ # @param sessions [Array<Delivery>]
77
+ def append_correction(id:, at:, sessions:)
78
+ append(type: "corrected", id: id, at: at.iso8601, sessions: sessions.map(&:to_h))
79
+ end
80
+
81
+ # Every broadcast in the log (the rotated file's first), oldest
82
+ # first, with its corrections. A line that doesn't parse is skipped.
83
+ # @return [Array<Record>]
84
+ def records
85
+ found = {}
86
+ [rotated_path, @path].each do |file|
87
+ next unless File.file?(file)
88
+
89
+ File.foreach(file) do |line|
90
+ data = parse(line)
91
+ next unless data
92
+
93
+ if data["type"] == "broadcast"
94
+ found[data["id"]] = record(data)
95
+ elsif data["type"] == "corrected" && (previous = found[data["id"]])
96
+ found[data["id"]] = previous.with(corrections: previous.corrections + [correction(data)])
97
+ end
98
+ end
99
+ end
100
+ found.values
101
+ end
102
+
103
+ # The broadcast +given+ names: its id, with or without "b-", or the
104
+ # start of one.
105
+ # @return [Record]
106
+ # @raise [NotFound]
107
+ def find(given)
108
+ want = given.to_s.strip.delete_prefix("b-")
109
+ raise NotFound, "no broadcast id given" if want.empty?
110
+
111
+ matches = records.select { |r| r.id.delete_prefix("b-").start_with?(want) }
112
+ return matches.first if matches.one?
113
+ raise NotFound, "no broadcast #{given} in the log (chi broadcast log)" if matches.empty?
114
+
115
+ raise NotFound, "#{given} names #{matches.size} broadcasts (#{matches.map(&:id).join(", ")}); give more of the id"
116
+ end
117
+
118
+ private
119
+
120
+ def rotated_path = "#{@path}.1"
121
+
122
+ def append(data)
123
+ FileUtils.mkdir_p(File.dirname(@path))
124
+ File.rename(@path, rotated_path) if File.file?(@path) && File.size(@path) > MAX_BYTES
125
+ File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o600) do |file|
126
+ file.flock(File::LOCK_EX)
127
+ file.write("#{JSON.generate(data)}\n")
128
+ end
129
+ end
130
+
131
+ def parse(line)
132
+ data = JSON.parse(line)
133
+ data.is_a?(Hash) && data["id"].is_a?(String) ? data : nil
134
+ rescue JSON::ParserError
135
+ nil
136
+ end
137
+
138
+ def record(data)
139
+ Record.new(id: data["id"], at: time(data["at"]), text: data["text"].to_s, note_tags: Array(data["note_tags"]),
140
+ triage_model: data["triage_model"],
141
+ recipients: Array(data["recipients"]).filter_map { |e| entry(e) }, corrections: [])
142
+ end
143
+
144
+ def entry(data)
145
+ return nil unless data.is_a?(Hash)
146
+
147
+ Entry.new(session: data["session"].to_s, result: data["result"].to_s, p: data["p"], reason: data["reason"].to_s,
148
+ by: data["by"].to_s, tags: Array(data["tags"]))
149
+ end
150
+
151
+ def correction(data)
152
+ sessions = Array(data["sessions"]).filter_map do |d|
153
+ Delivery.new(session: d["session"].to_s, result: d["result"].to_s) if d.is_a?(Hash)
154
+ end
155
+ Correction.new(at: time(data["at"]), sessions: sessions)
156
+ end
157
+
158
+ def time(value)
159
+ Time.iso8601(value.to_s)
160
+ rescue ArgumentError
161
+ Time.at(0)
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../idle_target"
5
+ require_relative "../model_profile"
6
+
7
+ module Samagotchi
8
+ # Loaded on first use, as the CLI commands do: host_registry pulls in the
9
+ # LLM layer.
10
+ autoload :HostRegistry, File.expand_path("../host_registry", __dir__)
11
+
12
+ module Broadcast
13
+ # Which model a broadcast's triage asks: broadcast.triage_model /
14
+ # triage_host_ref / triage_base_url; with none of them set, the recap's
15
+ # (recap.*); with none of those, default.model. Settings that can't be
16
+ # resolved are a problem, not a fallback to the next ones: triage then
17
+ # delivers unchecked and says why.
18
+ module TriageModel
19
+ # @!attribute target [IdleTarget, nil] nil when +problem+ says why
20
+ # @!attribute setting [String] where it came from: "broadcast.triage_model",
21
+ # "recap.model" or "default.model" (the prefix of the keys read)
22
+ Choice = Data.define(:target, :setting, :problem)
23
+
24
+ SETTINGS = [%w[broadcast.triage_model broadcast.triage_host_ref broadcast.triage_base_url],
25
+ %w[recap.model recap.host_ref recap.base_url]].freeze
26
+
27
+ module_function
28
+
29
+ # @param get [#call] a Config key → its value
30
+ # @return [Choice]
31
+ def resolve(host_registry: nil, get: ->(key) { Config.get(key) })
32
+ registry = host_registry || HostRegistry.new
33
+ SETTINGS.each do |model, host_ref, base_url|
34
+ target = IdleTarget.resolve(model: get.call(model), host_ref: get.call(host_ref), base_url: get.call(base_url),
35
+ host_registry: registry)
36
+ return Choice.new(target: target, setting: model, problem: nil) if target
37
+ rescue IdleTarget::Unresolved => e
38
+ return Choice.new(target: nil, setting: model, problem: problem(e, model, host_ref))
39
+ end
40
+ model = ModelProfile.required_model_name(get.call("default.model"))
41
+ Choice.new(target: IdleTarget.of_model(registry, model), setting: "default.model", problem: nil)
42
+ rescue StandardError => e
43
+ Choice.new(target: nil, setting: "default.model", problem: e.message.to_s.lines.first.to_s.strip[0, 200])
44
+ end
45
+
46
+ def problem(error, model_key, host_ref_key)
47
+ case error.reason
48
+ when :host_ref_unknown then "#{host_ref_key} '#{error.host_ref}' is not in hosts:"
49
+ when :model_host_mismatch then "#{model_key} '#{error.model}' names host '#{error.named}', not #{host_ref_key} '#{error.host_ref}'"
50
+ else "#{model_key} is missing (a host or base_url alone names no model)"
51
+ end
52
+ end
53
+ private_class_method :problem
54
+ end
55
+ end
56
+ end
@@ -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,245 @@ 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 "".
392
+ def unchecked_summary(decisions)
393
+ unchecked = decisions.map(&:verdict).select(&:unchecked?)
394
+ return "" if unchecked.empty?
395
+
396
+ whys = unchecked.map { |v| v.reason.delete_prefix("unchecked: ").sub(/\s*[("].*\z/m, "") }.uniq
397
+ " · #{unchecked.size} unchecked: #{whys.join(", ")}"
398
+ end
399
+
176
400
  # What a recipient gets: the user's text first (the terminal's "note
177
401
  # from broadcast: …" line shows its start), then a line saying when it
178
402
  # 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)
403
+ # note's header has the time only) and, for a tag match, why it reached
404
+ # this session. No instruction: the system prompt says what a broadcast
405
+ # note asks.
406
+ def body(text, verdict)
182
407
  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
408
+ return "#{text}\n(#{shared} with every active session.)" if verdict.by == "all"
409
+ return "#{text}\n(#{shared} with the sessions it may concern.)" unless verdict.match
184
410
 
185
- because = match.because
411
+ because = verdict.match.because
186
412
  because = "#{because[0, BECAUSE_CHARS - 1]}…" if because.length > BECAUSE_CHARS
187
413
  "#{text}\n(#{shared} with the sessions it may concern; it reached you because #{because}.)"
188
414
  end
189
415
 
190
- def reason(verdict)
191
- verdict.skip || verdict.match&.reason || "--all"
192
- end
193
-
194
416
  def line(recipient, word, why, width: 9) = "#{recipient.short_id} #{word.ljust(width)} #{why}"
195
417
 
196
418
  # The note's first line, quoted and cut, for the output's head.
@@ -206,23 +428,5 @@ module Samagotchi
206
428
  value = hours.to_f
207
429
  "#{value == value.round ? value.round : value} #{value == 1 ? "hour" : "hours"}"
208
430
  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
431
  end
228
432
  end