samagotchi 0.10.1 → 0.12.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +23 -4
  5. data/docs/cli.md +49 -8
  6. data/docs/configuration.md +44 -11
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/context-telemetry.md +4 -2
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  13. data/lib/samagotchi/bridge.rb +4 -4
  14. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  15. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/client.rb +21 -1
  21. data/lib/samagotchi/config.rb +44 -40
  22. data/lib/samagotchi/context_status.rb +17 -16
  23. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  25. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  27. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  28. data/lib/samagotchi/engine.rb +506 -499
  29. data/lib/samagotchi/host_registry.rb +76 -92
  30. data/lib/samagotchi/idle_reminders.rb +13 -36
  31. data/lib/samagotchi/kernel_loop.rb +352 -407
  32. data/lib/samagotchi/llm/chat_loop.rb +95 -125
  33. data/lib/samagotchi/llm/model_result.rb +14 -20
  34. data/lib/samagotchi/llm/native_backend.rb +3 -21
  35. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  36. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  37. data/lib/samagotchi/llm/usage.rb +2 -57
  38. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  39. data/lib/samagotchi/model_catalog.rb +101 -0
  40. data/lib/samagotchi/model_profile.rb +23 -4
  41. data/lib/samagotchi/model_ref.rb +60 -0
  42. data/lib/samagotchi/models_command.rb +112 -0
  43. data/lib/samagotchi/prompt_history.rb +91 -0
  44. data/lib/samagotchi/recap_setup.rb +15 -3
  45. data/lib/samagotchi/reminder_queue.rb +73 -0
  46. data/lib/samagotchi/reminder_store.rb +3 -3
  47. data/lib/samagotchi/self_report.rb +19 -7
  48. data/lib/samagotchi/send_command.rb +9 -9
  49. data/lib/samagotchi/session.rb +9 -2
  50. data/lib/samagotchi/session_commands.rb +15 -17
  51. data/lib/samagotchi/session_manager.rb +22 -9
  52. data/lib/samagotchi/sessions_command.rb +57 -51
  53. data/lib/samagotchi/steer.rb +23 -0
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  56. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  57. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  58. data/lib/samagotchi/terminal_ui.rb +16 -35
  59. data/lib/samagotchi/thinking.rb +1 -1
  60. data/lib/samagotchi/tool_response.rb +67 -0
  61. data/lib/samagotchi/tool_runner.rb +10 -7
  62. data/lib/samagotchi/tools/builtins.rb +1 -1
  63. data/lib/samagotchi/tools/delegate.rb +4 -3
  64. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  65. data/lib/samagotchi/tools/memory.rb +8 -6
  66. data/lib/samagotchi/turn_flow.rb +4 -4
  67. data/lib/samagotchi/turn_state.rb +126 -0
  68. data/lib/samagotchi/used_memories.rb +88 -0
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/vision_support.rb +4 -4
  71. data/lib/samagotchi/web/app.rb +42 -35
  72. data/lib/samagotchi/web/public/app.js +29 -2
  73. data/lib/samagotchi/web/public/data.js +9 -1
  74. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  75. data/lib/samagotchi/web/session_hub.rb +0 -4
  76. data/lib/samagotchi/web/session_summary.rb +6 -6
  77. data/lib/samagotchi/worker.rb +3 -2
  78. metadata +13 -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)
@@ -271,7 +229,14 @@ module Samagotchi
271
229
  # Returns { host_name => { host:, port:, transport:, models: [LLM::ModelInfo], error: nil|String } }
272
230
  # Also populates the model index. Unless forced, a host's list is reused
273
231
  # for its TTL (60s; 10 minutes for a remote host).
274
- def list_all_models(force: true)
232
+ #
233
+ # With +wait+ (seconds), answers by one shared deadline: a host still
234
+ # listing then gets the error "no answer in S s" and its thread is left
235
+ # running (only for a short-lived process: `chi models`). Such a partial
236
+ # listing is not stored as the cache or the model index, so a later
237
+ # resolve doesn't miss the slow host's ids; finished hosts' lists are
238
+ # kept for their TTL as usual.
239
+ def list_all_models(force: true, wait: nil)
275
240
  results = {}
276
241
  results_mutex = Mutex.new
277
242
  threads = @entries.map do |name, entry|
@@ -289,19 +254,38 @@ module Samagotchi
289
254
  results_mutex.synchronize { results[name] = data }
290
255
  end
291
256
  end
292
- threads.each { |thread| thread.join if thread.is_a?(Thread) }
257
+ if wait
258
+ deadline = @clock.call + wait
259
+ threads.each { |thread| thread.join([deadline - @clock.call, 0].max) if thread.is_a?(Thread) }
260
+ partial = false
261
+ # a new hash: the threads keep writing into +results+ (their closure's)
262
+ snapshot = results_mutex.synchronize do
263
+ @entries.each_with_object({}) do |(name, entry), acc|
264
+ acc[name] = results.fetch(name) do
265
+ partial = true
266
+ { host: entry.host, port: entry.port, transport: entry.transport, models: [],
267
+ error: "no answer in #{wait == wait.to_i ? wait.to_i : wait} s" }
268
+ end
269
+ end
270
+ end
271
+ return snapshot if partial
272
+ else
273
+ threads.each { |thread| thread.join if thread.is_a?(Thread) }
274
+ end
275
+
276
+ # Model index: model_id downcased -> the hosts listing it, in hosts:
277
+ # order (not the order the threads answered in).
278
+ index = Hash.new { |h, k| h[k] = [] }
279
+ @entries.each_key do |hname|
280
+ data = results[hname]
281
+ next if data.nil? || data[:error]
293
282
 
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]
298
283
  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)
284
+ down = m.id.to_s.strip.downcase
285
+ index[down] << hname unless down.empty? || index[down].include?(hname)
303
286
  end
304
287
  end
288
+ index.default_proc = nil
305
289
 
306
290
  @mutex.synchronize do
307
291
  @cache = results
@@ -1,64 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
4
-
5
- require_relative "reminder_store"
3
+ require_relative "reminder_queue"
6
4
 
7
5
  module Samagotchi
8
6
  # Idle job for periodic reminders — polled by the shared IdleScheduler
9
7
  # (one background thread for the whole idle layer).
10
8
  #
11
9
  # Two delivery paths cooperate:
12
- # 1. Pull-based: Engine#collect_due_reminders
13
- # reads ReminderStore#due_reminders at turn start → injects
14
- # [SYSTEM: REMINDERS DUE] → marks all as fired atomically.
10
+ # 1. Every turn injects what is due (ReminderQueue#inject!, from
11
+ # Engine#run_turn) as [SYSTEM: REMINDERS DUE] and marks it fired.
15
12
  # 2. Synthetic turns: when this job's tick finds due reminders while
16
- # idle, it latches @due_reminder_name and fires @auto_turn_callback
17
- # (the TUI/SessionManager queue a synthetic turn from the latch).
13
+ # idle, it fires @auto_turn_callback, which queues their names
14
+ # (Engine#note_due_reminders) for the worker's or the REPL's
15
+ # reminder turn. While names are queued the tick waits; whoever
16
+ # clears them (a turn's injection, the reminder turn's consumer)
17
+ # re-arms it. The queue is the only "already due" state.
18
18
  class IdleReminders
19
19
  DEFAULT_MIN_INACTIVITY_SECONDS = 60.0 # Minimum idle time before checking for reminders
20
20
 
21
21
  attr_reader :inactivity
22
22
 
23
23
  def initialize(engine:, inactivity: DEFAULT_MIN_INACTIVITY_SECONDS,
24
- reminder_store: nil,
24
+ queue: nil,
25
25
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
26
26
  callback: nil)
27
27
  raise ArgumentError, "IdleReminders requires an engine" unless engine
28
28
 
29
29
  @engine = engine
30
30
  @inactivity = inactivity
31
- @reminder_store = reminder_store || engine.instance_variable_get(:@reminder_store)
31
+ @queue = queue
32
32
  @clock = clock
33
33
  @auto_turn_callback = callback
34
-
35
- @mutex = Monitor.new
36
- @due_reminder_name = nil
37
- end
38
-
39
- # Get all due reminders (from ReminderStore). Called by Engine#collect_due_reminders.
40
- # Thread-safe. Returns all due reminders, not just one.
41
- # @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
42
- def due_reminders
43
- @reminder_store&.due_reminders || []
44
- end
45
- # Get the names of all due reminders. Called by the background thread to
46
- # determine which reminders need a synthetic turn.
47
- # @return [Array<String>] reminder names that are due
48
- def due_reminder_names
49
- due_reminders.map { |r| r[:name] }
50
- end
51
-
52
- # Clear the pending due reminder (after it has been delivered).
53
- # Called by Engine after injecting the reminder into the system prompt.
54
- def clear_due
55
- @mutex.synchronize { @due_reminder_name = nil }
56
34
  end
57
35
 
58
36
  # One detector step, called by the shared IdleScheduler. Public so specs
59
37
  # can drive it deterministically.
60
38
  def tick
61
- return if @due_reminder_name # already due, waiting for engine to deliver
39
+ return if @queue&.pending? # already queued, waiting for a turn to deliver it
62
40
  return unless should_check?
63
41
 
64
42
  check_due_reminders
@@ -78,14 +56,13 @@ module Samagotchi
78
56
  end
79
57
 
80
58
  def check_due_reminders
81
- return unless @reminder_store
82
- due_names = @reminder_store.due_reminders.map { |r| r[:name] }
59
+ return unless @queue
60
+ due_names = @queue.due.map { |r| r[:name] }
83
61
  return if due_names.empty?
84
62
  # Signal the engine to create a synthetic turn via callback.
85
63
  # The callback is responsible for triggering a turn (e.g. SessionManager
86
64
  # writes a file, TerminalUI queues input). The engine's run_turn or
87
65
  # REPL injection point then picks up and delivers the reminders.
88
- @mutex.synchronize { @due_reminder_name = due_names.first }
89
66
  if @auto_turn_callback
90
67
  @auto_turn_callback.call(due_names)
91
68
  end