samagotchi 0.64.0 → 0.66.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +20 -3
  6. data/docs/cli.md +36 -2
  7. data/docs/configuration.md +177 -8
  8. data/docs/context.md +4 -1
  9. data/docs/internals/engine.md +19 -4
  10. data/docs/internals/prompt-caching.md +10 -2
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +163 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +13 -2
  15. data/docs/sub-agent.md +3 -1
  16. data/lib/samagotchi/bridge.rb +11 -1
  17. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
  21. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
  22. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  23. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
  24. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  25. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  28. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  31. data/lib/samagotchi/cache_confirm.rb +9 -6
  32. data/lib/samagotchi/client.rb +4 -2
  33. data/lib/samagotchi/commands/registry.rb +25 -8
  34. data/lib/samagotchi/config.rb +13 -5
  35. data/lib/samagotchi/engine.rb +151 -11
  36. data/lib/samagotchi/generation_phase.rb +17 -0
  37. data/lib/samagotchi/host_config.rb +27 -6
  38. data/lib/samagotchi/host_registry.rb +34 -11
  39. data/lib/samagotchi/idle_client.rb +62 -25
  40. data/lib/samagotchi/idle_recap.rb +5 -2
  41. data/lib/samagotchi/idle_target.rb +30 -7
  42. data/lib/samagotchi/kernel_loop.rb +9 -1
  43. data/lib/samagotchi/llm/adapters.rb +41 -0
  44. data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
  45. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  46. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  47. data/lib/samagotchi/llm/api_key.rb +26 -9
  48. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  49. data/lib/samagotchi/llm/dialect.rb +57 -0
  50. data/lib/samagotchi/llm/errors.rb +81 -12
  51. data/lib/samagotchi/llm/model_listing.rb +111 -0
  52. data/lib/samagotchi/llm/model_result.rb +9 -2
  53. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  54. data/lib/samagotchi/model_profile.rb +1 -1
  55. data/lib/samagotchi/plugin/api.rb +80 -7
  56. data/lib/samagotchi/plugin/context.rb +22 -1
  57. data/lib/samagotchi/plugin/loader.rb +3 -2
  58. data/lib/samagotchi/reasoning_history.rb +14 -4
  59. data/lib/samagotchi/recap_setup.rb +2 -1
  60. data/lib/samagotchi/self_report.rb +18 -28
  61. data/lib/samagotchi/send_command.rb +23 -7
  62. data/lib/samagotchi/session.rb +21 -3
  63. data/lib/samagotchi/session_commands.rb +16 -1
  64. data/lib/samagotchi/session_manager.rb +4 -1
  65. data/lib/samagotchi/session_metrics.rb +6 -11
  66. data/lib/samagotchi/session_setup.rb +14 -7
  67. data/lib/samagotchi/steer_cut.rb +4 -0
  68. data/lib/samagotchi/system_prompt.rb +16 -1
  69. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  70. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
  71. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  72. data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
  73. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  74. data/lib/samagotchi/terminal_ui.rb +18 -2
  75. data/lib/samagotchi/tool_runner.rb +13 -1
  76. data/lib/samagotchi/toolkit.rb +246 -0
  77. data/lib/samagotchi/toolkit_command.rb +117 -0
  78. data/lib/samagotchi/tools/registry.rb +43 -6
  79. data/lib/samagotchi/turn_note.rb +22 -5
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +14 -1
  82. data/lib/samagotchi/web/public/activity.js +3 -0
  83. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  84. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  85. data/lib/samagotchi/web/public/app.js +111 -20
  86. data/lib/samagotchi/web/public/commands.js +4 -2
  87. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  88. data/lib/samagotchi/web/public/index.html +38 -0
  89. data/lib/samagotchi/web/public/info_bar.js +5 -3
  90. data/lib/samagotchi/web/public/live_turn.js +8 -0
  91. data/lib/samagotchi/web/public/session_state.js +3 -0
  92. data/lib/samagotchi/web/public/stage_model.js +27 -7
  93. data/lib/samagotchi/web/public/stage_view.js +32 -10
  94. data/lib/samagotchi/web/public/stream_handlers.js +21 -2
  95. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  96. data/lib/samagotchi/web/public/turn_events.js +22 -8
  97. data/lib/samagotchi/web/public/turn_html.js +20 -4
  98. data/lib/samagotchi/web/public/turn_model.js +16 -9
  99. data/lib/samagotchi/web/public/turn_view.js +6 -4
  100. data/lib/samagotchi/worker.rb +2 -0
  101. metadata +17 -1
@@ -15,17 +15,24 @@ module Samagotchi
15
15
  # conversation) and .empty_retries (the retries spent): the Engine saves
16
16
  # them on the turn's note for the UIs (TurnNote.empty).
17
17
  #
18
+ # .refusal is set when the provider refused the answer (Messages'
19
+ # stop_reason refusal, OpenAI's content_filter): {category:} as the
20
+ # provider names it, {} without one; nil otherwise. Such a turn is an
21
+ # empty answer the loop never asked again.
22
+ #
18
23
  # .run_start is where the turn's run started in the conversation (the
19
24
  # length of the messages the loop got), set by the Engine after the loop
20
25
  # returns; nil from a loop called directly. A step-limit offer keeps it
21
26
  # (TurnFlow), so a Continue resumes that run (Engine#run_turn resume_from:).
22
27
  class ModelResult
23
28
  attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
24
- :tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries
29
+ :tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries, :refusal
25
30
  attr_accessor :run_start
26
31
 
27
32
  def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
28
- tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0)
33
+ tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0,
34
+ refusal: nil)
35
+ @refusal = refusal
29
36
  @empty_steps = Array(empty_steps)
30
37
  @empty_retries = empty_retries.to_i
31
38
  @text = text
@@ -4,30 +4,16 @@ require "json"
4
4
  require "uri"
5
5
  require_relative "../config"
6
6
  require_relative "../sampling_settings"
7
+ require_relative "../thinking"
7
8
  require_relative "errors"
8
9
  require_relative "http"
10
+ require_relative "model_listing"
9
11
  require_relative "prompt_cache"
10
12
  require_relative "usage"
11
13
  require_relative "utf8_scrub"
12
14
 
13
15
  module Samagotchi
14
16
  module LLM
15
- # A model a host lists. Unknown fields are nil.
16
- ModelInfo = Data.define(:id, :context_window, :supports_tools, :raw) do
17
- # true when the list says the model takes images (OpenRouter's
18
- # architecture.input_modalities, llama.cpp's "multimodal" capability),
19
- # false when it lists input modalities without "image", else nil.
20
- def image_input
21
- return nil unless raw.is_a?(Hash)
22
-
23
- modalities = raw.dig("architecture", "input_modalities")
24
- return modalities.include?("image") if modalities.is_a?(Array)
25
- return true if Array(raw["capabilities"]).include?("multimodal")
26
-
27
- nil
28
- end
29
- end
30
-
31
17
  # A tool call the model made. arguments: the parsed Hash, or the raw
32
18
  # String when it isn't valid JSON (the tool then reports the error).
33
19
  ToolCall = Data.define(:id, :name, :arguments)
@@ -43,9 +29,17 @@ module Samagotchi
43
29
  # +top_logprobs+: the first answer token's likeliest alternatives
44
30
  # (TokenLogprob) when the request asked for logprobs and the server
45
31
  # sent them (a non-streamed answer only); [] otherwise.
32
+ # +provider_content+: the response's content blocks in order, as the
33
+ # server sent them (string keys), for a wire that sends them back
34
+ # verbatim (AnthropicMessages: thinking blocks with their signatures);
35
+ # nil on the OpenAI wire.
36
+ # +stop_details+: why the provider stopped, when it says more than the
37
+ # finish reason (Messages' stop_details on a refusal: type, category,
38
+ # explanation; string keys); nil otherwise.
46
39
  ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut,
47
- :top_logprobs) do
48
- def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze, **fields) = super
40
+ :top_logprobs, :provider_content, :stop_details) do
41
+ def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze, provider_content: nil, stop_details: nil,
42
+ **fields) = super
49
43
  end
50
44
 
51
45
  # One alternative for an answer token: its text and log probability.
@@ -75,6 +69,8 @@ module Samagotchi
75
69
  # default, with errors mapped to ProviderError. It speaks the wire format
76
70
  # only; the chat loop owns the conversation and the tools.
77
71
  class OpenAIChat
72
+ include ModelListing
73
+
78
74
  MAX_MODEL_PAGES = 20
79
75
  # How much of a 200's non-stream body is read as a possible JSON error.
80
76
  PLAIN_ERROR_LIMIT = 64 * 1024
@@ -90,14 +86,11 @@ module Samagotchi
90
86
 
91
87
  # @param entry [HostRegistry::HostEntry]
92
88
  def self.for(entry, **)
93
- new(base_url: entry.openai_base_url, host_name: entry.name, api_key_env: entry.api_key_env,
94
- remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.openai_base_url)),
89
+ new(base_url: entry.api_base_url, host_name: entry.name, api_key_env: entry.api_key_env, auth: entry.auth,
90
+ remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.api_base_url)),
95
91
  **)
96
92
  end
97
93
 
98
- # true when a streamed +payload+ carries a tool-call delta.
99
- def self.tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
100
-
101
94
  # true when +url+ is OpenRouter's API (openrouter.ai or a subdomain).
102
95
  def self.openrouter?(url)
103
96
  host = URI(url.to_s).host.to_s.downcase
@@ -134,19 +127,23 @@ module Samagotchi
134
127
  # @param host_name [String] names the host in errors
135
128
  # @param api_key_env [String, nil] the variable holding the API key;
136
129
  # nil sends no Authorization header (a local server)
130
+ # @param auth [Symbol, nil] the header the key goes in
131
+ # (ApiKey::SCHEMES); nil: Authorization: Bearer
137
132
  # @param stream [Boolean] stream the reply (false: one JSON body)
138
133
  # @param retries [Boolean] false: one attempt
139
134
  # @param timeout [Numeric, nil] read timeout in seconds (default
140
135
  # server.read_timeout); the connect timeout is capped by it
136
+ # @param open_timeout [Numeric, nil] connect timeout in seconds, when
137
+ # shorter than that (a reachability probe's)
141
138
  # @param models_ttl [Numeric] how long #context_window reuses the list
142
139
  # @param remote [Boolean] a provider on the network, not a local server
143
140
  # @param first_token_timeout [Numeric, nil] seconds a streamed answer
144
141
  # may take to show something (LLM::HTTP); nil: no limit
145
142
  # @param default_max_tokens [Integer, nil] max_tokens for a request
146
143
  # whose options name none (OPENROUTER_MAX_TOKENS); nil sends none
147
- def initialize(base_url:, host_name:, api_key_env: nil, stream: true, retries: true, timeout: nil,
144
+ def initialize(base_url:, host_name:, api_key_env: nil, auth: nil, stream: true, retries: true, timeout: nil,
148
145
  env: ENV, sleeper: nil, retry_policy: nil, models_ttl: DEFAULT_MODELS_TTL, remote: false,
149
- first_token_timeout: nil, purpose: "chat", default_max_tokens: nil)
146
+ first_token_timeout: nil, purpose: "chat", default_max_tokens: nil, open_timeout: nil)
150
147
  @remote = remote
151
148
  @default_max_tokens = default_max_tokens
152
149
  # What its requests are for, in the log (LLM::HTTP::LOGGED_PURPOSES).
@@ -156,13 +153,12 @@ module Samagotchi
156
153
  @host_name = host_name.to_s
157
154
  @api_key_env = api_key_env
158
155
  @stream = stream
159
- @models_ttl = models_ttl
160
- @models_mutex = Mutex.new
161
- open_timeout, read_timeout = timeouts(timeout)
156
+ init_model_listing(models_ttl)
157
+ open_timeout, read_timeout = timeouts(timeout, open_timeout)
162
158
  policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
163
159
  @http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
164
160
  retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
165
- api_key: ApiKey.for(api_key_env, host: @host_name, env: env))
161
+ api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth))
166
162
  end
167
163
 
168
164
  # @param messages [Array<Hash>] wire messages (role, content, tool_calls,
@@ -216,6 +212,25 @@ module Samagotchi
216
212
  # A provider on the network: no llama.cpp /props to ask for the window.
217
213
  def remote? = @remote
218
214
 
215
+ # Model entries go back as text, tool_calls and reasoning_content,
216
+ # never as saved provider blocks (AnthropicMessages does).
217
+ def replays_content? = false
218
+
219
+ # true when a streamed chunk's +payload+ (as #chat's on_delta gets it)
220
+ # carries part of a tool call (ChatLoop marks the chunk's event).
221
+ def tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
222
+
223
+ # The request fields a thinking +level+ (Thinking::LEVELS) adds on
224
+ # this wire: Thinking.chat_fields's OpenAI-style switches.
225
+ # @return [Hash] frozen
226
+ def thinking_fields(level) = Thinking.chat_fields(level)
227
+
228
+ # The thinking fields for a short side answer from a model that
229
+ # refused thinking off (gpt-oss: "Reasoning is mandatory"): none, the
230
+ # model's own default.
231
+ # @return [Hash] frozen
232
+ def thinking_off_refused_fields = {}.freeze
233
+
219
234
  # @return [Array<ModelInfo>] the host's models (every page)
220
235
  def list_models
221
236
  models = []
@@ -231,26 +246,6 @@ module Samagotchi
231
246
  models
232
247
  end
233
248
 
234
- # The window (tokens) the host lists for +model+, or nil. The list is
235
- # read once per models_ttl; a failed listing answers nil.
236
- def context_window(model:)
237
- info = cached_models.find { |m| m.id == model.to_s } ||
238
- cached_models.find { |m| m.id.casecmp?(model.to_s) }
239
- info&.context_window
240
- rescue StandardError
241
- nil
242
- end
243
-
244
- # Whether the host's list says +model+ takes images (ModelInfo#image_input),
245
- # or nil when it doesn't say or the listing fails.
246
- def image_input(model:)
247
- info = cached_models.find { |m| m.id == model.to_s } ||
248
- cached_models.find { |m| m.id.casecmp?(model.to_s) }
249
- info&.image_input
250
- rescue StandardError
251
- nil
252
- end
253
-
254
249
  def self.served_model(payload)
255
250
  model = payload.is_a?(Hash) ? payload["model"] : nil
256
251
  model.is_a?(String) && !model.strip.empty? ? model : nil
@@ -265,9 +260,10 @@ module Samagotchi
265
260
 
266
261
  private
267
262
 
268
- def timeouts(timeout)
263
+ def timeouts(timeout, open_limit = nil)
269
264
  open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
270
265
  read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
266
+ open_timeout = [open_timeout, open_limit].min if open_limit
271
267
  return [open_timeout, read_timeout] unless timeout
272
268
 
273
269
  [[open_timeout, timeout].min, timeout]
@@ -411,49 +407,6 @@ module Samagotchi
411
407
 
412
408
  def get_request(uri) = Net::HTTP::Get.new(uri)
413
409
 
414
- def model_entries(body)
415
- return [] unless body.is_a?(Hash)
416
-
417
- entries = body["data"].is_a?(Array) ? body["data"] : body["models"]
418
- Array(entries).select { |entry| entry.is_a?(Hash) }
419
- end
420
-
421
- def model_info(raw)
422
- id = (raw["id"] || raw["model"] || raw["name"]).to_s
423
- ModelInfo.new(id: id, context_window: window_of(raw), supports_tools: tools_support(raw), raw: raw)
424
- end
425
-
426
- # The running window when the list says: context_length, context_window,
427
- # max_model_len, or llama.cpp's meta.n_ctx (not n_ctx_train).
428
- def window_of(raw)
429
- [raw["context_length"], raw["context_window"], raw["max_model_len"], raw.dig("meta", "n_ctx")].each do |value|
430
- return value if value.is_a?(Integer) && value.positive?
431
- end
432
- nil
433
- end
434
-
435
- def tools_support(raw)
436
- listed = raw["supported_parameters"] || raw["capabilities"]
437
- return nil unless listed.is_a?(Array)
438
-
439
- listed.include?("tools") || nil
440
- end
441
-
442
- def cached_models
443
- @models_mutex.synchronize do
444
- fresh = @models_at && (monotonic - @models_at) < @models_ttl
445
- return @models if fresh
446
- end
447
- models = list_models
448
- @models_mutex.synchronize do
449
- @models = models
450
- @models_at = monotonic
451
- end
452
- models
453
- end
454
-
455
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
456
-
457
410
  # Streamed deltas put together: text, reasoning, tool calls by index,
458
411
  # the finish reason, the usage chunk, the served model and provider.
459
412
  class Assembly
@@ -484,7 +484,7 @@ module Samagotchi
484
484
  private_class_method :configured
485
485
 
486
486
  # Only a native llama.cpp host has /props with a chat template; a chat
487
- # host (api: openai) barely uses a profile, and mlx/oMLX expose none.
487
+ # host (api: openai or anthropic) barely uses a profile, and mlx/oMLX expose none.
488
488
  def self.probe?(entry, client)
489
489
  return false if client.nil? || !client.respond_to?(:server_props)
490
490
  return false if entry&.chat?
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "../toolkit"
4
5
  require_relative "../log"
5
6
  require_relative "../hooks/registry"
6
7
  require_relative "../tools/args"
8
+ require_relative "../commands/registry"
7
9
  require_relative "service"
8
10
  require_relative "tool_result"
9
11
 
@@ -16,6 +18,8 @@ module Samagotchi
16
18
  COMMAND_NAME = %r{\A/[a-z][a-z0-9_-]{0,31}\z}
17
19
  TOOL_NAME = /\A[a-z][a-z0-9_]{0,47}\z/
18
20
  PARAM_NAME = /\A[a-z_][a-z0-9_]*\z/i
21
+ ANNOTATION_LABEL_MAX = 40
22
+ ANNOTATIONS_MAX = 5
19
23
 
20
24
  # @param bundle [String]
21
25
  # @param label [String] "<file> (bundle <name>)": what hooks and
@@ -29,9 +33,11 @@ module Samagotchi
29
33
  @context = context
30
34
  @hooks = []
31
35
  @commands = []
36
+ @annotations = []
32
37
  @tools = []
33
38
  @services = []
34
39
  @inits = []
40
+ @toolkits = []
35
41
  @committed = false
36
42
  end
37
43
 
@@ -63,6 +69,35 @@ module Samagotchi
63
69
  nil
64
70
  end
65
71
 
72
+ # A pill in the web's selection bar: it prepares +command+ (a command
73
+ # line) with +template+ filled from the selected text on the lines
74
+ # under it ({quote}: the labelled quote block Annotate makes, {text}:
75
+ # the selection alone, {source}: answer, thinking, step, tool or
76
+ # user), and fills the composer with it, or sends it at once with
77
+ # +send+ (docs/plugins.md). Web only.
78
+ # @param label [String] the pill's text, 1–40 characters
79
+ # @param command [String] "/name" or "/name words": a command this
80
+ # plugin registers (checked once #register returns, so the order
81
+ # doesn't matter; another's is a load error)
82
+ # @param template [String] what goes under the command line
83
+ # @param send [Boolean] send at once instead of filling the composer
84
+ def annotation(label, command:, template: "{quote}", send: false)
85
+ label = label.to_s.strip
86
+ unless label.length.between?(1, ANNOTATION_LABEL_MAX)
87
+ raise ArgumentError, "annotation label #{label.inspect} must be 1 to #{ANNOTATION_LABEL_MAX} characters"
88
+ end
89
+
90
+ line = command.to_s.strip
91
+ name = line.split(/\s/, 2).first.to_s
92
+ raise ArgumentError, "annotation #{label}: command #{line.inspect} must start with a /name" unless name.match?(COMMAND_NAME)
93
+ raise ArgumentError, "annotation #{label}: template must be a non-blank String" unless template.is_a?(String) && !template.strip.empty?
94
+ raise ArgumentError, "at most #{ANNOTATIONS_MAX} annotations per plugin" if @annotations.size >= ANNOTATIONS_MAX
95
+
96
+ @annotations << { name: name, annotation: Commands::Registry::Annotation.new(label: label, line: line, template: template,
97
+ sends: send ? true : false) }
98
+ nil
99
+ end
100
+
66
101
  # A tool the model can call: the block gets the call's arguments (a
67
102
  # frozen Hash, string keys, typed by the schema: see Tools::Args) and
68
103
  # the Context, and returns the result text ("Error: …" marks a
@@ -215,12 +250,40 @@ module Samagotchi
215
250
  nil
216
251
  end
217
252
 
218
- # Register what was staged. Called by Loader after #register.
253
+ TOOLKIT_NAME = /\A[a-z][a-z0-9_-]{0,31}\z/
254
+
255
+ # A named toolkit sessions can be put on (/toolkit NAME, chi --toolkit,
256
+ # toolkit.default, ctx.use_toolkit): +base+'s tools (a toolkit chi or
257
+ # a bundle has) with +add+ declared and +remove+ left out. A name chi
258
+ # (full, lean, child) or another bundle has is a load error; a base
259
+ # that doesn't resolve leaves the sessions on it with every tool
260
+ # (Toolkit#resolve).
261
+ def toolkit(name, base:, add: [], remove: [])
262
+ name = name.to_s
263
+ raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
264
+ raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
265
+ if Toolkit::BUILTIN_KITS.key?(name)
266
+ raise ArgumentError, "toolkit #{name} is chi's own"
267
+ elsif (taken = @registries.toolkits.find { |kit| kit.name == name })
268
+ raise ArgumentError, "toolkit #{name} is already declared (#{taken.source})"
269
+ end
270
+ raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
271
+
272
+ @toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, source: @bundle)
273
+ nil
274
+ end
275
+
276
+ # Register what was staged. Called by Loader after #register. What
277
+ # can only be checked now (an annotation's command) is checked before
278
+ # anything is registered: a raise here leaves the registries as they were.
219
279
  def commit!
280
+ check_annotations!
220
281
  context = @context
221
282
  @commands.each do |cmd|
222
283
  block = cmd[:block]
223
- @registries.commands.register(cmd[:name], cmd[:description], anytime: cmd[:anytime], source: @bundle) do |args|
284
+ annotations = @annotations.select { |item| item[:name] == cmd[:name] }.map { |item| item[:annotation] }
285
+ @registries.commands.register(cmd[:name], cmd[:description], anytime: cmd[:anytime], source: @bundle,
286
+ annotations: annotations) do |args|
224
287
  block.call(args, context)
225
288
  end
226
289
  end
@@ -243,12 +306,24 @@ module Samagotchi
243
306
  block.call(context)
244
307
  end
245
308
  end
309
+ @registries.toolkits&.concat(@toolkits)
246
310
  @committed = true
247
311
  end
248
312
 
249
313
  # @return [Hash] how many of each it registered (for the log)
250
- def counts = { commands: @commands.size, tools: @tools.size, hooks: @hooks.size, services: @services.size,
251
- inits: @inits.size }
314
+ def counts = { commands: @commands.size, annotations: @annotations.size, tools: @tools.size, hooks: @hooks.size,
315
+ services: @services.size, inits: @inits.size, toolkits: @toolkits.size }
316
+
317
+ # Each annotation's command is one #command staged.
318
+ def check_annotations!
319
+ names = @commands.map { |cmd| cmd[:name] }
320
+ @annotations.each do |item|
321
+ next if names.include?(item[:name])
322
+
323
+ raise ArgumentError, "annotation #{item[:annotation].label}: #{item[:name]} is not a command this plugin registers"
324
+ end
325
+ end
326
+ private :check_annotations!
252
327
 
253
328
  # A checked tool declaration (#tool's arguments: Api#tool and
254
329
  # ToolSet#tool pass theirs on, so a new keyword is added here).
@@ -281,9 +356,7 @@ module Samagotchi
281
356
  def self.apply_tools(registry, bundle, specs, context)
282
357
  wanted = specs.to_h { |spec| [spec[:name], spec] }
283
358
  changed = false
284
- registry.entries.each do |entry|
285
- next unless entry.source == bundle
286
-
359
+ registry.by_source(bundle).each do |entry|
287
360
  if (spec = wanted[entry.name]) && spec[:schema] == entry.schema && spec[:label] == entry.label
288
361
  entry_fields(spec, context).slice(:handler, :preview, :targets, :refs).each { |key, value| entry[key] = value }
289
362
  next
@@ -29,9 +29,13 @@ module Samagotchi
29
29
  # (reason, label), each true when it acted on a running turn.
30
30
  # +frontend+ returns the Engine's interface (:repl, :worker,
31
31
  # :non_interactive), which ctx.frontend names for plugins.
32
+ # +toolkit+ returns the session's toolkit name, +declared_tools+ the
33
+ # tool names its next turn declares, +use_toolkit+ takes a name and
34
+ # returns whether it staged it (Engine#use_toolkit).
32
35
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
33
36
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
34
- :steer, :stop_turn, :stop_generation, :frontend, keyword_init: true)
37
+ :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
38
+ keyword_init: true)
35
39
 
36
40
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
37
41
  # sent nothing usable. The message says why, for the user.
@@ -109,6 +113,23 @@ module Samagotchi
109
113
  # current_model_only names it
110
114
  def model_key = @host.model_key&.call
111
115
 
116
+ # @return [String, nil] the name of the session's toolkit ("full" for
117
+ # one from before toolkits; nil with no session)
118
+ def toolkit = @host.toolkit&.call
119
+
120
+ # @return [Array<String>] the tools the session's next turn declares
121
+ # (its toolkit resolved: a kit name says less than this)
122
+ def declared_tools = Array(@host.declared_tools&.call)
123
+
124
+ # Put the session on toolkit +name+ (a plugin's chi.toolkit, or
125
+ # chi's): only before the session's first model reply and never while
126
+ # a turn runs, since the declared tools start every request; it then
127
+ # applies at the next turn's start, and the user is told. Otherwise
128
+ # nothing changes and the plugin can say to run /toolkit NAME.
129
+ # @return [Boolean] whether it was staged
130
+ # @raise [ArgumentError] a toolkit no one declared
131
+ def use_toolkit(name) = !!@host.use_toolkit&.call(name.to_s)
132
+
112
133
  # Whether the session is a `chi scratch` one (deleted when it ends).
113
134
  def scratch? = !!@host.scratch&.call
114
135
 
@@ -16,9 +16,10 @@ module Samagotchi
16
16
  # (the Engine's system prompts); +services+ (Plugin::Services) is what
17
17
  # Engine#shutdown stops; +stage_tools+ is (bundle, specs, context),
18
18
  # chi.replace_tools' set, which the Engine applies on its turn thread;
19
- # +init+ adds a chi.init task (Engine#add_init_task).
19
+ # +init+ adds a chi.init task (Engine#add_init_task); +toolkits+ is the
20
+ # Engine's list of the plugins' toolkits (chi.toolkit: Toolkit::Kit).
20
21
  Registries = Struct.new(:commands, :tools, :hooks, :context_for, :tools_changed, :services, :stage_tools, :init,
21
- keyword_init: true)
22
+ :toolkits, keyword_init: true)
22
23
 
23
24
  # Loads installed bundles' plugins (manifest plugin: {file:, sha256:})
24
25
  # into an Engine's registries (docs/plugins.md).
@@ -16,12 +16,17 @@ module Samagotchi
16
16
  #
17
17
  # Order (#resolve): the session's own level (/reasoning-history), then
18
18
  # SAMAGOTCHI_REASONING_HISTORY, models.<key>.reasoning_history,
19
- # hosts.<name>.reasoning_history, then turn. Native hosts ignore it:
19
+ # hosts.<name>.reasoning_history, then the host's api default: all for
20
+ # api: anthropic (its thinking blocks go back verbatim, and removing
21
+ # earlier turns' would rewrite the cached prefix every turn; ChatLoop
22
+ # floors drop at turn there), turn elsewhere. Native hosts ignore it:
20
23
  # KernelLoop keeps the running turn's reasoning on its own, from the
21
24
  # run's start (.run_start).
22
25
  module ReasoningHistory
23
26
  LEVELS = %i[drop turn all].freeze
24
27
  DEFAULT = :turn
28
+ # The default for a host's api when it isn't DEFAULT.
29
+ API_DEFAULTS = { anthropic: :all }.freeze
25
30
  KEY = "reasoning_history"
26
31
  ENV_NAME = "SAMAGOTCHI_REASONING_HISTORY"
27
32
  # The source a session's own level reads as.
@@ -82,7 +87,7 @@ module Samagotchi
82
87
  host = target.entry&.reasoning_history
83
88
  return [host, "hosts.#{target.entry.name}"] if host
84
89
 
85
- [DEFAULT, nil]
90
+ [API_DEFAULTS.fetch(target.entry&.api&.to_sym, DEFAULT), nil]
86
91
  end
87
92
 
88
93
  # #resolve, as a SettingExplained with the session's own level beside it.
@@ -112,10 +117,15 @@ module Samagotchi
112
117
  end
113
118
 
114
119
  # Whether +conversation+ has a model entry with thinking (what all
115
- # sends back as reasoning_content; LLM::ChatLoop#thought?).
120
+ # sends back as reasoning_content; LLM::ChatLoop#thought?), or with a
121
+ # saved thinking block (a Messages host's, which may carry no text:
122
+ # Claude's omitted display).
116
123
  def earlier_thought?(conversation)
117
124
  Array(conversation).any? do |entry|
118
- entry[:role].to_s == "model" && entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?
125
+ next false unless entry[:role].to_s == "model"
126
+
127
+ (entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?) ||
128
+ Array(entry[:anthropic_content]).any? { |block| block.is_a?(Hash) && %w[thinking redacted_thinking].include?(block["type"].to_s) }
119
129
  end
120
130
  end
121
131
 
@@ -65,7 +65,8 @@ module Samagotchi
65
65
  min_user_turns: recap_number_setting(kwarg_config, :min_user_turns, "recap.min_user_turns", IdleRecap::DEFAULT_MIN_USER_TURNS, :int),
66
66
  timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
67
67
  sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
68
- store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call })
68
+ store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call }),
69
+ session_id: -> { session_id.call }
69
70
  )
70
71
  end
71
72
 
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "net/http"
5
- require "uri"
6
3
  require_relative "version"
7
4
  require_relative "config"
8
5
  require_relative "context_window"
@@ -16,8 +13,7 @@ require_relative "thinking"
16
13
  require_relative "reasoning_history"
17
14
  require_relative "served_model"
18
15
  require_relative "host_registry"
19
- require_relative "llm/http"
20
- require_relative "llm/api_key"
16
+ require_relative "llm/adapters"
21
17
  require_relative "tools/memory"
22
18
  require_relative "hooks/loader"
23
19
  require_relative "memory_bundle/provenance"
@@ -273,7 +269,9 @@ module Samagotchi
273
269
  entry = HostRegistry.new(env: env).resolve(model).entry
274
270
  return "-" unless entry
275
271
 
276
- entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
272
+ return "native (raw prompt)" unless entry.chat?
273
+
274
+ entry.dialect ? "chat (api: #{entry.api}, dialect: #{entry.dialect})" : "chat (api: #{entry.api})"
277
275
  end
278
276
 
279
277
  NO_PROPS = "reported per turn (the server has no /props)"
@@ -281,7 +279,7 @@ module Samagotchi
281
279
  # chi self's one server call: a single-model llama.cpp answers any name
282
280
  # with the model it loaded, and its /props names it (model_alias). One
283
281
  # GET with the probe's short timeouts; a remote host isn't asked. A local
284
- # chat host (api: openai) has no /props, so it gets a GET <base>/models
282
+ # chat host (api: openai or anthropic) has no /props, so it gets its model list
285
283
  # with the same timeouts: up/down and the ids it serves.
286
284
  def served_model_for(model, env)
287
285
  registry = HostRegistry.new(env: env)
@@ -306,36 +304,28 @@ module Samagotchi
306
304
  "unknown (#{e.class})"
307
305
  end
308
306
 
309
- # A local chat host's reachability: one GET <base>/models with the
310
- # native probe's short timeouts (Client's /props probe), no retry. Up
311
- # with the ids it serves, down when it doesn't answer.
307
+ # A local chat host's reachability: its model list through its own
308
+ # adapter (LLM::Adapters, not the registry's cached one) with the native
309
+ # probe's short timeouts (Client's /props probe), no retry. Up with the
310
+ # ids it serves, down when it doesn't answer.
312
311
  def chat_served_model_for(entry, bare, env)
313
- uri = URI("#{entry.openai_base_url}/models")
314
- request = Net::HTTP::Get.new(uri)
315
- http = LLM::HTTP.new(label: entry.name, open_timeout: Client::CONTEXT_WINDOW_PROBE_OPEN_TIMEOUT,
316
- read_timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT,
317
- retry_policy: LLM::HTTP::RetryPolicy.none,
318
- api_key: LLM::ApiKey.for(entry.api_key_env, host: entry.host, env: env))
319
- response = http.fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "models" })
320
- return "down (HTTP #{response.code})" unless response.code.to_s == "200"
321
-
322
- ids = chat_model_ids(response.body)
312
+ adapter = LLM::Adapters.for(entry, open_timeout: Client::CONTEXT_WINDOW_PROBE_OPEN_TIMEOUT,
313
+ timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT, retries: false, env: env)
314
+ ids = adapter.list_models.map(&:id).reject(&:empty?)
323
315
  return "up (no models listed)" if ids.empty?
324
316
 
325
317
  note = ids.any? { |id| id.casecmp?(bare.to_s) } ? "" : " (not #{bare})"
326
318
  "up: #{ids.join(", ")}#{note}"
319
+ rescue LLM::ProviderError => e
320
+ # A status (an error, or a 3xx such as a proxy's login page) is down;
321
+ # a 200 whose body isn't a model list (ProtocolError) is up.
322
+ return "down (HTTP #{e.status})" if e.status
323
+
324
+ e.is_a?(LLM::ProtocolError) ? "up (no models listed)" : "down (the server didn't answer; is it running?)"
327
325
  rescue StandardError
328
326
  "down (the server didn't answer; is it running?)"
329
327
  end
330
328
 
331
- def chat_model_ids(body)
332
- parsed = JSON.parse(body.to_s)
333
- data = parsed.is_a?(Hash) ? parsed["data"] : parsed
334
- Array(data).filter_map { |raw| raw.is_a?(Hash) ? (raw["id"] || raw[:id]).to_s : raw.to_s }.reject(&:empty?)
335
- rescue JSON::ParserError
336
- []
337
- end
338
-
339
329
  # Offline, so no /props probe: where nothing is configured, a native
340
330
  # llama.cpp host's chat template decides at runtime.
341
331
  def profile_for(model, env)