samagotchi 0.52.0 → 0.54.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +94 -54
  5. data/docs/configuration.md +1 -2
  6. data/docs/context.md +104 -9
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bundles.md +73 -0
  9. data/docs/internals/context-telemetry.md +8 -5
  10. data/docs/internals/engine.md +16 -7
  11. data/docs/internals/tool-guardrails.md +8 -6
  12. data/docs/plugins.md +2 -1
  13. data/docs/releasing.md +6 -3
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +5 -3
  16. data/lib/samagotchi/analytics_file.rb +35 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  18. data/lib/samagotchi/bundle_command.rb +35 -44
  19. data/lib/samagotchi/bundle_needs.rb +4 -5
  20. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  22. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/child_move.rb +6 -4
  25. data/lib/samagotchi/config.rb +0 -1
  26. data/lib/samagotchi/context_command.rb +1 -1
  27. data/lib/samagotchi/context_providers.rb +11 -14
  28. data/lib/samagotchi/context_status.rb +10 -18
  29. data/lib/samagotchi/engine.rb +132 -135
  30. data/lib/samagotchi/extension_load.rb +102 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
  33. data/lib/samagotchi/host_registry.rb +13 -0
  34. data/lib/samagotchi/kernel_loop.rb +5 -3
  35. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  36. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  37. data/lib/samagotchi/memory_bundle/builder.rb +184 -190
  38. data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
  39. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  40. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  41. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  42. data/lib/samagotchi/memory_bundle/manifest.rb +9 -29
  43. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  44. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  45. data/lib/samagotchi/memory_bundle/provenance.rb +46 -57
  46. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  47. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  48. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  50. data/lib/samagotchi/plugin/loader.rb +8 -8
  51. data/lib/samagotchi/self_report.rb +2 -2
  52. data/lib/samagotchi/send_command.rb +4 -28
  53. data/lib/samagotchi/session_commands.rb +32 -4
  54. data/lib/samagotchi/session_continue.rb +275 -0
  55. data/lib/samagotchi/session_manager.rb +45 -257
  56. data/lib/samagotchi/session_metrics.rb +18 -21
  57. data/lib/samagotchi/sessions_command.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  59. data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
  60. data/lib/samagotchi/terminal_ui.rb +25 -3
  61. data/lib/samagotchi/tool_activity.rb +84 -14
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/web/app.rb +3 -8
  64. data/lib/samagotchi/web/public/activity.js +2 -0
  65. data/lib/samagotchi/web/public/app.js +6 -8
  66. data/lib/samagotchi/web/public/command_complete.js +7 -2
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/index.html +4 -2
  69. data/lib/samagotchi/web/public/question_card.js +9 -1
  70. data/lib/samagotchi/web/public/question_cards.js +5 -3
  71. data/lib/samagotchi/web/public/thinking_chip.js +31 -21
  72. data/lib/samagotchi/web/public/timing.js +8 -2
  73. data/lib/samagotchi/web/public/turn_html.js +10 -6
  74. metadata +9 -1
@@ -84,10 +84,10 @@ module Samagotchi
84
84
  # @raise [Invalid] the provider makes a name that isn't a source name
85
85
  def resolve(url)
86
86
  url = url.to_s.strip
87
- MemoryBundle::Provenance.each_installed do |bundle, data|
88
- next if data[:error] || !data[:context_providers].is_a?(Array)
87
+ MemoryBundle::Provenance.each_installed do |bundle, record|
88
+ next if record.error? || !record.context_providers?
89
89
 
90
- providers(bundle, data).each do |provider|
90
+ providers(bundle, record).each do |provider|
91
91
  match = provider.regexp.match(url) or next
92
92
  return resolved(bundle, provider, match)
93
93
  end
@@ -97,13 +97,11 @@ module Samagotchi
97
97
 
98
98
  # Whether an installed bundle declares a provider (the web's "+ URL").
99
99
  def any?
100
- MemoryBundle::Provenance.each_installed.any? do |_bundle, data|
101
- !data[:error] && data[:context_providers].is_a?(Array) && !data[:context_providers].empty?
102
- end
100
+ MemoryBundle::Provenance.each_installed.any? { |_bundle, record| !record.error? && record.context_providers? }
103
101
  end
104
102
 
105
- def providers(bundle, data)
106
- parse_list(data[:context_providers])
103
+ def providers(bundle, record)
104
+ parse_list(record.context_providers)
107
105
  rescue Invalid => e
108
106
  Log.warn(:context, "providers_unreadable", bundle: bundle, msg: e.message)
109
107
  []
@@ -131,13 +129,12 @@ module Samagotchi
131
129
  return source.cmd unless source.provider
132
130
 
133
131
  provenance = MemoryBundle::Provenance.new(name: source.provider)
134
- data = provenance.read
135
- raise Invalid, "bundle #{source.provider} isn't installed" unless data
132
+ record = provenance.record
133
+ raise Invalid, "bundle #{source.provider} isn't installed" unless record
136
134
 
137
- scripts = data[:scripts].is_a?(Hash) ? data[:scripts] : {}
138
- scripts.each do |file, meta|
139
- path = File.join(provenance.scripts_dir, file.to_s)
140
- recorded = MemoryBundle::Provenance.recorded_sha(meta.is_a?(Hash) ? meta[:sha256] : nil)
135
+ record.scripts.each do |file, sha|
136
+ path = File.join(provenance.scripts_dir, file)
137
+ recorded = MemoryBundle::Provenance.recorded_sha(sha)
141
138
  next if File.file?(path) && MemoryBundle::Provenance.file_sha(path) == recorded
142
139
 
143
140
  raise Invalid, "bundle #{source.provider}'s scripts/#{file} differs from the installed one (reinstall the bundle)"
@@ -12,8 +12,9 @@ module Samagotchi
12
12
  #
13
13
  # - the status line's value (#display), every request and again from the
14
14
  # server's final counts after each generation;
15
- # - a :context_status stream event (#observe's return), on a bucket change
16
- # or every context.status_cadence requests;
15
+ # - a :context_status stream event (#observe's return), on every request,
16
+ # so the web and the attached TUI get a fresh estimate each time (the
17
+ # context.status_cadence setting no longer throttles this event);
17
18
  # - the model's own short line (#take_guidance), once per rise into a
18
19
  # bucket whose guidance asks for a change (the second threshold up).
19
20
  #
@@ -125,7 +126,6 @@ module Samagotchi
125
126
  parsed = Config.get("context.status_thresholds").to_s.split(",").map { |value| value.strip.to_i }
126
127
  .select { |value| value.between?(1, 99) }.uniq.sort
127
128
  @thresholds = parsed.empty? ? DEFAULT_THRESHOLDS : parsed
128
- @cadence = [Config.get("context.status_cadence").to_i, 0].max
129
129
  @last_bucket = self.class.last_bucket(conversation)
130
130
  @server_usage = nil
131
131
  @counted = nil
@@ -153,16 +153,19 @@ module Samagotchi
153
153
  # +image_tokens+: the images' estimate (their base64 is not in the chars).
154
154
  #
155
155
  # @return [Hash, nil] the :context_status event's fields (without
156
- # :type/:iteration) when the emit gate fires, else nil
156
+ # :type/:iteration) for every request when enabled, else nil (the
157
+ # context.status off). The estimate is returned every request, so the
158
+ # web and the attached TUI get a fresh number on each; the model's own
159
+ # line is still only left on a rise into a guidance bucket (below).
157
160
  def observe(prompt_chars, iteration_index:, window:, image_tokens: 0)
158
161
  return nil unless @enabled
159
162
 
160
163
  usage = estimate(prompt_chars, server_usage: @server_usage, window: window, image_tokens: image_tokens, counted: @counted)
161
164
  bucket = bucket_for(usage[:estimated_pct])
162
- # The status line's value, every iteration; the gate below decides
163
- # only the event and the model's guidance line.
165
+ # The status line's value, every iteration; the event below too, so the
166
+ # consumers get a fresh estimate on every request. Only the model's line
167
+ # is still gated: it is left only on a rise (below).
164
168
  @display = { est_pct: usage[:estimated_pct], bucket: bucket }
165
- emit = emit?(bucket: bucket, iteration_index: iteration_index)
166
169
  previous_bucket = @last_bucket
167
170
  @last_bucket = bucket
168
171
  line = if @forget
@@ -171,7 +174,6 @@ module Samagotchi
171
174
  guidance_message(usage: usage, bucket: bucket)
172
175
  end
173
176
  @guidance = line if line
174
- return nil unless emit
175
177
 
176
178
  { status: status_message(usage: usage, bucket: bucket), usage: usage, bucket: bucket, source: usage[:source] }
177
179
  end
@@ -293,16 +295,6 @@ module Samagotchi
293
295
  rank >= GUIDANCE_FROM_RANK && rank > bucket_rank(previous)
294
296
  end
295
297
 
296
- def emit?(bucket:, iteration_index:)
297
- bucket_changed = bucket != if @last_bucket.nil?
298
- "under#{@thresholds.first}"
299
- else
300
- @last_bucket
301
- end
302
- cadence_due = @cadence.positive? && ((iteration_index + 1) % @cadence).zero?
303
- bucket_changed || cadence_due
304
- end
305
-
306
298
  # The forget layer's line for this request, or nil (the tiers and when
307
299
  # they come: the class comment).
308
300
  def forget_line(usage, bucket, previous, iteration_index)
@@ -21,6 +21,7 @@ require_relative "tools/builtins"
21
21
  require_relative "tools/task_runtime"
22
22
  require_relative "session_commands"
23
23
  require_relative "plugin/loader"
24
+ require_relative "extension_load"
24
25
  require_relative "log"
25
26
  require_relative "log_subscriber"
26
27
  require_relative "thinking_tails"
@@ -89,6 +90,9 @@ module Samagotchi
89
90
  new(profile: profile, plugins: false).assist_system_prompt
90
91
  end
91
92
 
93
+ # What memory_write answers in a scratch session.
94
+ SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
95
+
92
96
  # @param client [Client, nil] defaults to Client.new
93
97
  # @param profile [ModelProfile, Symbol, String, nil]
94
98
  # @param session_id [String, nil] resume an existing session
@@ -100,8 +104,6 @@ module Samagotchi
100
104
  # @param memories [Array<String>] explicit --memory preload list (merged with the config.yml `memories:` baseline)
101
105
  # @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
102
106
  # prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
103
- # What memory_write answers in a scratch session.
104
- SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
105
107
  # @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
106
108
  # @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
107
109
  # delegate (a child would outlive it) nor plugin fork
@@ -144,17 +146,7 @@ module Samagotchi
144
146
  # Load hooks from config (plugins) and create the registry; what fails
145
147
  # to load is announced, and a required guardrail's failure denies
146
148
  # every tool call. Rules load now too, so their errors are announced.
147
- @guardrail_wiring = GuardrailWiring.new(
148
- scratch: @scratch,
149
- hooks: -> { @hooks },
150
- tools: -> { @tools },
151
- session: -> { @session },
152
- model_key: -> { @model_key },
153
- model_name: -> { guardrail_model_name },
154
- cancelled: -> { !!active_cancel_controller&.cancelled? },
155
- ask: ->(fields) { open_question(fields) },
156
- notify: ->(message) { guardrail_notify(message) }
157
- )
149
+ @guardrail_wiring = build_guardrail_wiring
158
150
  @guardrail_failures = @guardrail_wiring.failures
159
151
  # Plugins that failed to load: announced apart, as plugins (not
160
152
  # guardrails: no tool call is denied for them).
@@ -163,30 +155,17 @@ module Samagotchi
163
155
  # approvals). Built before the hooks and plugins load: a plugin can ask
164
156
  # during its load (answered nil then: the interface is still
165
157
  # :non_interactive).
166
- @question_desk = QuestionDesk.new(
167
- session: -> { @session },
168
- state_dir: -> { session_state_dir },
169
- emit: ->(event) { emit_event(nil, event) },
170
- cancel_controller: -> { active_cancel_controller },
171
- interface: -> { interface },
172
- user_input: ->(sid) { ArchiveStore.user_input(sid, state_dir: session_state_dir) }
173
- )
158
+ @question_desk = build_question_desk
174
159
  # Built here, not on first use: the turn thread and the Bridge's read it.
175
160
  @relay_desk = RelayDesk.new
176
- @hooks = load_hooks_from_config
177
- load_hooks_from_bundles
161
+ @extension_load = ExtensionLoad.new(hook_failures: @guardrail_failures)
162
+ @hooks = @extension_load.hooks
178
163
  # The tools this session offers (the prompts' declarations and the
179
164
  # kernel's dispatch): the built-ins, per Engine. execute.description
180
165
  # off: execute offers no description parameter.
181
166
  @tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false,
182
167
  policy: Config.get(LLMContextStrategy::POLICY_SETTING))
183
- if @scratch
184
- # A scratch session's children would outlive it, and so would a
185
- # note it left in a peer; its memories would too (write and edit
186
- # into the memories: ScratchWrites).
187
- [Tools::Delegate::NAME, Tools::DelegateResult::NAME, Tools::SendNote::NAME].each { |name| @tools.unregister(name) }
188
- @tools[Tools::MemoryWrite::NAME].handler = ->(_call, _kctx) { SCRATCH_MEMORY_WRITE }
189
- end
168
+ restrict_scratch_tools! if @scratch
190
169
  # Likewise the slash commands its SessionCommands run.
191
170
  @command_registry = SessionCommands.register_builtins(Commands::Registry.new)
192
171
  # Installed bundles' plugins add commands, tools and hooks to these
@@ -199,16 +178,7 @@ module Samagotchi
199
178
  # chi.init tasks (#add_init_task), started by #start_init_tasks!, and
200
179
  # plugins' tool sets from chi.replace_tools until the turn thread
201
180
  # applies them (#apply_staged_tools!). Built before the plugins load.
202
- @plugin_tasks = PluginTasks.new(
203
- clock: -> { monotonic_now },
204
- synchronize_events: ->(&block) { synchronize_events(&block) },
205
- announce: ->(event) { announce(event) },
206
- emit: ->(sink, event) { emit_event(sink, event) },
207
- show_card: ->(**card) { show_card(**card) },
208
- tools: -> { @tools },
209
- notify: ->(text, level, source, fallback_for: nil) { hook_notify(text, level, source, fallback_for: fallback_for) },
210
- tools_changed: -> { tools_changed! }
211
- )
181
+ @plugin_tasks = build_plugin_tasks
212
182
  @lifecycle_mutex = Mutex.new
213
183
  @shut_down = false
214
184
  load_plugins if plugins
@@ -225,36 +195,9 @@ module Samagotchi
225
195
  @reminders = build_reminders(auto_turn_callback: callback)
226
196
  # Track whether this is the first turn in the session (for session_start event)
227
197
  @first_turn = true
228
- # A given kernel (specs) gets the Engine's store, hooks and tools, as
229
- # the one built here does.
230
- @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, hooks: @hooks, reminder_store: @reminder_store,
231
- tools: @tools)
232
- @kernel.reminder_store = @reminder_store
233
- @kernel.hooks = @hooks
234
- @kernel.tools = @tools
235
- sync_kernel_client!
236
- sync_model_key!
237
- # The mutes never change during a session, so no re-sync: the kernel's
238
- # memory_read guard reads the same list for every turn.
239
- @muted_memory_names = MutedMemories.normalize_list(muted_memories)
240
- @kernel.muted_memory_names = @muted_memory_names
241
- # ask_user_question blocks on the Engine's question flow (TUI/Web answer it).
242
- @kernel.question_handler = proc { |payload| request_question(payload) }
243
- # Every tool call asks this gate first. The kernel is never rebuilt, so
244
- # it holds across model switches.
245
- self.guardrail_state_dir = Session.default_state_dir
246
- # list_sessions and send_note speak for whichever session runs now.
247
- @kernel.peers = PeerView.new(self)
248
- @kernel.guardrail_gate = @guardrail_wiring.gate
249
- # The turn-end warm-up; the kernel asks it for a slot pin per request.
250
- @prompt_warmup = PromptWarmup.new
251
- @kernel.warmup = @prompt_warmup
252
- # What a hook can do beyond reading its event (event[:notify],
253
- # event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
254
- @hooks.runtime = hook_runtime
255
- # The loop follows the effective model's host (its api:): the raw-prompt
256
- # NativeBackend, or the chat backend for openai hosts.
257
- @native_backend = LLM::NativeBackend.new(kernel: @kernel)
198
+ # The kernel (built here, or a spec's) and what it reads of the Engine;
199
+ # then the native backend over it.
200
+ wire_kernel(kernel, muted_memories)
258
201
  Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
259
202
  @resume_session = session_id ? Session.load(session_id) : nil
260
203
  @prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
@@ -286,6 +229,23 @@ module Samagotchi
286
229
  engine: self,
287
230
  jobs: [@reminders, @recap].compact
288
231
  )
232
+ subscribe_session_observers
233
+ end
234
+
235
+ # ── initialize's collaborators ──────────────────────────────────────────
236
+
237
+ # The tools a scratch session doesn't offer, and its memory_write.
238
+ def restrict_scratch_tools!
239
+ # A scratch session's children would outlive it, and so would a
240
+ # note it left in a peer; its memories would too (write and edit
241
+ # into the memories: ScratchWrites).
242
+ [Tools::Delegate::NAME, Tools::DelegateResult::NAME, Tools::SendNote::NAME].each { |name| @tools.unregister(name) }
243
+ @tools[Tools::MemoryWrite::NAME].handler = ->(_call, _kctx) { SCRATCH_MEMORY_WRITE }
244
+ end
245
+ private :restrict_scratch_tools!
246
+
247
+ # The persistent observers of every turn's events.
248
+ def subscribe_session_observers
289
249
  # The metrics collector is a persistent observer so every run_turn event
290
250
  # (the REPL, -p/--non-interactive/--resume and SessionManager workers)
291
251
  # feeds it automatically.
@@ -297,6 +257,88 @@ module Samagotchi
297
257
  @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir)
298
258
  }))
299
259
  end
260
+ private :subscribe_session_observers
261
+
262
+ # The kernel and what it holds of the Engine (stores, hooks, tools, the
263
+ # mutes, the question flow, the gate, peers, warm-up, the hook runtime),
264
+ # and the native backend over it. After the plugins load.
265
+ def wire_kernel(kernel, muted_memories)
266
+ # A given kernel (specs) gets the Engine's store, hooks and tools, as
267
+ # the one built here does.
268
+ @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, hooks: @hooks, reminder_store: @reminder_store,
269
+ tools: @tools)
270
+ @kernel.reminder_store = @reminder_store
271
+ @kernel.hooks = @hooks
272
+ @kernel.tools = @tools
273
+ sync_kernel_client!
274
+ sync_model_key!
275
+ # The mutes never change during a session, so no re-sync: the kernel's
276
+ # memory_read guard reads the same list for every turn.
277
+ @muted_memory_names = MutedMemories.normalize_list(muted_memories)
278
+ @kernel.muted_memory_names = @muted_memory_names
279
+ # ask_user_question blocks on the Engine's question flow (TUI/Web answer it).
280
+ @kernel.question_handler = proc { |payload| request_question(payload) }
281
+ # Every tool call asks this gate first. The kernel is never rebuilt, so
282
+ # it holds across model switches.
283
+ self.guardrail_state_dir = Session.default_state_dir
284
+ # list_sessions and send_note speak for whichever session runs now.
285
+ @kernel.peers = PeerView.new(self)
286
+ @kernel.guardrail_gate = @guardrail_wiring.gate
287
+ # The turn-end warm-up; the kernel asks it for a slot pin per request.
288
+ @prompt_warmup = PromptWarmup.new
289
+ @kernel.warmup = @prompt_warmup
290
+ # What a hook can do beyond reading its event (event[:notify],
291
+ # event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
292
+ @hooks.runtime = hook_runtime
293
+ # The loop follows the effective model's host (its api:): the raw-prompt
294
+ # NativeBackend, or the chat backend for openai hosts.
295
+ @native_backend = LLM::NativeBackend.new(kernel: @kernel)
296
+ end
297
+ private :wire_kernel
298
+
299
+ # The tool guardrails (see #initialize for when).
300
+ def build_guardrail_wiring
301
+ GuardrailWiring.new(
302
+ scratch: @scratch,
303
+ hooks: -> { @hooks },
304
+ tools: -> { @tools },
305
+ session: -> { @session },
306
+ model_key: -> { @model_key },
307
+ model_name: -> { guardrail_model_name },
308
+ cancelled: -> { !!active_cancel_controller&.cancelled? },
309
+ ask: ->(fields) { open_question(fields) },
310
+ notify: ->(message) { guardrail_notify(message) }
311
+ )
312
+ end
313
+ private :build_guardrail_wiring
314
+
315
+ # The question flow (see #initialize for when).
316
+ def build_question_desk
317
+ QuestionDesk.new(
318
+ session: -> { @session },
319
+ state_dir: -> { session_state_dir },
320
+ emit: ->(event) { emit_event(nil, event) },
321
+ cancel_controller: -> { active_cancel_controller },
322
+ interface: -> { interface },
323
+ user_input: ->(sid) { ArchiveStore.user_input(sid, state_dir: session_state_dir) }
324
+ )
325
+ end
326
+ private :build_question_desk
327
+
328
+ # The plugins' init tasks and staged tool sets (see #initialize for when).
329
+ def build_plugin_tasks
330
+ PluginTasks.new(
331
+ clock: -> { monotonic_now },
332
+ synchronize_events: ->(&block) { synchronize_events(&block) },
333
+ announce: ->(event) { announce(event) },
334
+ emit: ->(sink, event) { emit_event(sink, event) },
335
+ show_card: ->(**card) { show_card(**card) },
336
+ tools: -> { @tools },
337
+ notify: ->(text, level, source, fallback_for: nil) { hook_notify(text, level, source, fallback_for: fallback_for) },
338
+ tools_changed: -> { tools_changed! }
339
+ )
340
+ end
341
+ private :build_plugin_tasks
300
342
 
301
343
  # A monotonically-increasing clock (wall clock can jump backwards; the idle
302
344
  # detector must never treat a jump as "activity"). Injectable for specs.
@@ -504,6 +546,20 @@ module Samagotchi
504
546
  end
505
547
  private :record_prompt_notes
506
548
 
549
+ # A failed turn's one line (the error's #summary). A host's failure
550
+ # under an alias says what the alias became (HostRegistry#alias_note):
551
+ # "host main" alone doesn't tell --model splash's user it was splash.
552
+ # @return [String]
553
+ def failure_summary(error)
554
+ summary = error.respond_to?(:summary) ? error.summary : error.message
555
+ return summary unless error.is_a?(LLM::ProviderError) && error.host
556
+
557
+ note = @host_registry.alias_note(@effective_model_name)
558
+ note && note.end_with?("on host #{error.host}") ? "#{summary} (#{note})" : summary
559
+ rescue StandardError
560
+ summary
561
+ end
562
+
507
563
  # +name+'s resolved ref (ModelRef#ref).
508
564
  def model_ref_for(name)
509
565
  @host_registry.model_ref(name).ref
@@ -666,7 +722,7 @@ module Samagotchi
666
722
  # @raise [ArgumentError] a card without a title, or a bad action
667
723
  def show_card(source:, title:, body: "", actions: [], level: :info, id: nil)
668
724
  card = build_card(source: source, title: title, body: body, actions: actions, level: level, id: id)
669
- return hold_load_event(card.merge(in_turn: true))[:id] if @loading_plugins
725
+ return @extension_load.hold(card.merge(in_turn: true))[:id] if @extension_load.loading?
670
726
  return announce_anytime(card.merge(in_turn: false))[:id] if anytime_thread?
671
727
  # A plugin's init task (chi.init): never a running turn's event.
672
728
  return announce(card.merge(in_turn: false)) && card[:id] if current_init_task
@@ -767,7 +823,7 @@ module Samagotchi
767
823
  announce({ type: :guardrail_warning, message: message }) if message
768
824
  plugins = @plugin_failures.message
769
825
  announce({ type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
770
- Array(@plugin_load_events).each do |event|
826
+ @extension_load.held_events.each do |event|
771
827
  announce(event[:type] == :card ? event.merge(in_turn: false) : event.merge(between_turns: true))
772
828
  end
773
829
  end
@@ -1228,7 +1284,7 @@ module Samagotchi
1228
1284
  emit_event(on_event, { type: :guardrail_warning, message: message }) if message
1229
1285
  plugins = @plugin_failures.message
1230
1286
  emit_event(on_event, { type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
1231
- Array(@plugin_load_events).each { |event| emit_event(on_event, event) }
1287
+ @extension_load.held_events.each { |event| emit_event(on_event, event) }
1232
1288
  end
1233
1289
  private :announce_guardrail_failures
1234
1290
 
@@ -1388,7 +1444,7 @@ module Samagotchi
1388
1444
  level = :info unless %i[info warn].include?(level)
1389
1445
  notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level,
1390
1446
  fallback_for: notice_fallback(fallback_for, hook) }.compact
1391
- return hold_load_event(notice) && nil if @loading_plugins
1447
+ return @extension_load.hold(notice) && nil if @extension_load.loading?
1392
1448
 
1393
1449
  in_turn, sink = @turn_state.in_turn_sink
1394
1450
  if anytime_thread?
@@ -2119,7 +2175,7 @@ module Samagotchi
2119
2175
  failed[:kept_steps] = steps if steps
2120
2176
  # A provider error says what kind it is, for one line per kind in the UIs.
2121
2177
  if error.is_a?(LLM::ProviderError)
2122
- failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: error.summary)
2178
+ failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: failure_summary(error))
2123
2179
  end
2124
2180
  failed
2125
2181
  end
@@ -2380,48 +2436,6 @@ module Samagotchi
2380
2436
  end
2381
2437
  end
2382
2438
 
2383
- # Load hooks from the global config file using the Hooks::Loader.
2384
- # Returns a Registry with all plugins registered (or an empty Registry if
2385
- # no hooks config is present).
2386
- def load_hooks_from_config
2387
- config_path = Samagotchi::ConfigFile.global_path
2388
- data = Samagotchi::ConfigFile.read_yaml(path: config_path)
2389
- return Hooks::Loader.load(data, failures: @guardrail_failures) if data.is_a?(Hash)
2390
-
2391
- Hooks::Registry.new
2392
- end
2393
-
2394
- def load_hooks_from_bundles
2395
- require_relative "memory_bundle/provenance"
2396
- settings = bundle_settings
2397
- MemoryBundle::Provenance.each_installed(holding: :hooks) do |bundle_name, data|
2398
- if data[:error]
2399
- Log.warn(:hooks, "bundle_manifest_invalid", echo: "[samagotchi:hooks] bundle '#{bundle_name}': #{data[:error]}; its hooks are not loaded",
2400
- bundle: bundle_name)
2401
- @guardrail_failures.add("hooks (bundle #{bundle_name})", data[:error], required: false)
2402
- next
2403
- end
2404
- bundle_dir = File.join(MemoryBundle::Provenance.bundles_dir, bundle_name)
2405
- hooks_dir = File.join(bundle_dir, "hooks")
2406
- if (data[:trust_level] || "experimental").to_s == "experimental"
2407
- Log.info(:hooks, "experimental_bundle", echo: "[hooks] Bundle '#{bundle_name}' is experimental — its hooks may change or misbehave.", bundle: bundle_name)
2408
- end
2409
- begin
2410
- Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: data[:hooks], registry: @hooks,
2411
- failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {},
2412
- requires_chi: data[:requires_chi])
2413
- rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
2414
- raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
2415
-
2416
- Log.error(:hooks, "bundle_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' failed to load hooks: #{e.class}: #{e.message}", bundle: bundle_name, error: e.class.name)
2417
- end
2418
- end
2419
- rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
2420
- raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
2421
-
2422
- Log.error(:hooks, "bundles_load_failed", echo: "[samagotchi:hooks] failed to load bundle hooks: #{e.class}: #{e.message}", error: e.class.name)
2423
- end
2424
-
2425
2439
  def load_plugins
2426
2440
  host = plugin_host
2427
2441
  registries = Plugin::Registries.new(
@@ -2440,17 +2454,7 @@ module Samagotchi
2440
2454
  # What plugins show while they load (an MCP server that didn't
2441
2455
  # start) waits for the first turn, beside the load warnings: no UI
2442
2456
  # is there yet, and the Engine isn't built.
2443
- @plugin_load_events = []
2444
- @loading_plugins = true
2445
- Plugin::Loader.load_installed(registries, failures: @plugin_failures, settings: bundle_settings)
2446
- ensure
2447
- @loading_plugins = false
2448
- end
2449
-
2450
- # Keep a notice or card a plugin showed while loading (#load_plugins).
2451
- def hold_load_event(event)
2452
- @plugin_load_events << event
2453
- event
2457
+ @extension_load.load_plugins(registries, failures: @plugin_failures)
2454
2458
  end
2455
2459
 
2456
2460
  # Keep a plugin's new tool set (chi.replace_tools, from any thread)
@@ -2536,13 +2540,6 @@ module Samagotchi
2536
2540
  end
2537
2541
  private :ask_side_model
2538
2542
 
2539
- # config.yml `bundles:` (ConfigFile.bundle_settings), read once: the
2540
- # bundle hooks and the plugins both want it.
2541
- def bundle_settings
2542
- @bundle_settings ||= Samagotchi::ConfigFile.bundle_settings
2543
- end
2544
- private :bundle_settings
2545
-
2546
2543
  # The session's current model as a recap target: its host's OpenAI API
2547
2544
  # (native llama.cpp hosts serve /v1/chat/completions too), key variable
2548
2545
  # and bare model name, as a turn resolves them.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "log"
5
+ require_relative "hooks"
6
+ require_relative "plugin/loader"
7
+
8
+ module Samagotchi
9
+ # What an Engine loads from outside chi as it starts: config.yml's hooks,
10
+ # installed bundles' hooks and plugins, and config.yml `bundles:` (their
11
+ # settings). It holds what plugins showed while they loaded (notices,
12
+ # cards): no UI is there yet, so the Engine announces them on the first
13
+ # turn (Engine#announce_load_events!).
14
+ class ExtensionLoad
15
+ # @param hook_failures [Guardrails::LoadFailures] where hooks that fail to load go
16
+ def initialize(hook_failures:)
17
+ @hook_failures = hook_failures
18
+ @loading = false
19
+ @held_events = []
20
+ end
21
+
22
+ # @return [Array<Hash>] the notices and cards plugins showed while loading
23
+ attr_reader :held_events
24
+
25
+ # Whether the plugins are loading now (#load_plugins): what they show
26
+ # then is held (#hold), not shown.
27
+ def loading? = @loading
28
+
29
+ # Keep a notice or card a plugin showed while loading.
30
+ # @return [Hash] the event
31
+ def hold(event)
32
+ @held_events << event
33
+ event
34
+ end
35
+
36
+ # The hooks: config.yml's (Hooks::Loader; an empty Registry without a
37
+ # hooks config), then the installed bundles' added to them.
38
+ # @return [Hooks::Registry]
39
+ def hooks
40
+ registry = hooks_from_config
41
+ add_bundle_hooks(registry)
42
+ registry
43
+ end
44
+
45
+ # Load the installed bundles' plugins into +registries+; one that fails
46
+ # goes to +failures+, and the rest still load.
47
+ def load_plugins(registries, failures:)
48
+ @held_events = []
49
+ @loading = true
50
+ Plugin::Loader.load_installed(registries, failures: failures, settings: bundle_settings)
51
+ ensure
52
+ @loading = false
53
+ end
54
+
55
+ # config.yml `bundles:` (ConfigFile.bundle_settings), read once: the
56
+ # bundle hooks and the plugins both want it.
57
+ def bundle_settings
58
+ @bundle_settings ||= Samagotchi::ConfigFile.bundle_settings
59
+ end
60
+
61
+ private
62
+
63
+ def hooks_from_config
64
+ config_path = Samagotchi::ConfigFile.global_path
65
+ data = Samagotchi::ConfigFile.read_yaml(path: config_path)
66
+ return Hooks::Loader.load(data, failures: @hook_failures) if data.is_a?(Hash)
67
+
68
+ Hooks::Registry.new
69
+ end
70
+
71
+ def add_bundle_hooks(registry)
72
+ require_relative "memory_bundle/provenance"
73
+ settings = bundle_settings
74
+ MemoryBundle::Provenance.each_installed(holding: :hooks) do |bundle_name, bundle|
75
+ if bundle.error?
76
+ Log.warn(:hooks, "bundle_manifest_invalid", echo: "[samagotchi:hooks] bundle '#{bundle_name}': #{bundle.error}; its hooks are not loaded",
77
+ bundle: bundle_name)
78
+ @hook_failures.add("hooks (bundle #{bundle_name})", bundle.error, required: false)
79
+ next
80
+ end
81
+ bundle_dir = File.join(MemoryBundle::Provenance.bundles_dir, bundle_name)
82
+ hooks_dir = File.join(bundle_dir, "hooks")
83
+ if bundle.experimental?
84
+ Log.info(:hooks, "experimental_bundle", echo: "[hooks] Bundle '#{bundle_name}' is experimental — its hooks may change or misbehave.", bundle: bundle_name)
85
+ end
86
+ begin
87
+ Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: bundle.hooks, registry: registry,
88
+ failures: @hook_failures, settings: settings[bundle_name.to_s] || {},
89
+ requires_chi: bundle.requires_chi)
90
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
91
+ raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
92
+
93
+ Log.error(:hooks, "bundle_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' failed to load hooks: #{e.class}: #{e.message}", bundle: bundle_name, error: e.class.name)
94
+ end
95
+ end
96
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
97
+ raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
98
+
99
+ Log.error(:hooks, "bundles_load_failed", echo: "[samagotchi:hooks] failed to load bundle hooks: #{e.class}: #{e.message}", error: e.class.name)
100
+ end
101
+ end
102
+ end