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.
Files changed (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/bin/chi +9 -4
  4. data/docs/architecture.md +3 -2
  5. data/docs/configuration.md +15 -4
  6. data/docs/releasing.md +1 -1
  7. data/docs/sessions.md +1 -1
  8. data/docs/sub-agent.md +11 -5
  9. data/docs/testing.md +6 -0
  10. data/lib/samagotchi/answer_command.rb +11 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  12. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  13. data/lib/samagotchi/bootstrap_command.rb +2 -2
  14. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
  17. data/lib/samagotchi/bridge.rb +12 -9
  18. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  19. data/lib/samagotchi/bridge_client.rb +1 -1
  20. data/lib/samagotchi/bundle_command.rb +39 -29
  21. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  23. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
  25. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  26. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  29. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  31. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/cancellation_controller.rb +1 -1
  38. data/lib/samagotchi/cli/command.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  40. data/lib/samagotchi/client.rb +3 -5
  41. data/lib/samagotchi/commands/registry.rb +2 -2
  42. data/lib/samagotchi/config.rb +38 -14
  43. data/lib/samagotchi/context_note.rb +1 -1
  44. data/lib/samagotchi/context_status.rb +6 -6
  45. data/lib/samagotchi/continue_offer.rb +44 -12
  46. data/lib/samagotchi/debug_log.rb +1 -1
  47. data/lib/samagotchi/desktop/macos.rb +5 -4
  48. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  49. data/lib/samagotchi/engine.rb +49 -19
  50. data/lib/samagotchi/gem_update.rb +2 -1
  51. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  52. data/lib/samagotchi/guardrails/approval.rb +5 -5
  53. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  54. data/lib/samagotchi/guardrails/gate.rb +2 -2
  55. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  56. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  57. data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
  58. data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
  59. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  60. data/lib/samagotchi/hooks/loader.rb +18 -16
  61. data/lib/samagotchi/hooks/registry.rb +10 -2
  62. data/lib/samagotchi/host_registry.rb +1 -3
  63. data/lib/samagotchi/idle_recap.rb +18 -15
  64. data/lib/samagotchi/idle_reminders.rb +6 -3
  65. data/lib/samagotchi/image_store.rb +5 -2
  66. data/lib/samagotchi/kernel_loop.rb +31 -31
  67. data/lib/samagotchi/llm/chat_loop.rb +9 -6
  68. data/lib/samagotchi/llm/errors.rb +33 -2
  69. data/lib/samagotchi/llm/http.rb +12 -12
  70. data/lib/samagotchi/llm/openai_chat.rb +53 -30
  71. data/lib/samagotchi/llm/prompt_cache.rb +83 -0
  72. data/lib/samagotchi/log.rb +2 -2
  73. data/lib/samagotchi/log_subscriber.rb +2 -1
  74. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  75. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  76. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  77. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  78. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  79. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  80. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  81. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  82. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  83. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  84. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  85. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  86. data/lib/samagotchi/memory_bundle.rb +1 -0
  87. data/lib/samagotchi/model_overlay.rb +3 -5
  88. data/lib/samagotchi/model_profile.rb +12 -8
  89. data/lib/samagotchi/note_command.rb +2 -1
  90. data/lib/samagotchi/output_formatter.rb +18 -17
  91. data/lib/samagotchi/owner_lock.rb +2 -1
  92. data/lib/samagotchi/parent_report.rb +24 -7
  93. data/lib/samagotchi/plugin/api.rb +8 -5
  94. data/lib/samagotchi/plugin/context.rb +1 -3
  95. data/lib/samagotchi/plugin/loader.rb +1 -1
  96. data/lib/samagotchi/plugin/service.rb +1 -1
  97. data/lib/samagotchi/plugin/sessions.rb +1 -0
  98. data/lib/samagotchi/plugin_tasks.rb +1 -1
  99. data/lib/samagotchi/prompt.rb +68 -12
  100. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  101. data/lib/samagotchi/question_desk.rb +7 -13
  102. data/lib/samagotchi/recap_setup.rb +1 -1
  103. data/lib/samagotchi/relay_desk.rb +1 -1
  104. data/lib/samagotchi/reminder_store.rb +2 -1
  105. data/lib/samagotchi/reply_wait.rb +12 -2
  106. data/lib/samagotchi/send_command.rb +4 -2
  107. data/lib/samagotchi/session.rb +2 -2
  108. data/lib/samagotchi/session_archive_command.rb +5 -1
  109. data/lib/samagotchi/session_commands.rb +1 -3
  110. data/lib/samagotchi/session_manager.rb +2 -4
  111. data/lib/samagotchi/session_metrics.rb +10 -10
  112. data/lib/samagotchi/session_observer.rb +3 -2
  113. data/lib/samagotchi/session_retention.rb +2 -2
  114. data/lib/samagotchi/sessions_command.rb +14 -5
  115. data/lib/samagotchi/system_prompt.rb +19 -8
  116. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  117. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  118. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
  119. data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
  120. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  121. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  122. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  123. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  124. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  125. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  126. data/lib/samagotchi/terminal_ui.rb +9 -5
  127. data/lib/samagotchi/text_diff.rb +12 -12
  128. data/lib/samagotchi/thinking.rb +6 -2
  129. data/lib/samagotchi/thought_stream_splitter.rb +6 -3
  130. data/lib/samagotchi/tool_activity.rb +1 -0
  131. data/lib/samagotchi/tool_call_parser.rb +13 -14
  132. data/lib/samagotchi/tool_declarations.rb +36 -35
  133. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  134. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  135. data/lib/samagotchi/tools/delegate.rb +1 -1
  136. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  137. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  138. data/lib/samagotchi/tools/edit.rb +14 -13
  139. data/lib/samagotchi/tools/execute.rb +3 -5
  140. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  141. data/lib/samagotchi/tools/memory.rb +21 -22
  142. data/lib/samagotchi/tools/read.rb +7 -7
  143. data/lib/samagotchi/tools/task_create.rb +1 -1
  144. data/lib/samagotchi/tools/task_get.rb +1 -1
  145. data/lib/samagotchi/tools/task_list.rb +1 -1
  146. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  147. data/lib/samagotchi/tools/task_stop.rb +1 -1
  148. data/lib/samagotchi/tools/task_wait.rb +3 -4
  149. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  150. data/lib/samagotchi/tools/write.rb +1 -1
  151. data/lib/samagotchi/turn_flow.rb +8 -8
  152. data/lib/samagotchi/turn_note.rb +5 -1
  153. data/lib/samagotchi/turn_state.rb +22 -0
  154. data/lib/samagotchi/version.rb +1 -1
  155. data/lib/samagotchi/vision_support.rb +2 -2
  156. data/lib/samagotchi/web/app.rb +13 -6
  157. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  158. data/lib/samagotchi/web/qr.rb +7 -1
  159. data/lib/samagotchi/web/server.rb +6 -2
  160. data/lib/samagotchi/worker.rb +10 -2
  161. 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
- **options)
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, **options)
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,
@@ -138,7 +138,7 @@ module Samagotchi
138
138
  end
139
139
  end
140
140
 
141
- def stream_attempt(uri, request, current, &on_line)
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
- on_line.call(buffer.slice!(0, newline_index + 1).strip, shown)
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
- on_line.call(buffer.strip, shown) unless buffer.strip.empty?
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.new(cancel_controller.reason) if !e.is_a?(RequestCancelled) && cancel_controller&.cancelled?
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, &block)
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, &block)
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) { |state| yield(state) }
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.new(cancel_controller.reason) if cancel_controller&.cancelled?
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.new(cancel_controller.reason) if cancel_controller&.cancelled?
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.new(cancel_controller.reason) if cancel_controller&.cancelled?
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.new(cancel_controller.reason) if cancel_controller.cancelled?
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, **options)
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?, **options)
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#{after ? "?after=#{URI.encode_www_form_component(after)}" : ""}")
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: OpenAIChat.drop_leading_newlines(message["content"].to_s),
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") ? true : nil
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
@@ -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.is_a?(Float) ? value.round(3) : 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 = (LogPath.resolve rescue nil) if path == :auto
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
- if respond_to?(handler = :"on_#{type}", true)
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(', ')}" unless @built_files.empty?
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.gsub(/-+/, "-").gsub(/\A-+|-+\z/, "")
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