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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/docs/architecture.md +10 -4
  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 +80 -1
  9. data/docs/internals/engine.md +57 -15
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/plugins.md +49 -6
  12. data/docs/sessions.md +7 -3
  13. data/docs/testing.md +4 -1
  14. data/lib/samagotchi/answer_display.rb +4 -3
  15. data/lib/samagotchi/answer_refs.rb +210 -0
  16. data/lib/samagotchi/bridge/card_store.rb +6 -1
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  19. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  20. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/engine.rb +47 -9
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/hooks/registry.rb +3 -1
  30. data/lib/samagotchi/interrupted_turn.rb +39 -0
  31. data/lib/samagotchi/kernel_loop.rb +13 -20
  32. data/lib/samagotchi/llm/chat_loop.rb +28 -17
  33. data/lib/samagotchi/log_subscriber.rb +6 -3
  34. data/lib/samagotchi/plugin/api.rb +36 -18
  35. data/lib/samagotchi/reasoning_history.rb +22 -4
  36. data/lib/samagotchi/ref.rb +25 -8
  37. data/lib/samagotchi/reply_wait.rb +6 -2
  38. data/lib/samagotchi/row_refs.rb +126 -0
  39. data/lib/samagotchi/session.rb +40 -4
  40. data/lib/samagotchi/session_manager.rb +64 -6
  41. data/lib/samagotchi/session_metrics.rb +24 -2
  42. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  43. data/lib/samagotchi/terminal_ui.rb +30 -3
  44. data/lib/samagotchi/tool_activity.rb +8 -4
  45. data/lib/samagotchi/tool_response.rb +5 -4
  46. data/lib/samagotchi/tool_row_fields.rb +4 -2
  47. data/lib/samagotchi/tool_runner.rb +16 -3
  48. data/lib/samagotchi/tools/builtins.rb +9 -2
  49. data/lib/samagotchi/tools/execute.rb +15 -3
  50. data/lib/samagotchi/tools/peers.rb +3 -2
  51. data/lib/samagotchi/tools/registry.rb +5 -3
  52. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +19 -1
  55. data/lib/samagotchi/web/message_parts.rb +17 -9
  56. data/lib/samagotchi/web/public/activity.js +4 -1
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +74 -33
  59. data/lib/samagotchi/web/public/chains.js +18 -20
  60. data/lib/samagotchi/web/public/context_chips.js +92 -64
  61. data/lib/samagotchi/web/public/families_view.js +51 -39
  62. data/lib/samagotchi/web/public/index.html +66 -13
  63. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  64. data/lib/samagotchi/web/public/lists_view.js +13 -13
  65. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  66. data/lib/samagotchi/web/public/question_card.js +11 -0
  67. data/lib/samagotchi/web/public/question_cards.js +60 -5
  68. data/lib/samagotchi/web/public/ref_menu.js +24 -3
  69. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  70. data/lib/samagotchi/web/public/refs.js +140 -20
  71. data/lib/samagotchi/web/public/scroll.js +42 -0
  72. data/lib/samagotchi/web/public/stage_model.js +22 -3
  73. data/lib/samagotchi/web/public/stage_view.js +299 -70
  74. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  75. data/lib/samagotchi/web/public/timing.js +1 -1
  76. data/lib/samagotchi/web/public/turn_html.js +5 -4
  77. data/lib/samagotchi/web/public/turn_model.js +49 -0
  78. data/lib/samagotchi/web/public/turn_view.js +121 -17
  79. data/lib/samagotchi/worker.rb +169 -21
  80. data/lib/samagotchi/worker_inbound.rb +5 -3
  81. data/lib/samagotchi/worker_signals.rb +81 -0
  82. 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. 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.60.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:
@@ -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 tell the observers (:answer_display), after the turn_completed
1482
- # they already had: the web re-reads the answer then. A turn_completed
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
- if display.changed? && messages.last.equal?(display.target)
1492
- replace_session_messages(session, messages[0...-1] + [display.target.merge(AnswerDisplay::KEY => display.text)])
1493
- @session_observer.notify({ type: :answer_display, display: display.text })
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, and event[:present] for
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 one more (AnswerDisplay):
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: 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)
@@ -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` rides along so the stored conversation keeps it; the
1009
- # prompt formatter never reads it (AnswerDisplay).
1010
- { role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display] }.compact
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+, #thought_floor): the same
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