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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +40 -4
  4. data/bin/chi +86 -19
  5. data/docs/cli.md +108 -5
  6. data/docs/configuration.md +229 -45
  7. data/docs/desktop.md +6 -0
  8. data/docs/hooks.md +126 -5
  9. data/docs/plugins.md +68 -2
  10. data/docs/releasing.md +18 -8
  11. data/docs/sessions.md +30 -4
  12. data/lib/samagotchi/answer_display.rb +95 -0
  13. data/lib/samagotchi/archive_store.rb +90 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  16. data/lib/samagotchi/bootstrap_command.rb +347 -0
  17. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
  19. data/lib/samagotchi/bridge.rb +9 -0
  20. data/lib/samagotchi/bridge_client.rb +6 -2
  21. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  22. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  25. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  29. data/lib/samagotchi/client.rb +72 -13
  30. data/lib/samagotchi/config.rb +196 -36
  31. data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
  32. data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
  33. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  34. data/lib/samagotchi/engine.rb +233 -36
  35. data/lib/samagotchi/guardrails/approval.rb +9 -0
  36. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  37. data/lib/samagotchi/guardrails.rb +1 -0
  38. data/lib/samagotchi/hooks/registry.rb +24 -5
  39. data/lib/samagotchi/host_registry.rb +4 -3
  40. data/lib/samagotchi/idle_recap.rb +5 -1
  41. data/lib/samagotchi/kernel_loop.rb +47 -15
  42. data/lib/samagotchi/llm/chat_loop.rb +59 -20
  43. data/lib/samagotchi/llm/errors.rb +21 -3
  44. data/lib/samagotchi/llm/http.rb +42 -13
  45. data/lib/samagotchi/llm/openai_chat.rb +12 -4
  46. data/lib/samagotchi/log_subscriber.rb +18 -3
  47. data/lib/samagotchi/model_profile.rb +1 -1
  48. data/lib/samagotchi/plugin/context.rb +22 -1
  49. data/lib/samagotchi/plugin/sessions.rb +3 -1
  50. data/lib/samagotchi/reply_wait.rb +126 -0
  51. data/lib/samagotchi/sampling_settings.rb +58 -0
  52. data/lib/samagotchi/self_report.rb +1 -0
  53. data/lib/samagotchi/send_command.rb +153 -7
  54. data/lib/samagotchi/session.rb +52 -11
  55. data/lib/samagotchi/session_archive_command.rb +107 -0
  56. data/lib/samagotchi/session_commands.rb +11 -2
  57. data/lib/samagotchi/session_manager.rb +114 -9
  58. data/lib/samagotchi/session_metrics.rb +222 -106
  59. data/lib/samagotchi/steer.rb +72 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
  62. data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
  63. data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
  64. data/lib/samagotchi/terminal_ui.rb +77 -4
  65. data/lib/samagotchi/tool_activity.rb +3 -1
  66. data/lib/samagotchi/tools/builtins.rb +15 -4
  67. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  68. data/lib/samagotchi/tools/execute.rb +52 -14
  69. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  70. data/lib/samagotchi/tools/task_wait.rb +27 -3
  71. data/lib/samagotchi/turn_note.rb +60 -6
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +2 -6
  74. data/lib/samagotchi/web/app.rb +88 -4
  75. data/lib/samagotchi/web/public/activity.js +10 -1
  76. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  77. data/lib/samagotchi/web/public/annotations.js +13 -0
  78. data/lib/samagotchi/web/public/app.js +437 -88
  79. data/lib/samagotchi/web/public/card.js +5 -3
  80. data/lib/samagotchi/web/public/chat_view.js +10 -1
  81. data/lib/samagotchi/web/public/copy.js +20 -4
  82. data/lib/samagotchi/web/public/ctx.js +15 -0
  83. data/lib/samagotchi/web/public/data.js +21 -6
  84. data/lib/samagotchi/web/public/format.js +9 -0
  85. data/lib/samagotchi/web/public/index.html +38 -2
  86. data/lib/samagotchi/web/public/notify.js +175 -0
  87. data/lib/samagotchi/web/public/question_card.js +2 -1
  88. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  89. data/lib/samagotchi/web/public/timing.js +39 -14
  90. data/lib/samagotchi/web/public/turn_events.js +46 -0
  91. data/lib/samagotchi/web/public/turn_view.js +47 -7
  92. data/lib/samagotchi/web/server.rb +8 -4
  93. data/lib/samagotchi/web/session_hub.rb +2 -1
  94. data/lib/samagotchi/web/session_summary.rb +24 -1
  95. data/lib/samagotchi/worker.rb +11 -0
  96. metadata +20 -1
@@ -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
- str = raw.to_s
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, and legacy flat UPPER keys (e.g.,
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
- # Legacy flat top-level fallback (transition): e.g., file contains SAMAGOTCHI_DEFAULT_MODEL
295
- if entry && data.is_a?(Hash)
296
- if data.key?(entry.env_key)
297
- return data[entry.env_key]
298
- end
299
- Array(entry.aliases).each do |a|
300
- return data[a] if data.key?(a)
301
- return data[a.to_sym] if data.key?(a.to_sym)
302
- end
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
- # Validation for top-level sections
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
- errors = []
387
- # Legacy flat UPPER keys are handled separately — don't flag them here
388
- legacy_keys = BY_ENV.keys
389
- data.each_key do |k|
390
- next if %w[hosts hooks model_aliases models guardrails bundles].include?(k.to_s)
391
- next if legacy_keys.include?(k.to_s)
392
- # Sections are top-level keys that map to hashes (e.g., default, recap)
393
- # If key contains _ or -, suggest dotted form
394
- if k.to_s.include?("_")
395
- errors << "top-level key '#{k}' contains '_' — use nested form '#{k.to_s.tr('_', '.')}' (e.g., default.model)"
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
- # Check section name shape if its value is a Hash
398
- if data[k].is_a?(Hash) && k.to_s.match?(/[_-]/)
399
- errors << "section '#{k}' must match #{SECTION_RE.inspect} (no _ or -)"
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
- errors
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, "invalid_section", echo: "Warning: #{w}") }
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
- if k.to_s.match?(/\A[A-Z_]{2,}\z/) && k.to_s.start_with?("SAMAGOTCHI_")
499
- Log.warn(:config, "legacy_key", echo: "Warning: config key '#{k}' is legacy UPPER — use '#{k.to_s.downcase.sub(/^samagotchi_/, '').tr('_', '.')}' (e.g., default.model)", key: k.to_s)
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
- guard let cwd else { return "" }
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 cwd.hasPrefix(home) ? "~" + cwd.dropFirst(home.count) : cwd
57
+ return path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path
56
58
  }
57
59
 
58
- /// "just now", "5m ago", "2h ago", "yesterday", "3d ago", or "".
59
- func age(now: Date = Date()) -> String {
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
- guard let date = precise.date(from: updatedAt) ?? ISO8601DateFormatter().date(from: updatedAt) else { return "" }
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 the sessions. ⏎ sends a
3
- // message (`chi send`), ⌘⏎ a note (`chi note`), ⇧⏎ is a newline.
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. A
37
- /// recent (stopped) one is never preselected: a message would wake it.
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
- if model.sessions.isEmpty {
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), (1...9).contains(n),
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
- guard model.sendEnabled, !ids.isEmpty else { NSSound.beep(); return }
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
- UserDefaults.standard.set(ids.filter { id in model.live.contains { $0.id == id } }, forKey: PanelModel.lastChoiceKey)
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
- self.model.message = out.isEmpty ? "Sent." : out
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 its worker") ? 3.0 : 1.0
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