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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +23 -4
- data/docs/cli.md +49 -8
- data/docs/configuration.md +44 -11
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +3 -2
- data/docs/internals/context-telemetry.md +4 -2
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
- data/lib/samagotchi/bridge.rb +4 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +21 -1
- data/lib/samagotchi/config.rb +44 -40
- data/lib/samagotchi/context_status.rb +17 -16
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/empty_answer_retry.rb +67 -8
- data/lib/samagotchi/engine.rb +506 -499
- data/lib/samagotchi/host_registry.rb +76 -92
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +352 -407
- data/lib/samagotchi/llm/chat_loop.rb +95 -125
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +1 -1
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +1 -1
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +23 -4
- data/lib/samagotchi/model_ref.rb +60 -0
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/prompt_history.rb +91 -0
- data/lib/samagotchi/recap_setup.rb +15 -3
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/self_report.rb +19 -7
- data/lib/samagotchi/send_command.rb +9 -9
- data/lib/samagotchi/session.rb +9 -2
- data/lib/samagotchi/session_commands.rb +15 -17
- data/lib/samagotchi/session_manager.rb +22 -9
- data/lib/samagotchi/sessions_command.rb +57 -51
- data/lib/samagotchi/steer.rb +23 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
- data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
- data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
- data/lib/samagotchi/terminal_ui.rb +16 -35
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/tool_response.rb +67 -0
- data/lib/samagotchi/tool_runner.rb +10 -7
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +4 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +8 -6
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +42 -35
- data/lib/samagotchi/web/public/app.js +29 -2
- data/lib/samagotchi/web/public/data.js +9 -1
- data/lib/samagotchi/web/public/prompt_history.js +63 -0
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +6 -6
- data/lib/samagotchi/worker.rb +3 -2
- 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
|
|
18
|
-
#
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
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
|
-
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
251
|
-
#
|
|
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:
|
|
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
|
|
260
|
-
#
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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.
|
|
13
|
-
#
|
|
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
|
|
17
|
-
# (
|
|
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
|
-
|
|
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
|
-
@
|
|
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 @
|
|
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 @
|
|
82
|
-
due_names = @
|
|
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
|