samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "json"
6
+ require "uri"
7
+ require "ipaddr"
8
+ require_relative "../version"
9
+ require_relative "../llm/openai_chat"
10
+
11
+ module Samagotchi
12
+ module Bootstrap
13
+ # What `chi bootstrap TARGET` finds at a model server: llama.cpp's native
14
+ # API (/props), an OpenAI-compatible one (/v1/models), one that wants an
15
+ # API key, or nothing. One attempt per request, no retries: a refused
16
+ # port answers at once instead of after chi's usual backoff.
17
+ class Probe
18
+ # The ports `chi bootstrap` with no target tries on localhost.
19
+ LOCAL_PORTS = { 8080 => "llama.cpp", 11434 => "Ollama", 1234 => "LM Studio", 8000 => "vLLM" }.freeze
20
+ DEFAULT_PORT = 8080
21
+ # Connect and read seconds for /props and /v1/models: a remote https
22
+ # handshake needs more than Client#server_props' 1 s / 2 s.
23
+ TIMEOUT = 5
24
+ # Seconds a local-port scan waits per port.
25
+ SCAN_TIMEOUT = 1
26
+ TEST_TIMEOUT = 60
27
+ TEST_PROMPT = "Reply with the word ok."
28
+
29
+ # One place a server may be: root is scheme://host:port (llama.cpp's
30
+ # /props), base the OpenAI API base (<root>/v1, or a URL's own path).
31
+ # url is true when the user gave a URL with a path, which the config
32
+ # then keeps as written.
33
+ Candidate = Data.define(:root, :base, :url) do
34
+ def uri = URI(root)
35
+ def host = uri.host
36
+ def port = uri.port
37
+ def scheme = uri.scheme
38
+ def label = "#{host}:#{port}"
39
+ end
40
+
41
+ # kind: :native, :openai, :needs_key, :unreachable or :unknown.
42
+ # props: the /props JSON (native); models: [LLM::ModelInfo];
43
+ # status: the HTTP status that decided (:unknown, :needs_key);
44
+ # reason: why it's unreachable.
45
+ Result = Data.define(:kind, :candidate, :props, :models, :status, :reason) do
46
+ def self.of(kind, candidate, props: nil, models: [], status: nil, reason: nil)
47
+ new(kind: kind, candidate: candidate, props: props, models: models, status: status, reason: reason)
48
+ end
49
+
50
+ def native? = kind == :native
51
+ def reached? = %i[native openai].include?(kind)
52
+ end
53
+
54
+ # The target the user typed, as the candidates to try in order.
55
+ # host, host:port, an IP → http://host:port (port 8080 when none)
56
+ # a domain without a scheme → https first, then http
57
+ # a URL → as given; a path is the OpenAI base
58
+ # Raises ArgumentError for something that isn't a host or URL.
59
+ def self.candidates(target)
60
+ text = target.to_s.strip
61
+ raise ArgumentError, "no target" if text.empty?
62
+
63
+ if text.match?(%r{\A[a-z][a-z0-9+.-]*://}i)
64
+ uri = parse_uri(text)
65
+ raise ArgumentError, "not an http(s) URL: #{text}" unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
66
+
67
+ root = root_url(uri.scheme, uri.host, uri.port)
68
+ path = uri.path.to_s.chomp("/")
69
+ return [Candidate.new(root: root, base: path.empty? ? "#{root}/v1" : "#{root}#{path}", url: !path.empty?)]
70
+ end
71
+
72
+ uri = parse_uri("http://#{text}")
73
+ raise ArgumentError, "not a host or URL: #{text}" unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
74
+
75
+ path = uri.path.to_s.chomp("/")
76
+ port = text.match?(%r{:\d+(/|\z)}) ? uri.port : nil
77
+ schemes = domain?(uri.host) ? { "https" => 443, "http" => 80 } : { "http" => DEFAULT_PORT }
78
+ schemes.map do |scheme, default_port|
79
+ root = root_url(scheme, uri.host, port || default_port)
80
+ Candidate.new(root: root, base: path.empty? ? "#{root}/v1" : "#{root}#{path}", url: !path.empty?)
81
+ end
82
+ end
83
+
84
+ # scheme://host:port, without the port when it is the scheme's own
85
+ # (https://openrouter.ai, not https://openrouter.ai:443): it is written
86
+ # into config.yml as typed.
87
+ def self.root_url(scheme, host, port)
88
+ default = { "http" => 80, "https" => 443 }[scheme]
89
+ port == default ? "#{scheme}://#{host}" : "#{scheme}://#{host}:#{port}"
90
+ end
91
+
92
+ # A name with a dot that isn't an IP or localhost: reached over https
93
+ # on 443 first, like a browser would.
94
+ def self.domain?(host)
95
+ return false if host.nil? || host.casecmp?("localhost") || ip?(host)
96
+
97
+ host.include?(".")
98
+ end
99
+
100
+ def self.ip?(host)
101
+ IPAddr.new(host.to_s.delete_prefix("[").delete_suffix("]"))
102
+ true
103
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
104
+ false
105
+ end
106
+
107
+ def self.parse_uri(text)
108
+ URI.parse(text)
109
+ rescue URI::InvalidURIError
110
+ nil
111
+ end
112
+ private_class_method :parse_uri
113
+
114
+ # @param env [Hash] where the API key variable is read
115
+ # @param timeout [Numeric] connect and read seconds per request
116
+ def initialize(env: ENV, timeout: TIMEOUT)
117
+ @env = env
118
+ @timeout = timeout
119
+ end
120
+
121
+ # Probe the target's candidates in order; the next one is tried only
122
+ # when this one refused the connection or failed its TLS handshake
123
+ # (https → http), not on a timeout.
124
+ # @param key_env [String, nil] the API key's environment variable
125
+ # @return [Result]
126
+ def classify_target(candidates, key_env: nil)
127
+ result = nil
128
+ candidates.each do |candidate|
129
+ result = classify(candidate, key_env: key_env)
130
+ break unless result.kind == :unreachable && result.reason.to_s.match?(/refused|SSL|TLS|certificate/i)
131
+ end
132
+ result
133
+ end
134
+
135
+ # @return [Result]
136
+ def classify(candidate, key_env: nil)
137
+ props = begin
138
+ status, body = get(URI("#{candidate.root}/props"), key_env)
139
+ status == 200 ? parse_object(body) : nil
140
+ rescue *network_errors => e
141
+ return Result.of(:unreachable, candidate, reason: reason(e))
142
+ end
143
+ return Result.of(:native, candidate, props: props, models: native_models(candidate, props, key_env)) if native_props?(props)
144
+
145
+ begin
146
+ Result.of(:openai, candidate, models: list_models(candidate.base, key_env))
147
+ rescue LLM::ProviderError => e
148
+ kind = [401, 403].include?(e.status) ? :needs_key : :unknown
149
+ Result.of(kind, candidate, status: e.status, reason: e.status ? nil : e.message)
150
+ rescue *network_errors => e
151
+ Result.of(:unreachable, candidate, reason: reason(e))
152
+ end
153
+ end
154
+
155
+ # The ports in LOCAL_PORTS that answer as a model server, probed in
156
+ # parallel. @return [Array<Result>] the reached ones, in LOCAL_PORTS order
157
+ def scan_local(ports: LOCAL_PORTS.keys)
158
+ scanner = self.class.new(env: @env, timeout: SCAN_TIMEOUT)
159
+ ports.map do |port|
160
+ Thread.new { scanner.classify(Candidate.new(root: "http://localhost:#{port}", base: "http://localhost:#{port}/v1", url: false)) }
161
+ end.map(&:value).select(&:reached?)
162
+ end
163
+
164
+ # /props for one model (a llama.cpp router loads it on demand), or nil
165
+ # when the server doesn't answer it.
166
+ def model_props(candidate, model, key_env: nil)
167
+ status, body = get(URI("#{candidate.root}/props?#{URI.encode_www_form(model: model)}"), key_env)
168
+ status == 200 ? parse_object(body) : nil
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
173
+ # One short chat request: does the server generate for +model+? Any 200
174
+ # with a choice counts (a thinking model may spend its 16 tokens
175
+ # reasoning). @return [Float] seconds taken
176
+ # @raise [RuntimeError] with what went wrong
177
+ def test_turn(candidate, model, key_env: nil, read_timeout: TEST_TIMEOUT)
178
+ uri = URI("#{candidate.base}/chat/completions")
179
+ request = Net::HTTP::Post.new(uri)
180
+ request["Content-Type"] = "application/json"
181
+ request.body = JSON.generate(model: model, messages: [{ role: "user", content: TEST_PROMPT }], max_tokens: 16,
182
+ stream: false)
183
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
184
+ response = send_request(uri, request, key_env, read_timeout: read_timeout)
185
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
186
+ body = parse_object(response.body)
187
+ unless response.code.to_i == 200 && body && body["choices"].is_a?(Array) && !body["choices"].empty?
188
+ raise "HTTP #{response.code}: #{error_text(body, response.body)}"
189
+ end
190
+
191
+ elapsed
192
+ rescue *network_errors => e
193
+ raise "#{reason(e)} (#{candidate.label})"
194
+ end
195
+
196
+ private
197
+
198
+ def native_props?(props)
199
+ props.is_a?(Hash) && (props.key?("chat_template") || props.key?("build_info"))
200
+ end
201
+
202
+ # llama.cpp serves /v1/models too; a server that doesn't gets its
203
+ # model_alias.
204
+ def native_models(candidate, props, key_env)
205
+ models = begin
206
+ list_models(candidate.base, key_env)
207
+ rescue StandardError
208
+ []
209
+ end
210
+ return models unless models.empty?
211
+
212
+ name = props["model_alias"].to_s.strip
213
+ name.empty? ? [] : [LLM::ModelInfo.new(id: name, context_window: nil, supports_tools: nil, raw: {})]
214
+ end
215
+
216
+ def list_models(base, key_env)
217
+ LLM::OpenAIChat.new(base_url: base, host_name: URI(base).host, api_key_env: key_env, retries: false,
218
+ timeout: @timeout, env: @env).list_models
219
+ end
220
+
221
+ def get(uri, key_env)
222
+ response = send_request(uri, Net::HTTP::Get.new(uri), key_env, read_timeout: @timeout)
223
+ [response.code.to_i, response.body]
224
+ end
225
+
226
+ def send_request(uri, request, key_env, read_timeout:)
227
+ request["User-Agent"] = Samagotchi::USER_AGENT
228
+ key = key_env && @env[key_env].to_s
229
+ request["Authorization"] = "Bearer #{key}" if key && !key.strip.empty?
230
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout,
231
+ read_timeout: read_timeout) { |http| http.request(request) }
232
+ end
233
+
234
+ def network_errors
235
+ [*LLM::HTTP::NETWORK_ERRORS, OpenSSL::SSL::SSLError, Errno::EADDRNOTAVAIL, Errno::ENETDOWN, Errno::EPIPE]
236
+ end
237
+
238
+ def reason(error)
239
+ case error
240
+ when Errno::ECONNREFUSED then "connection refused"
241
+ when Net::OpenTimeout then "no answer in #{@timeout} s"
242
+ when Timeout::Error, IO::TimeoutError then "timed out"
243
+ when SocketError then "unknown host"
244
+ when OpenSSL::SSL::SSLError then "TLS: #{error.message}"
245
+ else error.message
246
+ end
247
+ end
248
+
249
+ def parse_object(text)
250
+ parsed = JSON.parse(text.to_s)
251
+ parsed.is_a?(Hash) ? parsed : nil
252
+ rescue JSON::ParserError
253
+ nil
254
+ end
255
+
256
+ def error_text(body, raw)
257
+ message = body.is_a?(Hash) && (body.dig("error", "message") || body["error"] || body["message"])
258
+ (message || raw.to_s)[0, 200].to_s.strip
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "config"
5
+ require_relative "model_profile"
6
+ require_relative "bootstrap/probe"
7
+ require_relative "bootstrap/config_writer"
8
+
9
+ module Samagotchi
10
+ # `chi bootstrap [TARGET]`: the first setup. It names a model server, works
11
+ # out what it is (llama.cpp's native API or an OpenAI-compatible one),
12
+ # picks the model, sends one test request and writes config.yml: a fresh
13
+ # file, or a hosts: entry added to an existing one. Prompts only on a
14
+ # terminal; a script gets the list and exit 2 instead.
15
+ class BootstrapCommand
16
+ USAGE = <<~TEXT
17
+ Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--no-test] [--dry-run]
18
+ Finds the model server at TARGET and writes config.yml for it.
19
+ chi bootstrap 192.168.1.29:8081 llama.cpp on the LAN
20
+ chi bootstrap localhost:11434 Ollama
21
+ chi bootstrap https://openrouter.ai/api/v1 --key-env OPENROUTER_API_KEY
22
+ chi bootstrap look on this machine's usual ports
23
+ TARGET host[:port] (port 8080 when none), or a URL; a URL's
24
+ path is the OpenAI API base
25
+ --name NAME the hosts entry's name (default: local, lan, or the
26
+ domain's name)
27
+ --model ID the model, when the server has several
28
+ --key-env VAR the environment variable holding the API key (the
29
+ key itself is never written)
30
+ --no-test skip the test request
31
+ --dry-run show what would be written; write nothing
32
+ With no config.yml it writes one; an existing one gets a hosts:
33
+ entry added (after a backup), and its other lines stay as they are.
34
+ TEXT
35
+
36
+ PICK_SHOWN = 20
37
+ # A test request slower than this says it is waiting (a model loading).
38
+ LOADING_AFTER = 3
39
+
40
+ # @param argv [Array<String>] the arguments after "bootstrap"
41
+ # @param probe [Bootstrap::Probe, nil] (specs)
42
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, env: ENV, probe: nil, config_path: nil,
43
+ platform: RbConfig::CONFIG["host_os"])
44
+ @argv = argv.dup
45
+ @stdin = stdin
46
+ @stdout = stdout
47
+ @stderr = stderr
48
+ @env = env
49
+ @probe = probe || Bootstrap::Probe.new(env: env)
50
+ @config_path = config_path || ConfigFile.global_path(env: env)
51
+ @platform = platform
52
+ end
53
+
54
+ # @return [Integer] 0 written (or already there), 1 failed (a failed
55
+ # test still writes the config), 2 usage or a choice to make
56
+ def run
57
+ options = parse or return 2
58
+ return 0 if options[:help]
59
+
60
+ key_env = options[:key_env]
61
+ return fail!("#{key_env} is not set; export it first (the API key; chi only writes its name)") if key_env && unset?(key_env)
62
+
63
+ result = find(options, key_env) or return @exit
64
+ unlocked = with_key(result, key_env) or return @exit
65
+ result, key_env = unlocked
66
+ describe(result)
67
+ model = pick_model(result, options[:model]) or return @exit
68
+ show_model(result, model, key_env)
69
+ tested = options[:no_test] || test(result, model, key_env)
70
+ written = write(result, model, key_env, options) or return @exit
71
+ written.zero? && tested ? 0 : 1
72
+ end
73
+
74
+ private
75
+
76
+ def parse
77
+ options = {}
78
+ until @argv.empty?
79
+ arg = @argv.shift
80
+ case arg
81
+ when "-h", "--help", "help"
82
+ @stdout.puts(USAGE)
83
+ return { help: true }
84
+ when "--name", "--model", "--key-env"
85
+ options[arg.delete_prefix("--").tr("-", "_").to_sym] = @argv.shift or return usage_error("#{arg} needs a value")
86
+ when /\A--(name|model|key-env)=(.+)\z/ then options[Regexp.last_match(1).tr("-", "_").to_sym] = Regexp.last_match(2)
87
+ when "--no-test" then options[:no_test] = true
88
+ when "--dry-run" then options[:dry_run] = true
89
+ when /\A-/ then return usage_error("unknown option #{arg}")
90
+ else
91
+ return usage_error("one TARGET only (got #{options[:target]} and #{arg})") if options[:target]
92
+
93
+ options[:target] = arg
94
+ end
95
+ end
96
+ if options[:key_env] && !options[:key_env].match?(ConfigFile::ENV_NAME_RE)
97
+ return usage_error("--key-env takes the variable's name (e.g. OPENROUTER_API_KEY), not the key")
98
+ end
99
+
100
+ options
101
+ end
102
+
103
+ # The server's Probe::Result, or nil with @exit set.
104
+ def find(options, key_env)
105
+ return scan unless options[:target]
106
+
107
+ candidates = begin
108
+ Bootstrap::Probe.candidates(options[:target])
109
+ rescue ArgumentError => e
110
+ usage_error(e.message)
111
+ @exit = 2
112
+ return nil
113
+ end
114
+ result = @probe.classify_target(candidates, key_env: key_env)
115
+ case result.kind
116
+ when :unreachable
117
+ fail!("can't reach #{result.candidate.label} (#{result.reason})")
118
+ when :unknown
119
+ detail = result.status ? "HTTP #{result.status}" : result.reason
120
+ fail!("reached #{result.candidate.label} but it answers neither llama.cpp /props nor /v1/models (#{detail})")
121
+ else result
122
+ end
123
+ end
124
+
125
+ def scan
126
+ found = @probe.scan_local
127
+ ports = Bootstrap::Probe::LOCAL_PORTS.keys.join(", ")
128
+ return fail!("no model server found on localhost (#{ports}); pass one: chi bootstrap HOST[:PORT]") if found.empty?
129
+ return found.first if found.size == 1
130
+
131
+ labels = found.map { |r| "#{r.candidate.label} (#{Bootstrap::Probe::LOCAL_PORTS[r.candidate.port]}, #{models_count(r)})" }
132
+ unless tty?
133
+ @stderr.puts("chi bootstrap: model servers on this machine:")
134
+ labels.each { |label| @stderr.puts(" #{label}") }
135
+ @stderr.puts("pick one: chi bootstrap localhost:PORT")
136
+ @exit = 2
137
+ return nil
138
+ end
139
+ picked = pick(labels, "server") or return fail!("nothing picked")
140
+ found[labels.index(picked)]
141
+ end
142
+
143
+ def models_count(result)
144
+ n = result.models.size
145
+ n == 1 ? "1 model" : "#{n} models"
146
+ end
147
+
148
+ # [result, key_env] once the server lets us in, or nil with @exit set.
149
+ def with_key(result, key_env)
150
+ return [result, key_env] unless result.kind == :needs_key
151
+
152
+ host = result.candidate.label
153
+ return fail!("#{host} refused the key in #{key_env} (HTTP #{result.status})") if key_env
154
+
155
+ unless tty?
156
+ @stderr.puts("chi bootstrap: #{host} wants an API key (HTTP #{result.status}); " \
157
+ "pass the variable that holds it: --key-env VAR")
158
+ @exit = 2
159
+ return nil
160
+ end
161
+ @stdout.print("#{host} wants an API key. API key environment variable (e.g. OPENROUTER_API_KEY): ")
162
+ key_env = @stdin.gets.to_s.strip
163
+ return fail!("no variable given") if key_env.empty?
164
+ return fail!("#{key_env} isn't a variable name") unless key_env.match?(ConfigFile::ENV_NAME_RE)
165
+ return fail!("#{key_env} is not set; export it and run chi bootstrap again") if unset?(key_env)
166
+
167
+ result = @probe.classify(result.candidate, key_env: key_env)
168
+ return fail!("#{host} refused the key in #{key_env} (HTTP #{result.status})") if result.kind == :needs_key
169
+ return fail!("can't reach #{host} (#{result.reason || "HTTP #{result.status}"})") unless result.reached?
170
+
171
+ [result, key_env]
172
+ end
173
+
174
+ def describe(result)
175
+ if result.native?
176
+ build = result.props["build_info"].to_s
177
+ @stdout.puts("found: llama.cpp at #{result.candidate.root}#{build.empty? ? "" : " (build #{build})"}")
178
+ else
179
+ @stdout.puts("found: an OpenAI-compatible API at #{result.candidate.base}")
180
+ end
181
+ end
182
+
183
+ def pick_model(result, wanted)
184
+ ids = result.models.map(&:id).reject(&:empty?)
185
+ if wanted
186
+ return wanted if ids.empty?
187
+
188
+ match = ids.find { |id| id == wanted } || ids.find { |id| id.casecmp?(wanted) }
189
+ return match if match
190
+
191
+ @stderr.puts("chi bootstrap: #{result.candidate.label} has no model #{wanted}; it lists:")
192
+ ids.each { |id| @stderr.puts(" #{id}") }
193
+ @exit = 1
194
+ return nil
195
+ end
196
+ return fail!("#{result.candidate.label} lists no models; pass one: --model ID") if ids.empty?
197
+ return ids.first if ids.size == 1
198
+ return pick(ids, "model") || fail!("nothing picked") if tty?
199
+
200
+ @stderr.puts("chi bootstrap: #{result.candidate.label} has #{ids.size} models:")
201
+ ids.each { |id| @stderr.puts(" #{id}") }
202
+ @stderr.puts("pick one: --model ID")
203
+ @exit = 2
204
+ nil
205
+ end
206
+
207
+ def show_model(result, model, key_env)
208
+ @stdout.puts("model: #{model}")
209
+ return unless result.native?
210
+
211
+ props = @probe.model_props(result.candidate, model, key_env: key_env) || result.props
212
+ n_ctx = props.dig("default_generation_settings", "n_ctx")
213
+ @stdout.puts("context: #{n_ctx} tokens") if n_ctx.is_a?(Integer) && n_ctx.positive?
214
+ profile, evidence = ModelProfile.fingerprint(props)
215
+ @stdout.puts("profile: #{profile} (chat template: #{evidence})") if profile
216
+ end
217
+
218
+ def test(result, model, key_env)
219
+ done = Queue.new
220
+ waiting = Thread.new do
221
+ @stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
222
+ end
223
+ seconds = @probe.test_turn(result.candidate, model, key_env: key_env)
224
+ @stdout.puts(format("test: answered in %.1f s", seconds))
225
+ true
226
+ rescue StandardError => e
227
+ @stdout.puts("test: failed: #{e.message}")
228
+ false
229
+ ensure
230
+ done&.push(true)
231
+ waiting&.join
232
+ end
233
+
234
+ # 0 written, 1 written but not as planned, nil with @exit on a refusal.
235
+ def write(result, model, key_env, options)
236
+ writer = Bootstrap::ConfigWriter.new(path: @config_path, env: @env)
237
+ fields = host_fields(result.candidate, result.native?, key_env)
238
+ ids = result.models.map(&:id)
239
+ name = begin
240
+ writer.host_name(Bootstrap::ConfigWriter.derived_name(result.candidate.host), requested: options[:name], model_ids: ids)
241
+ rescue Bootstrap::ConfigWriter::Error => e
242
+ usage_error(e.message)
243
+ @exit = 2
244
+ return nil
245
+ end
246
+ outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
247
+ report(outcome, model)
248
+ end
249
+
250
+ # The hosts entry: host/port for a plain http server (api: openai when it
251
+ # isn't llama.cpp), url for https or a URL with its own path.
252
+ def host_fields(candidate, native, key_env)
253
+ fields = if candidate.url || candidate.scheme == "https"
254
+ { "url" => native ? candidate.root : candidate.base }
255
+ else
256
+ { "host" => candidate.host, "port" => candidate.port }
257
+ end
258
+ fields["api"] = "openai" unless native
259
+ fields["api_key_env"] = key_env if key_env
260
+ fields
261
+ end
262
+
263
+ def report(outcome, model)
264
+ path = outcome.path
265
+ case outcome.kind
266
+ when :dry_run
267
+ @stdout.puts("dry run: would write #{outcome.where} (#{path}):")
268
+ @stdout.puts(outcome.text.gsub(/^/, " "))
269
+ @stdout.puts("and print: #{outcome.model_hint}") if outcome.model_hint
270
+ 0
271
+ when :new
272
+ @stdout.puts("config: #{path} (new)")
273
+ next_steps
274
+ 0
275
+ when :appended
276
+ @stdout.puts("config: #{path} (hosts entry '#{outcome.name}' added; backup #{File.basename(outcome.backup)})")
277
+ @stdout.puts("default.model: #{outcome.default_model}") if outcome.default_model
278
+ @stdout.puts("add under default: in config.yml:\n#{outcome.model_hint}") if outcome.model_hint
279
+ @stdout.puts("use it: chi --model #{outcome.name}:#{model} (or /model in a session)") unless outcome.default_model
280
+ 0
281
+ when :exists
282
+ @stdout.puts("config: #{path} already has this server as '#{outcome.existing}'; nothing written")
283
+ @stdout.puts("use it: chi --model #{outcome.existing}:#{model} (or /model in a session)")
284
+ 0
285
+ when :snippet
286
+ @stdout.puts("config: #{path} uses YAML chi won't edit (flow style or anchors); add this yourself:")
287
+ @stdout.puts(outcome.text.gsub(/^/, " "))
288
+ @stdout.puts("use it: chi --model #{outcome.name}:#{model}")
289
+ 0
290
+ else
291
+ @stderr.puts("chi bootstrap: the edited #{path} didn't read back as expected; " \
292
+ "it is back as it was (#{File.basename(outcome.backup)}). Add this yourself:")
293
+ @stderr.puts(outcome.text.gsub(/^/, " "))
294
+ 1
295
+ end
296
+ end
297
+
298
+ def next_steps
299
+ lines = [["chi", "a session"], ["chi web --open", "the web UI"], ["chi bundle list", "optional bundles (guardrails, …)"]]
300
+ lines << ["chi desktop install", "macOS \"Send to chi\" helper"] if @platform.to_s.include?("darwin")
301
+ lines.each_with_index do |(command, what), i|
302
+ @stdout.puts("#{i.zero? ? "next:" : " "} #{command.ljust(20)} # #{what}")
303
+ end
304
+ end
305
+
306
+ # A numbered list (the first PICK_SHOWN; a typed word narrows it).
307
+ def pick(items, noun)
308
+ shown = items
309
+ loop do
310
+ shown.first(PICK_SHOWN).each_with_index { |item, i| @stdout.puts(format(" %2d) %s", i + 1, item)) }
311
+ more = shown.size - PICK_SHOWN
312
+ @stdout.puts(" … #{more} more: type part of a name to narrow the list") if more.positive?
313
+ @stdout.print("#{noun} (number#{items.size > PICK_SHOWN ? " or text" : ""}, empty to stop): ")
314
+ answer = @stdin.gets.to_s.strip
315
+ return nil if answer.empty?
316
+
317
+ number = Integer(answer, exception: false)
318
+ return shown[number - 1] if number && number.between?(1, [shown.size, PICK_SHOWN].min)
319
+
320
+ narrowed = items.select { |item| item.downcase.include?(answer.downcase) }
321
+ if narrowed.empty?
322
+ @stdout.puts("no #{noun} matches '#{answer}'")
323
+ else
324
+ shown = narrowed
325
+ return shown.first if shown.size == 1
326
+ end
327
+ end
328
+ end
329
+
330
+ def tty?
331
+ @stdin.respond_to?(:tty?) && @stdin.tty?
332
+ end
333
+
334
+ def unset?(name) = @env[name].to_s.strip.empty?
335
+
336
+ def fail!(message)
337
+ @stderr.puts("chi bootstrap: #{message}")
338
+ @exit = 1
339
+ nil
340
+ end
341
+
342
+ def usage_error(message)
343
+ @stderr.puts("chi bootstrap: #{message} (see chi bootstrap --help)")
344
+ nil
345
+ end
346
+ end
347
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Samagotchi
7
+ class Bridge
8
+ # The running turn's open card with actions (check-in's Nudge / Keep
9
+ # going / Stop) as a file in the session's folder, pending_card.json
10
+ # {id:, bundle:}, for `chi web`'s session hub: it watches files, not
11
+ # workers, and a tab behind notifies from its summary (notify.js), as
12
+ # for a pending question. A persistent observer, like CardStore.
13
+ #
14
+ # Written when such a card shows; removed when that card comes again
15
+ # without actions (resolved), when the turn ends, and when the Bridge
16
+ # starts or stops. A card with no actions (loop-guard's warn) never
17
+ # writes it, nor does a card between turns.
18
+ class PendingCard
19
+ FILE = "pending_card.json"
20
+ TURN_ENDS = %i[turn_completed turn_canceled turn_failed].freeze
21
+
22
+ # @param session_dir [String] the session's folder
23
+ # @return [Hash, nil] {id:, bundle:}, nil without a readable file
24
+ def self.read(session_dir)
25
+ data = JSON.parse(File.read(File.join(session_dir, FILE)))
26
+ return nil unless data.is_a?(Hash) && !data["id"].to_s.empty?
27
+
28
+ { id: data["id"].to_s, bundle: data["bundle"].to_s }
29
+ rescue StandardError
30
+ nil
31
+ end
32
+
33
+ def initialize(session_dir)
34
+ @path = File.join(session_dir, FILE)
35
+ @mutex = Mutex.new
36
+ @id = nil
37
+ end
38
+
39
+ def call(event)
40
+ @mutex.synchronize { fold(event) }
41
+ rescue StandardError
42
+ nil # never break the running turn
43
+ end
44
+
45
+ # No card is open (a start after a worker that died with one, a stop).
46
+ def clear
47
+ @mutex.synchronize do
48
+ @id = nil
49
+ FileUtils.rm_f(@path)
50
+ end
51
+ rescue StandardError
52
+ nil
53
+ end
54
+
55
+ private
56
+
57
+ def fold(event)
58
+ case event[:type]
59
+ when :card then card(event)
60
+ when *TURN_ENDS then remove
61
+ end
62
+ end
63
+
64
+ def card(event)
65
+ asks = Array(event[:actions]).any?
66
+ if event[:in_turn] && asks
67
+ write(event[:id].to_s, event[:source].to_s)
68
+ elsif @id && event[:id].to_s == @id
69
+ remove
70
+ end
71
+ end
72
+
73
+ def write(id, bundle)
74
+ FileUtils.mkdir_p(File.dirname(@path))
75
+ temp = "#{@path}.tmp"
76
+ File.write(temp, JSON.generate({ "id" => id, "bundle" => bundle }))
77
+ File.rename(temp, @path)
78
+ @id = id
79
+ end
80
+
81
+ def remove
82
+ return unless @id
83
+
84
+ @id = nil
85
+ FileUtils.rm_f(@path)
86
+ end
87
+ end
88
+ end
89
+ end