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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/docs/architecture.md +3 -0
  4. data/docs/cli.md +22 -8
  5. data/docs/configuration.md +32 -19
  6. data/docs/context.md +8 -0
  7. data/docs/guardrails.md +2 -1
  8. data/docs/hooks.md +8 -0
  9. data/docs/internals/engine.md +24 -11
  10. data/docs/internals/prompt-caching.md +6 -1
  11. data/docs/plugins.md +42 -3
  12. data/docs/sessions.md +4 -4
  13. data/docs/testing.md +4 -1
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  16. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  18. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  19. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  20. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/engine.rb +9 -0
  23. data/lib/samagotchi/engine_peers.rb +1 -0
  24. data/lib/samagotchi/kernel_loop.rb +7 -16
  25. data/lib/samagotchi/llm/chat_loop.rb +23 -15
  26. data/lib/samagotchi/plugin/api.rb +36 -18
  27. data/lib/samagotchi/reasoning_history.rb +22 -4
  28. data/lib/samagotchi/row_refs.rb +126 -0
  29. data/lib/samagotchi/session_metrics.rb +24 -2
  30. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  31. data/lib/samagotchi/terminal_ui.rb +26 -3
  32. data/lib/samagotchi/tool_activity.rb +8 -4
  33. data/lib/samagotchi/tool_response.rb +5 -4
  34. data/lib/samagotchi/tool_row_fields.rb +4 -2
  35. data/lib/samagotchi/tool_runner.rb +16 -3
  36. data/lib/samagotchi/tools/builtins.rb +9 -2
  37. data/lib/samagotchi/tools/execute.rb +15 -3
  38. data/lib/samagotchi/tools/peers.rb +3 -2
  39. data/lib/samagotchi/tools/registry.rb +5 -3
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +14 -1
  42. data/lib/samagotchi/web/message_parts.rb +17 -9
  43. data/lib/samagotchi/web/public/activity.js +4 -1
  44. data/lib/samagotchi/web/public/app.js +59 -15
  45. data/lib/samagotchi/web/public/chains.js +18 -20
  46. data/lib/samagotchi/web/public/context_chips.js +92 -64
  47. data/lib/samagotchi/web/public/families_view.js +51 -39
  48. data/lib/samagotchi/web/public/index.html +61 -10
  49. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  50. data/lib/samagotchi/web/public/lists_view.js +13 -13
  51. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  52. data/lib/samagotchi/web/public/question_card.js +11 -0
  53. data/lib/samagotchi/web/public/question_cards.js +60 -5
  54. data/lib/samagotchi/web/public/ref_menu.js +18 -5
  55. data/lib/samagotchi/web/public/refs.js +113 -15
  56. data/lib/samagotchi/web/public/scroll.js +42 -0
  57. data/lib/samagotchi/web/public/stage_model.js +22 -3
  58. data/lib/samagotchi/web/public/stage_view.js +299 -70
  59. data/lib/samagotchi/web/public/timing.js +1 -1
  60. data/lib/samagotchi/web/public/turn_html.js +5 -4
  61. data/lib/samagotchi/web/public/turn_model.js +49 -0
  62. data/lib/samagotchi/web/public/turn_view.js +104 -10
  63. data/lib/samagotchi/worker.rb +3 -2
  64. data/lib/samagotchi/worker_inbound.rb +5 -3
  65. 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. The after_turn hook links from a per-PR
24
- # cache and never runs gh (the web holds the answer until the hooks are
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
- event[:present].call { |display| LineLinker.new(cached).link(display) }
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
- hits(head).each do |start, finish, url|
595
- out << head[pos...start] << "[#{head[start...finish]}](#{url})"
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, url] for each ref to link, by offset.
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
- url = ref_url(match) and found << [start, finish, url]
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
- url = ref_url(match) and found << [start, finish, url]
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 ref_url(match)
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 : url_for(path, from, to)
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.0
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:bf2cfccbcadedcd85ea0b53f2d263c8729e14ed8cf310bca4a56affe50181292
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
- STOPPED = [Samagotchi::Tools::Execute::STOPPED_BY_USER, Samagotchi::Tools::Execute::NOT_RUN_ON_STOP].freeze
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, quiet: quiet }
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.4.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.35.0"
7
+ requires_chi: ">= 0.61.0"
8
8
  files:
9
- source_links.md: sha256:1670a3051b67610f41a680fc8e61bc1c99657a87f0f32121d040e05512896748
9
+ source_links.md: sha256:ace01a0f7c308a0ae54ece714c31c0e6d7a49c372d98111030e6d268320bed3a
10
10
  hooks:
11
11
  source_links.rb:
12
- sha256: sha256:8ce4d11659f4bc151167f99e47299c0f6ef246797c9167c609c1699e3edea2e5
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,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.61.0
3
+ version: 0.62.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:
@@ -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: thought_floor(messages, reasoning_since))
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+, #thought_floor): the same
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 start), under all every one.
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: past both the last query (a
198
- # user message: a prompt, a steer, merged input, the chat template's
199
- # last query; or an entry that starts a turn, turn_start) and +since+
200
- # (the Run's start, so a continue turn -- a reminder, a context wake,
201
- # a delegate report's wake -- sends none of the run before it; a
202
- # step-limit Continue's Run starts where the run it resumes did).
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
- [query ? query + 1 : 0, since.to_i].max
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 this run's entries start: the reasoning it sends back
421
- # (ChatLoop#reasoning_boundary) is its own, a resumed run's too.
422
- @start = start || conversation.length
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: @start, reasoning: reasoning_level), tools: @loop.tool_definitions, model: @model_name,
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: @start, reasoning: reasoning_level).positive?
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: @start, reasoning: reasoning_level)
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