samagotchi 0.2.0 → 0.3.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 +116 -1
- data/README.md +40 -4
- data/bin/chi +86 -19
- data/docs/cli.md +108 -5
- data/docs/configuration.md +229 -45
- data/docs/desktop.md +6 -0
- data/docs/hooks.md +126 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +18 -8
- data/docs/sessions.md +30 -4
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +72 -13
- data/lib/samagotchi/config.rb +196 -36
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
- data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +233 -36
- data/lib/samagotchi/guardrails/approval.rb +9 -0
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/kernel_loop.rb +47 -15
- data/lib/samagotchi/llm/chat_loop.rb +59 -20
- data/lib/samagotchi/llm/errors.rb +21 -3
- data/lib/samagotchi/llm/http.rb +42 -13
- data/lib/samagotchi/llm/openai_chat.rb +12 -4
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +1 -0
- data/lib/samagotchi/send_command.rb +153 -7
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +11 -2
- data/lib/samagotchi/session_manager.rb +114 -9
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +77 -4
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -6
- data/lib/samagotchi/web/app.rb +88 -4
- data/lib/samagotchi/web/public/activity.js +10 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +437 -88
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +10 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +21 -6
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +38 -2
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +46 -0
- data/lib/samagotchi/web/public/turn_view.js +47 -7
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +11 -0
- metadata +20 -1
data/lib/samagotchi/config.rb
CHANGED
|
@@ -116,7 +116,6 @@ module Samagotchi
|
|
|
116
116
|
Entry.new(key: "context.status_cadence", yaml_path: %w[context status_cadence], type: :integer, default: 0, expose: %i[env config cli]),
|
|
117
117
|
|
|
118
118
|
Entry.new(key: "thinking.ui", yaml_path: %w[thinking ui], type: :string, default: nil, expose: %i[env config cli]),
|
|
119
|
-
Entry.new(key: "thinking.preview_lines", yaml_path: %w[thinking preview_lines], type: :integer, default: 1, expose: %i[env config cli]),
|
|
120
119
|
Entry.new(key: "thinking.render_interval", yaml_path: %w[thinking render_interval], type: :float, default: 0.08, expose: %i[env config cli]),
|
|
121
120
|
Entry.new(key: "thinking.turn_preamble", yaml_path: %w[thinking turn_preamble], type: :bool, default: true, expose: %i[env config cli]),
|
|
122
121
|
|
|
@@ -126,6 +125,9 @@ module Samagotchi
|
|
|
126
125
|
Entry.new(key: "retry.max", yaml_path: %w[retry max], type: :integer, default: 5, expose: %i[env config cli]),
|
|
127
126
|
Entry.new(key: "retry.base_delay", yaml_path: %w[retry base_delay], type: :float, default: 0.5, expose: %i[env config cli]),
|
|
128
127
|
Entry.new(key: "retry.max_delay", yaml_path: %w[retry max_delay], type: :float, default: 8.0, expose: %i[env config cli]),
|
|
128
|
+
# Asks again in the same turn after an empty answer (EmptyAnswerRetry);
|
|
129
|
+
# 0 = off, capped at 3. No CLI flag: workers get no CLI args.
|
|
130
|
+
Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
|
|
129
131
|
|
|
130
132
|
Entry.new(key: "read.truncate_at_bytes", yaml_path: %w[read truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
|
|
131
133
|
Entry.new(key: "read.preview_bytes", yaml_path: %w[read preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
|
|
@@ -141,6 +143,8 @@ module Samagotchi
|
|
|
141
143
|
Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli]),
|
|
142
144
|
# The page shows each turn as one block of generations (the live one at the bottom); false brings back the row of bubbles; ?view=turn|chat overrides it per page load.
|
|
143
145
|
Entry.new(key: "web.turn_view", yaml_path: %w[web turn_view], type: :bool, default: true, expose: %i[env config cli]),
|
|
146
|
+
# Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
|
|
147
|
+
Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli]),
|
|
144
148
|
|
|
145
149
|
Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli]),
|
|
146
150
|
Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli]),
|
|
@@ -149,9 +153,23 @@ module Samagotchi
|
|
|
149
153
|
Entry.new(key: "default.input", yaml_path: %w[default input], type: :string, default: nil, expose: %i[env config]),
|
|
150
154
|
Entry.new(key: "history.file", yaml_path: %w[history file], type: :string, default: nil, expose: %i[env config]),
|
|
151
155
|
Entry.new(key: "skip_agent_md", yaml_path: %w[skip_agent_md], type: :bool, default: false, expose: %i[env config]),
|
|
152
|
-
Entry.new(key: "bridge.enable", yaml_path: %w[bridge enable], type: :bool, default: false, expose: %i[env config]),
|
|
153
156
|
].freeze
|
|
154
157
|
|
|
158
|
+
# Top-level maps read by their own code, whose entry names and contents
|
|
159
|
+
# are the user's: ConfigFile.model_aliases / preloaded_memories,
|
|
160
|
+
# Hooks::Loader, Engine#read_bundle_settings.
|
|
161
|
+
FREE_FORM_MAPS = %w[model_aliases hooks bundles memories].freeze
|
|
162
|
+
# Maps of named entries and the keys an entry may hold
|
|
163
|
+
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
164
|
+
MAP_ENTRY_KEYS = {
|
|
165
|
+
"hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling enabled].freeze,
|
|
166
|
+
"models" => %w[profile vision sampling].freeze
|
|
167
|
+
}.freeze
|
|
168
|
+
# Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
|
|
169
|
+
SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
|
|
170
|
+
# Removed settings that warn on their own (Engine.warn_removed_backend_setting).
|
|
171
|
+
REMOVED_KEYS = %w[backend].freeze
|
|
172
|
+
|
|
155
173
|
# Fast lookup maps
|
|
156
174
|
BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
|
|
157
175
|
BY_ENV = ENTRIES.each_with_object({}) do |e, h|
|
|
@@ -186,7 +204,8 @@ module Samagotchi
|
|
|
186
204
|
return nil if raw.nil?
|
|
187
205
|
# For string, preserve as-is (including trailing spaces like "Please ")
|
|
188
206
|
if entry.type == :string
|
|
189
|
-
|
|
207
|
+
# A YAML list for a "|"-separated setting (web.annotate_presets).
|
|
208
|
+
str = raw.is_a?(Array) ? raw.join("|") : raw.to_s
|
|
190
209
|
return nil if str.empty? && entry.type != :string
|
|
191
210
|
return str
|
|
192
211
|
end
|
|
@@ -286,21 +305,31 @@ module Samagotchi
|
|
|
286
305
|
end
|
|
287
306
|
end
|
|
288
307
|
|
|
289
|
-
# Lookup yaml_path in nested hash, accepting snake leaf, kebab alias
|
|
290
|
-
# entry-specific yaml_aliases,
|
|
291
|
-
# SAMAGOTCHI_DEFAULT_MODEL) for transition
|
|
308
|
+
# Lookup yaml_path in nested hash, accepting snake leaf, kebab alias and
|
|
309
|
+
# entry-specific yaml_aliases, then legacy flat UPPER keys (e.g.,
|
|
310
|
+
# SAMAGOTCHI_DEFAULT_MODEL) for transition: the nested key wins when a
|
|
311
|
+
# file has both (ConfigFile.load_global_env! warns about that).
|
|
292
312
|
def lookup_yaml(data, yaml_path)
|
|
293
313
|
entry = ENTRIES.find { |e| e.yaml_path == yaml_path }
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
314
|
+
nested = lookup_nested(data, yaml_path, entry)
|
|
315
|
+
return nested unless nested.nil?
|
|
316
|
+
|
|
317
|
+
lookup_legacy(data, entry)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# The legacy flat key's value for +entry+, or nil.
|
|
321
|
+
def lookup_legacy(data, entry)
|
|
322
|
+
return nil unless entry && data.is_a?(Hash)
|
|
323
|
+
|
|
324
|
+
[entry.env_key, *Array(entry.aliases)].each do |key|
|
|
325
|
+
return data[key] if data.key?(key)
|
|
326
|
+
return data[key.to_sym] if data.key?(key.to_sym)
|
|
303
327
|
end
|
|
328
|
+
nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# The nested key's value for +yaml_path+, or nil.
|
|
332
|
+
def lookup_nested(data, yaml_path, entry = nil)
|
|
304
333
|
cur = data
|
|
305
334
|
yaml_path.each_with_index do |seg, idx|
|
|
306
335
|
return nil unless cur.is_a?(Hash)
|
|
@@ -380,26 +409,98 @@ module Samagotchi
|
|
|
380
409
|
reload!(cli_overrides: overrides)
|
|
381
410
|
end
|
|
382
411
|
|
|
383
|
-
#
|
|
412
|
+
# Problems with the keys of a parsed config.yml, one message per key the
|
|
413
|
+
# code doesn't read (with a "did you mean" when a known key is close).
|
|
414
|
+
# Every config-exposed entry in ENTRIES is known as written; names under
|
|
415
|
+
# the maps are the user's, and host/model entries may hold the keys
|
|
416
|
+
# their readers take. Legacy flat keys warn in ConfigFile.load_global_env!.
|
|
384
417
|
def validate_yaml_sections(data)
|
|
385
418
|
return [] unless data.is_a?(Hash)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
419
|
+
|
|
420
|
+
data.flat_map do |key, value|
|
|
421
|
+
key = key.to_s
|
|
422
|
+
if find_by_env(key)&.config_exposed? || FREE_FORM_MAPS.include?(key) || REMOVED_KEYS.include?(key)
|
|
423
|
+
[]
|
|
424
|
+
elsif MAP_ENTRY_KEYS.key?(key)
|
|
425
|
+
map_entry_problems(key, value)
|
|
426
|
+
elsif value.is_a?(Hash) && (known_sections.include?(key) || ENTRIES.any? { |e| e.yaml_path.size > 1 && e.section == key })
|
|
427
|
+
value.keys.filter_map { |leaf| key_problem("#{key}.#{leaf}") }
|
|
428
|
+
elsif known_sections.include?(key)
|
|
429
|
+
# An all-commented section is nil; recap: false turns recaps off.
|
|
430
|
+
value.nil? || (key == "recap" && value == false) ? [] : ["config: '#{key}' must be a mapping of settings; ignored"]
|
|
431
|
+
else
|
|
432
|
+
[key_problem(key)].compact
|
|
396
433
|
end
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Dotted keys config.yml may set: config-exposed entries (snake and kebab
|
|
438
|
+
# leaf, yaml_aliases), the extra section keys and the map names.
|
|
439
|
+
def known_config_keys
|
|
440
|
+
@known_config_keys ||= ENTRIES.select(&:config_exposed?).flat_map do |entry|
|
|
441
|
+
*section, leaf = entry.yaml_path
|
|
442
|
+
[leaf, leaf.tr("_", "-"), *Array(entry.yaml_aliases)].map { |l| [*section, l].join(".") }
|
|
443
|
+
end.to_set.merge(SECTION_EXTRA_KEYS.flat_map { |s, leaves| leaves.map { |l| "#{s}.#{l}" } })
|
|
444
|
+
.merge(FREE_FORM_MAPS).merge(MAP_ENTRY_KEYS.keys).freeze
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
private
|
|
448
|
+
|
|
449
|
+
def known_sections
|
|
450
|
+
@known_sections ||= known_config_keys.filter_map { |k| k.split(".").first if k.include?(".") }.to_set.freeze
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# nil when +dotted+ is a key the file may hold.
|
|
454
|
+
def key_problem(dotted)
|
|
455
|
+
return nil if known_config_keys.include?(dotted)
|
|
456
|
+
|
|
457
|
+
entry = find_by_key(dotted) || find_by_env(dotted)
|
|
458
|
+
if entry
|
|
459
|
+
where = entry.cli_exposed? ? "#{entry.env_key} or #{entry.cli_flag}" : entry.env_key
|
|
460
|
+
return "config: '#{dotted}' can't be set in config.yml; use #{where}"
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
canonical = ENTRIES.select(&:config_exposed?).map(&:key) + known_sections.to_a +
|
|
464
|
+
SECTION_EXTRA_KEYS.flat_map { |s, leaves| leaves.map { |l| "#{s}.#{l}" } } + FREE_FORM_MAPS + MAP_ENTRY_KEYS.keys
|
|
465
|
+
# A legacy-looking flat key compares by its nested form.
|
|
466
|
+
probe = dotted.start_with?("SAMAGOTCHI_") ? dotted.delete_prefix("SAMAGOTCHI_").downcase : dotted
|
|
467
|
+
unknown_key_message(dotted, probe, canonical)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def map_entry_problems(map, value)
|
|
471
|
+
return [] unless value.is_a?(Hash)
|
|
472
|
+
|
|
473
|
+
allowed = MAP_ENTRY_KEYS.fetch(map)
|
|
474
|
+
value.flat_map do |name, entry|
|
|
475
|
+
next [] unless entry.is_a?(Hash)
|
|
476
|
+
|
|
477
|
+
(entry.keys.map(&:to_s) - allowed).map do |k|
|
|
478
|
+
unknown_key_message("#{map}.#{name}.#{k}", k, allowed, prefix: "#{map}.#{name}.")
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# "config: unknown key 'x'", with a "did you mean" naming the candidates
|
|
484
|
+
# within edit distance 2 of +probe+ (closest first) and the ones that
|
|
485
|
+
# share its last segment, at most three.
|
|
486
|
+
def unknown_key_message(key, probe, candidates, prefix: "")
|
|
487
|
+
candidates = candidates.uniq
|
|
488
|
+
near = candidates.map { |c| [levenshtein(probe, c), c] }.select { |d, _| d <= 2 }.sort.map(&:last)
|
|
489
|
+
close = (near + candidates.select { |c| c.split(".").last == probe.split(".").last }).uniq.first(3)
|
|
490
|
+
hint = close.empty? ? "" : " (did you mean #{close.map { |c| "'#{prefix}#{c}'" }.join(' or ')}?)"
|
|
491
|
+
"config: unknown key '#{key}'#{hint}"
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def levenshtein(a, b)
|
|
495
|
+
prev = (0..b.size).to_a
|
|
496
|
+
a.each_char.with_index(1) do |ca, i|
|
|
497
|
+
cur = [i]
|
|
498
|
+
b.each_char.with_index(1) do |cb, j|
|
|
499
|
+
cur << [prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca == cb ? 0 : 1)].min
|
|
400
500
|
end
|
|
501
|
+
prev = cur
|
|
401
502
|
end
|
|
402
|
-
|
|
503
|
+
prev.last
|
|
403
504
|
end
|
|
404
505
|
end
|
|
405
506
|
end
|
|
@@ -467,6 +568,43 @@ module Samagotchi
|
|
|
467
568
|
nil
|
|
468
569
|
end
|
|
469
570
|
|
|
571
|
+
# Request keys chi sets itself; a `sampling:` map can't override them.
|
|
572
|
+
# (Length caps would need their own semantics.)
|
|
573
|
+
SAMPLING_RESERVED_KEYS = %w[model messages prompt stream stream_options tools tool_choice stop n_predict
|
|
574
|
+
max_tokens n parallel_tool_calls response_format cache_prompt].freeze
|
|
575
|
+
|
|
576
|
+
# A `sampling:` setting (hosts entry or models: entry): request parameters
|
|
577
|
+
# passed through to the provider as written (keys symbolized, nested maps
|
|
578
|
+
# too, so e.g. chat_template_kwargs works). A null value means "don't
|
|
579
|
+
# send it" (drops chi's own temperature default). Not a map: warns once,
|
|
580
|
+
# nil. Reserved keys warn once and are dropped. nil when nothing is left.
|
|
581
|
+
def sampling_map(value, where)
|
|
582
|
+
return nil if value.nil?
|
|
583
|
+
unless value.is_a?(Hash)
|
|
584
|
+
warn_once "Warning: #{where}: sampling must be a map of request parameters; ignored"
|
|
585
|
+
return nil
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
result = value.each_with_object({}) do |(raw_key, raw_value), acc|
|
|
589
|
+
key = raw_key.to_s.strip
|
|
590
|
+
next if key.empty?
|
|
591
|
+
if SAMPLING_RESERVED_KEYS.include?(key)
|
|
592
|
+
warn_once "Warning: #{where}: sampling.#{key} is set by chi; ignored"
|
|
593
|
+
next
|
|
594
|
+
end
|
|
595
|
+
acc[key.to_sym] = deep_symbolize(raw_value)
|
|
596
|
+
end
|
|
597
|
+
result.empty? ? nil : result.freeze
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def deep_symbolize(value)
|
|
601
|
+
case value
|
|
602
|
+
when Hash then value.to_h { |k, v| [k.to_s.to_sym, deep_symbolize(v)] }.freeze
|
|
603
|
+
when Array then value.map { |v| deep_symbolize(v) }.freeze
|
|
604
|
+
else value
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
|
|
470
608
|
# Forget the warnings already printed (specs).
|
|
471
609
|
def reset_warnings!
|
|
472
610
|
@warned = nil
|
|
@@ -492,11 +630,18 @@ module Samagotchi
|
|
|
492
630
|
existed = File.file?(path)
|
|
493
631
|
raw = read_yaml(env: env, path: path) || {}
|
|
494
632
|
if existed
|
|
495
|
-
Samagotchi::Config.validate_yaml_sections(raw).each { |w| Log.warn(:config, "
|
|
496
|
-
# Warn on legacy UPPER keys
|
|
633
|
+
Samagotchi::Config.validate_yaml_sections(raw).each { |w| Log.warn(:config, "config_key", echo: "Warning: #{w}") }
|
|
634
|
+
# Warn on legacy UPPER keys; the nested key wins when both are set.
|
|
635
|
+
# Any other flat key is unknown to validate_yaml_sections.
|
|
497
636
|
raw.each_key do |k|
|
|
498
|
-
|
|
499
|
-
|
|
637
|
+
entry = Samagotchi::Config.find_by_env(k)
|
|
638
|
+
next unless entry&.config_exposed?
|
|
639
|
+
|
|
640
|
+
if !Samagotchi::Config.lookup_nested(raw, entry.yaml_path, entry).nil?
|
|
641
|
+
nested = entry.yaml_path.join(".")
|
|
642
|
+
Log.warn(:config, "legacy_key", echo: "Warning: config: both '#{k}' and '#{nested}' are set; using '#{nested}', remove the flat key", key: k.to_s)
|
|
643
|
+
else
|
|
644
|
+
Log.warn(:config, "legacy_key", echo: "Warning: config key '#{k}' is legacy UPPER — use '#{entry.yaml_path.join('.')}'", key: k.to_s)
|
|
500
645
|
end
|
|
501
646
|
end
|
|
502
647
|
end
|
|
@@ -580,6 +725,7 @@ module Samagotchi
|
|
|
580
725
|
profile = (raw_cfg["profile"] || raw_cfg[:profile]).to_s.strip.downcase
|
|
581
726
|
first_token_timeout = raw_cfg.key?("first_token_timeout") ? raw_cfg["first_token_timeout"] : raw_cfg[:first_token_timeout]
|
|
582
727
|
vision = ConfigFile.vision_flag(raw_cfg.key?("vision") ? raw_cfg["vision"] : raw_cfg[:vision], "hosts entry '#{name}'")
|
|
728
|
+
sampling = ConfigFile.sampling_map(raw_cfg.key?("sampling") ? raw_cfg["sampling"] : raw_cfg[:sampling], "hosts entry '#{name}'")
|
|
583
729
|
unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
|
|
584
730
|
warn_once "Warning: hosts entry '#{name}': first_token_timeout must be seconds (0 = off); using the default"
|
|
585
731
|
first_token_timeout = nil
|
|
@@ -643,7 +789,7 @@ module Samagotchi
|
|
|
643
789
|
api: api_val&.to_sym, original_name: name, scheme: scheme,
|
|
644
790
|
url: url.empty? ? nil : url, api_key_env: api_key_env.empty? ? nil : api_key_env,
|
|
645
791
|
profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
|
|
646
|
-
vision: vision }
|
|
792
|
+
vision: vision, sampling: sampling }
|
|
647
793
|
end
|
|
648
794
|
end
|
|
649
795
|
|
|
@@ -746,7 +892,7 @@ module Samagotchi
|
|
|
746
892
|
location = v[:url] ? { "url" => v[:url] } : { "host" => v[:host], "port" => v[:port] }
|
|
747
893
|
location.merge("transport" => v[:transport]&.to_s, "api" => v[:api]&.to_s, "api_key_env" => v[:api_key_env],
|
|
748
894
|
"profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
|
|
749
|
-
"vision" => v[:vision]).compact
|
|
895
|
+
"vision" => v[:vision], "sampling" => v[:sampling]).compact
|
|
750
896
|
end
|
|
751
897
|
JSON.generate(simple)
|
|
752
898
|
rescue StandardError
|
|
@@ -828,11 +974,25 @@ module Samagotchi
|
|
|
828
974
|
vision = vision_flag(v.key?("vision") ? v["vision"] : v[:vision], "models: #{key}")
|
|
829
975
|
result[key] = { profile: profile.empty? ? nil : profile }
|
|
830
976
|
result[key][:vision] = vision unless vision.nil?
|
|
977
|
+
sampling = sampling_map(v.key?("sampling") ? v["sampling"] : v[:sampling], "models: #{key}")
|
|
978
|
+
result[key][:sampling] = sampling if sampling
|
|
831
979
|
end
|
|
832
980
|
rescue StandardError
|
|
833
981
|
{}
|
|
834
982
|
end
|
|
835
983
|
|
|
984
|
+
# The first +names+ (model as typed, alias-resolved, bare, …) whose
|
|
985
|
+
# models: entry sets +field+: [key, value], or nil. VisionSupport and
|
|
986
|
+
# SamplingSettings look a model up the same way.
|
|
987
|
+
def model_setting(names, field, models: nil)
|
|
988
|
+
models ||= model_settings
|
|
989
|
+
Array(names).map { |name| name.to_s.strip.downcase }.reject(&:empty?).uniq.each do |key|
|
|
990
|
+
value = models.dig(key, field)
|
|
991
|
+
return [key, value] unless value.nil?
|
|
992
|
+
end
|
|
993
|
+
nil
|
|
994
|
+
end
|
|
995
|
+
|
|
836
996
|
def resolve_model_alias(raw, env: ENV, path: global_path(env: env), hosts: nil)
|
|
837
997
|
value = raw.to_s.strip
|
|
838
998
|
return value if value.empty?
|
|
@@ -49,18 +49,24 @@ struct LiveSession: Decodable, Identifiable, Equatable {
|
|
|
49
49
|
/// Only UUID-shaped ids go into chi's argv, so nothing can turn into a flag.
|
|
50
50
|
var valid: Bool { UUID(uuidString: id) != nil }
|
|
51
51
|
|
|
52
|
-
var shortCwd: String {
|
|
53
|
-
|
|
52
|
+
var shortCwd: String { cwd.map(Self.shorten) ?? "" }
|
|
53
|
+
|
|
54
|
+
/// A path with the home folder as "~".
|
|
55
|
+
static func shorten(_ path: String) -> String {
|
|
54
56
|
let home = NSHomeDirectory()
|
|
55
|
-
return
|
|
57
|
+
return path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
guard let updatedAt else { return "" }
|
|
60
|
+
var updated: Date? {
|
|
61
|
+
guard let updatedAt else { return nil }
|
|
61
62
|
let precise = ISO8601DateFormatter()
|
|
62
63
|
precise.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
63
|
-
|
|
64
|
+
return precise.date(from: updatedAt) ?? ISO8601DateFormatter().date(from: updatedAt)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// "just now", "5m ago", "2h ago", "yesterday", "3d ago", or "".
|
|
68
|
+
func age(now: Date = Date()) -> String {
|
|
69
|
+
guard let date = updated else { return "" }
|
|
64
70
|
let seconds = max(0, now.timeIntervalSince(date))
|
|
65
71
|
switch seconds {
|
|
66
72
|
case ..<60: return "just now"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The Spotlight-like panel: a message line on top, the text (selection or
|
|
2
|
-
// clipboard) below it as quoted context, then
|
|
3
|
-
// message (`chi send
|
|
2
|
+
// clipboard) below it as quoted context, then a "New session" row and the
|
|
3
|
+
// sessions. ⏎ sends a message (`chi send`, or `chi send --new` on the new
|
|
4
|
+
// row), ⌘⏎ a note (`chi note`), ⇧⏎ is a newline.
|
|
4
5
|
import AppKit
|
|
5
6
|
import SwiftUI
|
|
6
7
|
|
|
@@ -16,6 +17,8 @@ final class PanelModel: ObservableObject {
|
|
|
16
17
|
@Published var source = ""
|
|
17
18
|
@Published var sessions: [LiveSession] = []
|
|
18
19
|
@Published var selected: Set<String> = []
|
|
20
|
+
/// The "New session" row; never together with sessions (`--new` takes no ids).
|
|
21
|
+
@Published var newSelected = false
|
|
19
22
|
@Published var phase: Phase = .loading
|
|
20
23
|
@Published var message = ""
|
|
21
24
|
|
|
@@ -28,21 +31,45 @@ final class PanelModel: ObservableObject {
|
|
|
28
31
|
var trimmedPrompt: String { prompt.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
29
32
|
var hasContext: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
30
33
|
var sendEnabled: Bool {
|
|
31
|
-
canSend && !selected.isEmpty && (!trimmedPrompt.isEmpty || hasContext)
|
|
34
|
+
canSend && (newSelected || !selected.isEmpty) && (!trimmedPrompt.isEmpty || hasContext)
|
|
32
35
|
}
|
|
33
36
|
var live: [LiveSession] { sessions.filter { !$0.recent } }
|
|
34
37
|
var recent: [LiveSession] { sessions.filter(\.recent) }
|
|
35
38
|
|
|
36
|
-
/// The last choice, where still live; else the only live session
|
|
37
|
-
/// recent (stopped) one is never
|
|
39
|
+
/// The last choice, where still live; else the only live session; else,
|
|
40
|
+
/// with none live, the new row. A recent (stopped) one is never
|
|
41
|
+
/// preselected: a message would wake it.
|
|
38
42
|
func preselect() {
|
|
39
43
|
let last = Set(UserDefaults.standard.stringArray(forKey: Self.lastChoiceKey) ?? [])
|
|
40
44
|
selected = last.intersection(Set(live.map(\.id)))
|
|
41
45
|
if selected.isEmpty, live.count == 1 { selected = [live[0].id] }
|
|
46
|
+
newSelected = live.isEmpty
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
func toggle(_ id: String) {
|
|
45
|
-
if selected.contains(id) { selected.remove(id) } else { selected.insert(id) }
|
|
50
|
+
if selected.contains(id) { selected.remove(id) } else { selected.insert(id); newSelected = false }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func toggleNew() {
|
|
54
|
+
newSelected.toggle()
|
|
55
|
+
if newSelected { selected = [] }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Where a new session starts: the helper runs in $HOME, which is in no
|
|
59
|
+
/// project, so the folder of the most recently updated live session, else
|
|
60
|
+
/// of the newest recent one, else home.
|
|
61
|
+
var newSessionDir: String {
|
|
62
|
+
let exists = { (s: LiveSession) -> Bool in
|
|
63
|
+
var dir: ObjCBool = false
|
|
64
|
+
return s.cwd.map { FileManager.default.fileExists(atPath: $0, isDirectory: &dir) && dir.boolValue } ?? false
|
|
65
|
+
}
|
|
66
|
+
let newest = { (list: [LiveSession]) in list.filter(exists).max { ($0.updated ?? .distantPast) < ($1.updated ?? .distantPast) } }
|
|
67
|
+
return (newest(live) ?? newest(recent))?.cwd ?? NSHomeDirectory()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
var newSessionFolder: String {
|
|
71
|
+
let dir = newSessionDir
|
|
72
|
+
return dir == NSHomeDirectory() ? "~" : (dir as NSString).lastPathComponent
|
|
46
73
|
}
|
|
47
74
|
|
|
48
75
|
/// In list order, only ids that are live and UUID-shaped.
|
|
@@ -103,10 +130,7 @@ struct PanelView: View {
|
|
|
103
130
|
Text("Looking for live sessions…").foregroundColor(.secondary)
|
|
104
131
|
}.padding(10)
|
|
105
132
|
default:
|
|
106
|
-
|
|
107
|
-
Text(model.phase == .failed && !model.message.isEmpty ? "" : "No live or recent sessions. Start one with `chi` in a terminal.")
|
|
108
|
-
.foregroundColor(.secondary).padding(10)
|
|
109
|
-
}
|
|
133
|
+
newRow
|
|
110
134
|
ForEach(Array(model.sessions.prefix(9).enumerated()), id: \.element.id) { index, session in
|
|
111
135
|
if session.recent && index == model.live.count {
|
|
112
136
|
HStack(spacing: 6) {
|
|
@@ -123,6 +147,28 @@ struct PanelView: View {
|
|
|
123
147
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
124
148
|
}
|
|
125
149
|
|
|
150
|
+
var newRow: some View {
|
|
151
|
+
let on = model.newSelected
|
|
152
|
+
return Button(action: { model.toggleNew() }) {
|
|
153
|
+
HStack(spacing: 10) {
|
|
154
|
+
Image(systemName: on ? "plus.circle.fill" : "plus.circle")
|
|
155
|
+
.font(.system(size: 16))
|
|
156
|
+
.foregroundColor(on ? .accentColor : .secondary)
|
|
157
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
158
|
+
Text("New session in \(model.newSessionFolder)").font(.system(size: 13, weight: .medium)).lineLimit(1)
|
|
159
|
+
Text(LiveSession.shorten(model.newSessionDir)).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
|
|
160
|
+
}
|
|
161
|
+
Spacer()
|
|
162
|
+
Text("⌘0").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
|
|
163
|
+
}
|
|
164
|
+
.padding(.horizontal, 8).padding(.vertical, 6)
|
|
165
|
+
.background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
|
|
166
|
+
.contentShape(Rectangle())
|
|
167
|
+
}
|
|
168
|
+
.buttonStyle(.plain)
|
|
169
|
+
.help("start a session with the message, as the web does (chi send --new); a note needs a session")
|
|
170
|
+
}
|
|
171
|
+
|
|
126
172
|
func row(_ session: LiveSession, index: Int) -> some View {
|
|
127
173
|
let on = model.selected.contains(session.id)
|
|
128
174
|
return Button(action: { model.toggle(session.id) }) {
|
|
@@ -274,12 +320,12 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
274
320
|
return true
|
|
275
321
|
}
|
|
276
322
|
|
|
277
|
-
/// ⌘1…⌘9 toggle a session.
|
|
323
|
+
/// ⌘1…⌘9 toggle a session, ⌘0 the new row.
|
|
278
324
|
private func handleKey(_ event: NSEvent) -> Bool {
|
|
279
325
|
guard event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
|
|
280
|
-
let chars = event.charactersIgnoringModifiers, let n = Int(chars),
|
|
326
|
+
let chars = event.charactersIgnoringModifiers, let n = Int(chars),
|
|
281
327
|
n <= model.sessions.count else { return false }
|
|
282
|
-
model.toggle(model.sessions[n - 1].id)
|
|
328
|
+
if n == 0 { model.toggleNew() } else { model.toggle(model.sessions[n - 1].id) }
|
|
283
329
|
return true
|
|
284
330
|
}
|
|
285
331
|
|
|
@@ -290,6 +336,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
290
336
|
model.text = text
|
|
291
337
|
model.source = source
|
|
292
338
|
model.message = ""
|
|
339
|
+
model.newSelected = false
|
|
293
340
|
model.phase = .loading
|
|
294
341
|
placeOnActiveScreen()
|
|
295
342
|
panel.makeKeyAndOrderFront(nil)
|
|
@@ -323,10 +370,14 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
323
370
|
|
|
324
371
|
func send(_ kind: SendKind) {
|
|
325
372
|
let ids = model.selectedIds
|
|
326
|
-
|
|
373
|
+
let new = model.newSelected
|
|
374
|
+
// A note into a session that doesn't exist yet makes no sense.
|
|
375
|
+
guard model.sendEnabled, new ? kind == .message : !ids.isEmpty else { NSSound.beep(); return }
|
|
327
376
|
model.phase = .sending
|
|
328
377
|
model.message = ""
|
|
329
|
-
|
|
378
|
+
if !new {
|
|
379
|
+
UserDefaults.standard.set(ids.filter { id in model.live.contains { $0.id == id } }, forKey: PanelModel.lastChoiceKey)
|
|
380
|
+
}
|
|
330
381
|
let prompt = model.trimmedPrompt
|
|
331
382
|
let command: String
|
|
332
383
|
var args: [String]
|
|
@@ -335,7 +386,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
335
386
|
case .message:
|
|
336
387
|
// stdin is the quoted context; without -m, chi takes it as the message.
|
|
337
388
|
command = "chi send"
|
|
338
|
-
args = ["send"]
|
|
389
|
+
args = new ? ["send", "--new", "--dir", model.newSessionDir] : ["send"]
|
|
339
390
|
if !prompt.isEmpty { args += ["-m", prompt] }
|
|
340
391
|
stdin = model.hasContext ? Data(model.text.utf8) : nil
|
|
341
392
|
case .note:
|
|
@@ -346,7 +397,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
346
397
|
let parts = [prompt, model.hasContext ? model.text : ""].filter { !$0.isEmpty }
|
|
347
398
|
stdin = Data(parts.joined(separator: "\n\n").utf8)
|
|
348
399
|
}
|
|
349
|
-
args += ids
|
|
400
|
+
if !new { args += ids }
|
|
350
401
|
runner.run(args, stdin: stdin) { [weak self] result in
|
|
351
402
|
guard let self else { return }
|
|
352
403
|
switch result {
|
|
@@ -362,9 +413,10 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
362
413
|
self.model.message = "\(command) took over \(Int(self.runner.timeout)) s and was stopped; \(what). \(out)"
|
|
363
414
|
} else if r.status == 0 {
|
|
364
415
|
self.model.phase = .sent
|
|
365
|
-
|
|
416
|
+
// --new prints "<full id> started".
|
|
417
|
+
self.model.message = out.isEmpty ? "Sent." : new ? "started \(out.prefix(8))…" : out
|
|
366
418
|
// Long enough to read a "waits for the session's next start" line.
|
|
367
|
-
let linger = out.contains("waits for") || out.contains("started
|
|
419
|
+
let linger = out.contains("waits for") || out.contains("started") ? 3.0 : 1.0
|
|
368
420
|
DispatchQueue.main.asyncAfter(deadline: .now() + linger) { [weak self] in
|
|
369
421
|
if self?.model.phase == .sent { self?.panel.close() }
|
|
370
422
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "turn_note"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# Asking the model again, in the same turn, when a generation ends with no
|
|
8
|
+
# visible text and no tool calls (thinking only, or nothing): a hidden
|
|
9
|
+
# nudge (TurnNote.empty_retry), then another generation. Both loops use
|
|
10
|
+
# it; the Engine's TurnNote.empty is what's left when the retries run out.
|
|
11
|
+
module EmptyAnswerRetry
|
|
12
|
+
MAX = 3
|
|
13
|
+
# Greedy decoding on a near-identical prompt would likely loop again:
|
|
14
|
+
# the retry runs at this temperature unless one is configured.
|
|
15
|
+
TEMPERATURE = 0.6
|
|
16
|
+
# A generation cut at the provider's output cap (finish_reason length)
|
|
17
|
+
# is retried unless the context is this full.
|
|
18
|
+
CONTEXT_FULL_RATIO = 0.9
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# retry.empty_answer, 0..MAX.
|
|
23
|
+
def limit
|
|
24
|
+
Config.get("retry.empty_answer").to_i.clamp(0, MAX)
|
|
25
|
+
rescue StandardError
|
|
26
|
+
1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The request options of a retry generation: the turn's sampling, with
|
|
30
|
+
# TEMPERATURE when it sets none (a configured nil keeps "don't send").
|
|
31
|
+
def sampling(base)
|
|
32
|
+
base = base || {}
|
|
33
|
+
base.key?(:temperature) ? base : base.merge(temperature: TEMPERATURE)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The prompt filled the window (a length stop is then no loop to break).
|
|
37
|
+
def context_full?(used_tokens, window_tokens)
|
|
38
|
+
return false unless used_tokens.to_i.positive? && window_tokens.to_i.positive?
|
|
39
|
+
|
|
40
|
+
used_tokens.to_f / window_tokens >= CONTEXT_FULL_RATIO
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|