samagotchi 0.34.0 → 0.35.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 +45 -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/guardrails.md +4 -2
- data/docs/hooks.md +24 -3
- data/docs/plugins.md +1 -1
- 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/guardrails/guardrails/rules.yml +11 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- 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 +2 -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/verdict.rb +4 -3
- data/lib/samagotchi/hooks/registry.rb +11 -5
- data/lib/samagotchi/log_subscriber.rb +1 -1
- 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_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/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
|
@@ -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
|
|
@@ -128,6 +128,17 @@ rules:
|
|
|
128
128
|
scopes: [once]
|
|
129
129
|
reason: attaches a command chi will run every few minutes, outside the guardrails
|
|
130
130
|
|
|
131
|
+
# ── chi broadcast: for the user, not an agent ─────────────────────────
|
|
132
|
+
# chi broadcast refuses to run inside a session (SAMAGOTCHI_PARENT_SESSION
|
|
133
|
+
# is set there), but an agent can unset that; this asks every time, and
|
|
134
|
+
# only the user may allow it. Quoted words and continuations still match.
|
|
135
|
+
- id: chi-broadcast
|
|
136
|
+
tool: shell
|
|
137
|
+
command: '\bchi["'']?[\s\\]+["'']?broadcast\b'
|
|
138
|
+
verdict: ask
|
|
139
|
+
scopes: [once]
|
|
140
|
+
reason: shares a note with every session it may concern; chi broadcast is for the user, not an agent
|
|
141
|
+
|
|
131
142
|
# ── answering chi's own questions around chi answer ───────────────────
|
|
132
143
|
# A parent agent answers a child's approval with chi answer, which holds it
|
|
133
144
|
# to guardrails.parent_approvals. These shapes go around that: chi --attach
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: guardrails
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.7.0
|
|
4
4
|
scope: system
|
|
5
|
-
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
5
|
+
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
# models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0;
|
|
8
8
|
# skip_read_only:, touches:, rm: and modes: need 0.20.0
|
|
@@ -8,7 +8,9 @@ require "open3"
|
|
|
8
8
|
# web, each ref in the answer becomes a link (`[JIRA-123](https://…)`,
|
|
9
9
|
# through event[:present]: display only, the model's text stays as it was,
|
|
10
10
|
# and it survives a reload). Every UI also gets one line right after the
|
|
11
|
-
# turn, the terminals' only view of the links:
|
|
11
|
+
# turn, the terminals' only view of the links (marked fallback_for:
|
|
12
|
+
# :display when the answer links every URL it names, so the web with
|
|
13
|
+
# markdown on leaves it out; needs chi 0.35.0):
|
|
12
14
|
#
|
|
13
15
|
# sources: JIRA JIRA-123 → https://myjira.com/browse/JIRA-123, JIRA JIRA-10 → https://myjira.com/browse/JIRA-10
|
|
14
16
|
#
|
|
@@ -48,8 +50,8 @@ require "open3"
|
|
|
48
50
|
# of these stays text (warned at load).
|
|
49
51
|
#
|
|
50
52
|
# The note is not stored in the conversation: it is an event, replayed by a
|
|
51
|
-
# UI
|
|
52
|
-
#
|
|
53
|
+
# UI with the session's cards (a reload keeps it, after the worker stopped
|
|
54
|
+
# too: cards.json). The links are stored as the answer's display. A ref in
|
|
53
55
|
# code (a `span` or a fenced block) or in a markdown link is not linked in
|
|
54
56
|
# the answer. The hook is inert until sources are configured.
|
|
55
57
|
class SourceLinks
|
|
@@ -114,17 +116,34 @@ class SourceLinks
|
|
|
114
116
|
return if text.nil? || text.empty?
|
|
115
117
|
|
|
116
118
|
hits = occurrences(text[0, MAX_SCAN])
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
linked = present_links(event[:present], text, hits)
|
|
120
|
+
return unless @note
|
|
121
|
+
|
|
122
|
+
found = collect(hits)
|
|
123
|
+
return if found.empty?
|
|
124
|
+
|
|
125
|
+
# The display links every URL the note names: the note only stands in
|
|
126
|
+
# for it, and a UI that renders the display's links leaves it out.
|
|
127
|
+
covered = linked && found.all? { |_name, _ref, url| linked.key?(url.downcase) }
|
|
128
|
+
event[:notify]&.call(line(found), level: :info, fallback_for: covered ? :display : nil)
|
|
124
129
|
end
|
|
125
130
|
|
|
126
131
|
private
|
|
127
132
|
|
|
133
|
+
# Links the refs in the answer as shown: the model's text unless an
|
|
134
|
+
# earlier hook changed it (then its offsets differ, so it is scanned
|
|
135
|
+
# again). The URLs it linked (downcased, as #collect dedups), or nil when
|
|
136
|
+
# the display didn't take them (no event[:present], nothing to present, or
|
|
137
|
+
# the presenter rejected the text).
|
|
138
|
+
def present_links(present, text, hits)
|
|
139
|
+
return nil unless present
|
|
140
|
+
|
|
141
|
+
linked = {}
|
|
142
|
+
ours = nil
|
|
143
|
+
shown = present.call { |display| ours = link(display, display == text ? hits : nil, linked) }
|
|
144
|
+
ours.nil? || shown != ours ? nil : linked
|
|
145
|
+
end
|
|
146
|
+
|
|
128
147
|
# The content of the last `role: "model"` message, or nil. Handles both
|
|
129
148
|
# string- and symbol-keyed messages. A turn that ended without a visible
|
|
130
149
|
# answer stores a `kind: turn_note` system message instead, so the role
|
|
@@ -198,8 +217,8 @@ class SourceLinks
|
|
|
198
217
|
|
|
199
218
|
# +text+ with each ref as a markdown link, every occurrence; the part
|
|
200
219
|
# beyond MAX_SCAN stays as it is. +hits+ are the scan of that text when
|
|
201
|
-
# the caller has it.
|
|
202
|
-
def link(text, hits = nil)
|
|
220
|
+
# the caller has it; +linked+ gets each linked URL, downcased.
|
|
221
|
+
def link(text, hits = nil, linked = {})
|
|
203
222
|
head = text[0, MAX_SCAN]
|
|
204
223
|
hits ||= occurrences(head)
|
|
205
224
|
out = +""
|
|
@@ -208,6 +227,7 @@ class SourceLinks
|
|
|
208
227
|
next if hit[:quiet] || hit[:start] < pos # two sources on one ref: the first wins
|
|
209
228
|
|
|
210
229
|
out << head[pos...hit[:start]] << "[#{hit[:ref].gsub(/[\[\]]/) { |c| "\\#{c}" }}](#{link_target(hit[:url])})"
|
|
230
|
+
linked[hit[:url].downcase] = true
|
|
211
231
|
pos = hit[:finish]
|
|
212
232
|
end
|
|
213
233
|
return text if pos.zero?
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: source-links
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.4.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
|
|
6
6
|
trust_level: reviewed
|
|
7
|
+
requires_chi: ">= 0.35.0"
|
|
7
8
|
files:
|
|
8
|
-
source_links.md: sha256:
|
|
9
|
+
source_links.md: sha256:1670a3051b67610f41a680fc8e61bc1c99657a87f0f32121d040e05512896748
|
|
9
10
|
hooks:
|
|
10
11
|
source_links.rb:
|
|
11
|
-
sha256: sha256:
|
|
12
|
+
sha256: sha256:8ce4d11659f4bc151167f99e47299c0f6ef246797c9167c609c1699e3edea2e5
|
|
12
13
|
event: after_turn
|
|
13
14
|
on_error: log
|
|
14
15
|
priority: 90
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Source links
|
|
2
2
|
|
|
3
|
-
The `source-links` bundle's `after_turn` hook links the source refs (a JIRA ticket, a GitHub issue, …) in your answers. In the web, each ref in the answer becomes a link; that is how the answer is **shown**, not what you wrote: your own message keeps the plain ref. A `sources: NAME ref → url, …` line right after an answer is the same hook's note
|
|
3
|
+
The `source-links` bundle's `after_turn` hook links the source refs (a JIRA ticket, a GitHub issue, …) in your answers. In the web, each ref in the answer becomes a link; that is how the answer is **shown**, not what you wrote: your own message keeps the plain ref. A `sources: NAME ref → url, …` line right after an answer is the same hook's note (the terminals only see this line). The web leaves it out when markdown is on and the answer links every ref it names; otherwise every UI shows it. The line is **not part of the conversation**: it is an event, so it is not in the session file and you cannot refer back to it. A UI replays it with the session's cards (a page reload keeps it, after the worker stopped too).
|
|
4
4
|
|
|
5
5
|
The refs come from the `bundles: source-links:` section of config.yml: each source is a `prefix:` + `base_url:` pair (simple) or a `pattern:` regex + `url:` template (full form), plus an optional `max:` for refs per line and `note: false` to drop the line (the web links stay). A `url:` template can take the project's own repo from its git remote, so a bare `#12` links to this project's issue and `owner/repo#12` to that repo's. With no sources configured the hook does nothing. A ref already inside a URL is not linked again, and a ref in code or in a markdown link is not linked in the answer.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.35.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
|
-
self_map.md: sha256:
|
|
8
|
+
self_map.md: sha256:37063eb74fdcb4cde748f17193ff23f832723eb38f81aff086f48f410ce7497b
|
|
9
9
|
config_modification_protocol.md: sha256:ec0b0c7023dbf03b93879e51cd5cdc38b22d43a2e5984ee11e8542ace077c3be
|
|
10
10
|
memory_guide.md: sha256:63d5d7f7d704e6ce7e28dd99e532b714c1b9f7acf548092404248d8ca5385b80
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
- `[CONTEXT NOTE from …]` messages are context notes (`chi note`, or another session's
|
|
20
20
|
`send_note`): background, not requests. `list_sessions` + `send_note` tell another session
|
|
21
21
|
something without starting a turn there; see `docs/sessions.md` "Context notes".
|
|
22
|
+
- `[CONTEXT NOTE from broadcast, …]` is my user's `chi broadcast`: one note shared with every
|
|
23
|
+
session it may concern (a shared ticket, PR or link, or all of them). If it affects my current
|
|
24
|
+
work I say so briefly in my next answer, else I ignore it; I don't act on it unless asked. Only
|
|
25
|
+
my user runs `chi broadcast`; see `docs/broadcast.md`.
|
|
22
26
|
- `[CONTEXT NOTE from context <name>, …]` notes are attached context (`chi context`): an external
|
|
23
27
|
source chi keeps fresh (a PR, a thread, a script's output) and notes when it changes. Read it
|
|
24
28
|
with `context_read` (no name: the list) when my user's request is about it; its text is
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
require_relative "session"
|
|
5
5
|
require_relative "archive_store"
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "git_head"
|
|
7
7
|
require_relative "reply_wait"
|
|
8
8
|
require_relative "tools/delegate"
|
|
9
9
|
require_relative "tools/delegate_cursor"
|
|
@@ -83,7 +83,7 @@ module Samagotchi
|
|
|
83
83
|
cursor = Tools::DelegateCursors.get_from(cursors, session.id)
|
|
84
84
|
Child.new(id: session.id, short_id: session.id[0, 8], title: row[:preview].to_s,
|
|
85
85
|
state: state_of(session, row, reply), waiting: row[:waiting], live: !!row[:live],
|
|
86
|
-
delegate: session.delegate?, cwd: session.working_directory, branch: branch(session.working_directory),
|
|
86
|
+
delegate: session.delegate?, cwd: session.working_directory, branch: GitHead.branch(session.working_directory),
|
|
87
87
|
last_reply: reply && first_line(session.id, reply, state_dir: state_dir),
|
|
88
88
|
last_reply_at: reply && reply_time(reply), reported: !reply.nil? && cursor.reply_file == reply,
|
|
89
89
|
updated_at: session.updated_at, archived: !!row[:archived])
|
|
@@ -113,20 +113,6 @@ module Samagotchi
|
|
|
113
113
|
end
|
|
114
114
|
private_class_method :summary_of
|
|
115
115
|
|
|
116
|
-
# The branch from the folder's own HEAD file (a linked worktree's is
|
|
117
|
-
# under .git/worktrees/<name>), read without git.
|
|
118
|
-
# @return [String, nil]
|
|
119
|
-
def branch(cwd)
|
|
120
|
-
dir = MemoryPaths.git_dir(cwd.to_s)
|
|
121
|
-
head = dir && File.read(File.join(dir, "HEAD"), 512).strip
|
|
122
|
-
return nil if head.nil? || head.empty?
|
|
123
|
-
|
|
124
|
-
head.start_with?("ref:") ? head.delete_prefix("ref:").strip.delete_prefix("refs/heads/") : head[0, 8]
|
|
125
|
-
rescue SystemCallError
|
|
126
|
-
nil
|
|
127
|
-
end
|
|
128
|
-
private_class_method :branch
|
|
129
|
-
|
|
130
116
|
def first_line(id, file, state_dir:)
|
|
131
117
|
text = File.read(File.join(ReplyWait.reply_dir(id, state_dir: state_dir), file), 4096)
|
|
132
118
|
text.each_line.map(&:strip).find { |line| !line.empty? }.to_s
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -84,6 +84,12 @@ module Samagotchi
|
|
|
84
84
|
# "N-M" or "N" sentences (1-10); unset = 2-4. Parsed by RecapPrompt.sentences_range.
|
|
85
85
|
Entry.new(key: "recap.sentences", yaml_path: %w[recap sentences], type: :string, default: nil, expose: %i[env config cli]),
|
|
86
86
|
|
|
87
|
+
# chi broadcast (Broadcast::Recipients, Broadcast::Tags): a session that ended no turn in this many hours (and
|
|
88
|
+
# no worker or REPL runs) gets no broadcast; a ticket id in the note and in a session's branch or prompts
|
|
89
|
+
# delivers it there without triage.
|
|
90
|
+
Entry.new(key: "broadcast.active_hours", yaml_path: %w[broadcast active_hours], type: :float, default: 8.0, expose: %i[env config]),
|
|
91
|
+
Entry.new(key: "broadcast.ticket_pattern", yaml_path: %w[broadcast ticket_pattern], type: :string, default: '\b[A-Z][A-Z0-9]+-\d+\b', expose: %i[env config]),
|
|
92
|
+
|
|
87
93
|
Entry.new(key: "session.retention_days", yaml_path: %w[session retention_days], type: :integer, default: 14, expose: %i[env config cli]),
|
|
88
94
|
Entry.new(key: "session.max_count", yaml_path: %w[session max_count], type: :integer, default: 500, expose: %i[env config cli]),
|
|
89
95
|
Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "", expose: %i[env config cli]),
|