samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "registry"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../log"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Hooks
|
|
9
|
+
# Plugin-based hook loader that loads Ruby classes from a directory.
|
|
10
|
+
#
|
|
11
|
+
# Each plugin is a `.rb` file that defines a class with a `#call(event)` method.
|
|
12
|
+
# The class name must match the filename (PascalCase):
|
|
13
|
+
# `my_hook.rb` → `MyHook` class
|
|
14
|
+
#
|
|
15
|
+
# The loader:
|
|
16
|
+
# 1. Loads each plugin file via `require` (absolute path)
|
|
17
|
+
# 2. Instantiates the class
|
|
18
|
+
# 3. Registers a Proc in the given Registry that calls `plugin.call(event)`
|
|
19
|
+
#
|
|
20
|
+
# Plugins are loaded once and cached. The same plugin can be registered
|
|
21
|
+
# for multiple event types by specifying it multiple times in the config.
|
|
22
|
+
#
|
|
23
|
+
# Config format:
|
|
24
|
+
# hooks:
|
|
25
|
+
# hooks_dir: "~/my_hooks/" # default: <config dir>/hooks/, next to config.yml
|
|
26
|
+
# before_turn:
|
|
27
|
+
# - path: "my_hook.rb"
|
|
28
|
+
# on_error: skip # or "log"
|
|
29
|
+
# - path: "another.rb"
|
|
30
|
+
# before_tool_call:
|
|
31
|
+
# - path: "guard.rb"
|
|
32
|
+
# required: true # fail closed: if it can't load, or raises,
|
|
33
|
+
# # tool calls are denied
|
|
34
|
+
#
|
|
35
|
+
# The loader creates a `Hooks::Registry` instance, loads plugins, and
|
|
36
|
+
# registers each plugin's `call` method as a Proc under the specified event name.
|
|
37
|
+
class Loader
|
|
38
|
+
class << self
|
|
39
|
+
# $XDG_CONFIG_HOME/samagotchi/hooks/ or ~/.config/samagotchi/hooks/.
|
|
40
|
+
def default_hooks_dir(env = ENV)
|
|
41
|
+
File.join(ConfigFile.config_dir(env: env), "hooks", "")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Load hooks from a config hash and return a Registry with registered plugins.
|
|
45
|
+
#
|
|
46
|
+
# @param config_hash [Hash, nil] the hooks section from config.yml
|
|
47
|
+
# @param env [Hash] environment variables (default: ENV)
|
|
48
|
+
# @param failures [Guardrails::LoadFailures, nil] collects hooks that
|
|
49
|
+
# failed to load
|
|
50
|
+
# @return [Samagotchi::Hooks::Registry] the registry with all plugins registered
|
|
51
|
+
def load(config_hash, env: ENV, failures: nil)
|
|
52
|
+
return Hooks::Registry.new unless config_hash&.key?("hooks")
|
|
53
|
+
|
|
54
|
+
hooks_config = config_hash["hooks"]
|
|
55
|
+
hooks_dir = expand_path(hooks_config["hooks_dir"] || default_hooks_dir(env), env)
|
|
56
|
+
|
|
57
|
+
registry = Hooks::Registry.new
|
|
58
|
+
definitions = parse_definitions(hooks_config)
|
|
59
|
+
|
|
60
|
+
definitions.each do |defn|
|
|
61
|
+
begin
|
|
62
|
+
plugin = load_plugin(hooks_dir, defn[:path], settings: defn[:settings])
|
|
63
|
+
# Persistent: config hooks must fire on every turn, not be wiped
|
|
64
|
+
# by Engine#run_turn's per-turn clear_hooks after turn 1.
|
|
65
|
+
registry.register_persistent(defn[:event_type].to_sym, label: "#{defn[:path]} (config)") do |event|
|
|
66
|
+
begin
|
|
67
|
+
plugin.call(event)
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
if defn[:required] && defn[:event_type] == "before_tool_call"
|
|
70
|
+
deny_for_raise(event, defn[:path], e)
|
|
71
|
+
else
|
|
72
|
+
handle_error(defn[:on_error] || "skip", defn[:path], e)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
rescue ScriptError, StandardError => e
|
|
77
|
+
# ScriptError: a SyntaxError (or LoadError) from `require`.
|
|
78
|
+
Log.error(:hooks, "hook_load_failed", echo: "[samagotchi:hooks] hook #{defn[:path]} failed to load: #{e.class}: #{e.message}", hook: defn[:path].to_s, error: e.class.name)
|
|
79
|
+
failures&.add("hook #{defn[:path]} (config)", "#{e.class}: #{e.message}", required: defn[:required])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
registry
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Expand a path that may start with ~.
|
|
88
|
+
def self.expand_path(path, env = ENV)
|
|
89
|
+
return path unless path.start_with?("~")
|
|
90
|
+
home = env["HOME"] || Dir.home
|
|
91
|
+
File.join(home, path[1..])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Parse hook definitions from the config hooks section.
|
|
95
|
+
# Returns an array of { event_type:, path:, on_error: } hashes.
|
|
96
|
+
def self.parse_definitions(hooks_config)
|
|
97
|
+
definitions = []
|
|
98
|
+
hooks_config.each do |event_type, configs|
|
|
99
|
+
next unless event_type.to_s != "hooks_dir" && configs.is_a?(Array)
|
|
100
|
+
configs.each do |cfg|
|
|
101
|
+
next unless cfg.is_a?(Hash) && cfg["path"]
|
|
102
|
+
definitions << {
|
|
103
|
+
event_type: event_type.to_s,
|
|
104
|
+
path: cfg["path"],
|
|
105
|
+
on_error: (cfg["on_error"] || "skip").to_s,
|
|
106
|
+
required: cfg["required"] == true,
|
|
107
|
+
settings: cfg["settings"].is_a?(Hash) ? cfg["settings"] : {}
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
definitions
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Load a plugin from the hooks directory.
|
|
115
|
+
# Returns an instance of the plugin class. Instances are cached per
|
|
116
|
+
# [path, settings] across Engines: two entries with different
|
|
117
|
+
# settings get two instances.
|
|
118
|
+
# @param settings [Hash] the entry's `settings:` (string keys); a
|
|
119
|
+
# class whose initialize takes an argument gets it
|
|
120
|
+
def self.load_plugin(hooks_dir, path, settings: {})
|
|
121
|
+
full_path = File.expand_path(File.join(hooks_dir, path))
|
|
122
|
+
settings = {} unless settings.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
# Check if already loaded (Ruby's require caching handles this)
|
|
125
|
+
# We cache the instance separately to avoid re-instantiating
|
|
126
|
+
unless @plugin_cache
|
|
127
|
+
@plugin_cache = {}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
@plugin_cache[[full_path, settings]] ||= begin
|
|
131
|
+
require full_path
|
|
132
|
+
class_name = File.basename(path, ".rb").split("_").map(&:capitalize).join
|
|
133
|
+
klass = Object.const_get(class_name)
|
|
134
|
+
instance = Hooks.build_plugin(klass, settings)
|
|
135
|
+
# Validate that the instance responds to #call
|
|
136
|
+
raise ArgumentError, "Plugin #{class_name} does not respond to #call" unless instance.respond_to?(:call)
|
|
137
|
+
instance
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A required before_tool_call hook that raises denies the call.
|
|
142
|
+
def self.deny_for_raise(event, hook_path, error)
|
|
143
|
+
return unless event.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
reason = "required hook #{hook_path} raised #{error.class}: #{error.message}"
|
|
146
|
+
event[:guardrail]&.deny!(reason, rule: "guardrail-load", source: "core", decided_by: "core")
|
|
147
|
+
event[:blocked] = true
|
|
148
|
+
event[:block_reason] ||= reason
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Handle a hook error based on the on_error config.
|
|
152
|
+
def self.handle_error(on_error, hook_path, error = nil)
|
|
153
|
+
case on_error
|
|
154
|
+
when "log"
|
|
155
|
+
Log.warn(:hooks, "hook_failed", echo: "[samagotchi:hook] #{error ? "#{error.class}: #{error.message}" : "hook failed"} (#{hook_path})", hook: hook_path.to_s, error: error&.class&.name)
|
|
156
|
+
when "skip"
|
|
157
|
+
# Silent — just skip
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Hooks
|
|
7
|
+
# A hook plugin instance: a class whose initialize takes an argument
|
|
8
|
+
# gets its settings (one positional Hash with string keys), any other
|
|
9
|
+
# is built bare. `initialize(**kw)` is not told apart (arity -1 too)
|
|
10
|
+
# and not supported.
|
|
11
|
+
# @param klass [Class]
|
|
12
|
+
# @param settings [Hash]
|
|
13
|
+
def self.build_plugin(klass, settings)
|
|
14
|
+
takes_settings = klass.instance_method(:initialize).arity != 0
|
|
15
|
+
takes_settings ? klass.new(settings.is_a?(Hash) ? settings : {}) : klass.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# What a hook can do beyond reading its event: the Engine's three
|
|
19
|
+
# callables, each given the hook's label. +notify+ takes
|
|
20
|
+
# (text:, level:, hook:) and shows one line to the user; +ask_user+
|
|
21
|
+
# takes (question:, options:, header:, allow_freeform:, hook:) and
|
|
22
|
+
# returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
|
|
23
|
+
# cancels the running turn (true when it did). A registry without one
|
|
24
|
+
# gives hooks no-op helpers.
|
|
25
|
+
Runtime = Struct.new(:notify, :ask_user, :stop_turn, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
# A thread-safe registry for named hook callbacks.
|
|
28
|
+
#
|
|
29
|
+
# Each hook is stored as a Proc that receives an event hash (passed by
|
|
30
|
+
# reference — mutations on the hash survive). The registry supports
|
|
31
|
+
# per-hook registration/unregistration, clearing all hooks, and
|
|
32
|
+
# synchronous dispatch.
|
|
33
|
+
#
|
|
34
|
+
# Every fire puts the hook runtime on the event: +event[:hook]+ (the
|
|
35
|
+
# label of the proc about to run: "<file> (bundle <name>)", a config
|
|
36
|
+
# hook's label, or "turn hook"), and the helpers +event[:notify]+,
|
|
37
|
+
# +event[:ask_user]+ and +event[:stop_turn]+ (see #fire). Keys the fire
|
|
38
|
+
# site put on the event are never overwritten.
|
|
39
|
+
#
|
|
40
|
+
# Thread safety is achieved via Monitor.
|
|
41
|
+
class Registry
|
|
42
|
+
TURN_HOOK_LABEL = "turn hook"
|
|
43
|
+
CONFIG_HOOK_LABEL = "config hook"
|
|
44
|
+
# Events after which there is no turn left to stop.
|
|
45
|
+
TURN_OVER_EVENTS = %i[after_turn session_end].freeze
|
|
46
|
+
|
|
47
|
+
# @return [Runtime, nil] what the helpers call (the Engine sets it)
|
|
48
|
+
attr_accessor :runtime
|
|
49
|
+
|
|
50
|
+
def initialize
|
|
51
|
+
@mutex = Monitor.new
|
|
52
|
+
@hooks = {} # name -> Array<Proc> (manual, turn-scoped hooks, run last)
|
|
53
|
+
@persistent_hooks = {} # name -> Array<{label:, proc:}> (config.yml hooks, survive clear_all)
|
|
54
|
+
@bundle_hooks = {} # name -> Array<{bundle:, hook_name:, priority:, proc:}>
|
|
55
|
+
@runtime = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Register a hook with the given name.
|
|
59
|
+
# Multiple hooks can be registered under the same name; they fire in
|
|
60
|
+
# registration order.
|
|
61
|
+
# @param name [Symbol] hook event identifier
|
|
62
|
+
# @param block [Proc] receives an event hash (mutated in place)
|
|
63
|
+
# @return [void]
|
|
64
|
+
def register(name, &block)
|
|
65
|
+
raise ArgumentError, "hook name must be a Symbol" unless name.is_a?(Symbol)
|
|
66
|
+
raise ArgumentError, "hook block is required" unless block_given?
|
|
67
|
+
@mutex.synchronize { (@hooks[name] ||= []) << block }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Register a process-scoped plain hook (config.yml). Unlike #register it
|
|
71
|
+
# survives the per-turn #clear_all, so configured hooks fire on every
|
|
72
|
+
# turn, not only the first. Fires after bundle hooks, before
|
|
73
|
+
# turn-scoped ones.
|
|
74
|
+
# @param name [Symbol] hook event identifier
|
|
75
|
+
# @param label [String, nil] what event[:hook] names the hook by
|
|
76
|
+
# (the loader passes the file); default "config hook"
|
|
77
|
+
# @return [void]
|
|
78
|
+
def register_persistent(name, label: nil, &block)
|
|
79
|
+
raise ArgumentError, "hook name must be a Symbol" unless name.is_a?(Symbol)
|
|
80
|
+
raise ArgumentError, "hook block is required" unless block_given?
|
|
81
|
+
label = label.to_s.strip
|
|
82
|
+
label = CONFIG_HOOK_LABEL if label.empty?
|
|
83
|
+
@mutex.synchronize { (@persistent_hooks[name] ||= []) << { label: label, proc: block } }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Register a bundle-owned hook. Bundle hooks are ordered by
|
|
87
|
+
# (priority, bundle_name, hook_name) and fire BEFORE any plain
|
|
88
|
+
# (config.yml / manual) hooks registered via #register.
|
|
89
|
+
#
|
|
90
|
+
# @param bundle_name [String] owning bundle
|
|
91
|
+
# @param event_name [Symbol] hook event identifier
|
|
92
|
+
# @param hook_name [String] logical hook name (e.g. file basename)
|
|
93
|
+
# @param priority [Integer] lower runs first
|
|
94
|
+
# @return [void]
|
|
95
|
+
def register_bundle(bundle_name, event_name, hook_name:, priority: 100, &block)
|
|
96
|
+
raise ArgumentError, "hook block is required" unless block_given?
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
(@bundle_hooks[event_name] ||= []) << {
|
|
99
|
+
bundle: bundle_name,
|
|
100
|
+
hook_name: hook_name,
|
|
101
|
+
priority: priority,
|
|
102
|
+
proc: block
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Unregister all hooks owned by a bundle.
|
|
108
|
+
# @param bundle_name [String]
|
|
109
|
+
# @param event_name [Symbol, nil] restrict to one event (nil = all events)
|
|
110
|
+
# @return [Integer] number of hooks removed
|
|
111
|
+
def unregister_bundle(bundle_name, event_name = nil)
|
|
112
|
+
removed = 0
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
if event_name
|
|
115
|
+
arr = @bundle_hooks[event_name]
|
|
116
|
+
return 0 unless arr
|
|
117
|
+
before = arr.size
|
|
118
|
+
@bundle_hooks[event_name] = arr.reject { |h| h[:bundle] == bundle_name }
|
|
119
|
+
removed = before - @bundle_hooks[event_name].size
|
|
120
|
+
@bundle_hooks.delete(event_name) if @bundle_hooks[event_name].empty?
|
|
121
|
+
else
|
|
122
|
+
@bundle_hooks.each do |_event, arr|
|
|
123
|
+
before = arr.size
|
|
124
|
+
arr.reject! { |h| h[:bundle] == bundle_name }
|
|
125
|
+
removed += before - arr.size
|
|
126
|
+
end
|
|
127
|
+
@bundle_hooks.reject! { |_, arr| arr.empty? }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
removed
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Unregister a hook by name.
|
|
134
|
+
# @param name [Symbol]
|
|
135
|
+
# @return [Boolean] true if it was removed, false if not found
|
|
136
|
+
def unregister(name)
|
|
137
|
+
@mutex.synchronize do
|
|
138
|
+
removed_plain = @hooks.delete(name)
|
|
139
|
+
removed_persistent = @persistent_hooks.delete(name)
|
|
140
|
+
!!(removed_plain || removed_persistent)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Remove all turn-scoped hooks (registered via #register).
|
|
145
|
+
# Bundle hooks (#unregister_bundle) and config hooks (#register_persistent)
|
|
146
|
+
# are process-scoped and survive the per-turn clear_all, so guardrails
|
|
147
|
+
# and configured hooks apply to every turn.
|
|
148
|
+
# @return [void]
|
|
149
|
+
def clear_all
|
|
150
|
+
@mutex.synchronize do
|
|
151
|
+
@hooks.clear
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Dispatch an event to all registered hooks under the given name.
|
|
156
|
+
#
|
|
157
|
+
# Each hook receives the *same* event hash by reference, so hooks can
|
|
158
|
+
# mutate fields to affect downstream behavior. A hook that raises is
|
|
159
|
+
# caught and ignored so that one misbehaving hook cannot break the
|
|
160
|
+
# running turn.
|
|
161
|
+
#
|
|
162
|
+
# Ordering: bundle hooks (sorted by priority, then bundle, then hook
|
|
163
|
+
# name) fire first; config hooks next; turn-scoped hooks last, each in
|
|
164
|
+
# registration order.
|
|
165
|
+
#
|
|
166
|
+
# All procs get the same hash, so event[:hook] is set before each one;
|
|
167
|
+
# the helpers are set once per fire and read event[:hook] when called:
|
|
168
|
+
# event[:notify].call(text, level: :info) one line to the user
|
|
169
|
+
# event[:ask_user].call(question:, options:, header: nil, allow_freeform: false)
|
|
170
|
+
# -> {selected:, freeform:, selected_indices:} or nil (no one to
|
|
171
|
+
# ask, cancelled, bad options)
|
|
172
|
+
# event[:stop_turn].call(reason) -> true when the turn was cancelled;
|
|
173
|
+
# in a before_tool_call event it also denies the call; from
|
|
174
|
+
# after_turn / session_end it does nothing (false)
|
|
175
|
+
#
|
|
176
|
+
# @param name [Symbol] the hook name to fire
|
|
177
|
+
# @param event [Hash] the event payload (may be mutated by hooks)
|
|
178
|
+
# @return [void]
|
|
179
|
+
def fire(name, event)
|
|
180
|
+
procs = ordered_procs(name)
|
|
181
|
+
return if procs.empty?
|
|
182
|
+
|
|
183
|
+
with_runtime(event)
|
|
184
|
+
procs.each do |label, hook_proc|
|
|
185
|
+
event[:hook] = label if event.is_a?(Hash)
|
|
186
|
+
begin
|
|
187
|
+
hook_proc.call(event)
|
|
188
|
+
rescue StandardError
|
|
189
|
+
# A failing hook must not break the turn.
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Like #fire, and yields the event after each hook (a raising one
|
|
195
|
+
# too), so the caller can fold what that hook did before the next one
|
|
196
|
+
# runs (the guardrail gate keeps a deny sticky this way).
|
|
197
|
+
# @yieldparam event [Hash]
|
|
198
|
+
# @return [void]
|
|
199
|
+
def fire_each(name, event)
|
|
200
|
+
procs = ordered_procs(name)
|
|
201
|
+
return if procs.empty?
|
|
202
|
+
|
|
203
|
+
with_runtime(event)
|
|
204
|
+
procs.each do |label, hook_proc|
|
|
205
|
+
event[:hook] = label if event.is_a?(Hash)
|
|
206
|
+
begin
|
|
207
|
+
hook_proc.call(event)
|
|
208
|
+
rescue StandardError
|
|
209
|
+
# A failing hook must not break the turn.
|
|
210
|
+
end
|
|
211
|
+
yield event
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @return [Integer] total number of registered hooks (bundle + plain)
|
|
216
|
+
def size
|
|
217
|
+
@mutex.synchronize do
|
|
218
|
+
@hooks.values.sum(&:size) + @persistent_hooks.values.sum(&:size) + @bundle_hooks.values.sum(&:size)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
# Returns the ordered [label, proc] pairs for an event: bundle hooks
|
|
225
|
+
# sorted by (priority, bundle, hook_name), then config hooks, then
|
|
226
|
+
# turn-scoped hooks, each in registration order.
|
|
227
|
+
def ordered_procs(name)
|
|
228
|
+
@mutex.synchronize do
|
|
229
|
+
bundle_procs = (@bundle_hooks[name] || [])
|
|
230
|
+
.sort_by { |h| [h[:priority].to_i, h[:bundle].to_s, h[:hook_name].to_s] }
|
|
231
|
+
.map { |h| ["#{h[:hook_name]} (bundle #{h[:bundle]})", h[:proc]] }
|
|
232
|
+
config_procs = (@persistent_hooks[name] || []).map { |h| [h[:label], h[:proc]] }
|
|
233
|
+
turn_procs = (@hooks[name] || []).map { |hook_proc| [TURN_HOOK_LABEL, hook_proc] }
|
|
234
|
+
bundle_procs + config_procs + turn_procs
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# The three helpers, once per fire; a fire site's own keys stay.
|
|
239
|
+
def with_runtime(event)
|
|
240
|
+
return unless event.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
event[:notify] ||= lambda { |text, level: :info|
|
|
243
|
+
@runtime&.notify&.call(text: text.to_s, level: level, hook: event[:hook])
|
|
244
|
+
nil
|
|
245
|
+
}
|
|
246
|
+
event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false|
|
|
247
|
+
@runtime&.ask_user&.call(question: question, options: options, header: header,
|
|
248
|
+
allow_freeform: allow_freeform, hook: event[:hook])
|
|
249
|
+
}
|
|
250
|
+
event[:stop_turn] ||= lambda { |reason|
|
|
251
|
+
next false if TURN_OVER_EVENTS.include?(event[:type])
|
|
252
|
+
|
|
253
|
+
if event[:type] == :before_tool_call && event[:guardrail].respond_to?(:deny!)
|
|
254
|
+
event[:guardrail].deny!("the turn was stopped by #{event[:hook]}: #{reason}")
|
|
255
|
+
end
|
|
256
|
+
@runtime&.stop_turn&.call(reason: reason.to_s, hook: event[:hook]) ? true : false
|
|
257
|
+
}
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hooks/registry"
|
|
4
|
+
require_relative "hooks/loader"
|
|
5
|
+
require_relative "hooks/bundle_loader"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Thin wrapper that exposes the Hooks::Registry, Hooks::Loader and
|
|
9
|
+
# Hooks::BundleLoader (bundle-owned hook plugins).
|
|
10
|
+
#
|
|
11
|
+
# The Engine holds an instance of Hooks::Registry and provides:
|
|
12
|
+
# - #register_hook(name, &block) — register a turn-scoped hook
|
|
13
|
+
# - #unregister_hook(name) — remove a hook
|
|
14
|
+
# - #clear_hooks — auto-cleaned after each run_turn
|
|
15
|
+
#
|
|
16
|
+
# Hook event vocabulary:
|
|
17
|
+
# :before_turn — before the turn starts
|
|
18
|
+
# :after_turn — after the turn completes (success or cancel)
|
|
19
|
+
# :before_generation — before calling the LLM API
|
|
20
|
+
# :after_generation — after LLM returns, before tool parse
|
|
21
|
+
# :before_tool_call — before a tool is dispatched: vote with event[:guardrail].deny!/ask!
|
|
22
|
+
# (or the older event[:blocked]=true + event[:block_reason]); the
|
|
23
|
+
# event also has context: and targets: (docs/guardrails.md)
|
|
24
|
+
# :after_tool_call — after tool execution, before result injection
|
|
25
|
+
module Hooks
|
|
26
|
+
REGISTRY_CLASS = Registry
|
|
27
|
+
LOADER_CLASS = Loader
|
|
28
|
+
BUNDLE_LOADER_CLASS = BundleLoader
|
|
29
|
+
end
|
|
30
|
+
end
|