samagotchi 0.61.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +3 -0
  6. data/docs/cli.md +26 -9
  7. data/docs/configuration.md +33 -20
  8. data/docs/context.md +21 -3
  9. data/docs/guardrails.md +2 -1
  10. data/docs/hooks.md +14 -3
  11. data/docs/internals/engine.md +24 -11
  12. data/docs/internals/prompt-caching.md +6 -1
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +131 -5
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +4 -4
  17. data/docs/sub-agent.md +6 -0
  18. data/docs/testing.md +10 -4
  19. data/lib/samagotchi/answer_refs.rb +18 -26
  20. data/lib/samagotchi/bridge.rb +23 -1
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  29. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  31. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  35. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/engine.rb +9 -0
  40. data/lib/samagotchi/engine_peers.rb +1 -0
  41. data/lib/samagotchi/idle_recap.rb +2 -3
  42. data/lib/samagotchi/image_store.rb +8 -0
  43. data/lib/samagotchi/kernel_loop.rb +7 -16
  44. data/lib/samagotchi/llm/chat_loop.rb +23 -15
  45. data/lib/samagotchi/plugin/api.rb +36 -18
  46. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  47. data/lib/samagotchi/reasoning_history.rb +22 -4
  48. data/lib/samagotchi/recap_setup.rb +3 -3
  49. data/lib/samagotchi/row_refs.rb +125 -0
  50. data/lib/samagotchi/session.rb +12 -0
  51. data/lib/samagotchi/session_metrics.rb +24 -2
  52. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
  54. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  55. data/lib/samagotchi/terminal_ui.rb +57 -14
  56. data/lib/samagotchi/tool_activity.rb +8 -4
  57. data/lib/samagotchi/tool_response.rb +5 -4
  58. data/lib/samagotchi/tool_row_fields.rb +4 -2
  59. data/lib/samagotchi/tool_runner.rb +16 -3
  60. data/lib/samagotchi/tools/builtins.rb +9 -2
  61. data/lib/samagotchi/tools/execute.rb +37 -7
  62. data/lib/samagotchi/tools/peers.rb +3 -2
  63. data/lib/samagotchi/tools/registry.rb +5 -3
  64. data/lib/samagotchi/version.rb +1 -1
  65. data/lib/samagotchi/web/app.rb +20 -5
  66. data/lib/samagotchi/web/message_parts.rb +17 -9
  67. data/lib/samagotchi/web/public/activity.js +6 -3
  68. data/lib/samagotchi/web/public/app.js +106 -106
  69. data/lib/samagotchi/web/public/card.js +73 -23
  70. data/lib/samagotchi/web/public/chains.js +24 -24
  71. data/lib/samagotchi/web/public/command_view.js +39 -35
  72. data/lib/samagotchi/web/public/context_chips.js +92 -64
  73. data/lib/samagotchi/web/public/copy.js +7 -15
  74. data/lib/samagotchi/web/public/diff_view.js +20 -23
  75. data/lib/samagotchi/web/public/families_view.js +51 -39
  76. data/lib/samagotchi/web/public/format.js +0 -48
  77. data/lib/samagotchi/web/public/images.js +12 -16
  78. data/lib/samagotchi/web/public/index.html +61 -10
  79. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  80. data/lib/samagotchi/web/public/lists_view.js +13 -13
  81. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  82. data/lib/samagotchi/web/public/question_card.js +11 -0
  83. data/lib/samagotchi/web/public/question_cards.js +63 -7
  84. data/lib/samagotchi/web/public/read_view.js +10 -9
  85. data/lib/samagotchi/web/public/ref_menu.js +18 -5
  86. data/lib/samagotchi/web/public/refs.js +113 -15
  87. data/lib/samagotchi/web/public/scope.js +23 -0
  88. data/lib/samagotchi/web/public/scroll.js +42 -0
  89. data/lib/samagotchi/web/public/stage_model.js +22 -3
  90. data/lib/samagotchi/web/public/stage_view.js +299 -70
  91. data/lib/samagotchi/web/public/timing.js +1 -8
  92. data/lib/samagotchi/web/public/turn_html.js +122 -61
  93. data/lib/samagotchi/web/public/turn_model.js +49 -0
  94. data/lib/samagotchi/web/public/turn_view.js +167 -116
  95. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  96. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  97. data/lib/samagotchi/worker.rb +49 -32
  98. data/lib/samagotchi/worker_inbound.rb +17 -14
  99. metadata +10 -1
@@ -20,8 +20,10 @@ require "tmpdir"
20
20
  # hunk, else the file at the PR's head. The PRs are the session's attached
21
21
  # github-pr sources and the PR URLs in the user's messages (a delegate
22
22
  # child's task is its first one). Display only (event[:present]): the
23
- # model's text stays as it was. 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,10 +1,10 @@
1
1
  ---
2
2
  name: loop-guard
3
- version: 0.3.7
3
+ version: 0.3.8
4
4
  scope: system
5
5
  description: "Breaks loops: the same tool call with the same result again and again in one turn is denied with advice (after a few denies the turn stops with a card), and thinking that repeats itself while it streams is cut and the model asked again"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:e8c31f608f7cef5d605285efcc40ca32efd7fd60c862a39480b9edf61df56acc
9
+ sha256: sha256:334c45355493b35904a27a61d8cd12e4cce157fa0eff233918c92cf2961cba57
10
10
  requires_chi: ">= 0.6.0"
@@ -297,7 +297,7 @@ class Plugin
297
297
  def register(chi)
298
298
  chi.on(:before_turn) { |_event| reset }
299
299
  chi.on(:before_tool_call) { |event, ctx| before(event, ctx) }
300
- chi.on(:after_tool_call) { |event| after(event) }
300
+ chi.on(:after_tool_call) { |event, ctx| after(event, ctx) }
301
301
  return unless @thinking["watch"]
302
302
 
303
303
  chi.on(:before_generation) do |_event|
@@ -377,6 +377,7 @@ class Plugin
377
377
  @warned = {} # key => true once notified this turn
378
378
  @denials = 0
379
379
  @pending = nil
380
+ @stop_card = nil # the stop's card, posted after the denied call
380
381
  @thinking_loops = 0
381
382
  @good_steps = 0
382
383
  @watch = nil
@@ -412,7 +413,8 @@ class Plugin
412
413
  # A call's result, paired with the before_tool_call that keyed it. A call
413
414
  # that was denied (by us or another voter) ran nothing: no result, or the
414
415
  # deny text would become the key's result and the next call would pass.
415
- def after(event)
416
+ def after(event, ctx)
417
+ post_stop_card(ctx)
416
418
  pending = @pending
417
419
  @pending = nil
418
420
  return unless pending && event[:tool].to_s == pending[:key].first
@@ -426,16 +428,25 @@ class Plugin
426
428
  @previews[key] = preview(output)
427
429
  end
428
430
 
431
+ # The turn stops now; its card waits for the denied call's
432
+ # after_tool_call (#post_stop_card), so a UI that prints events in order
433
+ # shows it under that call's row, not before it.
429
434
  def stop(ctx)
430
435
  ctx.stop_turn("the model kept repeating the same calls")
431
436
  repeated = @attempts.select { |key, n| n > 1 && @last_result[key] && @results[[key, @last_result[key]]] >= @deny_after }
432
437
  lines = repeated.map do |key, n|
433
438
  "- `#{short_key(key)}`: #{n} times, the same result each time (#{@previews[key]})"
434
439
  end
435
- ctx.card(title: "loop-guard stopped the turn",
436
- body: "The model kept repeating these calls:\n\n#{lines.join("\n")}\n\n" \
437
- "Tell it what to try instead, or where to look.",
438
- level: :warn)
440
+ @stop_card = { title: "loop-guard stopped the turn",
441
+ body: "The model kept repeating these calls:\n\n#{lines.join("\n")}\n\n" \
442
+ "Tell it what to try instead, or where to look.",
443
+ level: :warn }
444
+ end
445
+
446
+ def post_stop_card(ctx)
447
+ card = @stop_card
448
+ @stop_card = nil
449
+ ctx.card(**card) if card
439
450
  end
440
451
 
441
452
  def warn(ctx, key, what)
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: slow-commands
3
- version: 0.2.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,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.61.0
3
+ version: 0.63.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
9
  config_modification_protocol.md: sha256:16403da5dd97a335e19d9d15bb1ba4390237bf9f4869e35d63c172a8828b5ae0
10
- memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
10
+ memory_guide.md: sha256:b0a1ba42c5334d4117b5733c8c26d7477364baca9169b0c0a711de6ac7fc7f3c
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -94,7 +94,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
94
94
  - Integrity: each file's sha256 is recorded at install; a hook/plugin/rule file changed afterwards is not loaded (rules: every call denied) until reinstalled. It is an integrity check, not proof of authorship.
95
95
  - Installing = trusting its Ruby code (hooks, plugin), like a gem. Install only copies; the code runs at the next session start (`Engine.new`), so a running worker needs a restart to pick it up.
96
96
  - A bundle without `*.md` (btw, mcp, loop-guard) adds no line to the prompt's memory index.
97
- - Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
97
+ - Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
98
98
 
99
99
  ### CLI — `chi bundle`
100
100
 
@@ -16,8 +16,9 @@ module Samagotchi
16
16
  class ContextAbsorber
17
17
  # +note+: Engine#add_context_note's hash; +subscription+: the session's
18
18
  # Subscription after it (nil: dropped); +wake_note+: the same note with
19
- # the wake line, for an update whose source asked to wake (the worker
20
- # may start a turn with it), else nil.
19
+ # the wake line, for an update whose source asked to wake or a session
20
+ # push source's first text that asked (the worker may start a turn with
21
+ # it), else nil.
21
22
  Delivery = Data.define(:name, :note, :subscription, :wake_note) do
22
23
  def initialize(name:, note:, subscription:, wake_note: nil) = super
23
24
  end
@@ -93,9 +94,12 @@ module Samagotchi
93
94
  "(third-party text, not your user's words):\n> #{quoted}\n#{last_line}"
94
95
  end
95
96
 
96
- def self.attached_text(name:, hint:, why:, summary:)
97
+ # The attached note's text; a wake turn's (+wake+) ends with the wake
98
+ # line in place of the background line.
99
+ def self.attached_text(name:, hint:, why:, summary:, wake: false)
97
100
  why_part = why ? " Why: #{why}#{"." unless why.match?(/[.!?]\z/)}" : ""
98
- "Attached: #{name}#{" (#{hint})" if hint}.#{why_part}\nSummary: #{summary}\n#{BACKGROUND_LINE} #{read_line(name)}"
101
+ last_line = wake ? WAKE_LAST_LINE : "#{BACKGROUND_LINE} #{read_line(name)}"
102
+ "Attached: #{name}#{" (#{hint})" if hint}.#{why_part}\nSummary: #{summary}\n#{last_line}"
99
103
  end
100
104
 
101
105
  def self.read_line(name) = "Read it with context_read(name: \"#{name}\") when your user's request is about it."
@@ -105,6 +109,8 @@ module Samagotchi
105
109
  # A source the session sees: attached when it has text and was never
106
110
  # noted; updated for a new revision; an error once per run of failures
107
111
  # after a success. A source with no text yet waits (nothing to read).
112
+ # An update that asked to wake carries a wake note, and so does a first
113
+ # text that asked when #first_text_wakes?.
108
114
  def delivery_for(attached, sub, now)
109
115
  snapshot = attached.snapshot
110
116
  return nil unless snapshot.text?
@@ -112,8 +118,11 @@ module Samagotchi
112
118
  name = attached.name
113
119
  hint = snapshot.hint || attached.source.hint
114
120
  if sub.seen.nil?
115
- text = self.class.attached_text(name: name, hint: hint, why: attached.source.why, summary: snapshot.summary)
116
- return delivery(name, text_note_id(name, snapshot), text, now, seen(sub, snapshot))
121
+ args = { name: name, hint: hint, why: attached.source.why, summary: snapshot.summary }
122
+ found = delivery(name, text_note_id(name, snapshot), self.class.attached_text(**args), now, seen(sub, snapshot))
123
+ return found unless first_text_wakes?(attached, sub)
124
+
125
+ return found.with(wake_note: found.note.merge(text: self.class.attached_text(**args, wake: true)))
117
126
  end
118
127
  if sub.seen != snapshot.revision
119
128
  args = { name: name, hint: hint, summary: snapshot.summary, changes: snapshot.serial - sub.seen_serial }
@@ -131,6 +140,16 @@ module Samagotchi
131
140
  sub.with(error_seen: snapshot.error_since))
132
141
  end
133
142
 
143
+ # A first text may wake only for a session's pushed source (`chi context
144
+ # add NAME --push`, attached empty) that asked. Not for a command source
145
+ # (a URL/provider source has a cmd too): its first fetch runs as it is
146
+ # attached, while the user is likely looking at the session. Not for a
147
+ # project source: its first text may predate this session. A blank
148
+ # Subscription has seen_serial 0, so any revision that asked counts.
149
+ def first_text_wakes?(attached, sub)
150
+ attached.source.push? && attached.location.session? && attached.snapshot.wake_serial.to_i > sub.seen_serial
151
+ end
152
+
134
153
  # A subscription whose source is gone: detached when the session was
135
154
  # told of it, dropped quietly otherwise.
136
155
  def detached(sub, now)
@@ -288,6 +288,9 @@ module Samagotchi
288
288
  self.guardrail_state_dir = Session.default_state_dir
289
289
  # list_sessions and send_note speak for whichever session runs now.
290
290
  @kernel.peers = PeerView.new(self)
291
+ # A plugin tool's row refs resolve against the session's folder, as
292
+ # answer refs do (AnswerRefs), not the process's.
293
+ @kernel.session_cwd = -> { @session&.working_directory }
291
294
  @kernel.guardrail_gate = @guardrail_wiring.gate
292
295
  # The turn-end warm-up; the kernel asks it for a slot pin per request.
293
296
  @prompt_warmup = PromptWarmup.new
@@ -1340,6 +1343,12 @@ module Samagotchi
1340
1343
 
1341
1344
  def session_state_dir = @session_state_dir || Session.default_state_dir
1342
1345
 
1346
+ # The running turn's metrics so far, saved as a turn a worker died in
1347
+ # would leave them (SessionMetrics#persist's running_turn): a worker's
1348
+ # turn checkpoint (TurnCheckpoint) calls it when it writes.
1349
+ # @return [Boolean] whether they were saved
1350
+ def checkpoint_metrics = @metrics.persist(state_dir: session_state_dir, running_turn: true)
1351
+
1343
1352
  # What a Bridge adds to a plugin's ctx.messages while a turn runs (the
1344
1353
  # turn so far, as messages); nil without a Bridge (the REPL).
1345
1354
  attr_writer :running_turn_messages
@@ -15,6 +15,7 @@ module Samagotchi
15
15
  def project_root = engine.session&.project_root
16
16
  def state_dir = engine.peer_state_dir
17
17
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
18
+ def cancel_reason = engine.active_cancel_controller&.reason
18
19
  def relay = engine.relay_peer
19
20
  def model_ref = engine.effective_model_ref
20
21
  def thinking = engine.thinking_override
@@ -7,6 +7,7 @@ require "time"
7
7
 
8
8
  require_relative "idle_client"
9
9
  require_relative "idle_target"
10
+ require_relative "image_store"
10
11
  require_relative "output_formatter"
11
12
  require_relative "steer"
12
13
  require_relative "recap_store"
@@ -53,8 +54,6 @@ module Samagotchi
53
54
  # separator, while the chat loop writes one message per call.
54
55
  TOOL_OUTPUT_SEPARATOR = "\n\n---\n\n"
55
56
  TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
56
- # The text the web sends for a turn of images alone (images.js turnText).
57
- IMAGE_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
58
57
  DELEGATE_REPORT_LINE = "(a delegate session's report, delivered by chi; not the user's words)"
59
58
 
60
59
  module_function
@@ -76,7 +75,7 @@ module Samagotchi
76
75
  # A delegate child's report (ChildReports) is chi's news, not
77
76
  # what the user said.
78
77
  content = "#{DELEGATE_REPORT_LINE}\n#{content}" if message["source"].to_s == Steer::DELEGATE_REPORT
79
- images = IMAGE_ONLY_RE.match?(content) ? [] : image_lines(message["images"])
78
+ images = ImageRef.placeholder_only?(content) ? [] : image_lines(message["images"])
80
79
  [content, *images].reject(&:empty?).join("\n")
81
80
  when "model", "assistant"
82
81
  strip_thought(message["content"].to_s.gsub(TOOL_CALL_RE, ""))
@@ -159,6 +159,14 @@ module Samagotchi
159
159
  # One-line texts about an image ref ({file:, mime:, width:, height:,
160
160
  # bytes:, name:, source:}).
161
161
  module ImageRef
162
+ # The text the web sends for a turn of images alone (images.js
163
+ # turnText): "[image: name]" per image, nothing else.
164
+ PLACEHOLDER_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
165
+
166
+ # Whether +text+ is only that placeholder: the images' own lines would
167
+ # name them a second time.
168
+ def self.placeholder_only?(text) = PLACEHOLDER_ONLY_RE.match?(text.to_s)
169
+
162
170
  # Claude's rule of thumb (28×28 px per token). About 30% over on the
163
171
  # local Qwen, close enough for a label and the context estimate.
164
172
  def self.estimated_tokens(ref)