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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -1
- data/README.md +1 -1
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +1 -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 +8 -5
- data/docs/internals/engine.md +16 -7
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -3
- data/docs/sessions.md +1 -1
- data/docs/testing.md +5 -3
- data/lib/samagotchi/analytics_file.rb +35 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundle_command.rb +35 -44
- 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 +0 -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 +132 -135
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
- data/lib/samagotchi/host_registry.rb +13 -0
- 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 +184 -190
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
- 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 +9 -29
- 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 +46 -57
- 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 +4 -28
- data/lib/samagotchi/session_commands.rb +32 -4
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +45 -257
- data/lib/samagotchi/session_metrics.rb +18 -21
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +25 -3
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -8
- data/lib/samagotchi/web/public/activity.js +2 -0
- data/lib/samagotchi/web/public/app.js +6 -8
- data/lib/samagotchi/web/public/command_complete.js +7 -2
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/index.html +4 -2
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +5 -3
- data/lib/samagotchi/web/public/thinking_chip.js +31 -21
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_html.js +10 -6
- 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,
|
|
88
|
-
next if
|
|
87
|
+
MemoryBundle::Provenance.each_installed do |bundle, record|
|
|
88
|
+
next if record.error? || !record.context_providers?
|
|
89
89
|
|
|
90
|
-
providers(bundle,
|
|
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?
|
|
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,
|
|
106
|
-
parse_list(
|
|
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
|
-
|
|
135
|
-
raise Invalid, "bundle #{source.provider} isn't installed" unless
|
|
132
|
+
record = provenance.record
|
|
133
|
+
raise Invalid, "bundle #{source.provider} isn't installed" unless record
|
|
136
134
|
|
|
137
|
-
scripts
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
16
|
-
#
|
|
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)
|
|
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
|
|
163
|
-
#
|
|
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)
|
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.
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
@
|
|
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
|