samagotchi 0.68.0 → 0.70.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +31 -8
  6. data/docs/context.md +15 -6
  7. data/docs/guardrails.md +23 -1
  8. data/docs/hooks.md +5 -1
  9. data/docs/internals/background-tasks.md +1 -1
  10. data/docs/internals/bundles.md +1 -1
  11. data/docs/internals/engine.md +25 -13
  12. data/docs/internals/prompt-caching.md +9 -2
  13. data/docs/internals/tool-guardrails.md +61 -1
  14. data/docs/plugins.md +66 -38
  15. data/docs/releasing.md +14 -6
  16. data/docs/sessions.md +5 -5
  17. data/docs/sub-agent.md +2 -1
  18. data/docs/testing.md +8 -1
  19. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +10 -8
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +4 -4
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +6 -6
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +3 -3
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +6 -5
  26. data/lib/samagotchi/bundles/codex-peer/manifest.yml +10 -8
  27. data/lib/samagotchi/bundles/codex-peer/plugin.rb +4 -4
  28. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +6 -6
  29. data/lib/samagotchi/bundles/codex-peer/scripts/pair +3 -3
  30. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +6 -5
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  33. data/lib/samagotchi/config.rb +6 -21
  34. data/lib/samagotchi/context_absorber.rb +35 -21
  35. data/lib/samagotchi/engine.rb +7 -2
  36. data/lib/samagotchi/guardrails/targets.rb +9 -2
  37. data/lib/samagotchi/host_config.rb +1 -29
  38. data/lib/samagotchi/host_model.rb +0 -7
  39. data/lib/samagotchi/kernel_loop.rb +34 -7
  40. data/lib/samagotchi/llm/errors.rb +47 -1
  41. data/lib/samagotchi/llm/http.rb +20 -2
  42. data/lib/samagotchi/llm_context_stale.rb +8 -2
  43. data/lib/samagotchi/log_subscriber.rb +1 -0
  44. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  45. data/lib/samagotchi/model_price.rb +2 -4
  46. data/lib/samagotchi/peer_sources.rb +1 -1
  47. data/lib/samagotchi/plugin/api.rb +3 -2
  48. data/lib/samagotchi/plugin/peer.rb +35 -16
  49. data/lib/samagotchi/session_metrics.rb +4 -1
  50. data/lib/samagotchi/system_prompt.rb +19 -3
  51. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  52. data/lib/samagotchi/tool_activity.rb +3 -0
  53. data/lib/samagotchi/tool_output_cut.rb +312 -0
  54. data/lib/samagotchi/tool_output_files.rb +73 -0
  55. data/lib/samagotchi/tool_row_fields.rb +4 -2
  56. data/lib/samagotchi/tool_runner.rb +70 -15
  57. data/lib/samagotchi/toolkit.rb +27 -14
  58. data/lib/samagotchi/toolkit_binding.rb +8 -2
  59. data/lib/samagotchi/toolkit_command.rb +20 -4
  60. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  61. data/lib/samagotchi/tools/delegate.rb +4 -1
  62. data/lib/samagotchi/tools/delegate_wait.rb +12 -1
  63. data/lib/samagotchi/tools/ref_text.rb +27 -0
  64. data/lib/samagotchi/tools/registry.rb +95 -8
  65. data/lib/samagotchi/tools/task_wait.rb +4 -1
  66. data/lib/samagotchi/tools/tool_index.rb +223 -72
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +14 -9
  69. data/lib/samagotchi/web/message_parts.rb +9 -4
  70. data/lib/samagotchi/web/public/activity.js +3 -1
  71. data/lib/samagotchi/web/public/app.js +123 -22
  72. data/lib/samagotchi/web/public/chains.js +19 -2
  73. data/lib/samagotchi/web/public/families_view.js +15 -0
  74. data/lib/samagotchi/web/public/index.html +7 -5
  75. data/lib/samagotchi/web/public/model_picker.js +21 -6
  76. data/lib/samagotchi/web/public/session_state.js +5 -0
  77. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  78. data/lib/samagotchi/web/public/timing.js +3 -1
  79. data/lib/samagotchi/web/public/toast_place.js +16 -0
  80. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  81. data/lib/samagotchi/web/public/turn_html.js +4 -2
  82. data/lib/samagotchi/worker.rb +2 -2
  83. data/lib/samagotchi/worker_inbound.rb +3 -0
  84. metadata +5 -1
@@ -17,14 +17,17 @@ module Samagotchi
17
17
  # @param can_stage [#call] → whether a plugin may change the toolkit now
18
18
  # (no turn runs, no model reply yet)
19
19
  # @param reset_prompt [#call] → build the system prompts again
20
+ # @param refresh_index [#call] → the tool index's search tool described
21
+ # for the Registry's toolkit (Tools::ToolIndex#refresh!)
20
22
  # @param emit [#call] (sink, event) → emits to a turn's sink + observers
21
23
  # @param notify [#call] (text, level, source, extra:) → a notice
22
- def initialize(tools:, session:, state_dir:, can_stage:, reset_prompt:, emit:, notify:)
24
+ def initialize(tools:, session:, state_dir:, can_stage:, reset_prompt:, emit:, notify:, refresh_index: -> {})
23
25
  @tools = tools
24
26
  @session = session
25
27
  @state_dir = state_dir
26
28
  @can_stage = can_stage
27
29
  @reset_prompt = reset_prompt
30
+ @refresh_index = refresh_index
28
31
  @emit = emit
29
32
  @notify = notify
30
33
  # The toolkits plugins declare (chi.toolkit), beside the built-in and
@@ -63,7 +66,9 @@ module Samagotchi
63
66
  filter = resolved.everything? ? nil : resolved
64
67
  tools = @tools.call
65
68
  Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: tools.entries(toolkit: filter),
66
- left_out: tools.left_out_names(toolkit: filter), pending: filter != tools.toolkit)
69
+ left_out: tools.left_out_names(toolkit: filter),
70
+ reachable: tools.reachable_names(toolkit: filter),
71
+ removed: tools.removed_names(toolkit: filter), pending: filter != tools.toolkit)
67
72
  end
68
73
 
69
74
  # The session's toolkit for the UIs (the snapshot, command_ran): nil on
@@ -114,6 +119,7 @@ module Samagotchi
114
119
  return false if resolved == tools.toolkit
115
120
 
116
121
  tools.toolkit = resolved
122
+ @refresh_index.call
117
123
  @reset_prompt.call
118
124
  true
119
125
  end
@@ -7,7 +7,8 @@ require_relative "toolkit"
7
7
  module Samagotchi
8
8
  # /toolkit, the session's toolkit (Session#toolkit): with no argument,
9
9
  # the tools its next turn declares with their estimated tokens, the
10
- # toolkit and the tools it leaves out; `/toolkit NAME` puts the session
10
+ # toolkit and the tools it leaves out (the ones find_tools reaches, the
11
+ # ones it removes); `/toolkit NAME` puts the session
11
12
  # on another toolkit (its own adds and removes go), `/toolkit add TOOL`
12
13
  # and `/toolkit remove TOOL` change one tool. A change is saved at once
13
14
  # and applies at the next turn's start (Engine#apply_toolkit!). The
@@ -57,7 +58,9 @@ module Samagotchi
57
58
  [[listing(after), "From the next turn's start (a running turn keeps its tools)."].join("\n"), true, nil]
58
59
  end
59
60
 
60
- # The toolkit, its declared tools with tokens, and the ones left out.
61
+ # The toolkit, its declared tools with tokens, and the ones left out:
62
+ # reachable with find_tools / call_tool, removed (only /toolkit add
63
+ # brings one back), or, with call_tool not declared, not loaded.
61
64
  def listing(explained)
62
65
  toolkit = explained.toolkit
63
66
  lines = ["toolkit: #{toolkit.label}#{" (applies at the next turn's start)" if explained.pending}"]
@@ -68,7 +71,13 @@ module Samagotchi
68
71
  lines << " declared (#{declared.size}, ~#{format_tokens(declared.sum { |entry| tokens(entry) })} tokens): " \
69
72
  "#{declared.map { |entry| "#{entry.name} ~#{format_tokens(tokens(entry))}" }.join(", ")}"
70
73
  left_out = explained.left_out
71
- lines << " not loaded: #{left_out.empty? ? "none" : left_out.join(", ")}"
74
+ if explained.reachable.empty?
75
+ lines << " not loaded: #{left_out.empty? ? "none" : left_out.join(", ")}"
76
+ else
77
+ route = explained.removed.include?("find_tools") ? "call_tool" : "find_tools"
78
+ lines << " reachable (#{route}): #{explained.reachable.join(", ")}"
79
+ lines << " removed: #{explained.removed.empty? ? "none" : explained.removed.join(", ")}"
80
+ end
72
81
  lines << " toolkits: #{@engine.toolkit_kits.keys.join(", ")}; #{USAGE.delete_prefix("usage: ")} changes it"
73
82
  lines.join("\n")
74
83
  end
@@ -101,8 +110,15 @@ module Samagotchi
101
110
  added = names(after) - names(before)
102
111
  removed = names(before) - names(after)
103
112
  parts = []
113
+ # Removed (hard: call_tool can't reach it), declared before or not;
114
+ # left out by the kit (soft); reachable now and not before.
115
+ hard = after.removed - before.removed
116
+ left = removed - hard
117
+ soft = after.reachable - before.reachable - removed
104
118
  parts << "declares #{added.join(", ")}" unless added.empty?
105
- parts << "leaves out #{removed.join(", ")}" unless removed.empty?
119
+ parts << "leaves out #{left.join(", ")}" unless left.empty?
120
+ parts << "removes #{hard.join(", ")} (call_tool can't reach #{hard.size == 1 ? "it" : "them"})" unless hard.empty?
121
+ parts << "lets call_tool reach #{soft.join(", ")}" unless soft.empty?
106
122
  parts << "declares the same tools under its own name" if parts.empty?
107
123
  "Switching to #{after.toolkit.label} #{parts.join("; ")}."
108
124
  end
@@ -63,6 +63,14 @@ module Samagotchi
63
63
  # called_as: with the model's own spelling.
64
64
  ALIASES = { "bash" => "execute" }.freeze
65
65
 
66
+ # The tool index's call tool, whose call of a built-in runs as that
67
+ # built-in (ToolIndex#unwrap; ToolIndex::CALL_NAME is this), and the
68
+ # group it names them by. The one place the name is spelled.
69
+ VIA = "call_tool"
70
+ VIA_GROUP = "chi/"
71
+ # A saved run's "[name]" lead (ToolResponse::LEAD).
72
+ SAVED_LEAD = /\A\[([^\]\s]+)\]/
73
+
66
74
  # One built-in's mapping, resolved from its schema and OVERRIDES.
67
75
  Row = Data.define(:name, :content_keys, :path_key, :fields, :aliases, :verbatim, :fallback, :fallback_key,
68
76
  :options) do
@@ -97,7 +105,11 @@ module Samagotchi
97
105
  unknown = ([path_key].compact + Array(o[:verbatim]) + Array(o[:also]) + o.fetch(:aliases, {}).keys) - properties
98
106
  unknown << content_keys.join("/") unless content_keys.empty? || content_keys.intersect?(properties)
99
107
  raise ArgumentError, "#{name}: overrides name no schema property: #{unknown.join(", ")}" unless unknown.empty?
100
- raise ArgumentError, "#{name}: a property would replace the call's name" if fields.include?("name")
108
+ # A property can't set the call's name, nor the fields only chi sets
109
+ # (an alias's called_as, an unwrapped call's via).
110
+ if (reserved = fields & %w[name called_as via]).any?
111
+ raise ArgumentError, "#{name}: a property would replace the call's #{reserved.first}"
112
+ end
101
113
 
102
114
  Row.new(name: name, content_keys: content_keys, path_key: path_key, fields: fields,
103
115
  aliases: o.fetch(:aliases, {}), verbatim: Array(o[:verbatim]), fallback: o[:fallback],
@@ -135,6 +147,38 @@ module Samagotchi
135
147
  call
136
148
  end
137
149
 
150
+ # A saved call as it ran (a reload, the stale-read pairing): a call_tool
151
+ # of a built-in ("chi/delegate", or a bare "delegate") is that
152
+ # built-in's call with via: "call_tool" when +output+ (its saved
153
+ # result) shows it ran as that built-in: its "[name]" lead, or, with
154
+ # no lead, an error that names it (refused by its name before the
155
+ # gate); any other call as it is (a call_tool that ran, refused or
156
+ # not, leads with "[call_tool]").
157
+ # @param call [Hash] as #build gives it
158
+ # @param output [String, nil]
159
+ # @return [Hash]
160
+ def unwrap_saved(call, output)
161
+ return call unless call[:name] == VIA && call[:args].is_a?(Hash)
162
+
163
+ name = call[:args]["tool"].to_s.strip.delete_prefix(VIA_GROUP)
164
+ return call unless row(name) && ran_as?(output.to_s, name)
165
+
166
+ build(name, saved_args(call[:args]["args"])).merge(via: VIA)
167
+ end
168
+
169
+ def ran_as?(output, name)
170
+ lead = output[SAVED_LEAD, 1]
171
+ lead ? lead == name : output.start_with?("Error: #{name} ")
172
+ end
173
+
174
+ # call_tool's inner args as saved: an object, or its JSON text.
175
+ def saved_args(args)
176
+ args = JSON.parse(args) if args.is_a?(String) && !args.strip.empty?
177
+ args.is_a?(Hash) ? args : {}
178
+ rescue JSON::ParserError
179
+ {}
180
+ end
181
+
138
182
  # An execute-shaped call the model spelled +name+: built as its +target+,
139
183
  # tagged with the model's spelling for the activity event and the log.
140
184
  def aliased(name, target, args, raw)
@@ -66,7 +66,10 @@ module Samagotchi
66
66
  return child_id if child_id.start_with?("Error:")
67
67
 
68
68
  note = warning ? "Warning: #{warning}\n" : ""
69
- return note + DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
69
+ if wait
70
+ result = DelegateWait.call(child_id, peers: peers, timeout: timeout)
71
+ return RefText.wrap(note + result, result.respond_to?(:ref_path) ? result.ref_path : nil)
72
+ end
70
73
 
71
74
  started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
72
75
  "#{note}session: #{child_id}\nstatus: running\n#{started}; #{running_hint(peers)} " \
@@ -7,6 +7,7 @@ require_relative "output_guardrails"
7
7
  require_relative "peers"
8
8
  require_relative "delegate_relay"
9
9
  require_relative "delegate_cursor"
10
+ require_relative "ref_text"
10
11
 
11
12
  module Samagotchi
12
13
  # Loaded on first use: child_reports requires this file (a circular
@@ -78,7 +79,8 @@ module Samagotchi
78
79
  unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
79
80
  outcomes.concat(relayed_outcomes.delete(key) || [])
80
81
  advance(peers.session_id, child_id, wait, state_dir: sd)
81
- return with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
82
+ text = with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
83
+ return RefText.wrap(text, reply_path(child_id, wait, state_dir: sd))
82
84
  end
83
85
 
84
86
  outcome = DelegateRelay.call(child_id, wait.question, relay: relay, state_dir: sd, more: others&.count.to_i)
@@ -104,6 +106,15 @@ module Samagotchi
104
106
  })
105
107
  end
106
108
 
109
+ # The child's reply file a :done wait read (its output/ file), which
110
+ # ToolRunner's cut of a long reply names (ToolOutputCut's
111
+ # :head_tail_ref); nil for any other outcome.
112
+ def reply_path(child_id, wait, state_dir:)
113
+ return nil unless wait.status == :done && wait.file
114
+
115
+ File.join(ReplyWait.reply_dir(child_id, state_dir: state_dir), wait.file)
116
+ end
117
+
107
118
  # The relayed approvals' outcome lines, after the status line.
108
119
  def with_outcomes(text, outcomes)
109
120
  return text if outcomes.empty?
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Tools
5
+ # A tool's text that shows part of a file kept elsewhere in whole: a
6
+ # task's log (task_wait's output tail), a child's reply file (delegate,
7
+ # delegate_result). It is the text itself (a String), like
8
+ # Plugin::ToolResult; #ref_path is the file, which ToolRunner's cut
9
+ # names when the text is longer than max_tool_output_chars
10
+ # (ToolOutputCut's :head_tail_ref), instead of saving a copy;
11
+ # #stderr_path, a handed-over execute's stderr log, the cut names too.
12
+ class RefText < String
13
+ attr_reader :ref_path, :stderr_path
14
+
15
+ def initialize(text, ref_path, stderr_path: nil)
16
+ @ref_path = ref_path
17
+ @stderr_path = stderr_path.to_s.empty? ? nil : stderr_path
18
+ super(text.to_s)
19
+ end
20
+
21
+ # +text+ as a RefText of +ref_path+; +text+ as it is with no path.
22
+ def self.wrap(text, ref_path, stderr_path: nil)
23
+ ref_path.to_s.empty? ? text : new(text, ref_path, stderr_path: stderr_path)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -26,18 +26,33 @@ module Samagotchi
26
26
  # @!attribute layer [Symbol, nil] the LLM context layer the tool
27
27
  # belongs to (LLMContextStrategy): offered only in a turn running
28
28
  # under it (forget_outputs, :forget); nil: always
29
- Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :refs, :source, :layer, keyword_init: true) do
29
+ # @!attribute unwrap [#call, nil] (call, layers) → the call it stands
30
+ # for, run in its place before the guardrail gate (call_tool on one
31
+ # of chi's own tools: Tools::ToolIndex#unwrap), or nil to run it as
32
+ # it is; nil on every other tool
33
+ Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :refs, :source, :layer, :unwrap,
34
+ keyword_init: true) do
30
35
  def core? = source == "core"
31
36
 
32
37
  # Offered in a turn under +layers+ (the turn's LLM context layers).
33
38
  def offered?(layers) = layer.nil? || Array(layers).include?(layer)
34
39
  end
35
40
 
41
+ # What Tools::ToolIndex#declare registered: the entries of +source+
42
+ # are the index's search and call tools, +call_name+ the call tool's
43
+ # name, and +sourced+ whether a source (an MCP server's bundle)
44
+ # registered when the plugins loaded.
45
+ Index = Data.define(:source, :call_name, :sourced)
46
+
36
47
  def initialize
37
48
  @entries = {}
38
49
  @toolkit = nil
50
+ @index = nil
39
51
  end
40
52
 
53
+ # The tool index's declaration (Index), nil without one.
54
+ attr_accessor :index
55
+
41
56
  # The session's resolved toolkit (Toolkit::Resolved), which the lists
42
57
  # below and #offered filter by; nil declares every tool (full). The
43
58
  # Engine sets it at a turn's start, before the prompt is built, and
@@ -45,11 +60,12 @@ module Samagotchi
45
60
  attr_accessor :toolkit
46
61
 
47
62
  # @return [Entry]
48
- def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil)
63
+ def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil,
64
+ unwrap: nil)
49
65
  raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
50
66
 
51
67
  @entries[name] = Entry.new(name: name, schema: schema, handler: handler, label: label, preview: preview,
52
- targets: targets, refs: refs, source: source, layer: layer)
68
+ targets: targets, refs: refs, source: source, layer: layer, unwrap: unwrap)
53
69
  end
54
70
 
55
71
  # Remove a tool (a plugin's tool set changed after load).
@@ -70,18 +86,48 @@ module Samagotchi
70
86
  end
71
87
 
72
88
  # The entry +name+ names when the session's toolkit leaves it out (a
73
- # real tool the model can't call here), nil otherwise.
89
+ # real tool the model can't call here, by its name), nil otherwise.
74
90
  # @return [Entry, nil]
75
91
  def left_out(name, layers: [])
76
92
  entry = self[name]
77
- entry if entry&.offered?(layers) && !declared?(entry)
93
+ entry if entry && left_out?(entry, layers, @toolkit)
78
94
  end
79
95
 
80
96
  # The tools +toolkit+ (the session's by default) leaves out of a turn
81
- # under +layers+.
97
+ # under +layers+. The tool index's own aren't, unless it removes them:
98
+ # they are declared when there is something to reach.
82
99
  # @return [Array<String>] in declaration order
83
100
  def left_out_names(layers: [], toolkit: @toolkit)
84
- ordered(@entries.values.select { |entry| entry.offered?(layers) && !declared?(entry, toolkit) }).map(&:name)
101
+ ordered(@entries.values.select { |entry| left_out?(entry, layers, toolkit) }).map(&:name)
102
+ end
103
+
104
+ # The built-in +name+ names when the session's toolkit leaves it out
105
+ # but the tool index reaches it (#reachable_names).
106
+ # @return [Entry, nil]
107
+ def indexed(name, layers: [], toolkit: @toolkit)
108
+ entry = self[name]
109
+ entry if entry && call_offered?(toolkit) && soft?(entry, layers, toolkit)
110
+ end
111
+
112
+ # The built-ins +toolkit+ leaves out of a turn under +layers+ that the
113
+ # tool index reaches (call_tool): not in its kit's list, but not
114
+ # removed either (remove:, --no-tool, /toolkit remove), with call_tool
115
+ # itself declared.
116
+ # @return [Array<String>] in declaration order
117
+ def reachable_names(layers: [], toolkit: @toolkit)
118
+ return [] unless call_offered?(toolkit)
119
+
120
+ ordered(@entries.values.select { |entry| soft?(entry, layers, toolkit) }).map(&:name)
121
+ end
122
+
123
+ # The tools +toolkit+ removes from a turn under +layers+ (remove:,
124
+ # --no-tool, /toolkit remove): only the user brings them back.
125
+ # @return [Array<String>] in declaration order
126
+ def removed_names(layers: [], toolkit: @toolkit)
127
+ return [] if toolkit.nil?
128
+
129
+ ordered(@entries.values.select { |entry| entry.offered?(layers) && toolkit.removed.include?(entry.name) })
130
+ .map(&:name)
85
131
  end
86
132
 
87
133
  # Every tool +source+ registered, declared or not (a plugin's tool set
@@ -112,7 +158,48 @@ module Samagotchi
112
158
 
113
159
  private
114
160
 
115
- def declared?(entry, toolkit = @toolkit) = toolkit.nil? || toolkit.declares?(entry)
161
+ # Declared by +toolkit+ (nil: every tool, full). The tool index's
162
+ # tools are declared when they reach something, whatever the toolkit:
163
+ # a source registered at load, or a built-in it leaves out but
164
+ # doesn't remove (#index_needed?); unless it removes them.
165
+ def declared?(entry, toolkit = @toolkit)
166
+ if index_entry?(entry)
167
+ !toolkit&.removed&.include?(entry.name) && index_needed?(toolkit)
168
+ else
169
+ toolkit.nil? || toolkit.declares?(entry)
170
+ end
171
+ end
172
+
173
+ def index_entry?(entry) = !@index.nil? && entry.source == @index.source
174
+
175
+ # From what the plugins registered at load and the toolkit alone, so
176
+ # the declared tools stay the same for a session's life. A built-in
177
+ # left out counts only while call_tool can reach it (not removed).
178
+ def index_needed?(toolkit)
179
+ return true if @index.sourced
180
+ return false if toolkit&.removed&.include?(@index.call_name)
181
+
182
+ @entries.each_value.any? { |entry| soft?(entry, [], toolkit) }
183
+ end
184
+
185
+ def left_out?(entry, layers, toolkit)
186
+ return false unless entry.offered?(layers) && !declared?(entry, toolkit)
187
+
188
+ !index_entry?(entry) || toolkit&.removed&.include?(entry.name) == true
189
+ end
190
+
191
+ # A built-in +toolkit+ leaves out of a turn under +layers+ without
192
+ # removing it.
193
+ def soft?(entry, layers, toolkit)
194
+ !toolkit.nil? && entry.core? && entry.offered?(layers) && !toolkit.removed.include?(entry.name) &&
195
+ !toolkit.declares?(entry)
196
+ end
197
+
198
+ # Whether +toolkit+ declares the index's call tool.
199
+ def call_offered?(toolkit)
200
+ entry = @index && self[@index.call_name]
201
+ !entry.nil? && declared?(entry, toolkit)
202
+ end
116
203
 
117
204
  def ordered(entries)
118
205
  core, added = entries.partition(&:core?)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "task_runtime"
4
+ require_relative "ref_text"
4
5
 
5
6
  module Samagotchi
6
7
  module Tools
@@ -121,7 +122,9 @@ module Samagotchi
121
122
  lines << output_tail.rstrip unless output_tail.empty?
122
123
  stderr_tail = stderr_tail(record, tail_lines)
123
124
  lines.concat(["stderr_tail:", stderr_tail.rstrip]) unless stderr_tail.empty?
124
- lines.join("\n")
125
+ # The whole log is at output_path (stderr at stderr_path): a cut of
126
+ # this text names them.
127
+ RefText.wrap(lines.join("\n"), record.output_path, stderr_path: record.stderr_path)
125
128
  end
126
129
  private_class_method :format_response
127
130