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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +2 -0
  4. data/bin/chi +3 -0
  5. data/docs/broadcast.md +129 -0
  6. data/docs/cli.md +8 -0
  7. data/docs/configuration.md +2 -0
  8. data/docs/guardrails.md +4 -2
  9. data/docs/hooks.md +24 -3
  10. data/docs/plugins.md +1 -1
  11. data/docs/sessions.md +6 -6
  12. data/lib/samagotchi/bridge/card_store.rb +1 -1
  13. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  14. data/lib/samagotchi/broadcast/recipients.rb +63 -0
  15. data/lib/samagotchi/broadcast/scope_card.rb +94 -0
  16. data/lib/samagotchi/broadcast/tags.rb +154 -0
  17. data/lib/samagotchi/broadcast_command.rb +228 -0
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/system/self_map.md +4 -0
  25. data/lib/samagotchi/children_status.rb +2 -16
  26. data/lib/samagotchi/config.rb +6 -0
  27. data/lib/samagotchi/engine.rb +29 -5
  28. data/lib/samagotchi/git_head.rb +24 -0
  29. data/lib/samagotchi/guardrails/verdict.rb +4 -3
  30. data/lib/samagotchi/hooks/registry.rb +11 -5
  31. data/lib/samagotchi/log_subscriber.rb +1 -1
  32. data/lib/samagotchi/note_command.rb +13 -13
  33. data/lib/samagotchi/note_delivery.rb +42 -0
  34. data/lib/samagotchi/plugin/context.rb +4 -4
  35. data/lib/samagotchi/session_manager.rb +4 -3
  36. data/lib/samagotchi/sessions_command.rb +3 -2
  37. data/lib/samagotchi/system_prompt.rb +1 -0
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
  39. data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
  40. data/lib/samagotchi/terminal_ui.rb +14 -0
  41. data/lib/samagotchi/tools/send_note.rb +5 -11
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/app.rb +3 -0
  44. data/lib/samagotchi/web/capabilities.rb +19 -0
  45. data/lib/samagotchi/web/public/activity.js +26 -6
  46. data/lib/samagotchi/web/public/app.js +515 -288
  47. data/lib/samagotchi/web/public/card.js +12 -3
  48. data/lib/samagotchi/web/public/command_view.js +2 -3
  49. data/lib/samagotchi/web/public/families_view.js +171 -0
  50. data/lib/samagotchi/web/public/format.js +52 -0
  51. data/lib/samagotchi/web/public/index.html +94 -21
  52. data/lib/samagotchi/web/public/live_timing.js +154 -0
  53. data/lib/samagotchi/web/public/live_turn.js +44 -0
  54. data/lib/samagotchi/web/public/sessions_list.js +228 -32
  55. data/lib/samagotchi/web/public/strip.js +5 -2
  56. data/lib/samagotchi/web/public/turn_html.js +107 -0
  57. data/lib/samagotchi/web/public/turn_model.js +5 -4
  58. data/lib/samagotchi/web/public/turn_view.js +43 -129
  59. data/lib/samagotchi/web/session_summary.rb +4 -0
  60. data/lib/samagotchi/worker.rb +166 -307
  61. data/lib/samagotchi/worker_inbound.rb +251 -0
  62. data/lib/samagotchi/worker_wakes.rb +120 -0
  63. 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.6.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 only while the session's worker lives (a reload keeps it; a stopped
52
- # worker loses it). The links are stored as the answer's display. A ref in
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
- if @note
118
- found = collect(hits)
119
- event[:notify]&.call(line(found), level: :info) unless found.empty?
120
- end
121
- # The answer as shown: the model's text unless an earlier hook changed
122
- # it (then its offsets differ, so it is scanned again).
123
- event[:present]&.call { |shown| link(shown, shown == text ? hits : nil) }
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.4
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:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
+ source_links.md: sha256:1670a3051b67610f41a680fc8e61bc1c99657a87f0f32121d040e05512896748
9
10
  hooks:
10
11
  source_links.rb:
11
- sha256: sha256:c93269964ebc824b5f15ab60db81a458facd50de61446c550f254ea4c68964e2
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, which every UI shows (the terminals only see this line). 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 while the session's worker lives (a page reload keeps it; a stopped worker loses it).
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.34.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:eb017702746f9aad96404d9982a3c27bec717906fa2a14175ab207ad6f843f72
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 "memory_paths"
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
@@ -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]),