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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -1
- data/README.md +2 -2
- data/bin/chi +1 -1
- data/docs/broadcast.md +91 -12
- data/docs/configuration.md +8 -1
- data/docs/guardrails.md +15 -0
- data/docs/plugins.md +23 -2
- data/lib/samagotchi/broadcast/triage.rb +267 -0
- data/lib/samagotchi/broadcast/triage_log.rb +165 -0
- data/lib/samagotchi/broadcast/triage_model.rb +56 -0
- data/lib/samagotchi/broadcast_command.rb +273 -69
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +35 -23
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cli/command.rb +27 -3
- data/lib/samagotchi/config.rb +9 -0
- data/lib/samagotchi/context_command.rb +0 -17
- data/lib/samagotchi/engine.rb +4 -6
- data/lib/samagotchi/guardrail_wiring.rb +7 -2
- data/lib/samagotchi/guardrails/approval.rb +5 -2
- data/lib/samagotchi/guardrails/approvals.rb +9 -5
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/targets.rb +76 -11
- data/lib/samagotchi/idle_client.rb +48 -28
- data/lib/samagotchi/idle_recap.rb +6 -5
- data/lib/samagotchi/idle_target.rb +95 -0
- data/lib/samagotchi/llm/openai_chat.rb +29 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/note_command.rb +0 -21
- data/lib/samagotchi/recap_setup.rb +38 -52
- data/lib/samagotchi/send_command.rb +0 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/question_card.js +4 -3
- metadata +5 -1
|
@@ -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"
|
|
18
|
-
#
|
|
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.
|
|
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,
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
146
|
-
|
|
147
|
-
@stdout.puts(line(
|
|
148
|
-
@stdout.puts(cards.fetch(
|
|
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 =
|
|
151
|
-
@stdout.puts("would deliver #{delivered} · skipped #{
|
|
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
|
-
|
|
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 =
|
|
158
|
-
next [
|
|
250
|
+
lines = decisions.map do |d|
|
|
251
|
+
next [d, "skipped", d.verdict.reason] unless d.deliver?
|
|
159
252
|
|
|
160
|
-
result = NoteDelivery.deliver(
|
|
161
|
-
|
|
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
|
-
[
|
|
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
|
-
[
|
|
260
|
+
[d, "failed", e.message]
|
|
167
261
|
end
|
|
168
|
-
lines.sort_by.with_index { |(_,
|
|
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 { |_,
|
|
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
|
|
180
|
-
# instruction: the system prompt says what a broadcast
|
|
181
|
-
|
|
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.)"
|
|
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
|