samagotchi 0.8.1 → 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 +89 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +9 -2
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/memory.md +5 -3
- data/docs/plugins.md +47 -17
- 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 +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +65 -17
- 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/identity.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
- 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/guardrails/targets.rb +7 -1
- 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/log_subscriber.rb +5 -1
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +25 -30
- 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 +29 -15
- data/lib/samagotchi/session_metrics.rb +2 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +2 -1
- 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/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +8 -0
- 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 +2 -13
- 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 +13 -1
|
@@ -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
|
|
@@ -141,8 +141,8 @@ module Samagotchi
|
|
|
141
141
|
# Run the conversation loop and return the final model response plus
|
|
142
142
|
# resumable conversation state when execution stops at max_iterations.
|
|
143
143
|
#
|
|
144
|
-
# @param messages [Array<Hash
|
|
145
|
-
#
|
|
144
|
+
# @param messages [Array<Hash>] conversation so far ({role:, content:});
|
|
145
|
+
# a stopped run resumes from its Result#conversation
|
|
146
146
|
# @param max_iterations [Integer] safety cap on tool-call rounds
|
|
147
147
|
# @param on_stream_event [Proc, nil] optional callback for generation events
|
|
148
148
|
# @param cancel_controller [CancellationController, nil] optional cancellation source
|
|
@@ -170,7 +170,6 @@ module Samagotchi
|
|
|
170
170
|
empty_retries = 0
|
|
171
171
|
empty_retry_limit = EmptyAnswerRetry.limit
|
|
172
172
|
@retry_generation = false
|
|
173
|
-
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
174
173
|
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
175
174
|
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
176
175
|
# prompt starts with what the server already has cached.
|
|
@@ -192,6 +191,9 @@ module Samagotchi
|
|
|
192
191
|
conversation << line
|
|
193
192
|
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
|
|
194
193
|
end
|
|
194
|
+
# Each generation splits its own stream: one that ended inside a
|
|
195
|
+
# thought or a tool call doesn't leave the next one "inside" it.
|
|
196
|
+
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
195
197
|
emit_stream_event(
|
|
196
198
|
on_stream_event,
|
|
197
199
|
type: :generation_started,
|
|
@@ -235,6 +237,7 @@ module Samagotchi
|
|
|
235
237
|
type: :generation_chunk,
|
|
236
238
|
iteration: iteration_index + 1,
|
|
237
239
|
content: chunk[:content],
|
|
240
|
+
text: split[:text],
|
|
238
241
|
thinking: split[:thinking],
|
|
239
242
|
payload: chunk[:payload]
|
|
240
243
|
)
|
|
@@ -263,9 +266,8 @@ module Samagotchi
|
|
|
263
266
|
""
|
|
264
267
|
end
|
|
265
268
|
if cut
|
|
266
|
-
# The cut stream
|
|
267
|
-
#
|
|
268
|
-
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
269
|
+
# The cut stream's visible text goes with it: the buffer as it was
|
|
270
|
+
# before (the next generation gets a fresh splitter).
|
|
269
271
|
partial_assistant_buffer.slice!(buffer_mark..)
|
|
270
272
|
emit_stream_event(on_stream_event, type: :generation_completed, iteration: iteration_index + 1,
|
|
271
273
|
content_length: 0, thinking_chars: streamed_thinking,
|
|
@@ -660,8 +662,8 @@ module Samagotchi
|
|
|
660
662
|
private
|
|
661
663
|
|
|
662
664
|
# The generation's thinking: what the stream split into the thinking
|
|
663
|
-
# lane
|
|
664
|
-
#
|
|
665
|
+
# lane, else what the profile's thought blocks hold (an empty thought, a
|
|
666
|
+
# bare Gemma <|think|> cue, a response that wasn't streamed).
|
|
665
667
|
def thinking_chars(response, streamed)
|
|
666
668
|
return streamed if streamed.positive?
|
|
667
669
|
|
|
@@ -687,14 +689,8 @@ module Samagotchi
|
|
|
687
689
|
end
|
|
688
690
|
end
|
|
689
691
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
duplicate_conversation(messages.conversation)
|
|
693
|
-
else
|
|
694
|
-
# Standard multi-turn compliance: never pass prior raw thought blocks.
|
|
695
|
-
sanitize_history(messages)
|
|
696
|
-
end
|
|
697
|
-
end
|
|
692
|
+
# Standard multi-turn compliance: never pass prior raw thought blocks.
|
|
693
|
+
def prepare_conversation(messages) = sanitize_history(messages)
|
|
698
694
|
|
|
699
695
|
def duplicate_conversation(messages)
|
|
700
696
|
messages.map(&:dup)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "socket"
|
|
4
3
|
require_relative "session"
|
|
5
4
|
require_relative "version"
|
|
6
5
|
require_relative "worker_sidecar"
|
|
@@ -12,7 +11,6 @@ module Samagotchi
|
|
|
12
11
|
# older one names none) and a `chi web` on its port (/api/info). Read-only:
|
|
13
12
|
# a dead worker's sidecar is left for the next client to clean up.
|
|
14
13
|
module LiveVersions
|
|
15
|
-
PROBE_TIMEOUT = 0.2
|
|
16
14
|
WEB_TIMEOUT = 0.5
|
|
17
15
|
|
|
18
16
|
# version is nil for a sidecar written before sidecars carried one.
|
|
@@ -24,8 +22,8 @@ module Samagotchi
|
|
|
24
22
|
def workers(state_dir: Session.default_state_dir)
|
|
25
23
|
Dir[File.join(state_dir, "*", WorkerSidecar::FILE)].sort.filter_map do |path|
|
|
26
24
|
session_dir = File.dirname(path)
|
|
27
|
-
sidecar = WorkerSidecar.
|
|
28
|
-
next unless sidecar
|
|
25
|
+
sidecar = WorkerSidecar.live(session_dir, unlink: false)
|
|
26
|
+
next unless sidecar
|
|
29
27
|
|
|
30
28
|
Worker.new(session_id: File.basename(session_dir), version: sidecar.version)
|
|
31
29
|
end
|
|
@@ -48,14 +46,5 @@ module Samagotchi
|
|
|
48
46
|
info = Web::InfoProbe.call(host, port, timeout: timeout)
|
|
49
47
|
info.is_a?(Hash) ? info : nil
|
|
50
48
|
end
|
|
51
|
-
|
|
52
|
-
def listening?(port, host: "127.0.0.1")
|
|
53
|
-
return false unless port.positive?
|
|
54
|
-
|
|
55
|
-
Socket.tcp(host, port, connect_timeout: PROBE_TIMEOUT).close
|
|
56
|
-
true
|
|
57
|
-
rescue StandardError
|
|
58
|
-
false
|
|
59
|
-
end
|
|
60
49
|
end
|
|
61
50
|
end
|