samagotchi 0.34.0 → 0.36.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 +77 -1
- data/README.md +2 -0
- data/bin/chi +3 -0
- data/docs/broadcast.md +129 -0
- data/docs/cli.md +8 -0
- data/docs/configuration.md +2 -0
- data/docs/context.md +37 -0
- data/docs/guardrails.md +14 -4
- data/docs/hooks.md +25 -4
- data/docs/memory.md +3 -0
- data/docs/plugins.md +29 -3
- data/docs/sessions.md +6 -6
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/broadcast/recipients.rb +63 -0
- data/lib/samagotchi/broadcast/scope_card.rb +94 -0
- data/lib/samagotchi/broadcast/tags.rb +154 -0
- data/lib/samagotchi/broadcast_command.rb +228 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +22 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +44 -11
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
- data/lib/samagotchi/bundles/system/self_map.md +4 -0
- data/lib/samagotchi/children_status.rb +2 -16
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/engine.rb +29 -5
- data/lib/samagotchi/git_head.rb +24 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
- data/lib/samagotchi/guardrails/rules.rb +15 -4
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/guardrails/verdict.rb +4 -3
- data/lib/samagotchi/hooks/registry.rb +11 -5
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
- data/lib/samagotchi/note_command.rb +13 -13
- data/lib/samagotchi/note_delivery.rb +42 -0
- data/lib/samagotchi/plugin/context.rb +4 -4
- data/lib/samagotchi/session_commands.rb +1 -0
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
- data/lib/samagotchi/terminal_ui.rb +14 -0
- data/lib/samagotchi/tool_activity.rb +14 -3
- data/lib/samagotchi/tool_declarations.rb +8 -4
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +103 -2
- data/lib/samagotchi/tools/send_note.rb +5 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/capabilities.rb +19 -0
- data/lib/samagotchi/web/public/activity.js +26 -6
- data/lib/samagotchi/web/public/app.js +515 -288
- data/lib/samagotchi/web/public/card.js +12 -3
- data/lib/samagotchi/web/public/command_view.js +2 -3
- data/lib/samagotchi/web/public/families_view.js +171 -0
- data/lib/samagotchi/web/public/format.js +52 -0
- data/lib/samagotchi/web/public/index.html +94 -21
- data/lib/samagotchi/web/public/live_timing.js +154 -0
- data/lib/samagotchi/web/public/live_turn.js +44 -0
- data/lib/samagotchi/web/public/sessions_list.js +228 -32
- data/lib/samagotchi/web/public/strip.js +5 -2
- data/lib/samagotchi/web/public/turn_html.js +107 -0
- data/lib/samagotchi/web/public/turn_model.js +5 -4
- data/lib/samagotchi/web/public/turn_view.js +43 -129
- data/lib/samagotchi/web/session_summary.rb +4 -0
- data/lib/samagotchi/worker.rb +166 -307
- data/lib/samagotchi/worker_inbound.rb +251 -0
- data/lib/samagotchi/worker_wakes.rb +120 -0
- metadata +15 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "json"
|
|
4
5
|
require "open3"
|
|
6
|
+
require "tmpdir"
|
|
5
7
|
|
|
6
8
|
# github-pr: a session on a branch with an open GitHub PR gets the PR
|
|
7
9
|
# attached as context (docs/context.md) when its worker starts: a quiet
|
|
@@ -10,12 +12,46 @@ require "open3"
|
|
|
10
12
|
# serves `chi context add <PR URL>` and the web's "+ URL"). Nothing for a
|
|
11
13
|
# scratch session or a delegate child (D12), and nothing, quietly, without
|
|
12
14
|
# gh, its login, a repo or an open PR.
|
|
15
|
+
#
|
|
16
|
+
# Line links: in the web, a `lib/foo.rb:28` (or `lib/foo.rb:28-34`, in
|
|
17
|
+
# inline code too) in the model's answer links to that line of the PR the
|
|
18
|
+
# session reviews: the PR's Files changed view when the lines are in a
|
|
19
|
+
# hunk, else the file at the PR's head. The PRs are the session's attached
|
|
20
|
+
# github-pr sources and the PR URLs in the user's messages (a delegate
|
|
21
|
+
# child's task is its first one). Display only (event[:present]): the
|
|
22
|
+
# model's text stays as it was. The after_turn hook links from a per-PR
|
|
23
|
+
# cache and never runs gh (the web holds the answer until the hooks are
|
|
24
|
+
# done); before_turn, before_generation and the init task fill the cache
|
|
25
|
+
# off the turn's thread. `bundles: github-pr: line_links: false` turns it
|
|
26
|
+
# off.
|
|
13
27
|
class Plugin
|
|
14
28
|
# gh and git each get this long (a network hang must not keep a thread).
|
|
15
29
|
COMMAND_SECONDS = 20
|
|
30
|
+
# before_generation looks for a newly attached PR at most this often.
|
|
31
|
+
GENERATION_CHECK_SECONDS = 30
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@cache = PrCache.new
|
|
35
|
+
@generation_checked_at = nil
|
|
36
|
+
end
|
|
16
37
|
|
|
17
38
|
def register(chi)
|
|
18
|
-
chi.init("Looking for this branch's pull request", quiet: true)
|
|
39
|
+
chi.init("Looking for this branch's pull request", quiet: true) do |ctx|
|
|
40
|
+
summary = attach_branch_pr(ctx)
|
|
41
|
+
# Inline (an init task has its own thread), whatever the attach
|
|
42
|
+
# returned (attached already, a child, a scratch session), and after
|
|
43
|
+
# it, so a PR it just attached is warm for the first answer.
|
|
44
|
+
refresh(ctx, attached_prs(ctx)) if line_links?(ctx)
|
|
45
|
+
summary
|
|
46
|
+
end
|
|
47
|
+
chi.on(:before_turn) do |event, ctx|
|
|
48
|
+
# The prompt too: messages is the history before this turn, and a
|
|
49
|
+
# delegate child's task is its first prompt.
|
|
50
|
+
kick(ctx, attached_prs(ctx) + PrRef.from_messages(event[:messages], prompt: event[:prompt])) if line_links?(ctx)
|
|
51
|
+
end
|
|
52
|
+
chi.on(:before_generation) { |_event, ctx| check_attached(ctx) }
|
|
53
|
+
# After source-links (90): its links are in the display this one gets.
|
|
54
|
+
chi.on(:after_turn, priority: 100) { |event, ctx| link_answer(event, ctx) }
|
|
19
55
|
end
|
|
20
56
|
|
|
21
57
|
# @return [String] what it did (the init task's summary)
|
|
@@ -41,11 +77,102 @@ class Plugin
|
|
|
41
77
|
"no pull request attached"
|
|
42
78
|
end
|
|
43
79
|
|
|
80
|
+
# The after_turn handler: links the answer's `path:line` refs from the
|
|
81
|
+
# PRs' cached data. Never runs gh: nothing cached yet links nothing.
|
|
82
|
+
def link_answer(event, ctx)
|
|
83
|
+
return unless line_links?(ctx) && event[:status].to_s == "completed" && event[:present]
|
|
84
|
+
|
|
85
|
+
text = LineLinker.last_model_text(event[:messages])
|
|
86
|
+
return if text.nil? || !text.match?(LineLinker::QUICK)
|
|
87
|
+
|
|
88
|
+
prs = (attached_prs(ctx) + PrRef.from_messages(event[:messages])).uniq
|
|
89
|
+
cached = prs.filter_map { |pr| (data = @cache[pr]) && [pr, data] }
|
|
90
|
+
return if cached.empty?
|
|
91
|
+
|
|
92
|
+
event[:present].call { |display| LineLinker.new(cached).link(display) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Fetches the PRs in +prs+ whose cache entry is missing or old, on this
|
|
96
|
+
# thread, unless a refresh runs already (the init task; #kick's thread).
|
|
97
|
+
def refresh(ctx, prs)
|
|
98
|
+
stale = @cache.stale(prs.uniq, now)
|
|
99
|
+
return if stale.empty? || !@cache.claim
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
stale.each { |pr| fetch(ctx, pr) }
|
|
103
|
+
ensure
|
|
104
|
+
@cache.release
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
44
108
|
private
|
|
45
109
|
|
|
110
|
+
def line_links?(ctx) = ctx.settings["line_links"] != false
|
|
111
|
+
|
|
112
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
|
+
|
|
114
|
+
# #refresh on its own thread (a raw one, docs/plugins.md), when there is
|
|
115
|
+
# something to fetch and no refresh runs. Process exit ends it; a gh
|
|
116
|
+
# still running then finishes alone.
|
|
117
|
+
def kick(ctx, prs)
|
|
118
|
+
prs = prs.uniq
|
|
119
|
+
return if prs.empty? || @cache.refreshing? || @cache.stale(prs, now).empty?
|
|
120
|
+
|
|
121
|
+
thread = Thread.new { refresh(ctx, prs) }
|
|
122
|
+
thread.report_on_exception = false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A PR attached while the turn runs (a parent attaching it right after
|
|
126
|
+
# `delegate wait: false`), checked at most every GENERATION_CHECK_SECONDS.
|
|
127
|
+
def check_attached(ctx)
|
|
128
|
+
return unless line_links?(ctx)
|
|
129
|
+
return if @generation_checked_at && now - @generation_checked_at < GENERATION_CHECK_SECONDS
|
|
130
|
+
|
|
131
|
+
@generation_checked_at = now
|
|
132
|
+
kick(ctx, attached_prs(ctx))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The session's attached github-pr sources, as PrRefs.
|
|
136
|
+
def attached_prs(ctx)
|
|
137
|
+
ctx.context.list.filter_map { |source| PrRef.parse(source[:hint]) if source[:provider] == "github-pr" }
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
ctx.log.debug(:pr_lines_no_sources, error: e.class.name)
|
|
140
|
+
[]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The PR's head and base, and its files when the head moved (or none
|
|
144
|
+
# are cached). A failure is remembered, so the next kicks wait.
|
|
145
|
+
def fetch(ctx, pr)
|
|
146
|
+
shas = run(ctx, "gh", "api", "repos/#{pr.owner}/#{pr.repo}/pulls/#{pr.number}", "--jq",
|
|
147
|
+
'.head.sha + " " + .base.sha', chdir: Dir.tmpdir).to_s.split
|
|
148
|
+
return failed(ctx, pr, "no pull request data") unless shas.size == 2
|
|
149
|
+
|
|
150
|
+
cached = @cache[pr]
|
|
151
|
+
files = cached.files if cached && cached.head_sha == shas[0]
|
|
152
|
+
files ||= fetch_files(ctx, pr) or return failed(ctx, pr, "no file list")
|
|
153
|
+
@cache.store(pr, PrData.new(head_sha: shas[0], base_sha: shas[1], files: files, fetched_at: now))
|
|
154
|
+
ctx.log.debug(:pr_lines_fetched, pr: pr.url, files: files.size)
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
failed(ctx, pr, "#{e.class.name}: #{e.message.to_s[0, 200]}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The PR's files, one JSON object a line (--jq), every page.
|
|
160
|
+
def fetch_files(ctx, pr)
|
|
161
|
+
out = run(ctx, "gh", "api", "--paginate", "repos/#{pr.owner}/#{pr.repo}/pulls/#{pr.number}/files?per_page=100",
|
|
162
|
+
"--jq", ".[] | {filename, status, previous_filename, patch}", chdir: Dir.tmpdir) or return nil
|
|
163
|
+
out.each_line.filter_map { |line| PrFile.from_api(JSON.parse(line)) unless line.strip.empty? }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Logged once per PR until a fetch of it succeeds (no gh, no login, no
|
|
167
|
+
# network: every kick would say it again).
|
|
168
|
+
def failed(ctx, pr, why)
|
|
169
|
+
ctx.log.info(:pr_lines_not_fetched, pr: pr.url, why: why) if @cache.fail(pr, now)
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
46
173
|
# stdout of a command that succeeded within COMMAND_SECONDS, else nil.
|
|
47
|
-
def run(ctx, *command)
|
|
48
|
-
Open3.popen2(*command, chdir:
|
|
174
|
+
def run(ctx, *command, chdir: ctx.cwd)
|
|
175
|
+
Open3.popen2(*command, chdir: chdir, err: File::NULL, pgroup: true) do |stdin, stdout, wait|
|
|
49
176
|
stdin.close
|
|
50
177
|
reader = Thread.new { stdout.read }
|
|
51
178
|
unless wait.join(COMMAND_SECONDS)
|
|
@@ -63,3 +190,317 @@ class Plugin
|
|
|
63
190
|
nil
|
|
64
191
|
end
|
|
65
192
|
end
|
|
193
|
+
|
|
194
|
+
# A GitHub pull request, by its URL.
|
|
195
|
+
PrRef = Data.define(:owner, :repo, :number)
|
|
196
|
+
|
|
197
|
+
class PrRef
|
|
198
|
+
# A PR URL anywhere in a text (trailing text allowed, like the provider's
|
|
199
|
+
# match: `…/pull/42/files`).
|
|
200
|
+
URL = %r{https://github\.com/([\w.-]+)/([\w.-]+)/pull/(\d+)}
|
|
201
|
+
# PRs taken from the user's messages, newest first.
|
|
202
|
+
FROM_MESSAGES = 3
|
|
203
|
+
|
|
204
|
+
# @return [PrRef, nil]
|
|
205
|
+
def self.parse(text)
|
|
206
|
+
match = text.to_s.match(URL) or return nil
|
|
207
|
+
new(owner: match[1], repo: match[2], number: match[3].to_i)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The PRs the user's messages (and +prompt+, the turn's own) name, newest
|
|
211
|
+
# first, up to FROM_MESSAGES distinct. Not tool results or answers: `gh pr
|
|
212
|
+
# list` output, logs or a CHANGELOG name unrelated PRs.
|
|
213
|
+
def self.from_messages(messages, prompt: nil)
|
|
214
|
+
texts = Array(messages).filter_map do |message|
|
|
215
|
+
next unless message.is_a?(Hash)
|
|
216
|
+
|
|
217
|
+
role = message.key?(:role) ? message[:role] : message["role"]
|
|
218
|
+
(message.key?(:content) ? message[:content] : message["content"]).to_s if role.to_s == "user"
|
|
219
|
+
end
|
|
220
|
+
texts << prompt.to_s if prompt
|
|
221
|
+
found = []
|
|
222
|
+
texts.reverse_each do |text|
|
|
223
|
+
text.scan(URL).reverse_each do |owner, repo, number|
|
|
224
|
+
pr = new(owner: owner, repo: repo, number: number.to_i)
|
|
225
|
+
found << pr unless found.include?(pr)
|
|
226
|
+
return found if found.size >= FROM_MESSAGES
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
found
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def url = "https://github.com/#{owner}/#{repo}/pull/#{number}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# One file of a PR: its path (the new name), a rename's old one, its status
|
|
236
|
+
# ("added", "modified", "removed", "renamed", …) and its hunks as line
|
|
237
|
+
# ranges on each side (none: a binary file or too large a diff).
|
|
238
|
+
PrFile = Data.define(:path, :previous_path, :status, :new_hunks, :old_hunks)
|
|
239
|
+
|
|
240
|
+
class PrFile
|
|
241
|
+
# A hunk header: `@@ -a,b +c,d @@` (a count left out is 1).
|
|
242
|
+
HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
|
243
|
+
|
|
244
|
+
# @param file [Hash] one entry of the PR files API
|
|
245
|
+
# @return [PrFile, nil]
|
|
246
|
+
def self.from_api(file)
|
|
247
|
+
return nil unless file.is_a?(Hash) && file["filename"].is_a?(String)
|
|
248
|
+
|
|
249
|
+
old_hunks = []
|
|
250
|
+
new_hunks = []
|
|
251
|
+
file["patch"].to_s.scan(HUNK) do |old_start, old_count, new_start, new_count|
|
|
252
|
+
old_hunks << span(old_start, old_count)
|
|
253
|
+
new_hunks << span(new_start, new_count)
|
|
254
|
+
end
|
|
255
|
+
new(path: file["filename"], previous_path: file["previous_filename"], status: file["status"].to_s,
|
|
256
|
+
new_hunks: new_hunks.compact, old_hunks: old_hunks.compact)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# A hunk side's lines, or nil for none (`+0,0`: a removed file).
|
|
260
|
+
def self.span(start, count)
|
|
261
|
+
count = (count || 1).to_i
|
|
262
|
+
count.zero? ? nil : start.to_i..(start.to_i + count - 1)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def removed? = status == "removed"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# What the cache holds of a PR.
|
|
269
|
+
PrData = Data.define(:head_sha, :base_sha, :files, :fetched_at)
|
|
270
|
+
|
|
271
|
+
# The PRs' data, per PR, shared by the hooks' threads and the refresh's.
|
|
272
|
+
class PrCache
|
|
273
|
+
# An entry (or a failed fetch) younger than this isn't fetched again.
|
|
274
|
+
FRESH_SECONDS = 60
|
|
275
|
+
|
|
276
|
+
def initialize
|
|
277
|
+
@mutex = Mutex.new
|
|
278
|
+
@entries = {}
|
|
279
|
+
@failed = {}
|
|
280
|
+
@refreshing = false
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @return [PrData, nil]
|
|
284
|
+
def [](pr) = @mutex.synchronize { @entries[pr] }
|
|
285
|
+
|
|
286
|
+
def store(pr, data)
|
|
287
|
+
@mutex.synchronize do
|
|
288
|
+
@entries[pr] = data
|
|
289
|
+
@failed.delete(pr)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Remembers a failed fetch; true the first time since the last success.
|
|
294
|
+
def fail(pr, time)
|
|
295
|
+
@mutex.synchronize do
|
|
296
|
+
first = !@failed.key?(pr)
|
|
297
|
+
@failed[pr] = time
|
|
298
|
+
first
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# The PRs whose entry is missing or older than FRESH_SECONDS (a recent
|
|
303
|
+
# failure counts as fresh).
|
|
304
|
+
def stale(prs, time)
|
|
305
|
+
@mutex.synchronize do
|
|
306
|
+
prs.reject do |pr|
|
|
307
|
+
fetched = [@entries[pr]&.fetched_at, @failed[pr]].compact.max
|
|
308
|
+
fetched && time - fetched < FRESH_SECONDS
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Takes the one refresh slot: false when a refresh runs.
|
|
314
|
+
def claim
|
|
315
|
+
@mutex.synchronize do
|
|
316
|
+
next false if @refreshing
|
|
317
|
+
|
|
318
|
+
@refreshing = true
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def release = @mutex.synchronize { @refreshing = false }
|
|
323
|
+
|
|
324
|
+
def refreshing? = @mutex.synchronize { @refreshing }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Links an answer's `path:line` and `path:line-line` refs to the PRs'
|
|
328
|
+
# lines. A ref in inline code links when the code is the ref alone
|
|
329
|
+
# (`` `foo.rb:28` `` becomes ``[`foo.rb:28`](url)``); fenced blocks,
|
|
330
|
+
# markdown links and bare URLs are left alone.
|
|
331
|
+
class LineLinker
|
|
332
|
+
# The answer is scanned only up to this many characters.
|
|
333
|
+
MAX_SCAN = 20_000
|
|
334
|
+
# A path (no spaces), a line, an optional end line and an optional
|
|
335
|
+
# column (kept in the link's text, not used).
|
|
336
|
+
REF = %r{(?<![\w./@+~-])(?<path>[\w./@+~-]*[\w@+~-]):(?<from>\d+)(?:-(?<to>\d+))?(?::\d+)?(?![\w/])}
|
|
337
|
+
REF_ALONE = /\A#{REF}\z/
|
|
338
|
+
# Whether an answer may hold a ref at all.
|
|
339
|
+
QUICK = /\S:\d/
|
|
340
|
+
URL_SPAN = %r{[a-z][a-z0-9+.-]*://\S+}i
|
|
341
|
+
MARKDOWN_LINK = /\[[^\]]*\]\([^)]*\)/
|
|
342
|
+
# A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
|
|
343
|
+
FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
|
|
344
|
+
|
|
345
|
+
# The content of the last model message, or nil (string or symbol keys).
|
|
346
|
+
def self.last_model_text(messages)
|
|
347
|
+
Array(messages).reverse_each do |message|
|
|
348
|
+
next unless message.is_a?(Hash)
|
|
349
|
+
next unless (message.key?(:role) ? message[:role] : message["role"]).to_s == "model"
|
|
350
|
+
|
|
351
|
+
return (message.key?(:content) ? message[:content] : message["content"]).to_s
|
|
352
|
+
end
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# @param prs [Array<Array(PrRef, PrData)>]
|
|
357
|
+
def initialize(prs)
|
|
358
|
+
@prs = prs
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# +text+ with each ref that names exactly one PR's file as a markdown
|
|
362
|
+
# link; the part beyond MAX_SCAN stays as it is.
|
|
363
|
+
def link(text)
|
|
364
|
+
head = text[0, MAX_SCAN]
|
|
365
|
+
out = +""
|
|
366
|
+
pos = 0
|
|
367
|
+
hits(head).each do |start, finish, url|
|
|
368
|
+
out << head[pos...start] << "[#{head[start...finish]}](#{url})"
|
|
369
|
+
pos = finish
|
|
370
|
+
end
|
|
371
|
+
return text if pos.zero?
|
|
372
|
+
|
|
373
|
+
out << head[pos..] << text[MAX_SCAN..].to_s
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# The URL for +path+'s lines +from+..+to+ (+to+ nil: one line), or nil:
|
|
377
|
+
# no PR has the file, or more than one does.
|
|
378
|
+
def url_for(path, from, to = nil)
|
|
379
|
+
found = @prs.filter_map do |pr, data|
|
|
380
|
+
file = resolve(data.files, path)
|
|
381
|
+
[pr, data, file] if file
|
|
382
|
+
end
|
|
383
|
+
return nil unless found.size == 1
|
|
384
|
+
|
|
385
|
+
line_url(*found.first, from, to)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
private
|
|
389
|
+
|
|
390
|
+
# [start, finish, url] for each ref to link, by offset.
|
|
391
|
+
def hits(text)
|
|
392
|
+
fences = fenced_blocks(text)
|
|
393
|
+
skipped = fences + spans(text, URL_SPAN) + spans(text, MARKDOWN_LINK)
|
|
394
|
+
code = inline_code(text, fences)
|
|
395
|
+
found = []
|
|
396
|
+
code.each do |start, finish, inner|
|
|
397
|
+
next if inside_any?(skipped, start, finish)
|
|
398
|
+
|
|
399
|
+
match = inner.strip.match(REF_ALONE) or next
|
|
400
|
+
url = ref_url(match) and found << [start, finish, url]
|
|
401
|
+
end
|
|
402
|
+
text.scan(REF) do
|
|
403
|
+
match = Regexp.last_match
|
|
404
|
+
start = match.begin(0)
|
|
405
|
+
finish = match.end(0)
|
|
406
|
+
next if inside_any?(skipped, start, finish) || inside_any?(code, start, finish)
|
|
407
|
+
|
|
408
|
+
url = ref_url(match) and found << [start, finish, url]
|
|
409
|
+
end
|
|
410
|
+
found.sort_by(&:first)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# A path has a `/` or an extension (`10:30` is a time, `host:8080` a port).
|
|
414
|
+
def ref_url(match)
|
|
415
|
+
path = match[:path]
|
|
416
|
+
return nil unless path.include?("/") || path.match?(/\.[A-Za-z0-9]+\z/)
|
|
417
|
+
|
|
418
|
+
from = match[:from].to_i
|
|
419
|
+
to = match[:to]&.to_i
|
|
420
|
+
to = nil if to && to <= from
|
|
421
|
+
from.zero? ? nil : url_for(path, from, to)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# The PR file +path+ names: the path (or a rename's old one) exactly,
|
|
425
|
+
# after a leading `./`; else the longest PR path +path+ ends with on a
|
|
426
|
+
# `/` boundary (an absolute path into a worktree); else the one PR path
|
|
427
|
+
# that ends with +path+ (a bare `foo.rb`). nil: none, or several.
|
|
428
|
+
def resolve(files, path)
|
|
429
|
+
path = path.sub(%r{\A(?:\./)+}, "")
|
|
430
|
+
exact = files.find { |file| file.path == path } || files.find { |file| file.previous_path == path }
|
|
431
|
+
return exact if exact
|
|
432
|
+
|
|
433
|
+
longer = files.select { |file| path.end_with?("/#{file.path}") }.max_by { |file| file.path.length }
|
|
434
|
+
return longer if longer
|
|
435
|
+
|
|
436
|
+
shorter = files.select { |file| file.path.end_with?("/#{path}") }
|
|
437
|
+
shorter.one? ? shorter.first : nil
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# The PR's Files changed anchor when the lines are inside one hunk (the
|
|
441
|
+
# new side; a removed file's old side), else the file at the PR's head
|
|
442
|
+
# (a removed one: at its base), which has every line.
|
|
443
|
+
def line_url(pr, data, file, from, to)
|
|
444
|
+
last = to || from
|
|
445
|
+
hunks, side = file.removed? ? [file.old_hunks, "L"] : [file.new_hunks, "R"]
|
|
446
|
+
if hunks.any? { |hunk| hunk.cover?(from) && hunk.cover?(last) }
|
|
447
|
+
anchor = "#{side}#{from}#{"-#{side}#{to}" if to}"
|
|
448
|
+
return "#{pr.url}/files#diff-#{Digest::SHA256.hexdigest(file.path)}#{anchor}"
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
sha = file.removed? ? data.base_sha : data.head_sha
|
|
452
|
+
path = file.path.split("/").map { |segment| segment.gsub(/[^\w.~-]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join } }
|
|
453
|
+
"https://github.com/#{pr.owner}/#{pr.repo}/blob/#{sha}/#{path.join("/")}#L#{from}#{"-L#{to}" if to}"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# [start, finish, inner] for each inline code span outside +fences+: a
|
|
457
|
+
# run of backticks to the next run of the same length.
|
|
458
|
+
def inline_code(text, fences)
|
|
459
|
+
runs = []
|
|
460
|
+
text.scan(/`+/) { runs << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
|
|
461
|
+
runs.reject! { |start, finish| inside_any?(fences, start, finish) }
|
|
462
|
+
by_length = Hash.new { |hash, key| hash[key] = [] }
|
|
463
|
+
runs.each_with_index { |(start, finish), index| by_length[finish - start] << index }
|
|
464
|
+
found = []
|
|
465
|
+
index = 0
|
|
466
|
+
while index < runs.size
|
|
467
|
+
start, finish = runs[index]
|
|
468
|
+
closing = by_length[finish - start].bsearch { |other| other > index }
|
|
469
|
+
if closing
|
|
470
|
+
found << [start, runs[closing][1], text[finish...runs[closing][0]]]
|
|
471
|
+
index = closing + 1
|
|
472
|
+
else
|
|
473
|
+
index += 1
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
found
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def fenced_blocks(text)
|
|
480
|
+
blocks = []
|
|
481
|
+
open = nil
|
|
482
|
+
offset = 0
|
|
483
|
+
text.each_line do |line|
|
|
484
|
+
marker = line[FENCE_OPEN, 1]
|
|
485
|
+
if open.nil? && marker
|
|
486
|
+
open = [offset, marker]
|
|
487
|
+
elsif open && marker && marker[0] == open[1][0] && marker.length >= open[1].length && line.strip == marker
|
|
488
|
+
blocks << [open[0], offset + line.length]
|
|
489
|
+
open = nil
|
|
490
|
+
end
|
|
491
|
+
offset += line.length
|
|
492
|
+
end
|
|
493
|
+
blocks << [open[0], text.length] if open
|
|
494
|
+
blocks
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def spans(text, regex)
|
|
498
|
+
found = []
|
|
499
|
+
text.scan(regex) { found << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
|
|
500
|
+
found
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def inside_any?(spans, start, finish)
|
|
504
|
+
spans.any? { |span_start, span_end| start >= span_start && finish <= span_end }
|
|
505
|
+
end
|
|
506
|
+
end
|
|
@@ -60,6 +60,17 @@ rules:
|
|
|
60
60
|
verdict: ask
|
|
61
61
|
reason: rm -rf on a path outside the working tree
|
|
62
62
|
|
|
63
|
+
# memory_write remove: true moves a memory into the bundle trash. Memory
|
|
64
|
+
# files don't count as outside the repo, so nothing else asks; a delegate
|
|
65
|
+
# child could otherwise take its parent's handoff away. Every removal
|
|
66
|
+
# asks, once at a time.
|
|
67
|
+
- id: memory-remove
|
|
68
|
+
tool: memory_write
|
|
69
|
+
memory: remove
|
|
70
|
+
verdict: ask
|
|
71
|
+
scopes: [once]
|
|
72
|
+
reason: removes a memory (it goes to the bundle trash, which chi bundle trash lists)
|
|
73
|
+
|
|
63
74
|
# ── running code from the network ──────────────────────────────────────
|
|
64
75
|
- id: pipe-to-shell
|
|
65
76
|
tool: shell
|
|
@@ -128,6 +139,17 @@ rules:
|
|
|
128
139
|
scopes: [once]
|
|
129
140
|
reason: attaches a command chi will run every few minutes, outside the guardrails
|
|
130
141
|
|
|
142
|
+
# ── chi broadcast: for the user, not an agent ─────────────────────────
|
|
143
|
+
# chi broadcast refuses to run inside a session (SAMAGOTCHI_PARENT_SESSION
|
|
144
|
+
# is set there), but an agent can unset that; this asks every time, and
|
|
145
|
+
# only the user may allow it. Quoted words and continuations still match.
|
|
146
|
+
- id: chi-broadcast
|
|
147
|
+
tool: shell
|
|
148
|
+
command: '\bchi["'']?[\s\\]+["'']?broadcast\b'
|
|
149
|
+
verdict: ask
|
|
150
|
+
scopes: [once]
|
|
151
|
+
reason: shares a note with every session it may concern; chi broadcast is for the user, not an agent
|
|
152
|
+
|
|
131
153
|
# ── answering chi's own questions around chi answer ───────────────────
|
|
132
154
|
# A parent agent answers a child's approval with chi answer, which holds it
|
|
133
155
|
# to guardrails.parent_approvals. These shapes go around that: chi --attach
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: guardrails
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.8.0
|
|
4
4
|
scope: system
|
|
5
|
-
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
5
|
+
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer, removing a memory; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
# models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0;
|
|
8
|
-
#
|
|
9
|
-
|
|
8
|
+
# a rule's memory: key (memory-remove) needs 0.36.0: an older chi refuses the rule set (unknown key), so every
|
|
9
|
+
# guarded call would be refused
|
|
10
|
+
requires_chi: ">= 0.36.0"
|
|
10
11
|
files:
|
|
11
12
|
guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.5.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files: {}
|
|
8
8
|
plugin:
|
|
9
9
|
file: plugin.rb
|
|
10
|
-
sha256: sha256:
|
|
10
|
+
sha256: sha256:b946862b2c74734dd5af9ffba12f14a79b760b993a1248630f9fc5ecaaa740f5
|
|
11
11
|
requires_chi: ">= 0.33.0"
|
|
@@ -27,11 +27,14 @@
|
|
|
27
27
|
# A server that doesn't start, answer or list its tools is skipped with a
|
|
28
28
|
# notice; the rest of chi works. One that exits mid-session starts again on
|
|
29
29
|
# its next call, at most MAX_RESTARTS times a session. /mcp lists the
|
|
30
|
-
# servers and their tools
|
|
30
|
+
# servers and their tools, with an estimate of the tokens their
|
|
31
|
+
# definitions take in every request.
|
|
31
32
|
require "digest"
|
|
32
33
|
require "json"
|
|
33
34
|
require "open3"
|
|
34
35
|
require "samagotchi/process_group"
|
|
36
|
+
require "samagotchi/token_usage"
|
|
37
|
+
require "samagotchi/tool_declarations"
|
|
35
38
|
require "time"
|
|
36
39
|
require "shellwords"
|
|
37
40
|
require "tmpdir"
|
|
@@ -47,6 +50,8 @@ class Plugin
|
|
|
47
50
|
CACHE_TTL = 24 * 60 * 60
|
|
48
51
|
# Restarts of a server that exited, per session.
|
|
49
52
|
MAX_RESTARTS = 3
|
|
53
|
+
# The states whose server's tools the model has (what publish declares).
|
|
54
|
+
OFFERED = %i[running cached exited].freeze
|
|
50
55
|
IMAGE_EXT = { "image/png" => "png", "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp" }.freeze
|
|
51
56
|
|
|
52
57
|
# A JSON-RPC client for one MCP server over stdio: newline-delimited JSON
|
|
@@ -244,11 +249,12 @@ class Plugin
|
|
|
244
249
|
|
|
245
250
|
# One configured server: its service, state and tools. +listed+ is its
|
|
246
251
|
# tools/list as the server answers it (what the cache keeps), +tools+
|
|
247
|
-
# the ones chi offers (the tools: filter applied, each with chi_name)
|
|
252
|
+
# the ones chi offers (the tools: filter applied, each with chi_name),
|
|
253
|
+
# +tokens+ their definitions' estimated size (#definition_tokens).
|
|
248
254
|
# +client+ is the running process's (a restart replaces it; the
|
|
249
255
|
# service's stop closes the current one), +restarts+ how many it had.
|
|
250
256
|
Server = Struct.new(:name, :config, :service, :state, :error, :tools, :listed, :timeout, :cwd, :command, :env,
|
|
251
|
-
:digest, :cached_at, :relist, :relisting, :client, :restarts, keyword_init: true)
|
|
257
|
+
:digest, :cached_at, :relist, :relisting, :client, :restarts, :tokens, keyword_init: true)
|
|
252
258
|
|
|
253
259
|
def initialize(settings = {})
|
|
254
260
|
@settings = settings
|
|
@@ -278,7 +284,7 @@ class Plugin
|
|
|
278
284
|
configs = {} unless configs.is_a?(Hash)
|
|
279
285
|
@servers = configs.map do |name, config|
|
|
280
286
|
server = Server.new(name: name.to_s, config: config.is_a?(Hash) ? config : {}, state: :starting, tools: [],
|
|
281
|
-
restarts: 0)
|
|
287
|
+
restarts: 0, tokens: 0)
|
|
282
288
|
server.timeout = positive(server.config["timeout"]) || @timeout
|
|
283
289
|
resolve(server, ctx)
|
|
284
290
|
server.service = chi.service(name) { |svc| start(server, svc, ctx) }
|
|
@@ -506,24 +512,43 @@ class Plugin
|
|
|
506
512
|
# ── Tools ─────────────────────────────────────────────────────────────
|
|
507
513
|
|
|
508
514
|
# Declare the server's tools on +target+ (chi at load, or the set of
|
|
509
|
-
# chi.replace_tools later), the tools: filter applied.
|
|
515
|
+
# chi.replace_tools later), the tools: filter applied. Their estimated
|
|
516
|
+
# tokens go to the log when they changed.
|
|
510
517
|
def declare(target, server, ctx)
|
|
511
518
|
wanted = server.config["tools"] && Array(server.config["tools"]).map(&:to_s)
|
|
512
519
|
tools = server.listed.map(&:dup)
|
|
513
520
|
tools = tools.select { |tool| wanted.any? { |w| File.fnmatch(w, tool["name"], File::FNM_EXTGLOB) } } if wanted
|
|
514
521
|
server.tools = tools
|
|
522
|
+
tokens = 0
|
|
515
523
|
tools.each do |tool|
|
|
516
524
|
name = tool_name(server.name, tool["name"])
|
|
517
|
-
|
|
525
|
+
schema = tool["inputSchema"] || { "type" => "object", "properties" => {} }
|
|
526
|
+
target.tool(name, description(tool), schema: schema,
|
|
518
527
|
label: "#{server.name}: #{tool["name"]}", preview: ->(args) { preview(args) }) do |args, call_ctx|
|
|
519
528
|
call(server, tool["name"], args, call_ctx)
|
|
520
529
|
end
|
|
521
530
|
tool["chi_name"] = name
|
|
531
|
+
tokens += definition_tokens(name, description(tool), schema)
|
|
522
532
|
rescue ArgumentError => e
|
|
523
533
|
text = "MCP tool #{server.name}/#{tool["name"]} left out: #{e.message}"
|
|
524
534
|
ctx.notify(text, level: :warn) unless @left_out.include?(text)
|
|
525
535
|
@left_out << text
|
|
526
536
|
end
|
|
537
|
+
return if tokens == server.tokens
|
|
538
|
+
|
|
539
|
+
server.tokens = tokens
|
|
540
|
+
ctx.log.info("mcp_tools_estimated", server: server.name, tools: tools.count { |tool| tool["chi_name"] },
|
|
541
|
+
tokens: tokens)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# A tool's definition as the chat path sends it (LLM::ChatLoop#tool_definitions:
|
|
545
|
+
# the chat schema, wrapped as a function), in estimated tokens
|
|
546
|
+
# (TokenUsage::CHARS_PER_TOKEN). The native prompts (Gemma, Qwen) render a
|
|
547
|
+
# flatter schema, so it is an upper bound there.
|
|
548
|
+
def definition_tokens(name, description, schema)
|
|
549
|
+
spec = Samagotchi::Plugin::Api.tool_spec(name, description, schema: schema) { nil }
|
|
550
|
+
function = Samagotchi::ToolDeclarations.chat_schemas([spec[:schema]]).first.slice(:name, :description, :parameters)
|
|
551
|
+
Samagotchi::TokenUsage.estimate(JSON.generate({ type: "function", function: function }))
|
|
527
552
|
end
|
|
528
553
|
|
|
529
554
|
# The servers' tools changed after load (a live list that differs from
|
|
@@ -532,7 +557,7 @@ class Plugin
|
|
|
532
557
|
def publish(ctx)
|
|
533
558
|
@publish.synchronize do
|
|
534
559
|
@chi.replace_tools do |set|
|
|
535
|
-
@servers.each { |server| declare(set, server, ctx) if
|
|
560
|
+
@servers.each { |server| declare(set, server, ctx) if OFFERED.include?(server.state) }
|
|
536
561
|
end
|
|
537
562
|
end
|
|
538
563
|
end
|
|
@@ -750,20 +775,24 @@ class Plugin
|
|
|
750
775
|
def listing
|
|
751
776
|
return "No servers. Add them in config.yml under `bundles: mcp: servers:` (docs/plugins.md, The mcp bundle)." if @servers.empty?
|
|
752
777
|
|
|
753
|
-
@servers.map do |server|
|
|
778
|
+
servers = @servers.map do |server|
|
|
754
779
|
head = "**#{server.name}**: #{state_text(server)}"
|
|
755
780
|
# Sorted: a server lists its tools in its own (often grouped) order.
|
|
756
781
|
tools = server.tools.map { |tool| tool["chi_name"] }.compact.sort
|
|
757
782
|
tools.empty? ? head : "#{head}\n#{tools.map { |t| "- `#{t}`" }.join("\n")}"
|
|
758
|
-
end
|
|
783
|
+
end
|
|
784
|
+
total = @servers.sum { |server| OFFERED.include?(server.state) ? server.tokens : 0 }
|
|
785
|
+
[*servers, "Total: ~#{thousands(total)} tokens of tool definitions in every request " \
|
|
786
|
+
"(estimated: their JSON as the chat API gets it, ÷ #{format("%g", Samagotchi::TokenUsage::CHARS_PER_TOKEN)})."]
|
|
787
|
+
.join("\n\n")
|
|
759
788
|
end
|
|
760
789
|
|
|
761
790
|
def state_text(server)
|
|
762
791
|
case server.state
|
|
763
|
-
when :cached then "cached (not started), #{server.tools.size} tool#{"s" unless server.tools.size == 1}"
|
|
792
|
+
when :cached then "cached (not started), #{server.tools.size} tool#{"s" unless server.tools.size == 1}, #{tokens_text(server)}"
|
|
764
793
|
when :running
|
|
765
794
|
count = server.tools.count { |tool| tool["chi_name"] }
|
|
766
|
-
"running (pid #{server.client.pid}), #{count} tool#{"s" unless count == 1}"
|
|
795
|
+
"running (pid #{server.client.pid}), #{count} tool#{"s" unless count == 1}, #{tokens_text(server)}"
|
|
767
796
|
when :starting then "starting"
|
|
768
797
|
else "#{server.state == :failed ? "failed" : "stopped"}: #{server.error}"
|
|
769
798
|
end
|
|
@@ -771,6 +800,10 @@ class Plugin
|
|
|
771
800
|
"stopped"
|
|
772
801
|
end
|
|
773
802
|
|
|
803
|
+
def tokens_text(server) = "~#{thousands(server.tokens)} tokens"
|
|
804
|
+
|
|
805
|
+
def thousands(number) = number.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
806
|
+
|
|
774
807
|
def positive(value)
|
|
775
808
|
number = Float(value.to_s, exception: false)
|
|
776
809
|
number&.positive? ? number : nil
|