samagotchi 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +2 -2
  4. data/bin/chi +1 -1
  5. data/docs/broadcast.md +91 -12
  6. data/docs/configuration.md +8 -1
  7. data/docs/context.md +37 -0
  8. data/docs/guardrails.md +26 -3
  9. data/docs/hooks.md +1 -1
  10. data/docs/memory.md +3 -0
  11. data/docs/plugins.md +51 -4
  12. data/lib/samagotchi/broadcast/triage.rb +267 -0
  13. data/lib/samagotchi/broadcast/triage_log.rb +165 -0
  14. data/lib/samagotchi/broadcast/triage_model.rb +56 -0
  15. data/lib/samagotchi/broadcast_command.rb +273 -69
  16. data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
  17. data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
  18. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
  19. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  20. data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  23. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/mcp/plugin.rb +72 -27
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
  27. data/lib/samagotchi/cli/command.rb +27 -3
  28. data/lib/samagotchi/config.rb +9 -0
  29. data/lib/samagotchi/context_command.rb +0 -17
  30. data/lib/samagotchi/engine.rb +4 -6
  31. data/lib/samagotchi/guardrail_wiring.rb +7 -2
  32. data/lib/samagotchi/guardrails/approval.rb +5 -2
  33. data/lib/samagotchi/guardrails/approvals.rb +9 -5
  34. data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
  35. data/lib/samagotchi/guardrails/rules.rb +19 -6
  36. data/lib/samagotchi/guardrails/targets.rb +83 -11
  37. data/lib/samagotchi/idle_client.rb +48 -28
  38. data/lib/samagotchi/idle_recap.rb +6 -5
  39. data/lib/samagotchi/idle_target.rb +95 -0
  40. data/lib/samagotchi/llm/openai_chat.rb +29 -3
  41. data/lib/samagotchi/log_line.rb +1 -1
  42. data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
  43. data/lib/samagotchi/note_command.rb +0 -21
  44. data/lib/samagotchi/recap_setup.rb +38 -52
  45. data/lib/samagotchi/send_command.rb +0 -22
  46. data/lib/samagotchi/session_commands.rb +1 -0
  47. data/lib/samagotchi/tool_activity.rb +14 -3
  48. data/lib/samagotchi/tool_declarations.rb +8 -4
  49. data/lib/samagotchi/tools/builtins.rb +1 -1
  50. data/lib/samagotchi/tools/memory.rb +103 -2
  51. data/lib/samagotchi/version.rb +1 -1
  52. data/lib/samagotchi/web/public/question_card.js +4 -3
  53. metadata +5 -1
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../idle_client"
4
+ require_relative "../idle_target"
5
+ require_relative "../cancellation_controller"
6
+ require_relative "../log"
7
+ require_relative "tags"
8
+
9
+ module Samagotchi
10
+ module Broadcast
11
+ # Whether a broadcast concerns a recipient its tags didn't match: a
12
+ # small model reads the note and the session's ScopeCard and answers
13
+ # yes or no (Triage::LLM). Many recipients are judged in parallel under
14
+ # one deadline (Triage.judge_all); whatever isn't judged by then, or
15
+ # fails, is delivered unchecked (fail open: a missed recipient is worse
16
+ # than an extra note).
17
+ module Triage
18
+ # One recipient's verdict.
19
+ # @!attribute relevant [Boolean] it gets the note
20
+ # @!attribute p [Float, nil] the model's P(yes) from its logprobs,
21
+ # 1.0 or 0.0 from a plain answer, nil when no model judged it
22
+ # @!attribute reason [String] why, for the output and the log
23
+ # @!attribute by [String] who decided: "tags" (a shared tag),
24
+ # "model", "fallback" (delivered unchecked: deadline, error, an
25
+ # answer that is no yes or no), "scope" (the note's first line
26
+ # names another project), "all" (--all) or "repl" (a chi REPL owns
27
+ # it: no notes)
28
+ # @!attribute match [Match, nil] the shared tag (by "tags")
29
+ Verdict = Data.define(:relevant, :p, :reason, :by, :match) do
30
+ def initialize(match: nil, **) = super
31
+
32
+ def unchecked? = by == "fallback"
33
+ end
34
+
35
+ # The spike's prompt (plain yes/no beat JSON with a p and a reason
36
+ # for every model tried: writing a reason pushes towards "relevant").
37
+ SYSTEM = <<~TEXT
38
+ You route short notes that a developer broadcasts to their running coding-agent sessions.
39
+ You get one note and one session's card (project, branch, title, what the session was started for, its latest recap, the last request).
40
+ Decide whether the note may affect THIS session's current work: would the agent working on it want to know?
41
+ A note can start with an informal scope line (a project, a component, a team); use it as a hint.
42
+ Notes about things unrelated to the session's work (social, other projects, other components) are not relevant.
43
+ Answer with exactly one word: yes or no.
44
+ TEXT
45
+
46
+ DEFAULT_PARALLEL = 4
47
+ DEFAULT_DEADLINE = 20.0
48
+ DEFAULT_THRESHOLD = 0.5
49
+ # How long threads still asking get to stop once the deadline passed.
50
+ JOIN_GRACE = 1.0
51
+
52
+ module_function
53
+
54
+ # @return [Verdict] delivered unchecked, +why+ in the reason
55
+ def unchecked(why) = Verdict.new(relevant: true, p: nil, reason: "unchecked: #{why}", by: "fallback")
56
+
57
+ # The messages a model judges +note+ for +card+ by.
58
+ def messages(note, card)
59
+ [{ role: "system", content: SYSTEM }, { role: "user", content: "NOTE:\n#{note}\n\nSESSION CARD:\n#{card}" }]
60
+ end
61
+
62
+ # The one project the note's first line names, as a whole word (any
63
+ # case), among +projects+ (the recipients' project names); nil when it
64
+ # names none or several. A scope line ("shopfront/checkout", "agentd
65
+ # web") then keeps the note to that project's sessions; anything
66
+ # fuzzier is the model's job.
67
+ # @param projects [Array<String>]
68
+ # @return [String, nil]
69
+ def scope_project(note, projects)
70
+ line = note.to_s.lines.first.to_s.downcase
71
+ named = projects.compact.uniq.select do |project|
72
+ line.match?(/(?<![[:alnum:]_-])#{Regexp.escape(project.downcase)}(?![[:alnum:]_-])/)
73
+ end
74
+ named.one? ? named.first : nil
75
+ end
76
+
77
+ # Every card's verdict: a shared tag delivers (Tags.match); a note
78
+ # whose first line names one of the projects (scope_project) skips
79
+ # the other projects' sessions; the model judges the rest
80
+ # (judge_all, +judge+ its keywords).
81
+ # @param cards [Array<ScopeCard>]
82
+ # @param note_tags [Array<Tag>]
83
+ # @return [Hash{String => Verdict}] by card id, in +cards+' order
84
+ def verdicts(note, cards, note_tags:, new_backend:, **judge)
85
+ scope = scope_project(note, cards.map(&:project))
86
+ decided = cards.to_h do |card|
87
+ match = Tags.match(note_tags, card.tags)
88
+ verdict = if match
89
+ Verdict.new(relevant: true, p: nil, reason: match.reason, by: "tags", match: match)
90
+ elsif scope && card.project != scope
91
+ Verdict.new(relevant: false, p: nil, reason: "scope line names #{scope}", by: "scope")
92
+ end
93
+ [card.id, verdict]
94
+ end
95
+ judged = judge_all(note, cards.reject { |card| decided[card.id] }, new_backend: new_backend, **judge)
96
+ cards.to_h { |card| [card.id, decided[card.id] || judged.fetch(card.id)] }
97
+ end
98
+
99
+ # Judges +cards+ with at most +parallel+ backends at a time, one per
100
+ # thread (a backend keeps per-host state), all under +deadline+
101
+ # seconds. A card not judged by then is delivered unchecked; the
102
+ # requests still running are cancelled.
103
+ # @param new_backend [#call] (CancellationController) → a backend:
104
+ # #judge(note, card) → Verdict
105
+ # @param cards [Array<ScopeCard>]
106
+ # @return [Hash{String => Verdict}] by card id
107
+ def judge_all(note, cards, new_backend:, parallel: DEFAULT_PARALLEL, deadline: DEFAULT_DEADLINE,
108
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
109
+ return {} if cards.empty?
110
+
111
+ queue = Queue.new
112
+ cards.each { |card| queue << card }
113
+ queue.close
114
+ results = {}
115
+ lock = Mutex.new
116
+ changed = ConditionVariable.new
117
+ cancel = CancellationController.new
118
+ ends = clock.call + deadline.to_f
119
+ finished = 0
120
+ threads = Array.new(parallel.to_i.clamp(1, cards.size)) do
121
+ Thread.new do
122
+ card = nil
123
+ backend = new_backend.call(cancel)
124
+ while (card = queue.pop)
125
+ verdict = judge_one(backend, note, card)
126
+ lock.synchronize do
127
+ results[card.id] = verdict unless cancel.cancelled?
128
+ changed.broadcast
129
+ end
130
+ card = nil
131
+ end
132
+ rescue StandardError => e
133
+ # No backend (or a bug around it): this thread's card and the
134
+ # ones it takes from here on are delivered unchecked at once,
135
+ # so nobody waits for the deadline.
136
+ Log.exception(:broadcast, "triage_thread_failed", e)
137
+ failed = unchecked("triage failed (#{e.class})")
138
+ lock.synchronize { results[card.id] ||= failed if card }
139
+ while (card = queue.pop)
140
+ lock.synchronize { results[card.id] ||= failed }
141
+ end
142
+ ensure
143
+ lock.synchronize do
144
+ finished += 1
145
+ changed.broadcast
146
+ end
147
+ end
148
+ end
149
+ lock.synchronize do
150
+ loop do
151
+ break if results.size == cards.size || finished == threads.size
152
+
153
+ left = ends - clock.call
154
+ break if left <= 0
155
+
156
+ changed.wait(lock, left)
157
+ end
158
+ cancel.cancel!(:deadline)
159
+ queue.clear
160
+ end
161
+ threads.each { |thread| thread.join(JOIN_GRACE) || thread.kill }
162
+ cards.to_h { |card| [card.id, results[card.id] || unchecked("triage deadline")] }
163
+ end
164
+
165
+ def judge_one(backend, note, card)
166
+ backend.judge(note, card)
167
+ rescue Samagotchi::LLM::RequestCancelled
168
+ unchecked("triage deadline")
169
+ rescue StandardError => e
170
+ Log.exception(:broadcast, "triage_failed", e)
171
+ unchecked("triage failed (#{e.class})")
172
+ end
173
+ private_class_method :judge_one
174
+
175
+ # A triage model on an OpenAI-compatible host (an IdleTarget), asked
176
+ # through its own IdleClient: thinking off, a few tokens, the first
177
+ # token's logprobs when the host gives them. P(yes) / (P(yes) +
178
+ # P(no)) from those is graded against +threshold+; a plain yes or no
179
+ # is 1.0 or 0.0.
180
+ class LLM
181
+ MAX_TOKENS = 16
182
+ TOP_LOGPROBS = 5
183
+ LOGPROB_OPTIONS = { logprobs: true, top_logprobs: TOP_LOGPROBS }.freeze
184
+ # Hosts (base urls) that refused logprobs with a 400: asked without
185
+ # them from then on, in this process.
186
+ @no_logprobs = Set.new
187
+ @no_logprobs_lock = Mutex.new
188
+
189
+ class << self
190
+ def logprobs?(base_url) = @no_logprobs_lock.synchronize { !@no_logprobs.include?(base_url) }
191
+
192
+ def no_logprobs!(base_url) = @no_logprobs_lock.synchronize { @no_logprobs << base_url }
193
+
194
+ # Specs: forget the hosts that refused.
195
+ def reset_logprobs! = @no_logprobs_lock.synchronize { @no_logprobs.clear }
196
+ end
197
+
198
+ # @param target [IdleTarget]
199
+ # @param timeout [Numeric] each request's
200
+ # @param client [IdleClient, nil] specs inject one
201
+ def initialize(target:, timeout:, threshold: DEFAULT_THRESHOLD, cancel_controller: nil, client: nil)
202
+ @target = target
203
+ @threshold = threshold.to_f
204
+ @cancel = cancel_controller
205
+ @client = client || IdleClient.for(target, timeout: timeout, purpose: "broadcast")
206
+ end
207
+
208
+ # @return [Verdict]
209
+ # @raise [Samagotchi::LLM::RequestCancelled] the deadline passed
210
+ def judge(note, card)
211
+ answer = ask(Triage.messages(note, card))
212
+ word = self.class.word(answer.text)
213
+ return Triage.unchecked("the model answered #{answer.text.to_s.strip[0, 40].inspect}") unless word
214
+
215
+ p = self.class.logprob_p(answer.top_logprobs, word: word)
216
+ relevant = p ? p >= @threshold : word == "yes"
217
+ reason = "model: #{relevant ? "yes" : "no"}#{format(" (p %.2f)", p) if p}"
218
+ Verdict.new(relevant: relevant, p: p || (word == "yes" ? 1.0 : 0.0), reason: reason, by: "model")
219
+ rescue IdleClient::SummarizeError => e
220
+ Triage.unchecked("the model failed (#{e.message[0, 120]})")
221
+ end
222
+
223
+ # "yes" or "no": the answer's first such word (thinking left out),
224
+ # nil when it has neither.
225
+ def self.word(text)
226
+ text.to_s.gsub(%r{<think>.*?</think>}m, "").strip.downcase[/\A\W*(yes|no)\b/, 1]
227
+ end
228
+
229
+ # P(yes) / (P(yes) + P(no)) over the first token's alternatives
230
+ # ("Yes", " yes" count as yes), when that token is the answer +word+
231
+ # itself: its likeliest alternative is yes or no and agrees with
232
+ # +word+. nil otherwise (an answer starting "**", "<think>" or a
233
+ # quote: those alternatives say nothing about yes or no).
234
+ # @param top [Array<Samagotchi::LLM::TokenLogprob>]
235
+ # @return [Float, nil]
236
+ def self.logprob_p(top, word:)
237
+ first = top.max_by(&:logprob)
238
+ return nil unless first && first.token.strip.downcase == word
239
+
240
+ sum = ->(want) { top.select { |t| t.token.strip.downcase == want }.sum { |t| Math.exp(t.logprob) } }
241
+ yes = sum.call("yes")
242
+ no = sum.call("no")
243
+ (yes / (yes + no)).round(4)
244
+ end
245
+
246
+ private
247
+
248
+ def ask(messages)
249
+ base_url = @target.base_url
250
+ options = self.class.logprobs?(base_url) ? LOGPROB_OPTIONS : {}
251
+ begin
252
+ @client.ask(messages, max_tokens: MAX_TOKENS, cancel_controller: @cancel, kind: "broadcast", options: options)
253
+ rescue IdleClient::SummarizeError => e
254
+ raise unless !options.empty? && e.cause.is_a?(Samagotchi::LLM::BadRequest)
255
+
256
+ # The host won't give logprobs (a server that refuses "n and
257
+ # logprobs"): ask again without, and leave them out from now on.
258
+ self.class.no_logprobs!(base_url)
259
+ Log.info(:broadcast, "logprobs_refused", model: @target.label, detail: e.cause.message.to_s[0, 200])
260
+ options = {}
261
+ retry
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
@@ -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