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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -1
- data/bin/chi +6 -1
- data/docs/architecture.md +35 -5
- data/docs/cli.md +33 -8
- data/docs/configuration.md +74 -20
- data/docs/internals/participants.md +111 -12
- data/docs/internals/prompt-caching.md +7 -6
- data/docs/internals/remote-sessions.md +6 -1
- data/docs/internals/web-push.md +4 -2
- data/docs/plugins.md +24 -8
- data/docs/sessions.md +4 -3
- data/docs/testing.md +13 -2
- data/lib/samagotchi/answer_command.rb +2 -0
- data/lib/samagotchi/bridge.rb +56 -13
- data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
- data/lib/samagotchi/bridge_client.rb +58 -19
- data/lib/samagotchi/bridge_secret.rb +35 -0
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +5 -0
- data/lib/samagotchi/participant_messages.rb +5 -0
- data/lib/samagotchi/participants.rb +16 -6
- data/lib/samagotchi/plugin/api.rb +6 -3
- data/lib/samagotchi/review_command.rb +3 -1
- data/lib/samagotchi/run_options.rb +3 -1
- data/lib/samagotchi/session_access/contract.rb +4 -1
- data/lib/samagotchi/session_access/local.rb +15 -4
- data/lib/samagotchi/session_access/remote.rb +43 -7
- data/lib/samagotchi/session_access.rb +11 -1
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/sessions_command.rb +1 -2
- data/lib/samagotchi/share_command.rb +29 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
- data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
- data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/toolkit.rb +50 -26
- data/lib/samagotchi/toolkit_binding.rb +32 -0
- data/lib/samagotchi/toolkit_command.rb +23 -0
- data/lib/samagotchi/tools/deferred_tools.rb +10 -2
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/registry.rb +20 -6
- data/lib/samagotchi/tools/tool_index.rb +103 -20
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -43
- data/lib/samagotchi/web/grants.rb +127 -16
- data/lib/samagotchi/web/link_codes.rb +24 -15
- data/lib/samagotchi/web/participant_access.rb +69 -13
- data/lib/samagotchi/web/participant_exposure.rb +44 -0
- data/lib/samagotchi/web/participant_review.rb +71 -9
- data/lib/samagotchi/web/participants_admin.rb +12 -6
- data/lib/samagotchi/web/proxy_gate.rb +18 -3
- data/lib/samagotchi/web/public/annotate_bar.css +18 -0
- data/lib/samagotchi/web/public/annotate_bar.js +10 -3
- data/lib/samagotchi/web/public/app.js +66 -155
- data/lib/samagotchi/web/public/composer.css +73 -0
- data/lib/samagotchi/web/public/composer_grip.js +21 -15
- data/lib/samagotchi/web/public/composer_mount.js +196 -0
- data/lib/samagotchi/web/public/forms.css +12 -0
- data/lib/samagotchi/web/public/index.html +14 -88
- data/lib/samagotchi/web/public/level_chip.js +2 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
- data/lib/samagotchi/web/public/notifications.js +58 -8
- data/lib/samagotchi/web/public/participant.html +46 -10
- data/lib/samagotchi/web/public/participant.js +101 -18
- data/lib/samagotchi/web/public/participant_annotate.js +75 -0
- data/lib/samagotchi/web/public/participant_model.js +53 -1
- data/lib/samagotchi/web/public/participants_panel.js +37 -13
- data/lib/samagotchi/web/public/question_card.js +15 -1
- data/lib/samagotchi/web/public/question_cards.js +11 -2
- data/lib/samagotchi/web/public/review_history.js +60 -0
- data/lib/samagotchi/web/public/scope.js +12 -8
- data/lib/samagotchi/web/public/stage_frame.js +9 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -4
- data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
- data/lib/samagotchi/web/push/notifier.rb +17 -3
- data/lib/samagotchi/web/running.rb +7 -4
- data/lib/samagotchi/web/server.rb +34 -11
- data/lib/samagotchi/web/session_create.rb +7 -7
- data/lib/samagotchi/web/start_dir.rb +26 -0
- data/lib/samagotchi/web/tunnel.rb +10 -1
- data/lib/samagotchi/worker_sidecar.rb +22 -11
- metadata +10 -1
data/lib/samagotchi/toolkit.rb
CHANGED
|
@@ -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,
|
|
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
|
-
#
|
|
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
|
-
#
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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}
|
|
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:
|
|
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
|
-
|
|
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 +
|
|
44
|
-
#
|
|
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, :
|
|
49
|
-
def initialize(source:, call_name:,
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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,
|
|
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) +
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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 (#
|
|
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 =
|
|
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 =
|
|
636
|
-
starting = (named.empty? ?
|
|
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 =
|
|
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
|