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.
Files changed (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. 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