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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +9 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/memory.md +5 -3
  9. data/docs/plugins.md +47 -17
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/bootstrap_command.rb +29 -29
  12. data/lib/samagotchi/bridge/card_store.rb +4 -4
  13. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  14. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  16. data/lib/samagotchi/bridge.rb +59 -112
  17. data/lib/samagotchi/bridge_client.rb +11 -28
  18. data/lib/samagotchi/bundle_command.rb +158 -212
  19. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  21. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  22. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  25. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  26. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  27. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/skills/plugin.rb +65 -17
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  32. data/lib/samagotchi/bundles/system/identity.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  34. data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
  35. data/lib/samagotchi/cli/command.rb +51 -0
  36. data/lib/samagotchi/cli/flags.rb +116 -0
  37. data/lib/samagotchi/config.rb +70 -55
  38. data/lib/samagotchi/config_text_edit.rb +107 -0
  39. data/lib/samagotchi/desktop_command.rb +24 -18
  40. data/lib/samagotchi/edit_preview.rb +2 -2
  41. data/lib/samagotchi/engine.rb +20 -75
  42. data/lib/samagotchi/events.rb +23 -0
  43. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  44. data/lib/samagotchi/guardrails/targets.rb +7 -1
  45. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  46. data/lib/samagotchi/hooks/loader.rb +23 -33
  47. data/lib/samagotchi/hooks/registry.rb +41 -0
  48. data/lib/samagotchi/hooks.rb +0 -3
  49. data/lib/samagotchi/host_registry.rb +15 -0
  50. data/lib/samagotchi/idle_reminders.rb +0 -7
  51. data/lib/samagotchi/image_store.rb +22 -0
  52. data/lib/samagotchi/kernel_loop.rb +12 -16
  53. data/lib/samagotchi/live_versions.rb +2 -13
  54. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  55. data/lib/samagotchi/log_subscriber.rb +5 -1
  56. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  57. data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  59. data/lib/samagotchi/memory_bundle/installer.rb +25 -30
  60. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  61. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  63. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  64. data/lib/samagotchi/memory_paths.rb +10 -0
  65. data/lib/samagotchi/note_command.rb +23 -37
  66. data/lib/samagotchi/owner_lock.rb +14 -3
  67. data/lib/samagotchi/plugin/api.rb +11 -17
  68. data/lib/samagotchi/plugin/context.rb +70 -15
  69. data/lib/samagotchi/plugin/loader.rb +2 -3
  70. data/lib/samagotchi/sampling_settings.rb +3 -5
  71. data/lib/samagotchi/self_report.rb +6 -9
  72. data/lib/samagotchi/send_command.rb +33 -52
  73. data/lib/samagotchi/session.rb +88 -92
  74. data/lib/samagotchi/session_archive_command.rb +8 -14
  75. data/lib/samagotchi/session_commands.rb +3 -21
  76. data/lib/samagotchi/session_delete_command.rb +15 -29
  77. data/lib/samagotchi/session_manager.rb +29 -15
  78. data/lib/samagotchi/session_metrics.rb +2 -1
  79. data/lib/samagotchi/sessions_command.rb +3 -2
  80. data/lib/samagotchi/system_prompt.rb +2 -1
  81. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  83. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  85. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  86. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  87. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  88. data/lib/samagotchi/terminal_ui.rb +156 -234
  89. data/lib/samagotchi/thinking.rb +2 -3
  90. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  91. data/lib/samagotchi/tool_call_parser.rb +56 -292
  92. data/lib/samagotchi/tool_declarations.rb +1 -1
  93. data/lib/samagotchi/tool_runner.rb +8 -0
  94. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  95. data/lib/samagotchi/tools/builtins.rb +2 -1
  96. data/lib/samagotchi/tools/edit.rb +21 -29
  97. data/lib/samagotchi/tools/memory.rb +2 -13
  98. data/lib/samagotchi/tools/send_note.rb +1 -1
  99. data/lib/samagotchi/update_command.rb +32 -22
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/vision_support.rb +4 -5
  102. data/lib/samagotchi/web/app.rb +89 -151
  103. data/lib/samagotchi/web/public/activity.js +1 -11
  104. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  105. data/lib/samagotchi/web/public/app.js +158 -785
  106. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  107. data/lib/samagotchi/web/public/command_list.js +99 -0
  108. data/lib/samagotchi/web/public/data.js +22 -66
  109. data/lib/samagotchi/web/public/format.js +0 -23
  110. data/lib/samagotchi/web/public/index.html +0 -2
  111. data/lib/samagotchi/web/public/model_pick.js +0 -7
  112. data/lib/samagotchi/web/public/notifications.js +169 -0
  113. data/lib/samagotchi/web/public/question_cards.js +352 -0
  114. data/lib/samagotchi/web/public/session_state.js +34 -0
  115. data/lib/samagotchi/web/public/timing.js +1 -1
  116. data/lib/samagotchi/web/public/turn_events.js +11 -2
  117. data/lib/samagotchi/web/public/turn_model.js +0 -14
  118. data/lib/samagotchi/web/session_hub.rb +6 -4
  119. data/lib/samagotchi/web/session_summary.rb +5 -3
  120. data/lib/samagotchi/worker.rb +59 -53
  121. data/lib/samagotchi/worker_sidecar.rb +27 -1
  122. metadata +13 -1
@@ -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
- def write_default_model!(model_name, env: ENV, path: global_path(env: env))
897
- resolved = model_name.to_s.strip
898
- raise ArgumentError, "model name is required" if resolved.empty?
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
- if resolved.include?(":") || resolved.include?("/")
901
- sep = resolved.include?(":") ? ":" : "/"
902
- prefix = resolved.split(sep, 2).first.to_s.strip.downcase
903
- begin
904
- hosts = hosts_config(env: env, path: path)
905
- if hosts && !hosts.empty? && !hosts.key?(prefix) && prefix.match?(HOST_NAME_RE)
906
- end
907
- rescue StandardError
908
- nil
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
- raw_data = read_yaml(env: env, path: path) || {}
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
- raw_data["default"] ||= {}
915
- if raw_data["default"].is_a?(Hash)
916
- raw_data["default"]["model"] = resolved
917
- else
918
- raw_data["default"] = { "model" => resolved }
919
- end
920
- FileUtils.mkdir_p(File.dirname(path))
921
- AtomicFile.write(path, YAML.dump(raw_data))
922
- clear_yaml_cache!(path)
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
- def write_model_alias!(alias_name, model_name, env: ENV, path: global_path(env: env))
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
- raise ArgumentError, "alias name is required" if alias_key.empty?
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
- raise ArgumentError, "alias name '#{alias_key}' is reserved" if RESERVED_MODEL_ALIASES.include?(lowered_key)
1015
- raise ArgumentError, "alias name must not contain whitespace" if alias_key.match?(/\s/)
1016
- raise ArgumentError, "alias name must not start with '-'" if alias_key.start_with?("-")
1017
- raise ArgumentError, "alias name must not contain '/'" if alias_key.include?("/")
1018
- unless alias_key.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
1019
- raise ArgumentError, "alias name must match /[a-z0-9][a-z0-9._-]*/i (got '#{alias_key}')"
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
- resolved_model = model_name.to_s.strip
1023
- raise ArgumentError, "alias must not point to itself" if lowered_key == resolved_model.downcase
1024
-
1025
- raw_data = read_yaml(env: env, path: path) || {}
1030
+ nil
1031
+ end
1026
1032
 
1027
- aliases_hash = raw_data[MODEL_ALIASES_KEY]
1028
- unless aliases_hash.is_a?(Hash)
1029
- aliases_hash = {}
1030
- raw_data[MODEL_ALIASES_KEY] = aliases_hash
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
- # Normalize existing alias keys to downcase to avoid duplicates like Qwen/qwen
1034
- normalized = {}
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 = normalized[lowered_key]
1043
- normalized[lowered_key] = resolved_model
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
- FileUtils.mkdir_p(File.dirname(path))
1046
- AtomicFile.write(path, YAML.dump(raw_data))
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? || %w[-h --help help].include?(sub)
49
+ if sub.nil? || HELP_WORDS.include?(sub)
37
50
  @stdout.puts(USAGE)
38
51
  return 0
39
52
  end
@@ -44,7 +57,9 @@ module Samagotchi
44
57
  return 1
45
58
  end
46
59
 
47
- options = parse(sub) or return 2
60
+ options = parse(sub)
61
+ return options if options.is_a?(Integer)
62
+
48
63
  # --no-register (hidden): no pbs/lsregister/open/pkill, for installs
49
64
  # into temp dirs (HOME=…) that must not touch the real Services.
50
65
  helper = @platform.call(register: !options[:no_register])
@@ -56,17 +71,14 @@ module Samagotchi
56
71
 
57
72
  private
58
73
 
59
- # @return [Hash, nil] nil after a usage error
74
+ def command_name = "chi desktop"
75
+
76
+ # @return [Hash, Integer] the options, or the usage error's exit status
60
77
  def parse(sub)
61
- flags = { "--no-register" => :no_register }
62
- flags.merge!("--force" => :force, "--login" => :login) if sub == "install"
63
- @argv.each_with_object({}) do |arg, options|
64
- unless flags.key?(arg)
65
- usage_error("unknown option #{arg}")
66
- return nil
67
- end
68
- options[flags[arg]] = true
69
- end
78
+ parsed = (sub == "install" ? INSTALL_FLAGS : FLAGS).parse(@argv)
79
+ return usage_error(parsed.error.message) if parsed.error
80
+
81
+ parsed.options
70
82
  end
71
83
 
72
84
  # The warnings come after a done install: a refused or failed one
@@ -136,11 +148,5 @@ module Samagotchi
136
148
  def print_warnings(helper)
137
149
  helper.warnings.each { |warning| @stderr.puts("warning: #{warning}") }
138
150
  end
139
-
140
- def usage_error(message)
141
- @stderr.puts("chi desktop: #{message}")
142
- @stderr.puts(USAGE)
143
- 2
144
- end
145
151
  end
146
152
  end
@@ -33,8 +33,8 @@ module Samagotchi
33
33
  return before if before.is_a?(Hash)
34
34
 
35
35
  if call[:name].to_s == "edit"
36
- result = Tools::Edit.apply(call[:content], path: path, start_line: call[:start_line],
37
- end_line: call[:end_line], read: ->(_) { before })
36
+ result = Tools::Edit.apply(path: path, old_text: call[:old_text], new_text: call[:new_text],
37
+ start_line: call[:start_line], end_line: call[:end_line], read: ->(_) { before })
38
38
  return { error: result.delete_prefix("Error: ") } if result.is_a?(String)
39
39
 
40
40
  after = result.first
@@ -11,7 +11,6 @@ require_relative "context_note"
11
11
  require_relative "steer"
12
12
  require_relative "turn_note"
13
13
  require_relative "model_profile"
14
- require_relative "thought_stream_splitter"
15
14
  require_relative "cancellation_controller"
16
15
  require_relative "context_window"
17
16
  require_relative "kernel_loop"
@@ -107,7 +106,7 @@ module Samagotchi
107
106
  # #profile_resolution decides on first need (see there), so building an
108
107
  # Engine makes no network call.
109
108
  @given_profile = profile ? ModelProfile.normalize(profile) : nil
110
- @model_lookup_names = [@default_model_name]
109
+ @typed_model_name = @default_model_name
111
110
  @profile_resolution = nil
112
111
  # Ensure the built-in system bundle is installed (lazy, warn-only).
113
112
  # This is the single seam for both TUI and non-TUI (web/worker) paths.
@@ -769,7 +768,7 @@ module Samagotchi
769
768
  resolved = ModelProfile.check_host!(ModelProfile.required_model_name(aliased), hosts: @host_registry.entries)
770
769
  @effective_model_name = resolved
771
770
  bare = bare_model_name(resolved)
772
- @model_lookup_names = [model_name, aliased, resolved]
771
+ @typed_model_name = model_name
773
772
  # A profile given to .new was for the starting model.
774
773
  @given_profile = nil
775
774
  @profile_resolution = nil
@@ -844,7 +843,7 @@ module Samagotchi
844
843
  # head system prompt invalidates the cache for the entire conversation
845
844
  # (prompt re-evaluated every interval). A tail append keeps the prefix
846
845
  # intact — only the new reminder suffix is evaluated. Mirrors the
847
- # context-status injection at lib/samagotchi/kernel_loop.rb:447.
846
+ # context guidance line KernelLoop#run appends (ContextStatus#take_guidance).
848
847
  messages << { role: "system", content: reminder_text }
849
848
  # Atomically mark all due as fired under one lock and clear the
850
849
  # IdleReminders latch so the next interval can be detected.
@@ -1107,29 +1106,15 @@ module Samagotchi
1107
1106
  @guardrail_wiring.state_dir = state_dir
1108
1107
  end
1109
1108
 
1110
- # The gate's core checks, in order (GuardrailWiring#checks).
1111
- def guardrail_checks
1112
- @guardrail_wiring.checks
1113
- end
1114
-
1115
1109
  # The YAML rules, read again when a file changed (GuardrailWiring#rules).
1116
1110
  # @return [Guardrails::Rules]
1117
1111
  def guardrail_rules
1118
1112
  @guardrail_wiring.rules
1119
1113
  end
1120
1114
 
1121
- # Installed bundles' guardrails/*.yml (GuardrailWiring#bundle_rules).
1122
- def bundle_guardrail_rules
1123
- @guardrail_wiring.bundle_rules
1124
- end
1125
-
1126
1115
  # @return [Guardrails::LoadFailures]
1127
1116
  attr_reader :guardrail_failures
1128
1117
 
1129
- def guardrail_protected_paths
1130
- @guardrail_wiring.protected_paths
1131
- end
1132
-
1133
1118
  # @return [Guardrails::Approvals]
1134
1119
  def guardrail_approvals
1135
1120
  @guardrail_wiring.approvals
@@ -1165,12 +1150,6 @@ module Samagotchi
1165
1150
  @plugin_failures.message if @guardrail_failures_announced
1166
1151
  end
1167
1152
 
1168
- # The context the gate sees for a tool call now.
1169
- # @return [Guardrails::Context]
1170
- def guardrail_context
1171
- @guardrail_wiring.context
1172
- end
1173
-
1174
1153
  # Ask the user to approve a call the gate voted `ask` on
1175
1154
  # (GuardrailWiring#request_approval).
1176
1155
  # @param verdict [Guardrails::Verdict]
@@ -2065,7 +2044,7 @@ module Samagotchi
2065
2044
  (task = current_init_task) ? task.cancelled? : @activity_mutex && active_cancel_controller&.cancelled?
2066
2045
  },
2067
2046
  card: ->(**card) { show_card(**card) },
2068
- steer: ->(text, source) { steer(text, source: source) },
2047
+ steer: ->(text, label) { steer(text, source: hook_source(label)) },
2069
2048
  stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
2070
2049
  stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
2071
2050
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
@@ -2108,31 +2087,13 @@ module Samagotchi
2108
2087
  end
2109
2088
  private :ask_side_model
2110
2089
 
2111
- # config.yml `bundles:`: each bundle's settings by name, for its hooks.
2112
- # @return [Hash{String => Hash}] {} when absent; a section that isn't a
2113
- # mapping warns once and counts as absent
2090
+ # config.yml `bundles:` (ConfigFile.bundle_settings), read once: the
2091
+ # bundle hooks and the plugins both want it.
2114
2092
  def bundle_settings
2115
- # Read once: the bundle hooks and the plugins both want it.
2116
- @bundle_settings ||= read_bundle_settings
2093
+ @bundle_settings ||= Samagotchi::ConfigFile.bundle_settings
2117
2094
  end
2118
2095
  private :bundle_settings
2119
2096
 
2120
- def read_bundle_settings
2121
- data = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
2122
- section = data.is_a?(Hash) ? data["bundles"] : nil
2123
- return {} if section.nil?
2124
- unless section.is_a?(Hash)
2125
- Log.warn(:hooks, "bundles_section_invalid", echo: "[samagotchi:hooks] config.yml bundles: must be a mapping of bundle name to settings; ignored")
2126
- return {}
2127
- end
2128
-
2129
- section.each_with_object({}) do |(name, value), acc|
2130
- acc[name.to_s] = value.is_a?(Hash) ? value : {}
2131
- end
2132
- rescue StandardError
2133
- {}
2134
- end
2135
- private :read_bundle_settings
2136
2097
 
2137
2098
  # The session's current model as a recap target: its host's OpenAI API
2138
2099
  # (native llama.cpp hosts serve /v1/chat/completions too), key variable
@@ -2168,44 +2129,29 @@ module Samagotchi
2168
2129
  # only notifying the observer.
2169
2130
  # Wrap a raw kernel stream event and fan it out to the turn sink + observers.
2170
2131
  #
2171
- # Phase 2 (web-stream-rendering): each :generation_chunk is enriched with two
2172
- # ADDITIVE fields derived from the raw `content` (which is left untouched —
2173
- # the TUI thinking spinner, analytics, and Bridge replay all rely on raw):
2132
+ # Each :generation_chunk comes split already, by the loop that streamed it:
2133
+ # besides the raw `content` (left untouched — the TUI thinking spinner,
2134
+ # analytics, and Bridge replay rely on it) it carries
2174
2135
  # * :text — visible prose (thinking AND tool_call blocks removed)
2175
2136
  # * :thinking — thinking-only content
2176
- # The splitter is profile-aware and resets on each :generation_started so a
2177
- # turn's multiple generations each start clean.
2178
- #
2179
- # Enrichment is profile-scoped:
2180
- # * Splitting profiles (explicit think close, e.g. Qwen) ALWAYS emit
2181
- # `text`/`thinking` on every :generation_chunk — even when empty — so the
2182
- # web client can rely on them and never fall back to raw `content`.
2183
- # * Non-splitting profiles (nil think close, e.g. Gemma) leave the event
2184
- # unchanged; the web client then falls back to raw `content`, preserving
2185
- # today's behavior (no regression).
2137
+ # KernelLoop splits a native stream with a ThoughtStreamSplitter per
2138
+ # generation (every profile, Gemma's thought channel included); the chat
2139
+ # loop gets reasoning apart from the answer. The web routes on those
2140
+ # fields (chunk_router.js).
2186
2141
  #
2187
2142
  # With a :generation_progress hook registered, a Hooks::StreamWatch gets
2188
2143
  # each chunk's thinking and text after the UIs had it.
2189
2144
  def build_stream_event_handler(on_event, cancel_controller: nil)
2190
- splitter = ThoughtStreamSplitter.for_profile(profile)
2191
- enrich = profile.thought_close ? :always : :never
2192
2145
  watch = stream_watch(cancel_controller)
2193
2146
  proc do |event|
2194
2147
  progress = nil
2195
2148
  case event[:type]
2196
2149
  when :generation_started
2197
- splitter = ThoughtStreamSplitter.for_profile(profile)
2198
2150
  watch&.started(event[:iteration])
2199
2151
  when :generation_chunk
2200
- # The chat loop already splits its stream (reasoning arrives apart
2201
- # from the answer); only raw native chunks are split here.
2202
- if event.key?(:text)
2203
- progress = { thinking: event[:thinking], text: event[:text] }
2204
- else
2205
- delta = splitter.feed(event[:content])
2206
- progress = { thinking: delta[:thinking], text: delta[:text] }
2207
- event = event.merge(text: delta[:text], thinking: delta[:thinking]) if enrich == :always
2208
- end
2152
+ # A chunk without the lanes (no loop of ours sends one) counts as text.
2153
+ text = event.key?(:text) ? event[:text] : event[:content]
2154
+ progress = { thinking: event[:thinking].to_s, text: text.to_s }
2209
2155
  end
2210
2156
  # The chat loop asked again without the thinking fields: a notice,
2211
2157
  # not an event of its own.
@@ -2300,11 +2246,10 @@ module Samagotchi
2300
2246
  ModelProfile.resolve(names: model_lookup_names(target), entry: target.entry, client: target.client, bare_model: target.bare_model)
2301
2247
  end
2302
2248
 
2303
- # The names a models: entry may be under: as typed (maybe an alias), the
2304
- # part after a host prefix, alias-resolved, bare.
2249
+ # The names a models: entry may be under (HostRegistry#lookup_names):
2250
+ # the model as typed at start or at the last /model, and what it became.
2305
2251
  def model_lookup_names(target)
2306
- typed = @model_lookup_names.first
2307
- (@model_lookup_names + [@host_registry.parse_qualified_model(typed).last, target.bare_model]).compact
2252
+ @host_registry.lookup_names(@typed_model_name, resolved: @effective_model_name, target: target)
2308
2253
  end
2309
2254
 
2310
2255
  # Everything that holds a profile follows the resolution: the kernel's
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The sets of event types more than one place reads (the Worker, the
5
+ # Bridge's observers, the TUI, the web), kept here once. The web's own
6
+ # copies (app.js's stream handlers, turn_events.js's snapshotEvents) are
7
+ # checked against these by spec/events_drift_spec.rb.
8
+ module Events
9
+ # A turn is over: it completed, was canceled, or failed.
10
+ TURN_END = %i[turn_completed turn_canceled turn_failed].freeze
11
+ # Turn ends that leave the turn in the conversation: a failed turn's
12
+ # prompt goes back to the composer instead.
13
+ TURN_KEPT = %i[turn_completed turn_canceled].freeze
14
+ # The kinds of a running turn's parts (Bridge::TurnAccumulator), which a
15
+ # joining UI replays as live events.
16
+ PART_KINDS = %w[thinking text tool input steer reminder notice].freeze
17
+ # Types no Engine emits that a UI handles: the Bridge's stream frames
18
+ # and the web's one synthetic replay event (a prompt merged into the
19
+ # turn, turn_events.js snapshotEvents).
20
+ STREAM_FRAMES = %i[snapshot reset stream_closed].freeze
21
+ SYNTHETIC = %i[merged_input].freeze
22
+ end
23
+ end
@@ -156,13 +156,11 @@ module Samagotchi
156
156
  def protected_paths
157
157
  @protected ||= begin
158
158
  require_relative "memory_bundle/provenance"
159
- config = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
160
- hooks_dir = config.is_a?(Hash) && config["hooks"].is_a?(Hash) ? config["hooks"]["hooks_dir"] : nil
161
159
  Guardrails::ProtectedPaths.new(
162
160
  store_dir: File.dirname(@approvals.path),
163
161
  bundles_dir: MemoryBundle::Provenance.bundles_dir,
164
162
  config_path: Samagotchi::ConfigFile.global_path,
165
- hooks_dir: Hooks::Loader.expand_path(hooks_dir || Hooks::Loader.default_hooks_dir)
163
+ hooks_dir: Hooks::Loader.hooks_dir
166
164
  )
167
165
  end
168
166
  end
@@ -209,8 +207,7 @@ module Samagotchi
209
207
  end
210
208
 
211
209
  def load_rules
212
- section = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
213
- section = section["guardrails"] if section.is_a?(Hash)
210
+ section = Samagotchi::ConfigFile.section("guardrails")
214
211
  rules = []
215
212
  disable = []
216
213
  begin
@@ -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? { |p| p != root && !p.start_with?(File.join(root, "")) }
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.