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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -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/context.md +37 -0
- data/docs/guardrails.md +26 -3
- data/docs/hooks.md +1 -1
- data/docs/memory.md +3 -0
- data/docs/plugins.md +51 -4
- 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/coordinator/manifest.yml +7 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +72 -27
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
- 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/parent_approvals.rb +6 -0
- data/lib/samagotchi/guardrails/rules.rb +19 -6
- data/lib/samagotchi/guardrails/targets.rb +83 -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/memory_bundle/index_updater.rb +6 -3
- 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/session_commands.rb +1 -0
- data/lib/samagotchi/tool_activity.rb +14 -3
- data/lib/samagotchi/tool_declarations.rb +8 -4
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +103 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/question_card.js +4 -3
- metadata +5 -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"
|
|
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
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: coordinator
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.2.0
|
|
4
4
|
scope: system
|
|
5
|
-
description: "Coordinate parallel work: the coordinator skill (split the work, one worktree and child session per task, check each report, ask before each merge), /coordinate <goal
|
|
5
|
+
description: "Coordinate parallel work: the coordinator skill (split the work, one worktree and child session per task, check each report, ask before each merge, a handoff memory to resume from), /coordinate <goal>, /coordinate resume and /children"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files:
|
|
8
8
|
skill_coordinator.md:
|
|
9
|
-
sha256: sha256:
|
|
10
|
-
description: "Coordinate parallel work: split it, delegate each task wait:false in its own worktree (cwd:), check each report, ask the user before each merge"
|
|
9
|
+
sha256: sha256:a24b91ae27737027125cc0d627bf31edbacdd1be842f4daf82ddf062c9160413
|
|
10
|
+
description: "Coordinate parallel work: split it, delegate each task wait:false in its own worktree (cwd:), check each report, ask the user before each merge; resume from a handoff_* memory"
|
|
11
11
|
plugin:
|
|
12
12
|
file: plugin.rb
|
|
13
|
-
sha256: sha256:
|
|
14
|
-
|
|
13
|
+
sha256: sha256:2b11a24f22398f4d00132c2342f7c02eacfc6cec0e517e8241a17241affc8ca7
|
|
14
|
+
# the handoff memory needs memory_write's description-only form and remove: (0.36.0)
|
|
15
|
+
requires_chi: ">= 0.36.0"
|
|
@@ -18,6 +18,13 @@
|
|
|
18
18
|
# for the goal. A REPL session (--no-shared) takes no messages: the request
|
|
19
19
|
# is shown for the user to send.
|
|
20
20
|
#
|
|
21
|
+
# /coordinate resume picks up a coordinator handoff: the open handoff_*
|
|
22
|
+
# memories of this project (an index line whose description doesn't start
|
|
23
|
+
# with DONE, its file there). One: the model is asked to resume it (the
|
|
24
|
+
# skill's step 0); several: a card with a Resume action each
|
|
25
|
+
# (/coordinate resume <name>); none: says so. It reads the project's
|
|
26
|
+
# memories folder, nothing else.
|
|
27
|
+
#
|
|
21
28
|
# A child is asked before it changes anything outside its worktree by chi
|
|
22
29
|
# itself (the core child-boundary guardrail), in every mode: nothing to set up.
|
|
23
30
|
class Plugin
|
|
@@ -27,7 +34,12 @@ class Plugin
|
|
|
27
34
|
STOPPABLE = %w[running waiting].freeze
|
|
28
35
|
TEXT_CHARS = 80
|
|
29
36
|
USAGE = "usage: /children [all] | /children stop <id>"
|
|
30
|
-
COORDINATE_USAGE = "usage: /coordinate <goal> — chi splits it into tasks, one child session and worktree each"
|
|
37
|
+
COORDINATE_USAGE = "usage: /coordinate <goal> — chi splits it into tasks, one child session and worktree each; " \
|
|
38
|
+
"/coordinate resume [name] — pick up an open handoff"
|
|
39
|
+
RESUME_CARD_ID = "coordinate-resume"
|
|
40
|
+
# A card takes 6 actions.
|
|
41
|
+
MAX_RESUMES = 6
|
|
42
|
+
HANDOFF_LINE = /^- \*\*(handoff_[^*\s]+)\*\*(.*)$/
|
|
31
43
|
|
|
32
44
|
def initialize(_settings = {}); end
|
|
33
45
|
|
|
@@ -36,7 +48,8 @@ class Plugin
|
|
|
36
48
|
anytime: true do |args, ctx|
|
|
37
49
|
children_command(args.to_s.strip, ctx)
|
|
38
50
|
end
|
|
39
|
-
chi.command "/coordinate", "run work in parallel: chi splits <goal> into tasks, each in a child session and worktree"
|
|
51
|
+
chi.command "/coordinate", "run work in parallel: chi splits <goal> into tasks, each in a child session and worktree; " \
|
|
52
|
+
"resume: pick up an open handoff",
|
|
40
53
|
anytime: true do |args, ctx|
|
|
41
54
|
coordinate(args.to_s.strip, ctx)
|
|
42
55
|
end
|
|
@@ -97,13 +110,68 @@ class Plugin
|
|
|
97
110
|
def coordinate(goal, ctx)
|
|
98
111
|
return COORDINATE_USAGE if goal.empty?
|
|
99
112
|
|
|
100
|
-
|
|
113
|
+
verb, rest = goal.split(/\s+/, 2)
|
|
114
|
+
# "resume" and at most a name; "resume the old work" is a goal.
|
|
115
|
+
return resume(rest.to_s.strip, ctx) if verb == "resume" && !rest.to_s.strip.match?(/\s/)
|
|
116
|
+
|
|
117
|
+
ask(ctx, "Read the skill_coordinator memory and follow it for this goal:\n\n#{goal}",
|
|
118
|
+
"asked chi to coordinate it; a running turn gets it at its next step")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# /coordinate resume [name]
|
|
122
|
+
def resume(name, ctx)
|
|
123
|
+
open = open_handoffs(ctx)
|
|
124
|
+
if name.empty?
|
|
125
|
+
return "no open coordinator handoff (handoff_* memory) in this project" if open.empty?
|
|
126
|
+
return resume_card(open, ctx) if open.size > 1
|
|
127
|
+
|
|
128
|
+
name = open.first[:name]
|
|
129
|
+
end
|
|
130
|
+
name = "handoff_#{name}" unless name.start_with?("handoff_")
|
|
131
|
+
unless open.any? { |handoff| handoff[:name] == name }
|
|
132
|
+
return "/coordinate resume: no open handoff #{name} in this project (#{open.empty? ? "none is open" : "open: #{open.map { |h| h[:name] }.join(", ")}"})"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
ask(ctx, "Read the skill_coordinator memory and resume the coordinator handoff #{name}: follow the skill's " \
|
|
136
|
+
"Resume (step 0) before anything else.",
|
|
137
|
+
"asked chi to resume #{name}; a running turn gets it at its next step")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def resume_card(open, ctx)
|
|
141
|
+
body = open.map { |handoff| "- #{handoff[:name]}#{" — #{handoff[:description]}" unless handoff[:description].empty?}" }
|
|
142
|
+
actions = open.first(MAX_RESUMES).map do |handoff|
|
|
143
|
+
{ label: "Resume #{handoff[:name].delete_prefix("handoff_")}", command: "/coordinate resume #{handoff[:name]}" }
|
|
144
|
+
end
|
|
145
|
+
ctx.card(id: RESUME_CARD_ID, title: "open handoffs (#{open.size})", body: body.join("\n"), actions: actions)
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The project's handoff_* memories whose index line isn't DONE, in index
|
|
150
|
+
# order: [{name:, description:}]. A line without its file is left out.
|
|
151
|
+
def open_handoffs(ctx)
|
|
152
|
+
dir = Samagotchi::MemoryPaths.scope_dir("project", cwd: ctx.cwd)
|
|
153
|
+
index = File.join(dir.to_s, "index.md")
|
|
154
|
+
return [] unless File.file?(index)
|
|
155
|
+
|
|
156
|
+
File.read(index, encoding: "UTF-8").each_line.filter_map do |line|
|
|
157
|
+
match = line.chomp.match(HANDOFF_LINE) or next
|
|
158
|
+
description = match[2].split(" — ", 2)[1].to_s.strip
|
|
159
|
+
next if description.match?(/\ADONE\b/i)
|
|
160
|
+
next unless File.file?(File.join(dir, "#{match[1]}.md"))
|
|
161
|
+
|
|
162
|
+
{ name: match[1], description: description }
|
|
163
|
+
end
|
|
164
|
+
rescue SystemCallError
|
|
165
|
+
[]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def ask(ctx, request, done)
|
|
101
169
|
begin
|
|
102
170
|
ctx.sessions.send(ctx.session_id, request)
|
|
103
171
|
rescue Samagotchi::Plugin::Sessions::Error => e
|
|
104
172
|
return "/coordinate: #{e.message}. Send this yourself:\n\n#{request}"
|
|
105
173
|
end
|
|
106
|
-
|
|
174
|
+
done
|
|
107
175
|
end
|
|
108
176
|
|
|
109
177
|
def quote(text)
|