samagotchi 0.61.0 → 0.62.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 +59 -1
- data/docs/architecture.md +3 -0
- data/docs/cli.md +22 -8
- data/docs/configuration.md +32 -19
- data/docs/context.md +8 -0
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +8 -0
- data/docs/internals/engine.md +24 -11
- data/docs/internals/prompt-caching.md +6 -1
- data/docs/plugins.md +42 -3
- data/docs/sessions.md +4 -4
- data/docs/testing.md +4 -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/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 +1 -1
- data/lib/samagotchi/engine.rb +9 -0
- data/lib/samagotchi/engine_peers.rb +1 -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/reasoning_history.rb +22 -4
- data/lib/samagotchi/row_refs.rb +126 -0
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +26 -3
- 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 +15 -3
- 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 +14 -1
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +4 -1
- data/lib/samagotchi/web/public/app.js +59 -15
- data/lib/samagotchi/web/public/chains.js +18 -20
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/families_view.js +51 -39
- 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 +60 -5
- data/lib/samagotchi/web/public/ref_menu.js +18 -5
- data/lib/samagotchi/web/public/refs.js +113 -15
- 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 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -4
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +104 -10
- data/lib/samagotchi/worker.rb +3 -2
- data/lib/samagotchi/worker_inbound.rb +5 -3
- metadata +3 -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,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.
|
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
|
|
@@ -18,6 +18,7 @@ require_relative "thinking"
|
|
|
18
18
|
require_relative "hooks"
|
|
19
19
|
require_relative "pending_input_queue"
|
|
20
20
|
require_relative "steer"
|
|
21
|
+
require_relative "reasoning_history"
|
|
21
22
|
require_relative "thought_stream_splitter"
|
|
22
23
|
require_relative "tools/builtins"
|
|
23
24
|
require_relative "muted_memories"
|
|
@@ -172,6 +173,10 @@ module Samagotchi
|
|
|
172
173
|
# too); the thinking level is its own field, since the native path
|
|
173
174
|
# sends the sampling as is.
|
|
174
175
|
attr_accessor :turn_settings
|
|
176
|
+
# The session's working directory (a callable → String or nil), which
|
|
177
|
+
# a plugin tool's row refs resolve their files against (ToolRunner,
|
|
178
|
+
# RowRefs); the Engine sets it. nil: the process's own (Dir.pwd).
|
|
179
|
+
attr_accessor :session_cwd
|
|
175
180
|
# Tools::Peers (or the Engine's live view of it): the session
|
|
176
181
|
# list_sessions and send_note speak for; nil outside a session.
|
|
177
182
|
attr_accessor :peers
|
|
@@ -291,7 +296,7 @@ module Samagotchi
|
|
|
291
296
|
private_constant :Turn, :Request, :Generation
|
|
292
297
|
|
|
293
298
|
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
|
|
294
|
-
conversation = prepare_conversation(messages, keep_thought_from:
|
|
299
|
+
conversation = prepare_conversation(messages, keep_thought_from: ReasoningHistory.run_start(messages, reasoning_since))
|
|
295
300
|
context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
|
|
296
301
|
emit = ->(event) { emit_stream_event(on_stream_event, event) }
|
|
297
302
|
@llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
|
|
@@ -1017,24 +1022,10 @@ module Samagotchi
|
|
|
1017
1022
|
end
|
|
1018
1023
|
|
|
1019
1024
|
# Standard multi-turn compliance: never pass prior raw thought blocks,
|
|
1020
|
-
# but a resumed run's (+keep_thought_from+,
|
|
1025
|
+
# but a resumed run's (+keep_thought_from+, ReasoningHistory.run_start): the same
|
|
1021
1026
|
# run going on after a step-limit Continue.
|
|
1022
1027
|
def prepare_conversation(messages, keep_thought_from: nil) = sanitize_history(messages, keep_thought_from: keep_thought_from)
|
|
1023
1028
|
|
|
1024
|
-
# Where a resumed run's thought blocks start: its start (+since+). Inside
|
|
1025
|
-
# one native run nothing re-sanitizes, so a user entry the run merged (a
|
|
1026
|
-
# steer, a delegate report, the Qwen recovery prompt) left the thoughts
|
|
1027
|
-
# before it in the prompt: they stay. Only a user or turn_start entry
|
|
1028
|
-
# added after the run ended (after its last model message: a !cmd's
|
|
1029
|
-
# output) starts them again past itself. nil without +since+.
|
|
1030
|
-
def thought_floor(messages, since)
|
|
1031
|
-
return nil if since.nil?
|
|
1032
|
-
|
|
1033
|
-
last_reply = messages.rindex { |m| m[:role].to_s == "model" } || -1
|
|
1034
|
-
query = messages.rindex { |m| m[:role].to_s == "user" || m[:turn_start] == true }
|
|
1035
|
-
query && query > last_reply ? [query + 1, since].max : since
|
|
1036
|
-
end
|
|
1037
|
-
|
|
1038
1029
|
def duplicate_conversation(messages)
|
|
1039
1030
|
messages.map(&:dup)
|
|
1040
1031
|
end
|
|
@@ -24,6 +24,7 @@ require_relative "../vision_context"
|
|
|
24
24
|
require_relative "../log"
|
|
25
25
|
require_relative "../empty_answer_retry"
|
|
26
26
|
require_relative "../steer"
|
|
27
|
+
require_relative "../reasoning_history"
|
|
27
28
|
require_relative "../turn_note"
|
|
28
29
|
require_relative "../thinking"
|
|
29
30
|
|
|
@@ -70,7 +71,7 @@ module Samagotchi
|
|
|
70
71
|
def provider = :chat
|
|
71
72
|
|
|
72
73
|
# +reasoning_since+ (ModelBackend#complete): the resumed run's start,
|
|
73
|
-
# the Run's own start then (#reasoning_boundary).
|
|
74
|
+
# the Run's own start then (Run#initialize, #reasoning_boundary).
|
|
74
75
|
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
75
76
|
model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
|
|
76
77
|
raise ArgumentError, "ChatLoop has no adapter" unless @adapter
|
|
@@ -137,7 +138,8 @@ module Samagotchi
|
|
|
137
138
|
# A model turn carries its saved reasoning as reasoning_content when
|
|
138
139
|
# +reasoning+ (a ReasoningHistory level; the turn's by default) says
|
|
139
140
|
# so: under turn the running turn's only (#reasoning_boundary, with
|
|
140
|
-
# +since+ the Run's
|
|
141
|
+
# +since+ where the Run's reasoning starts; a steer it merged doesn't
|
|
142
|
+
# move that), under all every one.
|
|
141
143
|
#
|
|
142
144
|
# The conversation goes through the kernel's LLMContextView first,
|
|
143
145
|
# as the native prompt does (it keeps every entry in place).
|
|
@@ -194,19 +196,24 @@ module Samagotchi
|
|
|
194
196
|
TOOL_IMAGES_TEXT = "[images from tool results]"
|
|
195
197
|
|
|
196
198
|
# The first index whose model entries carry their reasoning (nil:
|
|
197
|
-
# none do) under +level+. Under turn:
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
#
|
|
199
|
+
# none do) under +level+. Under turn: +since+ when given, where a
|
|
200
|
+
# Run's reasoning starts (ReasoningHistory.run_start, the native
|
|
201
|
+
# loop's rule: an entry the run merged -- a person's line, a plugin's
|
|
202
|
+
# steer, a Continue's text -- keeps the reasoning before it, so the
|
|
203
|
+
# request after it extends the one before; a continue turn -- a
|
|
204
|
+
# reminder, a context wake, a delegate report's wake -- starts a Run
|
|
205
|
+
# of its own and sends none of the run before it). Without +since+,
|
|
206
|
+
# past the last query (a user message or an entry that starts a turn,
|
|
207
|
+
# turn_start).
|
|
203
208
|
# @return [Integer, nil]
|
|
204
209
|
def reasoning_boundary(conversation, since: nil, level: reasoning_history)
|
|
205
210
|
case level
|
|
206
211
|
when :all then 0
|
|
207
212
|
when :turn
|
|
213
|
+
return since if since
|
|
214
|
+
|
|
208
215
|
query = conversation.rindex { |entry| entry[:role].to_s == "user" || entry[:turn_start] == true }
|
|
209
|
-
|
|
216
|
+
query ? query + 1 : 0
|
|
210
217
|
end
|
|
211
218
|
end
|
|
212
219
|
|
|
@@ -417,9 +424,10 @@ module Samagotchi
|
|
|
417
424
|
@cancel_controller = cancel_controller
|
|
418
425
|
@model_name = model_name
|
|
419
426
|
@pending_input = pending_input
|
|
420
|
-
# Where
|
|
421
|
-
# (ChatLoop#reasoning_boundary)
|
|
422
|
-
|
|
427
|
+
# Where the reasoning this run sends back starts
|
|
428
|
+
# (ChatLoop#reasoning_boundary): its own replies', a resumed run's
|
|
429
|
+
# too, fixed for the run; a steer it merges doesn't move it.
|
|
430
|
+
@reasoning_start = ReasoningHistory.run_start(conversation, start || conversation.length)
|
|
423
431
|
@tool_activity = []
|
|
424
432
|
@empty_retry = EmptyAnswerRetry.new
|
|
425
433
|
# The empty generations, never saved: the UIs draw the turn's
|
|
@@ -596,7 +604,7 @@ module Samagotchi
|
|
|
596
604
|
|
|
597
605
|
def request(iteration, retry_generation, streamed, thought, generation_controller, streamed_any = nil)
|
|
598
606
|
@loop.adapter.chat(
|
|
599
|
-
messages: @loop.wire_messages(@conversation, since: @
|
|
607
|
+
messages: @loop.wire_messages(@conversation, since: @reasoning_start, reasoning: reasoning_level), tools: @loop.tool_definitions, model: @model_name,
|
|
600
608
|
cancel_controller: generation_controller || @cancel_controller, session_id: @loop.session_id,
|
|
601
609
|
options: @loop.request_options(retry_generation: retry_generation, model: @model_name),
|
|
602
610
|
on_delta: lambda { |content:, reasoning:, payload:|
|
|
@@ -670,7 +678,7 @@ module Samagotchi
|
|
|
670
678
|
|
|
671
679
|
# Whether this step's request sends reasoning back.
|
|
672
680
|
def reasoning_sent?
|
|
673
|
-
!@without_reasoning && @loop.reasoning_chars(@conversation, since: @
|
|
681
|
+
!@without_reasoning && @loop.reasoning_chars(@conversation, since: @reasoning_start, reasoning: reasoning_level).positive?
|
|
674
682
|
end
|
|
675
683
|
|
|
676
684
|
# The level this step's request sends reasoning back under: none
|
|
@@ -733,7 +741,7 @@ module Samagotchi
|
|
|
733
741
|
# names and arguments, and the reasoning sent back.
|
|
734
742
|
def prompt_chars
|
|
735
743
|
LLMContextView.chars(@loop.llm_context_view.messages(@conversation)) +
|
|
736
|
-
@loop.reasoning_chars(@conversation, since: @
|
|
744
|
+
@loop.reasoning_chars(@conversation, since: @reasoning_start, reasoning: reasoning_level)
|
|
737
745
|
end
|
|
738
746
|
|
|
739
747
|
# The server's counts for this request (prompt + answer): the status
|