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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tool_activity"
4
+ require_relative "guardrails"
5
+ require_relative "vision_context"
6
+ require_relative "log"
7
+
8
+ module Samagotchi
9
+ # The single per-call path both loops use: the tool_call_started and
10
+ # tool_call_completed events, the guardrail gate (before_tool_call hooks
11
+ # and their veto), dispatch through KernelLoop, the after_tool_call hook
12
+ # and the output cap. Each loop picks what the model gets back: native
13
+ # feeds the full `output:`, the chat loop feeds `capped_output:` (the cap
14
+ # on the event applies to both).
15
+ class ToolRunner
16
+ # More images in one result are left out, each with a line (a tool
17
+ # that returns many screenshots can't flood the context).
18
+ MAX_IMAGES_PER_RESULT = 4
19
+
20
+ # @param kernel [KernelLoop] read lazily: Engine sets its hooks after
21
+ # the kernel is built.
22
+ def initialize(kernel)
23
+ @kernel = kernel
24
+ end
25
+
26
+ # @param call_index [Integer] 1-based position of the call in its batch
27
+ # @return [Hash] output:, capped_output:, truncated:, activity:,
28
+ # images: (refs) when the tool returned images the model gets to see, and
29
+ # shown_params: the params line the live row showed, and shown_label:
30
+ # its label ("chrome: screenshot"), only for a tool that isn't built in
31
+ # (the loops save them with the result, so a reload without the plugin
32
+ # shows the same row)
33
+ def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
34
+ params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
35
+ # The gate runs first, so tool_call_started shows the call that runs.
36
+ verdict = evaluate(call, iteration, params)
37
+ call = verdict.call
38
+ params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
39
+ label = plugin_label(call[:name])
40
+ started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
41
+ tool: call[:name], call: call.dup, params: params }
42
+ started[:label] = label if label
43
+ emit(on_stream_event, started)
44
+
45
+ # The ask comes after tool_call_started: the UI shows the tool line,
46
+ # then the approval under it.
47
+ settle_ask(verdict) if verdict.ask?
48
+ result = verdict.deny? ? denied(call, verdict) : dispatch(call)
49
+ result = approved(result, verdict) if verdict.allow? && verdict.decided_by
50
+ result, images = attach_images(call, result) if result[:images]
51
+
52
+ output = scrub(result[:output].to_s)
53
+ capped = output
54
+ truncated = false
55
+ if max_tool_output_chars && output.length > max_tool_output_chars
56
+ truncated = true
57
+ capped = output[0, max_tool_output_chars]
58
+ end
59
+
60
+ fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped })
61
+ completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
62
+ tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
63
+ completed[:images] = images if images&.any?
64
+ emit(on_stream_event, completed)
65
+
66
+ run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
67
+ run[:images] = images if images&.any?
68
+ run[:shown_params] = params if params && plugin_tool?(call[:name])
69
+ run[:shown_label] = label if label
70
+ run
71
+ end
72
+
73
+ private
74
+
75
+ # A tool's output can hold bytes that aren't UTF-8 (`printf '\xff'`, a
76
+ # binary file). They become "?" here, before the output reaches the
77
+ # conversation, the events and the saved session: JSON.generate raises
78
+ # on them, and the session would fail to save.
79
+ def scrub(text)
80
+ text = text.dup.force_encoding(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
81
+ text.valid_encoding? ? text : text.scrub("?")
82
+ end
83
+
84
+ # A tool returned images (read an image file, or a plugin's
85
+ # ToolResult): store each with the session (the turn's VisionContext)
86
+ # so the loop sends it. One that can't be sent adds a line saying why
87
+ # and keeps the text and the other images; for read, whose text only
88
+ # says the image is attached, that line replaces the text.
89
+ # @return [Array(Hash, Array<Hash>)] the result and its refs
90
+ def attach_images(call, result)
91
+ vision = @kernel.vision if @kernel.respond_to?(:vision)
92
+ reason = if vision.nil? then "images can't be attached here"
93
+ elsif !vision.sendable? then ImagePlan::CANT_SEE
94
+ end
95
+ refs = []
96
+ notes = []
97
+ Array(result[:images]).each_with_index do |entry, index|
98
+ entry = ImageStore.symbolize(entry)
99
+ unless valid_image_entry?(entry)
100
+ notes << "Error: image #{index + 1} is not {path:} or {bytes:, name:}"
101
+ next
102
+ end
103
+
104
+ description = entry[:description] || entry[:name] || (entry[:path] && File.basename(entry[:path])) || "image #{index + 1}"
105
+ if reason
106
+ notes << "#{description} is an image; #{reason}"
107
+ elsif refs.size >= MAX_IMAGES_PER_RESULT
108
+ notes << "#{description} is not attached: at most #{MAX_IMAGES_PER_RESULT} images per tool result"
109
+ else
110
+ refs << vision.ingest(entry[:path], name: entry[:name], bytes: entry[:bytes])
111
+ end
112
+ rescue ImageStore::Error => e
113
+ notes << "Error: #{e.message}"
114
+ end
115
+ unless refs.empty?
116
+ Log.info(:turn, "tool_images_attached", tool: call[:name], count: refs.size,
117
+ bytes: refs.sum { |ref| ref[:bytes].to_i }, left_out: notes.size)
118
+ end
119
+ [result.merge(output: images_output(call, result, notes)), refs]
120
+ end
121
+
122
+ def valid_image_entry?(entry)
123
+ return false unless entry.is_a?(Hash)
124
+
125
+ (entry[:path].is_a?(String) && !entry[:path].empty?) || (entry[:bytes].is_a?(String) && !entry[:bytes].empty?)
126
+ end
127
+
128
+ def images_output(call, result, notes)
129
+ return result[:output] if notes.empty?
130
+ return "[#{call[:name]}] #{notes.first}" if result[:image_only] && notes.first.start_with?("Error: ")
131
+ return "[#{call[:name]}]\n#{notes.first}" if result[:image_only]
132
+
133
+ [result[:output], *notes].join("\n")
134
+ end
135
+
136
+ # The kernel's tools, for the activity line of a tool that isn't built in.
137
+ def tools = @kernel.respond_to?(:tools) ? @kernel.tools : nil
138
+
139
+ # A tool the registry has from a plugin (not core, not unknown).
140
+ def plugin_tool?(name)
141
+ entry = tools && !name.nil? ? tools[name] : nil
142
+ !entry.nil? && !entry.core?
143
+ end
144
+
145
+ # A plugin tool's label, what the UIs show for its raw name.
146
+ def plugin_label(name) = ToolActivity.plugin_label(name, registry: tools)
147
+
148
+ # The Engine sets the kernel's gate (its context, later the approval
149
+ # flow); a bare kernel (specs) gets one that only runs the hooks.
150
+ def gate
151
+ given = @kernel.guardrail_gate if @kernel.respond_to?(:guardrail_gate)
152
+ given || (@gate ||= Guardrails::Gate.new(-> { @kernel.hooks if @kernel.respond_to?(:hooks) }, tools_lookup: -> { tools }))
153
+ end
154
+
155
+ # A gate that fails denies the call (fail closed).
156
+ def evaluate(call, iteration, params)
157
+ gate.evaluate(call, iteration: iteration, params: params)
158
+ rescue StandardError => e
159
+ Guardrails::Verdict.new(call: call).deny!("the guardrail check failed: #{e.class}: #{e.message}",
160
+ decided_by: "core")
161
+ end
162
+
163
+ def settle_ask(verdict)
164
+ gate.settle_ask(verdict)
165
+ rescue StandardError => e
166
+ verdict.settle!(:deny, decided_by: "core", note: "The approval failed (#{e.class}: #{e.message}).")
167
+ end
168
+
169
+ # An allowed ask: the activity says who allowed it, and for what scope.
170
+ def approved(result, verdict)
171
+ activity = result[:activity]
172
+ activity = activity.merge(guardrail: verdict.to_activity) if activity.is_a?(Hash)
173
+ result.merge(activity: activity)
174
+ end
175
+
176
+ # A legacy veto keeps its old text; a verdict's deny tells the model
177
+ # who decided and not to route around it.
178
+ def denied(call, verdict)
179
+ output = if verdict.legacy?
180
+ reason = verdict.reason.to_s.strip
181
+ "[#{call[:name]}] Error: blocked by guardrail: #{reason.empty? ? "blocked by hook" : reason}"
182
+ else
183
+ "[#{call[:name]}] Error: #{verdict.deny_text}"
184
+ end
185
+ activity = ToolActivity.tool_activity_event(call[:name], call, output, registry: tools)
186
+ { output: output, activity: activity.merge(status: "blocked", guardrail: verdict.to_activity) }
187
+ end
188
+
189
+ # KernelLoop#dispatch turns tool errors into "[name] Error: …" itself;
190
+ # this rescue only catches a failing dispatcher.
191
+ def dispatch(call)
192
+ @kernel.dispatch_tool_call(call)
193
+ rescue StandardError => e
194
+ { output: "[#{call[:name]}] Error: #{e.class}: #{e.message}", activity: nil }
195
+ end
196
+
197
+ # A failing hook must not break the turn.
198
+ def fire(name, event)
199
+ hooks = @kernel.hooks if @kernel.respond_to?(:hooks)
200
+ hooks&.fire(name, event)
201
+ rescue StandardError
202
+ nil
203
+ end
204
+
205
+ def emit(callback, event)
206
+ callback&.call(event)
207
+ rescue StandardError
208
+ nil
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Samagotchi
6
+ module Tools
7
+ # A registry (plugin) tool's structured arguments. The parsers put what
8
+ # the model gave on call[:args] (string keys) for any tool that is not a
9
+ # built-in; .coerce then types the values by the tool's schema, since
10
+ # Qwen's <parameter=…> values are all text and Gemma's bare ones may be.
11
+ module Args
12
+ module_function
13
+
14
+ # Gemma 4's native call body (+text:<|"|>hi<|"|>,n:3,tags:[…],opt:{…}+,
15
+ # without the outer braces) as a Hash; nil when it doesn't parse.
16
+ # @param raw [String]
17
+ # @param delim [String] the profile's string delimiter (<|"|>)
18
+ def parse_gemma(raw, delim)
19
+ GemmaReader.new(raw.to_s, delim).object_body
20
+ rescue GemmaReader::Invalid
21
+ nil
22
+ end
23
+
24
+ # +args+ with each value typed by its property in +parameters+ (a JSON
25
+ # Schema object): integer, number and boolean from their text, array
26
+ # and object from JSON text, and a scalar given for a string as text.
27
+ # A value that doesn't fit its type stays as it came; keys the schema
28
+ # doesn't name pass through.
29
+ # @param args [Hash] string keys
30
+ # @param parameters [Hash, nil] {type: "object", properties: {…}}
31
+ # @return [Hash] string keys
32
+ def coerce(args, parameters)
33
+ properties = field(parameters, :properties)
34
+ return args.to_h { |key, value| [key.to_s, value] } unless properties.is_a?(Hash)
35
+
36
+ args.to_h do |key, value|
37
+ spec = field(properties, key)
38
+ [key.to_s, spec.is_a?(Hash) ? coerce_value(value, spec) : value]
39
+ end
40
+ end
41
+
42
+ def coerce_value(value, spec)
43
+ case type_of(spec)
44
+ when "integer" then to_integer(value)
45
+ when "number" then to_number(value)
46
+ when "boolean" then to_boolean(value)
47
+ when "string" then value.is_a?(Numeric) || value == true || value == false ? value.to_s : value
48
+ when "array"
49
+ list = from_json(value, Array)
50
+ items = field(spec, :items)
51
+ list.is_a?(Array) && items.is_a?(Hash) ? list.map { |item| coerce_value(item, items) } : list
52
+ when "object"
53
+ hash = from_json(value, Hash)
54
+ hash.is_a?(Hash) ? coerce(hash, spec) : hash
55
+ else value
56
+ end
57
+ end
58
+
59
+ # The first non-null type ("type": ["string", "null"] is common in MCP).
60
+ def type_of(spec)
61
+ Array(field(spec, :type)).map(&:to_s).find { |type| type != "null" }
62
+ end
63
+
64
+ def to_integer(value)
65
+ case value
66
+ when String then value.strip.match?(/\A-?\d+\z/) ? Integer(value.strip, 10) : value
67
+ when Float then value == value.floor ? value.to_i : value
68
+ else value
69
+ end
70
+ end
71
+
72
+ def to_number(value)
73
+ return value unless value.is_a?(String)
74
+
75
+ text = value.strip
76
+ return Integer(text, 10) if text.match?(/\A-?\d+\z/)
77
+
78
+ Float(text)
79
+ rescue ArgumentError
80
+ value
81
+ end
82
+
83
+ def to_boolean(value)
84
+ return value unless value.is_a?(String)
85
+
86
+ case value.strip.downcase
87
+ when "true" then true
88
+ when "false" then false
89
+ else value
90
+ end
91
+ end
92
+
93
+ def from_json(value, klass)
94
+ return value unless value.is_a?(String)
95
+
96
+ parsed = JSON.parse(value)
97
+ parsed.is_a?(klass) ? parsed : value
98
+ rescue JSON::ParserError
99
+ value
100
+ end
101
+
102
+ def field(hash, key)
103
+ return nil unless hash.is_a?(Hash)
104
+
105
+ hash.key?(key.to_sym) ? hash[key.to_sym] : hash[key.to_s]
106
+ end
107
+
108
+ # A small reader for Gemma's value syntax: <|"|>text<|"|>, "text" or
109
+ # 'text', numbers, true/false/null, [a, b] and {key: value}; a bare
110
+ # word runs to the next , ] or }.
111
+ class GemmaReader
112
+ class Invalid < StandardError; end
113
+
114
+ def initialize(text, delim)
115
+ @text = text
116
+ @delim = delim
117
+ @pos = 0
118
+ end
119
+
120
+ # The whole text as an object's pairs.
121
+ def object_body
122
+ skip_space
123
+ return {} if @pos == @text.length
124
+
125
+ hash = pairs(nil)
126
+ skip_space
127
+ raise Invalid unless @pos == @text.length
128
+
129
+ hash
130
+ end
131
+
132
+ private
133
+
134
+ def pairs(close)
135
+ hash = {}
136
+ skip_space
137
+ return hash if close && peek == close
138
+
139
+ loop do
140
+ key = read_key
141
+ skip_space
142
+ raise Invalid unless peek == ":"
143
+
144
+ @pos += 1
145
+ hash[key] = read_value
146
+ skip_space
147
+ break unless peek == ","
148
+
149
+ @pos += 1
150
+ skip_space
151
+ break if close && peek == close # a trailing comma
152
+ end
153
+ hash
154
+ end
155
+
156
+ def read_key
157
+ skip_space
158
+ return read_string if string_start?
159
+
160
+ start = @pos
161
+ @pos += 1 while @pos < @text.length && @text[@pos].match?(/\w/)
162
+ raise Invalid if @pos == start
163
+
164
+ @text[start...@pos]
165
+ end
166
+
167
+ def read_value
168
+ skip_space
169
+ return read_string if string_start?
170
+
171
+ case peek
172
+ when "[" then read_list
173
+ when "{"
174
+ @pos += 1
175
+ hash = pairs("}")
176
+ expect("}")
177
+ hash
178
+ else read_bare
179
+ end
180
+ end
181
+
182
+ def read_list
183
+ @pos += 1
184
+ list = []
185
+ skip_space
186
+ until peek == "]"
187
+ list << read_value
188
+ skip_space
189
+ break unless peek == ","
190
+
191
+ @pos += 1
192
+ skip_space
193
+ end
194
+ expect("]")
195
+ list
196
+ end
197
+
198
+ def string_start? = @text[@pos, @delim.length] == @delim || peek == '"' || peek == "'"
199
+
200
+ def read_string
201
+ if @text[@pos, @delim.length] == @delim
202
+ start = @pos + @delim.length
203
+ close = @text.index(@delim, start) or raise Invalid
204
+ @pos = close + @delim.length
205
+ return @text[start...close]
206
+ end
207
+
208
+ quote = peek
209
+ @pos += 1
210
+ out = +""
211
+ while @pos < @text.length
212
+ char = @text[@pos]
213
+ if char == "\\" && @pos + 1 < @text.length
214
+ nxt = @text[@pos + 1]
215
+ out << { "n" => "\n", "t" => "\t" }.fetch(nxt, nxt)
216
+ @pos += 2
217
+ elsif char == quote
218
+ @pos += 1
219
+ return out
220
+ else
221
+ out << char
222
+ @pos += 1
223
+ end
224
+ end
225
+ raise Invalid
226
+ end
227
+
228
+ def read_bare
229
+ start = @pos
230
+ @pos += 1 while @pos < @text.length && !",]}".include?(@text[@pos])
231
+ word = @text[start...@pos].strip
232
+ raise Invalid if word.empty?
233
+
234
+ case word
235
+ when "true" then true
236
+ when "false" then false
237
+ when "null" then nil
238
+ when /\A-?\d+\z/ then Integer(word, 10)
239
+ when /\A-?\d+\.\d+(?:[eE][-+]?\d+)?\z/ then Float(word)
240
+ else word
241
+ end
242
+ end
243
+
244
+ def expect(char)
245
+ skip_space
246
+ raise Invalid unless peek == char
247
+
248
+ @pos += 1
249
+ end
250
+
251
+ def peek = @text[@pos]
252
+
253
+ def skip_space
254
+ @pos += 1 while @pos < @text.length && @text[@pos].match?(/\s/)
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Samagotchi
6
+ module Tools
7
+ # Structured user qualification tool.
8
+ #
9
+ # The model invokes this via tool_call when it needs a structured choice from the user
10
+ # instead of a free-form numbered list in plain text. The harness intercepts the call,
11
+ # renders a UI-agnostic question (TUI prompt / WEB buttons / future UIs via the same
12
+ # Engine event), blocks until the user answers, and returns the normalized selection
13
+ # as the tool_response. No direct terminal/web coupling lives here — callers provide
14
+ # the blocking handler via Engine (or fallback to a plain error).
15
+ #
16
+ # Single tool covers single + multi + freeform via flags: multi_select, allow_freeform.
17
+ class AskUserQuestion
18
+ NAME = "ask_user_question"
19
+
20
+ def self.name = NAME
21
+
22
+ # Direct invocation (used in specs / headless fallback).
23
+ # When a blocking handler is not injected, return an instructional error so the
24
+ # model falls back to plain text rather than hanging.
25
+ def self.call(question, options: nil, header: nil, multi_select: nil, allow_freeform: nil)
26
+ question = question.to_s.strip
27
+ return "Error: question is required" if question.empty?
28
+
29
+ opts = normalize_options(options)
30
+ return "Error: options must be an array of 2-8 non-empty strings" if opts.nil?
31
+
32
+ header = header.to_s.strip
33
+ header = nil if header.empty?
34
+ ms = to_bool(multi_select)
35
+ af = to_bool(allow_freeform)
36
+
37
+ payload = {
38
+ question: question,
39
+ options: opts,
40
+ header: header,
41
+ multi_select: ms,
42
+ allow_freeform: af
43
+ }.compact
44
+
45
+ JSON.pretty_generate(payload)
46
+ end
47
+
48
+ # Normalize options param: accept Array or JSON string; strip, reject empty.
49
+ # Dumb-model tolerant: handles JSON arrays, quoted CSV, bracket noise, single strings.
50
+ def self.normalize_options(raw)
51
+ arr = extract_options_array(raw)
52
+ return nil unless arr
53
+
54
+ cleaned = arr.map { |v| sanitize_option(v) }.reject { |v| v.nil? || v.empty? }
55
+ # Strict: 2-8, but lenient wrapper allows 1 for dumb-model salvage — keeps strict nil here
56
+ return nil unless cleaned.size.between?(2, 8)
57
+
58
+ cleaned
59
+ end
60
+
61
+ def self.extract_options_array(raw)
62
+ case raw
63
+ when Array
64
+ raw.dup
65
+ when String
66
+ s = raw.to_s.strip
67
+ return nil if s.empty?
68
+
69
+ # 1) Try JSON parse (most common: '["a","b"]')
70
+ begin
71
+ parsed = JSON.parse(s)
72
+ return parsed.dup if parsed.is_a?(Array)
73
+ # If parsed is a String like "a, b", fall through to split
74
+ if parsed.is_a?(String)
75
+ s = parsed
76
+ end
77
+ rescue JSON::ParserError
78
+ nil
79
+ end
80
+
81
+ # 2) If it looks like a JSON array but JSON parse failed due to single quotes or trailing commas, extract quoted strings
82
+ if s.strip.start_with?("[") && s.strip.end_with?("]")
83
+ quoted = s.scan(/"((?:[^"\\]|\\.)*)"/).flatten
84
+ unless quoted.empty?
85
+ # Unescape and strip
86
+ unescaped = quoted.map { |v| v.gsub('\\"', '"').gsub("\\\\", "\\").strip }
87
+ # Filter out pure bracket noise
88
+ filtered = unescaped.reject { |v| v.empty? || v.match?(/\A[\[\],\s]+\z/) }
89
+ return filtered unless filtered.empty?
90
+ end
91
+ # Also try single-quote variant
92
+ quoted2 = s.scan(/'((?:[^'\\]|\\.)*)'/).flatten
93
+ unless quoted2.empty?
94
+ unescaped2 = quoted2.map { |v| v.gsub("\\'", "'").gsub("\\\\", "\\").strip }
95
+ filtered2 = unescaped2.reject { |v| v.empty? || v.match?(/\A[\[\],\s]+\z/) }
96
+ return filtered2 unless filtered2.empty?
97
+ end
98
+ end
99
+
100
+ # 3) Fallback: remove outer brackets then split on comma/semicolon/newline, respecting quotes
101
+ t = s.strip
102
+ t = t.sub(/\A\s*\[/, "").sub(/\]\s*\z/, "")
103
+ # Split on comma/semicolon/newline not inside quotes (simple)
104
+ parts = t.split(/[,;\n]+/).map(&:strip)
105
+ # Strip surrounding quotes from each part
106
+ parts.map { |p| p.gsub(/\A["'\s]+|["'\s]+\z/, "").strip }
107
+ else
108
+ return nil
109
+ end
110
+ end
111
+
112
+ def self.sanitize_option(v)
113
+ s = v.to_s.strip
114
+ return nil if s.empty?
115
+
116
+ # Remove surrounding quotes/brackets that dumb models include
117
+ s = s.gsub(/\A["'\s\[\]]+|["'\s\[\]]+\z/, "").strip
118
+ # Drop wire control tokens (<|...|> and stray <| / |> fragments) that
119
+ # can bleed into labels when the model wraps options in tool_call markup.
120
+ s = s.gsub(/<\|[^|]*\|>/, "").gsub(/<\||\|>/, "")
121
+ # Unescape inner
122
+ s = s.gsub('\\"', '"').gsub("\\'", "'").gsub("\\\\", "\\")
123
+ s = s.strip
124
+ return nil if s.empty?
125
+ return nil if s.match?(/\A[\[\],\s]+\z/)
126
+ return nil if s == "]" || s == "["
127
+
128
+ s
129
+ end
130
+
131
+ # Public tolerant wrapper used by Engine/KernelLoop/Normalizer (no size check, always returns array)
132
+ def self.normalize_options_lenient(raw)
133
+ arr = extract_options_array(raw)
134
+ return [] unless arr
135
+
136
+ arr.map { |v| sanitize_option(v) }.reject { |v| v.nil? || v.empty? }
137
+ end
138
+
139
+ def self.to_bool(v)
140
+ return nil if v.nil?
141
+ return v if v == true || v == false
142
+
143
+ s = v.to_s.strip.downcase
144
+ return true if %w[1 true yes on].include?(s)
145
+ return false if %w[0 false no off].include?(s)
146
+
147
+ nil
148
+ end
149
+ private_class_method :to_bool
150
+ end
151
+ end
152
+ end