samagotchi 0.9.0 → 0.10.1
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 +68 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +5 -3
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/plugins.md +36 -13
- 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 +13 -4
- 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 +18 -9
- 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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "psych"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Sets one section.key in config.yml text, line by line, so the user's
|
|
9
|
+
# comments and layout stay (YAML.dump would drop them), the way
|
|
10
|
+
# Bootstrap::ConfigWriter inserts a host. A key already in the block is
|
|
11
|
+
# replaced on its line (a comment after the old value goes with it), a new
|
|
12
|
+
# one goes at the end of the block, a missing section at the end of the
|
|
13
|
+
# file. The result is parsed again and must equal +expected+; when it
|
|
14
|
+
# doesn't, or the text is something a line edit can't handle (a flow-style
|
|
15
|
+
# section, anchors, a duplicate section), the whole file is dumped from
|
|
16
|
+
# +expected+ instead.
|
|
17
|
+
module ConfigTextEdit
|
|
18
|
+
DEFAULT_INDENT = 2
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param text [String, nil] the file's text (nil: no file)
|
|
23
|
+
# @param section [String] a top-level key ("default")
|
|
24
|
+
# @param key [String] the key under it; an existing key matches in any case
|
|
25
|
+
# @param value [String] the value, written as a quoted string
|
|
26
|
+
# @param expected [Hash] the whole file's data after the change
|
|
27
|
+
# @return [String] the new text
|
|
28
|
+
def set(text, section:, key:, value:, expected:)
|
|
29
|
+
edited = text && edit(text, section, key, value)
|
|
30
|
+
edited && parse(edited) == expected ? edited : YAML.dump(expected)
|
|
31
|
+
rescue Psych::Exception
|
|
32
|
+
YAML.dump(expected)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def edit(text, section, key, value)
|
|
36
|
+
return nil if anchors?(text)
|
|
37
|
+
|
|
38
|
+
# A key the lines below can't see (flow style, odd quoting) would end up
|
|
39
|
+
# written twice, and YAML keeps the last one without a word.
|
|
40
|
+
original = parse(text)
|
|
41
|
+
original = {} unless original.is_a?(Hash)
|
|
42
|
+
section_data = original[section]
|
|
43
|
+
has_key = section_data.is_a?(Hash) && section_data.keys.any? { |k| k.to_s.downcase == key.downcase }
|
|
44
|
+
|
|
45
|
+
eol = text.include?("\r\n") ? "\r\n" : "\n"
|
|
46
|
+
lines = text.split(/(?<=\n)/)
|
|
47
|
+
lines[-1] = "#{lines[-1]}#{eol}" if lines.any? && !lines[-1].end_with?("\n")
|
|
48
|
+
header = /\A#{Regexp.escape(section)}:[ \t]*(#.*)?\z/
|
|
49
|
+
starts = lines.each_index.select { |i| lines[i].chomp.match?(header) }
|
|
50
|
+
return nil if starts.length > 1
|
|
51
|
+
|
|
52
|
+
return nil if starts.empty? && original.key?(section)
|
|
53
|
+
|
|
54
|
+
if starts.empty?
|
|
55
|
+
lines.concat(["#{section}:#{eol}", "#{" " * DEFAULT_INDENT}#{key}: #{scalar(value)}#{eol}"])
|
|
56
|
+
return lines.join
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
at = starts.first
|
|
60
|
+
stop = at + 1
|
|
61
|
+
stop += 1 while stop < lines.length && inside_block?(lines[stop])
|
|
62
|
+
children = lines[(at + 1)...stop].reject { |line| line.strip.empty? || comment?(line) }
|
|
63
|
+
child = children.first ? indent(children.first) : DEFAULT_INDENT
|
|
64
|
+
new_line = "#{" " * child}#{key}: #{scalar(value)}#{eol}"
|
|
65
|
+
found = ((at + 1)...stop).find { |i| indent(lines[i]) == child && key_of(lines[i])&.downcase == key.downcase }
|
|
66
|
+
if found
|
|
67
|
+
lines[found] = new_line
|
|
68
|
+
else
|
|
69
|
+
return nil if has_key
|
|
70
|
+
|
|
71
|
+
stop -= 1 while stop > at + 1 && (lines[stop - 1].strip.empty? || (comment?(lines[stop - 1]) && indent(lines[stop - 1]) < child))
|
|
72
|
+
lines.insert(stop, new_line)
|
|
73
|
+
end
|
|
74
|
+
lines.join
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def key_of(line)
|
|
78
|
+
line[/\A\s*(["']?)([^"':#\s][^"':#]*?)\1:(?:\s|\z)/, 2]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def inside_block?(line)
|
|
82
|
+
return true if line.strip.empty?
|
|
83
|
+
return false if line.start_with?("---", "...")
|
|
84
|
+
|
|
85
|
+
line.start_with?(" ", "\t") || comment?(line)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def comment?(line) = line.lstrip.start_with?("#")
|
|
89
|
+
def indent(line) = line[/\A */].length
|
|
90
|
+
|
|
91
|
+
# JSON's quoting is valid YAML: model ids hold "/" and ":".
|
|
92
|
+
def scalar(value) = JSON.generate(value.to_s)
|
|
93
|
+
|
|
94
|
+
def parse(text) = YAML.safe_load(text, permitted_classes: [], aliases: false)
|
|
95
|
+
|
|
96
|
+
def anchors?(text)
|
|
97
|
+
stack = [Psych.parse_stream(text)]
|
|
98
|
+
until stack.empty?
|
|
99
|
+
node = stack.pop
|
|
100
|
+
return true if node.is_a?(Psych::Nodes::Alias) || (node.respond_to?(:anchor) && node.anchor)
|
|
101
|
+
|
|
102
|
+
stack.concat(Array(node.children)) if node.respond_to?(:children)
|
|
103
|
+
end
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -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
|