samagotchi 0.9.0 → 0.10.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +3 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +34 -12
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +29 -29
  11. data/lib/samagotchi/bridge/card_store.rb +4 -4
  12. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  15. data/lib/samagotchi/bridge.rb +59 -112
  16. data/lib/samagotchi/bridge_client.rb +11 -28
  17. data/lib/samagotchi/bundle_command.rb +158 -212
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  20. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  21. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  26. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
  28. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  29. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/cli/command.rb +51 -0
  33. data/lib/samagotchi/cli/flags.rb +116 -0
  34. data/lib/samagotchi/config.rb +70 -55
  35. data/lib/samagotchi/config_text_edit.rb +107 -0
  36. data/lib/samagotchi/desktop_command.rb +24 -18
  37. data/lib/samagotchi/edit_preview.rb +2 -2
  38. data/lib/samagotchi/engine.rb +20 -75
  39. data/lib/samagotchi/events.rb +23 -0
  40. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  41. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  42. data/lib/samagotchi/hooks/loader.rb +23 -33
  43. data/lib/samagotchi/hooks/registry.rb +41 -0
  44. data/lib/samagotchi/hooks.rb +0 -3
  45. data/lib/samagotchi/host_registry.rb +15 -0
  46. data/lib/samagotchi/idle_reminders.rb +0 -7
  47. data/lib/samagotchi/image_store.rb +22 -0
  48. data/lib/samagotchi/kernel_loop.rb +12 -16
  49. data/lib/samagotchi/live_versions.rb +2 -13
  50. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  51. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  52. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  53. data/lib/samagotchi/memory_bundle/installer.rb +1 -27
  54. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  55. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  56. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  57. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  58. data/lib/samagotchi/memory_paths.rb +10 -0
  59. data/lib/samagotchi/note_command.rb +23 -37
  60. data/lib/samagotchi/owner_lock.rb +14 -3
  61. data/lib/samagotchi/plugin/api.rb +11 -17
  62. data/lib/samagotchi/plugin/context.rb +70 -15
  63. data/lib/samagotchi/plugin/loader.rb +2 -3
  64. data/lib/samagotchi/sampling_settings.rb +3 -5
  65. data/lib/samagotchi/self_report.rb +6 -9
  66. data/lib/samagotchi/send_command.rb +33 -52
  67. data/lib/samagotchi/session.rb +88 -92
  68. data/lib/samagotchi/session_archive_command.rb +8 -14
  69. data/lib/samagotchi/session_commands.rb +3 -21
  70. data/lib/samagotchi/session_delete_command.rb +15 -29
  71. data/lib/samagotchi/session_manager.rb +27 -13
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  74. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  75. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  76. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  77. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  78. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  79. data/lib/samagotchi/terminal_ui.rb +156 -234
  80. data/lib/samagotchi/thinking.rb +2 -3
  81. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  82. data/lib/samagotchi/tool_call_parser.rb +56 -292
  83. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/edit.rb +21 -29
  86. data/lib/samagotchi/tools/memory.rb +1 -11
  87. data/lib/samagotchi/tools/send_note.rb +1 -1
  88. data/lib/samagotchi/update_command.rb +32 -22
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/vision_support.rb +4 -5
  91. data/lib/samagotchi/web/app.rb +89 -151
  92. data/lib/samagotchi/web/public/activity.js +1 -11
  93. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  94. data/lib/samagotchi/web/public/app.js +158 -785
  95. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  96. data/lib/samagotchi/web/public/command_list.js +99 -0
  97. data/lib/samagotchi/web/public/data.js +22 -66
  98. data/lib/samagotchi/web/public/format.js +0 -23
  99. data/lib/samagotchi/web/public/index.html +0 -2
  100. data/lib/samagotchi/web/public/model_pick.js +0 -7
  101. data/lib/samagotchi/web/public/notifications.js +169 -0
  102. data/lib/samagotchi/web/public/question_cards.js +352 -0
  103. data/lib/samagotchi/web/public/session_state.js +34 -0
  104. data/lib/samagotchi/web/public/timing.js +1 -1
  105. data/lib/samagotchi/web/public/turn_events.js +11 -2
  106. data/lib/samagotchi/web/public/turn_model.js +0 -14
  107. data/lib/samagotchi/web/session_hub.rb +6 -4
  108. data/lib/samagotchi/web/session_summary.rb +5 -3
  109. data/lib/samagotchi/worker.rb +59 -53
  110. data/lib/samagotchi/worker_sidecar.rb +27 -1
  111. metadata +12 -1
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cli/command"
4
+ require_relative "cli/flags"
3
5
  require_relative "desktop"
4
6
 
5
7
  module Samagotchi
6
8
  # `chi desktop install|upgrade|uninstall|status`: the native helper that
7
9
  # sends selected text to live sessions as a context note (macOS for now).
8
10
  class DesktopCommand
11
+ include CLI::Command
12
+
9
13
  USAGE = <<~TEXT
10
14
  Usage: chi desktop <install|upgrade|uninstall|status>
11
15
  install [--force] [--login]
@@ -21,6 +25,15 @@ module Samagotchi
21
25
 
22
26
  SUBCOMMANDS = %w[install upgrade uninstall status].freeze
23
27
 
28
+ # --no-register (hidden) on each; --help is an unknown option after the
29
+ # subcommand.
30
+ FLAGS = CLI::Flags.new(args: false) { |f| f.switch "--no-register" }
31
+ INSTALL_FLAGS = CLI::Flags.new(args: false) do |f|
32
+ f.switch "--no-register"
33
+ f.switch "--force"
34
+ f.switch "--login"
35
+ end
36
+
24
37
  # @param platform [#call] register: → a Desktop::MacOS-like object
25
38
  def initialize(argv, stdout: $stdout, stderr: $stderr, platform: nil, supported: Desktop.supported?)
26
39
  @argv = argv.dup
@@ -33,7 +46,7 @@ module Samagotchi
33
46
  # @return [Integer] 0 ok, 1 failed, 2 usage
34
47
  def run
35
48
  sub = @argv.shift
36
- if sub.nil? || %w[-h --help help].include?(sub)
49
+ if sub.nil? || HELP_WORDS.include?(sub)
37
50
  @stdout.puts(USAGE)
38
51
  return 0
39
52
  end
@@ -44,7 +57,9 @@ module Samagotchi
44
57
  return 1
45
58
  end
46
59
 
47
- options = parse(sub) or return 2
60
+ options = parse(sub)
61
+ return options if options.is_a?(Integer)
62
+
48
63
  # --no-register (hidden): no pbs/lsregister/open/pkill, for installs
49
64
  # into temp dirs (HOME=…) that must not touch the real Services.
50
65
  helper = @platform.call(register: !options[:no_register])
@@ -56,17 +71,14 @@ module Samagotchi
56
71
 
57
72
  private
58
73
 
59
- # @return [Hash, nil] nil after a usage error
74
+ def command_name = "chi desktop"
75
+
76
+ # @return [Hash, Integer] the options, or the usage error's exit status
60
77
  def parse(sub)
61
- flags = { "--no-register" => :no_register }
62
- flags.merge!("--force" => :force, "--login" => :login) if sub == "install"
63
- @argv.each_with_object({}) do |arg, options|
64
- unless flags.key?(arg)
65
- usage_error("unknown option #{arg}")
66
- return nil
67
- end
68
- options[flags[arg]] = true
69
- end
78
+ parsed = (sub == "install" ? INSTALL_FLAGS : FLAGS).parse(@argv)
79
+ return usage_error(parsed.error.message) if parsed.error
80
+
81
+ parsed.options
70
82
  end
71
83
 
72
84
  # The warnings come after a done install: a refused or failed one
@@ -136,11 +148,5 @@ module Samagotchi
136
148
  def print_warnings(helper)
137
149
  helper.warnings.each { |warning| @stderr.puts("warning: #{warning}") }
138
150
  end
139
-
140
- def usage_error(message)
141
- @stderr.puts("chi desktop: #{message}")
142
- @stderr.puts(USAGE)
143
- 2
144
- end
145
151
  end
146
152
  end
@@ -33,8 +33,8 @@ module Samagotchi
33
33
  return before if before.is_a?(Hash)
34
34
 
35
35
  if call[:name].to_s == "edit"
36
- result = Tools::Edit.apply(call[:content], path: path, start_line: call[:start_line],
37
- end_line: call[:end_line], read: ->(_) { before })
36
+ result = Tools::Edit.apply(path: path, old_text: call[:old_text], new_text: call[:new_text],
37
+ start_line: call[:start_line], end_line: call[:end_line], read: ->(_) { before })
38
38
  return { error: result.delete_prefix("Error: ") } if result.is_a?(String)
39
39
 
40
40
  after = result.first
@@ -11,7 +11,6 @@ require_relative "context_note"
11
11
  require_relative "steer"
12
12
  require_relative "turn_note"
13
13
  require_relative "model_profile"
14
- require_relative "thought_stream_splitter"
15
14
  require_relative "cancellation_controller"
16
15
  require_relative "context_window"
17
16
  require_relative "kernel_loop"
@@ -107,7 +106,7 @@ module Samagotchi
107
106
  # #profile_resolution decides on first need (see there), so building an
108
107
  # Engine makes no network call.
109
108
  @given_profile = profile ? ModelProfile.normalize(profile) : nil
110
- @model_lookup_names = [@default_model_name]
109
+ @typed_model_name = @default_model_name
111
110
  @profile_resolution = nil
112
111
  # Ensure the built-in system bundle is installed (lazy, warn-only).
113
112
  # This is the single seam for both TUI and non-TUI (web/worker) paths.
@@ -769,7 +768,7 @@ module Samagotchi
769
768
  resolved = ModelProfile.check_host!(ModelProfile.required_model_name(aliased), hosts: @host_registry.entries)
770
769
  @effective_model_name = resolved
771
770
  bare = bare_model_name(resolved)
772
- @model_lookup_names = [model_name, aliased, resolved]
771
+ @typed_model_name = model_name
773
772
  # A profile given to .new was for the starting model.
774
773
  @given_profile = nil
775
774
  @profile_resolution = nil
@@ -844,7 +843,7 @@ module Samagotchi
844
843
  # head system prompt invalidates the cache for the entire conversation
845
844
  # (prompt re-evaluated every interval). A tail append keeps the prefix
846
845
  # intact — only the new reminder suffix is evaluated. Mirrors the
847
- # context-status injection at lib/samagotchi/kernel_loop.rb:447.
846
+ # context guidance line KernelLoop#run appends (ContextStatus#take_guidance).
848
847
  messages << { role: "system", content: reminder_text }
849
848
  # Atomically mark all due as fired under one lock and clear the
850
849
  # IdleReminders latch so the next interval can be detected.
@@ -1107,29 +1106,15 @@ module Samagotchi
1107
1106
  @guardrail_wiring.state_dir = state_dir
1108
1107
  end
1109
1108
 
1110
- # The gate's core checks, in order (GuardrailWiring#checks).
1111
- def guardrail_checks
1112
- @guardrail_wiring.checks
1113
- end
1114
-
1115
1109
  # The YAML rules, read again when a file changed (GuardrailWiring#rules).
1116
1110
  # @return [Guardrails::Rules]
1117
1111
  def guardrail_rules
1118
1112
  @guardrail_wiring.rules
1119
1113
  end
1120
1114
 
1121
- # Installed bundles' guardrails/*.yml (GuardrailWiring#bundle_rules).
1122
- def bundle_guardrail_rules
1123
- @guardrail_wiring.bundle_rules
1124
- end
1125
-
1126
1115
  # @return [Guardrails::LoadFailures]
1127
1116
  attr_reader :guardrail_failures
1128
1117
 
1129
- def guardrail_protected_paths
1130
- @guardrail_wiring.protected_paths
1131
- end
1132
-
1133
1118
  # @return [Guardrails::Approvals]
1134
1119
  def guardrail_approvals
1135
1120
  @guardrail_wiring.approvals
@@ -1165,12 +1150,6 @@ module Samagotchi
1165
1150
  @plugin_failures.message if @guardrail_failures_announced
1166
1151
  end
1167
1152
 
1168
- # The context the gate sees for a tool call now.
1169
- # @return [Guardrails::Context]
1170
- def guardrail_context
1171
- @guardrail_wiring.context
1172
- end
1173
-
1174
1153
  # Ask the user to approve a call the gate voted `ask` on
1175
1154
  # (GuardrailWiring#request_approval).
1176
1155
  # @param verdict [Guardrails::Verdict]
@@ -2065,7 +2044,7 @@ module Samagotchi
2065
2044
  (task = current_init_task) ? task.cancelled? : @activity_mutex && active_cancel_controller&.cancelled?
2066
2045
  },
2067
2046
  card: ->(**card) { show_card(**card) },
2068
- steer: ->(text, source) { steer(text, source: source) },
2047
+ steer: ->(text, label) { steer(text, source: hook_source(label)) },
2069
2048
  stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
2070
2049
  stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
2071
2050
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
@@ -2108,31 +2087,13 @@ module Samagotchi
2108
2087
  end
2109
2088
  private :ask_side_model
2110
2089
 
2111
- # config.yml `bundles:`: each bundle's settings by name, for its hooks.
2112
- # @return [Hash{String => Hash}] {} when absent; a section that isn't a
2113
- # mapping warns once and counts as absent
2090
+ # config.yml `bundles:` (ConfigFile.bundle_settings), read once: the
2091
+ # bundle hooks and the plugins both want it.
2114
2092
  def bundle_settings
2115
- # Read once: the bundle hooks and the plugins both want it.
2116
- @bundle_settings ||= read_bundle_settings
2093
+ @bundle_settings ||= Samagotchi::ConfigFile.bundle_settings
2117
2094
  end
2118
2095
  private :bundle_settings
2119
2096
 
2120
- def read_bundle_settings
2121
- data = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
2122
- section = data.is_a?(Hash) ? data["bundles"] : nil
2123
- return {} if section.nil?
2124
- unless section.is_a?(Hash)
2125
- Log.warn(:hooks, "bundles_section_invalid", echo: "[samagotchi:hooks] config.yml bundles: must be a mapping of bundle name to settings; ignored")
2126
- return {}
2127
- end
2128
-
2129
- section.each_with_object({}) do |(name, value), acc|
2130
- acc[name.to_s] = value.is_a?(Hash) ? value : {}
2131
- end
2132
- rescue StandardError
2133
- {}
2134
- end
2135
- private :read_bundle_settings
2136
2097
 
2137
2098
  # The session's current model as a recap target: its host's OpenAI API
2138
2099
  # (native llama.cpp hosts serve /v1/chat/completions too), key variable
@@ -2168,44 +2129,29 @@ module Samagotchi
2168
2129
  # only notifying the observer.
2169
2130
  # Wrap a raw kernel stream event and fan it out to the turn sink + observers.
2170
2131
  #
2171
- # Phase 2 (web-stream-rendering): each :generation_chunk is enriched with two
2172
- # ADDITIVE fields derived from the raw `content` (which is left untouched —
2173
- # the TUI thinking spinner, analytics, and Bridge replay all rely on raw):
2132
+ # Each :generation_chunk comes split already, by the loop that streamed it:
2133
+ # besides the raw `content` (left untouched — the TUI thinking spinner,
2134
+ # analytics, and Bridge replay rely on it) it carries
2174
2135
  # * :text — visible prose (thinking AND tool_call blocks removed)
2175
2136
  # * :thinking — thinking-only content
2176
- # The splitter is profile-aware and resets on each :generation_started so a
2177
- # turn's multiple generations each start clean.
2178
- #
2179
- # Enrichment is profile-scoped:
2180
- # * Splitting profiles (explicit think close, e.g. Qwen) ALWAYS emit
2181
- # `text`/`thinking` on every :generation_chunk — even when empty — so the
2182
- # web client can rely on them and never fall back to raw `content`.
2183
- # * Non-splitting profiles (nil think close, e.g. Gemma) leave the event
2184
- # unchanged; the web client then falls back to raw `content`, preserving
2185
- # today's behavior (no regression).
2137
+ # KernelLoop splits a native stream with a ThoughtStreamSplitter per
2138
+ # generation (every profile, Gemma's thought channel included); the chat
2139
+ # loop gets reasoning apart from the answer. The web routes on those
2140
+ # fields (chunk_router.js).
2186
2141
  #
2187
2142
  # With a :generation_progress hook registered, a Hooks::StreamWatch gets
2188
2143
  # each chunk's thinking and text after the UIs had it.
2189
2144
  def build_stream_event_handler(on_event, cancel_controller: nil)
2190
- splitter = ThoughtStreamSplitter.for_profile(profile)
2191
- enrich = profile.thought_close ? :always : :never
2192
2145
  watch = stream_watch(cancel_controller)
2193
2146
  proc do |event|
2194
2147
  progress = nil
2195
2148
  case event[:type]
2196
2149
  when :generation_started
2197
- splitter = ThoughtStreamSplitter.for_profile(profile)
2198
2150
  watch&.started(event[:iteration])
2199
2151
  when :generation_chunk
2200
- # The chat loop already splits its stream (reasoning arrives apart
2201
- # from the answer); only raw native chunks are split here.
2202
- if event.key?(:text)
2203
- progress = { thinking: event[:thinking], text: event[:text] }
2204
- else
2205
- delta = splitter.feed(event[:content])
2206
- progress = { thinking: delta[:thinking], text: delta[:text] }
2207
- event = event.merge(text: delta[:text], thinking: delta[:thinking]) if enrich == :always
2208
- end
2152
+ # A chunk without the lanes (no loop of ours sends one) counts as text.
2153
+ text = event.key?(:text) ? event[:text] : event[:content]
2154
+ progress = { thinking: event[:thinking].to_s, text: text.to_s }
2209
2155
  end
2210
2156
  # The chat loop asked again without the thinking fields: a notice,
2211
2157
  # not an event of its own.
@@ -2300,11 +2246,10 @@ module Samagotchi
2300
2246
  ModelProfile.resolve(names: model_lookup_names(target), entry: target.entry, client: target.client, bare_model: target.bare_model)
2301
2247
  end
2302
2248
 
2303
- # The names a models: entry may be under: as typed (maybe an alias), the
2304
- # part after a host prefix, alias-resolved, bare.
2249
+ # The names a models: entry may be under (HostRegistry#lookup_names):
2250
+ # the model as typed at start or at the last /model, and what it became.
2305
2251
  def model_lookup_names(target)
2306
- typed = @model_lookup_names.first
2307
- (@model_lookup_names + [@host_registry.parse_qualified_model(typed).last, target.bare_model]).compact
2252
+ @host_registry.lookup_names(@typed_model_name, resolved: @effective_model_name, target: target)
2308
2253
  end
2309
2254
 
2310
2255
  # Everything that holds a profile follows the resolution: the kernel's
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The sets of event types more than one place reads (the Worker, the
5
+ # Bridge's observers, the TUI, the web), kept here once. The web's own
6
+ # copies (app.js's stream handlers, turn_events.js's snapshotEvents) are
7
+ # checked against these by spec/events_drift_spec.rb.
8
+ module Events
9
+ # A turn is over: it completed, was canceled, or failed.
10
+ TURN_END = %i[turn_completed turn_canceled turn_failed].freeze
11
+ # Turn ends that leave the turn in the conversation: a failed turn's
12
+ # prompt goes back to the composer instead.
13
+ TURN_KEPT = %i[turn_completed turn_canceled].freeze
14
+ # The kinds of a running turn's parts (Bridge::TurnAccumulator), which a
15
+ # joining UI replays as live events.
16
+ PART_KINDS = %w[thinking text tool input steer reminder notice].freeze
17
+ # Types no Engine emits that a UI handles: the Bridge's stream frames
18
+ # and the web's one synthetic replay event (a prompt merged into the
19
+ # turn, turn_events.js snapshotEvents).
20
+ STREAM_FRAMES = %i[snapshot reset stream_closed].freeze
21
+ SYNTHETIC = %i[merged_input].freeze
22
+ end
23
+ end
@@ -156,13 +156,11 @@ module Samagotchi
156
156
  def protected_paths
157
157
  @protected ||= begin
158
158
  require_relative "memory_bundle/provenance"
159
- config = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
160
- hooks_dir = config.is_a?(Hash) && config["hooks"].is_a?(Hash) ? config["hooks"]["hooks_dir"] : nil
161
159
  Guardrails::ProtectedPaths.new(
162
160
  store_dir: File.dirname(@approvals.path),
163
161
  bundles_dir: MemoryBundle::Provenance.bundles_dir,
164
162
  config_path: Samagotchi::ConfigFile.global_path,
165
- hooks_dir: Hooks::Loader.expand_path(hooks_dir || Hooks::Loader.default_hooks_dir)
163
+ hooks_dir: Hooks::Loader.hooks_dir
166
164
  )
167
165
  end
168
166
  end
@@ -209,8 +207,7 @@ module Samagotchi
209
207
  end
210
208
 
211
209
  def load_rules
212
- section = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
213
- section = section["guardrails"] if section.is_a?(Hash)
210
+ section = Samagotchi::ConfigFile.section("guardrails")
214
211
  rules = []
215
212
  disable = []
216
213
  begin
@@ -30,9 +30,9 @@ module Samagotchi
30
30
  # Any hook whose sha256 differs is not loaded (reinstall the bundle
31
31
  # after editing a hook by hand).
32
32
  #
33
- # The +on_error+ policy is applied per hook at fire time:
34
- # - "fail_closed" (on :before_tool_call): a raising guardrail sets
35
- # event[:blocked] = true — the tool call is prevented (fail-closed).
33
+ # The +on_error+ policy is applied per hook at fire time (Hooks.wrap):
34
+ # - "fail_closed" (on :before_tool_call): a raising guardrail denies
35
+ # the tool call (fail-closed).
36
36
  # - "log": warn to stderr and continue.
37
37
  # - "skip" (default): silently continue.
38
38
  module BundleLoader
@@ -80,16 +80,14 @@ module Samagotchi
80
80
 
81
81
  begin
82
82
  plugin = instantiate(bundle_name, basename, file, settings: settings)
83
- log_failure = Hooks.failure_log_gate(event_sym)
84
-
85
- registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: priority) do |event|
86
- begin
87
- plugin.call(event)
88
- rescue Exception => e
89
- # A veto always applies; only the log line is throttled.
90
- handle_error(bundle_name, basename, on_error, fail_closed, event, e) if fail_closed || log_failure.call
91
- end
92
- end
83
+ policy = if fail_closed then :deny
84
+ elsif on_error == "log" then :log
85
+ else :skip
86
+ end
87
+ label = "#{fail_closed ? "guardrail " : ""}#{basename} (bundle #{bundle_name})"
88
+ handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
89
+ fields: { bundle: bundle_name }) { |event| plugin.call(event) }
90
+ registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: priority, &handler)
93
91
  loaded += 1
94
92
  rescue Exception => e
95
93
  Log.error(:hooks, "bundle_hook_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' failed to load: #{e.class}: #{e.message}", bundle: bundle_name, hook: basename.to_s, error: e.class.name)
@@ -119,10 +117,9 @@ module Samagotchi
119
117
  ns = namespace_for(bundle_name)
120
118
  content = File.read(file)
121
119
  ns.module_eval(content, file, 1)
122
- class_name = File.basename(basename, ".rb").split("_").map(&:capitalize).join
123
- klass = ns.const_get(class_name, false)
120
+ klass = Hooks.class_for(basename, ns)
124
121
  instance = Hooks.build_plugin(klass, settings)
125
- raise ArgumentError, "plugin #{class_name} does not respond to #call" unless instance.respond_to?(:call)
122
+ raise ArgumentError, "plugin #{klass.name.split("::").last} does not respond to #call" unless instance.respond_to?(:call)
126
123
  instance
127
124
  end
128
125
 
@@ -141,20 +138,6 @@ module Samagotchi
141
138
  Samagotchi::Bundles.const_set(ns_name.to_sym, Module.new)
142
139
  end
143
140
  end
144
-
145
- # Apply the per-hook on_error policy when a plugin raises.
146
- def handle_error(bundle_name, basename, on_error, fail_closed, event, error)
147
- if fail_closed && event.is_a?(Hash)
148
- event[:blocked] = true
149
- reason = "guardrail #{basename} (bundle #{bundle_name}) failed: #{error.class}: #{error.message}"
150
- event[:guardrail]&.deny!(reason, rule: "guardrail-load", source: "core", decided_by: "core")
151
- existing = event[:block_reason].to_s
152
- event[:block_reason] = existing.empty? ? reason : "#{existing}; #{reason}"
153
- elsif on_error == "log"
154
- Log.warn(:hooks, "bundle_hook_failed", echo: "[samagotchi:hooks] #{basename} (bundle #{bundle_name}) failed: #{error.class}: #{error.message}", bundle: bundle_name, hook: basename.to_s, error: error.class.name)
155
- end
156
- # "skip" (and fail_closed on non-veto events) is silent.
157
- end
158
141
  end
159
142
  end
160
143
  end
@@ -41,6 +41,15 @@ module Samagotchi
41
41
  File.join(ConfigFile.config_dir(env: env), "hooks", "")
42
42
  end
43
43
 
44
+ # The hooks folder: config.yml's hooks.hooks_dir, or the default,
45
+ # "~" expanded.
46
+ # @param data [Hash, nil] the parsed config.yml (read when not given)
47
+ def hooks_dir(env = ENV, data: ConfigFile.read_yaml(path: ConfigFile.global_path(env: env)))
48
+ section = data.is_a?(Hash) ? data["hooks"] : nil
49
+ configured = section.is_a?(Hash) ? section["hooks_dir"] : nil
50
+ expand_path(configured || default_hooks_dir(env), env)
51
+ end
52
+
44
53
  # Load hooks from a config hash and return a Registry with registered plugins.
45
54
  #
46
55
  # @param config_hash [Hash, nil] the hooks section from config.yml
@@ -52,7 +61,7 @@ module Samagotchi
52
61
  return Hooks::Registry.new unless config_hash&.key?("hooks")
53
62
 
54
63
  hooks_config = config_hash["hooks"]
55
- hooks_dir = expand_path(hooks_config["hooks_dir"] || default_hooks_dir(env), env)
64
+ hooks_dir = hooks_dir(env, data: config_hash)
56
65
 
57
66
  registry = Hooks::Registry.new
58
67
  definitions = parse_definitions(hooks_config)
@@ -60,20 +69,10 @@ module Samagotchi
60
69
  definitions.each do |defn|
61
70
  begin
62
71
  plugin = load_plugin(hooks_dir, defn[:path], settings: defn[:settings])
63
- log_failure = Hooks.failure_log_gate(defn[:event_type])
64
72
  # Persistent: config hooks must fire on every turn, not be wiped
65
73
  # by Engine#run_turn's per-turn clear_hooks after turn 1.
66
- registry.register_persistent(defn[:event_type].to_sym, label: "#{defn[:path]} (config)") do |event|
67
- begin
68
- plugin.call(event)
69
- rescue StandardError => e
70
- if defn[:required] && defn[:event_type] == "before_tool_call"
71
- deny_for_raise(event, defn[:path], e)
72
- else
73
- handle_error(defn[:on_error] || "skip", defn[:path], e) if log_failure.call
74
- end
75
- end
76
- end
74
+ registry.register_persistent(defn[:event_type].to_sym, label: "#{defn[:path]} (config)",
75
+ &wrapped(plugin, defn))
77
76
  rescue ScriptError, StandardError => e
78
77
  # ScriptError: a SyntaxError (or LoadError) from `require`.
79
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)
@@ -130,33 +129,24 @@ module Samagotchi
130
129
 
131
130
  @plugin_cache[[full_path, settings]] ||= begin
132
131
  require full_path
133
- class_name = File.basename(path, ".rb").split("_").map(&:capitalize).join
134
- klass = Object.const_get(class_name)
132
+ klass = Hooks.class_for(path)
135
133
  instance = Hooks.build_plugin(klass, settings)
136
134
  # Validate that the instance responds to #call
137
- raise ArgumentError, "Plugin #{class_name} does not respond to #call" unless instance.respond_to?(:call)
135
+ raise ArgumentError, "Plugin #{klass.name} does not respond to #call" unless instance.respond_to?(:call)
138
136
  instance
139
137
  end
140
138
  end
141
139
 
142
- # A required before_tool_call hook that raises denies the call.
143
- def self.deny_for_raise(event, hook_path, error)
144
- return unless event.is_a?(Hash)
145
-
146
- reason = "required hook #{hook_path} raised #{error.class}: #{error.message}"
147
- event[:guardrail]&.deny!(reason, rule: "guardrail-load", source: "core", decided_by: "core")
148
- event[:blocked] = true
149
- event[:block_reason] ||= reason
150
- end
151
-
152
- # Handle a hook error based on the on_error config.
153
- def self.handle_error(on_error, hook_path, error = nil)
154
- case on_error
155
- when "log"
156
- 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)
157
- when "skip"
158
- # Silent — just skip
140
+ # The plugin's #call under the entry's error policy: a required
141
+ # before_tool_call hook that raises denies the call; otherwise
142
+ # on_error: log warns and skip is silent.
143
+ def self.wrapped(plugin, defn)
144
+ if defn[:required] && defn[:event_type] == "before_tool_call"
145
+ policy, label = :deny, "required hook #{defn[:path]}"
146
+ else
147
+ policy, label = (defn[:on_error] == "log" ? :log : :skip), "#{defn[:path]} (config)"
159
148
  end
149
+ Hooks.wrap(label: label, event: defn[:event_type].to_sym, policy: policy) { |event| plugin.call(event) }
160
150
  end
161
151
  end
162
152
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "monitor"
4
+ require_relative "../log"
4
5
 
5
6
  module Samagotchi
6
7
  module Hooks
@@ -36,6 +37,46 @@ module Samagotchi
36
37
  end
37
38
  end
38
39
 
40
+ # A hook file's class: the PascalCase of its basename (my_hook.rb →
41
+ # MyHook), looked up in +namespace+ (a bundle's module; Object for a
42
+ # config hook).
43
+ # @return [Class]
44
+ def self.class_for(file, namespace = Object)
45
+ namespace.const_get(File.basename(file.to_s, ".rb").split("_").map(&:capitalize).join, false)
46
+ end
47
+
48
+ # A hook's block wrapped in its error policy: what a loader registers.
49
+ # When the block raises,
50
+ # :deny denies the call (event[:guardrail], the Gate's Verdict),
51
+ # whatever was raised: a fail-closed before_tool_call guardrail.
52
+ # The Gate folds the verdict into event[:blocked] and
53
+ # event[:block_reason] for the hooks after it.
54
+ # :log warns (a stream hook at most once a minute, failure_log_gate)
55
+ # :skip is silent
56
+ # :log and :skip rescue StandardError and ScriptError.
57
+ # @param label [String] the hook, for the deny reason and the warning
58
+ # @param event [Symbol] the hook's event
59
+ # @param log [Array(Symbol, String)] the warning's tag and record name
60
+ # @param echo [Boolean] the warning also goes to stderr
61
+ # @param fields [Hash] more fields for the warning's record
62
+ def self.wrap(label:, event:, policy:, log: [:hooks, "hook_failed"], echo: true, fields: {}, &block)
63
+ log_failure = failure_log_gate(event)
64
+ lambda do |payload|
65
+ block.call(payload)
66
+ rescue Exception => e # rubocop:disable Lint/RescueException -- :deny fails closed on anything
67
+ raise unless policy == :deny || e.is_a?(StandardError) || e.is_a?(ScriptError)
68
+
69
+ failed = "#{e.class}: #{e.message}"
70
+ if policy == :deny
71
+ payload[:guardrail]&.deny!("#{label} raised #{failed}", rule: "guardrail-load", source: "core", decided_by: "core") if payload.is_a?(Hash)
72
+ elsif policy == :log && log_failure.call
73
+ text = "#{label} failed: #{failed}"
74
+ Log.warn(log[0], log[1], echo: echo ? "[samagotchi:hooks] #{text}" : nil, hook: label, event: event.to_s,
75
+ error: e.class.name, msg: text, **fields)
76
+ end
77
+ end
78
+ end
79
+
39
80
  # What a hook can do beyond reading its event: the Engine's
40
81
  # callables, each given the hook's label. +notify+ takes
41
82
  # (text:, level:, hook:) and shows one line to the user; +ask_user+
@@ -26,8 +26,5 @@ module Samagotchi
26
26
  # event also has context: and targets: (docs/guardrails.md)
27
27
  # :after_tool_call — after tool execution, before result injection
28
28
  module Hooks
29
- REGISTRY_CLASS = Registry
30
- LOADER_CLASS = Loader
31
- BUNDLE_LOADER_CLASS = BundleLoader
32
29
  end
33
30
  end
@@ -77,6 +77,7 @@ module Samagotchi
77
77
  # @param clock [#call, nil] monotonic seconds (specs)
78
78
  def initialize(hosts_config: nil, env: ENV, client_override: nil, clock: nil)
79
79
  @client_override = client_override
80
+ @env = env
80
81
  @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
81
82
  @adapters = {}
82
83
  @host_lists = {}
@@ -133,6 +134,20 @@ module Samagotchi
133
134
  ConfigFile.parse_host_qualified_model(raw, hosts: @entries)
134
135
  end
135
136
 
137
+ # The names a models: entry may be under, in the order they are looked
138
+ # up: +typed+ as given (maybe an alias), its alias's target, +resolved+
139
+ # (what the model became, e.g. the default for a blank one), the part
140
+ # after a host prefix, and the bare model the host is asked for.
141
+ # Thinking, SamplingSettings, VisionSupport and ModelProfile all take these.
142
+ # @param target [ModelTarget, nil] the resolved model's target (resolved when not given)
143
+ # @return [Array<String>]
144
+ def lookup_names(typed, resolved: nil, target: nil)
145
+ aliased = ConfigFile.resolve_model_alias(typed, env: @env)
146
+ target ||= resolve(resolved || aliased)
147
+ [typed, aliased, resolved, parse_qualified_model(typed).last, target.bare_model]
148
+ .map { |name| name.to_s.strip }.reject(&:empty?).uniq
149
+ end
150
+
136
151
  # Resolve model string (already alias-resolved, may be qualified) to a HostEntry.
137
152
  # If qualified explicitly, return that host. If unqualified, try cached model index,
138
153
  # else fallback to default host.
@@ -36,13 +36,6 @@ module Samagotchi
36
36
  @due_reminder_name = nil
37
37
  end
38
38
 
39
- # Get the currently pending due reminder name (for Engine to read).
40
- # Kept for backward compatibility.
41
- # Called by Engine#collect_due_reminders. Thread-safe.
42
- def due_reminder_name
43
- @mutex.synchronize { @due_reminder_name }
44
- end
45
-
46
39
  # Get all due reminders (from ReminderStore). Called by Engine#collect_due_reminders.
47
40
  # Thread-safe. Returns all due reminders, not just one.
48
41
  # @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
@@ -197,6 +197,8 @@ module Samagotchi
197
197
  REF_RE = %r{\Aimages/[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z}
198
198
  MIME = { png: "image/png", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" }.freeze
199
199
  EXT = { png: "png", jpeg: "jpg", gif: "gif", webp: "webp" }.freeze
200
+ # The most images one turn may carry (a client's refs, check_refs).
201
+ MAX_TURN_REFS = 20
200
202
  # Nothing larger is even read.
201
203
  MAX_SOURCE_BYTES = 50 * 1024 * 1024
202
204
 
@@ -251,6 +253,26 @@ module Samagotchi
251
253
  File.file?(path) && !File.symlink?(path)
252
254
  end
253
255
 
256
+ # A turn's images from a client (the web, a Bridge request) as
257
+ # [{file:, name:}], or a String saying what's wrong. Only refs to files
258
+ # already in the session's images/ pass (an upload): never a path, so no
259
+ # client can make the worker read a file.
260
+ def self.check_refs(session_dir, raw)
261
+ return [] if raw.nil?
262
+ return "images must be a list" unless raw.is_a?(Array)
263
+ return "at most #{MAX_TURN_REFS} images" if raw.size > MAX_TURN_REFS
264
+
265
+ raw.map do |image|
266
+ return "each image must be {file:, name:}" unless image.is_a?(Hash)
267
+
268
+ ref = symbolize(image)
269
+ return "images are refs to uploaded files, not paths" if ref.key?(:path)
270
+ return "unknown image #{ref[:file].to_s[0, 80]}" unless valid_ref?(session_dir, ref)
271
+
272
+ { file: ref[:file].to_s, name: File.basename(ref[:name].to_s)[0, 120] }
273
+ end
274
+ end
275
+
254
276
  # Messages seeded into another session (a plugin's ctx.sessions.fork):
255
277
  # each image ref's file is copied from +from+ into +to+ (both session
256
278
  # dirs; no +from+ copies none). A ref whose file isn't there is dropped, and its message says