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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -1
- data/README.md +1 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +30 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +27 -6
- data/docs/internals/engine.md +55 -24
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -3
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
- data/lib/samagotchi/bundle_command.rb +31 -36
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +2 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +117 -134
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +170 -178
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +9 -253
- data/lib/samagotchi/session_metrics.rb +3 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +12 -2
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/tool_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +26 -3
- data/lib/samagotchi/web/capabilities.rb +13 -9
- data/lib/samagotchi/web/editor.rb +38 -0
- data/lib/samagotchi/web/message_parts.rb +5 -10
- data/lib/samagotchi/web/public/activity.js +35 -18
- data/lib/samagotchi/web/public/app.js +240 -1136
- data/lib/samagotchi/web/public/commands.js +236 -0
- data/lib/samagotchi/web/public/composer.js +101 -0
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +9 -2
- data/lib/samagotchi/web/public/info_bar.js +78 -0
- data/lib/samagotchi/web/public/lists_view.js +725 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +7 -4
- data/lib/samagotchi/web/public/refs.js +134 -0
- data/lib/samagotchi/web/public/restore.js +81 -0
- data/lib/samagotchi/web/public/session_state.js +52 -0
- data/lib/samagotchi/web/public/stream_handlers.js +324 -0
- data/lib/samagotchi/web/public/thinking_chip.js +6 -7
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +15 -7
- data/lib/samagotchi/web/server.rb +4 -4
- metadata +17 -2
- data/lib/samagotchi/web/public/stable_html.js +0 -18
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
@
|
|
177
|
-
|
|
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 =
|
|
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
|
-
#
|
|
229
|
-
# the
|
|
230
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
@
|
|
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,
|
|
155
|
-
if
|
|
156
|
-
Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{
|
|
157
|
-
@failures.add("rules (bundle #{bundle_name})",
|
|
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(
|
|
161
|
-
rules.concat(too_new_bundle_rules(bundle_name,
|
|
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
|
-
|
|
167
|
+
bundle.guardrails.sort.each do |basename, sha|
|
|
168
168
|
what = "rules #{basename} (bundle #{bundle_name})"
|
|
169
|
-
path = File.join(dir, basename
|
|
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,
|
|
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,
|
|
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,
|
|
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:
|
|
274
|
-
requires_chi:
|
|
275
|
-
@notify.call(kept_notice(bundle_name,
|
|
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,
|
|
279
|
+
def kept_notice(bundle_name, bundle)
|
|
280
280
|
id = @session_lookup.call&.id || "ID"
|
|
281
|
-
"bundle #{bundle_name} #{
|
|
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
|
|
730
|
-
#
|
|
731
|
-
#
|
|
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
|
|
655
|
-
#
|
|
656
|
-
#
|
|
657
|
-
#
|
|
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
|
|