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
data/lib/samagotchi/config.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "fileutils"
|
|
|
6
6
|
require "uri"
|
|
7
7
|
require "set"
|
|
8
8
|
require_relative "atomic_file"
|
|
9
|
+
require_relative "config_text_edit"
|
|
9
10
|
require_relative "log"
|
|
10
11
|
require_relative "paths"
|
|
11
12
|
|
|
@@ -893,33 +894,39 @@ module Samagotchi
|
|
|
893
894
|
nil
|
|
894
895
|
end
|
|
895
896
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
897
|
+
# A top-level section of config.yml as written (nil when absent or the
|
|
898
|
+
# file doesn't parse); a reader checks its shape.
|
|
899
|
+
def section(name, env: ENV, path: global_path(env: env))
|
|
900
|
+
data = read_yaml(path: path)
|
|
901
|
+
data.is_a?(Hash) ? data[name] : nil
|
|
902
|
+
end
|
|
899
903
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
end
|
|
904
|
+
# config.yml `bundles:`: each bundle's settings by name, for its hooks.
|
|
905
|
+
# @return [Hash{String => Hash}] {} when absent; a section that isn't a
|
|
906
|
+
# mapping warns and counts as absent
|
|
907
|
+
def bundle_settings(env: ENV, path: global_path(env: env))
|
|
908
|
+
raw = section("bundles", env: env, path: path)
|
|
909
|
+
return {} if raw.nil?
|
|
910
|
+
unless raw.is_a?(Hash)
|
|
911
|
+
Log.warn(:hooks, "bundles_section_invalid", echo: "[samagotchi:hooks] config.yml bundles: must be a mapping of bundle name to settings; ignored")
|
|
912
|
+
return {}
|
|
910
913
|
end
|
|
911
914
|
|
|
912
|
-
|
|
915
|
+
raw.each_with_object({}) { |(name, value), acc| acc[name.to_s] = value.is_a?(Hash) ? value : {} }
|
|
916
|
+
rescue StandardError
|
|
917
|
+
{}
|
|
918
|
+
end
|
|
913
919
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
920
|
+
# Sets default.model in config.yml; the rest of the file, comments
|
|
921
|
+
# included, stays as written (ConfigTextEdit).
|
|
922
|
+
def write_default_model!(model_name, env: ENV, path: global_path(env: env))
|
|
923
|
+
resolved = model_name.to_s.strip
|
|
924
|
+
raise ArgumentError, "model name is required" if resolved.empty?
|
|
925
|
+
|
|
926
|
+
expected = deep_copy(read_yaml(env: env, path: path) || {})
|
|
927
|
+
expected["default"] = {} unless expected["default"].is_a?(Hash)
|
|
928
|
+
expected["default"]["model"] = resolved
|
|
929
|
+
write_config_key(path, "default", "model", resolved, expected)
|
|
923
930
|
Samagotchi::Config.reload!(cli_overrides: Samagotchi::Config.cli_overrides)
|
|
924
931
|
true
|
|
925
932
|
end
|
|
@@ -1003,50 +1010,58 @@ module Samagotchi
|
|
|
1003
1010
|
aliases.fetch(value.downcase, value)
|
|
1004
1011
|
end
|
|
1005
1012
|
|
|
1013
|
+
# Words /model takes as arguments (clear/default/none/off reset the
|
|
1014
|
+
# runtime model), so no alias may be named after one.
|
|
1006
1015
|
RESERVED_MODEL_ALIASES = %w[clear default none off].freeze
|
|
1007
1016
|
|
|
1008
|
-
|
|
1017
|
+
# What is wrong with +alias_name+ as an alias for +target+, or nil.
|
|
1018
|
+
def model_alias_error(alias_name, target)
|
|
1009
1019
|
alias_key = alias_name.to_s.strip
|
|
1010
|
-
|
|
1011
|
-
raise ArgumentError, "model name is required" if model_name.to_s.strip.empty?
|
|
1020
|
+
return "alias name is required" if alias_key.empty?
|
|
1012
1021
|
|
|
1013
1022
|
lowered_key = alias_key.downcase
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
unless alias_key.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
|
|
1019
|
-
|
|
1020
|
-
end
|
|
1023
|
+
return "alias name '#{alias_key}' is reserved" if RESERVED_MODEL_ALIASES.include?(lowered_key)
|
|
1024
|
+
return "alias name must not contain whitespace" if alias_key.match?(/\s/)
|
|
1025
|
+
return "alias name must not start with '-'" if alias_key.start_with?("-")
|
|
1026
|
+
return "alias name must not contain '/'" if alias_key.include?("/")
|
|
1027
|
+
return "alias name must match /[a-z0-9][a-z0-9._-]*/i (got '#{alias_key}')" unless alias_key.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
|
|
1028
|
+
return "alias must not point to itself" if lowered_key == target.to_s.strip.downcase
|
|
1021
1029
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
raw_data = read_yaml(env: env, path: path) || {}
|
|
1030
|
+
nil
|
|
1031
|
+
end
|
|
1026
1032
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
end
|
|
1033
|
+
def write_model_alias!(alias_name, model_name, env: ENV, path: global_path(env: env))
|
|
1034
|
+
error = model_alias_error(alias_name, model_name)
|
|
1035
|
+
raise ArgumentError, error if error
|
|
1036
|
+
raise ArgumentError, "model name is required" if model_name.to_s.strip.empty?
|
|
1032
1037
|
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
aliases_hash.each do |k, v|
|
|
1036
|
-
nk = k.to_s.strip.downcase
|
|
1037
|
-
next if nk.empty?
|
|
1038
|
-
normalized[nk] = v.to_s.strip unless v.to_s.strip.empty?
|
|
1039
|
-
end
|
|
1040
|
-
raw_data[MODEL_ALIASES_KEY] = normalized
|
|
1038
|
+
lowered_key = alias_name.to_s.strip.downcase
|
|
1039
|
+
resolved_model = model_name.to_s.strip
|
|
1041
1040
|
|
|
1042
|
-
previous =
|
|
1043
|
-
|
|
1041
|
+
previous = model_aliases(env: env, path: path)[lowered_key]
|
|
1042
|
+
expected = deep_copy(read_yaml(env: env, path: path) || {})
|
|
1043
|
+
aliases = expected[MODEL_ALIASES_KEY].is_a?(Hash) ? expected[MODEL_ALIASES_KEY] : {}
|
|
1044
|
+
aliases.delete_if { |k, _| k.to_s.strip.downcase == lowered_key }
|
|
1045
|
+
aliases[lowered_key] = resolved_model
|
|
1046
|
+
expected[MODEL_ALIASES_KEY] = aliases
|
|
1047
|
+
write_config_key(path, MODEL_ALIASES_KEY, lowered_key, resolved_model, expected)
|
|
1048
|
+
previous
|
|
1049
|
+
end
|
|
1044
1050
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1051
|
+
# Write +section+.+key+ = +value+ into config.yml (+expected+: the whole
|
|
1052
|
+
# file's data after it), through a symlink, keeping the file's mode.
|
|
1053
|
+
def write_config_key(path, section, key, value, expected)
|
|
1054
|
+
target = File.exist?(path) ? File.realpath(path) : path
|
|
1055
|
+
text = File.file?(target) ? File.read(target, encoding: "UTF-8") : nil
|
|
1056
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
1057
|
+
perm = File.stat(target).mode & 0o7777 if text
|
|
1058
|
+
AtomicFile.write(target, ConfigTextEdit.set(text, section: section, key: key, value: value, expected: expected), perm: perm)
|
|
1047
1059
|
clear_yaml_cache!(path)
|
|
1048
|
-
previous
|
|
1049
1060
|
end
|
|
1061
|
+
private_class_method :write_config_key
|
|
1062
|
+
|
|
1063
|
+
def deep_copy(data) = Marshal.load(Marshal.dump(data))
|
|
1064
|
+
private_class_method :deep_copy
|
|
1050
1065
|
|
|
1051
1066
|
def nonempty_str(value)
|
|
1052
1067
|
str = value.to_s.strip
|
|
@@ -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
|
|
@@ -4,6 +4,7 @@ require_relative "../tools/tool_path"
|
|
|
4
4
|
require_relative "../tools/memory"
|
|
5
5
|
require_relative "../log"
|
|
6
6
|
require_relative "model_size"
|
|
7
|
+
require_relative "../memory_bundle/index_sync"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
module Guardrails
|
|
@@ -123,9 +124,14 @@ module Samagotchi
|
|
|
123
124
|
end
|
|
124
125
|
|
|
125
126
|
# Whether any path is outside the repo root (the cwd outside a repo).
|
|
127
|
+
# A memory's file doesn't count (MemoryBundle::IndexSync.memory_scope):
|
|
128
|
+
# write/edit there is what memory_write does, unasked; index.md, the
|
|
129
|
+
# bundles dir and the rest of chi's config dir still count.
|
|
126
130
|
def outside_repo?
|
|
127
131
|
root = @repo_root || @cwd
|
|
128
|
-
@paths.any?
|
|
132
|
+
@paths.any? do |p|
|
|
133
|
+
p != root && !p.start_with?(File.join(root, "")) && !MemoryBundle::IndexSync.memory_scope(p)
|
|
134
|
+
end
|
|
129
135
|
end
|
|
130
136
|
|
|
131
137
|
# The hook event's targets: hash.
|