samagotchi 0.60.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 +99 -1
- data/docs/architecture.md +10 -4
- 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 +80 -1
- data/docs/internals/engine.md +57 -15
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/plugins.md +49 -6
- data/docs/sessions.md +7 -3
- data/docs/testing.md +4 -1
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- 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 +47 -9
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/kernel_loop.rb +13 -20
- data/lib/samagotchi/llm/chat_loop.rb +28 -17
- data/lib/samagotchi/log_subscriber.rb +6 -3
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/ref.rb +25 -8
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/row_refs.rb +126 -0
- data/lib/samagotchi/session.rb +40 -4
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +30 -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/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -1
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +4 -1
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +74 -33
- 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 +66 -13
- 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 +24 -3
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +140 -20
- 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/stream_handlers.js +5 -3
- 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 +121 -17
- data/lib/samagotchi/worker.rb +169 -21
- data/lib/samagotchi/worker_inbound.rb +5 -3
- data/lib/samagotchi/worker_signals.rb +81 -0
- metadata +8 -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
|
@@ -64,6 +64,7 @@ require_relative "sampling_settings"
|
|
|
64
64
|
require_relative "thinking"
|
|
65
65
|
require_relative "reasoning_history"
|
|
66
66
|
require_relative "answer_display"
|
|
67
|
+
require_relative "answer_refs"
|
|
67
68
|
require_relative "edit_preview"
|
|
68
69
|
require_relative "question_desk"
|
|
69
70
|
require_relative "child_reports"
|
|
@@ -287,6 +288,9 @@ module Samagotchi
|
|
|
287
288
|
self.guardrail_state_dir = Session.default_state_dir
|
|
288
289
|
# list_sessions and send_note speak for whichever session runs now.
|
|
289
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 }
|
|
290
294
|
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
291
295
|
# The turn-end warm-up; the kernel asks it for a slot pin per request.
|
|
292
296
|
@prompt_warmup = PromptWarmup.new
|
|
@@ -1339,6 +1343,12 @@ module Samagotchi
|
|
|
1339
1343
|
|
|
1340
1344
|
def session_state_dir = @session_state_dir || Session.default_state_dir
|
|
1341
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
|
+
|
|
1342
1352
|
# What a Bridge adds to a plugin's ctx.messages while a turn runs (the
|
|
1343
1353
|
# turn so far, as messages); nil without a Bridge (the REPL).
|
|
1344
1354
|
attr_writer :running_turn_messages
|
|
@@ -1478,19 +1488,31 @@ module Samagotchi
|
|
|
1478
1488
|
private :save_quietly
|
|
1479
1489
|
|
|
1480
1490
|
# Keep what the after_turn hooks presented as the answer's `display`
|
|
1481
|
-
# and
|
|
1482
|
-
#
|
|
1491
|
+
# and the refs they attached as its `display_refs` (AnswerRefs, one
|
|
1492
|
+
# replace for both), and tell the observers (:answer_display, the
|
|
1493
|
+
# display or nil, and how many refs), after the turn_completed they
|
|
1494
|
+
# already had: the web re-reads the answer then. A turn_completed
|
|
1483
1495
|
# with display_pending (after_turn hooks were about to run) always gets
|
|
1484
1496
|
# one, `display: nil` when nothing was presented, so the web holds the
|
|
1485
1497
|
# answer's pop until then and never swaps it after. Terminals are not
|
|
1486
1498
|
# sinks of it; they printed the answer already. The session is saved by
|
|
1487
1499
|
# the caller, as after every turn.
|
|
1488
|
-
def store_answer_display(session, display, pending: false)
|
|
1500
|
+
def store_answer_display(session, display, refs, pending: false)
|
|
1489
1501
|
synchronize_events do
|
|
1502
|
+
# A plugin that kept event[:refs] and calls it later gets nil: the
|
|
1503
|
+
# list is stored now (and the turn is over on every path).
|
|
1504
|
+
refs.close!
|
|
1490
1505
|
messages = Array(session.messages)
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1506
|
+
changed = display.changed? && messages.last.equal?(display.target)
|
|
1507
|
+
added = refs.added? && messages.last.equal?(refs.target)
|
|
1508
|
+
if changed || added
|
|
1509
|
+
merged = messages.last.dup
|
|
1510
|
+
merged[AnswerDisplay::KEY] = display.text if changed
|
|
1511
|
+
merged[AnswerRefs::KEY] = refs.to_a if added
|
|
1512
|
+
replace_session_messages(session, messages[0...-1] + [merged])
|
|
1513
|
+
event = { type: :answer_display, display: changed ? display.text : nil }
|
|
1514
|
+
event[:refs] = refs.count if added
|
|
1515
|
+
@session_observer.notify(event)
|
|
1494
1516
|
elsif pending
|
|
1495
1517
|
@session_observer.notify({ type: :answer_display, display: nil })
|
|
1496
1518
|
end
|
|
@@ -2228,15 +2250,18 @@ module Samagotchi
|
|
|
2228
2250
|
|
|
2229
2251
|
# Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
|
|
2230
2252
|
# ends the turn in #run_turn without it), with a read-only
|
|
2231
|
-
# copy of the conversation the turn stored,
|
|
2232
|
-
# a display version of the answer (AnswerDisplay)
|
|
2253
|
+
# copy of the conversation the turn stored, event[:present] for
|
|
2254
|
+
# a display version of the answer (AnswerDisplay) and event[:refs]
|
|
2255
|
+
# for refs on it (AnswerRefs).
|
|
2233
2256
|
session = turn.session
|
|
2234
2257
|
display = AnswerDisplay.new(session.messages)
|
|
2258
|
+
refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
|
|
2235
2259
|
after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
|
|
2236
2260
|
messages: hook_messages(session.messages) }
|
|
2237
2261
|
after_turn[:present] = display.presenter(after_turn)
|
|
2262
|
+
after_turn[:refs] = refs.adder(after_turn)
|
|
2238
2263
|
@hooks.fire(:after_turn, after_turn)
|
|
2239
|
-
store_answer_display(session, display, pending: display_pending)
|
|
2264
|
+
store_answer_display(session, display, refs, pending: display_pending)
|
|
2240
2265
|
|
|
2241
2266
|
# Fire :session_end after the turn (turn-level lifecycle), as :after_turn
|
|
2242
2267
|
@hooks.fire(:session_end, { type: :session_end, session_id: session.id })
|
|
@@ -2547,6 +2572,19 @@ module Samagotchi
|
|
|
2547
2572
|
clone_messages(@session&.messages)
|
|
2548
2573
|
end
|
|
2549
2574
|
|
|
2575
|
+
# The running turn's conversation as its loop builds it (both loops
|
|
2576
|
+
# register it on the kernel's llm_context_turn): the same array the loop
|
|
2577
|
+
# appends to, not a copy, so read it on the turn's thread (the pending
|
|
2578
|
+
# input drain, at an iteration boundary: TurnCheckpoint). nil when no
|
|
2579
|
+
# turn runs (llm_context_turn is never cleared, and still holds the
|
|
2580
|
+
# last turn's then).
|
|
2581
|
+
# @return [Array<Hash>, nil]
|
|
2582
|
+
def live_conversation
|
|
2583
|
+
return nil unless turn_running? && @kernel.respond_to?(:llm_context_turn)
|
|
2584
|
+
|
|
2585
|
+
@kernel.llm_context_turn&.conversation
|
|
2586
|
+
end
|
|
2587
|
+
|
|
2550
2588
|
# Append messages to the current session's conversation.
|
|
2551
2589
|
# @param messages [Array<Hash>]
|
|
2552
2590
|
# @return [Array<Hash>] the session's 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
|
|
@@ -253,9 +253,11 @@ module Samagotchi
|
|
|
253
253
|
# event[:steer].call(text) -> true when the text was queued for the
|
|
254
254
|
# running turn's next boundary (its own user message, source: the
|
|
255
255
|
# hook's bundle); from after_turn / session_end false
|
|
256
|
-
# and after_turn's fire site adds
|
|
256
|
+
# and after_turn's fire site adds two more (AnswerDisplay, AnswerRefs):
|
|
257
257
|
# event[:present].call { |text| new_text } -> the answer's display
|
|
258
258
|
# text after the call (chained in hook order), nil without an answer
|
|
259
|
+
# event[:refs].call(match:, kind:, target:, label: nil, actions: [])
|
|
260
|
+
# -> true (kept), false (refused, logged), nil without an answer
|
|
259
261
|
#
|
|
260
262
|
# @param name [Symbol] the hook name to fire
|
|
261
263
|
# @param event [Hash] the event payload (may be mutated by hooks)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The mark of a turn a worker checkpointed and never finished
|
|
5
|
+
# (Session#interrupted_turn, saved as session.json's "interrupted_turn"):
|
|
6
|
+
# TurnCheckpoint writes it with the turn's messages so far while the turn
|
|
7
|
+
# runs; the turn's end saves without it; the next worker to start finds it
|
|
8
|
+
# and settles the turn (Worker#settle_interrupted_turn).
|
|
9
|
+
#
|
|
10
|
+
# id: the turn's id (nil when unknown); started_at / at: iso8601, when the
|
|
11
|
+
# turn began and when this checkpoint was taken; iteration: the loop
|
|
12
|
+
# boundaries it had passed (Integer).
|
|
13
|
+
InterruptedTurn = Data.define(:id, :started_at, :at, :iteration) do
|
|
14
|
+
def initialize(id: nil, started_at: nil, at: nil, iteration: nil) = super
|
|
15
|
+
|
|
16
|
+
# A saved record (string or symbol keys), an InterruptedTurn as it is;
|
|
17
|
+
# nil for anything else.
|
|
18
|
+
# @return [InterruptedTurn, nil]
|
|
19
|
+
def self.from_file(raw)
|
|
20
|
+
return raw if raw.is_a?(InterruptedTurn)
|
|
21
|
+
return nil unless raw.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
new(**members.to_h { |m| [m, raw.key?(m.to_s) ? raw[m.to_s] : raw[m]] })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Hash{String => Object}] string keys, unset fields left out
|
|
27
|
+
def to_file = to_h.compact.transform_keys(&:to_s)
|
|
28
|
+
|
|
29
|
+
# Seconds from the turn's start to this checkpoint (nil when unknown).
|
|
30
|
+
# @return [Float, nil]
|
|
31
|
+
def seconds
|
|
32
|
+
return nil unless started_at && at
|
|
33
|
+
|
|
34
|
+
(Time.iso8601(at) - Time.iso8601(started_at)).clamp(0, Float::INFINITY)
|
|
35
|
+
rescue ArgumentError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -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)
|
|
@@ -1003,11 +1008,13 @@ module Samagotchi
|
|
|
1003
1008
|
def sanitize_history(messages, keep_thought_from: nil)
|
|
1004
1009
|
messages.each_with_index.map do |m, index|
|
|
1005
1010
|
if m[:role] == "model" && keep_thought_from && index >= keep_thought_from
|
|
1006
|
-
{ role: m[:role], content: m[:content].to_s, display: m[:display] }.compact
|
|
1011
|
+
{ role: m[:role], content: m[:content].to_s, display: m[:display], display_refs: m[:display_refs] }.compact
|
|
1007
1012
|
elsif m[:role] == "model"
|
|
1008
|
-
# `display`
|
|
1009
|
-
# prompt formatter never reads
|
|
1010
|
-
|
|
1013
|
+
# `display` and `display_refs` ride along so the stored
|
|
1014
|
+
# conversation keeps them; the prompt formatter never reads them
|
|
1015
|
+
# (AnswerDisplay, AnswerRefs).
|
|
1016
|
+
{ role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display],
|
|
1017
|
+
display_refs: m[:display_refs] }.compact
|
|
1011
1018
|
else
|
|
1012
1019
|
m.dup
|
|
1013
1020
|
end
|
|
@@ -1015,24 +1022,10 @@ module Samagotchi
|
|
|
1015
1022
|
end
|
|
1016
1023
|
|
|
1017
1024
|
# Standard multi-turn compliance: never pass prior raw thought blocks,
|
|
1018
|
-
# but a resumed run's (+keep_thought_from+,
|
|
1025
|
+
# but a resumed run's (+keep_thought_from+, ReasoningHistory.run_start): the same
|
|
1019
1026
|
# run going on after a step-limit Continue.
|
|
1020
1027
|
def prepare_conversation(messages, keep_thought_from: nil) = sanitize_history(messages, keep_thought_from: keep_thought_from)
|
|
1021
1028
|
|
|
1022
|
-
# Where a resumed run's thought blocks start: its start (+since+). Inside
|
|
1023
|
-
# one native run nothing re-sanitizes, so a user entry the run merged (a
|
|
1024
|
-
# steer, a delegate report, the Qwen recovery prompt) left the thoughts
|
|
1025
|
-
# before it in the prompt: they stay. Only a user or turn_start entry
|
|
1026
|
-
# added after the run ended (after its last model message: a !cmd's
|
|
1027
|
-
# output) starts them again past itself. nil without +since+.
|
|
1028
|
-
def thought_floor(messages, since)
|
|
1029
|
-
return nil if since.nil?
|
|
1030
|
-
|
|
1031
|
-
last_reply = messages.rindex { |m| m[:role].to_s == "model" } || -1
|
|
1032
|
-
query = messages.rindex { |m| m[:role].to_s == "user" || m[:turn_start] == true }
|
|
1033
|
-
query && query > last_reply ? [query + 1, since].max : since
|
|
1034
|
-
end
|
|
1035
|
-
|
|
1036
1029
|
def duplicate_conversation(messages)
|
|
1037
1030
|
messages.map(&:dup)
|
|
1038
1031
|
end
|