samagotchi 0.12.0 → 0.13.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +20 -7
  4. data/bin/chi +5 -2
  5. data/docs/architecture.md +4 -2
  6. data/docs/cli.md +34 -10
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +10 -6
  9. data/docs/guardrails.md +73 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +4 -4
  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.rb +11 -1
  17. data/lib/samagotchi/bridge_client.rb +8 -3
  18. data/lib/samagotchi/bundle_command.rb +18 -10
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/cli/command.rb +3 -2
  24. data/lib/samagotchi/cli/exit.rb +23 -0
  25. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  26. data/lib/samagotchi/config.rb +50 -18
  27. data/lib/samagotchi/desktop_command.rb +5 -3
  28. data/lib/samagotchi/engine.rb +40 -14
  29. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  30. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  31. data/lib/samagotchi/host_registry.rb +0 -1
  32. data/lib/samagotchi/llm/openai_chat.rb +9 -1
  33. data/lib/samagotchi/memory_bundle/installer.rb +12 -0
  34. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  35. data/lib/samagotchi/memory_paths.rb +8 -0
  36. data/lib/samagotchi/model_profile.rb +30 -16
  37. data/lib/samagotchi/parent_report.rb +213 -0
  38. data/lib/samagotchi/plugin/api.rb +15 -2
  39. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  40. data/lib/samagotchi/question_desk.rb +29 -2
  41. data/lib/samagotchi/recap_setup.rb +15 -5
  42. data/lib/samagotchi/reply_wait.rb +5 -1
  43. data/lib/samagotchi/send_command.rb +50 -75
  44. data/lib/samagotchi/session.rb +15 -0
  45. data/lib/samagotchi/session_manager.rb +34 -7
  46. data/lib/samagotchi/sessions_command.rb +13 -3
  47. data/lib/samagotchi/system_prompt.rb +10 -2
  48. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  49. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  50. data/lib/samagotchi/terminal_ui.rb +15 -1
  51. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  52. data/lib/samagotchi/tool_call_parser.rb +5 -6
  53. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  54. data/lib/samagotchi/tools/builtins.rb +14 -2
  55. data/lib/samagotchi/tools/delegate_wait.rb +16 -4
  56. data/lib/samagotchi/tools/execute.rb +8 -8
  57. data/lib/samagotchi/tools/task_create.rb +3 -2
  58. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +4 -3
  61. data/lib/samagotchi/web/message_parts.rb +2 -1
  62. data/lib/samagotchi/web/server.rb +2 -1
  63. data/lib/samagotchi/web/session_summary.rb +3 -11
  64. metadata +7 -1
@@ -0,0 +1,155 @@
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, and chi's own. They survive a PTY wrapper (script, expect).
37
+ AGENT_MARKERS = ["CLAUDECODE", "AI_AGENT", PARENT_SESSION_ENV].freeze
38
+ # Rules that ask about chi's own config and hooks: the core
39
+ # ProtectedPaths asks and the guardrails bundle's shell rule.
40
+ PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi].freeze
41
+ # A shell command naming chi's config, hooks or guardrails (the
42
+ # guardrails bundle's shell-touches-chi, less .git/hooks).
43
+ CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles}
44
+
45
+ module_function
46
+
47
+ # Whether an answer typed into this process is a parent agent's (so
48
+ # this setting applies to it): stdin isn't a terminal (a pipe, a file,
49
+ # /dev/null), or an agent marker is set. A person at chi --attach or
50
+ # the REPL has a terminal and no marker.
51
+ def parent_process?(env: ENV, stdin: $stdin)
52
+ return true if AGENT_MARKERS.any? { |key| !env.fetch(key, "").to_s.strip.empty? }
53
+
54
+ !(stdin.respond_to?(:tty?) && stdin.tty?)
55
+ end
56
+
57
+ # This process's setting: "once" or "off" (anything else is off).
58
+ def setting
59
+ Config.get(KEY).to_s == "once" ? "once" : "off"
60
+ end
61
+
62
+ # Why a parent may not give this answer, or nil.
63
+ # @param pending [Hash] the pending question (symbol or string keys)
64
+ # @param indices [Array<Integer, nil>] the selected options' places
65
+ # @param setting [String, nil] "off" or "once"
66
+ # @return [Symbol, nil] :protected (chi's own config: the user's
67
+ # alone), :off (no allow at all), :once_only, or nil
68
+ def refusal(pending, indices, setting:)
69
+ return nil unless approval?(pending)
70
+
71
+ scopes = scopes(pending)
72
+ allows = Array(indices).reject { |index| deny?(pending, scopes, index) }
73
+ return nil if allows.empty?
74
+ return :protected if protected?(pending)
75
+ return :off unless setting.to_s == "once"
76
+ return nil if scopes && allows.all? { |index| index.is_a?(Integer) && index >= 0 && scopes[index] == "once" }
77
+
78
+ :once_only
79
+ end
80
+
81
+ # Whether +pending+ asks about a call on chi's own config, hooks,
82
+ # guardrail rules or approvals: by its rule, its paths or its command
83
+ # (the dirs as this process's environment and config.yml name them).
84
+ def protected?(pending)
85
+ facts = fetch(pending, :approval)
86
+ return false unless facts.is_a?(Hash)
87
+ return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
88
+ return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
89
+
90
+ dirs = chi_dirs
91
+ paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
92
+ return true if paths.any? { |path| dirs.any? { |dir| ProtectedPaths.within?(ProtectedPaths.real(path), dir) } }
93
+
94
+ command = fetch(facts, :command).to_s
95
+ return false if command.empty?
96
+
97
+ command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(ProtectedPaths.real(dir)) }
98
+ end
99
+
100
+ # chi's config dir, the hooks dir and the approval store's dir.
101
+ def chi_dirs
102
+ hooks = begin Hooks::Loader.hooks_dir rescue nil end
103
+ [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails")]
104
+ .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
105
+ end
106
+
107
+ # The refusal as a line for the parent: deny, and tell the user.
108
+ # Never how to allow it (the web, chi --attach): that is the user's.
109
+ # @param reason [Symbol] what #refusal returned
110
+ # @param typed [Boolean] the answer was typed at a ? prompt (the
111
+ # REPL), not given to chi answer
112
+ def message(reason, typed: false)
113
+ deny = typed ? "deny it (n; WHY), and tell your user" : "deny it (--option Deny --text WHY), and tell your user"
114
+ case reason
115
+ when :protected then "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{deny}"
116
+ when :off then "allowing a tool call is up to the user: #{deny}"
117
+ else "only Allow once (#{KEY}: once) can be given here: #{deny}"
118
+ end
119
+ end
120
+
121
+ # An approval: its kind says so, or it carries approval facts.
122
+ def approval?(pending)
123
+ fetch(pending, :kind).to_s == Approval::KIND || !fetch(pending, :approval).nil?
124
+ end
125
+
126
+ # The offered scopes, in option order, or nil when they are missing,
127
+ # empty, not strings, or leave no option after them for Deny.
128
+ def scopes(pending)
129
+ facts = fetch(pending, :approval)
130
+ scopes = facts.is_a?(Hash) ? fetch(facts, :scopes) : nil
131
+ return nil unless scopes.is_a?(Array) && !scopes.empty? && scopes.all?(String)
132
+ return nil unless scopes.size < options(pending).size
133
+
134
+ scopes
135
+ end
136
+
137
+ def deny?(pending, scopes, index)
138
+ return false unless index.is_a?(Integer) && index >= 0
139
+
140
+ options = options(pending)
141
+ return index >= scopes.size && index < options.size if scopes
142
+
143
+ index == options.size - 1 && options[index].to_s == Approval::DENY
144
+ end
145
+
146
+ def options(pending) = Array(fetch(pending, :options))
147
+
148
+ def fetch(hash, key)
149
+ return nil unless hash.is_a?(Hash)
150
+
151
+ hash.key?(key) ? hash[key] : hash[key.to_s]
152
+ end
153
+ end
154
+ end
155
+ 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
@@ -21,7 +21,6 @@ module Samagotchi
21
21
  class HostRegistry
22
22
  CACHE_TTL_SECONDS = 60
23
23
  REMOTE_CACHE_TTL_SECONDS = 600
24
- LIST_TIMEOUT_SECONDS = 3
25
24
  # Seconds a remote host's stream may take to show something (a queued
26
25
  # free model on OpenRouter can send only keep-alives for minutes).
27
26
  REMOTE_FIRST_TOKEN_TIMEOUT = 120
@@ -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
@@ -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
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+ require "json"
5
+
6
+ require_relative "cli/exit"
7
+
8
+ module Samagotchi
9
+ # How a wait's end (ReplyWait::Result) reads to whoever runs chi as a
10
+ # sub-agent: `chi send --wait` and `chi answer`. One JSON object for
11
+ # --format json, or a line (a block for a question) for stderr, and the
12
+ # exit status. A question carries everything needed to answer it, so a
13
+ # parent agent can answer from the output alone. Kept apart from the
14
+ # commands so a later delegate_answer tool reports the same shape.
15
+ module ParentReport
16
+ # The approval facts a parent needs; the rest (preview, branch) is the
17
+ # card's.
18
+ APPROVAL_KEYS = %i[tool label command paths args cwd rule source reason scopes].freeze
19
+
20
+ # stderr that keeps its last line: with --format json a failure before
21
+ # any wait is reported from it (#error_line).
22
+ class LastLine < SimpleDelegator
23
+ attr_reader :last
24
+
25
+ def puts(*lines)
26
+ @last = lines.last.to_s.chomp unless lines.empty?
27
+ __getobj__.puts(*lines)
28
+ end
29
+ end
30
+
31
+ module_function
32
+
33
+ # @param result [ReplyWait::Result]
34
+ # @return [String] answered, question, failed, canceled, no_answer,
35
+ # error, worker_gone, stopped or running
36
+ def status(result)
37
+ case result.status
38
+ when :done then "answered"
39
+ when :waiting_for_answer then "question"
40
+ when :no_reply
41
+ case result.outcome
42
+ when "failed" then "failed"
43
+ when "canceled" then "canceled"
44
+ else "no_answer"
45
+ end
46
+ when :error then "error"
47
+ when :worker_gone then "worker_gone"
48
+ when :stopped then "stopped"
49
+ # ReplyWait's own :canceled is the caller giving up (its cancelled:),
50
+ # and :timeout the deadline: the turn goes on either way.
51
+ else "running"
52
+ end
53
+ end
54
+
55
+ # @return [Integer] 0 answered, 3 a question waits, 4 still running,
56
+ # 1 anything else
57
+ def exit_status(result)
58
+ case status(result)
59
+ when "answered" then CLI::Exit::OK
60
+ when "question" then CLI::Exit::QUESTION
61
+ when "running" then CLI::Exit::RUNNING
62
+ else CLI::Exit::FAILED
63
+ end
64
+ end
65
+
66
+ # @param session_id [String]
67
+ # @param timeout [Numeric, nil] the wait's --timeout (for the running detail)
68
+ # @return [Hash] the --format json object
69
+ def json(result, session_id:, timeout: nil)
70
+ report = { status: status(result), session_id: session_id }
71
+ case report[:status]
72
+ when "answered" then report[:text] = result.text.to_s
73
+ when "question"
74
+ question = question(result.question)
75
+ report[:question] = question
76
+ report[:answer_with] = answer_with(session_id, question)
77
+ else report[:detail] = detail(result, session_id: session_id, timeout: timeout)
78
+ end
79
+ report
80
+ end
81
+
82
+ # @return [String] the JSON object as one line
83
+ def json_line(result, session_id:, timeout: nil)
84
+ JSON.generate(json(result, session_id: session_id, timeout: timeout))
85
+ end
86
+
87
+ # Print a wait's end the way the commands do: the JSON object on
88
+ # stdout, or the reply on stdout and anything else on stderr (a
89
+ # question in full) after "chi send: ".
90
+ # @param command [String] "chi send", "chi answer"
91
+ # @return [Integer] the exit status
92
+ def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil)
93
+ if json
94
+ stdout.puts(json_line(result, session_id: session_id, timeout: timeout))
95
+ elsif result.status == :done
96
+ stdout.puts(result.text)
97
+ else
98
+ stdout.flush
99
+ text = if result.status == :waiting_for_answer
100
+ question_text(result.question, session_id: session_id)
101
+ else
102
+ "#{detail(result, session_id: session_id, timeout: timeout)}\n"
103
+ end
104
+ stderr.print("#{command}: #{text}")
105
+ end
106
+ stdout.flush
107
+ exit_status(result)
108
+ end
109
+
110
+ # A failure before any wait (no such session, a busy one, a message
111
+ # that didn't go in) as the JSON object.
112
+ # @param session_id [String, nil]
113
+ def error_line(detail, session_id: nil)
114
+ JSON.generate({ status: "error", session_id: session_id, detail: detail.to_s })
115
+ end
116
+
117
+ # A pending question (Session#pending_question) as a parent sees it.
118
+ # kind is "question" for the model's own (the desk stores none),
119
+ # "hook" or "approval".
120
+ # @return [Hash]
121
+ def question(pending)
122
+ pending ||= {}
123
+ approval = pending[:approval].is_a?(Hash) ? symbolize(pending[:approval]) : nil
124
+ {
125
+ id: pending[:id].to_s,
126
+ kind: (pending[:kind] || "question").to_s,
127
+ header: blank(pending[:header]),
128
+ text: pending[:question].to_s,
129
+ options: Array(pending[:options]).map(&:to_s),
130
+ multi_select: !!pending[:multi_select],
131
+ allow_freeform: !!pending[:allow_freeform],
132
+ approval: approval&.slice(*APPROVAL_KEYS)
133
+ }.compact
134
+ end
135
+
136
+ # The command that answers +question+; N is the parent's to fill in.
137
+ # An approval's is a deny: allowing it is the user's
138
+ # (Guardrails::ParentApprovals).
139
+ def answer_with(session_id, question)
140
+ return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
141
+
142
+ "chi answer #{session_id} --question #{question[:id]} --option N"
143
+ end
144
+
145
+ # What a parent does with an approval.
146
+ DENY_AND_TELL = "deny it, and tell your user"
147
+
148
+ def approval?(question)
149
+ question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "approval"
150
+ end
151
+
152
+ # What a wait that ended without an answer says, one line.
153
+ def detail(result, session_id:, timeout: nil)
154
+ attach = "chi --attach #{session_id}"
155
+ case result.status
156
+ when :waiting_for_answer
157
+ if approval?(result.question)
158
+ "waiting for an approval: #{first_line(result.question[:question])}; #{DENY_AND_TELL}"
159
+ else
160
+ "waiting for an answer: #{first_line(result.question&.dig(:question))}; open it: #{attach} or the web"
161
+ end
162
+ when :no_reply then "#{no_reply_line(result)}; #{attach} shows it"
163
+ when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
164
+ when :worker_gone then "the worker is gone; #{attach} shows what happened"
165
+ when :stopped then "the session was stopped (chi sessions stop)"
166
+ else timeout ? "still running after #{format("%g", timeout)} s: #{attach}" : "still running: #{attach}"
167
+ end
168
+ end
169
+
170
+ # The whole question for stderr: header, text, numbered options, what
171
+ # else it takes, and the commands that answer it.
172
+ # @return [String] lines, newline-terminated
173
+ def question_text(pending, session_id:)
174
+ q = question(pending)
175
+ lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
176
+ text_lines = q[:text].strip.lines.map(&:rstrip)
177
+ text_lines = text_lines.drop(1) unless q[:header]
178
+ lines.concat(text_lines.map { |line| " #{line}" })
179
+ q[:options].each_with_index { |option, i| lines << " #{i + 1}. #{option}" }
180
+ if approval?(q)
181
+ lines << " allowing it is up to your user: #{DENY_AND_TELL}"
182
+ lines << " deny: #{answer_with(session_id, q)}"
183
+ else
184
+ lines << " more than one allowed: repeat --option" if q[:multi_select]
185
+ lines << " free text allowed: --text" if q[:allow_freeform]
186
+ lines << " answer: #{answer_with(session_id, q)}"
187
+ lines << " or open it: chi --attach #{session_id} or the web"
188
+ end
189
+ lines.map { |line| "#{line}\n" }.join
190
+ end
191
+
192
+ def no_reply_line(result)
193
+ case result.outcome
194
+ when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
195
+ when "canceled" then "the turn was canceled"
196
+ when "completed" then "the turn ended with no visible answer"
197
+ else "the turn ended without a reply (canceled, failed or empty)"
198
+ end
199
+ end
200
+
201
+ def first_line(text)
202
+ text.to_s.strip.lines.first.to_s.strip
203
+ end
204
+
205
+ def blank(value)
206
+ value.to_s.strip.empty? ? nil : value.to_s
207
+ end
208
+
209
+ def symbolize(hash)
210
+ hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
211
+ end
212
+ end
213
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "../log"
4
5
  require_relative "../hooks/registry"
5
6
  require_relative "../tools/args"
@@ -298,14 +299,26 @@ module Samagotchi
298
299
  schema: spec[:schema], label: spec[:label],
299
300
  handler: lambda { |call, _kctx|
300
301
  result = block.call(Api.args_of(call, parameters), context)
301
- # A String (a ToolResult too, with its images) as it is.
302
- result.is_a?(String) ? result : result.to_s
302
+ Api.result_text(result)
303
303
  },
304
304
  preview: preview && ->(call) { preview.call(Api.args_of(call, parameters))&.to_s },
305
305
  targets: targets && ->(call) { targets.call(Api.args_of(call, parameters)) }
306
306
  }
307
307
  end
308
308
 
309
+ # A tool's result as the model reads it: a String (a ToolResult too,
310
+ # with its images) as it is, a Hash or Array as JSON (its #to_s is
311
+ # Hash#inspect, which changes with the Ruby version), else #to_s.
312
+ def self.result_text(result)
313
+ case result
314
+ when String then result
315
+ when Hash, Array then JSON.generate(result)
316
+ else result.to_s
317
+ end
318
+ rescue JSON::GeneratorError
319
+ result.to_s
320
+ end
321
+
309
322
  # A tool call's arguments as a plugin sees them: the parsers' args:
310
323
  # (a call built without one, in a spec say: the call without its
311
324
  # name), string keys, typed by +parameters+, frozen.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "model_profile"
4
+
3
5
  module Samagotchi
4
6
  # Escapes literal control-token text when arbitrary source/output is embedded
5
7
  # inside prompts, then restores those placeholders when model text is used.
@@ -13,8 +15,8 @@ module Samagotchi
13
15
  ["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_CLOSE]]", "<tool_response|>"],
14
16
  ["[[SAMAGOTCHI_LITERAL_GEMMA_STRING_DELIM]]", '<|"|>'],
15
17
  ["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<|think|>"],
16
- ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", "<|channel>thought"],
17
- ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", "<channel|>"]
18
+ ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN],
19
+ ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE]
18
20
  ].freeze
19
21
 
20
22
  QWEN_TOKEN_PAIRS = [