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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../paths"
5
+ require_relative "../hooks/loader"
6
+ require_relative "approval"
7
+ require_relative "protected_paths"
8
+
9
+ module Samagotchi
10
+ module Guardrails
11
+ # What a parent agent may allow on an approval (guardrails.parent_approvals,
12
+ # config.yml only): off, a deny only; once, "Allow once" too, never a
13
+ # wider scope. `chi answer` checks it before it posts; the worker checks
14
+ # it again for an answer marked as chi answer's (CLIENT_ID), with its own
15
+ # config. A convention for an honest but eager parent, not a security
16
+ # boundary: any local process can post to the Bridge, with or without
17
+ # the marker.
18
+ #
19
+ # An option allows when Approval.settle would allow it: its index is
20
+ # below the approval's scopes. The scope is read by index, never by
21
+ # label. When the scopes are missing or don't fit the options, it fails
22
+ # closed: only a last option labelled Deny denies.
23
+ #
24
+ # Whatever the setting, an approval for a call on chi's own config dir
25
+ # (config.yml, memories/.bundles' rules), the hooks dir or the approval
26
+ # store is the user's alone (:protected): "Allow once" on a config.yml
27
+ # rewrite would be "allow everything" from then on.
28
+ module ParentApprovals
29
+ KEY = "guardrails.parent_approvals"
30
+ # The client id chi answer posts its answers with.
31
+ CLIENT_ID = "cli:answer"
32
+ # Set by chi's execute and task_create for the commands they run: a
33
+ # chi started there answers as a parent (#parent_process?).
34
+ PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
35
+ # Environment variables that say an agent runs this process: Claude
36
+ # Code's, the AI_AGENT convention, Codex CLI's (CODEX_THREAD_ID, which
37
+ # Codex sets on every command its shell tool runs), and chi's own.
38
+ # They survive a PTY wrapper (script, expect). The docs list them too
39
+ # (guardrails.md, sub-agent.md).
40
+ AGENT_MARKERS = ["CLAUDECODE", "AI_AGENT", "CODEX_THREAD_ID", PARENT_SESSION_ENV].freeze
41
+ # Rules that ask about chi's own config and hooks: the core
42
+ # ProtectedPaths asks and the guardrails bundle's shell rule.
43
+ PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi].freeze
44
+ # A shell command naming chi's config, hooks or guardrails (the
45
+ # guardrails bundle's shell-touches-chi, less .git/hooks).
46
+ CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles}
47
+
48
+ module_function
49
+
50
+ # Whether an answer typed into this process is a parent agent's (so
51
+ # this setting applies to it): stdin isn't a terminal (a pipe, a file,
52
+ # /dev/null), or an agent marker is set. A person at chi --attach or
53
+ # the REPL has a terminal and no marker.
54
+ def parent_process?(env: ENV, stdin: $stdin)
55
+ return true if AGENT_MARKERS.any? { |key| !env.fetch(key, "").to_s.strip.empty? }
56
+
57
+ !(stdin.respond_to?(:tty?) && stdin.tty?)
58
+ end
59
+
60
+ # This process's setting: "once" or "off" (anything else is off).
61
+ def setting
62
+ Config.get(KEY).to_s == "once" ? "once" : "off"
63
+ end
64
+
65
+ # Why a parent may not give this answer, or nil.
66
+ # @param pending [Hash] the pending question (symbol or string keys)
67
+ # @param indices [Array<Integer, nil>] the selected options' places
68
+ # @param setting [String, nil] "off" or "once"
69
+ # @return [Symbol, nil] :protected (chi's own config: the user's
70
+ # alone), :off (no allow at all), :once_only, or nil
71
+ def refusal(pending, indices, setting:)
72
+ return nil unless approval?(pending)
73
+
74
+ scopes = scopes(pending)
75
+ allows = Array(indices).reject { |index| deny?(pending, scopes, index) }
76
+ return nil if allows.empty?
77
+ return :protected if protected?(pending)
78
+ return :off unless setting.to_s == "once"
79
+ return nil if scopes && allows.all? { |index| index.is_a?(Integer) && index >= 0 && scopes[index] == "once" }
80
+
81
+ :once_only
82
+ end
83
+
84
+ # Whether +pending+ asks about a call on chi's own config, hooks,
85
+ # guardrail rules or approvals: by its rule, its paths or its command
86
+ # (the dirs as this process's environment and config.yml name them).
87
+ def protected?(pending)
88
+ facts = fetch(pending, :approval)
89
+ return false unless facts.is_a?(Hash)
90
+ return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
91
+ return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
92
+
93
+ dirs = chi_dirs
94
+ paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
95
+ return true if paths.any? { |path| dirs.any? { |dir| ProtectedPaths.within?(ProtectedPaths.real(path), dir) } }
96
+
97
+ command = fetch(facts, :command).to_s
98
+ return false if command.empty?
99
+
100
+ command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(ProtectedPaths.real(dir)) }
101
+ end
102
+
103
+ # chi's config dir, the hooks dir and the approval store's dir.
104
+ def chi_dirs
105
+ hooks = begin Hooks::Loader.hooks_dir rescue nil end
106
+ [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails")]
107
+ .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
108
+ end
109
+
110
+ # The refusal as a line for the parent: deny, and tell the user.
111
+ # Never how to allow it (the web, chi --attach): that is the user's.
112
+ # @param reason [Symbol] what #refusal returned
113
+ # @param typed [Boolean] the answer was typed at a ? prompt (the
114
+ # REPL), not given to chi answer
115
+ def message(reason, typed: false)
116
+ deny = typed ? "deny it (n; WHY), and tell your user" : "deny it (--option Deny --text WHY), and tell your user"
117
+ case reason
118
+ when :protected then "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{deny}"
119
+ when :off then "allowing a tool call is up to the user: #{deny}"
120
+ else "only Allow once (#{KEY}: once) can be given here: #{deny}"
121
+ end
122
+ end
123
+
124
+ # An approval: its kind says so, or it carries approval facts.
125
+ def approval?(pending)
126
+ fetch(pending, :kind).to_s == Approval::KIND || !fetch(pending, :approval).nil?
127
+ end
128
+
129
+ # The offered scopes, in option order, or nil when they are missing,
130
+ # empty, not strings, or leave no option after them for Deny.
131
+ def scopes(pending)
132
+ facts = fetch(pending, :approval)
133
+ scopes = facts.is_a?(Hash) ? fetch(facts, :scopes) : nil
134
+ return nil unless scopes.is_a?(Array) && !scopes.empty? && scopes.all?(String)
135
+ return nil unless scopes.size < options(pending).size
136
+
137
+ scopes
138
+ end
139
+
140
+ def deny?(pending, scopes, index)
141
+ return false unless index.is_a?(Integer) && index >= 0
142
+
143
+ options = options(pending)
144
+ return index >= scopes.size && index < options.size if scopes
145
+
146
+ index == options.size - 1 && options[index].to_s == Approval::DENY
147
+ end
148
+
149
+ def options(pending) = Array(fetch(pending, :options))
150
+
151
+ def fetch(hash, key)
152
+ return nil unless hash.is_a?(Hash)
153
+
154
+ hash.key?(key) ? hash[key] : hash[key.to_s]
155
+ end
156
+ end
157
+ end
158
+ end
@@ -4,6 +4,8 @@ require_relative "registry"
4
4
  require "digest"
5
5
  require_relative "../log"
6
6
  require_relative "../memory_bundle/provenance"
7
+ require_relative "../memory_bundle/manifest"
8
+ require_relative "../version"
7
9
 
8
10
  module Samagotchi
9
11
  # Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
@@ -28,7 +30,8 @@ module Samagotchi
28
30
  # fails to load, or its file's sha256 differs from the one recorded at
29
31
  # install, the failure is reported and the gate denies every tool call.
30
32
  # Any hook whose sha256 differs is not loaded (reinstall the bundle
31
- # after editing a hook by hand).
33
+ # after editing a hook by hand), nor any of a bundle whose requires_chi
34
+ # this chi doesn't meet.
32
35
  #
33
36
  # The +on_error+ policy is applied per hook at fire time (Hooks.wrap):
34
37
  # - "fail_closed" (on :before_tool_call): a raising guardrail denies
@@ -48,10 +51,14 @@ module Samagotchi
48
51
  # @param settings [Hash] the bundle's section of config.yml
49
52
  # `bundles:` (string keys); a hook class whose initialize takes an
50
53
  # argument gets it
54
+ # @param requires_chi [String, nil] the bundle's requirement; when
55
+ # this chi doesn't meet it no hook loads (each is reported, as the
56
+ # plugin loader does)
51
57
  # @return [Integer] number of hooks successfully registered
52
- def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {})
58
+ def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {}, requires_chi: nil)
53
59
  return 0 unless metadata.is_a?(Hash)
54
60
 
61
+ too_old = MemoryBundle::Manifest.requires_chi_failure(requires_chi, Samagotchi::VERSION)
55
62
  loaded = 0
56
63
  metadata.each do |raw_basename, raw_meta|
57
64
  basename = raw_basename.to_s
@@ -72,7 +79,7 @@ module Samagotchi
72
79
  failures&.add(what, "the file is missing", required: true) if fail_closed
73
80
  next
74
81
  end
75
- if (mismatch = sha_mismatch(file, meta.(:sha256), required: fail_closed))
82
+ if (mismatch = too_old || sha_mismatch(file, meta.(:sha256), required: fail_closed))
76
83
  Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
77
84
  failures&.add(what, mismatch, required: fail_closed)
78
85
  next
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
3
4
  require_relative "config"
4
5
  require_relative "model_ref"
5
6
  require_relative "client"
@@ -21,7 +22,6 @@ module Samagotchi
21
22
  class HostRegistry
22
23
  CACHE_TTL_SECONDS = 60
23
24
  REMOTE_CACHE_TTL_SECONDS = 600
24
- LIST_TIMEOUT_SECONDS = 3
25
25
  # Seconds a remote host's stream may take to show something (a queued
26
26
  # free model on OpenRouter can send only keep-alives for minutes).
27
27
  REMOTE_FIRST_TOKEN_TIMEOUT = 120
@@ -32,9 +32,12 @@ module Samagotchi
32
32
  # first_token_timeout: the configured first-token limit (see #first_token_limit);
33
33
  # vision: the configured true/false (VisionSupport), nil when unset;
34
34
  # sampling: the configured request parameters (SamplingSettings), nil when unset;
35
- # thinking: the configured level (Thinking), nil when unset.
35
+ # thinking: the configured level (Thinking), nil when unset;
36
+ # remote: the configured true/false (#remote?), nil when unset;
37
+ # window_tokens: the configured context window (ContextWindow.setting), nil when unset.
36
38
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
37
- :first_token_timeout, :vision, :sampling, :thinking, keyword_init: true) do
39
+ :first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
40
+ keyword_init: true) do
38
41
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
39
42
  # raw-prompt loop.
40
43
  def chat? = api == :openai
@@ -46,9 +49,12 @@ module Samagotchi
46
49
  # configured, else the root's /v1.
47
50
  def openai_base_url = url || "#{root_url}/v1"
48
51
 
49
- # A provider on the network rather than a local server: it needs a key
50
- # or speaks https. Its model list is cached longer.
51
- def remote? = !api_key_env.to_s.empty? || scheme == "https"
52
+ # A provider on the network rather than a local server: hosts.<name>.remote
53
+ # when set, else by its address (HostRegistry.remote_address?). An API
54
+ # key doesn't decide: a llama.cpp on the LAN can have one. A remote
55
+ # host's model list is cached longer, it gets a first-token limit, and
56
+ # it isn't asked for llama.cpp's /props.
57
+ def remote? = remote.nil? ? HostRegistry.remote_address?(scheme, host) : remote
52
58
 
53
59
  def models_ttl = remote? ? REMOTE_CACHE_TTL_SECONDS : CACHE_TTL_SECONDS
54
60
 
@@ -90,7 +96,8 @@ module Samagotchi
90
96
  entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
91
97
  api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
92
98
  profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
93
- vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking])
99
+ vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking],
100
+ remote: cfg[:remote], window_tokens: cfg[:window_tokens])
94
101
  entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
95
102
  first_token_timeout: entry.first_token_limit, name: entry.name,
96
103
  api_key_env: entry.api_key_env, env: env)
@@ -114,6 +121,23 @@ module Samagotchi
114
121
  @entries
115
122
  end
116
123
 
124
+ # Loopback, private (RFC 1918, IPv6 unique local) and link-local nets.
125
+ LOCAL_NETS = %w[127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10
126
+ ::1/128 fc00::/7 fe80::/10].map { |net| IPAddr.new(net) }.freeze
127
+
128
+ # Whether an address is remote: https, or an http IP address outside
129
+ # the local nets (LOCAL_NETS). An http host named by a name (localhost,
130
+ # box, mac.local, gpu.lan) counts as local: chi doesn't look names up.
131
+ def self.remote_address?(scheme, host)
132
+ return true if scheme.to_s == "https"
133
+
134
+ address = IPAddr.new(host.to_s.delete_prefix("[").delete_suffix("]"))
135
+ address = address.native if address.ipv4_mapped?
136
+ LOCAL_NETS.none? { |net| net.family == address.family && net.include?(address) }
137
+ rescue IPAddr::Error
138
+ false
139
+ end
140
+
117
141
  # server.first_token_timeout, or nil when unset or unreadable.
118
142
  def self.configured_first_token_timeout
119
143
  Config.get("server.first_token_timeout")
@@ -271,7 +271,7 @@ module Samagotchi
271
271
  def prepare_request(turn)
272
272
  prompt, images = format_prompt(turn)
273
273
  image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
274
- window = ContextWindow.resolve(client: @client, model: turn.model_name)
274
+ window = ContextWindow.resolve(client: @client, model: turn.model_name, setting: @turn_settings.window_setting)
275
275
  emit_context_status_event(turn.on_stream_event, turn.context, prompt, iteration_index: turn.iteration - 1,
276
276
  window: window, image_tokens: image_tokens)
277
277
  if (line = turn.context.take_guidance)
@@ -172,11 +172,13 @@ module Samagotchi
172
172
 
173
173
  # The window for +model+: the kernel's client probes the server it
174
174
  # points at (Engine keeps it on the effective model's host; a local
175
- # llama.cpp answers /props), then the host's model list, config, env
176
- # and the default. A remote provider has no /props to probe.
175
+ # llama.cpp answers /props), then the host's model list, the model's
176
+ # or host's window_tokens, config, env and the default. A remote
177
+ # provider has no /props to probe.
177
178
  def context_window(model)
178
179
  client = @adapter.remote? ? nil : @kernel.client
179
- ContextWindow.resolve(client: client, model: model, adapter: @adapter)
180
+ ContextWindow.resolve(client: client, model: model, adapter: @adapter,
181
+ setting: (@kernel.turn_settings&.window_setting if @kernel.respond_to?(:turn_settings)))
180
182
  rescue StandardError
181
183
  nil
182
184
  end
@@ -245,12 +245,19 @@ module Samagotchi
245
245
  function = call["function"] || {}
246
246
  ToolCall.new(id: call["id"], name: function["name"].to_s, arguments: self.class.parse_arguments(function["arguments"]))
247
247
  end
248
- ChatResponse.new(text: message["content"].to_s,
248
+ ChatResponse.new(text: OpenAIChat.drop_leading_newlines(message["content"].to_s),
249
249
  reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
250
250
  tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
251
251
  finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body))
252
252
  end
253
253
 
254
+ # Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
255
+ # writes after </think> at the start of the answer
256
+ # (https://github.com/incoai/splash/issues/254). Remove once Splash
257
+ # strips it; llama.cpp already does. Streamed, it applies to the chunks
258
+ # until the first text.
259
+ def self.drop_leading_newlines(text) = text.sub(/\A[\r\n]+/, "")
260
+
254
261
  def self.served_model(payload)
255
262
  model = payload.is_a?(Hash) ? payload["model"] : nil
256
263
  model.is_a?(String) && !model.strip.empty? ? model : nil
@@ -372,6 +379,7 @@ module Samagotchi
372
379
  @finish_reason = choice["finish_reason"] if choice["finish_reason"]
373
380
  delta = choice["delta"].is_a?(Hash) ? choice["delta"] : {}
374
381
  content = delta["content"].to_s
382
+ content = OpenAIChat.drop_leading_newlines(content) if @text.empty?
375
383
  reasoning = (delta["reasoning_content"] || delta["reasoning"]).to_s
376
384
  @text << content
377
385
  @reasoning << reasoning
@@ -8,12 +8,14 @@ module Samagotchi
8
8
  # ChatLoop) and ToolRunner: the turn's VisionContext (nil sends no
9
9
  # images, placeholders instead), its request parameters
10
10
  # (SamplingSettings.for; empty sends none), its thinking level
11
- # (Thinking.resolve) and the bare model id the turn runs on (every
12
- # debug dump is tagged with it). The Engine sets them once per turn,
13
- # in Engine#generate, after the model's host is synced.
14
- TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name) do
11
+ # (Thinking.resolve), the bare model id the turn runs on (every debug
12
+ # dump is tagged with it) and the model's or host's configured context
13
+ # window (ContextWindow.setting, nil for none). The Engine sets them
14
+ # once per turn, in Engine#generate, after the model's host is synced.
15
+ TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting) do
15
16
  # No vision, no sampling, the default thinking, no model name.
16
- def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil)
17
+ def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil,
18
+ window_setting: nil)
17
19
  end
18
20
  end
19
21
  end
@@ -265,6 +265,7 @@ module Samagotchi
265
265
  # ── Plugin: <file> into <bundle_dir>/plugin/ (docs/plugins.md) ────
266
266
  # Like the rules, the bundle's plugin replaces an earlier one.
267
267
  plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
268
+ warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
268
269
 
269
270
  # Files the previous version shipped and this one doesn't.
270
271
  if @upgrade && existing_provenance && existing_provenance[:files]
@@ -467,6 +468,17 @@ module Samagotchi
467
468
  end
468
469
  end
469
470
 
471
+ # The hook loader skips every hook of a bundle whose requires_chi this
472
+ # chi doesn't meet (Hooks::BundleLoader); say so at install, as
473
+ # install_plugin does for the plugin.
474
+ def warn_hooks_requires_chi(manifest, hook_files)
475
+ return if manifest.nil? || hook_files.empty?
476
+
477
+ if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
478
+ @warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
479
+ end
480
+ end
481
+
470
482
  # Copy the manifest's plugin file into the bundle's plugin/ dir,
471
483
  # warning when its sha256 differs from the declared one or this chi
472
484
  # doesn't meet requires_chi (the Engine then won't load it).
@@ -43,7 +43,15 @@ module Samagotchi
43
43
  }
44
44
  end
45
45
  { provenance: data, scope: scope, target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
46
- needs: needs_status(data) }
46
+ hooks_requires_failure: hooks_requires_failure(data), needs: needs_status(data) }
47
+ end
48
+
49
+ # Why none of the bundle's hooks load (this chi doesn't meet its
50
+ # requires_chi, Hooks::BundleLoader), or nil: it has no hooks or meets it.
51
+ def self.hooks_requires_failure(data)
52
+ return nil if (data[:hooks] || {}).empty?
53
+
54
+ Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION)
47
55
  end
48
56
 
49
57
  # The stored needs, each with found: from this process's PATH (the
@@ -51,6 +51,14 @@ module Samagotchi
51
51
  File.basename(common) == ".git" ? File.dirname(common) : common
52
52
  end
53
53
 
54
+ # The top of the git work tree holding +cwd+: the directory with the
55
+ # nearest .git (a linked worktree's own checkout, unlike #project_root);
56
+ # nil outside any repository.
57
+ def work_tree_root(cwd = Dir.pwd)
58
+ dot_git = find_dot_git(cwd)
59
+ dot_git && File.dirname(dot_git)
60
+ end
61
+
54
62
  # Is +cwd+ inside a git repository (a .git somewhere above it)?
55
63
  def in_repo?(cwd = Dir.pwd)
56
64
  !find_dot_git(cwd).nil?
@@ -18,11 +18,18 @@ module Samagotchi
18
18
  # so every surface that reports a missing model reports it the same way).
19
19
  UnknownHost = Class.new(MissingModel)
20
20
 
21
+ # Gemma 4 thinks in a channel: `<|channel>thought` … `<channel|>`. The
22
+ # one source for the parser, the stream splitter, the literal guard and
23
+ # the web's saved-message reader.
24
+ GEMMA_THOUGHT_CHANNEL_OPEN = "<|channel>thought"
25
+ GEMMA_THOUGHT_CHANNEL_CLOSE = "<channel|>"
26
+
21
27
  attr_reader :name, :turn_start, :turn_end,
22
28
  :tool_call_open, :tool_call_close,
23
29
  :tool_response_open, :tool_response_close,
24
30
  :string_delim,
25
31
  :thought_open, :thought_close,
32
+ :thought_channel_open, :thought_channel_close,
26
33
  :system_prefix, :user_prefix, :assistant_prefix,
27
34
  :model_prefix,
28
35
  :stop_sequences, :tool_decl_format,
@@ -39,6 +46,9 @@ module Samagotchi
39
46
  @string_delim = config[:string_delim]
40
47
  @thought_open = config[:thought_open]
41
48
  @thought_close = config[:thought_close]
49
+ # A thinking block besides thought_open's (Gemma's channel), or nil.
50
+ @thought_channel_open = config[:thought_channel_open]
51
+ @thought_channel_close = config[:thought_channel_close]
42
52
  @system_prefix = config[:system_prefix]
43
53
  @user_prefix = config[:user_prefix]
44
54
  @assistant_prefix = config[:assistant_prefix]
@@ -68,6 +78,8 @@ module Samagotchi
68
78
  string_delim: '<|"|>',
69
79
  thought_open: "<|think|>",
70
80
  thought_close: nil,
81
+ thought_channel_open: GEMMA_THOUGHT_CHANNEL_OPEN,
82
+ thought_channel_close: GEMMA_THOUGHT_CHANNEL_CLOSE,
71
83
  system_prefix: "",
72
84
  user_prefix: "",
73
85
  assistant_prefix: "",
@@ -131,8 +143,9 @@ module Samagotchi
131
143
  # qualified with a host that isn't configured, instead of sending the
132
144
  # whole ref to the default host as a model id, and when an alias after
133
145
  # a host prefix names another host ("openrouter:tiny" with tiny:
134
- # box:…). Where a model comes in (an Engine starting or switching, a
135
- # spawned session) calls it.
146
+ # box:…), or with a host config.yml has with enabled: false. Where a
147
+ # model comes in (an Engine starting or switching, a spawned session)
148
+ # calls it.
136
149
  # @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
137
150
  # entries); config.yml's by default
138
151
  # @return [String] +model_name+
@@ -145,6 +158,9 @@ module Samagotchi
145
158
  "use #{parsed.alias_name} or #{other}:#{parsed.alias_name}"
146
159
  end
147
160
  ref = parsed.ref
161
+ if !parsed.host_name && (disabled = disabled_host_prefix(ref, hosts, env))
162
+ raise UnknownHost, "host '#{disabled}' is disabled (enabled: false in config.yml)"
163
+ end
148
164
  warn_host_slash(ref, hosts) unless parsed.host_name
149
165
  host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
150
166
  return model_name unless host
@@ -155,6 +171,18 @@ module Samagotchi
155
171
  raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(', ')}"
156
172
  end
157
173
 
174
+ # The prefix of "box:x" when box is a host config.yml has with
175
+ # enabled: false (left out of +hosts+), else nil.
176
+ def self.disabled_host_prefix(ref, hosts, env)
177
+ prefix, rest = ref.to_s.split(":", 2)
178
+ return nil if rest.to_s.strip.empty?
179
+
180
+ prefix = prefix.strip.downcase
181
+ return nil if hosts.keys.any? { |k| k.to_s.downcase == prefix }
182
+
183
+ Samagotchi::ConfigFile.disabled_host_names(env: env).include?(prefix) ? prefix : nil
184
+ end
185
+
158
186
  # "box/x" named host box until '/' stopped naming a host: a saved
159
187
  # session or a hand-written ref says so once, then goes to the default host.
160
188
  def self.warn_host_slash(ref, hosts)
@@ -193,10 +221,6 @@ module Samagotchi
193
221
  DEFAULT_NAME
194
222
  end
195
223
 
196
- def self.from_env
197
- from_model_name(required_model_name(nil))
198
- end
199
-
200
224
  # ── Resolution: which profile a model gets ───────────────────────────
201
225
 
202
226
  NAMES = %w[qwen36 gemma4].freeze
@@ -328,16 +352,6 @@ module Samagotchi
328
352
  end
329
353
  private_class_method :probe?
330
354
 
331
- # ── Thought channel support ──────────────────────────────────────────
332
-
333
- def thought_channel_open
334
- nil
335
- end
336
-
337
- def uses_channel_thoughts?
338
- false
339
- end
340
-
341
355
  def uses_role_prefixes?
342
356
  !system_prefix.empty? && !user_prefix.empty?
343
357
  end