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,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
|