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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -1
- data/README.md +20 -7
- data/bin/chi +9 -9
- data/docs/architecture.md +4 -2
- data/docs/cli.md +34 -10
- data/docs/configuration.md +79 -15
- data/docs/desktop.md +10 -6
- data/docs/guardrails.md +74 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +31 -16
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge/card_store.rb +94 -6
- data/lib/samagotchi/bridge.rb +14 -2
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +81 -24
- data/lib/samagotchi/context_window.rb +30 -5
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +54 -17
- data/lib/samagotchi/guardrails/parent_approvals.rb +158 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +31 -7
- data/lib/samagotchi/kernel_loop.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -3
- data/lib/samagotchi/llm/openai_chat.rb +9 -1
- data/lib/samagotchi/llm/turn_settings.rb +7 -5
- data/lib/samagotchi/memory_bundle/installer.rb +12 -0
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_profile.rb +30 -16
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/send_command.rb +50 -75
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_manager.rb +40 -7
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +11 -3
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +53 -14
- data/lib/samagotchi/terminal_ui.rb +30 -3
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +14 -2
- data/lib/samagotchi/tools/delegate_wait.rb +32 -13
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/memory.rb +3 -3
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/utf8_default.rb +29 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -10
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +6 -1
- data/lib/samagotchi/web/public/app.js +39 -6
- data/lib/samagotchi/web/public/index.html +8 -1
- data/lib/samagotchi/web/public/notify.js +16 -3
- data/lib/samagotchi/web/public/question_cards.js +3 -1
- data/lib/samagotchi/web/public/sessions_list.js +37 -0
- data/lib/samagotchi/web/public/turn_view.js +6 -9
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +3 -11
- 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
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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
|
-
|
|
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[
|
|
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
|
|
185
|
-
|
|
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}:
|
|
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
|
|
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
|
-
|
|
730
|
-
|
|
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
|
|
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.
|
|
13
|
-
#
|
|
14
|
-
# 4.
|
|
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, :
|
|
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
|
-
|
|
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
|
|
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
|
|
19
|
-
|
|
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
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|