samagotchi 0.34.0 → 0.36.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 +77 -1
- data/README.md +2 -0
- data/bin/chi +3 -0
- data/docs/broadcast.md +129 -0
- data/docs/cli.md +8 -0
- data/docs/configuration.md +2 -0
- data/docs/context.md +37 -0
- data/docs/guardrails.md +14 -4
- data/docs/hooks.md +25 -4
- data/docs/memory.md +3 -0
- data/docs/plugins.md +29 -3
- data/docs/sessions.md +6 -6
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/broadcast/recipients.rb +63 -0
- data/lib/samagotchi/broadcast/scope_card.rb +94 -0
- data/lib/samagotchi/broadcast/tags.rb +154 -0
- data/lib/samagotchi/broadcast_command.rb +228 -0
- 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 +22 -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 +44 -11
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
- data/lib/samagotchi/bundles/system/self_map.md +4 -0
- data/lib/samagotchi/children_status.rb +2 -16
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/engine.rb +29 -5
- data/lib/samagotchi/git_head.rb +24 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
- data/lib/samagotchi/guardrails/rules.rb +15 -4
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/guardrails/verdict.rb +4 -3
- data/lib/samagotchi/hooks/registry.rb +11 -5
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
- data/lib/samagotchi/note_command.rb +13 -13
- data/lib/samagotchi/note_delivery.rb +42 -0
- data/lib/samagotchi/plugin/context.rb +4 -4
- data/lib/samagotchi/session_commands.rb +1 -0
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
- data/lib/samagotchi/terminal_ui.rb +14 -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/tools/send_note.rb +5 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/capabilities.rb +19 -0
- data/lib/samagotchi/web/public/activity.js +26 -6
- data/lib/samagotchi/web/public/app.js +515 -288
- data/lib/samagotchi/web/public/card.js +12 -3
- data/lib/samagotchi/web/public/command_view.js +2 -3
- data/lib/samagotchi/web/public/families_view.js +171 -0
- data/lib/samagotchi/web/public/format.js +52 -0
- data/lib/samagotchi/web/public/index.html +94 -21
- data/lib/samagotchi/web/public/live_timing.js +154 -0
- data/lib/samagotchi/web/public/live_turn.js +44 -0
- data/lib/samagotchi/web/public/sessions_list.js +228 -32
- data/lib/samagotchi/web/public/strip.js +5 -2
- data/lib/samagotchi/web/public/turn_html.js +107 -0
- data/lib/samagotchi/web/public/turn_model.js +5 -4
- data/lib/samagotchi/web/public/turn_view.js +43 -129
- data/lib/samagotchi/web/session_summary.rb +4 -0
- data/lib/samagotchi/worker.rb +166 -307
- data/lib/samagotchi/worker_inbound.rb +251 -0
- data/lib/samagotchi/worker_wakes.rb +120 -0
- metadata +15 -1
|
@@ -201,7 +201,7 @@ module Samagotchi
|
|
|
201
201
|
end
|
|
202
202
|
|
|
203
203
|
def add_notice(event)
|
|
204
|
-
notice = event.slice(:type, :hook, :text, :level)
|
|
204
|
+
notice = event.slice(:type, :hook, :text, :level, :fallback_for)
|
|
205
205
|
if event[:between_turns] || !@running
|
|
206
206
|
push(notice.merge(in_turn: false, turns: @turns_done))
|
|
207
207
|
else
|
|
@@ -10,7 +10,7 @@ module Samagotchi
|
|
|
10
10
|
# here, and draw it with the handler that drew it live, where it was.
|
|
11
11
|
module TurnNotice
|
|
12
12
|
FIELDS = {
|
|
13
|
-
hook_notice: %i[hook text level],
|
|
13
|
+
hook_notice: %i[hook text level fallback_for],
|
|
14
14
|
empty_answer_retry: %i[iteration attempt of stopped_by malformed],
|
|
15
15
|
steer_cut: %i[iteration source],
|
|
16
16
|
question_requested: %i[pending_question],
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../session"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# Loaded on first use: session_manager requires terminal_ui, which
|
|
8
|
+
# requires KernelLoop and so the tools (a require cycle otherwise).
|
|
9
|
+
autoload :SessionManager, File.expand_path("../session_manager", __dir__)
|
|
10
|
+
|
|
11
|
+
module Broadcast
|
|
12
|
+
# A session a broadcast may concern.
|
|
13
|
+
# @!attribute project [String, nil] its git project's root
|
|
14
|
+
# @!attribute live [Boolean] a worker runs it now
|
|
15
|
+
# @!attribute owner [String, nil] "worker", "tui" (a chi REPL, which
|
|
16
|
+
# takes no notes) or nil
|
|
17
|
+
Recipient = Data.define(:id, :short_id, :project, :cwd, :desc, :live, :owner) do
|
|
18
|
+
def repl? = owner == "tui"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Who a broadcast goes to: the user's own sessions, not delegate
|
|
22
|
+
# children (their parent relays what concerns them) and not scratch
|
|
23
|
+
# ones, that a worker or a chi REPL runs now or that ended a turn in
|
|
24
|
+
# the last +active_hours+. Forks count: they are the user's sessions.
|
|
25
|
+
# Archived sessions don't; test runs only when chi runs as one.
|
|
26
|
+
module Recipients
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# @return [Array<Recipient>] newest first
|
|
30
|
+
def list(state_dir:, active_hours:, include_tests: Session.test_session_env?, now: Time.now)
|
|
31
|
+
cutoff = now - (active_hours.to_f * 3600)
|
|
32
|
+
SessionManager.session_summaries(include_tests: include_tests, state_dir: state_dir).filter_map do |row|
|
|
33
|
+
next if row[:delegate] || row[:scratch]
|
|
34
|
+
next unless row[:live] || row[:owner] == "tui" || active_since?(row, cutoff, state_dir)
|
|
35
|
+
|
|
36
|
+
Recipient.new(id: row[:id], short_id: row[:short_id], project: row[:project], cwd: row[:cwd],
|
|
37
|
+
desc: row[:desc], live: row[:live], owner: row[:owner])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The user's last turn there ended after +cutoff+. updated_at moves on
|
|
42
|
+
# every save (a note absorbed, a resume), so it counts only for a
|
|
43
|
+
# session with no turn recorded; it is never older than the last
|
|
44
|
+
# turn's end, so an older one rules the session out unread.
|
|
45
|
+
def active_since?(row, cutoff, state_dir)
|
|
46
|
+
return false unless (time(row[:updated_at]) || Time.at(0)) >= cutoff
|
|
47
|
+
|
|
48
|
+
session = Session.summary_from_file(Session.session_file(row[:id], state_dir: state_dir))
|
|
49
|
+
last_turn = session&.last_turn
|
|
50
|
+
ended = last_turn.is_a?(Hash) ? time(last_turn["ended_at"] || last_turn[:ended_at]) : nil
|
|
51
|
+
(ended || time(row[:updated_at])) >= cutoff
|
|
52
|
+
end
|
|
53
|
+
private_class_method :active_since?
|
|
54
|
+
|
|
55
|
+
def time(value)
|
|
56
|
+
value && Time.iso8601(value.to_s)
|
|
57
|
+
rescue ArgumentError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
private_class_method :time
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "../session"
|
|
5
|
+
require_relative "../context_note"
|
|
6
|
+
require_relative "../context_sources"
|
|
7
|
+
require_relative "../git_head"
|
|
8
|
+
require_relative "../recap_store"
|
|
9
|
+
require_relative "tags"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
module Broadcast
|
|
13
|
+
# What a broadcast knows about one recipient, from local state only:
|
|
14
|
+
# where it works, what it was started for, its recap, its last prompt
|
|
15
|
+
# and its tags. Built fresh for each broadcast, never stored; P2's
|
|
16
|
+
# triage model reads #to_s.
|
|
17
|
+
# @!attribute folder [String, nil] the session's folder relative to its
|
|
18
|
+
# project ("../app-fix" for a worktree), nil when it is the project
|
|
19
|
+
# root; outside a project the whole path, ~ for home
|
|
20
|
+
# @!attribute tags [Array<Tag>]
|
|
21
|
+
ScopeCard = Data.define(:id, :project, :folder, :branch, :title, :tags, :started, :recap, :recent) do
|
|
22
|
+
def to_s
|
|
23
|
+
where = [("folder #{folder}" if folder), ("branch #{branch}" if branch)].compact.join(", ")
|
|
24
|
+
lines = ["project: #{project || "none"}#{" (#{where})" unless where.empty?}"]
|
|
25
|
+
lines << "title: #{title}" unless title.to_s.empty?
|
|
26
|
+
lines << "tags: #{tags.map(&:label).join(" · ")}" unless tags.empty?
|
|
27
|
+
lines << "started: #{started}" unless started.to_s.empty?
|
|
28
|
+
lines << "recap: #{recap}" unless recap.to_s.empty?
|
|
29
|
+
lines << "recent: #{recent}" unless recent.to_s.empty?
|
|
30
|
+
lines.join("\n")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Builds a ScopeCard for a Recipient.
|
|
35
|
+
module ScopeCards
|
|
36
|
+
STARTED_CHARS = 300
|
|
37
|
+
RECAP_CHARS = 400
|
|
38
|
+
RECENT_CHARS = 200
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# @param recipient [Recipient]
|
|
43
|
+
# @param ticket [Regexp] the ticket pattern (Tags.ticket_regexp)
|
|
44
|
+
# @return [ScopeCard]
|
|
45
|
+
def build(recipient, state_dir:, ticket:)
|
|
46
|
+
session = Session.load(recipient.id, state_dir: state_dir)
|
|
47
|
+
prompts = user_texts(session.messages)
|
|
48
|
+
branch = GitHead.branch(session.working_directory)
|
|
49
|
+
sources = ContextSources.attached(session.id, project_root: session.project_root, state_dir: state_dir)
|
|
50
|
+
.map { |attached| [attached.name, attached.source.hint] }
|
|
51
|
+
ScopeCard.new(id: session.id, project: recipient.project && File.basename(recipient.project),
|
|
52
|
+
folder: folder(session.working_directory, recipient.project), branch: branch,
|
|
53
|
+
title: recipient.desc, tags: Tags.of_session(branch: branch, messages: prompts, sources: sources,
|
|
54
|
+
ticket: ticket),
|
|
55
|
+
started: cut(prompts.first, STARTED_CHARS),
|
|
56
|
+
recap: cut(RecapStore.read(Session.session_dir(session.id, state_dir: state_dir))&.dig(:text),
|
|
57
|
+
RECAP_CHARS),
|
|
58
|
+
recent: cut(session.last_prompt, RECENT_CHARS))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The user's own messages, as text (an image part leaves its text).
|
|
62
|
+
def user_texts(messages)
|
|
63
|
+
messages.filter_map do |message|
|
|
64
|
+
next unless message[:role].to_s == "user"
|
|
65
|
+
|
|
66
|
+
content = message[:content]
|
|
67
|
+
text = if content.is_a?(Array)
|
|
68
|
+
content.filter_map { |part| part.is_a?(Hash) ? part[:text] || part["text"] : nil }.join("\n")
|
|
69
|
+
else
|
|
70
|
+
content.to_s
|
|
71
|
+
end
|
|
72
|
+
text unless text.strip.empty?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
private_class_method :user_texts
|
|
76
|
+
|
|
77
|
+
def folder(cwd, project)
|
|
78
|
+
return ContextNote.home_relative(cwd) if project.nil?
|
|
79
|
+
return nil if cwd.to_s == project.to_s
|
|
80
|
+
|
|
81
|
+
Pathname.new(cwd.to_s).relative_path_from(Pathname.new(project.to_s)).to_s
|
|
82
|
+
rescue ArgumentError
|
|
83
|
+
cwd
|
|
84
|
+
end
|
|
85
|
+
private_class_method :folder
|
|
86
|
+
|
|
87
|
+
def cut(text, limit)
|
|
88
|
+
line = text.to_s.gsub(/\s+/, " ").strip
|
|
89
|
+
line.length > limit ? "#{line[0, limit - 1]}…" : line
|
|
90
|
+
end
|
|
91
|
+
private_class_method :cut
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Broadcast
|
|
7
|
+
# A cheap, deterministic marker found in a note or about a session: a
|
|
8
|
+
# ticket id, a pull request, a link. A tag the note and a session share
|
|
9
|
+
# delivers the note there without asking a model (the fast path).
|
|
10
|
+
# @!attribute kind [String] "ticket", "pr" or "link"
|
|
11
|
+
# @!attribute value [String] "PAY-123"; "#42" or "acme/shop#42"; a
|
|
12
|
+
# link's host and path ("notion.so/team/checkout-v2")
|
|
13
|
+
# @!attribute from [String] where it was found: "note", "branch",
|
|
14
|
+
# "messages" (the user's prompts) or "context <name>" (an attached
|
|
15
|
+
# context source)
|
|
16
|
+
Tag = Data.define(:kind, :value, :from) do
|
|
17
|
+
def label = "#{kind} #{value}"
|
|
18
|
+
|
|
19
|
+
# Same kind and value; a pr with no repo matches the same number in
|
|
20
|
+
# any repo.
|
|
21
|
+
def matches?(other)
|
|
22
|
+
return false unless kind == other.kind
|
|
23
|
+
return value == other.value unless kind == "pr"
|
|
24
|
+
|
|
25
|
+
number, repo = Tags.pr_parts(value)
|
|
26
|
+
other_number, other_repo = Tags.pr_parts(other.value)
|
|
27
|
+
number == other_number && (repo.nil? || other_repo.nil? || repo == other_repo)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A tag of the note and the session tag it matched.
|
|
32
|
+
Match = Data.define(:note_tag, :session_tag) do
|
|
33
|
+
# For the broadcast's output: "ticket PAY-123 matches (branch)".
|
|
34
|
+
def reason = "#{note_tag.label} matches (#{session_tag.from})"
|
|
35
|
+
|
|
36
|
+
# For the note the session gets: "ticket PAY-123 matches your branch".
|
|
37
|
+
def because
|
|
38
|
+
tag = session_tag
|
|
39
|
+
case tag.from
|
|
40
|
+
when "branch" then "#{note_tag.label} matches your branch"
|
|
41
|
+
when "messages" then "#{note_tag.label} is in your user's messages too"
|
|
42
|
+
else "#{note_tag.label} matches your attached #{tag.from}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Tags from text, a branch and attached context, and the first match
|
|
48
|
+
# between a note's and a session's.
|
|
49
|
+
module Tags
|
|
50
|
+
DEFAULT_TICKET_PATTERN = '\b[A-Z][A-Z0-9]+-\d+\b'
|
|
51
|
+
# Words the default ticket pattern would take that are no ticket.
|
|
52
|
+
NOT_TICKETS = %w[UTF SHA ISO IEC RFC GPT AES RSA ECMA ES HTTP TLS SSL MD IPV X].freeze
|
|
53
|
+
URL_RE = %r{https?://[^\s<>()\[\]{}"'`|]+}
|
|
54
|
+
PR_URL_RE = %r{\A(?:www\.)?github\.com/([^/]+/[^/]+)/pull/(\d+)}i
|
|
55
|
+
PR_TEXT_RE = /\bPR\s?#(\d+)\b/i
|
|
56
|
+
PR_SOURCE_RE = /\Apr-(\d+)\z/
|
|
57
|
+
# The order matches are looked for in: the most specific first.
|
|
58
|
+
KINDS = %w[ticket pr link].freeze
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
# broadcast.ticket_pattern as a Regexp; nil, empty or invalid → the
|
|
63
|
+
# default (an invalid one is reported through +warn+).
|
|
64
|
+
# @return [Regexp]
|
|
65
|
+
def ticket_regexp(pattern, warn: nil)
|
|
66
|
+
return Regexp.new(DEFAULT_TICKET_PATTERN) if pattern.to_s.strip.empty?
|
|
67
|
+
|
|
68
|
+
Regexp.new(pattern.to_s)
|
|
69
|
+
rescue RegexpError => e
|
|
70
|
+
warn&.call("broadcast.ticket_pattern #{pattern.inspect} is not a valid pattern (#{e.message}); using the default")
|
|
71
|
+
Regexp.new(DEFAULT_TICKET_PATTERN)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Array<Tag>] the tags in +text+ (a note, the user's prompts)
|
|
75
|
+
def of_text(text, from:, ticket: ticket_regexp(nil))
|
|
76
|
+
text = text.to_s
|
|
77
|
+
found = tickets(text, ticket, from: from)
|
|
78
|
+
found += text.scan(PR_TEXT_RE).map { |(number)| Tag.new(kind: "pr", value: "##{number}", from: from) }
|
|
79
|
+
text.scan(URL_RE).each { |url| found += of_url(url, from: from) }
|
|
80
|
+
found.uniq
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A session's tags: its branch (matched whatever its case: branches
|
|
84
|
+
# are often lowercase), the user's messages, its attached context
|
|
85
|
+
# (a pr-<n> source and every source's URL hint).
|
|
86
|
+
# @param sources [Array<Array(String, String)>] [name, hint] pairs
|
|
87
|
+
# @return [Array<Tag>]
|
|
88
|
+
def of_session(branch:, messages:, sources:, ticket: ticket_regexp(nil))
|
|
89
|
+
found = tickets(branch.to_s, Regexp.new(ticket.source, ticket.options | Regexp::IGNORECASE), from: "branch")
|
|
90
|
+
found += messages.flat_map { |text| of_text(text, from: "messages", ticket: ticket) }
|
|
91
|
+
sources.each do |name, hint|
|
|
92
|
+
from = "context #{name}"
|
|
93
|
+
from_hint = hint.to_s.scan(URL_RE).flat_map { |url| of_url(url, from: from) }
|
|
94
|
+
number = name.to_s[PR_SOURCE_RE, 1]
|
|
95
|
+
# pr-<n> with no PR URL in its hint: the number alone, any repo
|
|
96
|
+
found << Tag.new(kind: "pr", value: "##{number}", from: from) if number && from_hint.none? { |t| t.kind == "pr" }
|
|
97
|
+
found += from_hint
|
|
98
|
+
end
|
|
99
|
+
found.uniq { |tag| [tag.kind, tag.value] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The first session tag a note tag matches, tickets first, then pull
|
|
103
|
+
# requests, then links.
|
|
104
|
+
# @return [Match, nil]
|
|
105
|
+
def match(note_tags, session_tags)
|
|
106
|
+
KINDS.each do |kind|
|
|
107
|
+
note_tags.each do |tag|
|
|
108
|
+
next unless tag.kind == kind
|
|
109
|
+
|
|
110
|
+
hit = session_tags.find { |other| tag.matches?(other) }
|
|
111
|
+
return Match.new(note_tag: tag, session_tag: hit) if hit
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# "acme/shop#42" → ["42", "acme/shop"]; "#42" → ["42", nil].
|
|
118
|
+
def pr_parts(value)
|
|
119
|
+
repo, number = value.to_s.split("#", 2)
|
|
120
|
+
[number, repo.to_s.empty? ? nil : repo.downcase]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def tickets(text, regexp, from:)
|
|
124
|
+
text.to_enum(:scan, regexp).filter_map do
|
|
125
|
+
id = Regexp.last_match(0).upcase
|
|
126
|
+
next if NOT_TICKETS.include?(id.split("-", 2).first)
|
|
127
|
+
|
|
128
|
+
Tag.new(kind: "ticket", value: id, from: from)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
private_class_method :tickets
|
|
132
|
+
|
|
133
|
+
# A link's host and path, without the scheme, www., the query, the
|
|
134
|
+
# fragment or a trailing slash; a pull request URL is a pr tag too. A
|
|
135
|
+
# bare host ("https://github.com/") says too little to be a tag.
|
|
136
|
+
def of_url(url, from:)
|
|
137
|
+
uri = URI.parse(url.sub(/[.,;:!?]+\z/, ""))
|
|
138
|
+
host = uri.host.to_s.downcase.delete_prefix("www.")
|
|
139
|
+
path = uri.path.to_s.chomp("/")
|
|
140
|
+
return [] if host.empty? || path.empty?
|
|
141
|
+
|
|
142
|
+
link = "#{host}#{path}"
|
|
143
|
+
tags = [Tag.new(kind: "link", value: link, from: from)]
|
|
144
|
+
if (pr = link.match(PR_URL_RE))
|
|
145
|
+
tags.unshift(Tag.new(kind: "pr", value: "#{pr[1].downcase}##{pr[2]}", from: from))
|
|
146
|
+
end
|
|
147
|
+
tags
|
|
148
|
+
rescue URI::Error
|
|
149
|
+
[]
|
|
150
|
+
end
|
|
151
|
+
private_class_method :of_url
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "session"
|
|
5
|
+
require_relative "session_inbox"
|
|
6
|
+
require_relative "note_delivery"
|
|
7
|
+
require_relative "broadcast/recipients"
|
|
8
|
+
require_relative "broadcast/scope_card"
|
|
9
|
+
require_relative "broadcast/tags"
|
|
10
|
+
require_relative "cli/command"
|
|
11
|
+
require_relative "cli/flags"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
# `chi broadcast`: share a note with every session it may concern,
|
|
15
|
+
# without picking them. Each recipient (Broadcast::Recipients) that shares
|
|
16
|
+
# 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.
|
|
19
|
+
class BroadcastCommand
|
|
20
|
+
include CLI::Command
|
|
21
|
+
|
|
22
|
+
SOURCE = "broadcast"
|
|
23
|
+
# Bytes kept free under SessionInbox's 16 KiB for the line each note
|
|
24
|
+
# ends with (#body).
|
|
25
|
+
BODY_ROOM = 512
|
|
26
|
+
# The longest "because" #body quotes (a link tag can be long).
|
|
27
|
+
BECAUSE_CHARS = 200
|
|
28
|
+
# The env every execute/task call exports (Tools::Builtins): set, the
|
|
29
|
+
# command runs inside a chi session, i.e. an agent runs it.
|
|
30
|
+
PARENT_ENV = "SAMAGOTCHI_PARENT_SESSION"
|
|
31
|
+
|
|
32
|
+
USAGE = <<~TEXT
|
|
33
|
+
Usage: chi broadcast [-m TEXT] [--all] [--dry-run]
|
|
34
|
+
Shares TEXT (or stdin) with the sessions it may concern, as a context
|
|
35
|
+
note from "broadcast": background the model sees on its next turn,
|
|
36
|
+
not a prompt. It starts no turn.
|
|
37
|
+
Recipients: your own sessions (not delegate children, not scratch
|
|
38
|
+
ones) that a worker or a chi REPL runs now, or that ended a turn in
|
|
39
|
+
the last broadcast.active_hours (8). One gets the note when it shares
|
|
40
|
+
a tag with it: a ticket id (PAY-123) in its branch or your prompts
|
|
41
|
+
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.
|
|
43
|
+
-m TEXT the note; without it, stdin is read
|
|
44
|
+
--all every recipient, tag or not
|
|
45
|
+
--dry-run show each recipient's scope card, tags and verdict; deliver nothing
|
|
46
|
+
For you, not for an agent: refused inside a chi session.
|
|
47
|
+
One session or a few by id: chi note. See docs/broadcast.md.
|
|
48
|
+
TEXT
|
|
49
|
+
|
|
50
|
+
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS, args: false) do |f|
|
|
51
|
+
f.switch "--all"
|
|
52
|
+
f.switch "--dry-run"
|
|
53
|
+
f.value "-m", "--message", key: :text
|
|
54
|
+
end
|
|
55
|
+
|
|
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?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param argv [Array<String>] the arguments after "broadcast"
|
|
63
|
+
# @param active_hours [Numeric, nil] broadcast.active_hours (config)
|
|
64
|
+
# @param ticket_pattern [String, nil] broadcast.ticket_pattern (config)
|
|
65
|
+
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"))
|
|
67
|
+
@argv = argv.dup
|
|
68
|
+
@stdin = stdin
|
|
69
|
+
@stdout = stdout
|
|
70
|
+
@stderr = stderr
|
|
71
|
+
@state_dir = state_dir || Session.default_state_dir
|
|
72
|
+
@env = env
|
|
73
|
+
@now = now
|
|
74
|
+
@active_hours = active_hours || 8
|
|
75
|
+
@ticket = Broadcast::Tags.ticket_regexp(ticket_pattern, warn: ->(line) { @stderr.puts("chi broadcast: #{line}") })
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Integer] exit status: 0 done (skipped sessions included),
|
|
79
|
+
# 1 refused, nothing to send to or a delivery failed, 2 usage
|
|
80
|
+
def run
|
|
81
|
+
options = parse
|
|
82
|
+
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
|
|
88
|
+
|
|
89
|
+
text = utf8(options[:text] || read_stdin)
|
|
90
|
+
return usage_error("no note text: pass -m TEXT or pipe it in") unless text
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
text = SessionInbox.checked_text(text)
|
|
94
|
+
limit = SessionInbox::NOTE_MAX_BYTES - BODY_ROOM
|
|
95
|
+
if text.bytesize > limit
|
|
96
|
+
raise SessionInbox::NoteRejected, "the note is #{text.bytesize} bytes; a broadcast takes up to #{limit} " \
|
|
97
|
+
"(16 KiB less room for the line chi adds)"
|
|
98
|
+
end
|
|
99
|
+
rescue SessionInbox::NoteRejected => e
|
|
100
|
+
error_line("chi broadcast: #{e.message}")
|
|
101
|
+
return 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
broadcast(text, all: options[:all], dry_run: options[:dry_run])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def command_name = "chi broadcast"
|
|
110
|
+
|
|
111
|
+
def parse
|
|
112
|
+
parsed = parse_flags(FLAGS, @argv)
|
|
113
|
+
parsed.is_a?(Integer) ? parsed : parsed.options
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Integer] exit status
|
|
117
|
+
def broadcast(text, all:, dry_run:)
|
|
118
|
+
recipients = Broadcast::Recipients.list(state_dir: @state_dir, active_hours: @active_hours, now: @now)
|
|
119
|
+
if recipients.empty?
|
|
120
|
+
error_line("chi broadcast: no sessions to send to: none runs now or ended a turn in the last " \
|
|
121
|
+
"#{format_hours(@active_hours)} (chi sessions list --scope=all)")
|
|
122
|
+
return 1
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
note_tags = Broadcast::Tags.of_text(text, from: "note", ticket: @ticket)
|
|
126
|
+
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)}")
|
|
131
|
+
@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
|
|
133
|
+
|
|
134
|
+
deliver_all(verdicts, text)
|
|
135
|
+
end
|
|
136
|
+
|
|
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?
|
|
139
|
+
|
|
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)
|
|
143
|
+
end
|
|
144
|
+
|
|
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(/^/, " "))
|
|
149
|
+
end
|
|
150
|
+
delivered = verdicts.count(&:deliver?)
|
|
151
|
+
@stdout.puts("would deliver #{delivered} · skipped #{verdicts.size - delivered}")
|
|
152
|
+
0
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def deliver_all(verdicts, text)
|
|
156
|
+
ok = true
|
|
157
|
+
lines = verdicts.map do |v|
|
|
158
|
+
next [v.recipient, false, "skipped", v.skip] unless v.deliver?
|
|
159
|
+
|
|
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?
|
|
162
|
+
|
|
163
|
+
[v.recipient, true, "delivered", "#{reason(v)}#{"; waits for its next start" if result.status == :waits}"]
|
|
164
|
+
rescue SessionInbox::NoteRejected, SystemCallError => e
|
|
165
|
+
ok = false
|
|
166
|
+
[v.recipient, false, "failed", e.message]
|
|
167
|
+
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))
|
|
170
|
+
end
|
|
171
|
+
delivered = lines.count { |_, d| d }
|
|
172
|
+
@stdout.puts("delivered #{delivered} · skipped #{lines.size - delivered}")
|
|
173
|
+
ok ? 0 : 1
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# What a recipient gets: the user's text first (the terminal's "note
|
|
177
|
+
# from broadcast: …" line shows its start), then a line saying when it
|
|
178
|
+
# 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)
|
|
182
|
+
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
|
|
184
|
+
|
|
185
|
+
because = match.because
|
|
186
|
+
because = "#{because[0, BECAUSE_CHARS - 1]}…" if because.length > BECAUSE_CHARS
|
|
187
|
+
"#{text}\n(#{shared} with the sessions it may concern; it reached you because #{because}.)"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def reason(verdict)
|
|
191
|
+
verdict.skip || verdict.match&.reason || "--all"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def line(recipient, word, why, width: 9) = "#{recipient.short_id} #{word.ljust(width)} #{why}"
|
|
195
|
+
|
|
196
|
+
# The note's first line, quoted and cut, for the output's head.
|
|
197
|
+
def headline(text)
|
|
198
|
+
lines = text.lines
|
|
199
|
+
first = lines.first.to_s.strip
|
|
200
|
+
first = "#{first[0, 59]}…" if first.length > 60
|
|
201
|
+
first = "#{first} …" if lines.size > 1 && !first.end_with?("…")
|
|
202
|
+
"\"#{first}\""
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def format_hours(hours)
|
|
206
|
+
value = hours.to_f
|
|
207
|
+
"#{value == value.round ? value.round : value} #{value == 1 ? "hour" : "hours"}"
|
|
208
|
+
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
|
+
end
|
|
228
|
+
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"
|