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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -1
- data/README.md +40 -4
- data/bin/chi +86 -19
- data/docs/cli.md +108 -5
- data/docs/configuration.md +229 -45
- data/docs/desktop.md +6 -0
- data/docs/hooks.md +126 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +18 -8
- data/docs/sessions.md +30 -4
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +72 -13
- data/lib/samagotchi/config.rb +196 -36
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
- data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +233 -36
- data/lib/samagotchi/guardrails/approval.rb +9 -0
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/kernel_loop.rb +47 -15
- data/lib/samagotchi/llm/chat_loop.rb +59 -20
- data/lib/samagotchi/llm/errors.rb +21 -3
- data/lib/samagotchi/llm/http.rb +42 -13
- data/lib/samagotchi/llm/openai_chat.rb +12 -4
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +1 -0
- data/lib/samagotchi/send_command.rb +153 -7
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +11 -2
- data/lib/samagotchi/session_manager.rb +114 -9
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +77 -4
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -6
- data/lib/samagotchi/web/app.rb +88 -4
- data/lib/samagotchi/web/public/activity.js +10 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +437 -88
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +10 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +21 -6
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +38 -2
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +46 -0
- data/lib/samagotchi/web/public/turn_view.js +47 -7
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +11 -0
- 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.
|