samagotchi 0.79.0 → 0.80.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +7 -4
  6. data/docs/internals/anthropic.md +3 -1
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +13 -3
  9. data/docs/plugins.md +144 -3
  10. data/docs/releasing.md +2 -2
  11. data/docs/sessions.md +1 -1
  12. data/docs/testing.md +32 -2
  13. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  14. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  15. data/lib/samagotchi/bootstrap_command.rb +92 -15
  16. data/lib/samagotchi/bridge.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  19. data/lib/samagotchi/continue_offer.rb +5 -5
  20. data/lib/samagotchi/engine.rb +40 -37
  21. data/lib/samagotchi/guardrails/approval.rb +6 -4
  22. data/lib/samagotchi/guardrails/gate.rb +7 -3
  23. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  24. data/lib/samagotchi/hooks/registry.rb +44 -3
  25. data/lib/samagotchi/host_registry.rb +12 -4
  26. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
  29. data/lib/samagotchi/llm/http.rb +12 -7
  30. data/lib/samagotchi/plugin/card.rb +44 -0
  31. data/lib/samagotchi/plugin/context.rb +53 -8
  32. data/lib/samagotchi/plugin/http.rb +154 -0
  33. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  34. data/lib/samagotchi/plugin/loader.rb +29 -10
  35. data/lib/samagotchi/question_answer.rb +29 -0
  36. data/lib/samagotchi/question_desk.rb +20 -19
  37. data/lib/samagotchi/relay_verifier.rb +1 -1
  38. data/lib/samagotchi/testing/boundary.rb +156 -0
  39. data/lib/samagotchi/testing/bundle.rb +349 -0
  40. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  41. data/lib/samagotchi/testing/fake_http.rb +137 -0
  42. data/lib/samagotchi/testing/helpers.rb +56 -0
  43. data/lib/samagotchi/testing/host.rb +76 -0
  44. data/lib/samagotchi/testing/minitest.rb +31 -0
  45. data/lib/samagotchi/testing/recorder.rb +138 -0
  46. data/lib/samagotchi/testing/recording_api.rb +27 -0
  47. data/lib/samagotchi/testing/rspec.rb +31 -0
  48. data/lib/samagotchi/testing/sandbox.rb +163 -0
  49. data/lib/samagotchi/testing/sessions.rb +77 -0
  50. data/lib/samagotchi/testing/verdict.rb +28 -0
  51. data/lib/samagotchi/testing/waiting.rb +36 -0
  52. data/lib/samagotchi/testing.rb +26 -0
  53. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  54. data/lib/samagotchi/tools/tool_index.rb +3 -0
  55. data/lib/samagotchi/turn_state.rb +28 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/public/app.js +47 -23
  58. data/lib/samagotchi/web/public/images.js +33 -0
  59. data/lib/samagotchi/web/public/index.html +71 -38
  60. data/lib/samagotchi/web/public/level_chip.js +11 -11
  61. data/lib/samagotchi/web/public/lists_view.js +19 -14
  62. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  63. data/lib/samagotchi/web/public/model_picker.js +1 -1
  64. data/lib/samagotchi/web/public/scope.js +3 -3
  65. data/lib/samagotchi/web/public/strip.js +35 -9
  66. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  67. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  68. metadata +21 -1
data/docs/testing.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # Testing
2
2
 
3
3
  ```sh
4
- bundle exec parallel_rspec -n 8 # Ruby specs, in parallel (or plain: bundle exec rspec)
4
+ bundle exec parallel_rspec -n 8 spec lib/samagotchi/bundles/*/spec # Ruby specs, both roots, in parallel
5
+ bundle exec rspec spec lib/samagotchi/bundles/*/spec # the same, plain
6
+ bundle exec rake bundles:specs # the shipped bundles' own specs, on the bundle test kit alone
5
7
  npm test # web frontend specs
6
8
  npm run e2e # web UI happy paths in Chromium, fake model (once: npx playwright install chromium)
7
9
  npx playwright test spec/e2e/stage.spec.js # one e2e file (the root playwright.config.js is the suite's)
@@ -53,6 +55,34 @@ Every example also runs under a 120 s limit (`spec_helper`'s `around`; `integrat
53
55
  `recap` examples are exempt), so a hang fails its example instead of the run. `SPEC_EXAMPLE_TIMEOUT=N` changes the
54
56
  limit, `0` turns it off (a debugger session).
55
57
 
58
+ ## Bundle specs: the test kit
59
+
60
+ A shipped bundle's plugin and hook files are tested through the bundle test kit (`samagotchi/testing`,
61
+ [plugins.md](plugins.md#testing-a-bundle-experimental)): `shipped_bundle("check-in", settings: …)` loads it through
62
+ chi's own loaders, registries, `ctx` and guardrail Gate, with the session, the hook helpers and `ctx.sessions`
63
+ recorded. No fake `chi` or `ctx`: what the kit can't express is a kit change (with a kit spec), not a local fake.
64
+
65
+ These specs live **next to the bundle**, in `lib/samagotchi/bundles/<name>/spec/` (their fixtures in its
66
+ `spec/fixtures/`), and require only `samagotchi/testing/rspec` (tagged `:bundle` by their path): no `spec_helper`, no
67
+ `spec/support`, as an external bundle's specs would run. `rake bundles:specs` runs them that way
68
+ (`rspec --options /dev/null -r samagotchi/testing/rspec`), in CI's bundles job; the full suite runs them too, under
69
+ `spec_helper`, given both roots (plain `rspec` runs `spec/` only: `.rspec` holds no `--pattern`, which would make
70
+ `rspec FILE` run everything). They are never shipped: the gem and an install leave `spec/` out, and the release
71
+ tools' version check ignores a bundle's `spec/` (a spec-only change needs no bump). A spec that runs real turns
72
+ (install → Engine → `run_turn`) stays in `spec/samagotchi/plugin/<name>_bundle_spec.rb`.
73
+
74
+ The kit's own specs, in `spec/samagotchi/testing/`, keep it and the bundles honest:
75
+
76
+ - `spec/samagotchi/testing/contract_spec.rb`: the kit sets every `Plugin::Host` and `Hooks::Runtime` member,
77
+ `Testing::Sessions` has `Plugin::Sessions`' methods and parameters, and each `ctx` helper lands in its recorder. A
78
+ member added to the Host fails it until the kit has it.
79
+ - `spec/samagotchi/testing/shipped_bundles_spec.rb`: each shipped bundle with a plugin or hooks registers the same
80
+ commands, tools, hooks, inits, toolkits and sources loaded by the kit as installed and loaded by chi.
81
+ - `spec/samagotchi/testing/boundary_spec.rb`, the boundary lint: each bundle's Ruby (plugin, hooks, scripts, specs)
82
+ reaches chi only through `Samagotchi::Testing::Boundary::ALLOWED` (the "Stable for bundles" list) and that
83
+ bundle's `Boundary::EXCEPTIONS` entry, each with why. A new internal reference fails it, and so does an exception
84
+ no longer used. Fix the reference (the kit or a stable API), or add the exception with its why.
85
+
56
86
  ## While you work: affected specs
57
87
 
58
88
  ```sh
@@ -73,7 +103,7 @@ The Ruby specs go to stdout; the node tests to run (`npm test` targets), "web ch
73
103
  hint for a hub class (named in too many specs to list) go to stderr. Nothing printed means nothing matched.
74
104
 
75
105
  It finds a change's own specs, not the regressions it causes elsewhere. So before committing, run the full suite
76
- (`bundle exec parallel_rspec -n 8`), unless your task says the coordinator runs it; the coordinator runs it at merge
106
+ (`bundle exec parallel_rspec -n 8 spec lib/samagotchi/bundles/*/spec`), unless your task says the coordinator runs it; the coordinator runs it at merge
77
107
  either way.
78
108
 
79
109
  ## Worktrees
@@ -6,6 +6,7 @@ require "fileutils"
6
6
  require "ipaddr"
7
7
  require_relative "../atomic_file"
8
8
  require_relative "../config"
9
+ require_relative "../host_config"
9
10
  require_relative "../yaml_lines"
10
11
  require_relative "../redacted_inspect"
11
12
 
@@ -91,6 +92,8 @@ module Samagotchi
91
92
  end
92
93
 
93
94
  # The name of an existing entry that points at the same server, or nil.
95
+ # An api: anthropic entry and any other on one server are different
96
+ # hosts (Fireworks serves both protocols).
94
97
  def duplicate_of(fields)
95
98
  current_hosts.find { |_, existing| same_server?(existing, fields) }&.first
96
99
  end
@@ -267,15 +270,22 @@ module Samagotchi
267
270
  def same_server?(existing, fields)
268
271
  a = location(existing)
269
272
  b = location(fields)
270
- a && b && a == b
273
+ a && b && a == b && anthropic?(existing) == anthropic?(fields)
271
274
  end
272
275
 
273
- # [scheme-less host, port, path] of an entry.
276
+ def anthropic?(fields) = fields["api"].to_s.strip.downcase == "anthropic"
277
+
278
+ # [scheme-less host, port, path] of an entry. An api: anthropic url's
279
+ # path loses a trailing /v1 (the adapter adds it), and one with
280
+ # neither url: nor host: is Anthropic's own API (HostConfig's rule).
274
281
  def location(fields)
275
282
  url = fields["url"].to_s.strip
283
+ url = ANTHROPIC_URL if url.empty? && anthropic?(fields) && fields["host"].to_s.strip.empty? && fields["port"].to_s.strip.empty?
276
284
  unless url.empty?
277
285
  uri = URI.parse(url)
278
- return [uri.host.to_s.downcase, uri.port, uri.path.to_s.chomp("/")]
286
+ path = uri.path.to_s.chomp("/")
287
+ path = path.delete_suffix("/v1") if anthropic?(fields)
288
+ return [uri.host.to_s.downcase, uri.port, path]
279
289
  end
280
290
  host = fields["host"].to_s.strip.downcase
281
291
  return nil if host.empty?
@@ -7,6 +7,8 @@ require "uri"
7
7
  require "ipaddr"
8
8
  require_relative "../version"
9
9
  require_relative "../llm/openai_chat"
10
+ require_relative "../llm/anthropic_messages"
11
+ require_relative "../llm/dialect"
10
12
  require_relative "../llm/http"
11
13
  require_relative "../llm/api_key"
12
14
  require_relative "../host_keys"
@@ -15,9 +17,12 @@ require_relative "../redacted_inspect"
15
17
  module Samagotchi
16
18
  module Bootstrap
17
19
  # What `chi bootstrap TARGET` finds at a model server: llama.cpp's native
18
- # API (/props), an OpenAI-compatible one (/v1/models), one that wants an
19
- # API key, or nothing. One attempt per request, no retries: a refused
20
- # port answers at once instead of after chi's usual backoff.
20
+ # API (/props), an OpenAI-compatible one (/v1/models), Anthropic's
21
+ # Messages API (its /v1/models, asked only after the OpenAI list was
22
+ # refused with 400/401/403: the Anthropic list reads an OpenAI-shaped
23
+ # one too), one that wants an API key, or nothing. One attempt per
24
+ # request, no retries: a refused port answers at once instead of after
25
+ # chi's usual backoff.
21
26
  class Probe
22
27
  include RedactedInspect
23
28
 
@@ -31,6 +36,14 @@ module Samagotchi
31
36
  SCAN_TIMEOUT = 1
32
37
  TEST_TIMEOUT = 60
33
38
  TEST_PROMPT = "Reply with the word ok."
39
+ # The OpenAI list's statuses after which the Anthropic list is asked:
40
+ # Anthropic answers a Bearer request 400 (no anthropic-version) or
41
+ # 401/403. Not 404/405: a typed URL path sends the two lists to
42
+ # different paths, so a 404 there says nothing about Anthropic.
43
+ ANTHROPIC_AFTER = [400, 401, 403].freeze
44
+ AUTH_STATUSES = [401, 403].freeze
45
+ FIREWORKS_HOST = "fireworks.ai"
46
+ FIREWORKS_MODEL_PREFIX = "accounts/fireworks/"
34
47
 
35
48
  # One place a server may be: root is scheme://host:port (llama.cpp's
36
49
  # /props), base the OpenAI API base (<root>/v1, or a URL's own path).
@@ -44,17 +57,34 @@ module Samagotchi
44
57
  def label = "#{host}:#{port}"
45
58
  end
46
59
 
47
- # kind: :native, :openai, :needs_key, :unreachable or :unknown.
48
- # props: the /props JSON (native); models: [LLM::ModelInfo];
60
+ # kind: :native, :openai, :anthropic, :needs_key, :unreachable or
61
+ # :unknown. props: the /props JSON (native); models: [LLM::ModelInfo];
49
62
  # status: the HTTP status that decided (:unknown, :needs_key);
50
- # reason: why it's unreachable.
51
- Result = Data.define(:kind, :candidate, :props, :models, :status, :reason) do
52
- def self.of(kind, candidate, props: nil, models: [], status: nil, reason: nil)
53
- new(kind: kind, candidate: candidate, props: props, models: models, status: status, reason: reason)
63
+ # reason: why it's unreachable; dialect: an :anthropic result's
64
+ # LLM::Dialect name when not the standard one ("fireworks"), else nil;
65
+ # auth: :bearer when an :anthropic list took the key only as Bearer
66
+ # (a Symbol, LLM::ApiKey::SCHEMES), else nil.
67
+ Result = Data.define(:kind, :candidate, :props, :models, :status, :reason, :dialect, :auth) do
68
+ def self.of(kind, candidate, props: nil, models: [], status: nil, reason: nil, dialect: nil, auth: nil)
69
+ new(kind: kind, candidate: candidate, props: props, models: models, status: status, reason: reason,
70
+ dialect: dialect, auth: auth)
54
71
  end
55
72
 
56
73
  def native? = kind == :native
57
- def reached? = %i[native openai].include?(kind)
74
+ def anthropic? = kind == :anthropic
75
+ def reached? = %i[native openai anthropic].include?(kind)
76
+ end
77
+
78
+ # A failed test turn: the message, and the HTTP status and request path
79
+ # when the server answered (BootstrapCommand's --api anthropic hint).
80
+ class TestFailed < RuntimeError
81
+ attr_reader :status, :path
82
+
83
+ def initialize(message = nil, status: nil, path: nil)
84
+ @status = status
85
+ @path = path
86
+ super(message)
87
+ end
58
88
  end
59
89
 
60
90
  # The target the user typed, as the candidates to try in order.
@@ -110,6 +140,21 @@ module Samagotchi
110
140
  false
111
141
  end
112
142
 
143
+ # The Messages API root: the OpenAI base without its trailing /v1
144
+ # (HostEntry#api_base_url's rule), e.g. https://api.anthropic.com.
145
+ def self.api_root(candidate) = candidate.base.chomp("/").delete_suffix("/v1")
146
+
147
+ # Fireworks by its host (fireworks.ai or a subdomain), or by a model id
148
+ # in its accounts/fireworks/ namespace.
149
+ def self.fireworks?(candidate, models = [])
150
+ fireworks_host?(candidate.host) || models.any? { |m| m.id.to_s.start_with?(FIREWORKS_MODEL_PREFIX) }
151
+ end
152
+
153
+ def self.fireworks_host?(host)
154
+ name = host.to_s.downcase
155
+ name == FIREWORKS_HOST || name.end_with?(".#{FIREWORKS_HOST}")
156
+ end
157
+
113
158
  def self.parse_uri(text)
114
159
  URI.parse(text)
115
160
  rescue URI::InvalidURIError
@@ -128,18 +173,27 @@ module Samagotchi
128
173
  # when this one refused the connection or failed its TLS handshake
129
174
  # (https → http), not on a timeout.
130
175
  # @param key_env [String, nil] the API key's environment variable
176
+ # @param api [Symbol, nil] :openai or :anthropic to skip the detection
177
+ # (--api); nil detects
178
+ # @param dialect [String, nil] the Messages dialect (--dialect); nil
179
+ # detects Fireworks
131
180
  # @return [Result]
132
- def classify_target(candidates, key_env: nil)
181
+ def classify_target(candidates, key_env: nil, api: nil, dialect: nil)
133
182
  result = nil
134
183
  candidates.each do |candidate|
135
- result = classify(candidate, key_env: key_env)
184
+ result = classify(candidate, key_env: key_env, api: api, dialect: dialect)
136
185
  break unless result.kind == :unreachable && result.reason.to_s.match?(/refused|SSL|TLS|certificate/i)
137
186
  end
138
187
  result
139
188
  end
140
189
 
190
+ # /props (llama.cpp), then the OpenAI list, then (only after the OpenAI
191
+ # list's 400/401/403) the Anthropic one. api: :anthropic asks only the
192
+ # Anthropic list; api: :openai never asks it.
141
193
  # @return [Result]
142
- def classify(candidate, key_env: nil)
194
+ def classify(candidate, key_env: nil, api: nil, dialect: nil)
195
+ return classify_anthropic(candidate, key_env, dialect, nil) if api&.to_sym == :anthropic
196
+
143
197
  props = begin
144
198
  status, body = get(URI("#{candidate.root}/props"), key_env)
145
199
  status == 200 ? parse_object(body) : nil
@@ -151,8 +205,9 @@ module Samagotchi
151
205
  begin
152
206
  Result.of(:openai, candidate, models: list_models(candidate.base, key_env))
153
207
  rescue LLM::ProviderError => e
154
- kind = [401, 403].include?(e.status) ? :needs_key : :unknown
155
- Result.of(kind, candidate, status: e.status, reason: e.status ? nil : e.message)
208
+ return failure(candidate, e) if api || !ANTHROPIC_AFTER.include?(e.status)
209
+
210
+ classify_anthropic(candidate, key_env, dialect, e)
156
211
  rescue *network_errors => e
157
212
  Result.of(:unreachable, candidate, reason: reason(e))
158
213
  end
@@ -178,25 +233,27 @@ module Samagotchi
178
233
 
179
234
  # One short chat request: does the server generate for +model+? Any 200
180
235
  # with a choice counts (a thinking model may spend its 16 tokens
181
- # reasoning). @return [Float] seconds taken
182
- # @raise [RuntimeError] with what went wrong
183
- def test_turn(candidate, model, key_env: nil, read_timeout: TEST_TIMEOUT)
184
- uri = URI("#{candidate.base}/chat/completions")
185
- request = Net::HTTP::Post.new(uri)
186
- request["Content-Type"] = "application/json"
187
- request.body = JSON.generate(model: model, messages: [{ role: "user", content: TEST_PROMPT }], max_tokens: 16,
188
- stream: false)
236
+ # reasoning). api: :anthropic posts {root}/v1/messages instead, and any
237
+ # 200 with a content array counts (stop_reason max_tokens too).
238
+ # @param auth [Symbol, nil] the key's header on :anthropic (nil: the
239
+ # dialect's)
240
+ # @return [Float] seconds taken
241
+ # @raise [TestFailed] with what went wrong
242
+ def test_turn(candidate, model, key_env: nil, api: :openai, dialect: nil, auth: nil, read_timeout: TEST_TIMEOUT)
243
+ anthropic = api&.to_sym == :anthropic
244
+ uri, request, scheme = anthropic ? messages_request(candidate, model, dialect, auth) : chat_request(candidate, model)
189
245
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
190
- response = send_request(uri, request, key_env, read_timeout: read_timeout)
246
+ response = send_request(uri, request, key_env, read_timeout: read_timeout, scheme: scheme)
191
247
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
192
248
  body = parse_object(response.body)
193
- unless response.code.to_i == 200 && body && body["choices"].is_a?(Array) && !body["choices"].empty?
194
- raise "HTTP #{response.code}: #{error_text(body, response.body)}"
249
+ unless response.code.to_i == 200 && answered?(body, anthropic ? "content" : "choices")
250
+ raise TestFailed.new("HTTP #{response.code}: #{error_text(body, response.body)}", status: response.code.to_i,
251
+ path: uri.path)
195
252
  end
196
253
 
197
254
  elapsed
198
255
  rescue *network_errors => e
199
- raise "#{reason(e)} (#{candidate.label})"
256
+ raise TestFailed, "#{reason(e)} (#{candidate.label})"
200
257
  end
201
258
 
202
259
  private
@@ -224,16 +281,81 @@ module Samagotchi
224
281
  timeout: @timeout, env: @env).list_models
225
282
  end
226
283
 
284
+ # The Anthropic list at the candidate's API root, the key in the
285
+ # dialect's header (Bearer on Fireworks, detected by its host before
286
+ # listing); a 401/403 with the standard dialect's x-api-key is asked
287
+ # once more with Bearer (a gateway that wants it). Ids in Fireworks'
288
+ # namespace make the result's dialect fireworks. +openai_error+ is the
289
+ # OpenAI list's refusal, the result again when the Anthropic list fails
290
+ # other than 401/403 (nil when --api anthropic skipped it).
291
+ def classify_anthropic(candidate, key_env, dialect, openai_error)
292
+ forced = dialect
293
+ dialect ||= LLM::Dialect::FIREWORKS.name if self.class.fireworks_host?(candidate.host)
294
+ auth = nil
295
+ models = begin
296
+ anthropic_models(candidate, key_env, dialect: dialect, auth: nil)
297
+ rescue LLM::ProviderError => e
298
+ raise unless AUTH_STATUSES.include?(e.status) && key_env && LLM::Dialect.for(dialect).auth != :bearer
299
+
300
+ auth = :bearer
301
+ anthropic_models(candidate, key_env, dialect: dialect, auth: auth)
302
+ end
303
+ dialect ||= LLM::Dialect::FIREWORKS.name if forced.nil? && self.class.fireworks?(candidate, models)
304
+ dialect = nil if dialect == LLM::Dialect::STANDARD.name
305
+ Result.of(:anthropic, candidate, models: models, dialect: dialect, auth: auth)
306
+ rescue LLM::ProviderError => e
307
+ return Result.of(:needs_key, candidate, status: e.status) if AUTH_STATUSES.include?(e.status)
308
+
309
+ failure(candidate, openai_error || e)
310
+ rescue *network_errors => e
311
+ Result.of(:unreachable, candidate, reason: reason(e))
312
+ end
313
+
314
+ def anthropic_models(candidate, key_env, dialect:, auth:)
315
+ LLM::AnthropicMessages.new(base_url: self.class.api_root(candidate), host_name: candidate.host, api_key_env: key_env,
316
+ auth: auth, dialect: dialect, stream: false, retries: false, timeout: @timeout,
317
+ env: @env).list_models
318
+ end
319
+
320
+ # A list's refusal as a Result: :needs_key on 401/403, else :unknown.
321
+ def failure(candidate, error)
322
+ kind = AUTH_STATUSES.include?(error.status) ? :needs_key : :unknown
323
+ Result.of(kind, candidate, status: error.status, reason: error.status ? nil : error.message)
324
+ end
325
+
326
+ def chat_request(candidate, model)
327
+ uri = URI("#{candidate.base}/chat/completions")
328
+ request = json_post(uri, model: model, messages: [{ role: "user", content: TEST_PROMPT }], max_tokens: 16, stream: false)
329
+ [uri, request, :bearer]
330
+ end
331
+
332
+ # No stream and no thinking field: the model's own defaults.
333
+ def messages_request(candidate, model, dialect, auth)
334
+ uri = URI("#{self.class.api_root(candidate)}/v1/messages")
335
+ request = json_post(uri, model: model, max_tokens: 16, messages: [{ role: "user", content: TEST_PROMPT }])
336
+ request["anthropic-version"] = LLM::AnthropicMessages::API_VERSION
337
+ [uri, request, auth || LLM::Dialect.for(dialect).auth]
338
+ end
339
+
340
+ def json_post(uri, body)
341
+ request = Net::HTTP::Post.new(uri)
342
+ request["Content-Type"] = "application/json"
343
+ request.body = JSON.generate(body)
344
+ request
345
+ end
346
+
347
+ def answered?(body, key) = body.is_a?(Hash) && body[key].is_a?(Array) && !body[key].empty?
348
+
227
349
  def get(uri, key_env)
228
350
  response = send_request(uri, Net::HTTP::Get.new(uri), key_env, read_timeout: @timeout)
229
351
  [response.code.to_i, response.body]
230
352
  end
231
353
 
232
- # One attempt through LLM::HTTP (User-Agent, the key's Bearer header),
233
- # any status returned as is. A key neither its variable nor the
354
+ # One attempt through LLM::HTTP (User-Agent, the key in +scheme+'s
355
+ # header), any status returned as is. A key neither its variable nor the
234
356
  # Keychain holds sends no header: the server's 401 then says one is needed.
235
- def send_request(uri, request, key_env, read_timeout:)
236
- key = key_env && HostKeys.fetch(key_env, env: @env) ? LLM::ApiKey.for(key_env, host: uri.host, env: @env) : nil
357
+ def send_request(uri, request, key_env, read_timeout:, scheme: :bearer)
358
+ key = key_env && HostKeys.fetch(key_env, env: @env) ? LLM::ApiKey.for(key_env, host: uri.host, env: @env, scheme: scheme) : nil
237
359
  LLM::HTTP.new(label: uri.host, open_timeout: @timeout, read_timeout: read_timeout, api_key: key,
238
360
  retry_policy: LLM::HTTP::RetryPolicy.none)
239
361
  .fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "probe" })
@@ -14,9 +14,10 @@ require_relative "redacted_inspect"
14
14
 
15
15
  module Samagotchi
16
16
  # `chi bootstrap [TARGET]`: the first setup. It names a model server, works
17
- # out what it is (llama.cpp's native API or an OpenAI-compatible one),
18
- # picks the model, sends one test request and writes config.yml: a fresh
19
- # file, or a hosts: entry added to an existing one. Then it installs the
17
+ # out what it is (llama.cpp's native API, an OpenAI-compatible one or
18
+ # Anthropic's Messages API, Bootstrap::Probe), picks the model, sends one
19
+ # test request and writes config.yml: a fresh file, or a hosts: entry
20
+ # added to an existing one. Then it installs the
20
21
  # system bundle and the core profile, and on a terminal asks about dev.
21
22
  # Prompts only on a terminal; a script gets the list and exit 2 instead.
22
23
  class BootstrapCommand
@@ -24,11 +25,14 @@ module Samagotchi
24
25
  include RedactedInspect
25
26
 
26
27
  USAGE = <<~TEXT
27
- Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--no-test] [--dry-run]
28
+ Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--api API] [--dialect NAME]
29
+ [--no-test] [--dry-run]
28
30
  Finds the model server at TARGET and writes config.yml for it.
29
31
  chi bootstrap 192.168.1.29:8081 llama.cpp on the LAN
30
32
  chi bootstrap localhost:11434 Ollama
31
33
  chi bootstrap https://openrouter.ai/api/v1 --key-env OPENROUTER_API_KEY
34
+ chi bootstrap https://api.anthropic.com --key-env ANTHROPIC_API_KEY
35
+ chi bootstrap https://api.fireworks.ai/inference/v1 --key-env FIREWORKS_API_KEY --api anthropic
32
36
  chi bootstrap look on this machine's usual ports
33
37
  TARGET host[:port] (port 8080 when none), or a URL; a URL's
34
38
  path is the OpenAI API base
@@ -37,6 +41,12 @@ module Samagotchi
37
41
  --model ID the model, when the server has several
38
42
  --key-env VAR the environment variable holding the API key (the
39
43
  key itself is never written)
44
+ --api API openai or anthropic (Anthropic's Messages API) instead
45
+ of detecting it; detection takes anthropic only when
46
+ the OpenAI model list is refused
47
+ --dialect NAME the Messages API's dialect: standard or fireworks
48
+ (implies --api anthropic; default: fireworks when the
49
+ host or the model ids say so)
40
50
  --no-test skip the test request
41
51
  --dry-run show what would be written; write nothing
42
52
  With no config.yml it writes one; an existing one gets a hosts:
@@ -50,10 +60,13 @@ module Samagotchi
50
60
  f.value "--name"
51
61
  f.value "--model"
52
62
  f.value "--key-env"
63
+ f.value "--api"
64
+ f.value "--dialect"
53
65
  f.switch "--no-test"
54
66
  f.switch "--dry-run"
55
67
  end
56
68
 
69
+ APIS = %i[openai anthropic].freeze
57
70
  PICK_SHOWN = 20
58
71
  # A test request slower than this says it is waiting (a model loading).
59
72
  LOADING_AFTER = 3
@@ -92,6 +105,8 @@ module Samagotchi
92
105
  return @exit
93
106
  end
94
107
 
108
+ @api = options[:api]
109
+ @dialect = options[:dialect]
95
110
  result = find(options, key_env) or return @exit
96
111
  unlocked = with_key(result, key_env) or return @exit
97
112
  result, key_env = unlocked
@@ -124,7 +139,25 @@ module Samagotchi
124
139
  return usage_error("--key-env takes the variable's name (e.g. OPENROUTER_API_KEY), not the key")
125
140
  end
126
141
 
127
- options
142
+ parse_api(options)
143
+ end
144
+
145
+ # --api as a Symbol, --dialect downcased (implying --api anthropic).
146
+ def parse_api(options)
147
+ if options[:api]
148
+ api = options[:api].strip.downcase.to_sym
149
+ return usage_error("--api must be #{APIS.join(" or ")} (got #{options[:api]})") unless APIS.include?(api)
150
+
151
+ options[:api] = api
152
+ end
153
+ return options unless options[:dialect]
154
+
155
+ dialect = options[:dialect].strip.downcase
156
+ names = LLM::Dialect::BY_NAME.keys
157
+ return usage_error("--dialect must be #{names.join(" or ")} (got #{options[:dialect]})") unless names.include?(dialect)
158
+ return usage_error("--dialect is for --api anthropic, not --api openai") if options[:api] == :openai
159
+
160
+ options.merge(dialect: dialect, api: :anthropic)
128
161
  end
129
162
 
130
163
  # The server's Probe::Result, or nil with @exit set.
@@ -137,17 +170,26 @@ module Samagotchi
137
170
  @exit = usage_error(e.message)
138
171
  return nil
139
172
  end
140
- result = @probe.classify_target(candidates, key_env: key_env)
173
+ result = @probe.classify_target(candidates, key_env: key_env, api: @api, dialect: @dialect)
141
174
  case result.kind
142
175
  when :unreachable
143
176
  fail!("can't reach #{result.candidate.label} (#{result.reason})")
144
177
  when :unknown
145
178
  detail = result.status ? "HTTP #{result.status}" : result.reason
146
- fail!("reached #{result.candidate.label} but it answers neither llama.cpp /props nor /v1/models (#{detail})")
179
+ fail!("reached #{result.candidate.label} but it answers #{unknown_asked} (#{detail})")
147
180
  else result
148
181
  end
149
182
  end
150
183
 
184
+ # What an :unknown server didn't answer, by what --api let the probe ask.
185
+ def unknown_asked
186
+ case @api
187
+ when :anthropic then "no Anthropic /v1/models"
188
+ when :openai then "neither llama.cpp /props nor /v1/models"
189
+ else "neither llama.cpp /props, /v1/models nor the Anthropic /v1/models"
190
+ end
191
+ end
192
+
151
193
  def scan
152
194
  found = @probe.scan_local
153
195
  ports = Bootstrap::Probe::LOCAL_PORTS.keys.join(", ")
@@ -190,7 +232,7 @@ module Samagotchi
190
232
  return fail!("#{key_env} isn't a variable name") unless key_env.match?(ConfigFile::ENV_NAME_RE)
191
233
  return fail!("#{key_env} is not set; #{export_hint(key_env, "and run chi bootstrap again")}") if unset?(key_env)
192
234
 
193
- result = @probe.classify(result.candidate, key_env: key_env)
235
+ result = @probe.classify(result.candidate, key_env: key_env, api: @api, dialect: @dialect)
194
236
  return fail!("#{host} refused the key in #{key_env} (HTTP #{result.status})") if result.kind == :needs_key
195
237
  return fail!("can't reach #{host} (#{result.reason || "HTTP #{result.status}"})") unless result.reached?
196
238
 
@@ -201,8 +243,15 @@ module Samagotchi
201
243
  if result.native?
202
244
  build = result.props["build_info"].to_s
203
245
  @stdout.puts("found: llama.cpp at #{result.candidate.root}#{" (build #{build})" unless build.empty?}")
246
+ elsif result.anthropic?
247
+ dialect = " (dialect: #{result.dialect})" if result.dialect
248
+ @stdout.puts("found: an Anthropic Messages API at #{Bootstrap::Probe.api_root(result.candidate)}#{dialect}")
204
249
  else
205
250
  @stdout.puts("found: an OpenAI-compatible API at #{result.candidate.base}")
251
+ # By the host only: a proxy listing accounts/fireworks/ ids may not serve /v1/messages.
252
+ if @api.nil? && Bootstrap::Probe.fireworks_host?(result.candidate.host)
253
+ @stdout.puts("Fireworks also serves the Anthropic Messages API: --api anthropic")
254
+ end
206
255
  end
207
256
  end
208
257
 
@@ -248,17 +297,28 @@ module Samagotchi
248
297
  @stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
249
298
  end
250
299
  end
251
- seconds = @probe.test_turn(result.candidate, model, key_env: key_env)
300
+ seconds = @probe.test_turn(result.candidate, model, key_env: key_env, api: result.anthropic? ? :anthropic : :openai,
301
+ dialect: result.dialect, auth: result.auth)
252
302
  @stdout.puts(format("test: answered in %.1f s", seconds))
253
303
  true
254
304
  rescue StandardError => e
255
- @stdout.puts("test: failed: #{e.message}")
305
+ @stdout.puts("test: failed: #{e.message}#{messages_hint(result, e)}")
256
306
  false
257
307
  ensure
258
308
  done&.push(true)
259
309
  waiting&.join
260
310
  end
261
311
 
312
+ # A detected OpenAI server whose model list worked but whose
313
+ # /chat/completions is 404: maybe a Messages-only url (a gateway's
314
+ # Anthropic route that also lists the OpenAI way).
315
+ def messages_hint(result, error)
316
+ return "" unless @api.nil? && result.kind == :openai && error.is_a?(Bootstrap::Probe::TestFailed)
317
+ return "" unless error.status == 404 && error.path.to_s.end_with?("/chat/completions")
318
+
319
+ " (an Anthropic Messages API? try --api anthropic)"
320
+ end
321
+
262
322
  # The "loading the model?" hint fits a local server (a llama.cpp loading
263
323
  # weights); a remote provider's slow answer is the network, not a load.
264
324
  def loading_hint?(result)
@@ -268,7 +328,7 @@ module Samagotchi
268
328
  # 0 written, 1 written but not as planned, nil with @exit on a refusal.
269
329
  def write(result, model, key_env, options)
270
330
  writer = Bootstrap::ConfigWriter.new(path: @config_path, env: @env)
271
- fields = host_fields(result.candidate, result.native?, key_env)
331
+ fields = host_fields(result, key_env)
272
332
  ids = result.models.map(&:id)
273
333
  base = Bootstrap::ConfigWriter.derived_name(result.candidate.host)
274
334
  name, reason = begin
@@ -350,18 +410,35 @@ module Samagotchi
350
410
  end
351
411
 
352
412
  # The hosts entry: host/port for a plain http server (api: openai when it
353
- # isn't llama.cpp), url for https or a URL with its own path.
354
- def host_fields(candidate, native, key_env)
413
+ # isn't llama.cpp), url for https or a URL with its own path (an
414
+ # Anthropic one without /v1: the API root, as the hand-written claude
415
+ # host has it). Then api, dialect, auth (Bearer only when the dialect's
416
+ # own header isn't), api_key_env.
417
+ def host_fields(result, key_env)
418
+ candidate = result.candidate
355
419
  fields = if candidate.url || candidate.scheme == "https"
356
- { "url" => native ? candidate.root : candidate.base }
420
+ { "url" => url_field(result) }
357
421
  else
358
422
  { "host" => candidate.host, "port" => candidate.port }
359
423
  end
360
- fields["api"] = "openai" unless native
424
+ if result.anthropic?
425
+ fields["api"] = "anthropic"
426
+ fields["dialect"] = result.dialect if result.dialect
427
+ fields["auth"] = result.auth.to_s if result.auth && result.auth != LLM::Dialect.for(result.dialect).auth
428
+ elsif !result.native?
429
+ fields["api"] = "openai"
430
+ end
361
431
  fields["api_key_env"] = key_env if key_env
362
432
  fields
363
433
  end
364
434
 
435
+ def url_field(result)
436
+ return result.candidate.root if result.native?
437
+ return Bootstrap::Probe.api_root(result.candidate) if result.anthropic?
438
+
439
+ result.candidate.base
440
+ end
441
+
365
442
  def report(outcome, model)
366
443
  path = outcome.path
367
444
  case outcome.kind
@@ -658,7 +658,7 @@ module Samagotchi
658
658
 
659
659
  begin
660
660
  result = @engine.answer_question(id: qid, selected: selected, freeform: freeform, client_id: client_id)
661
- [{}, 200, { status: "answered", session_id: session_id, answer: result }]
661
+ [{}, 200, { status: "answered", session_id: session_id, answer: result.to_h }]
662
662
  rescue Engine::QuestionNotPending => e
663
663
  # Another client answered first, or the question was cancelled.
664
664
  [{}, 409, { error: "question_not_pending", detail: e.message }]
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.79.0
3
+ version: 0.80.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:b5b3166c91f6f2af2563dd9db5f9959cda66747d907017acd0776fcd2fe29049
8
+ self_map.md: sha256:4ff5708e51f55089de47b107643ae0b74aee04cb96111226823a8d44fb4bc33d
9
9
  config_modification_protocol.md: sha256:66792f788cdbb8ee0252f2c387c96bd75266817bb75f721aaa181b602db97137
10
10
  memory_guide.md: sha256:c48eea744db69e4f3e9bb415e9a03a8c78bbcaa1205ad3b1fb157a079f92d22e
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -48,7 +48,7 @@
48
48
  approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
49
49
  - Bundles extend me: memories, `hooks/*.rb`, `guardrails/*.yml`, and a `plugin.rb` that adds slash
50
50
  commands (`anytime:` ones run mid-turn), model tools (they can return images I see), hooks,
51
- cards, side answers (`ask_model`),
51
+ cards, side answers (`ask_model`), HTTP calls (`ctx.http`),
52
52
  child sessions and services (e.g. MCP server processes). Shipped: `btw` (`/btw` side question),
53
53
  `mcp` (MCP server tools, searched with find_tools and called with call_tool; a screenshot comes as a picture; a toolkit's undeclared built-ins too, as chi/<tool>), `guardrails` (rules), `known-names` (typo guard), `source-links`
54
54
  (turns source refs like JIRA-123 in an answer into links in the web, and a one-line note), `loop-guard`
@@ -211,13 +211,13 @@ module Samagotchi
211
211
  def answered(answer, client_id:)
212
212
  # The /continue line takes the text on one line; the steer keeps its
213
213
  # line breaks.
214
- text = answer[:freeform].to_s.gsub(/\s+/, " ").strip
214
+ text = answer.freeform.to_s.gsub(/\s+/, " ").strip
215
215
  @continue_steer = nil
216
- if Array(answer[:selected]).include?(CONTINUE) && !text.empty?
217
- @continue_steer = { text: answer[:freeform].to_s.strip,
218
- source: answer[:by] == "parent_agent" ? "parent_agent" : "user" }
216
+ if answer.selected.include?(CONTINUE) && !text.empty?
217
+ @continue_steer = { text: answer.freeform.to_s.strip,
218
+ source: answer.parent_agent? ? "parent_agent" : "user" }
219
219
  end
220
- line = if Array(answer[:selected]).include?(CONTINUE) then "/continue yes"
220
+ line = if answer.selected.include?(CONTINUE) then "/continue yes"
221
221
  elsif text.empty? then "/continue no"
222
222
  else "/continue no, #{text}"
223
223
  end