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.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. 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
- # ref text case-insensitively.
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[:ref].downcase
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
- { name: name, regex: regex, url: ->(ref, match) { template.gsub("{match}", escape_url(match[1] || ref)) } }
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.2.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:0fb30311603c60f062740917ad0670904e022e8975b1d0eafe3df9e2904f2fe0
8
+ source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
9
  hooks:
10
10
  source_links.rb:
11
- sha256: sha256:5f2b8d5248efc7e0a98185d9e53a47e6a3edfe7c43ee5bf0ebe44d07f18d61aa
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.