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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/architecture.md +20 -3
- data/docs/cli.md +36 -2
- data/docs/configuration.md +177 -8
- data/docs/context.md +4 -1
- data/docs/internals/engine.md +19 -4
- data/docs/internals/prompt-caching.md +10 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +163 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +13 -2
- data/docs/sub-agent.md +3 -1
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +13 -5
- data/lib/samagotchi/engine.rb +151 -11
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +27 -6
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/api.rb +80 -7
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +3 -2
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/send_command.rb +23 -7
- data/lib/samagotchi/session.rb +21 -3
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +18 -2
- data/lib/samagotchi/tool_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/registry.rb +43 -6
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/annotate_actions.js +109 -0
- data/lib/samagotchi/web/public/annotate_bar.js +84 -2
- data/lib/samagotchi/web/public/app.js +111 -20
- data/lib/samagotchi/web/public/commands.js +4 -2
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +38 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +21 -2
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +16 -9
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/worker.rb +2 -0
- 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,
|
|
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.
|
|
94
|
-
remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
|
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"
|
|
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/
|
|
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
|
-
|
|
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
|
|
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:
|
|
310
|
-
#
|
|
311
|
-
#
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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)
|