samagotchi 0.35.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.
@@ -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) { |ctx| attach_branch_pr(ctx) }
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: ctx.cwd, err: File::NULL, pgroup: true) do |stdin, stdout, wait|
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
@@ -1,11 +1,12 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.7.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, chi broadcast, 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
- # skip_read_only:, touches:, rm: and modes: need 0.20.0
9
- requires_chi: ">= 0.20.0"
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.4.5
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:1ebedc86cd21c918766c794015b6bd16c397c0f7e2734d939d8310d446b8a9a1
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
- target.tool(name, description(tool), schema: tool["inputSchema"] || { "type" => "object", "properties" => {} },
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 %i[running cached exited].include?(server.state) }
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.join("\n\n")
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
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.35.0
3
+ version: 0.36.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:37063eb74fdcb4cde748f17193ff23f832723eb38f81aff086f48f410ce7497b
9
9
  config_modification_protocol.md: sha256:ec0b0c7023dbf03b93879e51cd5cdc38b22d43a2e5984ee11e8542ace077c3be
10
- memory_guide.md: sha256:63d5d7f7d704e6ce7e28dd99e532b714c1b9f7acf548092404248d8ca5385b80
10
+ memory_guide.md: sha256:f7049304a333be842b761037bab5c8e31e890b8f4ad12f092b14ee8deb053c51
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614