samagotchi 0.151.0 → 0.153.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/docs/bundle-authoring.md +5 -5
  4. data/docs/bundles.md +84 -0
  5. data/docs/cli.md +35 -10
  6. data/docs/configuration.md +19 -14
  7. data/docs/guardrails.md +11 -5
  8. data/docs/hooks.md +4 -3
  9. data/docs/internals/anthropic.md +3 -3
  10. data/docs/internals/attached-context.md +3 -0
  11. data/docs/internals/block-renderers.md +3 -1
  12. data/docs/internals/bundles.md +48 -14
  13. data/docs/internals/engine.md +9 -5
  14. data/docs/internals/objectives.md +6 -4
  15. data/docs/internals/participants.md +1 -1
  16. data/docs/internals/prompt-caching.md +7 -3
  17. data/docs/internals/web-frontend.md +20 -17
  18. data/docs/memory.md +6 -1
  19. data/docs/plugins.md +80 -29
  20. data/docs/releasing.md +8 -3
  21. data/docs/sessions.md +2 -2
  22. data/docs/testing.md +2 -2
  23. data/lib/samagotchi/block_renderers.rb +9 -8
  24. data/lib/samagotchi/bundle_command/check.rb +43 -3
  25. data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
  27. data/lib/samagotchi/bundle_command.rb +88 -10
  28. data/lib/samagotchi/bundle_needs.rb +4 -6
  29. data/lib/samagotchi/bundle_settings_check.rb +14 -13
  30. data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
  38. data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
  39. data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
  40. data/lib/samagotchi/child_env.rb +15 -0
  41. data/lib/samagotchi/context_providers.rb +42 -10
  42. data/lib/samagotchi/engine.rb +37 -9
  43. data/lib/samagotchi/extension_load.rb +4 -3
  44. data/lib/samagotchi/guardrail_wiring.rb +28 -19
  45. data/lib/samagotchi/guardrails/approval.rb +2 -2
  46. data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
  47. data/lib/samagotchi/guardrails/rules.rb +1 -0
  48. data/lib/samagotchi/guardrails/verdict.rb +20 -0
  49. data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
  50. data/lib/samagotchi/llm_context_stale.rb +2 -2
  51. data/lib/samagotchi/memory_bundle/asset_installer.rb +11 -8
  52. data/lib/samagotchi/memory_bundle/builder.rb +36 -30
  53. data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
  54. data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
  55. data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -46
  57. data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -15
  58. data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
  59. data/lib/samagotchi/memory_bundle/installer.rb +90 -16
  60. data/lib/samagotchi/memory_bundle/manifest.rb +146 -6
  61. data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
  62. data/lib/samagotchi/memory_bundle/merger.rb +7 -8
  63. data/lib/samagotchi/memory_bundle/profile.rb +3 -2
  64. data/lib/samagotchi/memory_bundle/provenance.rb +217 -18
  65. data/lib/samagotchi/memory_bundle/shas.rb +4 -3
  66. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
  67. data/lib/samagotchi/memory_bundle/status.rb +11 -10
  68. data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
  69. data/lib/samagotchi/message_parts.rb +3 -2
  70. data/lib/samagotchi/plugin/loader.rb +1 -1
  71. data/lib/samagotchi/session_config.rb +5 -4
  72. data/lib/samagotchi/system_one/record.rb +10 -2
  73. data/lib/samagotchi/toolkit.rb +8 -5
  74. data/lib/samagotchi/toolkit_binding.rb +30 -5
  75. data/lib/samagotchi/toolkit_report_command.rb +9 -5
  76. data/lib/samagotchi/toolkit_suggest.rb +12 -6
  77. data/lib/samagotchi/tools/builtin_calls.rb +14 -10
  78. data/lib/samagotchi/tools/deferred_tools.rb +2 -2
  79. data/lib/samagotchi/tools/delegate.rb +20 -2
  80. data/lib/samagotchi/tools/registry.rb +44 -20
  81. data/lib/samagotchi/tools/tool_index.rb +27 -21
  82. data/lib/samagotchi/update_command.rb +13 -2
  83. data/lib/samagotchi/version.rb +1 -1
  84. data/lib/samagotchi/web/app.rb +2 -1
  85. data/lib/samagotchi/web/public/app.js +61 -357
  86. data/lib/samagotchi/web/public/composer_mode.js +110 -0
  87. data/lib/samagotchi/web/public/owner_claim.js +81 -0
  88. data/lib/samagotchi/web/public/owner_verify.js +112 -0
  89. data/lib/samagotchi/web/public/session_fields.js +3 -3
  90. data/lib/samagotchi/web/public/strip_grip.js +3 -3
  91. data/lib/samagotchi/web/public/strip_hidden.js +65 -0
  92. data/lib/samagotchi/web/public/version_notices.js +132 -0
  93. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  94. data/lib/samagotchi/web/session_hub.rb +6 -0
  95. data/lib/samagotchi/worker.rb +6 -0
  96. metadata +11 -1
@@ -249,10 +249,13 @@ module Samagotchi
249
249
  Kit.new(name: REVIEW, tools: REVIEW_TOOLS, groups: [])
250
250
  ].to_h { |kit| [kit.name, kit] }.freeze
251
251
 
252
- # What a toolkit declares, the Registry's filter: every built-in
253
- # (+all_core+) or the ones in +core+, every plugin tool and every tool
254
- # an LLM context layer gates (forget_outputs), but for +removed+;
255
- # +added+ declares any registered tool. +fallback+: why the session's
252
+ # What a toolkit declares, the Registry's filter: every registered tool,
253
+ # built-ins and plugin tools (chi.tool) alike (+all_core+, full), or the
254
+ # built-ins in +core+, and every tool an LLM context layer gates
255
+ # (forget_outputs), but for +removed+; +added+ declares any registered
256
+ # tool. A tool it leaves out without removing is reachable through the
257
+ # tool index (Registry#reachable_names). The index's own tools
258
+ # (find_tools, call_tool) are the Registry's to declare. +fallback+: why the session's
256
259
  # own toolkit couldn't be resolved (this is FULL then), nil when it was.
257
260
  # +prompt+: the kit's prompt text (its own or its base's), nil without.
258
261
  # +groups+: :all or the sorted source group names/globs it reaches.
@@ -272,7 +275,7 @@ module Samagotchi
272
275
  # @param entry [Tools::Registry::Entry]
273
276
  def declares?(entry)
274
277
  return false if removed.include?(entry.name)
275
- return true if added.include?(entry.name) || !entry.core? || !entry.layer.nil?
278
+ return true if added.include?(entry.name) || !entry.layer.nil?
276
279
 
277
280
  all_core || core.include?(entry.name)
278
281
  end
@@ -38,6 +38,8 @@ module Samagotchi
38
38
  # config ones (#kits); Plugin::Registries appends to it.
39
39
  @plugin_kits = []
40
40
  @warned = []
41
+ # The tools #warn_stranded said once.
42
+ @stranded = []
41
43
  end
42
44
 
43
45
  # @return [Array<Toolkit::Kit>] the plugins' kits (Plugin::Registries' toolkits:)
@@ -192,13 +194,36 @@ module Samagotchi
192
194
  resolved = nil if resolved.everything?
193
195
  tools = @tools.call
194
196
  tools.reaching_kit = ->(group) { reaching_kit(group) }
195
- return false if resolved == tools.toolkit
197
+ changed = resolved != tools.toolkit
198
+ if changed
199
+ tools.toolkit = resolved
200
+ @refresh_index.call
201
+ @reset_prompt.call
202
+ end
203
+ warn_stranded(tools)
204
+ changed
205
+ end
196
206
 
197
- tools.toolkit = resolved
198
- @refresh_index.call
199
- @reset_prompt.call
200
- true
207
+ # "web_x isn't declared on toolkit lean, and call_tool can't reach it
208
+ # this session: it arrived after the session's tools were set ·
209
+ # /toolkit add web_x declares it"
210
+ def self.stranded_notice(tool, toolkit)
211
+ "#{tool} isn't declared on toolkit #{toolkit}, and call_tool can't reach it this session: it arrived after " \
212
+ "the session's tools were set · /toolkit add #{tool} declares it"
213
+ end
214
+
215
+ # A notice (by its bundle, once per tool and worker) for each plugin
216
+ # tool that arrived mid-session on a kit that leaves it out without
217
+ # the tool index to reach it (Tools::Registry#stranded_names).
218
+ def warn_stranded(tools)
219
+ return unless tools.respond_to?(:stranded_names)
220
+
221
+ (tools.stranded_names - @stranded).each do |name|
222
+ @stranded << name
223
+ @notify.call(self.class.stranded_notice(name, tools.toolkit.name), :warn, tools[name]&.source.to_s, extra: {})
224
+ end
201
225
  end
226
+ private :warn_stranded
202
227
 
203
228
  # The first kit that resolves and reaches source group +group+, the
204
229
  # user's own (config's, the plugins') before the built-ins, so a hint
@@ -241,7 +241,9 @@ module Samagotchi
241
241
  def grade(entry, kit, uses)
242
242
  kits = entry["kits"].is_a?(Hash) ? entry["kits"] : {}
243
243
  rules = kits[kit]
244
- core_tools = Array(entry["core_tools"])
244
+ # The tools a kit's rules declare: the built-ins and the plugin tools
245
+ # (chi.tool) logged when the ask started (an older line has none).
246
+ core_tools = Array(entry["core_tools"]) | Array(entry["plugin_tools"])
245
247
  return ["ungradable", " (no rules logged for #{kit})"] unless rules.is_a?(Hash)
246
248
  return ["ungradable", " (tool calls not readable)"] if uses == :ungradable
247
249
 
@@ -259,8 +261,10 @@ module Samagotchi
259
261
  end
260
262
 
261
263
  # Whether a kit's rules declare +use+ (Toolkit::Resolved#declares?): a
262
- # tool-index group by the kit's groups; a built-in by its core list;
263
- # any other tool (a plugin's) unless the kit removes it.
264
+ # tool-index group by the kit's groups; a logged built-in or plugin
265
+ # tool (+core_tools+) by the kit's list (all of them on full) and its
266
+ # adds; any other (the index's own, a layer's, one that registered
267
+ # after the ask) unless the kit removes it.
264
268
  def covers?(rules, core_tools, use)
265
269
  return group_reached?(rules["groups"], use.group) if use.group && use.group != "chi"
266
270
 
@@ -276,8 +280,8 @@ module Samagotchi
276
280
  (declared & core_tools) - Array(rules["removed"])
277
281
  end
278
282
 
279
- # +other+ declares no more built-ins and reaches no more groups than
280
- # +rules+, and less of one of them.
283
+ # +other+ declares no more logged tools (built-ins and plugin tools)
284
+ # and reaches no more groups than +rules+, and less of one of them.
281
285
  def narrower?(other, rules, core_tools)
282
286
  mine = core_set(rules, core_tools)
283
287
  theirs = core_set(other, core_tools)
@@ -31,8 +31,13 @@ module Samagotchi
31
31
  # A kit as the ask saw it: its name, classifier description and #rules.
32
32
  Candidate = Data.define(:name, :description, :rules)
33
33
  # +default+ is a Candidate (no description needed); +core_tools+ the
34
- # built-ins registered when the ask started.
35
- Snapshot = Data.define(:session_id, :turn_number, :default, :candidates, :core_tools, :state)
34
+ # built-ins registered when the ask started, +plugin_tools+ the plugin
35
+ # tools (chi.tool) registered then: a kit declares both by its rules.
36
+ Snapshot = Data.define(:session_id, :turn_number, :default, :candidates, :core_tools, :plugin_tools, :state) do
37
+ def initialize(session_id:, turn_number:, default:, candidates:, core_tools:, state:, plugin_tools: [])
38
+ super
39
+ end
40
+ end
36
41
  Suggestion = Data.define(:kit, :raw, :confidence, :decided)
37
42
  # One ask's outcome: its Suggestion, what on mode would do with it
38
43
  # (suggested, default, defer, below_threshold, invalid, no_judgment)
@@ -164,6 +169,7 @@ module Samagotchi
164
169
  decided: !!decided,
165
170
  would: would,
166
171
  core_tools: snapshot.core_tools,
172
+ plugin_tools: snapshot.plugin_tools,
167
173
  kits: [snapshot.default, *snapshot.candidates].to_h { |kit| [kit.name, kit.rules] }
168
174
  }
169
175
  Judged.new(suggestion: Suggestion.new(kit: selected, raw: raw, confidence: confidence, decided: !!decided),
@@ -319,10 +325,10 @@ module Samagotchi
319
325
  end
320
326
 
321
327
  # A resolved kit (Toolkit::Resolved) as frozen, JSON-ready rules, not
322
- # a tool list: plugin and MCP tools may still be loading when the ask
323
- # starts, and every kit declares a non-core tool anyway
324
- # (Resolved#declares?), so the report expands these against the tools
325
- # a turn really called (ToolkitReportCommand).
328
+ # a tool list: a plugin's replaced tools and MCP tools may still be
329
+ # loading when the ask starts, so the report expands these against
330
+ # the tools a turn really called (ToolkitReportCommand), the logged
331
+ # core_tools and plugin_tools by the same rule (Resolved#declares?).
326
332
  # @return [Hash{String => Object}]
327
333
  def self.rules(resolved)
328
334
  {
@@ -149,20 +149,23 @@ module Samagotchi
149
149
  end
150
150
 
151
151
  # A saved call as it ran (a reload, the stale-read pairing): a call_tool
152
- # of a built-in ("chi/delegate", or a bare "delegate") is that
153
- # built-in's call with via: "call_tool" when +output+ (its saved
154
- # result) shows it ran as that built-in: its "[name]" lead, or, with
155
- # no lead, an error that names it (refused by its name before the
156
- # gate); any other call as it is (a call_tool that ran, refused or
157
- # not, leads with "[call_tool]").
152
+ # of a chi tool, a built-in or a plugin's ("chi/delegate",
153
+ # "chi/web_search", or a bare "delegate") is that tool's call with
154
+ # via: "call_tool" when +output+ (its saved result) shows it ran as
155
+ # that tool: its "[name]" lead, or, with no lead, an error that names
156
+ # it (refused by its name before the gate; a plugin tool's only by
157
+ # its chi/ name, the one no MCP tool has); any other call as it is (a
158
+ # call_tool that ran, refused or not, leads with "[call_tool]"). No
159
+ # registry is asked: a reload without the plugin reads the same.
158
160
  # @param call [Hash] as #build gives it
159
161
  # @param output [String, nil]
160
162
  # @return [Hash]
161
163
  def unwrap_saved(call, output)
162
164
  return call unless call[:name] == VIA && call[:args].is_a?(Hash)
163
165
 
164
- name = call[:args]["tool"].to_s.strip.delete_prefix(VIA_GROUP)
165
- return call unless row(name) && ran_as?(output.to_s, name)
166
+ named = call[:args]["tool"].to_s.strip
167
+ name = named.delete_prefix(VIA_GROUP)
168
+ return call if name.empty? || name == VIA || !ran_as?(output.to_s, name, error: row(name) || named != name)
166
169
 
167
170
  build(name, saved_args(call[:args]["args"])).merge(via: VIA)
168
171
  end
@@ -183,9 +186,10 @@ module Samagotchi
183
186
  build(VIA, { "tool" => name, "args" => call[:args] })
184
187
  end
185
188
 
186
- def ran_as?(output, name)
189
+ # +error+: whether a lead-less error that names the tool counts.
190
+ def ran_as?(output, name, error: true)
187
191
  lead = output[SAVED_LEAD, 1]
188
- lead ? lead == name : output.start_with?("Error: #{name} ")
192
+ lead ? lead == name : error && output.start_with?("Error: #{name} ")
189
193
  end
190
194
 
191
195
  # call_tool's inner args as saved: an object, or its JSON text.
@@ -9,8 +9,8 @@ module Samagotchi
9
9
  # that loads tools (LLM::AnthropicMessages#loads_tools?): every tool the
10
10
  # session reaches but doesn't declare, so a find_tools hit can load it
11
11
  # and the model calls it by its own name. In order:
12
- # 1. the built-ins the toolkit leaves out but doesn't remove
13
- # (Registry#reachable_names), in registry order;
12
+ # 1. the chi tools (built-ins and plugin tools) the toolkit leaves out
13
+ # but doesn't remove (Registry#reachable_names), in registry order;
14
14
  # 2. the tool index's source tools (an MCP server's, by acts_as), only
15
15
  # while call_tool is declared (ToolIndex#deferrable: sorted by name);
16
16
  # 3. every other name the conversation loaded (a find result's
@@ -159,7 +159,8 @@ module Samagotchi
159
159
  # repository (a worktree the parent meant to pass as cwd:), which makes
160
160
  # every write there an ask for the user (child-boundary). One warning
161
161
  # line, however many are named; nil when the task names none, or names
162
- # only the checkout holding the child's folder (or a subfolder of it).
162
+ # only the checkout holding the child's folder (or a subfolder of it),
163
+ # or main while the child itself runs in a worktree of it.
163
164
  # Never raises: a started child must not come back as an Error:.
164
165
  # @return [String, nil]
165
166
  def self.other_checkout(task, folder, parent:, child_id:, no_cwd:)
@@ -180,6 +181,9 @@ module Samagotchi
180
181
  # not the one holding +folder+: the first path token in the task that
181
182
  # belongs to one (the deepest containing it), else nil. Reads files
182
183
  # only, no git process, so a spec stubbing Process.spawn is unaffected.
184
+ # A child started in a linked worktree is never pointed at main: its
185
+ # work belongs in the worktree, so naming a main-checkout file - mostly
186
+ # to read it - is not a cwd: the parent forgot.
183
187
  # @return [String, nil] an absolute checkout folder
184
188
  def self.foreign_checkout(task, folder)
185
189
  common = MemoryPaths.common_git_dir(folder)
@@ -187,17 +191,31 @@ module Samagotchi
187
191
 
188
192
  checkouts = Guardrails::GitInfo.checkouts(common).map { |checkout| File.realpath(checkout) }
189
193
  own = deepest_checkout(File.realpath(folder), checkouts)
194
+ main = main_checkout(common)
190
195
  task.to_s.scan(PATH_TOKEN) do |token|
191
196
  path = existing_ancestor(File.expand_path(token.sub(/[.:!?]+\z/, ""), folder))
192
197
  next if path.nil?
193
198
 
194
199
  found = deepest_checkout(path, checkouts)
195
- return found if found && found != own
200
+ next if found.nil? || found == own || found == main
201
+
202
+ return found
196
203
  end
197
204
  nil
198
205
  end
199
206
  private_class_method :foreign_checkout
200
207
 
208
+ # The main checkout of a common git dir (the folder holding it), nil for
209
+ # a bare or separate-git-dir layout, which has none.
210
+ def self.main_checkout(common)
211
+ return nil unless File.basename(common) == ".git"
212
+
213
+ File.realpath(File.dirname(common))
214
+ rescue StandardError
215
+ nil
216
+ end
217
+ private_class_method :main_checkout
218
+
201
219
  # The checkout containing +path+, matched by whole components (so
202
220
  # /x/app is not found in /x/app-fix): the deepest, so a nested worktree's
203
221
  # path belongs to that worktree, not to main; nil when none does.
@@ -41,14 +41,16 @@ module Samagotchi
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
43
  # name, and +groups+ the source groups registered when the plugins
44
- # loaded. +deferrable+ (→ the sources'
44
+ # loaded; +loaded+ the entries registered then (nil: whatever is
45
+ # registered now), which alone decide whether the index's tools are
46
+ # needed (Registry#index_needed?). +deferrable+ (→ the sources'
45
47
  # tools a host that loads tools may declare deferred) and +wrap+
46
48
  # (call → the call_tool call a direct call of one of them runs as, or
47
49
  # nil) are nil when nothing reaches them.
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
+ Index = Data.define(:source, :call_name, :groups, :deferrable, :wrap, :unreached, :loaded) do
51
+ def initialize(source:, call_name:, groups:, deferrable: nil, wrap: nil, unreached: nil, loaded: nil)
50
52
  super(source: source, call_name: call_name, groups: Array(groups).map(&:to_s).uniq.freeze,
51
- deferrable: deferrable, wrap: wrap, unreached: unreached)
53
+ deferrable: deferrable, wrap: wrap, unreached: unreached, loaded: loaded&.dup&.freeze)
52
54
  end
53
55
 
54
56
  def unreached_tool(name) = unreached&.call(name)
@@ -116,18 +118,18 @@ module Samagotchi
116
118
  ordered(@entries.values.select { |entry| left_out?(entry, layers, toolkit) }).map(&:name)
117
119
  end
118
120
 
119
- # The built-in +name+ names when the session's toolkit leaves it out
120
- # but the tool index reaches it (#reachable_names).
121
+ # The tool +name+ names (a built-in or a plugin's) when the session's
122
+ # toolkit leaves it out but the tool index reaches it (#reachable_names).
121
123
  # @return [Entry, nil]
122
124
  def indexed(name, layers: [], toolkit: @toolkit)
123
125
  entry = self[name]
124
126
  entry if entry && call_offered?(toolkit) && soft?(entry, layers, toolkit)
125
127
  end
126
128
 
127
- # The built-ins +toolkit+ leaves out of a turn under +layers+ that the
128
- # tool index reaches (call_tool): not in its kit's list, but not
129
- # removed either (remove:, --no-tool, /toolkit remove), with call_tool
130
- # itself declared.
129
+ # The tools +toolkit+ leaves out of a turn under +layers+ that the tool
130
+ # index reaches (call_tool), built-ins and plugin tools alike: not in
131
+ # its kit's list, but not removed either (remove:, --no-tool, /toolkit
132
+ # remove), with call_tool itself declared. Never the index's own.
131
133
  # @return [Array<String>] in declaration order
132
134
  def reachable_names(layers: [], toolkit: @toolkit)
133
135
  return [] unless call_offered?(toolkit)
@@ -145,6 +147,25 @@ module Samagotchi
145
147
  .map(&:name)
146
148
  end
147
149
 
150
+ # The tools +toolkit+ leaves out of a turn under +layers+ that a
151
+ # plugin registered after the index was declared (chi.replace_tools)
152
+ # and that nothing reaches: call_tool isn't declared (the index's
153
+ # need is the load-time one, so the declared tools don't grow
154
+ # mid-session), nor removed by the user. /toolkit add declares one.
155
+ # @return [Array<String>] in declaration order
156
+ def stranded_names(layers: [], toolkit: @toolkit)
157
+ return [] if @index&.loaded.nil? || toolkit.nil? || toolkit.removed.include?(@index.call_name)
158
+ return [] if call_offered?(toolkit)
159
+
160
+ loaded = @index.loaded.map(&:name)
161
+ ordered(@entries.values.select { |entry| !loaded.include?(entry.name) && soft?(entry, layers, toolkit) })
162
+ .map(&:name)
163
+ end
164
+
165
+ # Every entry registered now, declared or not, in registration order.
166
+ # @return [Array<Entry>]
167
+ def registered = @entries.values
168
+
148
169
  # Every tool +source+ registered, declared or not (a plugin's tool set
149
170
  # changing: Plugin::ToolSpec.apply).
150
171
  # @return [Array<Entry>]
@@ -175,7 +196,7 @@ module Samagotchi
175
196
 
176
197
  # Declared by +toolkit+ (nil: every tool, full). The tool index's
177
198
  # tools are declared when they reach something, whatever the toolkit:
178
- # a source registered at load, or a built-in it leaves out but
199
+ # a source registered at load, or a tool it leaves out but
179
200
  # doesn't remove (#index_needed?); unless it removes them.
180
201
  def declared?(entry, toolkit = @toolkit)
181
202
  if index_entry?(entry)
@@ -187,8 +208,10 @@ module Samagotchi
187
208
 
188
209
  def index_entry?(entry) = !@index.nil? && entry.source == @index.source
189
210
 
190
- # From what the plugins registered at load and the toolkit alone, so
191
- # the declared tools stay the same for a session's life. A built-in
211
+ # From what the plugins registered at load (Index#loaded) and the
212
+ # toolkit alone, so the declared tools stay the same for a session's
213
+ # life: a plugin tool registered later on a kit that leaves it out
214
+ # doesn't bring find_tools and call_tool in (#stranded_names). A tool
192
215
  # left out counts only while call_tool can reach it (not removed).
193
216
  def index_needed?(toolkit)
194
217
  return true if reached_groups?(toolkit)
@@ -198,10 +221,10 @@ module Samagotchi
198
221
  end
199
222
 
200
223
  # Whether the index's tools would reach something under +toolkit+,
201
- # removed or not: a source registered at load, or a built-in the
202
- # toolkit leaves out without removing it.
224
+ # removed or not: a source registered at load, or a tool (a built-in
225
+ # or a plugin's) the toolkit leaves out without removing it.
203
226
  def index_reaches?(toolkit)
204
- reached_groups?(toolkit) || @entries.values.any? { |entry| soft?(entry, [], toolkit) }
227
+ reached_groups?(toolkit) || (@index.loaded || @entries.values).any? { |entry| soft?(entry, [], toolkit) }
205
228
  end
206
229
 
207
230
  def reached_groups?(toolkit)
@@ -218,11 +241,12 @@ module Samagotchi
218
241
  toolkit&.removed&.include?(entry.name) == true && index_reaches?(toolkit)
219
242
  end
220
243
 
221
- # A built-in +toolkit+ leaves out of a turn under +layers+ without
222
- # removing it.
244
+ # A tool +toolkit+ leaves out of a turn under +layers+ without removing
245
+ # it: a built-in or a plugin's, never one of the index's own (they are
246
+ # declared when they reach something) or a layer's (always declared).
223
247
  def soft?(entry, layers, toolkit)
224
- !toolkit.nil? && entry.core? && entry.offered?(layers) && !toolkit.removed.include?(entry.name) &&
225
- !toolkit.declares?(entry)
248
+ !toolkit.nil? && !index_entry?(entry) && entry.layer.nil? && entry.offered?(layers) &&
249
+ !toolkit.removed.include?(entry.name) && !toolkit.declares?(entry)
226
250
  end
227
251
 
228
252
  # Whether +toolkit+ declares the index's call tool.
@@ -17,14 +17,15 @@ module Samagotchi
17
17
  # inputSchema, and call_tool, which calls one as <group>/<tool>.
18
18
  # A source (the mcp bundle, chi.tool_source) publishes its groups (an
19
19
  # MCP server each) and runs the calls; the index's own group, chi, is
20
- # the built-ins the session's toolkit leaves out but doesn't remove
21
- # (Registry#reachable_names), and a call_tool of one runs as that
22
- # built-in (#unwrap, before the guardrail gate). The index searches the
20
+ # the tools the session's toolkit leaves out but doesn't remove, chi's
21
+ # built-ins and its bundles' (chi.tool) alike (Registry#reachable_names),
22
+ # and a call_tool of one runs as that tool (#unwrap, before the
23
+ # guardrail gate). The index searches the
23
24
  # groups, looks a call's tool up and checks it.
24
25
  #
25
26
  # The two tools are registered once, after the plugins load, and
26
27
  # declared per toolkit (Registry#declared?): when a source registered
27
- # then, or the toolkit leaves a built-in reachable. The search tool's
28
+ # then, or the toolkit leaves a tool reachable. The search tool's
28
29
  # description has the source groups' lines as they were at load and a
29
30
  # chi line per toolkit (#refresh!): nothing a source publishes later
30
31
  # changes the request's tools, so the prompt cache keeps.
@@ -44,9 +45,10 @@ module Samagotchi
44
45
  # times one in its description.
45
46
  NAME_WEIGHT = 3
46
47
  PREVIEW_CHARS = 60
47
- # The index's own group: chi's built-ins a toolkit leaves out.
48
+ # The index's own group: chi's tools (built-ins and bundles' chi.tool)
49
+ # a toolkit leaves out.
48
50
  CHI_GROUP = "chi"
49
- CHI_SUMMARY = "chi's own tools not declared in this session"
51
+ CHI_SUMMARY = "chi's and its bundles' tools not declared in this session"
50
52
  # Group names a source can't publish: the index's own groups'.
51
53
  RESERVED_GROUPS = [CHI_GROUP].freeze
52
54
 
@@ -276,7 +278,7 @@ module Samagotchi
276
278
  end
277
279
 
278
280
  # Register the search and call tools in +registry+, once, after the
279
- # plugins load (or without them: the built-ins a toolkit leaves out
281
+ # plugins load (or without them: the tools a toolkit leaves out
280
282
  # are reachable either way); +registry+ declares them per toolkit
281
283
  # (Registry::Index). A name another tool has is left out (a line in
282
284
  # +failures+).
@@ -285,6 +287,7 @@ module Samagotchi
285
287
  @registry = registry
286
288
  @described = description_lines(all_groups.reject { |group| own?(group) }).freeze
287
289
  @declared_group_names = @described.keys.freeze
290
+ loaded = registry.registered
288
291
  specs = [find_spec, call_spec]
289
292
  declared = specs.filter_map do |spec|
290
293
  if (taken = registry[spec.name])
@@ -299,7 +302,7 @@ module Samagotchi
299
302
  end
300
303
  registry.index = Registry::Index.new(source: SOURCE, call_name: CALL_NAME, groups: @declared_group_names,
301
304
  deferrable: -> { deferrable }, wrap: ->(call) { wrap(call) },
302
- unreached: ->(name) { unreached_tool(name) })
305
+ unreached: ->(name) { unreached_tool(name) }, loaded: loaded)
303
306
  declared!(declared)
304
307
  declared.map(&:name)
305
308
  end
@@ -362,7 +365,7 @@ module Samagotchi
362
365
  # A call of the tool +name+ names, with +args+ typed by its schema,
363
366
  # run by its source. It fails closed: a tool that isn't the one
364
367
  # guardrails were given (#claimed) is refused, and so is one of the
365
- # index's own (a built-in runs as itself, #unwrap, or not at all).
368
+ # index's own (a chi tool runs as itself, #unwrap, or not at all).
366
369
  def call(name, args)
367
370
  tool = lookup(name)
368
371
  return tool if tool.is_a?(String)
@@ -388,12 +391,12 @@ module Samagotchi
388
391
  source.run(tool, inner)
389
392
  end
390
393
 
391
- # The built-in a call_tool +call+ names, as the call that runs in its
394
+ # The chi tool a call_tool +call+ names, as the call that runs in its
392
395
  # place (ToolRunner#run's first step, before the guardrail gate):
393
396
  # built from its arguments as a direct call would be
394
397
  # (BuiltinCalls.build), with via: "call_tool". The name is a chi tool
395
- # (a bare one only while the toolkit reaches it), or chi/<built-in>
396
- # for any registered built-in: one the toolkit removes or declares
398
+ # (a bare one only while the toolkit reaches it), or chi/<name> for
399
+ # any registered built-in or plugin tool: one the toolkit removes or declares
397
400
  # is then refused by its name before the gate (ToolRunner#left_out?),
398
401
  # with no hook or card. Nil, so the call runs as call_tool, when the
399
402
  # turn under +layers+ doesn't offer call_tool, for an MCP tool, a miss
@@ -429,8 +432,8 @@ module Samagotchi
429
432
  end
430
433
 
431
434
  # The name a request declares +name+'s tool by when it goes out
432
- # deferred (Tools::DeferredTools): a reachable built-in's (by its
433
- # name or chi/<name>), a deferrable source tool's acts_as (by it or
435
+ # deferred (Tools::DeferredTools): a reachable chi tool's, built-in or
436
+ # plugin (by its name or chi/<name>), a deferrable source tool's acts_as (by it or
434
437
  # <group>/<tool>). Nil for any other: declared, removed, unknown, or a
435
438
  # source tool that can't be deferred. Never waits for a start.
436
439
  # @return [String, nil]
@@ -549,7 +552,7 @@ module Samagotchi
549
552
  end
550
553
 
551
554
  # The wire names of +matches+ a request can declare deferred: a
552
- # built-in of the index's own groups by its name, a source's tool by
555
+ # chi tool of the index's own groups by its name, a source's tool by
553
556
  # its acts_as when it is #deferrable.
554
557
  def loadable(matches)
555
558
  sourced = nil
@@ -578,16 +581,17 @@ module Samagotchi
578
581
 
579
582
  def own?(group) = RESERVED_GROUPS.include?(group.name)
580
583
 
581
- # The registered built-in chi/<name> names, reachable or not.
584
+ # The registered chi tool chi/<name> names (a built-in or a plugin's,
585
+ # never the index's own), reachable or not.
582
586
  def builtin_named(name)
583
587
  return nil unless name.start_with?("#{CHI_GROUP}/")
584
588
 
585
589
  entry = @registry[name.delete_prefix("#{CHI_GROUP}/")]
586
- entry if entry&.core?
590
+ entry unless entry.nil? || entry.source == SOURCE
587
591
  end
588
592
 
589
- # The index's own groups: chi, the built-ins the Registry's toolkit
590
- # leaves out but reaches (none: no group). Each tool's schema is its
593
+ # The index's own groups: chi, the tools (built-ins and plugin tools)
594
+ # the Registry's toolkit leaves out but reaches (none: no group). Each tool's schema is its
591
595
  # registered entry's (a config's execute description included), and
592
596
  # it acts as itself.
593
597
  # @return [Array<Group>]
@@ -698,8 +702,10 @@ module Samagotchi
698
702
  return same.first if same.size == 1
699
703
  return "Error: #{name} names #{same.map(&:full_name).join(" and ")}; call one as <group>/<tool>" if same.size > 1
700
704
 
701
- # A built-in the session declares is no starting server's tool.
702
- return "Error: #{name} is declared; call it directly" if @registry&.offered(name)&.core?
705
+ # A chi tool the session declares (a built-in or a plugin's) is no
706
+ # starting server's tool.
707
+ declared = @registry&.offered(name)
708
+ return "Error: #{name} is declared; call it directly" if declared && declared.source != SOURCE
703
709
 
704
710
  # A name with a group's <group>/ waits for that group only; any
705
711
  # other name (an acts_as) for every one starting.
@@ -10,6 +10,7 @@ require_relative "gem_update"
10
10
  require_relative "installed_gem"
11
11
  require_relative "live_versions"
12
12
  require_relative "memory_bundle/file_standing"
13
+ require_relative "memory_bundle/manifest_standing"
13
14
  require_relative "memory_bundle/shipped_update"
14
15
  require_relative "memory_bundle/system_bundle"
15
16
  require_relative "session"
@@ -331,10 +332,20 @@ module Samagotchi
331
332
 
332
333
  require_relative "bundle_command/installed_check"
333
334
  bundles.flat_map do |bundle|
334
- BundleCommand::InstalledCheck.run(bundle).map { |found| Row.new(component: bundle.name, status: found.text, level: found.level) }
335
+ kept = MemoryBundle::ManifestStanding.keep(bundle, dry_run: @dry_run)
336
+ [manifest_row(kept)].compact +
337
+ BundleCommand::InstalledCheck.run(kept.bundle).map { |found| Row.new(component: bundle.name, status: found.text, level: found.level) }
335
338
  end
336
339
  end
337
340
 
341
+ # What an external bundle's manifest.yml came to (ManifestStanding.keep):
342
+ # kept (or would be) is a change, anything else to say a warning.
343
+ def manifest_row(kept)
344
+ return nil unless kept.line
345
+
346
+ Row.new(component: kept.bundle.name, status: kept.line, level: kept.kept ? nil : "warn")
347
+ end
348
+
338
349
  def bundle_row(update)
339
350
  row = Row.new(component: update.name, from: update.from, to: update.to)
340
351
  earlier = update.status == :failed ? [] : Array(update.earlier)
@@ -343,7 +354,7 @@ module Samagotchi
343
354
  when :would_update, :updated
344
355
  notes = [adds(update.adds), *notes, replaced(update.replaced), *update.kept_own].compact
345
356
  done(row, :update, notes.empty? ? nil : notes.join("; "))
346
- when :would_merge, :merged, :up_to_date then same_version(row, [*notes, update.note].compact, changed: Array(update.merged).any?)
357
+ when :would_merge, :merged, :up_to_date then same_version(row, [*notes, update.note, update.manifest].compact, changed: Array(update.merged).any?)
347
358
  when :skipped then skipped(row, [update.note, *notes].join("; "))
348
359
  else failed(row, [update.note, *notes].compact.join("; "))
349
360
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.151.0"
4
+ VERSION = "0.153.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -62,6 +62,7 @@ require_relative "editor"
62
62
  require_relative "../message_parts"
63
63
  require_relative "../launch_mode"
64
64
  require_relative "session_summary"
65
+ require_relative "session_hub"
65
66
  require_relative "project_list"
66
67
  require_relative "session_access_routes"
67
68
  require_relative "principal"
@@ -1025,7 +1026,7 @@ module Samagotchi
1025
1026
  def handle_info(req)
1026
1027
  return participant_info unless principal(req).owner?
1027
1028
 
1028
- chi_state = @hub&.chi_state || { version: Samagotchi::VERSION, installed: nil, available: nil, update_blocked: nil, auto: "off", restart: "idle", auto_offers: nil }
1029
+ chi_state = @hub&.chi_state || SessionHub::HUBLESS_STATE
1029
1030
  json_response(200, { app: "chi-web", **chi_state, pid: Process.pid,
1030
1031
  cwd: Dir.pwd, start_dir: start_dir, features: INFO_FEATURES, lan: @lan && @lan[:ip],
1031
1032
  proxy: @public_url && { identity_header: @public_url.identity_header } })