samagotchi 0.12.0 → 0.14.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +20 -7
  4. data/bin/chi +9 -9
  5. data/docs/architecture.md +4 -2
  6. data/docs/cli.md +34 -10
  7. data/docs/configuration.md +79 -15
  8. data/docs/desktop.md +10 -6
  9. data/docs/guardrails.md +74 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +31 -16
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge/card_store.rb +94 -6
  17. data/lib/samagotchi/bridge.rb +14 -2
  18. data/lib/samagotchi/bridge_client.rb +8 -3
  19. data/lib/samagotchi/bundle_command.rb +18 -10
  20. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
  22. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  23. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/exit.rb +23 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  31. data/lib/samagotchi/config.rb +81 -24
  32. data/lib/samagotchi/context_window.rb +30 -5
  33. data/lib/samagotchi/desktop_command.rb +5 -3
  34. data/lib/samagotchi/engine.rb +54 -17
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +158 -0
  36. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  37. data/lib/samagotchi/host_registry.rb +31 -7
  38. data/lib/samagotchi/kernel_loop.rb +1 -1
  39. data/lib/samagotchi/llm/chat_loop.rb +5 -3
  40. data/lib/samagotchi/llm/openai_chat.rb +9 -1
  41. data/lib/samagotchi/llm/turn_settings.rb +7 -5
  42. data/lib/samagotchi/memory_bundle/installer.rb +12 -0
  43. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  44. data/lib/samagotchi/memory_paths.rb +8 -0
  45. data/lib/samagotchi/model_profile.rb +30 -16
  46. data/lib/samagotchi/parent_report.rb +213 -0
  47. data/lib/samagotchi/plugin/api.rb +15 -2
  48. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  49. data/lib/samagotchi/question_desk.rb +29 -2
  50. data/lib/samagotchi/recap_setup.rb +15 -5
  51. data/lib/samagotchi/reply_wait.rb +5 -1
  52. data/lib/samagotchi/send_command.rb +50 -75
  53. data/lib/samagotchi/session.rb +15 -0
  54. data/lib/samagotchi/session_manager.rb +40 -7
  55. data/lib/samagotchi/sessions_command.rb +13 -3
  56. data/lib/samagotchi/system_prompt.rb +11 -3
  57. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +53 -14
  59. data/lib/samagotchi/terminal_ui.rb +30 -3
  60. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  61. data/lib/samagotchi/tool_call_parser.rb +5 -6
  62. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  63. data/lib/samagotchi/tools/builtins.rb +14 -2
  64. data/lib/samagotchi/tools/delegate_wait.rb +32 -13
  65. data/lib/samagotchi/tools/execute.rb +8 -8
  66. data/lib/samagotchi/tools/memory.rb +3 -3
  67. data/lib/samagotchi/tools/task_create.rb +3 -2
  68. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  69. data/lib/samagotchi/utf8_default.rb +29 -0
  70. data/lib/samagotchi/version.rb +1 -1
  71. data/lib/samagotchi/web/app.rb +22 -10
  72. data/lib/samagotchi/web/message_parts.rb +2 -1
  73. data/lib/samagotchi/web/public/activity.js +6 -1
  74. data/lib/samagotchi/web/public/app.js +39 -6
  75. data/lib/samagotchi/web/public/index.html +8 -1
  76. data/lib/samagotchi/web/public/notify.js +16 -3
  77. data/lib/samagotchi/web/public/question_cards.js +3 -1
  78. data/lib/samagotchi/web/public/sessions_list.js +37 -0
  79. data/lib/samagotchi/web/public/turn_view.js +6 -9
  80. data/lib/samagotchi/web/server.rb +2 -1
  81. data/lib/samagotchi/web/session_summary.rb +3 -11
  82. metadata +8 -1
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../reply_wait"
5
+ require_relative "../parent_report"
6
+ require_relative "exit"
7
+
8
+ module Samagotchi
9
+ module CLI
10
+ # The parent-agent side shared by `chi send --wait` and `chi answer`:
11
+ # --format json's one object whatever the end, the session id resolved
12
+ # for it, and the wait for what comes next, reported the ParentReport
13
+ # way (Ctrl-C included). The including class (a CLI::Command) defines
14
+ # #parse (options, or an exit status) and #run_parsed(options), sets
15
+ # @stdout, @stderr and @state_dir, and may override the constants
16
+ # (specs stub them on the class).
17
+ module ParentWait
18
+ POLL_INTERVAL = ReplyWait::POLL_INTERVAL
19
+ # No live worker this long while waiting: it died before it could mark
20
+ # the session (a worker takes well under a second to start).
21
+ WORKER_GONE_AFTER = 5
22
+ FORMATS = %w[text json].freeze
23
+
24
+ # @return [Integer] the exit status
25
+ def run
26
+ options = parse
27
+ return options if options.is_a?(Integer)
28
+
29
+ @json = options[:format] == "json"
30
+ # With --format json stdout is one JSON object, whatever the end: a
31
+ # failure before the wait is reported from stderr's last line.
32
+ @stderr = ParentReport::LastLine.new(@stderr) if @json
33
+ status = run_parsed(options)
34
+ if @json && !@reported && status == 1
35
+ detail = @stderr.last.to_s.delete_prefix("#{command_name}: ")
36
+ @stdout.puts(ParentReport.error_line(detail, session_id: @session_id))
37
+ @stdout.flush
38
+ end
39
+ status
40
+ end
41
+
42
+ private
43
+
44
+ # The full id of +given+ (an id or a prefix), kept for the JSON
45
+ # error line; nil after the error line.
46
+ # @return [String, nil]
47
+ def resolve(given)
48
+ id = Session.resolve_id(given, state_dir: @state_dir)
49
+ Session.load(id, state_dir: @state_dir)
50
+ @session_id = id
51
+ rescue ArgumentError => e
52
+ error_line("#{command_name}: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
53
+ nil
54
+ end
55
+
56
+ # Wait for the session's next reply past +cursor+ (ReplyWait) and
57
+ # report it. Ctrl-C leaves the turn running (130).
58
+ # @param owner_grace [Numeric, nil] seconds with no live worker before
59
+ # it counts as gone; nil waits for whatever wakes one
60
+ # @return [Integer] the exit status
61
+ def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER)
62
+ result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
63
+ owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL)
64
+ result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
65
+ @reported = true
66
+ ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
67
+ json: @json, timeout: timeout)
68
+ rescue Interrupt
69
+ if @json
70
+ @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id))
71
+ @stdout.flush
72
+ @reported = true
73
+ end
74
+ error_line("#{command_name}: still running: chi --attach #{id}")
75
+ Exit::INTERRUPTED
76
+ rescue ArgumentError
77
+ error_line("#{command_name}: the session is gone (deleted while waiting)")
78
+ Exit::FAILED
79
+ end
80
+ end
81
+ end
82
+ end
@@ -104,10 +104,14 @@ module Samagotchi
104
104
  Entry.new(key: "image.max_bytes", yaml_path: %w[image max_bytes], type: :integer, default: 3_750_000, expose: %i[env config]),
105
105
  Entry.new(key: "image.max_per_request", yaml_path: %w[image max_per_request], type: :integer, default: 20, expose: %i[env config]),
106
106
 
107
- Entry.new(key: "guardrails.enabled", yaml_path: %w[guardrails enabled], type: :bool, default: true, expose: %i[env config]),
107
+ # The guardrails settings are config.yml only: a parent agent must not switch a child's guardrails off from its
108
+ # own environment (SAMAGOTCHI_GUARDRAILS_ENABLED=false). SessionManager.spawn_options unsets them for a worker too.
109
+ Entry.new(key: "guardrails.enabled", yaml_path: %w[guardrails enabled], type: :bool, default: true, expose: %i[config]),
108
110
  # Which models get the rules with `models: small` (Guardrails::ModelSize): auto (32B or less by the name's size, an MoE's
109
111
  # active size counting), globs on the bare model name or its key, "|"-separated (a YAML list works too), or "" ([]) for none.
110
- Entry.new(key: "guardrails.small_models", yaml_path: %w[guardrails small_models], type: :string, default: "auto", expose: %i[env config]),
112
+ Entry.new(key: "guardrails.small_models", yaml_path: %w[guardrails small_models], type: :string, default: "auto", expose: %i[config]),
113
+ # What `chi answer` lets a parent agent allow on an approval: off (deny only) or once ("Allow once", never a wider scope).
114
+ Entry.new(key: "guardrails.parent_approvals", yaml_path: %w[guardrails parent_approvals], type: :enum, default: "off", expose: %i[config], enum_values: %w[off once]),
111
115
 
112
116
  Entry.new(key: "log.file", yaml_path: %w[log file], type: :string, default: nil, expose: %i[env config cli]),
113
117
  Entry.new(key: "log.disable", yaml_path: %w[log disable], type: :bool, default: false, expose: %i[env config cli]),
@@ -156,6 +160,8 @@ module Samagotchi
156
160
  Entry.new(key: "execute.truncate_at_bytes", yaml_path: %w[execute truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
157
161
  Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
158
162
  Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
163
+ # Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
164
+ Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 30, expose: %i[env config cli]),
159
165
 
160
166
  Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
161
167
  Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
@@ -181,8 +187,9 @@ module Samagotchi
181
187
  # Maps of named entries and the keys an entry may hold
182
188
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
183
189
  MAP_ENTRY_KEYS = {
184
- "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled].freeze,
185
- "models" => %w[profile vision sampling thinking].freeze
190
+ "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled
191
+ remote window_tokens].freeze,
192
+ "models" => %w[profile vision sampling thinking window_tokens].freeze
186
193
  }.freeze
187
194
  # Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
188
195
  SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
@@ -597,7 +604,23 @@ module Samagotchi
597
604
 
598
605
  # A `vision:` setting (hosts entry or models: entry): true, false, or nil
599
606
  # when unset. Anything else warns once and counts as unset.
600
- def vision_flag(value, where)
607
+ def vision_flag(value, where) = bool_flag(value, where, "vision")
608
+
609
+ # A `window_tokens:` setting (hosts entry or models: entry): a positive
610
+ # integer, nil when unset; anything else warns once and counts as unset.
611
+ def window_tokens(value, where)
612
+ return nil if value.nil?
613
+
614
+ tokens = Integer(value.to_s.strip, exception: false)
615
+ return tokens if tokens&.positive?
616
+
617
+ warn_once "Warning: #{where}: window_tokens must be a positive number of tokens; ignored"
618
+ nil
619
+ end
620
+
621
+ # A true/false setting +key+ of a map entry, nil when unset; anything
622
+ # else warns once and counts as unset.
623
+ def bool_flag(value, where, key)
601
624
  return nil if value.nil?
602
625
  return value if value == true || value == false
603
626
 
@@ -605,7 +628,7 @@ module Samagotchi
605
628
  return true if text == "true"
606
629
  return false if text == "false"
607
630
 
608
- warn_once "Warning: #{where}: vision must be true or false; ignored"
631
+ warn_once "Warning: #{where}: #{key} must be true or false; ignored"
609
632
  nil
610
633
  end
611
634
 
@@ -695,18 +718,7 @@ module Samagotchi
695
718
 
696
719
  def hosts_config(env: ENV, path: global_path(env: env))
697
720
  data = read_yaml(env: env, path: path)
698
- raw_hosts = data[HOSTS_KEY] if data.is_a?(Hash)
699
-
700
- # ENV override: SAMAGOTCHI_HOSTS_JSON (used to propagate to workers)
701
- env_json = env["SAMAGOTCHI_HOSTS_JSON"].to_s.strip
702
- unless env_json.empty?
703
- begin
704
- parsed_env = JSON.parse(env_json)
705
- raw_hosts = parsed_env if parsed_env.is_a?(Hash)
706
- rescue StandardError
707
- nil
708
- end
709
- end
721
+ raw_hosts = hosts_source(data, env)
710
722
 
711
723
  normalized = {}
712
724
  if raw_hosts.is_a?(Hash)
@@ -726,16 +738,18 @@ module Samagotchi
726
738
  port = raw_cfg["port"] || raw_cfg[:port]
727
739
  transport = raw_cfg["transport"] || raw_cfg[:transport]
728
740
  api = raw_cfg["api"] || raw_cfg[:api]
729
- enabled = raw_cfg.key?("enabled") ? raw_cfg["enabled"] : (raw_cfg.key?(:enabled) ? raw_cfg[:enabled] : true)
730
- if enabled == false || enabled.to_s.strip.downcase == "false"
731
- next
732
- end
741
+ next if host_disabled?(raw_cfg)
742
+
733
743
  url = (raw_cfg["url"] || raw_cfg[:url]).to_s.strip
734
744
  api_key_env = (raw_cfg["api_key_env"] || raw_cfg[:api_key_env]).to_s.strip
735
745
  # Kept as written; ModelProfile.resolve warns about an unknown one.
736
746
  profile = (raw_cfg["profile"] || raw_cfg[:profile]).to_s.strip.downcase
737
747
  first_token_timeout = raw_cfg.key?("first_token_timeout") ? raw_cfg["first_token_timeout"] : raw_cfg[:first_token_timeout]
738
748
  vision = ConfigFile.vision_flag(raw_cfg.key?("vision") ? raw_cfg["vision"] : raw_cfg[:vision], "hosts entry '#{name}'")
749
+ remote = ConfigFile.bool_flag(raw_cfg.key?("remote") ? raw_cfg["remote"] : raw_cfg[:remote], "hosts entry '#{name}'",
750
+ "remote")
751
+ window = ConfigFile.window_tokens(raw_cfg.key?("window_tokens") ? raw_cfg["window_tokens"] : raw_cfg[:window_tokens],
752
+ "hosts entry '#{name}'")
739
753
  sampling = ConfigFile.sampling_map(raw_cfg.key?("sampling") ? raw_cfg["sampling"] : raw_cfg[:sampling], "hosts entry '#{name}'")
740
754
  thinking = Thinking.level(raw_cfg.key?("thinking") ? raw_cfg["thinking"] : raw_cfg[:thinking], "hosts entry '#{name}'")
741
755
  unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
@@ -801,7 +815,8 @@ module Samagotchi
801
815
  api: api_val&.to_sym, original_name: name, scheme: scheme,
802
816
  url: url.empty? ? nil : url, api_key_env: api_key_env.empty? ? nil : api_key_env,
803
817
  profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
804
- vision: vision, sampling: sampling, thinking: thinking }
818
+ vision: vision, sampling: sampling, thinking: thinking, remote: remote,
819
+ window_tokens: window }
805
820
  end
806
821
  end
807
822
 
@@ -822,6 +837,42 @@ module Samagotchi
822
837
  {}
823
838
  end
824
839
 
840
+ # The names (lowercased) of the hosts entries with enabled: false, which
841
+ # hosts_config leaves out. Read from the same source: config.yml's
842
+ # hosts:, or SAMAGOTCHI_HOSTS_JSON in a worker.
843
+ # @return [Array<String>]
844
+ def disabled_host_names(env: ENV, path: global_path(env: env))
845
+ raw = hosts_source(read_yaml(env: env, path: path), env)
846
+ return [] unless raw.is_a?(Hash)
847
+
848
+ raw.filter_map { |name, cfg| name.to_s.strip.downcase if cfg.is_a?(Hash) && host_disabled?(cfg) }
849
+ rescue StandardError
850
+ []
851
+ end
852
+
853
+ # config.yml's hosts: as written, or the SAMAGOTCHI_HOSTS_JSON override
854
+ # a worker inherits from its parent.
855
+ def hosts_source(data, env)
856
+ raw = data[HOSTS_KEY] if data.is_a?(Hash)
857
+ env_json = env["SAMAGOTCHI_HOSTS_JSON"].to_s.strip
858
+ return raw if env_json.empty?
859
+
860
+ parsed = begin
861
+ JSON.parse(env_json)
862
+ rescue StandardError
863
+ nil
864
+ end
865
+ parsed.is_a?(Hash) ? parsed : raw
866
+ end
867
+ private_class_method :hosts_source
868
+
869
+ # enabled: false (or "false", any case) in a hosts entry.
870
+ def host_disabled?(cfg)
871
+ enabled = cfg.key?("enabled") ? cfg["enabled"] : (cfg.key?(:enabled) ? cfg[:enabled] : true)
872
+ enabled == false || enabled.to_s.strip.downcase == "false"
873
+ end
874
+ private_class_method :host_disabled?
875
+
825
876
  # Resolve the `recap:` section through the Config registry so scalar
826
877
  # recap settings share the single precedence path (CLI > ENV > file >
827
878
  # default). Returns false when explicitly disabled (file `recap: false`
@@ -906,8 +957,12 @@ module Samagotchi
906
957
  location = v[:url] ? { "url" => v[:url] } : { "host" => v[:host], "port" => v[:port] }
907
958
  location.merge("transport" => v[:transport]&.to_s, "api" => v[:api]&.to_s, "api_key_env" => v[:api_key_env],
908
959
  "profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
909
- "vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s).compact
960
+ "vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s,
961
+ "remote" => v[:remote], "window_tokens" => v[:window_tokens]).compact
910
962
  end
963
+ # Disabled hosts travel as just that, so a worker refuses "box:x"
964
+ # the way its parent does instead of sending it to the default host.
965
+ disabled_host_names(env: env, path: path).grep(HOST_NAME_RE).each { |name| simple[name] ||= { "enabled" => false } }
911
966
  JSON.generate(simple)
912
967
  rescue StandardError
913
968
  nil
@@ -991,6 +1046,8 @@ module Samagotchi
991
1046
  result[key][:sampling] = sampling if sampling
992
1047
  thinking = Thinking.level(v.key?("thinking") ? v["thinking"] : v[:thinking], "models: #{key}")
993
1048
  result[key][:thinking] = thinking if thinking
1049
+ window = window_tokens(v.key?("window_tokens") ? v["window_tokens"] : v[:window_tokens], "models: #{key}")
1050
+ result[key][:window_tokens] = window if window
994
1051
  end
995
1052
  rescue StandardError
996
1053
  {}
@@ -9,11 +9,16 @@ module Samagotchi
9
9
  # 1. the running server (Client#context_window, e.g. llama.cpp's n_ctx)
10
10
  # 2. the host's model list (a chat adapter's #context_window, e.g. a
11
11
  # provider's context_length)
12
- # 3. context.window_tokens (CLI, SAMAGOTCHI_CONTEXT_WINDOW_TOKENS or the
13
- # config file); it only fills in when the server reports nothing
14
- # 4. DEFAULT_TOKENS
12
+ # 3. models.<key>.window_tokens, else hosts.<name>.window_tokens
13
+ # (#setting: the most specific wins)
14
+ # 4. context.window_tokens (CLI, SAMAGOTCHI_CONTEXT_WINDOW_TOKENS or the
15
+ # config file)
16
+ # 5. DEFAULT_TOKENS
17
+ # Settings (3 and 4) only fill in when the server and its list report
18
+ # nothing.
15
19
  #
16
- # Sources: :server, :model_list, :config (CLI or file), :env, :default.
20
+ # Sources: :server, :model_list, :model_setting, :host_setting, :config
21
+ # (CLI or file), :env, :default.
17
22
  module ContextWindow
18
23
  DEFAULT_TOKENS = 256_000
19
24
 
@@ -22,7 +27,8 @@ module Samagotchi
22
27
  module_function
23
28
 
24
29
  # @param adapter [#context_window, nil] the host's chat adapter
25
- def resolve(client: nil, model: nil, adapter: nil)
30
+ # @param setting [Resolved, nil] the model's or host's window (#setting)
31
+ def resolve(client: nil, model: nil, adapter: nil, setting: nil)
26
32
  server_tokens = client.context_window(model: model) if client.respond_to?(:context_window)
27
33
  if positive_integer?(server_tokens)
28
34
  @last_server = Resolved.new(tokens: server_tokens, source: :server)
@@ -34,10 +40,29 @@ module Samagotchi
34
40
  @last_server = Resolved.new(tokens: listed, source: :model_list)
35
41
  return @last_server
36
42
  end
43
+ return @last_server = setting if setting
37
44
 
38
45
  configured
39
46
  end
40
47
 
48
+ # The window config.yml gives this model or its host:
49
+ # models.<key>.window_tokens (by the model's lookup names, as sampling
50
+ # and vision look a model up), else hosts.<name>.window_tokens.
51
+ # @param target [HostRegistry::ModelTarget]
52
+ # @param names [Array<String>] HostRegistry#lookup_names
53
+ # @param models [Hash, nil] ConfigFile.model_settings (specs)
54
+ # @return [Resolved, nil]
55
+ def setting(target, names:, models: nil)
56
+ models ||= ConfigFile.model_settings
57
+ _key, tokens = ConfigFile.model_setting(names, :window_tokens, models: models)
58
+ return Resolved.new(tokens: tokens, source: :model_setting) if positive_integer?(tokens)
59
+
60
+ tokens = target&.entry&.window_tokens
61
+ positive_integer?(tokens) ? Resolved.new(tokens: tokens, source: :host_setting) : nil
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
41
66
  # The window without asking a server: config, env, then the default.
42
67
  # `env` resolves against that env's config file instead of the live
43
68
  # process config (for `chi self`).
@@ -6,7 +6,8 @@ require_relative "desktop"
6
6
 
7
7
  module Samagotchi
8
8
  # `chi desktop install|upgrade|uninstall|status`: the native helper that
9
- # sends selected text to live sessions as a context note (macOS for now).
9
+ # sends selected text (or images) to sessions, live, stopped or new, as a
10
+ # message or a context note (macOS for now).
10
11
  class DesktopCommand
11
12
  include CLI::Command
12
13
 
@@ -15,8 +16,9 @@ module Samagotchi
15
16
  install [--force] [--login]
16
17
  build "Chi Helper" into ~/Applications (needs the Command Line Tools)
17
18
  and start it: Services > "Send to chi", or ⌃⌥⌘N with the clipboard,
18
- sends text to a live session as a context note. --login: start it
19
- at login too (else it runs until you log out)
19
+ sends text or images to a session (live, stopped or new) as a
20
+ message (⏎) or a context note (⌘⏎). --login: start it at login
21
+ too (else it runs until you log out)
20
22
  upgrade rebuild it for this chi and restart it (chi update does this only
21
23
  when its sources changed)
22
24
  uninstall remove it, its settings and its launch file
@@ -223,8 +223,6 @@ module Samagotchi
223
223
  # memory_read guard reads the same list for every turn.
224
224
  @muted_memory_names = MutedMemories.normalize_list(muted_memories)
225
225
  @kernel.muted_memory_names = @muted_memory_names
226
- # Keep kernel client in sync with active host via setter
227
- @kernel_client_synced = false
228
226
  # ask_user_question blocks on the Engine's question flow (TUI/Web answer it).
229
227
  @kernel.question_handler = proc { |payload| request_question(payload) }
230
228
  # Every tool call asks this gate first. The kernel is never rebuilt, so
@@ -967,10 +965,6 @@ module Samagotchi
967
965
  @prompt_builder.preloaded_memory_names
968
966
  end
969
967
 
970
- def memory_muted?(name)
971
- MutedMemories.muted?(name, @muted_memory_names)
972
- end
973
-
974
968
  # ── Guardrails ─────────────────────────────────────────────────────────────
975
969
 
976
970
  # Who can answer an approval: :repl, :worker or :non_interactive (the
@@ -1245,8 +1239,8 @@ module Samagotchi
1245
1239
 
1246
1240
  # Answer the pending question (called from UI thread; QuestionDesk#answer).
1247
1241
  # @return [Hash] normalized answer
1248
- def answer_question(id:, selected:, freeform: nil)
1249
- @question_desk.answer(id: id, selected: selected, freeform: freeform)
1242
+ def answer_question(id:, selected:, freeform: nil, client_id: nil)
1243
+ @question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id)
1250
1244
  end
1251
1245
 
1252
1246
  def set_question_sync_handler(&block)
@@ -1412,7 +1406,7 @@ module Samagotchi
1412
1406
  # the prompt is kept in the session and :turn_canceled is emitted, then the
1413
1407
  # Interrupt is re-raised so the caller still decides whether to exit. One
1414
1408
  # after the turn's end event (in its after_turn/session_end hooks) is
1415
- # only re-raised: the turn already ended. Any
1409
+ # only re-raised: the turn already ended; so is any error there. Any
1416
1410
  # other error (e.g. an LLM::ProviderError) emits :turn_failed and
1417
1411
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
1418
1412
  # one-line summary:.
@@ -1424,7 +1418,8 @@ module Samagotchi
1424
1418
  # A Stop cuts the /props probes this thread makes for the turn
1425
1419
  # (window, served model, vision) instead of waiting their timeout.
1426
1420
  probe_cancel_before = Client.swap_probe_cancel(turn.controller)
1427
- prepare_turn(turn, continue ? [] : images)
1421
+ return stopped_before_model(turn) unless prepare_turn(turn, continue ? [] : images)
1422
+
1428
1423
  result = generate(turn, max_iterations: max_iterations, max_tool_output_chars: max_tool_output_chars,
1429
1424
  pending_input: pending_input)
1430
1425
  publish_used_memories(session, on_event)
@@ -1441,6 +1436,13 @@ module Samagotchi
1441
1436
  end
1442
1437
  raise
1443
1438
  rescue StandardError => e
1439
+ # After the end event (post-turn work) likewise: logged and
1440
+ # re-raised, no turn_failed over the turn that ended.
1441
+ if turn.ended
1442
+ Log.warn(:turn, "post_turn_error", error: "#{e.class}: #{e.message}")
1443
+ raise
1444
+ end
1445
+
1444
1446
  # Keep what the turn got to (the prompt plus the loop's completed
1445
1447
  # tool iterations) like a cancel does, and save it: a worker exits
1446
1448
  # after a failed turn. The REPL still rolls back to its checkpoint.
@@ -1482,7 +1484,9 @@ module Samagotchi
1482
1484
 
1483
1485
  # Everything before the model is asked: :turn_started, the kernel's
1484
1486
  # per-turn settings, the plugins' setup, the session_start/before_turn
1485
- # hooks, then the messages to send (#turn_messages).
1487
+ # hooks, then the messages to send (#turn_messages). False when a Stop
1488
+ # came before the hooks (the turn ends canceled there; before the
1489
+ # load-failure warnings when it came during the probes), else true.
1486
1490
  def prepare_turn(turn, images)
1487
1491
  session = turn.session
1488
1492
  image_refs, image_error = turn_image_refs(session, images)
@@ -1506,6 +1510,10 @@ module Samagotchi
1506
1510
  # Before anything of the turn is kept or a reminder is used up.
1507
1511
  turn.settings = turn_settings(session)
1508
1512
  refuse_images!(turn.settings.vision) unless image_refs.empty?
1513
+ # A Stop during the probes: the turn ends before the load-failure
1514
+ # warnings, which show once, so the next turn still shows them.
1515
+ return false if turn.controller.cancelled?
1516
+
1509
1517
  announce_guardrail_failures(turn.on_event)
1510
1518
  # Plugins' slow setup that brings tools (an MCP server's first
1511
1519
  # start): the turn waits for it here, before the system prompt
@@ -1515,6 +1523,9 @@ module Samagotchi
1515
1523
  await_init_tasks(turn.controller, turn.on_event)
1516
1524
  # Plugins' tool sets that changed since the last turn.
1517
1525
  apply_staged_tools!
1526
+ # A Stop during the wait above: the turn ends here,
1527
+ # before the hooks run or a reminder is used up.
1528
+ return false if turn.controller.cancelled?
1518
1529
 
1519
1530
  # Fire :session_start on the very first turn
1520
1531
  if @first_turn
@@ -1528,9 +1539,22 @@ module Samagotchi
1528
1539
  messages: hook_messages(session.messages) })
1529
1540
 
1530
1541
  turn_messages(turn, image_refs)
1542
+ true
1531
1543
  end
1532
1544
  private :prepare_turn
1533
1545
 
1546
+ # A turn stopped before the model was asked (#prepare_turn returned
1547
+ # false): canceled with nothing kept, like a Ctrl-C there; the result
1548
+ # has no conversation, so a UI's checkpoint stands.
1549
+ def stopped_before_model(turn)
1550
+ reason = turn.controller.reason
1551
+ end_turn(turn, "canceled") do |seconds|
1552
+ [nil, { type: :turn_canceled, cancellation_reason: reason, duration_ms: (seconds * 1000).round }]
1553
+ end
1554
+ LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
1555
+ end
1556
+ private :stopped_before_model
1557
+
1534
1558
  # The turn's settings for the kernel (LLM::TurnSettings): vision,
1535
1559
  # sampling and thinking, the thinking resolved before the hooks run.
1536
1560
  # The model name is added in #generate, after the hooks that may switch
@@ -1541,7 +1565,8 @@ module Samagotchi
1541
1565
  thinking_target = @host_registry.resolve(@effective_model_name)
1542
1566
  @turn_thinking = [thinking_level(thinking_target), thinking_target]
1543
1567
  announce_thinking_level(*@turn_thinking)
1544
- LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil)
1568
+ LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil,
1569
+ window_setting: nil)
1545
1570
  end
1546
1571
  private :turn_settings
1547
1572
 
@@ -1586,7 +1611,7 @@ module Samagotchi
1586
1611
  # The turn's settings, set once here. The chat loop dispatches tools
1587
1612
  # through the kernel without its #run: tag those dumps with this
1588
1613
  # turn's model, not the last native one.
1589
- @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend)
1614
+ @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting)
1590
1615
 
1591
1616
  backend.complete(
1592
1617
  messages: turn.messages,
@@ -1640,7 +1665,8 @@ module Samagotchi
1640
1665
  end
1641
1666
  end
1642
1667
 
1643
- # Fire :after_turn hook (runs even on cancel/success), with a read-only
1668
+ # Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
1669
+ # ends the turn in #run_turn without it), with a read-only
1644
1670
  # copy of the conversation the turn stored, and event[:present] for
1645
1671
  # a display version of the answer (AnswerDisplay).
1646
1672
  session = turn.session
@@ -1651,7 +1677,7 @@ module Samagotchi
1651
1677
  @hooks.fire(:after_turn, after_turn)
1652
1678
  store_answer_display(session, display, pending: display_pending)
1653
1679
 
1654
- # Fire :session_end after every turn (turn-level lifecycle)
1680
+ # Fire :session_end after the turn (turn-level lifecycle), as :after_turn
1655
1681
  @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
1656
1682
  end
1657
1683
  private :complete_turn
@@ -1973,7 +1999,8 @@ module Samagotchi
1973
1999
  end
1974
2000
  begin
1975
2001
  Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: data[:hooks], registry: @hooks,
1976
- failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {})
2002
+ failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {},
2003
+ requires_chi: data[:requires_chi])
1977
2004
  rescue Exception => e
1978
2005
  Log.error(:hooks, "bundle_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' failed to load hooks: #{e.class}: #{e.message}", bundle: bundle_name, error: e.class.name)
1979
2006
  end
@@ -2218,7 +2245,17 @@ module Samagotchi
2218
2245
  adapter = @host_registry.adapter_for(target.entry)
2219
2246
  client = nil if adapter.remote?
2220
2247
  end
2221
- ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter)
2248
+ ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter,
2249
+ setting: ContextWindow.setting(target, names: model_lookup_names(target)))
2250
+ rescue StandardError
2251
+ nil
2252
+ end
2253
+
2254
+ # The effective model's models:/hosts: window_tokens, read per turn
2255
+ # (after the hooks that may switch the model).
2256
+ def turn_window_setting
2257
+ target = @host_registry.resolve(@effective_model_name)
2258
+ ContextWindow.setting(target, names: model_lookup_names(target))
2222
2259
  rescue StandardError
2223
2260
  nil
2224
2261
  end