samagotchi 0.21.0 → 0.23.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 +62 -1
- data/bin/chi +9 -4
- data/docs/architecture.md +3 -2
- data/docs/configuration.md +15 -4
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +1 -1
- data/docs/sub-agent.md +11 -5
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +11 -5
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +2 -2
- data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -6
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
- data/lib/samagotchi/bridge.rb +12 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
- data/lib/samagotchi/bridge_client.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +39 -29
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cancellation_controller.rb +1 -1
- data/lib/samagotchi/cli/command.rb +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/client.rb +3 -5
- data/lib/samagotchi/commands/registry.rb +2 -2
- data/lib/samagotchi/config.rb +38 -14
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +6 -6
- data/lib/samagotchi/continue_offer.rb +44 -12
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/desktop/macos.rb +5 -4
- data/lib/samagotchi/empty_answer_retry.rb +1 -1
- data/lib/samagotchi/engine.rb +49 -19
- data/lib/samagotchi/gem_update.rb +2 -1
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +5 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -2
- data/lib/samagotchi/guardrails/gate.rb +2 -2
- data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
- data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
- data/lib/samagotchi/hooks/loader.rb +18 -16
- data/lib/samagotchi/hooks/registry.rb +10 -2
- data/lib/samagotchi/host_registry.rb +1 -3
- data/lib/samagotchi/idle_recap.rb +18 -15
- data/lib/samagotchi/idle_reminders.rb +6 -3
- data/lib/samagotchi/image_store.rb +5 -2
- data/lib/samagotchi/kernel_loop.rb +31 -31
- data/lib/samagotchi/llm/chat_loop.rb +9 -6
- data/lib/samagotchi/llm/errors.rb +33 -2
- data/lib/samagotchi/llm/http.rb +12 -12
- data/lib/samagotchi/llm/openai_chat.rb +53 -30
- data/lib/samagotchi/llm/prompt_cache.rb +83 -0
- data/lib/samagotchi/log.rb +2 -2
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/memory_bundle/builder.rb +9 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
- data/lib/samagotchi/memory_bundle/installer.rb +67 -57
- data/lib/samagotchi/memory_bundle/listing.rb +5 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
- data/lib/samagotchi/memory_bundle/merger.rb +6 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
- data/lib/samagotchi/memory_bundle/source.rb +7 -3
- data/lib/samagotchi/memory_bundle/status.rb +3 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
- data/lib/samagotchi/memory_bundle/trash.rb +5 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/model_overlay.rb +3 -5
- data/lib/samagotchi/model_profile.rb +12 -8
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/output_formatter.rb +18 -17
- data/lib/samagotchi/owner_lock.rb +2 -1
- data/lib/samagotchi/parent_report.rb +24 -7
- data/lib/samagotchi/plugin/api.rb +8 -5
- data/lib/samagotchi/plugin/context.rb +1 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/plugin/service.rb +1 -1
- data/lib/samagotchi/plugin/sessions.rb +1 -0
- data/lib/samagotchi/plugin_tasks.rb +1 -1
- data/lib/samagotchi/prompt.rb +68 -12
- data/lib/samagotchi/prompt_literal_guard.rb +1 -1
- data/lib/samagotchi/question_desk.rb +7 -13
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/relay_desk.rb +1 -1
- data/lib/samagotchi/reminder_store.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +12 -2
- data/lib/samagotchi/send_command.rb +4 -2
- data/lib/samagotchi/session.rb +2 -2
- data/lib/samagotchi/session_archive_command.rb +5 -1
- data/lib/samagotchi/session_commands.rb +1 -3
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/session_metrics.rb +10 -10
- data/lib/samagotchi/session_observer.rb +3 -2
- data/lib/samagotchi/session_retention.rb +2 -2
- data/lib/samagotchi/sessions_command.rb +14 -5
- data/lib/samagotchi/system_prompt.rb +19 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
- data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
- data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
- data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
- data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
- data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
- data/lib/samagotchi/terminal_ui.rb +9 -5
- data/lib/samagotchi/text_diff.rb +12 -12
- data/lib/samagotchi/thinking.rb +6 -2
- data/lib/samagotchi/thought_stream_splitter.rb +6 -3
- data/lib/samagotchi/tool_activity.rb +1 -0
- data/lib/samagotchi/tool_call_parser.rb +13 -14
- data/lib/samagotchi/tool_declarations.rb +36 -35
- data/lib/samagotchi/tools/ask_user_question.rb +3 -4
- data/lib/samagotchi/tools/builtin_calls.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +0 -1
- data/lib/samagotchi/tools/edit.rb +14 -13
- data/lib/samagotchi/tools/execute.rb +3 -5
- data/lib/samagotchi/tools/list_reminders.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +21 -22
- data/lib/samagotchi/tools/read.rb +7 -7
- data/lib/samagotchi/tools/task_create.rb +1 -1
- data/lib/samagotchi/tools/task_get.rb +1 -1
- data/lib/samagotchi/tools/task_list.rb +1 -1
- data/lib/samagotchi/tools/task_runtime.rb +8 -9
- data/lib/samagotchi/tools/task_stop.rb +1 -1
- data/lib/samagotchi/tools/task_wait.rb +3 -4
- data/lib/samagotchi/tools/web_fetch.rb +2 -4
- data/lib/samagotchi/tools/write.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -8
- data/lib/samagotchi/turn_note.rb +5 -1
- data/lib/samagotchi/turn_state.rb +22 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -2
- data/lib/samagotchi/web/app.rb +13 -6
- data/lib/samagotchi/web/markdown_renderer.rb +2 -1
- data/lib/samagotchi/web/qr.rb +7 -1
- data/lib/samagotchi/web/server.rb +6 -2
- data/lib/samagotchi/worker.rb +10 -2
- metadata +21 -20
|
@@ -22,6 +22,9 @@ module Samagotchi
|
|
|
22
22
|
# ConnectionError no answer (refused, reset, timeout); RetryExhausted,
|
|
23
23
|
# ConnectionRefused
|
|
24
24
|
# RateLimited 429, with retry_after when the server says
|
|
25
|
+
# CreditsHeld 402 "… in-flight requests": credit reserved by other
|
|
26
|
+
# requests; a RateLimited, retried after 20 s
|
|
27
|
+
# OutOfCredits any other 402; never retried
|
|
25
28
|
# ServerError 5xx or a server's error event mid-stream
|
|
26
29
|
# AuthError 401/403, or an API key variable that is not set
|
|
27
30
|
# BadRequest other 4xx (a context overflow is one, whatever status;
|
|
@@ -84,6 +87,24 @@ module Samagotchi
|
|
|
84
87
|
def default_retryable? = true
|
|
85
88
|
end
|
|
86
89
|
|
|
90
|
+
# A 402 that says the account's credit is reserved by requests still in
|
|
91
|
+
# flight (OpenRouter: "This request would exceed your available credits
|
|
92
|
+
# given your current in-flight requests"): it clears once they settle,
|
|
93
|
+
# so it is retried like a 429, after CREDITS_SETTLE_DELAY unless the
|
|
94
|
+
# server says how long.
|
|
95
|
+
class CreditsHeld < RateLimited
|
|
96
|
+
def kind = :credits_held
|
|
97
|
+
|
|
98
|
+
def summary = "credits on host #{host} are held by in-flight requests (retry after #{retry_after.to_f.ceil}s)"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Any other 402: the account has run out of credits. Never retried.
|
|
102
|
+
class OutOfCredits < ProviderError
|
|
103
|
+
def kind = :credits
|
|
104
|
+
|
|
105
|
+
def summary = "out of credits on host #{host}: #{detail}; add credits, then send again"
|
|
106
|
+
end
|
|
107
|
+
|
|
87
108
|
class ServerError < ProviderError
|
|
88
109
|
def kind = :server
|
|
89
110
|
|
|
@@ -104,12 +125,12 @@ module Samagotchi
|
|
|
104
125
|
|
|
105
126
|
# @param hint [String, nil] what to try, added to the summary
|
|
106
127
|
def initialize(message = nil, context_overflow: false, tools_unsupported: false, reasoning_refused: false, hint: nil,
|
|
107
|
-
**
|
|
128
|
+
**)
|
|
108
129
|
@context_overflow = context_overflow
|
|
109
130
|
@tools_unsupported = tools_unsupported
|
|
110
131
|
@reasoning_refused = reasoning_refused
|
|
111
132
|
@hint = hint
|
|
112
|
-
super(message, **
|
|
133
|
+
super(message, **)
|
|
113
134
|
end
|
|
114
135
|
|
|
115
136
|
attr_reader :hint
|
|
@@ -238,6 +259,10 @@ module Samagotchi
|
|
|
238
259
|
VISION_UNSUPPORTED_RE = /support image input|image input is not supported|mmproj/i
|
|
239
260
|
# A 400 about the thinking fields chi sent (Thinking.chat_fields).
|
|
240
261
|
REASONING_REFUSED_RE = /reasoning|thinking/i
|
|
262
|
+
# A 402 about credit held by in-flight requests (see CreditsHeld).
|
|
263
|
+
CREDITS_HELD_RE = /in-flight requests/i
|
|
264
|
+
# Seconds to wait before retrying a CreditsHeld without a Retry-After.
|
|
265
|
+
CREDITS_SETTLE_DELAY = 20.0
|
|
241
266
|
RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
|
|
242
267
|
|
|
243
268
|
module_function
|
|
@@ -259,6 +284,12 @@ module Samagotchi
|
|
|
259
284
|
when 401, 403 then AuthError.new(text, **options)
|
|
260
285
|
when 429 then RateLimited.new(text, retry_after: parse_retry_after(retry_after), **options)
|
|
261
286
|
when 408 then ServerError.new(text, retryable: true, **options)
|
|
287
|
+
when 402
|
|
288
|
+
if CREDITS_HELD_RE.match?(message) || CREDITS_HELD_RE.match?(body.to_s)
|
|
289
|
+
CreditsHeld.new(text, retry_after: parse_retry_after(retry_after) || CREDITS_SETTLE_DELAY, **options)
|
|
290
|
+
else
|
|
291
|
+
OutOfCredits.new(text, **options)
|
|
292
|
+
end
|
|
262
293
|
when 400..499
|
|
263
294
|
tools = TOOLS_UNSUPPORTED_RE.match?(message)
|
|
264
295
|
BadRequest.new(text, tools_unsupported: tools,
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -138,7 +138,7 @@ module Samagotchi
|
|
|
138
138
|
end
|
|
139
139
|
end
|
|
140
140
|
|
|
141
|
-
def stream_attempt(uri, request, current, &
|
|
141
|
+
def stream_attempt(uri, request, current, &)
|
|
142
142
|
# Time to first token is the answering attempt's, not the retries'.
|
|
143
143
|
current[:attempt_started_at] = monotonic_now
|
|
144
144
|
shown = lambda do
|
|
@@ -155,11 +155,11 @@ module Samagotchi
|
|
|
155
155
|
response.read_body do |chunk|
|
|
156
156
|
buffer << chunk
|
|
157
157
|
while (newline_index = buffer.index("\n"))
|
|
158
|
-
|
|
158
|
+
yield(buffer.slice!(0, newline_index + 1).strip, shown)
|
|
159
159
|
end
|
|
160
160
|
end
|
|
161
161
|
# A body that doesn't end in a newline still has a last line.
|
|
162
|
-
|
|
162
|
+
yield(buffer.strip, shown) unless buffer.strip.empty?
|
|
163
163
|
end
|
|
164
164
|
end
|
|
165
165
|
end
|
|
@@ -192,7 +192,7 @@ module Samagotchi
|
|
|
192
192
|
cancellable(cancel_controller, current) do
|
|
193
193
|
attempt.call(current.merge!(attempts: 1))
|
|
194
194
|
rescue StandardError => e
|
|
195
|
-
raise RequestCancelled
|
|
195
|
+
raise RequestCancelled, cancel_controller.reason if !e.is_a?(RequestCancelled) && cancel_controller&.cancelled?
|
|
196
196
|
|
|
197
197
|
raise
|
|
198
198
|
end
|
|
@@ -208,7 +208,7 @@ module Samagotchi
|
|
|
208
208
|
@api_key&.authorize(request)
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
-
def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &
|
|
211
|
+
def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &)
|
|
212
212
|
options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
|
|
213
213
|
# Net::HTTP retries an idempotent request once on its own (max_retries
|
|
214
214
|
# defaults to 1) when the connection drops before an answer: chi's
|
|
@@ -218,15 +218,15 @@ module Samagotchi
|
|
|
218
218
|
retries = max_retries || 0
|
|
219
219
|
options[:max_retries] = retries
|
|
220
220
|
options[:use_ssl] = true if uri.scheme == "https"
|
|
221
|
-
Net::HTTP.start(uri.host, uri.port, **options, &
|
|
221
|
+
Net::HTTP.start(uri.host, uri.port, **options, &)
|
|
222
222
|
end
|
|
223
223
|
|
|
224
224
|
# Runs the block (one attempt) until it returns, retrying network
|
|
225
225
|
# errors. The block gets a hash to put the attempt's Net::HTTP in, so a
|
|
226
226
|
# cancel can reach its socket.
|
|
227
|
-
def with_retries(cancel_controller, on_retry, on_network_error, current = { mutex: Mutex.new })
|
|
227
|
+
def with_retries(cancel_controller, on_retry, on_network_error, current = { mutex: Mutex.new }, &block)
|
|
228
228
|
cancellable(cancel_controller, current) do
|
|
229
|
-
retrying(cancel_controller, on_retry, on_network_error, current
|
|
229
|
+
retrying(cancel_controller, on_retry, on_network_error, current, &block)
|
|
230
230
|
end
|
|
231
231
|
end
|
|
232
232
|
|
|
@@ -236,7 +236,7 @@ module Samagotchi
|
|
|
236
236
|
def cancellable(cancel_controller, current)
|
|
237
237
|
requesting_thread = Thread.current
|
|
238
238
|
listener_id = cancel_controller&.on_cancel { |reason| abort_request(current, requesting_thread, reason) }
|
|
239
|
-
raise RequestCancelled
|
|
239
|
+
raise RequestCancelled, cancel_controller.reason if cancel_controller&.cancelled?
|
|
240
240
|
|
|
241
241
|
yield
|
|
242
242
|
ensure
|
|
@@ -257,7 +257,7 @@ module Samagotchi
|
|
|
257
257
|
rescue RequestCancelled
|
|
258
258
|
raise
|
|
259
259
|
rescue ProviderError => e
|
|
260
|
-
raise RequestCancelled
|
|
260
|
+
raise RequestCancelled, cancel_controller.reason if cancel_controller&.cancelled?
|
|
261
261
|
|
|
262
262
|
e.attempts = attempts
|
|
263
263
|
raise if !e.retryable? || current[:streamed]
|
|
@@ -267,7 +267,7 @@ module Samagotchi
|
|
|
267
267
|
|
|
268
268
|
retry_after(e, attempts, delay, on_retry, cancel_controller, current)
|
|
269
269
|
rescue StandardError => e
|
|
270
|
-
raise RequestCancelled
|
|
270
|
+
raise RequestCancelled, cancel_controller.reason if cancel_controller&.cancelled?
|
|
271
271
|
raise first_token_timeout if current[:first_token_expired]
|
|
272
272
|
raise unless self.class.network_error?(e)
|
|
273
273
|
|
|
@@ -432,7 +432,7 @@ module Samagotchi
|
|
|
432
432
|
|
|
433
433
|
remaining = seconds
|
|
434
434
|
while remaining.positive?
|
|
435
|
-
raise RequestCancelled
|
|
435
|
+
raise RequestCancelled, cancel_controller.reason if cancel_controller.cancelled?
|
|
436
436
|
|
|
437
437
|
slice = [remaining, WAIT_TICK].min
|
|
438
438
|
@sleeper.call(slice)
|
|
@@ -6,6 +6,7 @@ require_relative "../config"
|
|
|
6
6
|
require_relative "../sampling_settings"
|
|
7
7
|
require_relative "errors"
|
|
8
8
|
require_relative "http"
|
|
9
|
+
require_relative "prompt_cache"
|
|
9
10
|
require_relative "usage"
|
|
10
11
|
require_relative "utf8_scrub"
|
|
11
12
|
|
|
@@ -52,13 +53,28 @@ module Samagotchi
|
|
|
52
53
|
# How much of a 200's non-stream body is read as a possible JSON error.
|
|
53
54
|
PLAIN_ERROR_LIMIT = 64 * 1024
|
|
54
55
|
DEFAULT_MODELS_TTL = 60
|
|
56
|
+
# OpenRouter holds a paid request's estimated cost against the balance
|
|
57
|
+
# while it runs: the input plus the output max_tokens allows (a fixed
|
|
58
|
+
# per-request cap when it is unset), and refuses a request with a 402
|
|
59
|
+
# once the running ones fill the budget. A request that names a sane
|
|
60
|
+
# limit holds less. 32k leaves room for any one step's answer.
|
|
61
|
+
OPENROUTER_MAX_TOKENS = 32_768
|
|
55
62
|
|
|
56
|
-
attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout
|
|
63
|
+
attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout, :default_max_tokens
|
|
57
64
|
|
|
58
65
|
# @param entry [HostRegistry::HostEntry]
|
|
59
|
-
def self.for(entry, **
|
|
66
|
+
def self.for(entry, **)
|
|
60
67
|
new(base_url: entry.openai_base_url, host_name: entry.name, api_key_env: entry.api_key_env,
|
|
61
|
-
remote: entry.remote?,
|
|
68
|
+
remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.openai_base_url)),
|
|
69
|
+
**)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# true when +url+ is OpenRouter's API (openrouter.ai or a subdomain).
|
|
73
|
+
def self.openrouter?(url)
|
|
74
|
+
host = URI(url.to_s).host.to_s.downcase
|
|
75
|
+
host == "openrouter.ai" || host.end_with?(".openrouter.ai")
|
|
76
|
+
rescue URI::Error
|
|
77
|
+
false
|
|
62
78
|
end
|
|
63
79
|
|
|
64
80
|
# Tool arguments as a Hash; "" is {}, invalid JSON stays a String.
|
|
@@ -86,10 +102,13 @@ module Samagotchi
|
|
|
86
102
|
# @param remote [Boolean] a provider on the network, not a local server
|
|
87
103
|
# @param first_token_timeout [Numeric, nil] seconds a streamed answer
|
|
88
104
|
# may take to show something (LLM::HTTP); nil: no limit
|
|
105
|
+
# @param default_max_tokens [Integer, nil] max_tokens for a request
|
|
106
|
+
# whose options name none (OPENROUTER_MAX_TOKENS); nil sends none
|
|
89
107
|
def initialize(base_url:, host_name:, api_key_env: nil, stream: true, retries: true, timeout: nil,
|
|
90
108
|
env: ENV, sleeper: nil, retry_policy: nil, models_ttl: DEFAULT_MODELS_TTL, remote: false,
|
|
91
|
-
first_token_timeout: nil, purpose: "chat")
|
|
109
|
+
first_token_timeout: nil, purpose: "chat", default_max_tokens: nil)
|
|
92
110
|
@remote = remote
|
|
111
|
+
@default_max_tokens = default_max_tokens
|
|
93
112
|
# What its requests are for, in the log (LLM::HTTP::LOGGED_PURPOSES).
|
|
94
113
|
@purpose = purpose
|
|
95
114
|
@first_token_timeout = first_token_timeout
|
|
@@ -121,7 +140,8 @@ module Samagotchi
|
|
|
121
140
|
session_id: nil)
|
|
122
141
|
body = request_body(messages, tools, model, options)
|
|
123
142
|
request = post_request("#{@base_url}/chat/completions", body, session_id: session_id)
|
|
124
|
-
log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body)
|
|
143
|
+
log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body),
|
|
144
|
+
cache: ("on" if PromptCache.marked?(body[:messages])) }
|
|
125
145
|
return chat_once(request, cancel_controller, log_fields) unless @stream
|
|
126
146
|
|
|
127
147
|
assembly = Assembly.new
|
|
@@ -161,7 +181,7 @@ module Samagotchi
|
|
|
161
181
|
models = []
|
|
162
182
|
after = nil
|
|
163
183
|
MAX_MODEL_PAGES.times do
|
|
164
|
-
uri = URI("#{@base_url}/models#{
|
|
184
|
+
uri = URI("#{@base_url}/models#{"?after=#{URI.encode_www_form_component(after)}" if after}")
|
|
165
185
|
body = parse_json(@http.fetch(uri, get_request(uri), log_fields: { purpose: "models" }).body, "model list")
|
|
166
186
|
models.concat(model_entries(body).map { |raw| model_info(raw) })
|
|
167
187
|
break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
|
|
@@ -191,6 +211,18 @@ module Samagotchi
|
|
|
191
211
|
nil
|
|
192
212
|
end
|
|
193
213
|
|
|
214
|
+
def self.served_model(payload)
|
|
215
|
+
model = payload.is_a?(Hash) ? payload["model"] : nil
|
|
216
|
+
model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# OpenRouter names the provider that served a request (Baseten,
|
|
220
|
+
# Fireworks, …) in the body's `provider`; other servers send none.
|
|
221
|
+
def self.served_provider(payload)
|
|
222
|
+
provider = payload.is_a?(Hash) ? payload["provider"] : nil
|
|
223
|
+
provider.is_a?(String) && !provider.strip.empty? ? provider : nil
|
|
224
|
+
end
|
|
225
|
+
|
|
194
226
|
private
|
|
195
227
|
|
|
196
228
|
def timeouts(timeout)
|
|
@@ -204,19 +236,30 @@ module Samagotchi
|
|
|
204
236
|
def request_body(messages, tools, model, options)
|
|
205
237
|
body = {
|
|
206
238
|
model: model,
|
|
207
|
-
messages: Array(messages).map { |message| wire_message(message) },
|
|
239
|
+
messages: cache_breakpoints(Array(messages).map { |message| wire_message(message) }, model),
|
|
208
240
|
temperature: 0.0,
|
|
209
241
|
stream: @stream
|
|
210
242
|
}
|
|
211
243
|
body[:stream_options] = { include_usage: true } if @stream
|
|
244
|
+
body[:max_tokens] = @default_max_tokens if @default_max_tokens
|
|
212
245
|
unless Array(tools).empty?
|
|
213
246
|
body[:tools] = tools
|
|
214
247
|
body[:tool_choice] = "auto"
|
|
215
248
|
end
|
|
216
|
-
# A configured value replaces chi's own (temperature); nil drops it.
|
|
249
|
+
# A configured value replaces chi's own (temperature, max_tokens); nil drops it.
|
|
217
250
|
body.merge(options || {}).compact
|
|
218
251
|
end
|
|
219
252
|
|
|
253
|
+
# Prompt-cache breakpoints (PromptCache) on the wire copies, for a
|
|
254
|
+
# Claude model's chat requests to a remote host: a one-shot side ask
|
|
255
|
+
# would pay for a cache write nothing reads, and a local model named
|
|
256
|
+
# after Claude must not get Array content.
|
|
257
|
+
def cache_breakpoints(messages, model)
|
|
258
|
+
return messages unless @remote && @purpose == "chat"
|
|
259
|
+
|
|
260
|
+
PromptCache.mark(messages, model: model)
|
|
261
|
+
end
|
|
262
|
+
|
|
220
263
|
# The body's fields beyond the conversation and the stream, for the
|
|
221
264
|
# log line: "temperature=0.6 presence_penalty=1.5".
|
|
222
265
|
def sampling_summary(body)
|
|
@@ -247,34 +290,15 @@ module Samagotchi
|
|
|
247
290
|
function = call["function"] || {}
|
|
248
291
|
ToolCall.new(id: call["id"], name: function["name"].to_s, arguments: self.class.parse_arguments(function["arguments"]))
|
|
249
292
|
end
|
|
250
|
-
ChatResponse.new(text:
|
|
293
|
+
ChatResponse.new(text: message["content"].to_s,
|
|
251
294
|
reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
|
|
252
295
|
tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
|
|
253
296
|
finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body),
|
|
254
297
|
provider: self.class.served_provider(body))
|
|
255
298
|
end
|
|
256
299
|
|
|
257
|
-
# Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
|
|
258
|
-
# writes after </think> at the start of the answer
|
|
259
|
-
# (https://github.com/incoai/splash/issues/254). Remove once Splash
|
|
260
|
-
# strips it; llama.cpp already does. Streamed, it applies to the chunks
|
|
261
|
-
# until the first text.
|
|
262
|
-
def self.drop_leading_newlines(text) = text.sub(/\A[\r\n]+/, "")
|
|
263
|
-
|
|
264
|
-
def self.served_model(payload)
|
|
265
|
-
model = payload.is_a?(Hash) ? payload["model"] : nil
|
|
266
|
-
model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
267
|
-
end
|
|
268
|
-
|
|
269
300
|
def served_model(payload) = self.class.served_model(payload)
|
|
270
301
|
|
|
271
|
-
# OpenRouter names the provider that served a request (Baseten,
|
|
272
|
-
# Fireworks, …) in the body's `provider`; other servers send none.
|
|
273
|
-
def self.served_provider(payload)
|
|
274
|
-
provider = payload.is_a?(Hash) ? payload["provider"] : nil
|
|
275
|
-
provider.is_a?(String) && !provider.strip.empty? ? provider : nil
|
|
276
|
-
end
|
|
277
|
-
|
|
278
302
|
def served_provider(payload) = self.class.served_provider(payload)
|
|
279
303
|
|
|
280
304
|
# The parsed payload of a `data:` line; nil for blank lines, comments
|
|
@@ -345,7 +369,7 @@ module Samagotchi
|
|
|
345
369
|
listed = raw["supported_parameters"] || raw["capabilities"]
|
|
346
370
|
return nil unless listed.is_a?(Array)
|
|
347
371
|
|
|
348
|
-
listed.include?("tools")
|
|
372
|
+
listed.include?("tools") || nil
|
|
349
373
|
end
|
|
350
374
|
|
|
351
375
|
def cached_models
|
|
@@ -393,7 +417,6 @@ module Samagotchi
|
|
|
393
417
|
@finish_reason = choice["finish_reason"] if choice["finish_reason"]
|
|
394
418
|
delta = choice["delta"].is_a?(Hash) ? choice["delta"] : {}
|
|
395
419
|
content = delta["content"].to_s
|
|
396
|
-
content = OpenAIChat.drop_leading_newlines(content) if @text.empty?
|
|
397
420
|
reasoning = (delta["reasoning_content"] || delta["reasoning"]).to_s
|
|
398
421
|
@text << content
|
|
399
422
|
@reasoning << reasoning
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module LLM
|
|
5
|
+
# Prompt-cache breakpoints for Claude models behind an OpenAI-compatible
|
|
6
|
+
# host (OpenRouter, a gateway). Anthropic caches a prompt only up to a
|
|
7
|
+
# content part marked with cache_control, in the order tools → system →
|
|
8
|
+
# messages, so two marks cover a chat request:
|
|
9
|
+
# - the first system message (with it, the tool list), and
|
|
10
|
+
# - the last non-system message with content, so the next request (the
|
|
11
|
+
# same prefix plus new messages) reads everything up to it.
|
|
12
|
+
# Mid and tail system messages (reminders, turn notes) never take the
|
|
13
|
+
# second mark. Other models' messages pass through untouched.
|
|
14
|
+
module PromptCache
|
|
15
|
+
CONTROL = { type: "ephemeral" }.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Whether +model+ (the bare id a host is asked for) names a Claude model.
|
|
20
|
+
def claude?(model) = model.to_s.match?(/claude/i)
|
|
21
|
+
|
|
22
|
+
# +messages+ (wire messages: symbol keys, content a String or an Array
|
|
23
|
+
# of parts) with the breakpoints set when +model+ is a Claude model;
|
|
24
|
+
# the given messages as they are otherwise. The marked messages and
|
|
25
|
+
# parts are copies: the caller's are never changed.
|
|
26
|
+
# @return [Array<Hash>]
|
|
27
|
+
def mark(messages, model:)
|
|
28
|
+
return messages unless claude?(model)
|
|
29
|
+
|
|
30
|
+
indexes = [system_index(messages), last_index(messages)].compact.uniq
|
|
31
|
+
return messages if indexes.empty?
|
|
32
|
+
|
|
33
|
+
messages.each_with_index.map { |message, index| indexes.include?(index) ? marked(message) : message }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether any message in +messages+ carries a breakpoint.
|
|
37
|
+
def marked?(messages)
|
|
38
|
+
Array(messages).any? do |message|
|
|
39
|
+
content = message[:content]
|
|
40
|
+
content.is_a?(Array) && content.any? { |part| part.is_a?(Hash) && control?(part) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def system_index(messages)
|
|
45
|
+
index = messages.index { |message| message[:role].to_s == "system" }
|
|
46
|
+
index if index && content?(messages[index][:content])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The last message that isn't a system message and has content (an
|
|
50
|
+
# assistant tool_calls message has nil or "").
|
|
51
|
+
def last_index(messages)
|
|
52
|
+
messages.rindex { |message| message[:role].to_s != "system" && content?(message[:content]) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def content?(content)
|
|
56
|
+
case content
|
|
57
|
+
when String then !content.empty?
|
|
58
|
+
when Array then content.any?(Hash)
|
|
59
|
+
else false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A String becomes one text part; an Array gets the mark on its last
|
|
64
|
+
# text part (the last part when none is text).
|
|
65
|
+
def marked(message)
|
|
66
|
+
content = message[:content]
|
|
67
|
+
return message.merge(content: [{ type: "text", text: content, cache_control: CONTROL }]) if content.is_a?(String)
|
|
68
|
+
|
|
69
|
+
target = content.rindex { |part| part.is_a?(Hash) && (part[:type] || part["type"]).to_s == "text" } ||
|
|
70
|
+
content.rindex { |part| part.is_a?(Hash) }
|
|
71
|
+
part = content[target]
|
|
72
|
+
key = part.key?("type") ? "cache_control" : :cache_control
|
|
73
|
+
parts = content.dup
|
|
74
|
+
parts[target] = part.merge(key => CONTROL)
|
|
75
|
+
message.merge(content: parts)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def control?(part) = part.key?(:cache_control) || part.key?("cache_control")
|
|
79
|
+
|
|
80
|
+
private_class_method :system_index, :last_index, :content?, :marked, :control?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/samagotchi/log.rb
CHANGED
|
@@ -143,7 +143,7 @@ module Samagotchi
|
|
|
143
143
|
def safe_value(key, value)
|
|
144
144
|
return "[redacted]" if key.match?(SECRET_KEY)
|
|
145
145
|
|
|
146
|
-
value = value.
|
|
146
|
+
value = value.round(3) if value.is_a?(Float)
|
|
147
147
|
value.is_a?(String) && value.match?(URL) ? safe_url(value) : value
|
|
148
148
|
end
|
|
149
149
|
|
|
@@ -185,7 +185,7 @@ module Samagotchi
|
|
|
185
185
|
def build_state
|
|
186
186
|
options = @options
|
|
187
187
|
path = options.fetch(:path, :auto)
|
|
188
|
-
path =
|
|
188
|
+
path = LogPath.resolve rescue nil if path == :auto
|
|
189
189
|
level = options[:level] || (Config.get("log.level") rescue nil)
|
|
190
190
|
level = LEVELS.key?(level.to_s.downcase.to_sym) ? level.to_s.downcase.to_sym : DEFAULT_LEVEL
|
|
191
191
|
{ writer: DebugLog.new(path: path), level: LEVELS.fetch(level), mirror: options[:mirror] ? true : false }
|
|
@@ -46,7 +46,8 @@ module Samagotchi
|
|
|
46
46
|
type = event[:type]&.to_sym
|
|
47
47
|
return unless type
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
handler = :"on_#{type}"
|
|
50
|
+
if respond_to?(handler, true)
|
|
50
51
|
send(handler, event)
|
|
51
52
|
elsif ANNOUNCED.key?(type)
|
|
52
53
|
log(:info, type, **event.slice(*ANNOUNCED[type]), **origin(event), **items(event))
|
|
@@ -65,8 +65,9 @@ module Samagotchi
|
|
|
65
65
|
candidates_by_basename = candidates.map { |p| [File.basename(p), p] }.to_h
|
|
66
66
|
missing = allow.reject { |k| candidates_by_basename.key?(k) }
|
|
67
67
|
unless missing.empty?
|
|
68
|
-
raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(
|
|
68
|
+
raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(", ")}"
|
|
69
69
|
end
|
|
70
|
+
|
|
70
71
|
candidates = with_overlays(allow, candidates_by_basename).map { |k| candidates_by_basename[k] }
|
|
71
72
|
else
|
|
72
73
|
candidates = leave_out_owned(candidates, resolved_name)
|
|
@@ -107,6 +108,7 @@ module Samagotchi
|
|
|
107
108
|
src = File.join(prov.hooks_dir, basename)
|
|
108
109
|
# Skip if hook file missing on disk
|
|
109
110
|
next unless File.exist?(src)
|
|
111
|
+
|
|
110
112
|
# meta may be symbol-keyed
|
|
111
113
|
m = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
|
|
112
114
|
sha = (m["sha256"] || m["checksum"] || "").to_s
|
|
@@ -245,7 +247,7 @@ module Samagotchi
|
|
|
245
247
|
def summary
|
|
246
248
|
lines = []
|
|
247
249
|
lines << "Built #{@built_files.size} file(s) to #{@out_path}"
|
|
248
|
-
lines << "Files: #{@built_files.join(
|
|
250
|
+
lines << "Files: #{@built_files.join(", ")}" unless @built_files.empty?
|
|
249
251
|
lines.concat(@placeholder_warnings.map { |w| "Placeholder: #{w}" }) unless @placeholder_warnings.empty?
|
|
250
252
|
lines.concat(@warnings) unless @warnings.empty?
|
|
251
253
|
lines.join("\n")
|
|
@@ -275,6 +277,7 @@ module Samagotchi
|
|
|
275
277
|
|
|
276
278
|
def normalize_scope(val)
|
|
277
279
|
return nil if val.nil? || val.to_s.strip.empty?
|
|
280
|
+
|
|
278
281
|
v = val.to_s.strip.downcase
|
|
279
282
|
%w[project system].include?(v) ? v : nil
|
|
280
283
|
end
|
|
@@ -295,7 +298,7 @@ module Samagotchi
|
|
|
295
298
|
def default_name(scope)
|
|
296
299
|
if scope == "project"
|
|
297
300
|
base = File.basename(MemoryPaths.project_root).gsub(/[^A-Za-z0-9_-]/, "-").downcase
|
|
298
|
-
base = base.
|
|
301
|
+
base = base.squeeze("-").gsub(/\A-+|-+\z/, "")
|
|
299
302
|
base = "project" if base.empty?
|
|
300
303
|
"chi_#{base}_memories"
|
|
301
304
|
else
|
|
@@ -356,6 +359,7 @@ module Samagotchi
|
|
|
356
359
|
if File.exist?(expanded) && !File.directory?(expanded)
|
|
357
360
|
raise BuildError, "Output path exists and is not a directory: #{expanded}"
|
|
358
361
|
end
|
|
362
|
+
|
|
359
363
|
[expanded, :dir]
|
|
360
364
|
end
|
|
361
365
|
end
|
|
@@ -365,6 +369,7 @@ module Samagotchi
|
|
|
365
369
|
if File.exist?(out_path)
|
|
366
370
|
raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
|
|
367
371
|
end
|
|
372
|
+
|
|
368
373
|
# Use zip CLI like source.rb does with unzip
|
|
369
374
|
Dir.chdir(staging) do
|
|
370
375
|
result = system("zip", "-r", out_path, ".")
|
|
@@ -377,6 +382,7 @@ module Samagotchi
|
|
|
377
382
|
if File.exist?(out_path)
|
|
378
383
|
raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
|
|
379
384
|
end
|
|
385
|
+
|
|
380
386
|
# Use tar CLI
|
|
381
387
|
Dir.chdir(staging) do
|
|
382
388
|
cmd = case format
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require "fileutils"
|
|
3
4
|
require "digest"
|
|
4
5
|
require_relative "../atomic_file"
|
|
@@ -16,6 +17,7 @@ module Samagotchi
|
|
|
16
17
|
def self.update_index(scope, entry_name, byte_count, description = nil, source: nil)
|
|
17
18
|
index_path = index_path_for(scope)
|
|
18
19
|
return true unless index_path
|
|
20
|
+
|
|
19
21
|
locked_write(File.dirname(index_path)) do |content|
|
|
20
22
|
new_line = managed_line(entry_name, scope, byte_count, description, source || nil)
|
|
21
23
|
next auto_index_header + "\n\n" + new_line + "\n" if content.nil? || content.strip.empty?
|
|
@@ -32,20 +34,25 @@ module Samagotchi
|
|
|
32
34
|
end
|
|
33
35
|
true
|
|
34
36
|
end
|
|
37
|
+
|
|
35
38
|
def self.managed_pattern(name)
|
|
36
39
|
/^- \*\*#{Regexp.escape(name)}\*\*[ \t]*(?:[·•].*|:.*)?(\r?\n|\z)/
|
|
37
40
|
end
|
|
41
|
+
|
|
38
42
|
def self.remove_index(scope, entry_name)
|
|
39
43
|
index_path = index_path_for(scope)
|
|
40
44
|
return true unless index_path && File.exist?(index_path)
|
|
45
|
+
|
|
41
46
|
locked_write(File.dirname(index_path)) do |content|
|
|
42
47
|
pattern = managed_pattern(entry_name)
|
|
43
48
|
next nil unless content&.match?(pattern)
|
|
49
|
+
|
|
44
50
|
# Clean up extra blank lines: collapse 3+ newlines to 2
|
|
45
51
|
content.gsub(pattern, "").gsub(/\n{3,}/, "\n\n")
|
|
46
52
|
end
|
|
47
53
|
true
|
|
48
54
|
end
|
|
55
|
+
|
|
49
56
|
# Read-modify-write of <dir>/index.md under an exclusive flock on a sidecar
|
|
50
57
|
# <dir>/.index.lock, so sessions in several worktrees of one repository
|
|
51
58
|
# (one shared project folder) don't drop each other's lines. Yields the
|
|
@@ -61,6 +68,7 @@ module Samagotchi
|
|
|
61
68
|
Samagotchi::AtomicFile.write(index_path, updated) unless updated.nil?
|
|
62
69
|
end
|
|
63
70
|
end
|
|
71
|
+
|
|
64
72
|
def self.managed_line(name, scope, byte_count, description, source = nil)
|
|
65
73
|
line = "- **#{name}** · #{scope} · #{date_str} · #{byte_count}"
|
|
66
74
|
line += " · from #{source}" unless source.to_s.strip.empty?
|
|
@@ -68,6 +76,7 @@ module Samagotchi
|
|
|
68
76
|
line += " — #{desc}" unless desc.to_s.empty?
|
|
69
77
|
line
|
|
70
78
|
end
|
|
79
|
+
|
|
71
80
|
def self.extract_description(line)
|
|
72
81
|
if line =~ /\s—\s(.*)\s*\z/
|
|
73
82
|
$1
|
|
@@ -75,6 +84,7 @@ module Samagotchi
|
|
|
75
84
|
$1
|
|
76
85
|
end
|
|
77
86
|
end
|
|
87
|
+
|
|
78
88
|
# The bundle a managed line names ("· from <bundle>", before any
|
|
79
89
|
# " — description"), or nil.
|
|
80
90
|
def self.extract_source(line)
|
|
@@ -86,14 +96,16 @@ module Samagotchi
|
|
|
86
96
|
"Managed entries below are auto-maintained by memory_write " \
|
|
87
97
|
"(name, scope, last-written date, size). Free-form sections are preserved."
|
|
88
98
|
end
|
|
99
|
+
|
|
89
100
|
def self.date_str
|
|
90
101
|
require "date"
|
|
91
102
|
Date.today.iso8601
|
|
92
103
|
end
|
|
104
|
+
|
|
93
105
|
def self.index_path_for(scope)
|
|
94
106
|
base_dir = MemoryPaths.scope_dir(scope.to_s.strip)
|
|
95
107
|
base_dir && File.join(base_dir, "#{MEMORY_INDEX}.md")
|
|
96
108
|
end
|
|
97
109
|
end
|
|
98
110
|
end
|
|
99
|
-
end
|
|
111
|
+
end
|