samagotchi 0.11.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -1
  3. data/README.md +20 -7
  4. data/bin/chi +10 -4
  5. data/docs/architecture.md +8 -6
  6. data/docs/cli.md +59 -11
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +34 -8
  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 +15 -5
  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/macos/App.swift +5 -1
  28. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  29. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  30. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  31. data/lib/samagotchi/desktop_command.rb +5 -3
  32. data/lib/samagotchi/engine.rb +480 -491
  33. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  34. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  35. data/lib/samagotchi/host_registry.rb +26 -3
  36. data/lib/samagotchi/idle_reminders.rb +13 -36
  37. data/lib/samagotchi/kernel_loop.rb +82 -120
  38. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  39. data/lib/samagotchi/llm/model_result.rb +14 -20
  40. data/lib/samagotchi/llm/native_backend.rb +3 -21
  41. data/lib/samagotchi/llm/openai_chat.rb +10 -2
  42. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  43. data/lib/samagotchi/llm/usage.rb +2 -57
  44. data/lib/samagotchi/memory_bundle/installer.rb +13 -1
  45. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  46. data/lib/samagotchi/memory_paths.rb +8 -0
  47. data/lib/samagotchi/model_catalog.rb +101 -0
  48. data/lib/samagotchi/model_profile.rb +31 -17
  49. data/lib/samagotchi/models_command.rb +112 -0
  50. data/lib/samagotchi/parent_report.rb +213 -0
  51. data/lib/samagotchi/plugin/api.rb +15 -2
  52. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  53. data/lib/samagotchi/question_desk.rb +29 -2
  54. data/lib/samagotchi/recap_setup.rb +15 -5
  55. data/lib/samagotchi/reminder_queue.rb +73 -0
  56. data/lib/samagotchi/reminder_store.rb +3 -3
  57. data/lib/samagotchi/reply_wait.rb +5 -1
  58. data/lib/samagotchi/self_report.rb +8 -1
  59. data/lib/samagotchi/send_command.rb +51 -76
  60. data/lib/samagotchi/session.rb +15 -0
  61. data/lib/samagotchi/session_commands.rb +5 -5
  62. data/lib/samagotchi/session_manager.rb +36 -13
  63. data/lib/samagotchi/sessions_command.rb +13 -3
  64. data/lib/samagotchi/system_prompt.rb +10 -2
  65. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  66. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  67. data/lib/samagotchi/terminal_ui.rb +23 -29
  68. data/lib/samagotchi/thinking.rb +1 -1
  69. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  70. data/lib/samagotchi/tool_call_parser.rb +5 -6
  71. data/lib/samagotchi/tool_runner.rb +4 -6
  72. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  73. data/lib/samagotchi/tools/builtins.rb +15 -3
  74. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  75. data/lib/samagotchi/tools/execute.rb +8 -8
  76. data/lib/samagotchi/tools/task_create.rb +3 -2
  77. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  78. data/lib/samagotchi/turn_flow.rb +4 -4
  79. data/lib/samagotchi/turn_state.rb +126 -0
  80. data/lib/samagotchi/used_memories.rb +88 -0
  81. data/lib/samagotchi/version.rb +1 -1
  82. data/lib/samagotchi/vision_support.rb +4 -4
  83. data/lib/samagotchi/web/app.rb +17 -35
  84. data/lib/samagotchi/web/message_parts.rb +2 -1
  85. data/lib/samagotchi/web/server.rb +2 -1
  86. data/lib/samagotchi/web/session_hub.rb +0 -4
  87. data/lib/samagotchi/web/session_summary.rb +8 -16
  88. data/lib/samagotchi/worker.rb +1 -1
  89. metadata +14 -1
@@ -4,8 +4,8 @@ require_relative "../token_usage"
4
4
 
5
5
  module Samagotchi
6
6
  module LLM
7
- # A turn's token counts, never nil: what the server reported (:server),
8
- # else a chars/4 estimate (:estimate), else zeros (:none).
7
+ # A response's token counts, never nil: what the server reported
8
+ # (:server), else zeros (:none).
9
9
  Usage = Data.define(:prompt_tokens, :completion_tokens, :source) do
10
10
  # @return [Usage, nil] nil when the payload carries no counts
11
11
  def self.from_payload(payload)
@@ -16,64 +16,9 @@ module Samagotchi
16
16
  source: :server)
17
17
  end
18
18
 
19
- # +extra_prompt_tokens+: what the prompt holds besides text (images).
20
- def self.estimate(prompt_text:, completion_text:, extra_prompt_tokens: 0)
21
- new(prompt_tokens: TokenUsage.estimate(prompt_text.to_s) + extra_prompt_tokens.to_i,
22
- completion_tokens: TokenUsage.estimate(completion_text.to_s),
23
- source: :estimate)
24
- end
25
-
26
19
  def self.none = new(prompt_tokens: 0, completion_tokens: 0, source: :none)
27
20
 
28
21
  def total_tokens = prompt_tokens + completion_tokens
29
22
  end
30
-
31
- # Builds a turn's Usage from its stream events, the way SessionMetrics
32
- # counts them: server counts are cumulative per request, so each
33
- # generation keeps its highest; the prompt grows across a turn's
34
- # generations, so the last generation's prompt counts, and completions
35
- # add up. Without server counts, the streamed text is estimated.
36
- class UsageCollector
37
- def initialize
38
- @generations = []
39
- end
40
-
41
- def observe(event)
42
- case event[:type]
43
- when :generation_started
44
- @generations << { prompt: 0, completion: 0, server: false, text: +"" }
45
- when :generation_chunk
46
- @generations << { prompt: 0, completion: 0, server: false, text: +"" } if @generations.empty?
47
- record(@generations.last, event)
48
- end
49
- end
50
-
51
- # @param prompt_text [String, nil] what an estimate counts as the prompt
52
- # @param extra_prompt_tokens [Integer] estimated non-text prompt tokens
53
- def usage(prompt_text: nil, extra_prompt_tokens: 0)
54
- server = @generations.select { |generation| generation[:server] }
55
- unless server.empty?
56
- return Usage.new(prompt_tokens: server.last[:prompt], completion_tokens: server.sum { |g| g[:completion] },
57
- source: :server)
58
- end
59
-
60
- text = @generations.sum("") { |generation| generation[:text] }
61
- return Usage.none if text.empty?
62
-
63
- Usage.estimate(prompt_text: prompt_text, completion_text: text, extra_prompt_tokens: extra_prompt_tokens)
64
- end
65
-
66
- private
67
-
68
- def record(generation, event)
69
- counts = Usage.from_payload(event[:payload])
70
- if counts
71
- generation[:server] = true
72
- generation[:prompt] = [generation[:prompt], counts.prompt_tokens].max
73
- generation[:completion] = [generation[:completion], counts.completion_tokens].max
74
- end
75
- generation[:text] << event[:content].to_s
76
- end
77
- end
78
23
  end
79
24
  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]
@@ -365,7 +366,7 @@ module Samagotchi
365
366
  version: manifest.version,
366
367
  source_path: @source,
367
368
  hooks: hooks_for_provenance,
368
- trust_level: manifest.respond_to?(:trust_level) ? manifest.trust_level : nil,
369
+ trust_level: manifest.trust_level,
369
370
  source_commit: source_commit,
370
371
  hooks_files: hooks_files_for_provenance,
371
372
  guardrails_files: guardrail_files_for_provenance,
@@ -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?
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "host_registry"
4
+
5
+ module Samagotchi
6
+ # The hosts' model listings (HostRegistry#list_all_models) as rows a user
7
+ # picks from: GET /api/models and `chi models`. Every name question goes
8
+ # to ModelRef / HostRegistry#resolve; nothing here parses a ref.
9
+ module ModelCatalog
10
+ # host: the host that listed the id; ref: the name `--model` takes for
11
+ # it (see .spell).
12
+ Row = Data.define(:host, :id, :ref)
13
+ # rows: the default host's first, then the others by name; warnings:
14
+ # "host: error" per host that failed.
15
+ Listing = Data.define(:rows, :warnings)
16
+
17
+ module_function
18
+
19
+ # @param results [Hash] list_all_models' answer
20
+ # @param registry [#default_entry]
21
+ # @return [Listing]
22
+ def listing(results, registry:)
23
+ default_host = registry.default_entry&.name
24
+ rows = []
25
+ warnings = []
26
+ results.keys.sort_by { |name| [name == default_host ? 0 : 1, name] }.each do |host|
27
+ data = results[host]
28
+ if data[:error]
29
+ warnings << "#{host}: #{data[:error]}"
30
+ next
31
+ end
32
+ Array(data[:models]).each do |info|
33
+ id = info.id.to_s
34
+ next if id.strip.empty? || id.end_with?(":batch")
35
+
36
+ rows << Row.new(host: host, id: id, ref: spell(host, id, default_host))
37
+ end
38
+ end
39
+ Listing.new(rows: rows, warnings: warnings)
40
+ end
41
+
42
+ # A listed id as a ref that routes back to its host: bare on the
43
+ # default host (a bare id goes there first), "host:id" elsewhere and for
44
+ # a default-host id with a ':' (bare, "qwen3:8b" would name a host qwen3).
45
+ # '/' doesn't name a host, so "openai/gpt-4o" stays bare.
46
+ def spell(host, id, default_host)
47
+ host == default_host && !id.include?(":") ? id : "#{host}:#{id}"
48
+ end
49
+
50
+ # `chi models --format json`'s object. default_host: where bare names
51
+ # go first (its rows are the "default host" group). default: the default's listed
52
+ # row name when it resolves to one, else its resolved ref; default_typed:
53
+ # the alias it was typed as. A row whose name resolves elsewhere (an
54
+ # alias named like the id shadows it) carries shadowed_by.
55
+ # @param default_name [String, nil] default.model as configured
56
+ # @return [Hash]
57
+ def payload(results, registry:, default_name:)
58
+ listing = listing(results, registry: registry)
59
+ models = listing.rows.map do |row|
60
+ hash = { name: row.ref, host: row.host, id: row.id }
61
+ shadow = shadowed_by(row, registry)
62
+ shadow ? hash.merge(shadowed_by: shadow) : hash
63
+ end
64
+ default, typed = default_for(default_name, listing.rows, registry)
65
+ { default: default, default_typed: typed, default_host: registry.default_entry&.name, models: models,
66
+ aliases: aliases(registry), warnings: listing.warnings }
67
+ end
68
+
69
+ # The alias (or the ref) that takes +row+'s name elsewhere, or nil.
70
+ def shadowed_by(row, registry)
71
+ target = registry.resolve(row.ref)
72
+ return nil if target.entry.name == row.host && target.bare_model == row.id
73
+
74
+ registry.model_ref(row.ref).alias_name || "#{target.entry.name}:#{target.bare_model}"
75
+ end
76
+
77
+ def default_for(name, rows, registry)
78
+ return [nil, nil] if name.to_s.strip.empty?
79
+
80
+ ref = registry.model_ref(name)
81
+ target = registry.resolve(name)
82
+ row = rows.find { |r| r.host == target.entry.name && r.id.casecmp?(target.bare_model) }
83
+ [row ? row.ref : ref.ref, ref.alias_name ? name.to_s.strip : nil]
84
+ end
85
+
86
+ # config.yml's aliases, each under the host its target goes to: the
87
+ # default host's first, then by host and name.
88
+ def aliases(registry)
89
+ default_host = registry.default_entry&.name
90
+ names = begin
91
+ ConfigFile.model_aliases
92
+ rescue StandardError
93
+ {}
94
+ end
95
+ rows = names.keys.map do |name|
96
+ { name: name, ref: registry.model_ref(name).ref, host: registry.resolve(name).entry.name }
97
+ end
98
+ rows.sort_by { |row| [row[:host] == default_host ? 0 : 1, row[:host], row[:name]] }
99
+ end
100
+ end
101
+ end
@@ -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
@@ -324,20 +348,10 @@ module Samagotchi
324
348
  return false if client.nil? || !client.respond_to?(:server_props)
325
349
  return false if entry&.chat?
326
350
 
327
- !client.respond_to?(:transport) || client.transport.name == :llama_cpp
351
+ client.transport.name == :llama_cpp
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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "host_registry"
5
+ require_relative "model_catalog"
6
+ require_relative "model_profile"
7
+ require_relative "cli/command"
8
+ require_relative "cli/flags"
9
+
10
+ module Samagotchi
11
+ # `chi models`: the names `--model` (and `chi send --new --model`) takes,
12
+ # listed anew from every host in parallel, within a bounded wait; the
13
+ # desktop helper's model picker reads --format json. No cache: each run
14
+ # lists every host (the registry's TTLs live in one process only).
15
+ class ModelsCommand
16
+ include CLI::Command
17
+
18
+ DEFAULT_TIMEOUT = 4.0
19
+ FORMATS = %w[text json].freeze
20
+
21
+ USAGE = <<~TEXT
22
+ Usage: chi models [--format text|json] [--timeout S] [TEXT]
23
+ Lists the models every configured host offers, as the names
24
+ --model takes: the default first, a default-host id bare, another
25
+ host's as host:id, then the aliases as "name -> ref". A host that
26
+ fails or doesn't answer in time is noted on stderr.
27
+ TEXT only the names containing it (any case)
28
+ --format json the default, the models (host, id, name), the aliases
29
+ and the warnings as one JSON object
30
+ --timeout S wait at most S seconds for the hosts; default 4
31
+ Exit 0 when any host listed its models, 1 when none did (the
32
+ default is still printed), 2 on a usage error.
33
+ TEXT
34
+
35
+ FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
36
+ f.value "--format"
37
+ f.value "--timeout"
38
+ end
39
+
40
+ # @param registry [HostRegistry, nil] specs inject one
41
+ # @param default_name [String, nil, :config] default.model; :config reads it
42
+ def initialize(argv, stdout: $stdout, stderr: $stderr, registry: nil, default_name: :config)
43
+ @argv = argv.dup
44
+ @stdout = stdout
45
+ @stderr = stderr
46
+ @registry = registry
47
+ @default_name = default_name
48
+ end
49
+
50
+ # @return [Integer] 0 any host listed, 1 none did, 2 usage
51
+ def run
52
+ parsed = parse_flags(FLAGS, @argv, format: "text", timeout: DEFAULT_TIMEOUT.to_s)
53
+ return parsed if parsed.is_a?(Integer)
54
+
55
+ options = parsed.options
56
+ return usage_error("--format takes text or json") unless FORMATS.include?(options[:format])
57
+
58
+ timeout = Float(options[:timeout], exception: false)
59
+ return usage_error("--timeout takes a number of seconds above 0") unless timeout&.positive?
60
+
61
+ payload, listed = catalog(timeout)
62
+ payload = filtered(payload, parsed.args.join(" ").strip)
63
+ options[:format] == "json" ? print_json(payload) : print_text(payload)
64
+ listed ? 0 : 1
65
+ end
66
+
67
+ private
68
+
69
+ def command_name = "chi models"
70
+
71
+ # @return [Array(Hash, Boolean)] the payload and whether any host listed
72
+ def catalog(timeout)
73
+ registry = @registry || HostRegistry.new
74
+ results = registry.list_all_models(force: true, wait: timeout)
75
+ listed = results.values.any? { |data| data[:error].nil? }
76
+ [ModelCatalog.payload(results, registry: registry, default_name: default_name), listed]
77
+ rescue StandardError => e
78
+ [{ default: default_name, default_typed: nil, default_host: nil, models: [], aliases: [], warnings: [e.message] }, false]
79
+ end
80
+
81
+ def default_name
82
+ return @default_name unless @default_name == :config
83
+
84
+ ModelProfile.required_model_name
85
+ rescue ArgumentError
86
+ nil
87
+ end
88
+
89
+ def filtered(payload, text)
90
+ return payload if text.empty?
91
+
92
+ needle = text.downcase
93
+ payload.merge(models: payload[:models].select { |m| m[:name].downcase.include?(needle) },
94
+ aliases: payload[:aliases].select { |a| "#{a[:name]} #{a[:ref]}".downcase.include?(needle) },
95
+ filter: text)
96
+ end
97
+
98
+ def print_json(payload)
99
+ @stdout.puts(JSON.generate(payload.except(:filter)))
100
+ end
101
+
102
+ def print_text(payload)
103
+ default = payload[:default]
104
+ names = payload[:models].reject { |m| m[:shadowed_by] }.map { |m| m[:name] }
105
+ shown_default = default && (payload[:filter].nil? || default.downcase.include?(payload[:filter].downcase))
106
+ @stdout.puts(default) if shown_default
107
+ (names - [default]).each { |name| @stdout.puts(name) }
108
+ payload[:aliases].each { |a| @stdout.puts("#{a[:name]} -> #{a[:ref]}") }
109
+ payload[:warnings].each { |w| @stderr.puts("chi models: #{w}") }
110
+ end
111
+ end
112
+ 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