samagotchi 0.3.0 → 0.5.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 +162 -1
- data/README.md +29 -2
- data/bin/chi +60 -69
- data/docs/cli.md +211 -77
- data/docs/configuration.md +118 -21
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +89 -7
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +15 -12
- data/docs/sessions.md +20 -18
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bridge.rb +20 -12
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +6 -6
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +25 -26
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +97 -113
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +236 -443
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +59 -123
- data/lib/samagotchi/live_versions.rb +65 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +38 -7
- data/lib/samagotchi/llm/http.rb +19 -22
- data/lib/samagotchi/llm/openai_chat.rb +22 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +27 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +37 -5
- data/lib/samagotchi/send_command.rb +190 -17
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +19 -53
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +142 -923
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +126 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +309 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +173 -38
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +19 -10
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +135 -78
- data/lib/samagotchi/web/public/chat_view.js +8 -1
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +185 -18
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +38 -10
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +76 -20
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- data/lib/samagotchi/worker.rb +5 -4
- metadata +38 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# The skills bundle (docs/plugins.md, The skills bundle): a skill is a memory
|
|
2
|
+
# named skill_<name> that holds the steps of a task done with the user
|
|
3
|
+
# (docs/memory.md, Skills). The system bundle's identity already tells the
|
|
4
|
+
# model to save, follow and update skills; this bundle adds `/skill` for the
|
|
5
|
+
# user and keeps an eye on the model's rewrites.
|
|
6
|
+
#
|
|
7
|
+
# History: before memory_write, write or edit changes a skill's file, the
|
|
8
|
+
# file as it was is kept under $XDG_STATE_HOME/samagotchi/plugins/skills/
|
|
9
|
+
# history/<scope>/<name>/<UTC time>.md (state, not the memories dir, so
|
|
10
|
+
# bundle build and dotfile syncs never see it), history_keep per skill. The
|
|
11
|
+
# after_tool_call of that call (calls run one at a time; that event carries
|
|
12
|
+
# no call, so the before side stashes it) compares the file on disk and
|
|
13
|
+
# shows a line: "skill release updated (+2 −1): …" or "skill release saved
|
|
14
|
+
# (project, 14 lines)".
|
|
15
|
+
#
|
|
16
|
+
# The nudge (nudge: true), for models that skip a failing step instead of
|
|
17
|
+
# fixing the skill: in a turn that read a skill (memory_read of a skill_*
|
|
18
|
+
# name, or a read of its file), the first failing tool call after it (an
|
|
19
|
+
# execute with "exit: N", N ≠ 0, or with no exit line an Error: line near
|
|
20
|
+
# the top; any tool's "[tool] Error: …") steers the model once to find out
|
|
21
|
+
# why and fix the skill. At the turn's end, a failed step with no rewrite of
|
|
22
|
+
# the skill gets a notice line.
|
|
23
|
+
#
|
|
24
|
+
# Settings (config.yml, bundles: skills:):
|
|
25
|
+
# history_keep: 20 older versions kept per skill
|
|
26
|
+
# nudge: true steer the model once when a skill's step fails
|
|
27
|
+
require "date"
|
|
28
|
+
require "fileutils"
|
|
29
|
+
|
|
30
|
+
class Plugin
|
|
31
|
+
WRITE_TOOLS = %w[memory_write write edit].freeze
|
|
32
|
+
NOTICE_WIDTH = 60 # the changed line in an update's notice
|
|
33
|
+
NUDGE = "A step of skill %s failed. Find out why before skipping it; if the skill is out of date, fix it now: " \
|
|
34
|
+
"memory_write the whole skill, its title and every section as they were, that step fixed, a Changelog " \
|
|
35
|
+
"line added."
|
|
36
|
+
NOT_FAILURES = %w[memory_read memory_write].freeze
|
|
37
|
+
USAGE = "usage: /skill save [name] [--system] | list | show <name> | diff <name> [N]"
|
|
38
|
+
|
|
39
|
+
def initialize(settings = {})
|
|
40
|
+
settings = {} unless settings.is_a?(Hash)
|
|
41
|
+
@history_keep = positive(settings["history_keep"]) || 20
|
|
42
|
+
@nudge = settings.key?("nudge") ? settings["nudge"] != false : true
|
|
43
|
+
@stash = nil
|
|
44
|
+
reset_turn
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def register(chi)
|
|
48
|
+
chi.on(:before_turn) { |_event, _ctx| reset_turn }
|
|
49
|
+
chi.on(:after_turn) { |_event, ctx| after_turn(ctx) }
|
|
50
|
+
chi.on(:before_tool_call) { |event, ctx| before_tool_call(event, ctx) }
|
|
51
|
+
chi.on(:after_tool_call) { |event, ctx| after_tool_call(event, ctx) }
|
|
52
|
+
chi.command "/skill", "skills (steps of a task we did): save [name] [--system], list, show <name>, diff <name> [N]",
|
|
53
|
+
anytime: true do |args, ctx|
|
|
54
|
+
command(args.to_s.strip, ctx)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# --- a skill's file changes ----------------------------------------------
|
|
61
|
+
|
|
62
|
+
def before_tool_call(event, ctx)
|
|
63
|
+
@stash = nil
|
|
64
|
+
tool = event.dig(:call, :name).to_s
|
|
65
|
+
note_read(tool, event)
|
|
66
|
+
return unless WRITE_TOOLS.include?(tool)
|
|
67
|
+
|
|
68
|
+
path = Array(event.dig(:targets, :paths)).find { |target| skill_at(target) }
|
|
69
|
+
return unless path
|
|
70
|
+
|
|
71
|
+
scope, name = skill_at(path)
|
|
72
|
+
old = File.file?(path) ? File.read(path) : nil
|
|
73
|
+
keep_version(ctx, scope, name, old) if old
|
|
74
|
+
@stash = { tool: tool, path: File.expand_path(path), scope: scope, name: name, old: old }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Success is the file changed on disk, whatever the output says. A call
|
|
78
|
+
# that didn't match the stash (a cancelled turn fires no before) drops it.
|
|
79
|
+
def after_tool_call(event, ctx)
|
|
80
|
+
stash = @stash
|
|
81
|
+
@stash = nil
|
|
82
|
+
step_failed(event) if @nudge && !@read.empty?
|
|
83
|
+
return unless stash && stash[:tool] == event[:tool].to_s
|
|
84
|
+
|
|
85
|
+
now = File.file?(stash[:path]) ? File.read(stash[:path]) : nil
|
|
86
|
+
return if now.nil? || now == stash[:old]
|
|
87
|
+
|
|
88
|
+
@written << stash[:name]
|
|
89
|
+
ctx.notify(change_notice(stash, now))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def change_notice(stash, now)
|
|
93
|
+
name = stash[:name]
|
|
94
|
+
return "skill #{name} saved (#{stash[:scope]}, #{now.lines.size} lines)" unless stash[:old]
|
|
95
|
+
|
|
96
|
+
ops = line_diff(stash[:old].lines(chomp: true), now.lines(chomp: true))
|
|
97
|
+
added = ops.count { |op, _| op == :add }
|
|
98
|
+
removed = ops.count { |op, _| op == :del }
|
|
99
|
+
first = ops.find { |op, _| op == :add } || ops.find { |op, _| op == :del }
|
|
100
|
+
line = first ? cut(first.last.strip) : ""
|
|
101
|
+
"skill #{name} updated (+#{added} −#{removed})#{line.empty? ? "" : ": #{line}"} · /skill diff #{name}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- the nudge ------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def reset_turn
|
|
107
|
+
@read = [] # skills read this turn, in order
|
|
108
|
+
@written = [] # skills changed this turn
|
|
109
|
+
@failed = false # a step failed after a skill was read
|
|
110
|
+
@nudged = false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def note_read(tool, event)
|
|
114
|
+
names = case tool
|
|
115
|
+
when "memory_read"
|
|
116
|
+
event.dig(:call, :content).to_s.split(",").map(&:strip).select { |entry| entry.start_with?("skill_") }
|
|
117
|
+
.filter_map { |entry| skill_name(entry) }
|
|
118
|
+
when "read"
|
|
119
|
+
Array(event.dig(:targets, :paths)).filter_map { |path| skill_at(path)&.last }
|
|
120
|
+
else []
|
|
121
|
+
end
|
|
122
|
+
@read |= names
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def step_failed(event)
|
|
126
|
+
tool = event[:tool].to_s
|
|
127
|
+
return if NOT_FAILURES.include?(tool) || !failure?(tool, event[:output].to_s)
|
|
128
|
+
|
|
129
|
+
@failed = true
|
|
130
|
+
return if @nudged
|
|
131
|
+
|
|
132
|
+
@nudged = !!event[:steer]&.call(format(NUDGE, @read.join(", ")))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A tool error ("[x] Error: …" raised, "[x]\nError: …" returned), or an
|
|
136
|
+
# execute that exited non-zero; its "exit: N" line may be cut off by the
|
|
137
|
+
# hook's output cap, and then an Error: line near the top counts.
|
|
138
|
+
def failure?(tool, output)
|
|
139
|
+
return true if output.match?(/\A\[#{Regexp.escape(tool)}\](?: |\n)Error:/)
|
|
140
|
+
return false unless tool == "execute"
|
|
141
|
+
|
|
142
|
+
exit_line = output.match(/^exit: (\d+)(?: \(no output\))?\s*\z/)
|
|
143
|
+
return exit_line[1] != "0" if exit_line
|
|
144
|
+
|
|
145
|
+
output.lines.first(20).any? { |line| line.match?(/\A\s*Error:/i) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def after_turn(ctx)
|
|
149
|
+
return unless @nudge && @failed
|
|
150
|
+
|
|
151
|
+
missed = @read - @written
|
|
152
|
+
return unless missed.size == @read.size
|
|
153
|
+
|
|
154
|
+
ctx.notify("skill #{missed.join(", ")} was followed, a step failed, the skill wasn't updated")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# --- history ---------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
# Keep +content+ as the newest version, unless it is the newest already (a
|
|
160
|
+
# denied or failed write leaves the file as it was).
|
|
161
|
+
def keep_version(ctx, scope, name, content)
|
|
162
|
+
dir = history_dir(ctx, scope, name)
|
|
163
|
+
FileUtils.mkdir_p(dir)
|
|
164
|
+
newest = versions(dir).first
|
|
165
|
+
return if newest && File.read(newest) == content
|
|
166
|
+
|
|
167
|
+
stamp = Time.now.utc.strftime("%Y%m%dT%H%M%S.%6NZ")
|
|
168
|
+
path = File.join(dir, "#{stamp}.md")
|
|
169
|
+
File.write("#{path}.tmp", content)
|
|
170
|
+
File.rename("#{path}.tmp", path)
|
|
171
|
+
versions(dir).drop(@history_keep).each { |old| File.delete(old) }
|
|
172
|
+
rescue SystemCallError => e
|
|
173
|
+
ctx.log.warn(:history_failed, skill: name, error: e.class.name, msg: e.message)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# history/system/<name>, history/project-<project folder>/<name>
|
|
177
|
+
def history_dir(ctx, scope, name)
|
|
178
|
+
key = scope == "system" ? "system" : "project-#{File.basename(memory_dirs["project"])}"
|
|
179
|
+
File.join(ctx.data_dir, "history", key, name)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The kept versions, newest first.
|
|
183
|
+
def versions(dir) = Dir.glob(File.join(dir, "*.md")).sort.reverse
|
|
184
|
+
|
|
185
|
+
# --- /skill ----------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def command(args, ctx)
|
|
188
|
+
verb, rest = args.split(/\s+/, 2)
|
|
189
|
+
rest = rest.to_s.strip
|
|
190
|
+
case verb
|
|
191
|
+
when "save" then save(rest, ctx)
|
|
192
|
+
when "list" then rest.empty? ? list : USAGE
|
|
193
|
+
when "show" then show(rest)
|
|
194
|
+
when "diff" then diff(rest, ctx)
|
|
195
|
+
else USAGE
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# /skill save [name] [--system]: asks the model, in this session, to save
|
|
200
|
+
# what was just done (the model has seen the commands; a side answer
|
|
201
|
+
# wouldn't). The request runs as a turn; sent while a turn runs it joins
|
|
202
|
+
# that turn at its next step, and the request says to finish the task
|
|
203
|
+
# first. A REPL session (--no-shared) takes no messages: the request is
|
|
204
|
+
# shown for the user to send.
|
|
205
|
+
def save(rest, ctx)
|
|
206
|
+
words = rest.split
|
|
207
|
+
scope = words.delete("--system") ? "system" : "project"
|
|
208
|
+
return USAGE if words.size > 1 || words.any? { |word| word.start_with?("-") }
|
|
209
|
+
|
|
210
|
+
name = words.first && skill_name(words.first)
|
|
211
|
+
return "/skill save: a name is letters, digits, _ and - (got #{words.first})" if words.first && !name
|
|
212
|
+
|
|
213
|
+
request = save_request(name, scope, exists: name && skill_path(name))
|
|
214
|
+
begin
|
|
215
|
+
ctx.sessions.send(ctx.session_id, request)
|
|
216
|
+
rescue Samagotchi::Plugin::Sessions::Error => e
|
|
217
|
+
return "/skill save: #{e.message}. Send this yourself:\n\n#{request}"
|
|
218
|
+
end
|
|
219
|
+
what = name ? "skill #{name}" : "a skill"
|
|
220
|
+
"asked chi to save #{what} (#{scope} scope); a running turn gets it at its next step"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def save_request(name, scope, exists:)
|
|
224
|
+
target = name ? "skill `skill_#{name}`" : "a skill named `skill_<name>` (a short name for the task)"
|
|
225
|
+
update = exists ? " It exists already: read it, keep what still holds, fix what changed, add a Changelog line." : ""
|
|
226
|
+
<<~TEXT.strip
|
|
227
|
+
Save what we just did as #{target} with memory_write, scope #{scope}.#{update} If you are still in the middle of the task, finish it first.
|
|
228
|
+
Content: plain Markdown, no frontmatter:
|
|
229
|
+
|
|
230
|
+
# Skill: #{name || "<name>"}
|
|
231
|
+
|
|
232
|
+
## Steps
|
|
233
|
+
1. …
|
|
234
|
+
## Gotchas
|
|
235
|
+
- …
|
|
236
|
+
## Changelog
|
|
237
|
+
- #{Date.today.iso8601} created
|
|
238
|
+
|
|
239
|
+
Steps are the commands and checks that worked, in order, with the real file and command names; a step that must pass says "stop if it fails". Dead ends and surprises go under Gotchas. Give memory_write a description: one line that starts with what this task is and names its main steps, in this task's own words, so the skill is found next time. Then show the skill briefly.
|
|
240
|
+
TEXT
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# /skill list: the skill_* memories of both scopes, with their index line's
|
|
244
|
+
# date and description.
|
|
245
|
+
def list
|
|
246
|
+
skills = memory_dirs.flat_map do |scope, dir|
|
|
247
|
+
index = index_lines(dir)
|
|
248
|
+
Dir.glob(File.join(dir, "skill_*.md")).filter_map do |path|
|
|
249
|
+
name = File.basename(path, ".md").delete_prefix("skill_")
|
|
250
|
+
next unless skill_name(name) == name
|
|
251
|
+
|
|
252
|
+
date, description = index.fetch("skill_#{name}", [nil, nil])
|
|
253
|
+
line = +"#{name} · #{scope}"
|
|
254
|
+
line << " · #{date}" if date
|
|
255
|
+
line << " — #{description}" if description
|
|
256
|
+
line
|
|
257
|
+
end.sort
|
|
258
|
+
end
|
|
259
|
+
return "no skills yet: after a task we did together, /skill save [name]" if skills.empty?
|
|
260
|
+
|
|
261
|
+
"skills:\n#{skills.map { |line| " #{line}" }.join("\n")}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# /skill show <name>: the skill as saved (project first).
|
|
265
|
+
def show(rest)
|
|
266
|
+
name = skill_name(rest)
|
|
267
|
+
return USAGE unless name
|
|
268
|
+
|
|
269
|
+
path = skill_path(name) or return "no skill #{name} (/skill list shows them)"
|
|
270
|
+
"skill #{name} · #{scope_of(path)}\n\n#{File.read(path).strip}"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# /skill diff <name> [N]: the skill now against its N-th newest kept
|
|
274
|
+
# version (1, the one before the last change, by default), unified.
|
|
275
|
+
def diff(rest, ctx)
|
|
276
|
+
word, back = rest.split
|
|
277
|
+
name = skill_name(word)
|
|
278
|
+
n = back ? Integer(back, exception: false) : 1
|
|
279
|
+
return USAGE unless name && n&.positive? && rest.split.size <= 2
|
|
280
|
+
|
|
281
|
+
path = skill_path(name) or return "no skill #{name} (/skill list shows them)"
|
|
282
|
+
kept = versions(history_dir(ctx, scope_of(path), name))
|
|
283
|
+
return "skill #{name} has no older version yet" if kept.empty?
|
|
284
|
+
return "skill #{name} has #{kept.size} older version#{"s" if kept.size > 1} (/skill diff #{name} 1..#{kept.size})" if n > kept.size
|
|
285
|
+
|
|
286
|
+
old = kept[n - 1]
|
|
287
|
+
body = unified(File.read(old).lines(chomp: true), File.read(path).lines(chomp: true))
|
|
288
|
+
return "skill #{name} is the same as version #{n}" if body.empty?
|
|
289
|
+
|
|
290
|
+
"--- skill_#{name} (#{version_time(old)})\n+++ skill_#{name} (now)\n#{body}"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
CONTEXT = 3
|
|
294
|
+
|
|
295
|
+
# Hunks with CONTEXT lines around each change, as diff -u prints them.
|
|
296
|
+
def unified(a, b)
|
|
297
|
+
ops = line_diff(a, b)
|
|
298
|
+
changed = ops.each_index.reject { |k| ops[k].first == :eq }
|
|
299
|
+
return "" if changed.empty?
|
|
300
|
+
|
|
301
|
+
# Group changes whose context would touch into one hunk.
|
|
302
|
+
groups = changed.slice_when { |x, y| y - x > 2 * CONTEXT + 1 }.to_a
|
|
303
|
+
old_at = new_at = 0
|
|
304
|
+
positions = ops.map do |op, _|
|
|
305
|
+
at = [old_at, new_at]
|
|
306
|
+
old_at += 1 unless op == :add
|
|
307
|
+
new_at += 1 unless op == :del
|
|
308
|
+
at
|
|
309
|
+
end
|
|
310
|
+
groups.map do |group|
|
|
311
|
+
from = [group.first - CONTEXT, 0].max
|
|
312
|
+
to = [group.last + CONTEXT, ops.size - 1].min
|
|
313
|
+
slice = ops[from..to]
|
|
314
|
+
old_count = slice.count { |op, _| op != :add }
|
|
315
|
+
new_count = slice.count { |op, _| op != :del }
|
|
316
|
+
old_start, new_start = positions[from]
|
|
317
|
+
header = "@@ -#{range(old_start, old_count)} +#{range(new_start, new_count)} @@"
|
|
318
|
+
lines = slice.map { |op, line| "#{{ eq: " ", del: "-", add: "+" }[op]}#{line}" }
|
|
319
|
+
[header, *lines].join("\n")
|
|
320
|
+
end.join("\n")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# diff -u's "start,count" (1-based; an empty side names the line before).
|
|
324
|
+
def range(start, count)
|
|
325
|
+
first = count.zero? ? start : start + 1
|
|
326
|
+
count == 1 ? first.to_s : "#{first},#{count}"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# "2026-09-30 10:22 UTC" from a version's file name.
|
|
330
|
+
def version_time(path)
|
|
331
|
+
stamp = File.basename(path, ".md")
|
|
332
|
+
match = stamp.match(/\A(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)/)
|
|
333
|
+
match ? "#{match[1]}-#{match[2]}-#{match[3]} #{match[4]}:#{match[5]} UTC" : stamp
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# --- skills on disk --------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
# "release", "skill_release", "Release-Notes" → "release", "release-notes";
|
|
339
|
+
# nil for anything else.
|
|
340
|
+
def skill_name(word)
|
|
341
|
+
name = word.to_s.downcase.delete_suffix(".md").delete_prefix("skill_")
|
|
342
|
+
name.match?(/\A[a-z0-9][a-z0-9_-]*\z/) ? name : nil
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# The memories dir of each scope, as memory_read/memory_write resolve it.
|
|
346
|
+
def memory_dirs
|
|
347
|
+
%w[project system].to_h { |scope| [scope, File.expand_path(Samagotchi::Tools::MemoryRead.memories_dir(scope))] }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# The skill's file, project first (as memory_read looks), or nil.
|
|
351
|
+
def skill_path(name, scope: nil)
|
|
352
|
+
dirs = scope ? memory_dirs.slice(scope) : memory_dirs
|
|
353
|
+
dirs.each_value do |dir|
|
|
354
|
+
path = File.join(dir, "skill_#{name}.md")
|
|
355
|
+
return path if File.file?(path)
|
|
356
|
+
end
|
|
357
|
+
nil
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# [scope, name] when +path+ is a skill_<name>.md right in a memories dir.
|
|
361
|
+
def skill_at(path)
|
|
362
|
+
path = File.expand_path(path.to_s)
|
|
363
|
+
scope = memory_dirs.key(File.dirname(path))
|
|
364
|
+
name = File.basename(path, ".md").delete_prefix("skill_")
|
|
365
|
+
return nil unless scope && File.basename(path) == "skill_#{name}.md" && skill_name(name) == name
|
|
366
|
+
|
|
367
|
+
[scope, name]
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def scope_of(path) = memory_dirs.key(File.dirname(path)) || "?"
|
|
371
|
+
|
|
372
|
+
# The managed index.md lines, {name => [date, description]}:
|
|
373
|
+
# "- **name** · scope · date · bytes — description".
|
|
374
|
+
def index_lines(dir)
|
|
375
|
+
path = File.join(dir, "index.md")
|
|
376
|
+
return {} unless File.file?(path)
|
|
377
|
+
|
|
378
|
+
File.readlines(path, chomp: true).each_with_object({}) do |line, lines|
|
|
379
|
+
match = line.match(/\A- \*\*(?<name>[^*]+)\*\* · [^·]+ · (?<date>[^·]+?) · [^—]+?(?: — (?<description>.+))?\z/)
|
|
380
|
+
lines[match[:name]] = [match[:date].strip, match[:description]&.strip] if match
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# --- helpers ---------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
# The lines of +a+ and +b+ as [:eq|:del|:add, line], in order (a longest
|
|
387
|
+
# common subsequence; skills are short).
|
|
388
|
+
def line_diff(a, b)
|
|
389
|
+
lcs = Array.new(a.size + 1) { Array.new(b.size + 1, 0) }
|
|
390
|
+
(a.size - 1).downto(0) do |i|
|
|
391
|
+
(b.size - 1).downto(0) do |j|
|
|
392
|
+
lcs[i][j] = a[i] == b[j] ? lcs[i + 1][j + 1] + 1 : [lcs[i + 1][j], lcs[i][j + 1]].max
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
ops = []
|
|
396
|
+
i = j = 0
|
|
397
|
+
while i < a.size && j < b.size
|
|
398
|
+
if a[i] == b[j]
|
|
399
|
+
ops << [:eq, a[i]]
|
|
400
|
+
i += 1
|
|
401
|
+
j += 1
|
|
402
|
+
elsif lcs[i + 1][j] >= lcs[i][j + 1]
|
|
403
|
+
ops << [:del, a[i]]
|
|
404
|
+
i += 1
|
|
405
|
+
else
|
|
406
|
+
ops << [:add, b[j]]
|
|
407
|
+
j += 1
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
ops.concat(a[i..].map { |line| [:del, line] }, b[j..].map { |line| [:add, line] })
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def cut(text) = text.length > NOTICE_WIDTH ? "#{text[0, NOTICE_WIDTH - 1]}…" : text
|
|
414
|
+
|
|
415
|
+
def positive(value)
|
|
416
|
+
number = Integer(value.to_s, exception: false)
|
|
417
|
+
number&.positive? ? number : nil
|
|
418
|
+
end
|
|
419
|
+
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Loaded through module_eval: it requires what it uses.
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
3
6
|
# An after_turn hook that links the source refs the model's answer
|
|
4
7
|
# mentions — a JIRA ticket, a GitHub issue, an internal wiki page. In the
|
|
5
8
|
# web, each ref in the answer becomes a link (`[JIRA-123](https://…)`,
|
|
@@ -22,10 +25,24 @@
|
|
|
22
25
|
# pattern: '\bGH-(\d+)\b' # full form: a regex
|
|
23
26
|
# url: 'https://github.com/org/repo/issues/{match}'
|
|
24
27
|
# case_insensitive: false # optional, default false
|
|
28
|
+
# - name: Issues # `#12` → this project's repo,
|
|
29
|
+
# # `owner/repo#12` → that repo
|
|
30
|
+
# pattern: '(?<![\w/&])(?:(?<repo>[A-Za-z0-9][\w-]*/[\w.-]*\w))?#(?<num>\d+)\b'
|
|
31
|
+
# url: 'https://github.com/{repo}/issues/{num}'
|
|
32
|
+
# remote: origin # optional: the git remote {repo}/{host}
|
|
33
|
+
# # come from, default origin
|
|
34
|
+
# remote_host: github.com # optional: a host or a list; else a
|
|
35
|
+
# # remote on another host links nothing
|
|
25
36
|
# max: 10 # optional: refs per line, default 10
|
|
26
37
|
# note: false # optional: no sources line (the web
|
|
27
38
|
# # links stay), default true
|
|
28
39
|
#
|
|
40
|
+
# A `url:` template takes {match} (group 1, else the whole ref), {1}…{9}
|
|
41
|
+
# (numbered groups), {name} (named groups), and {repo}/{host}: the named
|
|
42
|
+
# group when it took part, else the project's (Dir.pwd's) git remote, asked
|
|
43
|
+
# of git once per worker. A ref with a placeholder that can't be filled is
|
|
44
|
+
# not linked; a `{word}` that is none of these stays text (warned at load).
|
|
45
|
+
#
|
|
29
46
|
# The note is not stored in the conversation: it is an event, replayed by a
|
|
30
47
|
# UI only while the session's worker lives (a reload keeps it; a stopped
|
|
31
48
|
# worker loses it). The links are stored as the answer's display. A ref in
|
|
@@ -47,6 +64,32 @@ class SourceLinks
|
|
|
47
64
|
MARKDOWN_LINK = /\[([^\]]*)\]\(([^)]*)\)/
|
|
48
65
|
# A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
|
|
49
66
|
FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
|
|
67
|
+
# A `{word}` or `{N}` placeholder in a `url:` template.
|
|
68
|
+
PLACEHOLDER = /\{(\w+)\}/
|
|
69
|
+
# Placeholders every pattern has: {match} (group 1, else the whole ref),
|
|
70
|
+
# and {repo}/{host} (a named group, else the project's git remote).
|
|
71
|
+
BUILTIN_PLACEHOLDERS = %w[match repo host].freeze
|
|
72
|
+
# `git remote get-url` output: a URL with a scheme, `[user[:pw]@]host[:port]/path`.
|
|
73
|
+
REMOTE_URL = %r{\A(?:https?|ssh|git)://(?:[^/]*@)?(\[[^\]]*\]|[^/:@]+)(?::[^/]*)?(/.*)?\z}i
|
|
74
|
+
# scp-like `[user@]host:path`: a colon before any slash; a leading `/` or
|
|
75
|
+
# `.` is a local path.
|
|
76
|
+
REMOTE_SCP = %r{\A(?:[^@/:]+@)?([^/:.@][^/:@]*):(.*)\z}
|
|
77
|
+
|
|
78
|
+
# The {host:, repo:} a git remote URL names, or nil (a local path,
|
|
79
|
+
# `file://`, an empty path, or a path with an empty or dot segment).
|
|
80
|
+
def self.parse_remote_url(url)
|
|
81
|
+
url = url.to_s.strip
|
|
82
|
+
# Any other scheme (file://, a transport helper's) is not a web host.
|
|
83
|
+
match = url.include?("://") ? url.match(REMOTE_URL) : url.match(REMOTE_SCP)
|
|
84
|
+
return nil unless match
|
|
85
|
+
|
|
86
|
+
host = match[1]
|
|
87
|
+
repo = match[2].to_s.sub(%r{\A/+}, "").sub(%r{/+\z}, "").sub(/\.git\z/, "").sub(%r{/+\z}, "")
|
|
88
|
+
segments = repo.split("/", -1)
|
|
89
|
+
return nil if host.empty? || segments.empty? || segments.any? { |segment| ["", ".", ".."].include?(segment) }
|
|
90
|
+
|
|
91
|
+
{ host: host, repo: repo }
|
|
92
|
+
end
|
|
50
93
|
|
|
51
94
|
def initialize(settings = {})
|
|
52
95
|
settings = {} unless settings.is_a?(Hash)
|
|
@@ -54,6 +97,9 @@ class SourceLinks
|
|
|
54
97
|
max = settings["max"].to_i
|
|
55
98
|
@max = max.positive? ? max : DEFAULT_MAX
|
|
56
99
|
@note = settings["note"] != false
|
|
100
|
+
# [Dir.pwd, remote name] => {host:, repo:} or nil, for the worker's life.
|
|
101
|
+
@remotes = {}
|
|
102
|
+
@host_mismatch_logged = {}
|
|
57
103
|
end
|
|
58
104
|
|
|
59
105
|
def call(event)
|
|
@@ -115,9 +161,12 @@ class SourceLinks
|
|
|
115
161
|
next if inside_markdown_link?(links, text, match)
|
|
116
162
|
next if url_adjacent?(text, match)
|
|
117
163
|
|
|
164
|
+
# An unresolved placeholder: no link, from this source.
|
|
165
|
+
url = source[:url].call(match[0], match)
|
|
166
|
+
next if url.nil?
|
|
167
|
+
|
|
118
168
|
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 }
|
|
169
|
+
hits << { start: start, finish: finish, name: source[:name], ref: match[0], url: url, quiet: quiet }
|
|
121
170
|
end
|
|
122
171
|
rescue Regexp::TimeoutError
|
|
123
172
|
Samagotchi::Log.warn(:hooks, "source_links_timeout",
|
|
@@ -131,11 +180,12 @@ class SourceLinks
|
|
|
131
180
|
end
|
|
132
181
|
|
|
133
182
|
# [name, ref, url] for the note: first-occurrence order, deduped by the
|
|
134
|
-
#
|
|
183
|
+
# URL case-insensitively (`#12` and `o/r#12` may name one issue; with
|
|
184
|
+
# case_insensitive, `JIRA-1` and `jira-1` are one ticket).
|
|
135
185
|
def collect(hits)
|
|
136
186
|
seen = {}
|
|
137
187
|
hits.filter_map do |hit|
|
|
138
|
-
key = hit[:
|
|
188
|
+
key = hit[:url].downcase
|
|
139
189
|
next if seen.key?(key)
|
|
140
190
|
|
|
141
191
|
seen[key] = true
|
|
@@ -336,7 +386,12 @@ class SourceLinks
|
|
|
336
386
|
name = "source" if name.empty?
|
|
337
387
|
regex = Regexp.new(pattern, flags, timeout: REGEX_TIMEOUT)
|
|
338
388
|
template = entry["url"].to_s
|
|
339
|
-
|
|
389
|
+
known = known_placeholders(name, template, regex)
|
|
390
|
+
remote = entry["remote"].to_s.strip
|
|
391
|
+
remote = "origin" if remote.empty?
|
|
392
|
+
hosts = Array(entry["remote_host"]).map { |host| host.to_s.strip.downcase }.reject(&:empty?)
|
|
393
|
+
source = { name: name, remote: remote, remote_hosts: hosts }
|
|
394
|
+
source.merge(regex: regex, url: ->(ref, match) { render_url(template, known, match, ref, source) })
|
|
340
395
|
else
|
|
341
396
|
warn_invalid("a source needs a prefix: or a pattern:")
|
|
342
397
|
nil
|
|
@@ -346,6 +401,124 @@ class SourceLinks
|
|
|
346
401
|
nil
|
|
347
402
|
end
|
|
348
403
|
|
|
404
|
+
# The placeholders of +template+ this pattern can fill: {match}, {repo},
|
|
405
|
+
# {host}, its named groups and {1}…{N} for its N groups. Any other
|
|
406
|
+
# `{word}` stays as text, with one warning here, at compile time.
|
|
407
|
+
def known_placeholders(name, template, regex)
|
|
408
|
+
used = template.scan(PLACEHOLDER).flatten.uniq
|
|
409
|
+
groups = group_count(regex)
|
|
410
|
+
known, unknown = used.partition do |word|
|
|
411
|
+
if word.match?(/\A\d+\z/)
|
|
412
|
+
groups.nil? || (word.to_i.between?(1, groups))
|
|
413
|
+
else
|
|
414
|
+
BUILTIN_PLACEHOLDERS.include?(word) || regex.names.include?(word)
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
unless unknown.empty?
|
|
418
|
+
list = unknown.map { |word| "{#{word}}" }.join(", ")
|
|
419
|
+
Samagotchi::Log.warn(:hooks, "source_links_unknown_placeholder",
|
|
420
|
+
echo: "[samagotchi:hooks] source-links: #{name}: #{list}: no such group in its pattern; " \
|
|
421
|
+
"left as text")
|
|
422
|
+
end
|
|
423
|
+
known
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# How many groups +regex+ captures (with named groups, only those), found
|
|
427
|
+
# by matching an always-empty alternative; nil when that fails.
|
|
428
|
+
def group_count(regex)
|
|
429
|
+
probe = Regexp.new("(?:#{regex.source}\n)|", regex.options, timeout: REGEX_TIMEOUT)
|
|
430
|
+
probe.match("").size - 1
|
|
431
|
+
rescue RegexpError, Regexp::TimeoutError
|
|
432
|
+
nil
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# The URL for one hit: +template+ with its +known+ placeholders filled, or
|
|
436
|
+
# nil when one can't be (a group that didn't take part, a {repo} with an
|
|
437
|
+
# empty or dot segment, no remote): we never build a URL with a hole.
|
|
438
|
+
def render_url(template, known, match, ref, source)
|
|
439
|
+
unresolved = false
|
|
440
|
+
url = template.gsub(PLACEHOLDER) do
|
|
441
|
+
word = Regexp.last_match(1)
|
|
442
|
+
next Regexp.last_match(0) unless known.include?(word)
|
|
443
|
+
|
|
444
|
+
value = placeholder_value(word, match, ref, source)
|
|
445
|
+
unresolved = true if value.nil?
|
|
446
|
+
value.to_s
|
|
447
|
+
end
|
|
448
|
+
unresolved ? nil : url
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# One placeholder's escaped value, or nil when it is unresolved.
|
|
452
|
+
def placeholder_value(word, match, ref, source)
|
|
453
|
+
case word
|
|
454
|
+
when "match" then escape_url(match[1] || ref)
|
|
455
|
+
when /\A\d+\z/ then match[word.to_i]&.then { |value| escape_url(value) }
|
|
456
|
+
when "repo", "host"
|
|
457
|
+
value = match.names.include?(word) ? match[word] : nil
|
|
458
|
+
value ||= remote_value(word, source)
|
|
459
|
+
word == "repo" ? escape_repo(value) : value&.then { |host| escape_url(host) }
|
|
460
|
+
else match[word]&.then { |value| escape_url(value) }
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# {repo} / {host} from the project's git remote (the source's `remote:`,
|
|
465
|
+
# default origin); nil when there is none, or when its host is not one of
|
|
466
|
+
# the source's `remote_host:` list.
|
|
467
|
+
def remote_value(word, source)
|
|
468
|
+
remote = project_remote(source[:remote])
|
|
469
|
+
return nil unless remote
|
|
470
|
+
|
|
471
|
+
hosts = source[:remote_hosts]
|
|
472
|
+
unless hosts.empty? || hosts.include?(remote[:host].downcase)
|
|
473
|
+
key = [source[:name], remote[:host]]
|
|
474
|
+
unless @host_mismatch_logged[key]
|
|
475
|
+
@host_mismatch_logged[key] = true
|
|
476
|
+
Samagotchi::Log.debug(:hooks, "source_links_remote_host_mismatch", source: source[:name],
|
|
477
|
+
host: remote[:host], remote_host: hosts.join(","))
|
|
478
|
+
end
|
|
479
|
+
return nil
|
|
480
|
+
end
|
|
481
|
+
remote[word.to_sym]
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# The project's (Dir.pwd's) remote +name+ as {host:, repo:}, or nil.
|
|
485
|
+
# Asked of git lazily (only a hit that needs it) and remembered, nil too,
|
|
486
|
+
# per [Dir.pwd, name]: git applies insteadOf rewrites and includes, and a
|
|
487
|
+
# worktree reports its main repo's remote.
|
|
488
|
+
def project_remote(name)
|
|
489
|
+
key = [Dir.pwd, name]
|
|
490
|
+
return @remotes[key] if @remotes.key?(key)
|
|
491
|
+
|
|
492
|
+
@remotes[key] = read_remote(key[0], name)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def read_remote(dir, name)
|
|
496
|
+
out, status = Open3.capture2({ "GIT_DIR" => nil, "GIT_WORK_TREE" => nil },
|
|
497
|
+
"git", "-C", dir, "remote", "get-url", name, err: File::NULL)
|
|
498
|
+
unless status.success?
|
|
499
|
+
Samagotchi::Log.debug(:hooks, "source_links_no_remote", remote: name, exit: status.exitstatus)
|
|
500
|
+
return nil
|
|
501
|
+
end
|
|
502
|
+
remote = self.class.parse_remote_url(out)
|
|
503
|
+
Samagotchi::Log.debug(:hooks, "source_links_remote", remote: name, host: remote&.dig(:host), repo: remote&.dig(:repo))
|
|
504
|
+
remote
|
|
505
|
+
rescue SystemCallError => e
|
|
506
|
+
Samagotchi::Log.debug(:hooks, "source_links_no_remote", remote: name, error: e.class.name)
|
|
507
|
+
nil
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# A repo path with each `/`-separated segment escaped and the `/` kept
|
|
511
|
+
# (GitLab's `group/sub/proj`); nil for an empty, `.` or `..` segment, which
|
|
512
|
+
# a browser would resolve out of the path.
|
|
513
|
+
def escape_repo(value)
|
|
514
|
+
return nil if value.nil?
|
|
515
|
+
|
|
516
|
+
segments = value.split("/", -1)
|
|
517
|
+
return nil if segments.empty? || segments.any? { |segment| ["", ".", ".."].include?(segment) }
|
|
518
|
+
|
|
519
|
+
segments.map { |segment| escape_url(segment) }.join("/")
|
|
520
|
+
end
|
|
521
|
+
|
|
349
522
|
def warn_invalid(reason)
|
|
350
523
|
Samagotchi::Log.warn(:hooks, "source_links_invalid_source",
|
|
351
524
|
echo: "[samagotchi:hooks] source-links: skipping a source: #{reason}")
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: source-links
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.3.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
7
|
files:
|
|
8
|
-
source_links.md: sha256:
|
|
8
|
+
source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
|
|
9
9
|
hooks:
|
|
10
10
|
source_links.rb:
|
|
11
|
-
sha256: sha256:
|
|
11
|
+
sha256: sha256:0ee86a932efc90d81777855c81dea16510710d7371fe8b6b146a5a16b29794c2
|
|
12
12
|
event: after_turn
|
|
13
13
|
on_error: log
|
|
14
14
|
priority: 90
|
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
|
|
3
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
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.
|
|
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.
|