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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +3 -2
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/plugins.md +34 -12
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +1 -27
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +27 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +1 -11
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- 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? ||
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
37
|
-
|
|
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
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
-
@
|
|
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
|
|
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,
|
|
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
|
|
2112
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
2172
|
-
#
|
|
2173
|
-
#
|
|
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
|
-
#
|
|
2177
|
-
#
|
|
2178
|
-
#
|
|
2179
|
-
#
|
|
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
|
-
#
|
|
2201
|
-
|
|
2202
|
-
|
|
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
|
|
2304
|
-
#
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
35
|
-
#
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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 #{
|
|
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 =
|
|
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)"
|
|
67
|
-
|
|
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
|
-
|
|
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 #{
|
|
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
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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+
|
data/lib/samagotchi/hooks.rb
CHANGED
|
@@ -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
|