samagotchi 0.10.1 → 0.11.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/docs/architecture.md +19 -0
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +44 -11
  6. data/docs/guardrails.md +2 -1
  7. data/docs/hooks.md +3 -2
  8. data/docs/internals/context-telemetry.md +4 -2
  9. data/docs/sessions.md +3 -3
  10. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  11. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  12. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  14. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  15. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/client.rb +21 -1
  18. data/lib/samagotchi/config.rb +44 -40
  19. data/lib/samagotchi/context_status.rb +17 -16
  20. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  21. data/lib/samagotchi/engine.rb +64 -20
  22. data/lib/samagotchi/host_registry.rb +50 -90
  23. data/lib/samagotchi/kernel_loop.rb +288 -305
  24. data/lib/samagotchi/llm/chat_loop.rb +78 -87
  25. data/lib/samagotchi/model_profile.rb +22 -3
  26. data/lib/samagotchi/model_ref.rb +60 -0
  27. data/lib/samagotchi/prompt_history.rb +91 -0
  28. data/lib/samagotchi/recap_setup.rb +15 -3
  29. data/lib/samagotchi/self_report.rb +11 -6
  30. data/lib/samagotchi/send_command.rb +8 -8
  31. data/lib/samagotchi/session.rb +9 -2
  32. data/lib/samagotchi/session_commands.rb +10 -12
  33. data/lib/samagotchi/session_manager.rb +20 -3
  34. data/lib/samagotchi/sessions_command.rb +57 -51
  35. data/lib/samagotchi/steer.rb +23 -0
  36. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  37. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  38. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  39. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  40. data/lib/samagotchi/terminal_ui.rb +9 -8
  41. data/lib/samagotchi/tool_response.rb +67 -0
  42. data/lib/samagotchi/tool_runner.rb +6 -1
  43. data/lib/samagotchi/tools/delegate.rb +4 -3
  44. data/lib/samagotchi/tools/memory.rb +8 -6
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +28 -2
  47. data/lib/samagotchi/web/public/app.js +29 -2
  48. data/lib/samagotchi/web/public/data.js +9 -1
  49. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  50. data/lib/samagotchi/worker.rb +2 -1
  51. metadata +6 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "config"
4
+ require_relative "model_ref"
4
5
  require_relative "client"
5
6
  require_relative "llm/openai_chat"
6
7
 
@@ -14,8 +15,9 @@ module Samagotchi
14
15
  # /models), not on startup. Each host's list is cached (60s, 10 minutes
15
16
  # for a remote host) with skip-on-error; lists are LLM::ModelInfo.
16
17
  # - Routing: client_for_model resolves a (possibly qualified) model string
17
- # to the appropriate Client instance. A remote host is chosen only by
18
- # exact model id, host:model or an alias, never by a substring.
18
+ # to the appropriate Client instance: host:model, an alias, or an exact
19
+ # id in a cached list (default host first, then hosts: order); never by
20
+ # a substring.
19
21
  class HostRegistry
20
22
  CACHE_TTL_SECONDS = 60
21
23
  REMOTE_CACHE_TTL_SECONDS = 600
@@ -45,8 +47,7 @@ module Samagotchi
45
47
  def openai_base_url = url || "#{root_url}/v1"
46
48
 
47
49
  # A provider on the network rather than a local server: it needs a key
48
- # or speaks https. Its model list is cached longer and it is never
49
- # picked by a substring of a model name.
50
+ # or speaks https. Its model list is cached longer.
50
51
  def remote? = !api_key_env.to_s.empty? || scheme == "https"
51
52
 
52
53
  def models_ttl = remote? ? REMOTE_CACHE_TTL_SECONDS : CACHE_TTL_SECONDS
@@ -106,7 +107,7 @@ module Samagotchi
106
107
  @mutex = Mutex.new
107
108
  @cache = nil
108
109
  @cache_at = nil
109
- @model_index = nil # downcased model_id => host_name
110
+ @model_index = nil # downcased model_id => [host_name] in hosts: order
110
111
  end
111
112
 
112
113
  def entries
@@ -142,79 +143,26 @@ module Samagotchi
142
143
  # @param target [ModelTarget, nil] the resolved model's target (resolved when not given)
143
144
  # @return [Array<String>]
144
145
  def lookup_names(typed, resolved: nil, target: nil)
145
- aliased = ConfigFile.resolve_model_alias(typed, env: @env)
146
- target ||= resolve(resolved || aliased)
147
- [typed, aliased, resolved, parse_qualified_model(typed).last, target.bare_model]
146
+ target ||= resolve(resolved || typed)
147
+ [typed, model_ref(typed).ref, resolved, parse_qualified_model(typed).last, target.bare_model]
148
148
  .map { |name| name.to_s.strip }.reject(&:empty?).uniq
149
149
  end
150
150
 
151
- # Resolve model string (already alias-resolved, may be qualified) to a HostEntry.
152
- # If qualified explicitly, return that host. If unqualified, try cached model index,
153
- # else fallback to default host.
151
+ # The host a model name, alias or host:model ref goes to and the model
152
+ # id it is sent as (ModelRef: one alias pass). A ref that names a host
153
+ # goes there. A bare id goes to the default host when its cached list
154
+ # has it, else to the first host in hosts: order whose list has it,
155
+ # else to the default host (before /models nothing is listed: no
156
+ # discovery here, the latency budget). Never by a substring.
154
157
  def host_for_model(raw_model)
155
- host_ref, bare = parse_qualified_model(raw_model)
156
- # If qualified, try to resolve alias on the bare part (small-box:small -> small-box:gemma-small)
157
- if host_ref && bare
158
- begin
159
- aliases = ConfigFile.model_aliases
160
- resolved = aliases.fetch(bare.downcase, bare)
161
- bare = resolved if resolved != bare
162
- rescue StandardError
163
- nil
164
- end
165
- # parse_qualified_model names a host only when it is one of ours
166
- return [find_entry(host_ref), bare]
167
- end
168
- # Unqualified: also try alias resolution for discovery (small -> gemma-small or small -> small-box:gemma-small)
169
- begin
170
- aliases = ConfigFile.model_aliases
171
- resolved = aliases.fetch(bare.to_s.strip.downcase, bare)
172
- if resolved != bare
173
- # If alias points to a qualified ref, re-parse it
174
- q_host, q_bare = parse_qualified_model(resolved)
175
- if q_host
176
- entry = find_entry(q_host)
177
- return [entry, q_bare] if entry
178
- end
179
- bare = resolved
180
- end
181
- rescue StandardError
182
- nil
183
- end
184
- bare_down = bare.to_s.strip.downcase
185
- # Try cached index (populated after list_all_models)
186
- idx = @mutex.synchronize { @model_index }
187
- if idx && idx.key?(bare_down)
188
- host_name = idx[bare_down]
189
- entry = find_entry(host_name)
190
- return [entry, bare] if entry
191
- end
192
- # Fallback: try substring match in cached aggregated results if available
193
- # (lightweight: scan cached model lists). Remote hosts match exactly only.
194
- cached = @mutex.synchronize { @cache }
195
- if cached
196
- cached.each do |hname, data|
197
- next unless data[:models]
198
- data[:models].each do |m|
199
- if m.id.downcase == bare_down
200
- entry = find_entry(hname)
201
- return [entry, bare] if entry
202
- end
203
- end
204
- end
205
- cached.each do |hname, data|
206
- next unless data[:models]
207
- next if find_entry(hname)&.remote?
208
-
209
- data[:models].each do |m|
210
- if m.id.downcase.include?(bare_down)
211
- entry = find_entry(hname)
212
- return [entry, bare] if entry
213
- end
214
- end
215
- end
216
- end
217
- [default_entry, bare]
158
+ ref = model_ref(raw_model)
159
+ # ModelRef names a host only when it is one of ours
160
+ return [find_entry(ref.host_name), ref.id] if ref.host_name
161
+
162
+ listed = @mutex.synchronize { @model_index }&.fetch(ref.id.to_s.strip.downcase, nil) || []
163
+ default = default_entry
164
+ name = listed.include?(default.name) ? default.name : listed.first
165
+ [(name && find_entry(name)) || default, ref.id]
218
166
  end
219
167
 
220
168
  # The chat adapter for a host (one per host, so its cached model list
@@ -247,20 +195,30 @@ module Samagotchi
247
195
  [client_for(host_entry), bare, host_entry]
248
196
  end
249
197
 
250
- # The single host/model resolution: alias and host routing (host_for_model)
251
- # plus the name sent to the server (bare_name).
198
+ # The single host/model resolution: the alias applied, the host and
199
+ # the name sent to the server (host_for_model).
252
200
  # @param raw_model [String] a model name, alias or host:model ref
253
201
  # @return [ModelTarget]
254
202
  def resolve(raw_model)
255
- entry, = host_for_model(raw_model)
256
- ModelTarget.new(model: raw_model, entry: entry, bare_model: bare_name(raw_model), client: client_for(entry))
203
+ entry, bare = host_for_model(raw_model)
204
+ ModelTarget.new(model: raw_model, entry: entry, bare_model: bare, client: client_for(entry))
257
205
  end
258
206
 
259
- # The model name without a known host prefix ("box:gemma" → "gemma").
260
- # Aliases are not applied here.
207
+ # The model id sent for +full_ref+: its alias applied, a known host
208
+ # prefix stripped ("box:gemma" → "gemma").
261
209
  def bare_name(full_ref)
262
- _, bare = parse_qualified_model(full_ref)
263
- bare.to_s.strip.empty? ? full_ref.to_s.strip : bare
210
+ model_ref(full_ref).id
211
+ end
212
+
213
+ # +raw+ parsed against these hosts and config.yml's aliases.
214
+ # @return [ModelRef]
215
+ def model_ref(raw)
216
+ aliases = begin
217
+ ConfigFile.model_aliases(env: @env)
218
+ rescue StandardError
219
+ {}
220
+ end
221
+ ModelRef.parse(raw, hosts: @entries, aliases: aliases)
264
222
  end
265
223
 
266
224
  def client_for(entry)
@@ -291,17 +249,19 @@ module Samagotchi
291
249
  end
292
250
  threads.each { |thread| thread.join if thread.is_a?(Thread) }
293
251
 
294
- # Build model index: model_id downcased -> host_name (first host wins)
295
- index = {}
296
- results.each do |hname, data|
297
- next if data[:error]
252
+ # Model index: model_id downcased -> the hosts listing it, in hosts:
253
+ # order (not the order the threads answered in).
254
+ index = Hash.new { |h, k| h[k] = [] }
255
+ @entries.each_key do |hname|
256
+ data = results[hname]
257
+ next if data.nil? || data[:error]
258
+
298
259
  Array(data[:models]).each do |m|
299
- mid = m.id.to_s
300
- next if mid.strip.empty?
301
- down = mid.downcase
302
- index[down] = hname unless index.key?(down)
260
+ down = m.id.to_s.strip.downcase
261
+ index[down] << hname unless down.empty? || index[down].include?(hname)
303
262
  end
304
263
  end
264
+ index.default_proc = nil
305
265
 
306
266
  @mutex.synchronize do
307
267
  @cache = results