samagotchi 0.119.0 → 0.121.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/bin/chi +6 -1
  4. data/docs/architecture.md +35 -5
  5. data/docs/cli.md +33 -8
  6. data/docs/configuration.md +74 -20
  7. data/docs/internals/participants.md +111 -12
  8. data/docs/internals/prompt-caching.md +7 -6
  9. data/docs/internals/remote-sessions.md +6 -1
  10. data/docs/internals/web-push.md +4 -2
  11. data/docs/plugins.md +24 -8
  12. data/docs/sessions.md +4 -3
  13. data/docs/testing.md +13 -2
  14. data/lib/samagotchi/answer_command.rb +2 -0
  15. data/lib/samagotchi/bridge.rb +56 -13
  16. data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
  17. data/lib/samagotchi/bridge_client.rb +58 -19
  18. data/lib/samagotchi/bridge_secret.rb +35 -0
  19. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/config.rb +3 -0
  26. data/lib/samagotchi/engine.rb +1 -0
  27. data/lib/samagotchi/kernel_loop.rb +5 -0
  28. data/lib/samagotchi/participant_messages.rb +5 -0
  29. data/lib/samagotchi/participants.rb +16 -6
  30. data/lib/samagotchi/plugin/api.rb +6 -3
  31. data/lib/samagotchi/review_command.rb +3 -1
  32. data/lib/samagotchi/run_options.rb +3 -1
  33. data/lib/samagotchi/session_access/contract.rb +4 -1
  34. data/lib/samagotchi/session_access/local.rb +15 -4
  35. data/lib/samagotchi/session_access/remote.rb +43 -7
  36. data/lib/samagotchi/session_access.rb +11 -1
  37. data/lib/samagotchi/session_manager.rb +1 -1
  38. data/lib/samagotchi/sessions_command.rb +1 -2
  39. data/lib/samagotchi/share_command.rb +29 -8
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
  41. data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  43. data/lib/samagotchi/tool_declarations.rb +1 -1
  44. data/lib/samagotchi/tool_runner.rb +37 -1
  45. data/lib/samagotchi/toolkit.rb +50 -26
  46. data/lib/samagotchi/toolkit_binding.rb +32 -0
  47. data/lib/samagotchi/toolkit_command.rb +23 -0
  48. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  49. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  50. data/lib/samagotchi/tools/registry.rb +20 -6
  51. data/lib/samagotchi/tools/tool_index.rb +103 -20
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +117 -43
  54. data/lib/samagotchi/web/grants.rb +127 -16
  55. data/lib/samagotchi/web/link_codes.rb +24 -15
  56. data/lib/samagotchi/web/participant_access.rb +69 -13
  57. data/lib/samagotchi/web/participant_exposure.rb +44 -0
  58. data/lib/samagotchi/web/participant_review.rb +71 -9
  59. data/lib/samagotchi/web/participants_admin.rb +12 -6
  60. data/lib/samagotchi/web/proxy_gate.rb +18 -3
  61. data/lib/samagotchi/web/public/annotate_bar.css +18 -0
  62. data/lib/samagotchi/web/public/annotate_bar.js +10 -3
  63. data/lib/samagotchi/web/public/app.js +66 -155
  64. data/lib/samagotchi/web/public/composer.css +73 -0
  65. data/lib/samagotchi/web/public/composer_grip.js +21 -15
  66. data/lib/samagotchi/web/public/composer_mount.js +196 -0
  67. data/lib/samagotchi/web/public/forms.css +12 -0
  68. data/lib/samagotchi/web/public/index.html +14 -88
  69. data/lib/samagotchi/web/public/level_chip.js +2 -2
  70. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  71. data/lib/samagotchi/web/public/notifications.js +58 -8
  72. data/lib/samagotchi/web/public/participant.html +46 -10
  73. data/lib/samagotchi/web/public/participant.js +101 -18
  74. data/lib/samagotchi/web/public/participant_annotate.js +75 -0
  75. data/lib/samagotchi/web/public/participant_model.js +53 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +37 -13
  77. data/lib/samagotchi/web/public/question_card.js +15 -1
  78. data/lib/samagotchi/web/public/question_cards.js +11 -2
  79. data/lib/samagotchi/web/public/review_history.js +60 -0
  80. data/lib/samagotchi/web/public/scope.js +12 -8
  81. data/lib/samagotchi/web/public/stage_frame.js +9 -4
  82. data/lib/samagotchi/web/public/stage_view.js +4 -4
  83. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  84. data/lib/samagotchi/web/push/notifier.rb +17 -3
  85. data/lib/samagotchi/web/running.rb +7 -4
  86. data/lib/samagotchi/web/server.rb +34 -11
  87. data/lib/samagotchi/web/session_create.rb +7 -7
  88. data/lib/samagotchi/web/start_dir.rb +26 -0
  89. data/lib/samagotchi/web/tunnel.rb +10 -1
  90. data/lib/samagotchi/worker_sidecar.rb +22 -11
  91. metadata +10 -1
@@ -84,7 +84,8 @@ module Samagotchi
84
84
  # names, reachable and removed ones (the not-loaded line, find_tools'
85
85
  # chi line), and, when either list has any, the kit name the line gives.
86
86
  def tools_key
87
- [declared.map(&:name), reachable, removed, reachable.empty? && removed.empty? ? nil : resolved.name]
87
+ [declared.map(&:name), reachable, removed, resolved.groups,
88
+ reachable.empty? && removed.empty? ? nil : resolved.name]
88
89
  end
89
90
 
90
91
  # What the prompt carries of it, including the kit's prompt text. Two
@@ -92,14 +93,16 @@ module Samagotchi
92
93
  def prompt_key = [*tools_key, resolved.prompt]
93
94
 
94
95
  # What the UIs show (the snapshot, command_ran): nil when it declares
95
- # every tool on full (full, full +delegate), which has no chip.
96
+ # every tool on full (full, full +delegate), which has no chip unless
97
+ # its groups are filtered.
96
98
  # +left_out+ is +reachable+ and +removed+ together; +prompt+ (the
97
99
  # kit's text) only when the kit has one.
98
100
  def summary
99
- return nil if toolkit.name == Toolkit::FULL && left_out.empty? && resolved.fallback.nil?
101
+ return nil if toolkit.name == Toolkit::FULL && left_out.empty? && resolved.fallback.nil? &&
102
+ resolved.groups == :all
100
103
 
101
104
  summary = { name: toolkit.name, label: toolkit.label, left_out: left_out, reachable: reachable,
102
- removed: removed, fallback: resolved.fallback }
105
+ removed: removed, fallback: resolved.fallback, groups: resolved.groups }
103
106
  resolved.prompt ? summary.merge(prompt: resolved.prompt) : summary
104
107
  end
105
108
  end
@@ -107,17 +110,31 @@ module Samagotchi
107
110
  # Tool names as a list of unique, stripped strings.
108
111
  def self.tool_names(names) = Array(names).map { |tool| tool.to_s.strip }.reject(&:empty?).uniq
109
112
 
113
+ # Group names/globs as a stable list, or :all. Selectors match against
114
+ # the source's declaration-time group snapshot.
115
+ def self.group_names(groups)
116
+ return :all if groups == :all
117
+
118
+ Array(groups).map { |group| group.to_s.strip }.reject(&:empty?).uniq.sort.freeze
119
+ end
120
+
121
+ def self.group_matches?(selectors, name)
122
+ selectors == :all || selectors.any? { |selector| File.fnmatch?(selector, name.to_s) }
123
+ end
124
+
110
125
  # A named list of declared tools: +tools+ (:all, every built-in; or the
111
126
  # built-in names) when it has no +base+, else its +base+'s with +add+
112
127
  # and +remove+. +source+: "core", "config", or the bundle that declared
113
128
  # it (chi.toolkit). +prompt+: text the sessions on it get in their
114
129
  # system prompt, verbatim (stripped); nil inherits the base's, "" clears
115
- # it.
116
- Kit = Data.define(:name, :base, :tools, :add, :remove, :source, :prompt) do
117
- def initialize(name:, base: nil, tools: nil, add: [], remove: [], source: "core", prompt: nil)
130
+ # it. +groups+: :all or a sorted list of source group names/globs; nil
131
+ # inherits the base's.
132
+ Kit = Data.define(:name, :base, :tools, :add, :remove, :source, :prompt, :groups) do
133
+ def initialize(name:, base: nil, tools: nil, add: [], remove: [], source: "core", prompt: nil, groups: nil)
118
134
  super(name: name.to_s, base: base&.to_s, tools: tools == :all ? :all : Toolkit.tool_names(tools),
119
135
  add: Toolkit.tool_names(add), remove: Toolkit.tool_names(remove), source: source.to_s,
120
- prompt: prompt.is_a?(String) ? prompt.strip.freeze : nil)
136
+ prompt: prompt.is_a?(String) ? prompt.strip.freeze : nil,
137
+ groups: groups.nil? ? nil : Toolkit.group_names(groups))
121
138
  end
122
139
  end
123
140
 
@@ -150,13 +167,13 @@ module Samagotchi
150
167
  REVIEW_TOOLS = %w[read memory_read].freeze
151
168
 
152
169
  BUILTIN_KITS = [
153
- Kit.new(name: FULL, tools: :all),
154
- Kit.new(name: LEAN, tools: LEAN_TOOLS),
170
+ Kit.new(name: FULL, tools: :all, groups: :all),
171
+ Kit.new(name: LEAN, tools: LEAN_TOOLS, groups: :all),
155
172
  # A delegate child: it reports to its parent (send_note; the
156
173
  # delegated-by line names it) and leaves memory writes to it, and
157
174
  # starts no children of its own (removed: not even through call_tool).
158
175
  Kit.new(name: CHILD, base: LEAN, add: %w[send_note], remove: %w[memory_write delegate delegate_result]),
159
- Kit.new(name: REVIEW, tools: REVIEW_TOOLS)
176
+ Kit.new(name: REVIEW, tools: REVIEW_TOOLS, groups: [])
160
177
  ].to_h { |kit| [kit.name, kit] }.freeze
161
178
 
162
179
  # What a toolkit declares, the Registry's filter: every built-in
@@ -165,16 +182,19 @@ module Samagotchi
165
182
  # +added+ declares any registered tool. +fallback+: why the session's
166
183
  # own toolkit couldn't be resolved (this is FULL then), nil when it was.
167
184
  # +prompt+: the kit's prompt text (its own or its base's), nil without.
168
- Resolved = Data.define(:name, :all_core, :core, :added, :removed, :fallback, :prompt) do
169
- def initialize(name:, all_core: false, core: [], added: [], removed: [], fallback: nil, prompt: nil)
185
+ # +groups+: :all or the sorted source group names/globs it reaches.
186
+ Resolved = Data.define(:name, :all_core, :core, :added, :removed, :fallback, :prompt, :groups) do
187
+ def initialize(name:, all_core: false, core: [], added: [], removed: [], fallback: nil, prompt: nil, groups: :all)
170
188
  super(name: name.to_s, all_core: all_core, core: core.dup.freeze, added: added.dup.freeze,
171
- removed: removed.dup.freeze, fallback: fallback, prompt: prompt)
189
+ removed: removed.dup.freeze, fallback: fallback, prompt: prompt, groups: Toolkit.group_names(groups))
172
190
  end
173
191
 
174
- # Every built-in, nothing removed and no prompt text: what no toolkit
175
- # declares (the Registry's nil). A kit on full's tools with a prompt
176
- # is kept, so its text reaches the system prompt.
177
- def everything? = all_core && removed.empty? && prompt.nil?
192
+ # Whether a declared source group is reached by this toolkit.
193
+ def reaches_group?(name) = Toolkit.group_matches?(groups, name)
194
+
195
+ # Every built-in, nothing removed, no prompt text and no group filter:
196
+ # what no toolkit declares (the Registry's nil).
197
+ def everything? = all_core && removed.empty? && prompt.nil? && groups == :all
178
198
 
179
199
  # @param entry [Tools::Registry::Entry]
180
200
  def declares?(entry)
@@ -215,7 +235,7 @@ module Samagotchi
215
235
  new(name: name.empty? ? FULL : name)
216
236
  end
217
237
 
218
- # config.yml's toolkit.kits (a map of name => {base:, add:, remove:}),
238
+ # config.yml's toolkit.kits (a map of name => {base:, add:, remove:, groups:}),
219
239
  # nil when it has none.
220
240
  def self.config_kits
221
241
  section = ConfigFile.section("toolkit")
@@ -260,10 +280,13 @@ module Samagotchi
260
280
  base = if kit.base
261
281
  resolve_kit(kit.base, kits, seen + [name])
262
282
  else
263
- Resolved.new(name: name, all_core: kit.tools == :all, core: kit.tools == :all ? [] : kit.tools)
283
+ Resolved.new(name: name, all_core: kit.tools == :all, core: kit.tools == :all ? [] : kit.tools,
284
+ groups: kit.groups || :all)
264
285
  end
265
286
  prompt = kit.prompt.nil? ? base.prompt : kit.prompt
266
- base.changed(add: kit.add, remove: kit.remove).with(name: name, prompt: prompt.to_s.empty? ? nil : prompt)
287
+ groups = kit.groups.nil? ? base.groups : kit.groups
288
+ base.changed(add: kit.add, remove: kit.remove)
289
+ .with(name: name, prompt: prompt.to_s.empty? ? nil : prompt, groups: groups)
267
290
  end
268
291
 
269
292
  # config.yml's toolkit.kits.review, refused: the review kit is the
@@ -272,13 +295,13 @@ module Samagotchi
272
295
  "name your kit something else"
273
296
 
274
297
  # The keys a config kit entry takes.
275
- KIT_KEYS = %w[base add remove prompt].freeze
298
+ KIT_KEYS = %w[base add remove prompt groups].freeze
276
299
 
277
300
  # The kits known: the built-ins, then config's toolkit.kits, then the
278
301
  # plugins' (chi.toolkit). A config kit with a built-in's name is left
279
302
  # out (one it can't redefine; review, the participants' boundary, is
280
303
  # reserved) with a line in +errors+, and so is one
281
- # that isn't a {base:, add:, remove:, prompt:} map. One with another key
304
+ # that isn't a {base:, add:, remove:, prompt:, groups:} map. One with another key
282
305
  # (a typo, a tools: list) still loads, with a line naming the key, and
283
306
  # so does one whose prompt isn't text or is over PROMPT_MAX characters:
284
307
  # without the prompt, with a line saying why.
@@ -295,18 +318,19 @@ module Samagotchi
295
318
  elsif kits.key?(name)
296
319
  errors << "toolkit.kits.#{name}: #{name} is a built-in toolkit"
297
320
  elsif !spec.is_a?(Hash)
298
- errors << "toolkit.kits.#{name}: expected {base:, add:, remove:, prompt:}"
321
+ errors << "toolkit.kits.#{name}: expected {base:, add:, remove:, prompt:, groups:}"
299
322
  else
300
323
  spec = spec.transform_keys(&:to_s)
301
324
  (spec.keys - KIT_KEYS).each do |key|
302
- errors << "toolkit.kits.#{name}: unknown key #{key} (a kit takes base:, add:, remove:, prompt:)"
325
+ errors << "toolkit.kits.#{name}: unknown key #{key} " \
326
+ "(a kit takes base:, add:, remove:, prompt:, groups:)"
303
327
  end
304
328
  if (problem = prompt_error(spec["prompt"]))
305
329
  errors << "toolkit.kits.#{name}: #{problem}; the kit loads without it"
306
330
  spec.delete("prompt")
307
331
  end
308
332
  kits[name] = Kit.new(name: name, base: spec["base"] || FULL, add: spec["add"], remove: spec["remove"],
309
- prompt: spec["prompt"], source: "config")
333
+ prompt: spec["prompt"], groups: spec["groups"], source: "config")
310
334
  end
311
335
  end
312
336
  plugins.each { |kit| kits[kit.name] ||= kit }
@@ -116,8 +116,10 @@ module Samagotchi
116
116
  errors = []
117
117
  resolved = Toolkit.of(session&.toolkit).resolve(kits(errors))
118
118
  warn_unresolved(session, resolved, errors, on_event)
119
+ warn_unknown_groups(resolved, on_event)
119
120
  resolved = nil if resolved.everything?
120
121
  tools = @tools.call
122
+ tools.reaching_kit = ->(group) { reaching_kit(group) }
121
123
  return false if resolved == tools.toolkit
122
124
 
123
125
  tools.toolkit = resolved
@@ -126,6 +128,19 @@ module Samagotchi
126
128
  true
127
129
  end
128
130
 
131
+ # The first kit that resolves and reaches source group +group+, the
132
+ # user's own (config's, the plugins') before the built-ins, so a hint
133
+ # names "/toolkit web" rather than "full"; nil when none does.
134
+ def reaching_kit(group)
135
+ kits = self.kits
136
+ builtin = Toolkit::BUILTIN_KITS.keys
137
+ kits.keys.sort_by.with_index { |name, i| [builtin.include?(name) ? 1 : 0, i] }.find do |name|
138
+ Toolkit.resolve_kit(name, kits).reaches_group?(group)
139
+ rescue Toolkit::Unresolved
140
+ false
141
+ end
142
+ end
143
+
129
144
  private
130
145
 
131
146
  # The toolkit's load warnings, each once per process: a name that
@@ -142,5 +157,22 @@ module Samagotchi
142
157
  @emit.call(on_event, { type: :guardrail_warning, message: line, label: "toolkit" })
143
158
  end
144
159
  end
160
+
161
+ def warn_unknown_groups(resolved, on_event)
162
+ return if resolved.groups == :all
163
+
164
+ names = @tools.call.index&.groups || []
165
+ unknown = resolved.groups.reject do |selector|
166
+ names.any? { |name| Toolkit.group_matches?([selector], name) }
167
+ end
168
+ unknown.each do |selector|
169
+ line = "#{resolved.name}: group selector #{selector.inspect} matches no group declared at load"
170
+ next if @warned.include?(line)
171
+
172
+ @warned << line
173
+ Log.warn(:config, "toolkit_group_unknown", message: line)
174
+ @emit.call(on_event, { type: :guardrail_warning, message: line, label: "toolkit" })
175
+ end
176
+ end
145
177
  end
146
178
  end
@@ -71,6 +71,15 @@ module Samagotchi
71
71
  lines << " declared (#{declared.size}, ~#{format_tokens(declared.sum { |entry| tokens(entry) })} tokens): " \
72
72
  "#{declared.map { |entry| "#{entry.name} ~#{format_tokens(tokens(entry))}" }.join(", ")}"
73
73
  lines << prompt_line(explained.resolved.prompt) if explained.resolved.prompt
74
+ groups = explained.resolved.groups
75
+ group_names = if groups == :all
76
+ "all"
77
+ elsif groups.empty?
78
+ "none"
79
+ else
80
+ groups.join(", ")
81
+ end
82
+ lines << " groups: #{group_names}"
74
83
  left_out = explained.left_out
75
84
  if explained.reachable.empty?
76
85
  lines << " not loaded: #{left_out.empty? ? "none" : left_out.join(", ")}"
@@ -123,10 +132,24 @@ module Samagotchi
123
132
  # The kit's prompt text (Toolkit::Kit#prompt) is in the prompt too.
124
133
  text = after.resolved.prompt
125
134
  parts << (text ? "sends its kit's prompt text" : "drops the kit's prompt text") if text != before.resolved.prompt
135
+ parts.concat(group_changes(before.resolved.groups, after.resolved.groups))
126
136
  parts << "declares the same tools under its own name" if parts.empty?
127
137
  "Switching to #{after.toolkit.label} #{parts.join("; ")}."
128
138
  end
129
139
 
140
+ def group_changes(before, after)
141
+ return [] if before == after
142
+ return ["reaches all groups"] if after == :all
143
+ return ["limits groups to #{after.empty? ? "none" : after.join(", ")}"] if before == :all
144
+
145
+ added = after - before
146
+ removed = before - after
147
+ changes = []
148
+ changes << "reaches groups #{added.join(", ")}" unless added.empty?
149
+ changes << "leaves out groups #{removed.join(", ")}" unless removed.empty?
150
+ changes
151
+ end
152
+
130
153
  # What the confirm says is read again: from the tools on (the front of
131
154
  # every request), or from the kit's prompt text on when only that
132
155
  # changes (Explained#tools_key is unchanged: only a kit's prompt text).
@@ -39,7 +39,7 @@ module Samagotchi
39
39
  next if taken.include?(name) || registry.offered(name, layers: layers)
40
40
 
41
41
  taken << name
42
- deferred << Deferred.new(name: name, description: STUB_DESCRIPTION, schema: STUB_SCHEMA)
42
+ deferred << Deferred.new(name: name, description: stub_description(registry, name), schema: STUB_SCHEMA)
43
43
  end
44
44
  deferred
45
45
  end
@@ -74,7 +74,15 @@ module Samagotchi
74
74
  end
75
75
  end
76
76
 
77
- private_class_method :builtins, :sourced
77
+ def stub_description(registry, name)
78
+ index = registry.respond_to?(:index) ? registry.index : nil
79
+ tool = index&.unreached_tool(name)
80
+ return STUB_DESCRIPTION unless tool
81
+
82
+ ToolIndex::ToolkitGroupRefusal.for(tool, registry).stub_description
83
+ end
84
+
85
+ private_class_method :builtins, :sourced, :stub_description
78
86
  end
79
87
  end
80
88
  end
@@ -139,6 +139,10 @@ module Samagotchi
139
139
  def delivered(response, answer, fields, dismissed)
140
140
  return "not delivered (the child's worker is gone)" unless response
141
141
 
142
+ # The child's Bridge asked for a secret this worker didn't send (one
143
+ # from before the child's chi): not the child's guardrails refusing.
144
+ return "not delivered (#{BridgeSecret::REFUSED})" if response.secret_refused?
145
+
142
146
  hook = fields[:kind] == HOOK_KIND
143
147
  case response.status
144
148
  when 200
@@ -40,13 +40,18 @@ module Samagotchi
40
40
 
41
41
  # What Tools::ToolIndex#declare registered: the entries of +source+
42
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. +deferrable+ (→ the sources'
43
+ # name, and +groups+ the source groups registered when the plugins
44
+ # loaded. +deferrable+ (→ the sources'
45
45
  # tools a host that loads tools may declare deferred) and +wrap+
46
46
  # (call → the call_tool call a direct call of one of them runs as, or
47
47
  # nil) are nil when nothing reaches them.
48
- Index = Data.define(:source, :call_name, :sourced, :deferrable, :wrap) do
49
- def initialize(source:, call_name:, sourced:, deferrable: nil, wrap: nil) = super
48
+ Index = Data.define(:source, :call_name, :groups, :deferrable, :wrap, :unreached) do
49
+ def initialize(source:, call_name:, groups:, deferrable: nil, wrap: nil, unreached: nil)
50
+ super(source: source, call_name: call_name, groups: Array(groups).map(&:to_s).uniq.freeze,
51
+ deferrable: deferrable, wrap: wrap, unreached: unreached)
52
+ end
53
+
54
+ def unreached_tool(name) = unreached&.call(name)
50
55
  end
51
56
 
52
57
  def initialize
@@ -64,6 +69,11 @@ module Samagotchi
64
69
  # only there: a request's declared tools never change mid-turn.
65
70
  attr_accessor :toolkit
66
71
 
72
+ # Which kit reaches a source group: a lookup (group name → kit name,
73
+ # nil when none does) the Engine sets with the toolkit; a refusal's
74
+ # hint names it.
75
+ attr_accessor :reaching_kit
76
+
67
77
  # @return [Entry]
68
78
  def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil,
69
79
  unwrap: nil)
@@ -181,7 +191,7 @@ module Samagotchi
181
191
  # the declared tools stay the same for a session's life. A built-in
182
192
  # left out counts only while call_tool can reach it (not removed).
183
193
  def index_needed?(toolkit)
184
- return true if @index.sourced
194
+ return true if reached_groups?(toolkit)
185
195
  return false if toolkit&.removed&.include?(@index.call_name)
186
196
 
187
197
  index_reaches?(toolkit)
@@ -191,7 +201,11 @@ module Samagotchi
191
201
  # removed or not: a source registered at load, or a built-in the
192
202
  # toolkit leaves out without removing it.
193
203
  def index_reaches?(toolkit)
194
- @index.sourced || @entries.values.any? { |entry| soft?(entry, [], toolkit) }
204
+ reached_groups?(toolkit) || @entries.values.any? { |entry| soft?(entry, [], toolkit) }
205
+ end
206
+
207
+ def reached_groups?(toolkit)
208
+ @index.groups.any? { |name| toolkit.nil? || toolkit.reaches_group?(name) }
195
209
  end
196
210
 
197
211
  # Not declared by +toolkit+ under +layers+. An index tool counts only
@@ -63,6 +63,37 @@ module Samagotchi
63
63
  args: { type: "object", description: "the arguments, per the inputSchema #{FIND_NAME} gave" }
64
64
  }.freeze
65
65
 
66
+ # The user changes kits; calls and old deferred references cannot
67
+ # make a source group reachable again.
68
+ class ToolkitGroupRefusal < StandardError
69
+ RULE = "toolkit-group"
70
+
71
+ # The refusal of +tool+ under +registry+'s toolkit, naming a kit that
72
+ # reaches its group when the registry can tell (Registry#reaching_kit).
73
+ def self.for(tool, registry)
74
+ new(tool, registry.toolkit, kit: registry.respond_to?(:reaching_kit) ? registry.reaching_kit&.call(tool.group) : nil)
75
+ end
76
+
77
+ def initialize(tool, toolkit, kit: nil)
78
+ @tool = tool
79
+ @toolkit = toolkit.name
80
+ @kit = kit
81
+ super("Error: #{tool.full_name} isn't in this session's toolkit (#{@toolkit}); #{hint}.")
82
+ end
83
+
84
+ def stub_description
85
+ "Not available in this session's toolkit (#{@toolkit}); #{hint}."
86
+ end
87
+
88
+ private
89
+
90
+ def hint
91
+ return "only the user can switch (/toolkit NAME, or a kit that reaches #{@tool.group})" unless @kit
92
+
93
+ "only the user can switch (/toolkit #{@kit}, which reaches #{@tool.group})"
94
+ end
95
+ end
96
+
66
97
  # One tool in the index: +group+/+name+ is what the call tool takes,
67
98
  # +schema+ its inputSchema, +acts_as+ the name guardrail rules match
68
99
  # (mcp_<server>_<tool>; a bare name the call tool looks up too).
@@ -141,7 +172,7 @@ module Samagotchi
141
172
  # yet). +prefix+: a bare name with it is taken as an acts_as as given.
142
173
  # +label+ is the call tool's row label.
143
174
  class Source
144
- attr_reader :label, :prefix, :context, :groups, :declared_names, :declared_tokens
175
+ attr_reader :label, :prefix, :context, :groups, :declared_names, :declared_tokens, :declared_groups
145
176
 
146
177
  def initialize(label:, call:, acts_as:, wait: nil, prefix: nil, context: nil)
147
178
  raise ArgumentError, "tool_source: call must respond to #call" unless call.respond_to?(:call)
@@ -157,6 +188,7 @@ module Samagotchi
157
188
  @groups = [].freeze
158
189
  @declared_names = []
159
190
  @declared_tokens = 0
191
+ @declared_groups = [].freeze
160
192
  end
161
193
 
162
194
  def inspect = "#<ToolIndex::Source #{@label} groups=#{@groups.map(&:name).join(",")}>"
@@ -184,9 +216,10 @@ module Samagotchi
184
216
 
185
217
  # What ToolIndex#declare registered: the tools' names and their
186
218
  # definitions' estimated tokens (the mcp bundle's /mcp says them).
187
- def declared!(names, tokens)
219
+ def declared!(names, tokens, groups)
188
220
  @declared_names = names.dup.freeze
189
221
  @declared_tokens = tokens
222
+ @declared_groups = groups.dup.freeze
190
223
  end
191
224
  end
192
225
 
@@ -196,7 +229,8 @@ module Samagotchi
196
229
  @registry = nil
197
230
  # The source groups' lines in the search tool's description, as
198
231
  # #declare found them (#find_description).
199
- @described = [].freeze
232
+ @described = {}.freeze
233
+ @declared_group_names = nil
200
234
  @dropped = []
201
235
  @dropped_mutex = Mutex.new
202
236
  end
@@ -216,7 +250,7 @@ module Samagotchi
216
250
  # sources'. A source group with a reserved name, or one an earlier
217
251
  # group has, is left out of search, lookup and the description (said
218
252
  # once per name: a source publishes after load too).
219
- def groups
253
+ def all_groups
220
254
  own = registry_groups
221
255
  taken = own.map(&:name) | RESERVED_GROUPS
222
256
  kept = @sources.flat_map(&:groups).select do |group|
@@ -230,6 +264,17 @@ module Samagotchi
230
264
  own + kept
231
265
  end
232
266
 
267
+ def reached_groups
268
+ groups = all_groups
269
+ toolkit = @registry&.toolkit
270
+ return groups if toolkit.nil? || toolkit.groups == :all
271
+
272
+ declared = @declared_group_names || groups.reject { |group| own?(group) }.map(&:name)
273
+ groups.select do |group|
274
+ own?(group) || (declared.include?(group.name) && toolkit.reaches_group?(group.name))
275
+ end
276
+ end
277
+
233
278
  # Register the search and call tools in +registry+, once, after the
234
279
  # plugins load (or without them: the built-ins a toolkit leaves out
235
280
  # are reachable either way); +registry+ declares them per toolkit
@@ -238,7 +283,8 @@ module Samagotchi
238
283
  # @return [Array<String>] the names registered
239
284
  def declare(registry, failures: nil)
240
285
  @registry = registry
241
- @described = description_lines(groups.reject { |group| own?(group) }).freeze
286
+ @described = description_lines(all_groups.reject { |group| own?(group) }).freeze
287
+ @declared_group_names = @described.keys.freeze
242
288
  specs = [find_spec, call_spec]
243
289
  declared = specs.filter_map do |spec|
244
290
  if (taken = registry[spec.name])
@@ -251,8 +297,9 @@ module Samagotchi
251
297
  registry.register(spec.name, source: SOURCE, unwrap: unwrap, **spec.entry_fields(nil))
252
298
  spec
253
299
  end
254
- registry.index = Registry::Index.new(source: SOURCE, call_name: CALL_NAME, sourced: !@sources.empty?,
255
- deferrable: -> { deferrable }, wrap: ->(call) { wrap(call) })
300
+ registry.index = Registry::Index.new(source: SOURCE, call_name: CALL_NAME, groups: @declared_group_names,
301
+ deferrable: -> { deferrable }, wrap: ->(call) { wrap(call) },
302
+ unreached: ->(name) { unreached_tool(name) })
256
303
  declared!(declared)
257
304
  declared.map(&:name)
258
305
  end
@@ -293,7 +340,7 @@ module Samagotchi
293
340
  def find_description
294
341
  "Search tools this session doesn't declare, by keywords. It answers up to #{FIND_RESULTS} tools, each with " \
295
342
  "its name (<group>/<tool>) and inputSchema; call one with #{CALL_NAME}. An empty query lists every tool's " \
296
- "name.\nGroups:\n#{(description_lines(registry_groups) + @described).join("\n")}"
343
+ "name.\nGroups:\n#{(description_lines(registry_groups).values + described_groups.values).join("\n")}"
297
344
  end
298
345
 
299
346
  # ── Search ──────────────────────────────────────────────────────────
@@ -372,9 +419,10 @@ module Samagotchi
372
419
  # same list in any session and after a respawn.
373
420
  # @return [Array<Tool>]
374
421
  def deferrable
375
- tools = groups.reject { |group| own?(group) }.flat_map(&:tools)
376
- counts = tools.map(&:acts_as).tally
377
- tools.select do |tool|
422
+ all = all_groups.reject { |group| own?(group) }.flat_map(&:tools)
423
+ reached = reached_groups.reject { |group| own?(group) }.flat_map(&:tools)
424
+ counts = all.map(&:acts_as).tally
425
+ reached.select do |tool|
378
426
  tool.refusal.nil? && counts[tool.acts_as] == 1 && tool.acts_as.match?(DEFERRABLE_NAME) &&
379
427
  !@registry&.key?(tool.acts_as)
380
428
  end.sort_by(&:acts_as)
@@ -397,6 +445,22 @@ module Samagotchi
397
445
  deferrable.any? { |t| t.acts_as == tool.acts_as } ? tool.acts_as : nil
398
446
  end
399
447
 
448
+ # Finds a source tool regardless of the active toolkit, without
449
+ # waiting for a starting group. Used only to refuse a call or explain
450
+ # a deferred stub for a group the session cannot reach.
451
+ def unreached_tool(name)
452
+ name = name.to_s.strip
453
+ return nil if name.empty?
454
+
455
+ groups = all_groups.reject { |group| own?(group) }
456
+ groups.select! { |group| @declared_group_names.include?(group.name) } if @declared_group_names
457
+ tool = tool_in(name, groups)
458
+ return nil unless tool
459
+ return nil if reached_groups.any? { |group| group.name == tool.group }
460
+
461
+ tool
462
+ end
463
+
400
464
  # What a deferred source tool's wire name may be (Messages' tool
401
465
  # name rule).
402
466
  DEFERRABLE_NAME = /\A[a-zA-Z0-9_-]{1,128}\z/
@@ -466,7 +530,7 @@ module Samagotchi
466
530
  end
467
531
 
468
532
  def find_in(query, only)
469
- all = groups
533
+ all = reached_groups
470
534
  searched = all
471
535
  if only && !only.strip.empty?
472
536
  searched = all.select { |group| group.name == only.strip }
@@ -502,7 +566,14 @@ module Samagotchi
502
566
  def declared!(specs)
503
567
  names = specs.map(&:name)
504
568
  tokens = specs.sum { |spec| ToolIndex.spec_tokens(spec) }
505
- @sources.each { |source| source.declared!(names, tokens) }
569
+ groups = reached_groups.reject { |group| own?(group) }.map(&:name)
570
+ @sources.each { |source| source.declared!(names, tokens, groups) }
571
+ end
572
+
573
+ def described_groups
574
+ return @described unless @registry&.toolkit && @registry.toolkit.groups != :all
575
+
576
+ @described.select { |name, _line| @registry.toolkit.reaches_group?(name) }
506
577
  end
507
578
 
508
579
  def own?(group) = RESERVED_GROUPS.include?(group.name)
@@ -536,16 +607,16 @@ module Samagotchi
536
607
  # One line per group in the search tool's description: its name, its
537
608
  # summary and how many tools it can call.
538
609
  def description_lines(groups)
539
- groups.map do |group|
610
+ groups.to_h do |group|
540
611
  count = group.callable.size
541
612
  line = "- #{group.name}"
542
613
  line += ": #{group.summary}" if group.summary
543
614
  line += " (#{count} tool#{"s" unless count == 1})" unless group.tools.empty?
544
- line
615
+ [group.name, line]
545
616
  end
546
617
  end
547
618
 
548
- # Say once per name that a source's group was left out (#groups).
619
+ # Say once per name that a source's group was left out (#all_groups).
549
620
  def dropped(name, why)
550
621
  first = @dropped_mutex.synchronize { @dropped.include?(name) ? false : (@dropped << name) }
551
622
  return unless first
@@ -614,7 +685,7 @@ module Samagotchi
614
685
  # @return [Tool, String]
615
686
  def lookup(name, waited: false)
616
687
  name = name.strip
617
- all = groups
688
+ all = reached_groups
618
689
  tool = all.lazy.filter_map do |group|
619
690
  next unless name.start_with?("#{group.name}/")
620
691
 
@@ -632,8 +703,8 @@ module Samagotchi
632
703
 
633
704
  # A name with a group's <group>/ waits for that group only; any
634
705
  # other name (an acts_as) for every one starting.
635
- named = all.select { |group| name.start_with?("#{group.name}/") }
636
- starting = (named.empty? ? all : named).select { |group| group.state == :starting }
706
+ named = reached_groups.select { |group| name.start_with?("#{group.name}/") }
707
+ starting = (named.empty? ? reached_groups : named).select { |group| group.state == :starting }
637
708
  unless waited || starting.empty?
638
709
  starting.each { |group| source_of(group.name)&.wait(group.name) }
639
710
  return lookup(name, waited: true)
@@ -654,6 +725,18 @@ module Samagotchi
654
725
  tool.is_a?(Tool) ? tool : nil
655
726
  end
656
727
 
728
+ def tool_in(name, groups)
729
+ named = groups.filter_map do |group|
730
+ next unless name.start_with?("#{group.name}/")
731
+
732
+ group.tools.find { |tool| tool.name == name.delete_prefix("#{group.name}/") }
733
+ end
734
+ return named.first if named.one?
735
+
736
+ same = groups.flat_map(&:tools).select { |tool| tool.acts_as == name }
737
+ same.first if same.one?
738
+ end
739
+
657
740
  # The acts_as and label +name+ stands for, from the name alone:
658
741
  # <group>/<tool> of a known group (the one that has the tool, else the
659
742
  # first whose name it starts with), its source naming the acts_as; a
@@ -662,7 +745,7 @@ module Samagotchi
662
745
  # anything else.
663
746
  # @return [Array(String, String), Array(String, nil), nil]
664
747
  def claimed(name)
665
- all = groups
748
+ all = all_groups
666
749
  if (own = all.select { |g| own?(g) }.find { |g| g.tools.any? { |t| t.acts_as == name } })
667
750
  return [name, "#{own.name}: #{name}"]
668
751
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.119.0"
4
+ VERSION = "0.121.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end