samagotchi 0.61.0 → 0.63.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 +91 -1
- data/README.md +4 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +3 -0
- data/docs/cli.md +26 -9
- data/docs/configuration.md +33 -20
- data/docs/context.md +21 -3
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +14 -3
- data/docs/internals/engine.md +24 -11
- data/docs/internals/prompt-caching.md +6 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +131 -5
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +4 -4
- data/docs/sub-agent.md +6 -0
- data/docs/testing.md +10 -4
- data/lib/samagotchi/answer_refs.rb +18 -26
- data/lib/samagotchi/bridge.rb +23 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/context_absorber.rb +25 -6
- data/lib/samagotchi/engine.rb +9 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/idle_recap.rb +2 -3
- data/lib/samagotchi/image_store.rb +8 -0
- data/lib/samagotchi/kernel_loop.rb +7 -16
- data/lib/samagotchi/llm/chat_loop.rb +23 -15
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/plugin/attached_context.rb +7 -4
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/recap_setup.rb +3 -3
- data/lib/samagotchi/row_refs.rb +125 -0
- data/lib/samagotchi/session.rb +12 -0
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +57 -14
- data/lib/samagotchi/tool_activity.rb +8 -4
- data/lib/samagotchi/tool_response.rb +5 -4
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +16 -3
- data/lib/samagotchi/tools/builtins.rb +9 -2
- data/lib/samagotchi/tools/execute.rb +37 -7
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/registry.rb +5 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +20 -5
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +6 -3
- data/lib/samagotchi/web/public/app.js +106 -106
- data/lib/samagotchi/web/public/card.js +73 -23
- data/lib/samagotchi/web/public/chains.js +24 -24
- data/lib/samagotchi/web/public/command_view.js +39 -35
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/copy.js +7 -15
- data/lib/samagotchi/web/public/diff_view.js +20 -23
- data/lib/samagotchi/web/public/families_view.js +51 -39
- data/lib/samagotchi/web/public/format.js +0 -48
- data/lib/samagotchi/web/public/images.js +12 -16
- data/lib/samagotchi/web/public/index.html +61 -10
- data/lib/samagotchi/web/public/keep_focus.js +24 -0
- data/lib/samagotchi/web/public/lists_view.js +13 -13
- data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
- data/lib/samagotchi/web/public/question_card.js +11 -0
- data/lib/samagotchi/web/public/question_cards.js +63 -7
- data/lib/samagotchi/web/public/read_view.js +10 -9
- data/lib/samagotchi/web/public/ref_menu.js +18 -5
- data/lib/samagotchi/web/public/refs.js +113 -15
- data/lib/samagotchi/web/public/scope.js +23 -0
- data/lib/samagotchi/web/public/scroll.js +42 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +299 -70
- data/lib/samagotchi/web/public/timing.js +1 -8
- data/lib/samagotchi/web/public/turn_html.js +122 -61
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +167 -116
- data/lib/samagotchi/web/public/vendor/directive.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
- data/lib/samagotchi/worker.rb +49 -32
- data/lib/samagotchi/worker_inbound.rb +17 -14
- metadata +10 -1
|
@@ -20,8 +20,10 @@ require "tmpdir"
|
|
|
20
20
|
# hunk, else the file at the PR's head. The PRs are the session's attached
|
|
21
21
|
# github-pr sources and the PR URLs in the user's messages (a delegate
|
|
22
22
|
# child's task is its first one). Display only (event[:present]): the
|
|
23
|
-
# model's text stays as it was.
|
|
24
|
-
#
|
|
23
|
+
# model's text stays as it was. Each linked ref is also a file ref on the
|
|
24
|
+
# answer (event[:refs], chi 0.61.0): the file in this checkout, with a "PR
|
|
25
|
+
# diff" (or "File at head") action the web's ref menu offers. The
|
|
26
|
+
# after_turn hook links from a per-PR cache and never runs gh (the web holds the answer until the hooks are
|
|
25
27
|
# done); before_turn, before_generation and the init task fill the cache
|
|
26
28
|
# off the turn's thread (`gh api`), in a session worker only: the REPL and
|
|
27
29
|
# -p --non-interactive show no web page while they run (ctx.frontend; a chi
|
|
@@ -50,12 +52,17 @@ class Plugin
|
|
|
50
52
|
OFFER_LINE = "Not attached: the agent gets nothing until you attach it."
|
|
51
53
|
# first_prompt keeps this much of the prompt.
|
|
52
54
|
FIRST_PROMPT_CHARS = 160
|
|
55
|
+
# A git remote URL's GitHub owner and repo (https, ssh or scp-like).
|
|
56
|
+
REMOTE_REPO = %r{github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?/?\z}i
|
|
53
57
|
|
|
54
58
|
def initialize
|
|
55
59
|
@cache = PrCache.new
|
|
56
60
|
@generation_checked_at = nil
|
|
57
61
|
@warned_mode = false
|
|
58
62
|
@warned_line_links = false
|
|
63
|
+
# cwd => the "owner/repo"s (downcased) its git remotes name on
|
|
64
|
+
# github.com, for the worker's life.
|
|
65
|
+
@checkout_repos = {}
|
|
59
66
|
end
|
|
60
67
|
|
|
61
68
|
def register(chi)
|
|
@@ -138,7 +145,39 @@ class Plugin
|
|
|
138
145
|
cached = prs.filter_map { |pr| (data = @cache[pr]) && [pr, data] }
|
|
139
146
|
return if cached.empty?
|
|
140
147
|
|
|
141
|
-
|
|
148
|
+
linker = LineLinker.new(cached)
|
|
149
|
+
event[:present].call { |display| linker.link(display) }
|
|
150
|
+
add_refs(event[:refs], linker.found, ctx) unless linker.found.empty?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Each linked `path:line` as a file ref on the answer (event[:refs]): its
|
|
154
|
+
# target the file in this checkout (its root + the PR path), its action
|
|
155
|
+
# the link's URL, named by what it opens. Only for a PR of this
|
|
156
|
+
# checkout's repo (one of its git remotes names it): another project's PR
|
|
157
|
+
# has its files elsewhere, so its links stay links.
|
|
158
|
+
def add_refs(refs, found, ctx)
|
|
159
|
+
return unless refs
|
|
160
|
+
|
|
161
|
+
root = project_root(ctx)
|
|
162
|
+
return unless root
|
|
163
|
+
|
|
164
|
+
repos = checkout_repos(ctx)
|
|
165
|
+
found.each do |hit|
|
|
166
|
+
next unless repos.include?(hit[:repo].downcase)
|
|
167
|
+
|
|
168
|
+
action = hit[:diff] ? { id: "pr_diff", label: "PR diff", url: hit[:url] } : { id: "blob", label: "File at #{hit[:at]}", url: hit[:url] }
|
|
169
|
+
refs.call(match: hit[:match], kind: "file", label: nil, actions: [action],
|
|
170
|
+
target: { path: File.join(root, hit[:path]), line: hit[:line], end_line: hit[:end_line] })
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The GitHub "owner/repo"s (downcased) the checkout's git remotes name
|
|
175
|
+
# (`git remote -v`: a fork's upstream too), asked once per cwd.
|
|
176
|
+
def checkout_repos(ctx)
|
|
177
|
+
@checkout_repos[ctx.cwd] ||= run(ctx, "git", "remote", "-v").to_s.each_line.filter_map do |line|
|
|
178
|
+
match = line.split(/\s+/)[1].to_s.match(REMOTE_REPO)
|
|
179
|
+
"#{match[1]}/#{match[2]}".downcase if match
|
|
180
|
+
end.uniq
|
|
142
181
|
end
|
|
143
182
|
|
|
144
183
|
# Fetches the PRs in +prs+ whose cache entry is missing or old, on this
|
|
@@ -580,9 +619,17 @@ class LineLinker
|
|
|
580
619
|
nil
|
|
581
620
|
end
|
|
582
621
|
|
|
622
|
+
# The refs the last #link linked, in order: {match:, url:, diff:, at:,
|
|
623
|
+
# repo:, path:, line:, end_line:} (+match+ the ref's text, inline code's
|
|
624
|
+
# without its backticks; +diff+ true for a PR diff URL, else +at+ "head"
|
|
625
|
+
# or "base", the sha of the file's URL; +repo+ the PR's "owner/repo";
|
|
626
|
+
# +path+ the PR's path of the file).
|
|
627
|
+
attr_reader :found
|
|
628
|
+
|
|
583
629
|
# @param prs [Array<Array(PrRef, PrData)>]
|
|
584
630
|
def initialize(prs)
|
|
585
631
|
@prs = prs
|
|
632
|
+
@found = []
|
|
586
633
|
end
|
|
587
634
|
|
|
588
635
|
# +text+ with each ref that names exactly one PR's file as a markdown
|
|
@@ -591,8 +638,10 @@ class LineLinker
|
|
|
591
638
|
head = text[0, MAX_SCAN]
|
|
592
639
|
out = +""
|
|
593
640
|
pos = 0
|
|
594
|
-
|
|
595
|
-
|
|
641
|
+
@found = []
|
|
642
|
+
hits(head).each do |start, finish, hit|
|
|
643
|
+
out << head[pos...start] << "[#{head[start...finish]}](#{hit[:url]})"
|
|
644
|
+
@found << hit
|
|
596
645
|
pos = finish
|
|
597
646
|
end
|
|
598
647
|
return text if pos.zero?
|
|
@@ -602,7 +651,11 @@ class LineLinker
|
|
|
602
651
|
|
|
603
652
|
# The URL for +path+'s lines +from+..+to+ (+to+ nil: one line), or nil:
|
|
604
653
|
# no PR has the file, or more than one does.
|
|
605
|
-
def url_for(path, from, to = nil)
|
|
654
|
+
def url_for(path, from, to = nil) = line_for(path, from, to)&.fetch(:url)
|
|
655
|
+
|
|
656
|
+
# {url:, diff:, path:, line:, end_line:} for +path+'s lines (see #url_for),
|
|
657
|
+
# or nil.
|
|
658
|
+
def line_for(path, from, to = nil)
|
|
606
659
|
found = @prs.filter_map do |pr, data|
|
|
607
660
|
file = resolve(data.files, path)
|
|
608
661
|
[pr, data, file] if file
|
|
@@ -614,7 +667,7 @@ class LineLinker
|
|
|
614
667
|
|
|
615
668
|
private
|
|
616
669
|
|
|
617
|
-
# [start, finish,
|
|
670
|
+
# [start, finish, hit] for each ref to link, by offset (+hit+ as #found's).
|
|
618
671
|
def hits(text)
|
|
619
672
|
fences = fenced_blocks(text)
|
|
620
673
|
skipped = fences + spans(text, URL_SPAN) + spans(text, MARKDOWN_LINK)
|
|
@@ -624,7 +677,7 @@ class LineLinker
|
|
|
624
677
|
next if inside_any?(skipped, start, finish)
|
|
625
678
|
|
|
626
679
|
match = inner.strip.match(REF_ALONE) or next
|
|
627
|
-
|
|
680
|
+
hit = ref_line(match) and found << [start, finish, hit.merge(match: match[0])]
|
|
628
681
|
end
|
|
629
682
|
text.scan(REF) do
|
|
630
683
|
match = Regexp.last_match
|
|
@@ -632,20 +685,20 @@ class LineLinker
|
|
|
632
685
|
finish = match.end(0)
|
|
633
686
|
next if inside_any?(skipped, start, finish) || inside_any?(code, start, finish)
|
|
634
687
|
|
|
635
|
-
|
|
688
|
+
hit = ref_line(match) and found << [start, finish, hit.merge(match: match[0])]
|
|
636
689
|
end
|
|
637
690
|
found.sort_by(&:first)
|
|
638
691
|
end
|
|
639
692
|
|
|
640
693
|
# A path has a `/` or an extension (`10:30` is a time, `host:8080` a port).
|
|
641
|
-
def
|
|
694
|
+
def ref_line(match)
|
|
642
695
|
path = match[:path]
|
|
643
696
|
return nil unless path.include?("/") || path.match?(/\.[A-Za-z0-9]+\z/)
|
|
644
697
|
|
|
645
698
|
from = match[:from].to_i
|
|
646
699
|
to = match[:to]&.to_i
|
|
647
700
|
to = nil if to && to <= from
|
|
648
|
-
from.zero? ? nil :
|
|
701
|
+
from.zero? ? nil : line_for(path, from, to)
|
|
649
702
|
end
|
|
650
703
|
|
|
651
704
|
# The PR file +path+ names: the path (or a rename's old one) exactly,
|
|
@@ -687,18 +740,21 @@ class LineLinker
|
|
|
687
740
|
|
|
688
741
|
# The PR's Files changed anchor when the lines are inside one hunk (the
|
|
689
742
|
# new side; a removed file's old side), else the file at the PR's head
|
|
690
|
-
# (a removed one: at its base), which has every line.
|
|
743
|
+
# (a removed one: at its base), which has every line. As #line_for's
|
|
744
|
+
# hash.
|
|
691
745
|
def line_url(pr, data, file, from, to)
|
|
692
746
|
last = to || from
|
|
747
|
+
lines = { path: file.path, line: from, end_line: to, repo: "#{pr.owner}/#{pr.repo}" }
|
|
693
748
|
hunks, side = file.removed? ? [file.old_hunks, "L"] : [file.new_hunks, "R"]
|
|
694
749
|
if hunks.any? { |hunk| hunk.cover?(from) && hunk.cover?(last) }
|
|
695
750
|
anchor = "#{side}#{from}#{"-#{side}#{to}" if to}"
|
|
696
|
-
return "#{pr.url}/files#diff-#{Digest::SHA256.hexdigest(file.path)}#{anchor}"
|
|
751
|
+
return { url: "#{pr.url}/files#diff-#{Digest::SHA256.hexdigest(file.path)}#{anchor}", diff: true, at: nil, **lines }
|
|
697
752
|
end
|
|
698
753
|
|
|
699
754
|
sha = file.removed? ? data.base_sha : data.head_sha
|
|
700
755
|
path = file.path.split("/").map { |segment| segment.gsub(/[^\w.~-]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join } }
|
|
701
|
-
"https://github.com/#{pr.owner}/#{pr.repo}/blob/#{sha}/#{path.join("/")}#L#{from}#{"-L#{to}" if to}"
|
|
756
|
+
{ url: "https://github.com/#{pr.owner}/#{pr.repo}/blob/#{sha}/#{path.join("/")}#L#{from}#{"-L#{to}" if to}", diff: false,
|
|
757
|
+
at: file.removed? ? "base" : "head", **lines }
|
|
702
758
|
end
|
|
703
759
|
|
|
704
760
|
# [start, finish, inner] for each inline code span outside +fences+: a
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: loop-guard
|
|
3
|
-
version: 0.3.
|
|
3
|
+
version: 0.3.8
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Breaks loops: the same tool call with the same result again and again in one turn is denied with advice (after a few denies the turn stops with a card), and thinking that repeats itself while it streams is cut and the model asked again"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:334c45355493b35904a27a61d8cd12e4cce157fa0eff233918c92cf2961cba57
|
|
10
10
|
requires_chi: ">= 0.6.0"
|
|
@@ -297,7 +297,7 @@ class Plugin
|
|
|
297
297
|
def register(chi)
|
|
298
298
|
chi.on(:before_turn) { |_event| reset }
|
|
299
299
|
chi.on(:before_tool_call) { |event, ctx| before(event, ctx) }
|
|
300
|
-
chi.on(:after_tool_call) { |event| after(event) }
|
|
300
|
+
chi.on(:after_tool_call) { |event, ctx| after(event, ctx) }
|
|
301
301
|
return unless @thinking["watch"]
|
|
302
302
|
|
|
303
303
|
chi.on(:before_generation) do |_event|
|
|
@@ -377,6 +377,7 @@ class Plugin
|
|
|
377
377
|
@warned = {} # key => true once notified this turn
|
|
378
378
|
@denials = 0
|
|
379
379
|
@pending = nil
|
|
380
|
+
@stop_card = nil # the stop's card, posted after the denied call
|
|
380
381
|
@thinking_loops = 0
|
|
381
382
|
@good_steps = 0
|
|
382
383
|
@watch = nil
|
|
@@ -412,7 +413,8 @@ class Plugin
|
|
|
412
413
|
# A call's result, paired with the before_tool_call that keyed it. A call
|
|
413
414
|
# that was denied (by us or another voter) ran nothing: no result, or the
|
|
414
415
|
# deny text would become the key's result and the next call would pass.
|
|
415
|
-
def after(event)
|
|
416
|
+
def after(event, ctx)
|
|
417
|
+
post_stop_card(ctx)
|
|
416
418
|
pending = @pending
|
|
417
419
|
@pending = nil
|
|
418
420
|
return unless pending && event[:tool].to_s == pending[:key].first
|
|
@@ -426,16 +428,25 @@ class Plugin
|
|
|
426
428
|
@previews[key] = preview(output)
|
|
427
429
|
end
|
|
428
430
|
|
|
431
|
+
# The turn stops now; its card waits for the denied call's
|
|
432
|
+
# after_tool_call (#post_stop_card), so a UI that prints events in order
|
|
433
|
+
# shows it under that call's row, not before it.
|
|
429
434
|
def stop(ctx)
|
|
430
435
|
ctx.stop_turn("the model kept repeating the same calls")
|
|
431
436
|
repeated = @attempts.select { |key, n| n > 1 && @last_result[key] && @results[[key, @last_result[key]]] >= @deny_after }
|
|
432
437
|
lines = repeated.map do |key, n|
|
|
433
438
|
"- `#{short_key(key)}`: #{n} times, the same result each time (#{@previews[key]})"
|
|
434
439
|
end
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
440
|
+
@stop_card = { title: "loop-guard stopped the turn",
|
|
441
|
+
body: "The model kept repeating these calls:\n\n#{lines.join("\n")}\n\n" \
|
|
442
|
+
"Tell it what to try instead, or where to look.",
|
|
443
|
+
level: :warn }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def post_stop_card(ctx)
|
|
447
|
+
card = @stop_card
|
|
448
|
+
@stop_card = nil
|
|
449
|
+
ctx.card(**card) if card
|
|
439
450
|
end
|
|
440
451
|
|
|
441
452
|
def warn(ctx, key, what)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: slow-commands
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Remembers commands that ran past the execute limit in a project; the next identical execute is denied once a turn with advice to run it with task_create and task_wait; /slow-commands lists and forgets them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:cc8b9829ae77565d8d47969377e91124cf4c785164e417d3d323daeec1295269
|
|
10
10
|
# Execute::HANDED_OVER, read when the plugin loads (0.59.0); after_tool_call's
|
|
11
11
|
# call:, elapsed_ms: and result: (0.57.0)
|
|
12
12
|
requires_chi: ">= 0.59.0"
|
|
@@ -75,7 +75,11 @@ class Plugin
|
|
|
75
75
|
TIMED_OUT = /\A#{Regexp.escape(Samagotchi::Tools::Execute::TIMEOUT_PREFIX)} (\d+)s/
|
|
76
76
|
# Still running at the limit, handed over as a task (chi >= 0.59.0).
|
|
77
77
|
HANDED_OVER = /\A#{Regexp.escape(Samagotchi::Tools::Execute::HANDED_OVER)} (\d+)s limit/
|
|
78
|
-
|
|
78
|
+
# A stopped command's first words: the user's Stop, or a signal to chi
|
|
79
|
+
# (chi >= 0.62.0; an older chi has no such text).
|
|
80
|
+
STOPPED = %i[STOPPED_BY_USER NOT_RUN_ON_STOP STOPPED_BY_SIGNAL NOT_RUN_ON_SIGNAL].filter_map do |name|
|
|
81
|
+
Samagotchi::Tools::Execute.const_get(name) if Samagotchi::Tools::Execute.const_defined?(name)
|
|
82
|
+
end.freeze
|
|
79
83
|
LEX = Samagotchi::Guardrails::ShellLex
|
|
80
84
|
SHORT_CHARS = 60
|
|
81
85
|
WAIT_DEFAULT = 600
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Loaded through module_eval: it requires what it uses.
|
|
4
4
|
require "open3"
|
|
5
|
+
require "samagotchi/ref"
|
|
5
6
|
|
|
6
7
|
# An after_turn hook that links the source refs the model's answer
|
|
7
8
|
# mentions — a JIRA ticket, a GitHub issue, an internal wiki page. In the
|
|
@@ -27,6 +28,8 @@ require "open3"
|
|
|
27
28
|
# pattern: '\bGH-(\d+)\b' # full form: a regex
|
|
28
29
|
# url: 'https://github.com/org/repo/issues/{match}'
|
|
29
30
|
# case_insensitive: false # optional, default false
|
|
31
|
+
# kind: issue # optional: the refs' kind (below),
|
|
32
|
+
# # default issue
|
|
30
33
|
# - name: Issues # `#12` → this project's repo,
|
|
31
34
|
# # `owner/repo#12` → that repo
|
|
32
35
|
# pattern: '(?<![\w/&])(?:(?<repo>[A-Za-z0-9][\w-]*/[\w.-]*\w))?#(?<num>\d+)\b'
|
|
@@ -49,6 +52,12 @@ require "open3"
|
|
|
49
52
|
# placeholder that can't be filled is not linked; a `{word}` that is none
|
|
50
53
|
# of these stays text (warned at load).
|
|
51
54
|
#
|
|
55
|
+
# Each linked ref is also a ref on the answer (event[:refs], chi 0.61.0):
|
|
56
|
+
# its source's `kind:` (a short lowercase word, default "issue"; not
|
|
57
|
+
# "file"), target {id: the ref's text}, label "<name> <ref>", and one
|
|
58
|
+
# action, "Open in <name>", the ref's URL. A URL that isn't http(s) stays a
|
|
59
|
+
# markdown link only. The web's ref menu shows the action.
|
|
60
|
+
#
|
|
52
61
|
# The note is not stored in the conversation: it is an event, replayed by a
|
|
53
62
|
# UI with the session's cards (a reload keeps it, after the worker stopped
|
|
54
63
|
# too: cards.json). The links are stored as the answer's display. A ref in
|
|
@@ -75,6 +84,8 @@ class SourceLinks
|
|
|
75
84
|
# Placeholders every pattern has: {match} (group 1, else the whole ref),
|
|
76
85
|
# and {repo}/{host} (a named group, else the project's git remote).
|
|
77
86
|
BUILTIN_PLACEHOLDERS = %w[match repo host].freeze
|
|
87
|
+
# A source's refs' kind, unless its `kind:` says another.
|
|
88
|
+
DEFAULT_KIND = "issue"
|
|
78
89
|
# `git remote get-url` output: a URL with a scheme, `[user[:pw]@]host[:port]/path`.
|
|
79
90
|
REMOTE_URL = %r{\A(?:https?|ssh|git)://(?:[^/]*@)?(\[[^\]]*\]|[^/:@]+)(?::[^/]*)?(/.*)?\z}i
|
|
80
91
|
# scp-like `[user@]host:path`: a colon before any slash; a leading `/` or
|
|
@@ -117,6 +128,7 @@ class SourceLinks
|
|
|
117
128
|
|
|
118
129
|
hits = occurrences(text[0, MAX_SCAN])
|
|
119
130
|
linked = present_links(event[:present], text, hits)
|
|
131
|
+
add_refs(event[:refs], hits)
|
|
120
132
|
return unless @note
|
|
121
133
|
|
|
122
134
|
found = collect(hits)
|
|
@@ -144,6 +156,33 @@ class SourceLinks
|
|
|
144
156
|
ours.nil? || shown != ours ? nil : linked
|
|
145
157
|
end
|
|
146
158
|
|
|
159
|
+
# Each ref the answer links (#link's rule: not in code, the first source
|
|
160
|
+
# on a ref) as a ref on the answer, once per text and URL. Independent of
|
|
161
|
+
# the display (chi pairs it with the shown link by its URL). A URL that
|
|
162
|
+
# isn't http(s) can't be an action: that ref is left out.
|
|
163
|
+
def add_refs(refs, hits)
|
|
164
|
+
return unless refs
|
|
165
|
+
|
|
166
|
+
seen = {}
|
|
167
|
+
pos = 0
|
|
168
|
+
hits.each do |hit|
|
|
169
|
+
next if hit[:quiet] || hit[:start] < pos
|
|
170
|
+
|
|
171
|
+
pos = hit[:finish]
|
|
172
|
+
key = [hit[:ref], hit[:url]]
|
|
173
|
+
next if seen.key?(key) || !Samagotchi::RefAction.http_url?(hit[:url])
|
|
174
|
+
|
|
175
|
+
seen[key] = true
|
|
176
|
+
refs.call(match: hit[:ref], kind: hit[:kind], target: { id: hit[:ref] },
|
|
177
|
+
label: fit("#{hit[:name]} #{hit[:ref]}", Samagotchi::Ref::MAX_LABEL),
|
|
178
|
+
actions: [{ id: "open", label: fit("Open in #{hit[:name]}", Samagotchi::RefAction::MAX_LABEL), url: hit[:url] }])
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# +text+ cut to +max+ characters with a "…", so a long source name can't
|
|
183
|
+
# get its refs refused.
|
|
184
|
+
def fit(text, max) = text.length > max ? "#{text[0, max - 1]}…" : text
|
|
185
|
+
|
|
147
186
|
# The content of the last `role: "model"` message, or nil. Handles both
|
|
148
187
|
# string- and symbol-keyed messages. A turn that ended without a visible
|
|
149
188
|
# answer stores a `kind: turn_note` system message instead, so the role
|
|
@@ -188,7 +227,8 @@ class SourceLinks
|
|
|
188
227
|
next if url.nil?
|
|
189
228
|
|
|
190
229
|
quiet = inside_any?(code, start, finish) || inside_any?(labels, start, finish)
|
|
191
|
-
hits << { start: start, finish: finish, name: source[:name], ref: match[0], url: url,
|
|
230
|
+
hits << { start: start, finish: finish, name: source[:name], kind: source[:kind], ref: match[0], url: url,
|
|
231
|
+
quiet: quiet }
|
|
192
232
|
end
|
|
193
233
|
rescue Regexp::TimeoutError
|
|
194
234
|
Samagotchi::Log.warn(:hooks, "source_links_timeout",
|
|
@@ -407,7 +447,7 @@ class SourceLinks
|
|
|
407
447
|
name = prefix if name.empty?
|
|
408
448
|
regex = Regexp.new("\\b#{Regexp.escape(prefix)}-(\\d+)\\b", flags, timeout: REGEX_TIMEOUT)
|
|
409
449
|
base = entry["base_url"].to_s
|
|
410
|
-
{ name: name, regex: regex, url: ->(ref, _match) { "#{base}#{ref}" } }
|
|
450
|
+
{ name: name, kind: ref_kind(name, entry["kind"]), regex: regex, url: ->(ref, _match) { "#{base}#{ref}" } }
|
|
411
451
|
elsif !pattern.empty?
|
|
412
452
|
name = "source" if name.empty?
|
|
413
453
|
regex = Regexp.new(pattern, flags, timeout: REGEX_TIMEOUT)
|
|
@@ -416,7 +456,7 @@ class SourceLinks
|
|
|
416
456
|
remote = entry["remote"].to_s.strip
|
|
417
457
|
remote = "origin" if remote.empty?
|
|
418
458
|
hosts = Array(entry["remote_host"]).map { |host| host.to_s.strip.downcase }.reject(&:empty?)
|
|
419
|
-
source = { name: name, remote: remote, remote_hosts: hosts }
|
|
459
|
+
source = { name: name, kind: ref_kind(name, entry["kind"]), remote: remote, remote_hosts: hosts }
|
|
420
460
|
source.merge(regex: regex, url: ->(ref, match) { render_url(template, known, match, ref, source) })
|
|
421
461
|
else
|
|
422
462
|
warn_invalid("a source needs a prefix: or a pattern:")
|
|
@@ -427,6 +467,21 @@ class SourceLinks
|
|
|
427
467
|
nil
|
|
428
468
|
end
|
|
429
469
|
|
|
470
|
+
# A source's `kind:` for its refs: a short lowercase word other than
|
|
471
|
+
# "file" (a file ref needs a path), else DEFAULT_KIND, with a warning
|
|
472
|
+
# when one was given.
|
|
473
|
+
def ref_kind(name, raw)
|
|
474
|
+
return DEFAULT_KIND if raw.nil? || raw.to_s.strip.empty?
|
|
475
|
+
|
|
476
|
+
kind = raw.to_s.strip
|
|
477
|
+
return kind if Samagotchi::Ref::KIND.match?(kind) && kind != Samagotchi::Ref::FILE
|
|
478
|
+
|
|
479
|
+
Samagotchi::Log.warn(:hooks, "source_links_invalid_kind",
|
|
480
|
+
echo: "[samagotchi:hooks] source-links: #{name}: kind #{kind.inspect} is not a short " \
|
|
481
|
+
"lowercase word (nor file); using #{DEFAULT_KIND}")
|
|
482
|
+
DEFAULT_KIND
|
|
483
|
+
end
|
|
484
|
+
|
|
430
485
|
# The placeholders of +template+ this pattern can fill: {match}, {repo},
|
|
431
486
|
# {host}, its named groups and {1}…{N} for its N groups. Any other
|
|
432
487
|
# `{word}` stays as text, with one warning here, at compile time.
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: source-links
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.5.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
|
-
requires_chi: ">= 0.
|
|
7
|
+
requires_chi: ">= 0.61.0"
|
|
8
8
|
files:
|
|
9
|
-
source_links.md: sha256:
|
|
9
|
+
source_links.md: sha256:ace01a0f7c308a0ae54ece714c31c0e6d7a49c372d98111030e6d268320bed3a
|
|
10
10
|
hooks:
|
|
11
11
|
source_links.rb:
|
|
12
|
-
sha256: sha256:
|
|
12
|
+
sha256: sha256:8d84c68be88972737905925bc9fc4bd941007fb945e8104e9f6b3d61a1186b89
|
|
13
13
|
event: after_turn
|
|
14
14
|
on_error: log
|
|
15
15
|
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 (the terminals only see this line). The web leaves it out when markdown is on and the answer links every ref it names; otherwise every UI shows it. 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 with the session's cards (a page reload keeps it, after the worker stopped too).
|
|
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). 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.
|
|
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 `kind:` per source (what the web's ref menu calls its refs, default `issue`), 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.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.63.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:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
|
|
9
9
|
config_modification_protocol.md: sha256:16403da5dd97a335e19d9d15bb1ba4390237bf9f4869e35d63c172a8828b5ae0
|
|
10
|
-
memory_guide.md: sha256:
|
|
10
|
+
memory_guide.md: sha256:b0a1ba42c5334d4117b5733c8c26d7477364baca9169b0c0a711de6ac7fc7f3c
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
|
@@ -94,7 +94,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
|
|
|
94
94
|
- Integrity: each file's sha256 is recorded at install; a hook/plugin/rule file changed afterwards is not loaded (rules: every call denied) until reinstalled. It is an integrity check, not proof of authorship.
|
|
95
95
|
- Installing = trusting its Ruby code (hooks, plugin), like a gem. Install only copies; the code runs at the next session start (`Engine.new`), so a running worker needs a restart to pick it up.
|
|
96
96
|
- A bundle without `*.md` (btw, mcp, loop-guard) adds no line to the prompt's memory index.
|
|
97
|
-
- Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
|
|
97
|
+
- Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
|
|
98
98
|
|
|
99
99
|
### CLI — `chi bundle`
|
|
100
100
|
|
|
@@ -16,8 +16,9 @@ module Samagotchi
|
|
|
16
16
|
class ContextAbsorber
|
|
17
17
|
# +note+: Engine#add_context_note's hash; +subscription+: the session's
|
|
18
18
|
# Subscription after it (nil: dropped); +wake_note+: the same note with
|
|
19
|
-
# the wake line, for an update whose source asked to wake
|
|
20
|
-
# may start a turn with
|
|
19
|
+
# the wake line, for an update whose source asked to wake or a session
|
|
20
|
+
# push source's first text that asked (the worker may start a turn with
|
|
21
|
+
# it), else nil.
|
|
21
22
|
Delivery = Data.define(:name, :note, :subscription, :wake_note) do
|
|
22
23
|
def initialize(name:, note:, subscription:, wake_note: nil) = super
|
|
23
24
|
end
|
|
@@ -93,9 +94,12 @@ module Samagotchi
|
|
|
93
94
|
"(third-party text, not your user's words):\n> #{quoted}\n#{last_line}"
|
|
94
95
|
end
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
# The attached note's text; a wake turn's (+wake+) ends with the wake
|
|
98
|
+
# line in place of the background line.
|
|
99
|
+
def self.attached_text(name:, hint:, why:, summary:, wake: false)
|
|
97
100
|
why_part = why ? " Why: #{why}#{"." unless why.match?(/[.!?]\z/)}" : ""
|
|
98
|
-
|
|
101
|
+
last_line = wake ? WAKE_LAST_LINE : "#{BACKGROUND_LINE} #{read_line(name)}"
|
|
102
|
+
"Attached: #{name}#{" (#{hint})" if hint}.#{why_part}\nSummary: #{summary}\n#{last_line}"
|
|
99
103
|
end
|
|
100
104
|
|
|
101
105
|
def self.read_line(name) = "Read it with context_read(name: \"#{name}\") when your user's request is about it."
|
|
@@ -105,6 +109,8 @@ module Samagotchi
|
|
|
105
109
|
# A source the session sees: attached when it has text and was never
|
|
106
110
|
# noted; updated for a new revision; an error once per run of failures
|
|
107
111
|
# after a success. A source with no text yet waits (nothing to read).
|
|
112
|
+
# An update that asked to wake carries a wake note, and so does a first
|
|
113
|
+
# text that asked when #first_text_wakes?.
|
|
108
114
|
def delivery_for(attached, sub, now)
|
|
109
115
|
snapshot = attached.snapshot
|
|
110
116
|
return nil unless snapshot.text?
|
|
@@ -112,8 +118,11 @@ module Samagotchi
|
|
|
112
118
|
name = attached.name
|
|
113
119
|
hint = snapshot.hint || attached.source.hint
|
|
114
120
|
if sub.seen.nil?
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
args = { name: name, hint: hint, why: attached.source.why, summary: snapshot.summary }
|
|
122
|
+
found = delivery(name, text_note_id(name, snapshot), self.class.attached_text(**args), now, seen(sub, snapshot))
|
|
123
|
+
return found unless first_text_wakes?(attached, sub)
|
|
124
|
+
|
|
125
|
+
return found.with(wake_note: found.note.merge(text: self.class.attached_text(**args, wake: true)))
|
|
117
126
|
end
|
|
118
127
|
if sub.seen != snapshot.revision
|
|
119
128
|
args = { name: name, hint: hint, summary: snapshot.summary, changes: snapshot.serial - sub.seen_serial }
|
|
@@ -131,6 +140,16 @@ module Samagotchi
|
|
|
131
140
|
sub.with(error_seen: snapshot.error_since))
|
|
132
141
|
end
|
|
133
142
|
|
|
143
|
+
# A first text may wake only for a session's pushed source (`chi context
|
|
144
|
+
# add NAME --push`, attached empty) that asked. Not for a command source
|
|
145
|
+
# (a URL/provider source has a cmd too): its first fetch runs as it is
|
|
146
|
+
# attached, while the user is likely looking at the session. Not for a
|
|
147
|
+
# project source: its first text may predate this session. A blank
|
|
148
|
+
# Subscription has seen_serial 0, so any revision that asked counts.
|
|
149
|
+
def first_text_wakes?(attached, sub)
|
|
150
|
+
attached.source.push? && attached.location.session? && attached.snapshot.wake_serial.to_i > sub.seen_serial
|
|
151
|
+
end
|
|
152
|
+
|
|
134
153
|
# A subscription whose source is gone: detached when the session was
|
|
135
154
|
# told of it, dropped quietly otherwise.
|
|
136
155
|
def detached(sub, now)
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -288,6 +288,9 @@ module Samagotchi
|
|
|
288
288
|
self.guardrail_state_dir = Session.default_state_dir
|
|
289
289
|
# list_sessions and send_note speak for whichever session runs now.
|
|
290
290
|
@kernel.peers = PeerView.new(self)
|
|
291
|
+
# A plugin tool's row refs resolve against the session's folder, as
|
|
292
|
+
# answer refs do (AnswerRefs), not the process's.
|
|
293
|
+
@kernel.session_cwd = -> { @session&.working_directory }
|
|
291
294
|
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
292
295
|
# The turn-end warm-up; the kernel asks it for a slot pin per request.
|
|
293
296
|
@prompt_warmup = PromptWarmup.new
|
|
@@ -1340,6 +1343,12 @@ module Samagotchi
|
|
|
1340
1343
|
|
|
1341
1344
|
def session_state_dir = @session_state_dir || Session.default_state_dir
|
|
1342
1345
|
|
|
1346
|
+
# The running turn's metrics so far, saved as a turn a worker died in
|
|
1347
|
+
# would leave them (SessionMetrics#persist's running_turn): a worker's
|
|
1348
|
+
# turn checkpoint (TurnCheckpoint) calls it when it writes.
|
|
1349
|
+
# @return [Boolean] whether they were saved
|
|
1350
|
+
def checkpoint_metrics = @metrics.persist(state_dir: session_state_dir, running_turn: true)
|
|
1351
|
+
|
|
1343
1352
|
# What a Bridge adds to a plugin's ctx.messages while a turn runs (the
|
|
1344
1353
|
# turn so far, as messages); nil without a Bridge (the REPL).
|
|
1345
1354
|
attr_writer :running_turn_messages
|
|
@@ -15,6 +15,7 @@ module Samagotchi
|
|
|
15
15
|
def project_root = engine.session&.project_root
|
|
16
16
|
def state_dir = engine.peer_state_dir
|
|
17
17
|
def cancelled? = !!engine.active_cancel_controller&.cancelled?
|
|
18
|
+
def cancel_reason = engine.active_cancel_controller&.reason
|
|
18
19
|
def relay = engine.relay_peer
|
|
19
20
|
def model_ref = engine.effective_model_ref
|
|
20
21
|
def thinking = engine.thinking_override
|
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
|
|
8
8
|
require_relative "idle_client"
|
|
9
9
|
require_relative "idle_target"
|
|
10
|
+
require_relative "image_store"
|
|
10
11
|
require_relative "output_formatter"
|
|
11
12
|
require_relative "steer"
|
|
12
13
|
require_relative "recap_store"
|
|
@@ -53,8 +54,6 @@ module Samagotchi
|
|
|
53
54
|
# separator, while the chat loop writes one message per call.
|
|
54
55
|
TOOL_OUTPUT_SEPARATOR = "\n\n---\n\n"
|
|
55
56
|
TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
|
|
56
|
-
# The text the web sends for a turn of images alone (images.js turnText).
|
|
57
|
-
IMAGE_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
|
|
58
57
|
DELEGATE_REPORT_LINE = "(a delegate session's report, delivered by chi; not the user's words)"
|
|
59
58
|
|
|
60
59
|
module_function
|
|
@@ -76,7 +75,7 @@ module Samagotchi
|
|
|
76
75
|
# A delegate child's report (ChildReports) is chi's news, not
|
|
77
76
|
# what the user said.
|
|
78
77
|
content = "#{DELEGATE_REPORT_LINE}\n#{content}" if message["source"].to_s == Steer::DELEGATE_REPORT
|
|
79
|
-
images =
|
|
78
|
+
images = ImageRef.placeholder_only?(content) ? [] : image_lines(message["images"])
|
|
80
79
|
[content, *images].reject(&:empty?).join("\n")
|
|
81
80
|
when "model", "assistant"
|
|
82
81
|
strip_thought(message["content"].to_s.gsub(TOOL_CALL_RE, ""))
|
|
@@ -159,6 +159,14 @@ module Samagotchi
|
|
|
159
159
|
# One-line texts about an image ref ({file:, mime:, width:, height:,
|
|
160
160
|
# bytes:, name:, source:}).
|
|
161
161
|
module ImageRef
|
|
162
|
+
# The text the web sends for a turn of images alone (images.js
|
|
163
|
+
# turnText): "[image: name]" per image, nothing else.
|
|
164
|
+
PLACEHOLDER_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
|
|
165
|
+
|
|
166
|
+
# Whether +text+ is only that placeholder: the images' own lines would
|
|
167
|
+
# name them a second time.
|
|
168
|
+
def self.placeholder_only?(text) = PLACEHOLDER_ONLY_RE.match?(text.to_s)
|
|
169
|
+
|
|
162
170
|
# Claude's rule of thumb (28×28 px per token). About 30% over on the
|
|
163
171
|
# local Qwen, close enough for a label and the context estimate.
|
|
164
172
|
def self.estimated_tokens(ref)
|