samagotchi 0.53.0 → 0.55.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +94 -54
  6. data/docs/configuration.md +30 -2
  7. data/docs/context.md +104 -9
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/bundles.md +73 -0
  10. data/docs/internals/context-telemetry.md +27 -6
  11. data/docs/internals/engine.md +55 -24
  12. data/docs/internals/tool-guardrails.md +8 -6
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +3 -3
  15. data/docs/testing.md +23 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
  17. data/lib/samagotchi/bundle_command.rb +31 -36
  18. data/lib/samagotchi/bundle_needs.rb +4 -5
  19. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/child_move.rb +6 -4
  24. data/lib/samagotchi/config.rb +2 -1
  25. data/lib/samagotchi/context_command.rb +1 -1
  26. data/lib/samagotchi/context_providers.rb +11 -14
  27. data/lib/samagotchi/context_status.rb +10 -18
  28. data/lib/samagotchi/engine.rb +117 -134
  29. data/lib/samagotchi/extension_load.rb +102 -0
  30. data/lib/samagotchi/file_ref.rb +59 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/kernel_loop.rb +5 -3
  33. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  35. data/lib/samagotchi/memory_bundle/builder.rb +170 -178
  36. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  37. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  38. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  39. data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
  40. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  41. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  42. data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  44. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  47. data/lib/samagotchi/plugin/loader.rb +8 -8
  48. data/lib/samagotchi/self_report.rb +2 -2
  49. data/lib/samagotchi/send_command.rb +1 -1
  50. data/lib/samagotchi/session_continue.rb +275 -0
  51. data/lib/samagotchi/session_manager.rb +9 -253
  52. data/lib/samagotchi/session_metrics.rb +3 -1
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  54. data/lib/samagotchi/terminal_ui.rb +12 -2
  55. data/lib/samagotchi/tool_activity.rb +84 -14
  56. data/lib/samagotchi/tool_row_fields.rb +46 -0
  57. data/lib/samagotchi/tool_runner.rb +7 -11
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +26 -3
  60. data/lib/samagotchi/web/capabilities.rb +13 -9
  61. data/lib/samagotchi/web/editor.rb +38 -0
  62. data/lib/samagotchi/web/message_parts.rb +5 -10
  63. data/lib/samagotchi/web/public/activity.js +35 -18
  64. data/lib/samagotchi/web/public/app.js +240 -1136
  65. data/lib/samagotchi/web/public/commands.js +236 -0
  66. data/lib/samagotchi/web/public/composer.js +101 -0
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/diff_view.js +30 -4
  69. data/lib/samagotchi/web/public/index.html +9 -2
  70. data/lib/samagotchi/web/public/info_bar.js +78 -0
  71. data/lib/samagotchi/web/public/lists_view.js +725 -0
  72. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  73. data/lib/samagotchi/web/public/question_card.js +9 -1
  74. data/lib/samagotchi/web/public/question_cards.js +7 -4
  75. data/lib/samagotchi/web/public/refs.js +134 -0
  76. data/lib/samagotchi/web/public/restore.js +81 -0
  77. data/lib/samagotchi/web/public/session_state.js +52 -0
  78. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  79. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  80. data/lib/samagotchi/web/public/timing.js +8 -2
  81. data/lib/samagotchi/web/public/turn_events.js +17 -258
  82. data/lib/samagotchi/web/public/turn_html.js +15 -7
  83. data/lib/samagotchi/web/server.rb +4 -4
  84. metadata +17 -2
  85. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -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.
@@ -680,7 +722,7 @@ module Samagotchi
680
722
  # @raise [ArgumentError] a card without a title, or a bad action
681
723
  def show_card(source:, title:, body: "", actions: [], level: :info, id: nil)
682
724
  card = build_card(source: source, title: title, body: body, actions: actions, level: level, id: id)
683
- 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?
684
726
  return announce_anytime(card.merge(in_turn: false))[:id] if anytime_thread?
685
727
  # A plugin's init task (chi.init): never a running turn's event.
686
728
  return announce(card.merge(in_turn: false)) && card[:id] if current_init_task
@@ -781,7 +823,7 @@ module Samagotchi
781
823
  announce({ type: :guardrail_warning, message: message }) if message
782
824
  plugins = @plugin_failures.message
783
825
  announce({ type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
784
- Array(@plugin_load_events).each do |event|
826
+ @extension_load.held_events.each do |event|
785
827
  announce(event[:type] == :card ? event.merge(in_turn: false) : event.merge(between_turns: true))
786
828
  end
787
829
  end
@@ -1242,7 +1284,7 @@ module Samagotchi
1242
1284
  emit_event(on_event, { type: :guardrail_warning, message: message }) if message
1243
1285
  plugins = @plugin_failures.message
1244
1286
  emit_event(on_event, { type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
1245
- Array(@plugin_load_events).each { |event| emit_event(on_event, event) }
1287
+ @extension_load.held_events.each { |event| emit_event(on_event, event) }
1246
1288
  end
1247
1289
  private :announce_guardrail_failures
1248
1290
 
@@ -1402,7 +1444,7 @@ module Samagotchi
1402
1444
  level = :info unless %i[info warn].include?(level)
1403
1445
  notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level,
1404
1446
  fallback_for: notice_fallback(fallback_for, hook) }.compact
1405
- return hold_load_event(notice) && nil if @loading_plugins
1447
+ return @extension_load.hold(notice) && nil if @extension_load.loading?
1406
1448
 
1407
1449
  in_turn, sink = @turn_state.in_turn_sink
1408
1450
  if anytime_thread?
@@ -2394,48 +2436,6 @@ module Samagotchi
2394
2436
  end
2395
2437
  end
2396
2438
 
2397
- # Load hooks from the global config file using the Hooks::Loader.
2398
- # Returns a Registry with all plugins registered (or an empty Registry if
2399
- # no hooks config is present).
2400
- def load_hooks_from_config
2401
- config_path = Samagotchi::ConfigFile.global_path
2402
- data = Samagotchi::ConfigFile.read_yaml(path: config_path)
2403
- return Hooks::Loader.load(data, failures: @guardrail_failures) if data.is_a?(Hash)
2404
-
2405
- Hooks::Registry.new
2406
- end
2407
-
2408
- def load_hooks_from_bundles
2409
- require_relative "memory_bundle/provenance"
2410
- settings = bundle_settings
2411
- MemoryBundle::Provenance.each_installed(holding: :hooks) do |bundle_name, data|
2412
- if data[:error]
2413
- Log.warn(:hooks, "bundle_manifest_invalid", echo: "[samagotchi:hooks] bundle '#{bundle_name}': #{data[:error]}; its hooks are not loaded",
2414
- bundle: bundle_name)
2415
- @guardrail_failures.add("hooks (bundle #{bundle_name})", data[:error], required: false)
2416
- next
2417
- end
2418
- bundle_dir = File.join(MemoryBundle::Provenance.bundles_dir, bundle_name)
2419
- hooks_dir = File.join(bundle_dir, "hooks")
2420
- if (data[:trust_level] || "experimental").to_s == "experimental"
2421
- Log.info(:hooks, "experimental_bundle", echo: "[hooks] Bundle '#{bundle_name}' is experimental — its hooks may change or misbehave.", bundle: bundle_name)
2422
- end
2423
- begin
2424
- Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: data[:hooks], registry: @hooks,
2425
- failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {},
2426
- requires_chi: data[:requires_chi])
2427
- rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
2428
- raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
2429
-
2430
- 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)
2431
- end
2432
- end
2433
- rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
2434
- raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
2435
-
2436
- Log.error(:hooks, "bundles_load_failed", echo: "[samagotchi:hooks] failed to load bundle hooks: #{e.class}: #{e.message}", error: e.class.name)
2437
- end
2438
-
2439
2439
  def load_plugins
2440
2440
  host = plugin_host
2441
2441
  registries = Plugin::Registries.new(
@@ -2454,17 +2454,7 @@ module Samagotchi
2454
2454
  # What plugins show while they load (an MCP server that didn't
2455
2455
  # start) waits for the first turn, beside the load warnings: no UI
2456
2456
  # is there yet, and the Engine isn't built.
2457
- @plugin_load_events = []
2458
- @loading_plugins = true
2459
- Plugin::Loader.load_installed(registries, failures: @plugin_failures, settings: bundle_settings)
2460
- ensure
2461
- @loading_plugins = false
2462
- end
2463
-
2464
- # Keep a notice or card a plugin showed while loading (#load_plugins).
2465
- def hold_load_event(event)
2466
- @plugin_load_events << event
2467
- event
2457
+ @extension_load.load_plugins(registries, failures: @plugin_failures)
2468
2458
  end
2469
2459
 
2470
2460
  # Keep a plugin's new tool set (chi.replace_tools, from any thread)
@@ -2550,13 +2540,6 @@ module Samagotchi
2550
2540
  end
2551
2541
  private :ask_side_model
2552
2542
 
2553
- # config.yml `bundles:` (ConfigFile.bundle_settings), read once: the
2554
- # bundle hooks and the plugins both want it.
2555
- def bundle_settings
2556
- @bundle_settings ||= Samagotchi::ConfigFile.bundle_settings
2557
- end
2558
- private :bundle_settings
2559
-
2560
2543
  # The session's current model as a recap target: its host's OpenAI API
2561
2544
  # (native llama.cpp hosts serve /v1/chat/completions too), key variable
2562
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
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tools/read"
4
+ require_relative "tools/write"
5
+ require_relative "tools/edit"
6
+
7
+ module Samagotchi
8
+ # A ref to the file a read/write/edit call touched, for a UI to trace it
9
+ # (the web opens it in this machine's editor): its absolute path (the
10
+ # call's path against the session's cwd) and the lines the call named (a
11
+ # range read or a range edit). A ref is a kind and a target; what a click
12
+ # does is the UI's (refs.js keeps the actions per kind), so nothing here
13
+ # names an editor.
14
+ FileRef = Data.define(:path, :line, :end_line) do
15
+ # @return [Hash] {kind: "file", path:, line:, end_line:}, the lines only
16
+ # when there are some
17
+ def to_h = { kind: FileRef::KIND, **super.compact }
18
+ end
19
+
20
+ class FileRef
21
+ KIND = "file"
22
+
23
+ TOOLS = [Tools::Read::NAME, Tools::Write::NAME, Tools::Edit::NAME].freeze
24
+
25
+ # @param cwd [String, nil] the session's working directory; without one
26
+ # a relative path gets no ref (an absolute or ~ path still does)
27
+ # @return [FileRef, nil] nil for a tool that isn't a file tool and for a
28
+ # path it can't place. +line+: the call's start_line when positive;
29
+ # +end_line+: its end_line, only when past +line+.
30
+ def self.for(tool_name, call, cwd:)
31
+ return nil unless TOOLS.include?(tool_name) && call.is_a?(Hash)
32
+
33
+ path = absolute((tool_name == Tools::Read::NAME ? call[:content] : call[:path]).to_s.strip, cwd)
34
+ return nil unless path
35
+
36
+ line = positive(call[:start_line])
37
+ end_line = line && positive(call[:end_line])
38
+ new(path: path, line: line, end_line: end_line && end_line > line ? end_line : nil)
39
+ end
40
+
41
+ def self.absolute(raw, cwd)
42
+ return nil if raw.empty?
43
+ return File.expand_path(raw) if raw.start_with?("/", "~")
44
+ return nil if cwd.to_s.empty?
45
+
46
+ File.expand_path(raw, cwd)
47
+ rescue ArgumentError
48
+ # ~user for a user that doesn't exist.
49
+ nil
50
+ end
51
+ private_class_method :absolute
52
+
53
+ def self.positive(value)
54
+ number = Integer(value.to_s.strip, exception: false)
55
+ number&.positive? ? number : nil
56
+ end
57
+ private_class_method :positive
58
+ end
59
+ end
@@ -151,26 +151,26 @@ module Samagotchi
151
151
  def bundle_rules
152
152
  require_relative "memory_bundle/provenance"
153
153
  rules = []
154
- MemoryBundle::Provenance.each_installed(holding: :guardrails) do |bundle_name, data|
155
- if data[:error]
156
- Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
157
- @failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
154
+ MemoryBundle::Provenance.each_installed(holding: :guardrails) do |bundle_name, bundle|
155
+ if bundle.error?
156
+ Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{bundle.error}", bundle: bundle_name)
157
+ @failures.add("rules (bundle #{bundle_name})", bundle.error, required: true, group: :rules)
158
158
  next
159
159
  end
160
- if (too_new = MemoryBundle::Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION))
161
- rules.concat(too_new_bundle_rules(bundle_name, data, too_new))
160
+ if (too_new = MemoryBundle::Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION))
161
+ rules.concat(too_new_bundle_rules(bundle_name, bundle, too_new))
162
162
  next
163
163
  end
164
164
  dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
165
165
  loaded = []
166
166
  whole = true
167
- data[:guardrails].sort_by { |k, _| k.to_s }.each do |basename, meta|
167
+ bundle.guardrails.sort.each do |basename, sha|
168
168
  what = "rules #{basename} (bundle #{bundle_name})"
169
- path = File.join(dir, basename.to_s)
169
+ path = File.join(dir, basename)
170
170
  begin
171
171
  raise Guardrails::Rules::ParseError, "the file is missing" unless File.file?(path)
172
172
 
173
- unless MemoryBundle::Provenance.sha_matches?(path, meta.is_a?(Hash) ? meta[:sha256] : nil)
173
+ unless MemoryBundle::Provenance.sha_matches?(path, sha)
174
174
  raise Guardrails::Rules::ParseError, "its sha256 differs from the installed one (edited after install? reinstall the bundle)"
175
175
  end
176
176
 
@@ -256,7 +256,7 @@ module Samagotchi
256
256
  # load the new ones). Never loaded (a chi older than the bundle it
257
257
  # starts with): none, and a required load failure, as the hook loader
258
258
  # does for a guardrail hook, so the calls aren't quietly unguarded.
259
- def too_new_bundle_rules(bundle_name, data, reason)
259
+ def too_new_bundle_rules(bundle_name, bundle, reason)
260
260
  kept = @loaded_bundle_rules[bundle_name]
261
261
  unless kept
262
262
  Log.warn(:guardrails, "bundle_rules_requires_chi", echo: "[samagotchi:guardrails] bundle #{bundle_name}: its rules are not loaded: #{reason}",
@@ -266,19 +266,19 @@ module Samagotchi
266
266
  return []
267
267
  end
268
268
 
269
- key = [bundle_name, data[:version].to_s]
269
+ key = [bundle_name, bundle.version.to_s]
270
270
  return kept if @kept_noticed[key]
271
271
 
272
272
  @kept_noticed[key] = true
273
- Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: data[:version].to_s,
274
- requires_chi: data[:requires_chi].to_s)
275
- @notify.call(kept_notice(bundle_name, data))
273
+ Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: bundle.version.to_s,
274
+ requires_chi: bundle.requires_chi.to_s)
275
+ @notify.call(kept_notice(bundle_name, bundle))
276
276
  kept
277
277
  end
278
278
 
279
- def kept_notice(bundle_name, data)
279
+ def kept_notice(bundle_name, bundle)
280
280
  id = @session_lookup.call&.id || "ID"
281
- "bundle #{bundle_name} #{data[:version]} needs chi #{data[:requires_chi]} and this session runs chi #{Samagotchi::VERSION}: " \
281
+ "bundle #{bundle_name} #{bundle.version} needs chi #{bundle.requires_chi} and this session runs chi #{Samagotchi::VERSION}: " \
282
282
  "its new rules are not loaded, the ones loaded before still apply. Restart the session to load them " \
283
283
  "(chi sessions stop #{id}, then chi --resume #{id})"
284
284
  end
@@ -726,9 +726,11 @@ module Samagotchi
726
726
  end
727
727
 
728
728
  # Estimate context usage for this iteration's prompt (ContextStatus#observe)
729
- # and, when the emit gate fires, surface it to stream consumers as a
730
- # :context_status event. The telemetry is not for the model (it used to
731
- # be injected as a synthetic system message).
729
+ # and surface it to stream consumers as a :context_status event, on every
730
+ # request (observe returns the estimate each time it is enabled), so the
731
+ # web and the attached TUI get a fresh number per request. The telemetry
732
+ # is not for the model (it used to be injected as a synthetic system
733
+ # message); the model's own line is still left only on a rise.
732
734
  def emit_context_status_event(on_stream_event, context, prompt, iteration_index:, window:, image_tokens: 0)
733
735
  event = context.observe(prompt.length, iteration_index: iteration_index, window: window, image_tokens: image_tokens)
734
736
  return unless event
@@ -651,10 +651,11 @@ module Samagotchi
651
651
 
652
652
  # Before a request, as the native loop: estimate how full the window
653
653
  # is (the server's last count plus what the turn appended since),
654
- # emit :context_status on a bucket change, and put the model's own
655
- # line on the tail on a rise that asks it for a change. The estimate
656
- # counts the conversation's text and tool calls, not the tool
657
- # schemas (the server's count does).
654
+ # emit :context_status on every request (observe returns the estimate
655
+ # each time it is enabled), and put the model's own line on the tail
656
+ # on a rise that asks it for a change. The estimate counts the
657
+ # conversation's text and tool calls, not the tool schemas (the
658
+ # server's count does).
658
659
  def observe_context(iteration, window)
659
660
  return unless window
660
661