samagotchi 0.2.0 → 0.3.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +40 -4
  4. data/bin/chi +86 -19
  5. data/docs/cli.md +108 -5
  6. data/docs/configuration.md +229 -45
  7. data/docs/desktop.md +6 -0
  8. data/docs/hooks.md +126 -5
  9. data/docs/plugins.md +68 -2
  10. data/docs/releasing.md +18 -8
  11. data/docs/sessions.md +30 -4
  12. data/lib/samagotchi/answer_display.rb +95 -0
  13. data/lib/samagotchi/archive_store.rb +90 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  16. data/lib/samagotchi/bootstrap_command.rb +347 -0
  17. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
  19. data/lib/samagotchi/bridge.rb +9 -0
  20. data/lib/samagotchi/bridge_client.rb +6 -2
  21. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  22. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  25. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  29. data/lib/samagotchi/client.rb +72 -13
  30. data/lib/samagotchi/config.rb +196 -36
  31. data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
  32. data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
  33. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  34. data/lib/samagotchi/engine.rb +233 -36
  35. data/lib/samagotchi/guardrails/approval.rb +9 -0
  36. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  37. data/lib/samagotchi/guardrails.rb +1 -0
  38. data/lib/samagotchi/hooks/registry.rb +24 -5
  39. data/lib/samagotchi/host_registry.rb +4 -3
  40. data/lib/samagotchi/idle_recap.rb +5 -1
  41. data/lib/samagotchi/kernel_loop.rb +47 -15
  42. data/lib/samagotchi/llm/chat_loop.rb +59 -20
  43. data/lib/samagotchi/llm/errors.rb +21 -3
  44. data/lib/samagotchi/llm/http.rb +42 -13
  45. data/lib/samagotchi/llm/openai_chat.rb +12 -4
  46. data/lib/samagotchi/log_subscriber.rb +18 -3
  47. data/lib/samagotchi/model_profile.rb +1 -1
  48. data/lib/samagotchi/plugin/context.rb +22 -1
  49. data/lib/samagotchi/plugin/sessions.rb +3 -1
  50. data/lib/samagotchi/reply_wait.rb +126 -0
  51. data/lib/samagotchi/sampling_settings.rb +58 -0
  52. data/lib/samagotchi/self_report.rb +1 -0
  53. data/lib/samagotchi/send_command.rb +153 -7
  54. data/lib/samagotchi/session.rb +52 -11
  55. data/lib/samagotchi/session_archive_command.rb +107 -0
  56. data/lib/samagotchi/session_commands.rb +11 -2
  57. data/lib/samagotchi/session_manager.rb +114 -9
  58. data/lib/samagotchi/session_metrics.rb +222 -106
  59. data/lib/samagotchi/steer.rb +72 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
  62. data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
  63. data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
  64. data/lib/samagotchi/terminal_ui.rb +77 -4
  65. data/lib/samagotchi/tool_activity.rb +3 -1
  66. data/lib/samagotchi/tools/builtins.rb +15 -4
  67. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  68. data/lib/samagotchi/tools/execute.rb +52 -14
  69. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  70. data/lib/samagotchi/tools/task_wait.rb +27 -3
  71. data/lib/samagotchi/turn_note.rb +60 -6
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +2 -6
  74. data/lib/samagotchi/web/app.rb +88 -4
  75. data/lib/samagotchi/web/public/activity.js +10 -1
  76. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  77. data/lib/samagotchi/web/public/annotations.js +13 -0
  78. data/lib/samagotchi/web/public/app.js +437 -88
  79. data/lib/samagotchi/web/public/card.js +5 -3
  80. data/lib/samagotchi/web/public/chat_view.js +10 -1
  81. data/lib/samagotchi/web/public/copy.js +20 -4
  82. data/lib/samagotchi/web/public/ctx.js +15 -0
  83. data/lib/samagotchi/web/public/data.js +21 -6
  84. data/lib/samagotchi/web/public/format.js +9 -0
  85. data/lib/samagotchi/web/public/index.html +38 -2
  86. data/lib/samagotchi/web/public/notify.js +175 -0
  87. data/lib/samagotchi/web/public/question_card.js +2 -1
  88. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  89. data/lib/samagotchi/web/public/timing.js +39 -14
  90. data/lib/samagotchi/web/public/turn_events.js +46 -0
  91. data/lib/samagotchi/web/public/turn_view.js +47 -7
  92. data/lib/samagotchi/web/server.rb +8 -4
  93. data/lib/samagotchi/web/session_hub.rb +2 -1
  94. data/lib/samagotchi/web/session_summary.rb +24 -1
  95. data/lib/samagotchi/worker.rb +11 -0
  96. metadata +20 -1
@@ -0,0 +1,244 @@
1
+ # The check-in bundle (docs/plugins.md, The check-in bundle): a turn that
2
+ # has made many tool calls without answering gets checked on. After `after`
3
+ # tool calls (then every `every` more) it asks the user with a card (Nudge /
4
+ # Keep going / Stop), nudges the model by itself, or only says so.
5
+ #
6
+ # A nudge is ctx.steer: the message joins the running turn at its next
7
+ # boundary as its own user message, so the model reads it before its next
8
+ # step. It never starts a turn, and one that arrives after the model's
9
+ # final answer is dropped: the card's Nudge then says so at the turn's end
10
+ # (after_turn's messages have no steer with its text).
11
+ #
12
+ # The count is per turn: before_turn resets it (and closes a card left open
13
+ # by a turn that failed or was interrupted, where after_turn doesn't fire);
14
+ # steering merged mid-turn does not. The /checkin commands are anytime ones
15
+ # (a card's actions run while the turn goes on), so the state is behind a
16
+ # Mutex.
17
+ #
18
+ # Settings (config.yml, bundles: check-in:):
19
+ # after: 50 tool calls in one turn before the first check-in
20
+ # every: 50 then again every this many more
21
+ # mode: ask ask | nudge | notify
22
+ # message: "..." what a nudge says; {calls} is the count
23
+ # ignore_tools: [task_wait, task_get, delegate_result]
24
+ require "securerandom"
25
+
26
+ class Plugin
27
+ MODES = %w[ask nudge notify].freeze
28
+ DEFAULT_MESSAGE = "You've made {calls} tool calls in this turn without answering. Say briefly what you've found " \
29
+ "so far and what's left, then answer now or continue."
30
+ DEFAULT_IGNORE = %w[task_wait task_get delegate_result].freeze
31
+ LAST_TOOLS = 5
32
+ USAGE = "usage: /checkin [on|off|<calls>|mode ask|nudge|notify|nudge|later|stop]"
33
+
34
+ def initialize(settings = {})
35
+ settings = {} unless settings.is_a?(Hash)
36
+ @after = positive(settings["after"]) || 50
37
+ @every = positive(settings["every"]) || @after
38
+ @mode = MODES.include?(settings["mode"].to_s) ? settings["mode"].to_s : "ask"
39
+ message = settings["message"].to_s.strip
40
+ @message = message.empty? ? DEFAULT_MESSAGE : message
41
+ @ignore = settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
42
+ @enabled = true
43
+ @mutex = Mutex.new
44
+ reset
45
+ end
46
+
47
+ def register(chi)
48
+ chi.on(:before_turn) { |_event, ctx| before_turn(ctx) }
49
+ chi.on(:after_tool_call) { |event, ctx| after_tool_call(event, ctx) }
50
+ chi.on(:after_turn) { |event, ctx| after_turn(event, ctx) }
51
+ chi.command "/checkin", "check on a long turn: status, on|off, <calls>, mode ask|nudge|notify, nudge, later, stop",
52
+ anytime: true do |args, ctx|
53
+ command(args.to_s.strip.downcase, ctx)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # --- the turn --------------------------------------------------------------
60
+
61
+ def reset
62
+ @count = 0
63
+ @next_at = @after
64
+ @started = monotonic
65
+ @tools = []
66
+ @card_id = nil # this turn's card, once shown
67
+ @card_open = false
68
+ @nudge = nil # the card's Nudge this turn: { text:, count: }
69
+ end
70
+
71
+ def before_turn(ctx)
72
+ close_card(ctx, "The turn ended after #{calls} tool calls.")
73
+ @mutex.synchronize { reset }
74
+ end
75
+
76
+ def after_tool_call(event, ctx)
77
+ tool = event[:tool].to_s
78
+ return if @ignore.include?(tool)
79
+
80
+ due = @mutex.synchronize do
81
+ @count += 1
82
+ @tools = (@tools + [tool]).last(LAST_TOOLS)
83
+ next nil unless @enabled && @count >= @next_at
84
+
85
+ @next_at = @count + @every
86
+ [@count, @mode]
87
+ end
88
+ check_in(event, ctx, *due) if due
89
+ end
90
+
91
+ def check_in(event, ctx, count, mode)
92
+ case mode
93
+ when "nudge"
94
+ ctx.notify("nudged the model after #{count} tool calls") if event[:steer]&.call(message(count))
95
+ when "notify"
96
+ ctx.notify("#{count} tool calls in this turn, no answer yet")
97
+ else
98
+ show_card(ctx, count)
99
+ end
100
+ end
101
+
102
+ def after_turn(event, ctx)
103
+ return if close_card(ctx, "The turn ended after #{calls} tool calls.")
104
+
105
+ nudge_not_sent(event, ctx)
106
+ end
107
+
108
+ # The card said "Nudged": if the turn ended before the nudge joined it
109
+ # (the model answered first, or the turn was stopped), the card says so.
110
+ def nudge_not_sent(event, ctx)
111
+ id, nudge = @mutex.synchronize { [@card_id, @nudge] }
112
+ return unless id && nudge
113
+ return if steered?(event[:messages], nudge[:text])
114
+
115
+ first = event[:status].to_s == "canceled" ? "The turn ended first" : "The answer came first"
116
+ ctx.card(id: id, title: "check-in", body: "#{first}; nudge not sent.")
117
+ end
118
+
119
+ # Whether this turn's messages (after its last prompt) have the steer.
120
+ def steered?(messages, text)
121
+ Array(messages).reverse_each do |message|
122
+ next unless message.is_a?(Hash)
123
+
124
+ role = (message[:role] || message["role"]).to_s
125
+ kind = (message[:kind] || message["kind"]).to_s
126
+ return true if kind == "steer" && (message[:content] || message["content"]).to_s.strip == text.strip
127
+ return false if role == "user" && kind != "steer"
128
+ end
129
+ false
130
+ end
131
+
132
+ # One card per turn: a later check-in updates it in place.
133
+ def show_card(ctx, count)
134
+ id, body = @mutex.synchronize do
135
+ @card_id ||= "check-in-#{SecureRandom.hex(4)}"
136
+ @card_open = true
137
+ [@card_id, "#{elapsed} in this turn. Last tools: #{@tools.join(", ")}."]
138
+ end
139
+ ctx.card(id: id, title: "#{count} tool calls, no answer yet", body: body,
140
+ actions: [{ label: "Nudge", command: "/checkin nudge" },
141
+ { label: "Keep going", command: "/checkin later" },
142
+ { label: "Stop", command: "/checkin stop" }])
143
+ end
144
+
145
+ # The open card, replaced by one without actions (stale buttons go).
146
+ # @return [Boolean] whether there was one
147
+ def close_card(ctx, body)
148
+ id = @mutex.synchronize do
149
+ next nil unless @card_open
150
+
151
+ @card_open = false
152
+ @card_id
153
+ end
154
+ return false unless id
155
+
156
+ ctx.card(id: id, title: "check-in", body: body)
157
+ true
158
+ end
159
+
160
+ # --- /checkin --------------------------------------------------------------
161
+
162
+ def command(args, ctx)
163
+ case args
164
+ when "" then status
165
+ when "on", "off"
166
+ @mutex.synchronize { @enabled = args == "on" }
167
+ "check-in is #{args} for this session"
168
+ when /\A\d+\z/
169
+ threshold(Integer(args))
170
+ when /\Amode\s+(\S+)\z/
171
+ mode = Regexp.last_match(1)
172
+ return "check-in: unknown mode #{mode} (ask, nudge or notify)" unless MODES.include?(mode)
173
+
174
+ @mutex.synchronize { @mode = mode }
175
+ "check-in mode is #{mode} for this session"
176
+ when "nudge" then nudge(ctx)
177
+ when "later" then later(ctx)
178
+ when "stop" then stop(ctx)
179
+ else USAGE
180
+ end
181
+ end
182
+
183
+ def status
184
+ @mutex.synchronize do
185
+ state = @enabled ? "on" : "off"
186
+ "check-in is #{state}: mode #{@mode}, after #{@after} tool calls, then every #{@every}; " \
187
+ "this turn: #{@count} tool calls"
188
+ end
189
+ end
190
+
191
+ # For this session: check in at +n+ calls and every +n+ after; a turn
192
+ # already past it is checked on +n+ calls from now.
193
+ def threshold(n)
194
+ return "check-in: the threshold must be at least 1" unless n.positive?
195
+
196
+ @mutex.synchronize do
197
+ @after = @every = n
198
+ @next_at = n > @count ? n : @count + n
199
+ end
200
+ "check-in after #{n} tool calls, then every #{n}, for this session"
201
+ end
202
+
203
+ def nudge(ctx)
204
+ count = calls
205
+ text = message(count)
206
+ return "check-in: no turn running" unless ctx.steer(text)
207
+
208
+ @mutex.synchronize { @nudge = { text: text, count: count } }
209
+ close_card(ctx, "Nudged the model at #{count} tool calls.")
210
+ nil
211
+ end
212
+
213
+ def later(ctx)
214
+ at = @mutex.synchronize { @next_at }
215
+ return "check-in: no check-in open" unless close_card(ctx, "Kept going; the next check-in is at #{at} tool calls.")
216
+
217
+ nil
218
+ end
219
+
220
+ def stop(ctx)
221
+ return "check-in: no turn running" unless ctx.stop_turn("stopped from check-in")
222
+
223
+ close_card(ctx, "Stopped the turn at #{calls} tool calls.")
224
+ nil
225
+ end
226
+
227
+ # --- helpers ---------------------------------------------------------------
228
+
229
+ def calls = @mutex.synchronize { @count }
230
+
231
+ def message(count) = @message.gsub("{calls}", count.to_s)
232
+
233
+ def elapsed
234
+ seconds = (monotonic - @started).round
235
+ seconds < 60 ? "#{seconds}s" : "#{seconds / 60}m #{seconds % 60}s"
236
+ end
237
+
238
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
239
+
240
+ def positive(value)
241
+ number = Integer(value.to_s, exception: false)
242
+ number&.positive? ? number : nil
243
+ end
244
+ end
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ # An after_turn hook that links the source refs the model's answer
4
+ # mentions — a JIRA ticket, a GitHub issue, an internal wiki page. In the
5
+ # web, each ref in the answer becomes a link (`[JIRA-123](https://…)`,
6
+ # through event[:present]: display only, the model's text stays as it was,
7
+ # and it survives a reload). Every UI also gets one line right after the
8
+ # turn, the terminals' only view of the links:
9
+ #
10
+ # sources: JIRA JIRA-123 → https://myjira.com/browse/JIRA-123, JIRA JIRA-10 → https://myjira.com/browse/JIRA-10
11
+ #
12
+ # Sources are configured patterns (config.yml, `bundles: source-links:`),
13
+ # so JIRA is just the first entry; any source can be added:
14
+ #
15
+ # bundles:
16
+ # source-links:
17
+ # sources:
18
+ # - name: JIRA
19
+ # prefix: JIRA # simple form: \bJIRA-(\d+)\b
20
+ # base_url: https://myjira.com/browse/
21
+ # - name: GitHub
22
+ # pattern: '\bGH-(\d+)\b' # full form: a regex
23
+ # url: 'https://github.com/org/repo/issues/{match}'
24
+ # case_insensitive: false # optional, default false
25
+ # max: 10 # optional: refs per line, default 10
26
+ # note: false # optional: no sources line (the web
27
+ # # links stay), default true
28
+ #
29
+ # The note is not stored in the conversation: it is an event, replayed by a
30
+ # UI only while the session's worker lives (a reload keeps it; a stopped
31
+ # worker loses it). The links are stored as the answer's display. A ref in
32
+ # code (a `span` or a fenced block) or in a markdown link is not linked in
33
+ # the answer. The hook is inert until sources are configured.
34
+ class SourceLinks
35
+ # The answer is scanned only up to this many characters: the primary
36
+ # ReDoS guard, bounding the input a user-supplied regex can chew on.
37
+ MAX_SCAN = 20_000
38
+ # Refs per line before the "… +N more" tail.
39
+ DEFAULT_MAX = 10
40
+ # Per-regex timeout (Ruby 3.2+): a catastrophic pattern is abandoned
41
+ # instead of hanging the turn. No global Regexp.timeout is touched. The
42
+ # timeout is per match attempt, not per scan; MAX_SCAN bounds the total.
43
+ REGEX_TIMEOUT = 0.5
44
+ # A bare URL (scheme-anchored, so it can't start inside a markdown label),
45
+ # and a markdown link (label in group 1, target in group 2).
46
+ URL_SPAN = %r{[a-z][a-z0-9+.\-]*://\S+}i
47
+ MARKDOWN_LINK = /\[([^\]]*)\]\(([^)]*)\)/
48
+ # A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
49
+ FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
50
+
51
+ def initialize(settings = {})
52
+ settings = {} unless settings.is_a?(Hash)
53
+ @sources = compile_sources(settings["sources"])
54
+ max = settings["max"].to_i
55
+ @max = max.positive? ? max : DEFAULT_MAX
56
+ @note = settings["note"] != false
57
+ end
58
+
59
+ def call(event)
60
+ return unless event.is_a?(Hash) && event[:type] == :after_turn
61
+ return unless event[:status].to_s == "completed"
62
+ return if @sources.empty?
63
+
64
+ text = last_model_text(event[:messages])
65
+ return if text.nil? || text.empty?
66
+
67
+ hits = occurrences(text[0, MAX_SCAN])
68
+ if @note
69
+ found = collect(hits)
70
+ event[:notify]&.call(line(found), level: :info) unless found.empty?
71
+ end
72
+ # The answer as shown: the model's text unless an earlier hook changed
73
+ # it (then its offsets differ, so it is scanned again).
74
+ event[:present]&.call { |shown| link(shown, shown == text ? hits : nil) }
75
+ end
76
+
77
+ private
78
+
79
+ # The content of the last `role: "model"` message, or nil. Handles both
80
+ # string- and symbol-keyed messages. A turn that ended without a visible
81
+ # answer stores a `kind: turn_note` system message instead, so the role
82
+ # check alone is enough.
83
+ def last_model_text(messages)
84
+ Array(messages).reverse_each do |message|
85
+ next unless message.is_a?(Hash)
86
+ next unless (message.key?(:role) ? message[:role] : message["role"]).to_s == "model"
87
+
88
+ content = message.key?(:content) ? message[:content] : message["content"]
89
+ return content.to_s
90
+ end
91
+ nil
92
+ end
93
+
94
+ # Every ref the note may name, as {start:, finish:, name:, ref:, url:,
95
+ # quiet:}, by offset (whatever order the sources are configured in). The
96
+ # skip rules below drop a ref that is already a link; +quiet+ marks one
97
+ # the note names but the answer does not link (in code, or in a markdown
98
+ # link's label: a link can't hold another). A source whose regex times out
99
+ # is skipped whole: its partial matches are discarded, the others still
100
+ # report.
101
+ def occurrences(text)
102
+ url_spans = bare_url_spans(text)
103
+ links = markdown_links(text)
104
+ code = code_spans(text)
105
+ labels = links.map { |link| link[:label] }
106
+ found = []
107
+ @sources.each do |source|
108
+ hits = []
109
+ begin
110
+ text.scan(source[:regex]) do
111
+ match = Regexp.last_match
112
+ start = match.begin(0)
113
+ finish = match.end(0)
114
+ next if inside_any?(url_spans, start, finish)
115
+ next if inside_markdown_link?(links, text, match)
116
+ next if url_adjacent?(text, match)
117
+
118
+ quiet = inside_any?(code, start, finish) || inside_any?(labels, start, finish)
119
+ hits << { start: start, finish: finish, name: source[:name], ref: match[0],
120
+ url: source[:url].call(match[0], match), quiet: quiet }
121
+ end
122
+ rescue Regexp::TimeoutError
123
+ Samagotchi::Log.warn(:hooks, "source_links_timeout",
124
+ echo: "[samagotchi:hooks] source-links: #{source[:name]} timed out; skipped")
125
+ next
126
+ end
127
+ found.concat(hits)
128
+ end
129
+ # By offset; on a tie (two sources on one ref) the first configured wins.
130
+ found.each_with_index.sort_by { |hit, index| [hit[:start], index] }.map(&:first)
131
+ end
132
+
133
+ # [name, ref, url] for the note: first-occurrence order, deduped by the
134
+ # ref text case-insensitively.
135
+ def collect(hits)
136
+ seen = {}
137
+ hits.filter_map do |hit|
138
+ key = hit[:ref].downcase
139
+ next if seen.key?(key)
140
+
141
+ seen[key] = true
142
+ [hit[:name], hit[:ref], hit[:url]]
143
+ end
144
+ end
145
+
146
+ # +text+ with each ref as a markdown link, every occurrence; the part
147
+ # beyond MAX_SCAN stays as it is. +hits+ are the scan of that text when
148
+ # the caller has it.
149
+ def link(text, hits = nil)
150
+ head = text[0, MAX_SCAN]
151
+ hits ||= occurrences(head)
152
+ out = +""
153
+ pos = 0
154
+ hits.each do |hit|
155
+ next if hit[:quiet] || hit[:start] < pos # two sources on one ref: the first wins
156
+
157
+ out << head[pos...hit[:start]] << "[#{hit[:ref].gsub(/[\[\]]/) { |c| "\\#{c}" }}](#{link_target(hit[:url])})"
158
+ pos = hit[:finish]
159
+ end
160
+ return text if pos.zero?
161
+
162
+ out << head[pos..] << text[MAX_SCAN..].to_s
163
+ end
164
+
165
+ # A URL as a markdown link target: whitespace and what would end it
166
+ # (parentheses, angle brackets) percent-encoded.
167
+ def link_target(url)
168
+ url.gsub(/[\s()<>]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
169
+ end
170
+
171
+ # The [start, end) ranges of the answer that are code: fenced blocks
172
+ # (``` or ~~~, to the closing fence or the end) and inline spans (a run
173
+ # of backticks to the next run of the same length). A line walk and a
174
+ # lookup per backtick run, no regex over the whole answer.
175
+ def code_spans(text)
176
+ fences = fenced_blocks(text)
177
+ runs = []
178
+ text.scan(/`+/) { runs << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
179
+ runs.reject! { |start, finish| inside_any?(fences, start, finish) }
180
+ by_length = Hash.new { |hash, key| hash[key] = [] }
181
+ runs.each_with_index { |(start, finish), index| by_length[finish - start] << index }
182
+ spans = []
183
+ index = 0
184
+ while index < runs.size
185
+ start, finish = runs[index]
186
+ same = by_length[finish - start]
187
+ closing = same.bsearch { |other| other > index }
188
+ if closing
189
+ spans << [start, runs[closing][1]]
190
+ index = closing + 1
191
+ else
192
+ index += 1
193
+ end
194
+ end
195
+ fences + spans
196
+ end
197
+
198
+ def fenced_blocks(text)
199
+ blocks = []
200
+ open = nil
201
+ offset = 0
202
+ text.each_line do |line|
203
+ marker = line[FENCE_OPEN, 1]
204
+ if open.nil? && marker
205
+ open = [offset, marker]
206
+ elsif open && marker && marker[0] == open[1][0] && marker.length >= open[1].length && line.strip == marker
207
+ blocks << [open[0], offset + line.length]
208
+ open = nil
209
+ end
210
+ offset += line.length
211
+ end
212
+ blocks << [open[0], text.length] if open
213
+ blocks
214
+ end
215
+
216
+ # The [start, end) character ranges of the answer that are a bare URL
217
+ # (scheme-anchored, `[a-z][a-z0-9+.\-]*://\S+`). A ref inside one is not
218
+ # linked again. The span stops at the first `)` that no `(` inside the URL
219
+ # balances — so `(https://x.com/a)` ends before the `)`, while
220
+ # `…/Foo_(bar)` keeps it.
221
+ def bare_url_spans(text)
222
+ spans = []
223
+ text.scan(URL_SPAN) do
224
+ match = Regexp.last_match
225
+ finish = trim_url_end(match[0], match.begin(0), match.end(0))
226
+ spans << [match.begin(0), finish] if finish > match.begin(0)
227
+ end
228
+ spans
229
+ end
230
+
231
+ # The end offset of a URL match after trimming its tail: the span stops at
232
+ # the first `)` that no `(` inside the URL balances.
233
+ def trim_url_end(url, start, finish)
234
+ depth = 0
235
+ url.each_char.with_index do |char, index|
236
+ if char == "("
237
+ depth += 1
238
+ elsif char == ")"
239
+ if depth.zero?
240
+ finish = start + index
241
+ break
242
+ end
243
+ depth -= 1
244
+ end
245
+ end
246
+ finish
247
+ end
248
+
249
+ # A markdown link's label and target ranges, with the target text. A ref in
250
+ # the target is a link destination (skip it); a ref in the label is skipped
251
+ # only when the target names that same ref — `[JIRA-123](https://x.com/JIRA-123)`
252
+ # is skipped, while `[fix for JIRA-123](https://github.com/o/r/pull/9)` still
253
+ # links the ticket.
254
+ def markdown_links(text)
255
+ links = []
256
+ text.scan(MARKDOWN_LINK) do
257
+ match = Regexp.last_match
258
+ links << { label: [match.begin(1), match.end(1)],
259
+ target: [match.begin(2), match.end(2)],
260
+ target_text: match[2].to_s }
261
+ end
262
+ links
263
+ end
264
+
265
+ def inside_any?(spans, start, finish)
266
+ spans.any? { |span_start, span_end| start >= span_start && finish <= span_end }
267
+ end
268
+
269
+ def inside_markdown_link?(links, text, match)
270
+ start = match.begin(0)
271
+ finish = match.end(0)
272
+ links.any? do |link|
273
+ in_target = start >= link[:target][0] && finish <= link[:target][1]
274
+ in_label = start >= link[:label][0] && finish <= link[:label][1]
275
+ in_target || (in_label && target_names_ref?(link[:target_text], text[start...finish]))
276
+ end
277
+ end
278
+
279
+ # True when the link target names the ref as a whole token: the lookarounds
280
+ # reject a ref character (letter, digit or `-`) immediately before or after
281
+ # the ref. So `[JIRA-1](…/JIRA-12)` is NOT skipped (the target names
282
+ # JIRA-12), while `[JIRA-123](…/JIRA-123)` is. Note this is stricter than
283
+ # the bare-text scan's `\b`, which treats `-` as a boundary: `…/JIRA-1-foo`
284
+ # would match there but not here.
285
+ def target_names_ref?(target, ref)
286
+ escaped = Regexp.escape(ref)
287
+ target.match?(/(?<![A-Za-z0-9\-])#{escaped}(?![A-Za-z0-9\-])/)
288
+ end
289
+
290
+ # A ref glued to URL punctuation is part of a link too, even when the span
291
+ # scan misses it: `/browse/JIRA-1`, `?key=JIRA-1`, `JIRA-1/foo`. The
292
+ # character right before is `/`, `=` or `?`, or the one right after is `/`.
293
+ # `:` and `#` are NOT here: `Ticket:JIRA-5` and `#JIRA-123` are ordinary
294
+ # plain-text ways to write a ticket, and a real URL is caught by the span
295
+ # scan anyway.
296
+ def url_adjacent?(text, match)
297
+ start = match.begin(0)
298
+ before = start.positive? ? text[start - 1] : nil
299
+ after = text[match.end(0)]
300
+ ["/", "=", "?"].include?(before) || after == "/"
301
+ end
302
+
303
+ def line(found)
304
+ shown = found.first(@max)
305
+ text = "sources: #{shown.map { |name, ref, url| "#{name} #{ref} → #{url}" }.join(', ')}"
306
+ extra = found.size - shown.size
307
+ text += ", … +#{extra} more" if extra.positive?
308
+ text
309
+ end
310
+
311
+ def compile_sources(raw)
312
+ Array(raw).filter_map { |entry| compile_source(entry) }
313
+ end
314
+
315
+ # One configured source as {name:, regex:, url:}, or nil (with a warn) for
316
+ # an entry that is not a mapping, names neither a prefix nor a pattern, or
317
+ # whose pattern does not compile. Never raises into the turn.
318
+ def compile_source(entry)
319
+ unless entry.is_a?(Hash)
320
+ warn_invalid("a source entry must be a mapping, not #{entry.class}")
321
+ return nil
322
+ end
323
+
324
+ entry = entry.transform_keys(&:to_s)
325
+ name = entry["name"].to_s.strip
326
+ prefix = entry["prefix"].to_s.strip
327
+ pattern = entry["pattern"].to_s
328
+ flags = entry["case_insensitive"] ? Regexp::IGNORECASE : 0
329
+
330
+ if !prefix.empty?
331
+ name = prefix if name.empty?
332
+ regex = Regexp.new("\\b#{Regexp.escape(prefix)}-(\\d+)\\b", flags, timeout: REGEX_TIMEOUT)
333
+ base = entry["base_url"].to_s
334
+ { name: name, regex: regex, url: ->(ref, _match) { "#{base}#{ref}" } }
335
+ elsif !pattern.empty?
336
+ name = "source" if name.empty?
337
+ regex = Regexp.new(pattern, flags, timeout: REGEX_TIMEOUT)
338
+ template = entry["url"].to_s
339
+ { name: name, regex: regex, url: ->(ref, match) { template.gsub("{match}", escape_url(match[1] || ref)) } }
340
+ else
341
+ warn_invalid("a source needs a prefix: or a pattern:")
342
+ nil
343
+ end
344
+ rescue RegexpError => e
345
+ warn_invalid("its pattern does not compile: #{e.message}")
346
+ nil
347
+ end
348
+
349
+ def warn_invalid(reason)
350
+ Samagotchi::Log.warn(:hooks, "source_links_invalid_source",
351
+ echo: "[samagotchi:hooks] source-links: skipping a source: #{reason}")
352
+ end
353
+
354
+ # A capture group is free-form, so escape what goes into the URL path.
355
+ def escape_url(value)
356
+ value.to_s.gsub(%r{[^A-Za-z0-9\-._~]}) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
357
+ end
358
+ end
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: source-links
3
+ version: 0.2.0
4
+ scope: system
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
+ trust_level: reviewed
7
+ files:
8
+ source_links.md: sha256:0fb30311603c60f062740917ad0670904e022e8975b1d0eafe3df9e2904f2fe0
9
+ hooks:
10
+ source_links.rb:
11
+ sha256: sha256:5f2b8d5248efc7e0a98185d9e53a47e6a3edfe7c43ee5bf0ebe44d07f18d61aa
12
+ event: after_turn
13
+ on_error: log
14
+ priority: 90
@@ -0,0 +1,5 @@
1
+ # Source links
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).
4
+
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). 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.