samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,910 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+ require "fileutils"
6
+ require "uri"
7
+ require "set"
8
+ require_relative "log"
9
+
10
+ module Samagotchi
11
+ # Unified configuration registry implementing the implicit convention:
12
+ # ENV SAMAGOTCHI_ATTR (UPPER + prefix + _ = nesting)
13
+ # YAML attr: / nested: {param:} (lower snake, dot = nesting, Option A leaf keeps _)
14
+ # CLI --attr / --nested-param (kebab, _ → - for both section and leaf)
15
+ #
16
+ # Sections may not contain _ or - (lower alnum only). Leaves keep snake_case
17
+ # in YAML (base_url) and kebab in CLI (base-url) via registry derivation.
18
+ # Unknown underscore CLI (--recap_base_url) is rejected as unknown.
19
+ #
20
+ # Tiers (expose): :env, :config, :cli subsets. Only exposed layers are read.
21
+ # Maps (hosts, hooks, model_aliases) are excluded from the registry — they are
22
+ # handled by ConfigFile (same file, below), which reads the *same* parsed YAML
23
+ # through ConfigFile.read_yaml so the file is parsed once per change.
24
+ module Config
25
+ Entry = Struct.new(:key, :yaml_path, :type, :default, :expose, :enum_values, :aliases, :yaml_aliases, keyword_init: true) do
26
+ def env_key
27
+ "SAMAGOTCHI_" + yaml_path.map { |p| p.upcase }.join("_")
28
+ end
29
+
30
+ def cli_flag
31
+ "--" + yaml_path.join("-").tr("_", "-")
32
+ end
33
+
34
+ def section
35
+ yaml_path.first
36
+ end
37
+
38
+ def cli_exposed?
39
+ expose.include?(:cli)
40
+ end
41
+
42
+ def env_exposed?
43
+ expose.include?(:env)
44
+ end
45
+
46
+ def config_exposed?
47
+ expose.include?(:config)
48
+ end
49
+ end
50
+
51
+ # Section names: lower alnum only, no _ or -.
52
+ SECTION_RE = /\A[a-z0-9]+\z/.freeze
53
+ LEAF_RE = /\A[a-z0-9_]+\z/.freeze
54
+
55
+ ENTRIES = [
56
+ # universal – env+config+cli
57
+ Entry.new(key: "default.model", yaml_path: %w[default model], type: :string, default: nil, expose: %i[env config cli]),
58
+ # The prompt profile for every model in this process (ModelProfile::NAMES).
59
+ # No config key: per-model and per-host profiles live in models: and hosts:.
60
+ Entry.new(key: "model.profile", yaml_path: %w[model profile], type: :enum, default: nil, expose: %i[env cli], enum_values: %w[qwen36 gemma4]),
61
+ Entry.new(key: "server.transport", yaml_path: %w[server transport], type: :enum, default: "llama_cpp", expose: %i[env config cli], enum_values: %w[llama_cpp mlx omlx]),
62
+ Entry.new(key: "server.host", yaml_path: %w[server host], type: :string, default: "localhost", expose: %i[env config cli]),
63
+ Entry.new(key: "server.port", yaml_path: %w[server port], type: :integer, default: 8080, expose: %i[env config cli]),
64
+ Entry.new(key: "server.open_timeout", yaml_path: %w[server open_timeout], type: :integer, default: 10, expose: %i[env config cli]),
65
+ Entry.new(key: "server.read_timeout", yaml_path: %w[server read_timeout], type: :integer, default: 600, expose: %i[env config cli]),
66
+ # Seconds a streamed answer may take to show its first text, reasoning
67
+ # or tool call, for every host; 0 = off. Unset: 120 on remote hosts,
68
+ # off on local ones. hosts.<name>.first_token_timeout wins.
69
+ Entry.new(key: "server.first_token_timeout", yaml_path: %w[server first_token_timeout], type: :integer, default: nil, expose: %i[env config]),
70
+
71
+ Entry.new(key: "recap.enabled", yaml_path: %w[recap enabled], type: :bool, default: nil, expose: %i[env config]),
72
+ Entry.new(key: "recap.model", yaml_path: %w[recap model], type: :string, default: nil, expose: %i[env config cli]),
73
+ Entry.new(key: "recap.base_url", yaml_path: %w[recap base_url], type: :string, default: nil, expose: %i[env config cli]),
74
+ Entry.new(key: "recap.host_ref", yaml_path: %w[recap host_ref], type: :string, default: nil, expose: %i[env config cli], yaml_aliases: %w[host]),
75
+ Entry.new(key: "recap.inactivity", yaml_path: %w[recap inactivity], type: :float, default: nil, expose: %i[env config cli]),
76
+ Entry.new(key: "recap.timeout", yaml_path: %w[recap timeout], type: :float, default: nil, expose: %i[env config cli]),
77
+ Entry.new(key: "recap.min_user_turns", yaml_path: %w[recap min_user_turns], type: :integer, default: nil, expose: %i[env config cli]),
78
+ # "N-M" or "N" sentences (1-10); unset = 2-4. Parsed by RecapPrompt.sentences_range.
79
+ Entry.new(key: "recap.sentences", yaml_path: %w[recap sentences], type: :string, default: nil, expose: %i[env config cli]),
80
+
81
+ Entry.new(key: "session.retention_days", yaml_path: %w[session retention_days], type: :integer, default: 14, expose: %i[env config cli]),
82
+ Entry.new(key: "session.max_count", yaml_path: %w[session max_count], type: :integer, default: 500, expose: %i[env config cli]),
83
+ Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "running", expose: %i[env config cli]),
84
+ Entry.new(key: "session.sweep_interval_hours", yaml_path: %w[session sweep_interval_hours], type: :integer, default: 24, expose: %i[env config cli]),
85
+ Entry.new(key: "session.idle_exit_minutes", yaml_path: %w[session idle_exit_minutes], type: :float, default: 30.0, expose: %i[env config cli]),
86
+ # Plain `chi` runs like `chi --shared` (bin/chi, LaunchMode); false, or --no-shared per run, keeps the plain REPL. No CLI flag: that would duplicate --shared.
87
+ Entry.new(key: "session.shared", yaml_path: %w[session shared], type: :bool, default: true, expose: %i[env config]),
88
+ # false: a session nothing happened in is deleted when it is left (SessionManager.empty_session?).
89
+ Entry.new(key: "session.keep_empty", yaml_path: %w[session keep_empty], type: :bool, default: false, expose: %i[env config]),
90
+ # The most sessions one session may have delegated and still running (the delegate tool); a guard against a runaway model.
91
+ Entry.new(key: "session.max_children", yaml_path: %w[session max_children], type: :integer, default: 4, expose: %i[env config]),
92
+
93
+ # Images sent to a model (ImageStore): the long side they are downscaled
94
+ # to, the most bytes one may take (bigger → re-encoded as jpeg), and how
95
+ # many one request carries (older ones become placeholders).
96
+ Entry.new(key: "image.max_side", yaml_path: %w[image max_side], type: :integer, default: 1568, expose: %i[env config]),
97
+ Entry.new(key: "image.max_bytes", yaml_path: %w[image max_bytes], type: :integer, default: 3_750_000, expose: %i[env config]),
98
+ Entry.new(key: "image.max_per_request", yaml_path: %w[image max_per_request], type: :integer, default: 20, expose: %i[env config]),
99
+
100
+ Entry.new(key: "guardrails.enabled", yaml_path: %w[guardrails enabled], type: :bool, default: true, expose: %i[env config]),
101
+
102
+ Entry.new(key: "log.file", yaml_path: %w[log file], type: :string, default: nil, expose: %i[env config cli]),
103
+ Entry.new(key: "log.disable", yaml_path: %w[log disable], type: :bool, default: false, expose: %i[env config cli]),
104
+ # debug adds payload dumps (model responses, tool args/results) and fetch lines.
105
+ Entry.new(key: "log.level", yaml_path: %w[log level], type: :enum, default: "info", expose: %i[env config cli], enum_values: %w[debug info warn error]),
106
+
107
+ Entry.new(key: "status.line", yaml_path: %w[status line], type: :string, default: "on", expose: %i[env config cli]),
108
+ Entry.new(key: "status.width_mode", yaml_path: %w[status width_mode], type: :string, default: "terminal_cap", expose: %i[env config cli]),
109
+ Entry.new(key: "status.max_width", yaml_path: %w[status max_width], type: :integer, default: 160, expose: %i[env config cli]),
110
+ Entry.new(key: "status.fixed_width", yaml_path: %w[status fixed_width], type: :integer, default: 120, expose: %i[env config cli]),
111
+
112
+ Entry.new(key: "context.status", yaml_path: %w[context status], type: :bool, default: true, expose: %i[env config cli]),
113
+ Entry.new(key: "context.window_tokens", yaml_path: %w[context window_tokens], type: :integer, default: nil, expose: %i[env config cli]),
114
+ Entry.new(key: "context.chars_per_token", yaml_path: %w[context chars_per_token], type: :float, default: 4.0, expose: %i[env config cli]),
115
+ Entry.new(key: "context.status_thresholds", yaml_path: %w[context status_thresholds],type: :string, default: "20,40,60,80", expose: %i[env config cli]),
116
+ Entry.new(key: "context.status_cadence", yaml_path: %w[context status_cadence], type: :integer, default: 0, expose: %i[env config cli]),
117
+
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
+ Entry.new(key: "thinking.render_interval", yaml_path: %w[thinking render_interval], type: :float, default: 0.08, expose: %i[env config cli]),
121
+ Entry.new(key: "thinking.turn_preamble", yaml_path: %w[thinking turn_preamble], type: :bool, default: true, expose: %i[env config cli]),
122
+
123
+ Entry.new(key: "default.n_predict", yaml_path: %w[default n_predict], type: :integer, default: nil, expose: %i[env config cli]),
124
+ Entry.new(key: "max_tool_output_chars", yaml_path: %w[max_tool_output_chars], type: :integer, default: 10_000, expose: %i[env config cli]),
125
+
126
+ Entry.new(key: "retry.max", yaml_path: %w[retry max], type: :integer, default: 5, expose: %i[env config cli]),
127
+ Entry.new(key: "retry.base_delay", yaml_path: %w[retry base_delay], type: :float, default: 0.5, expose: %i[env config cli]),
128
+ Entry.new(key: "retry.max_delay", yaml_path: %w[retry max_delay], type: :float, default: 8.0, expose: %i[env config cli]),
129
+
130
+ 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
+ Entry.new(key: "read.preview_bytes", yaml_path: %w[read preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
132
+ Entry.new(key: "read.hard_max_bytes", yaml_path: %w[read hard_max_bytes], type: :integer, default: 2_097_152, expose: %i[env config cli]),
133
+ Entry.new(key: "read.telemetry_threshold_pct", yaml_path: %w[read telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
134
+
135
+ Entry.new(key: "execute.truncate_at_bytes", yaml_path: %w[execute truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
136
+ Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
137
+ Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
138
+
139
+ Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
140
+ Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
141
+ Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli]),
142
+ # 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
+ Entry.new(key: "web.turn_view", yaml_path: %w[web turn_view], type: :bool, default: true, expose: %i[env config cli]),
144
+
145
+ Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli]),
146
+ Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli]),
147
+
148
+ # env+config only
149
+ Entry.new(key: "default.input", yaml_path: %w[default input], type: :string, default: nil, expose: %i[env config]),
150
+ Entry.new(key: "history.file", yaml_path: %w[history file], type: :string, default: nil, expose: %i[env config]),
151
+ 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
+ ].freeze
154
+
155
+ # Fast lookup maps
156
+ BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
157
+ BY_ENV = ENTRIES.each_with_object({}) do |e, h|
158
+ h[e.env_key] = e
159
+ Array(e.aliases).each { |a| h[a] = e }
160
+ end.freeze
161
+ BY_CLI = ENTRIES.each_with_object({}) { |e, h| h[e.cli_flag] = e if e.cli_exposed? }.freeze
162
+
163
+ class << self
164
+ def find_by_key(key)
165
+ BY_KEY[key.to_s]
166
+ end
167
+
168
+ def find_by_env(env_key)
169
+ BY_ENV[env_key.to_s]
170
+ end
171
+
172
+ def find_by_cli(flag)
173
+ BY_CLI[flag.to_s]
174
+ end
175
+
176
+ def all_entries
177
+ ENTRIES
178
+ end
179
+
180
+ def cli_entries
181
+ ENTRIES.select(&:cli_exposed?)
182
+ end
183
+
184
+ # Coercion helpers
185
+ def coerce(entry, raw)
186
+ return nil if raw.nil?
187
+ # For string, preserve as-is (including trailing spaces like "Please ")
188
+ if entry.type == :string
189
+ str = raw.to_s
190
+ return nil if str.empty? && entry.type != :string
191
+ return str
192
+ end
193
+ str = raw.to_s.strip
194
+ return nil if str.empty? && entry.type != :string
195
+
196
+ case entry.type
197
+ when :string
198
+ str
199
+ when :integer
200
+ Integer(str, exception: false).tap do |v|
201
+ if v.nil?
202
+ Log.warn(:config, "invalid_value", echo: "Warning: invalid integer for #{entry.key} (#{entry.env_key}): #{raw.inspect} — using default", key: entry.key)
203
+ return entry.default
204
+ end
205
+ end
206
+ when :float
207
+ val = Float(str, exception: false)
208
+ if val.nil?
209
+ Log.warn(:config, "invalid_value", echo: "Warning: invalid float for #{entry.key}: #{raw.inspect}", key: entry.key)
210
+ return entry.default
211
+ end
212
+ val
213
+ when :bool
214
+ case str.downcase
215
+ when "1", "true", "yes", "on" then true
216
+ when "0", "false", "no", "off", "" then false
217
+ else
218
+ Log.warn(:config, "invalid_value", echo: "Warning: invalid bool for #{entry.key}: #{raw.inspect} — treating as false", key: entry.key)
219
+ false
220
+ end
221
+ when :enum
222
+ lowered = str.downcase
223
+ allowed = entry.enum_values.map(&:downcase)
224
+ unless allowed.include?(lowered)
225
+ Log.warn(:config, "invalid_value", echo: "Warning: invalid value for #{entry.key}: #{raw.inspect} (allowed: #{entry.enum_values.join(', ')}) — using default", key: entry.key)
226
+ return entry.default
227
+ end
228
+ # return canonical casing from enum_values
229
+ entry.enum_values.find { |v| v.downcase == lowered }
230
+ else
231
+ str
232
+ end
233
+ end
234
+
235
+ # Resolve value with precedence: cli > env > file > default
236
+ # file_data: parsed YAML hash (or nil)
237
+ # env: hash-like (ENV)
238
+ # cli_overrides: { "key" => raw_or_coerced }
239
+ def resolve(key, file_data: nil, env: ENV, cli_overrides: {})
240
+ resolve_with_origin(key, file_data: file_data, env: env, cli_overrides: cli_overrides).first
241
+ end
242
+
243
+ # Same as #resolve, plus the layer the value came from:
244
+ # :cli, :env, :file or :default.
245
+ def resolve_with_origin(key, file_data: nil, env: ENV, cli_overrides: {})
246
+ entry = find_by_key(key)
247
+ raise ArgumentError, "unknown config key: #{key}" unless entry
248
+
249
+ # CLI wins
250
+ if cli_overrides.key?(entry.key)
251
+ raw = cli_overrides[entry.key]
252
+ # cli_overrides may already be coerced; detect by type
253
+ return [raw, :cli] if already_coerced?(entry, raw)
254
+ return [coerce(entry, raw), :cli]
255
+ end
256
+ if cli_overrides.key?(entry.cli_flag)
257
+ return [coerce(entry, cli_overrides[entry.cli_flag]), :cli]
258
+ end
259
+
260
+ # ENV
261
+ if entry.env_exposed?
262
+ env_val = env[entry.env_key] if env.key?(entry.env_key)
263
+ unless env_val.nil? || env_val.to_s.strip.empty?
264
+ return [coerce(entry, env_val), :env]
265
+ end
266
+ end
267
+
268
+ # File
269
+ if entry.config_exposed? && file_data.is_a?(Hash)
270
+ file_val = lookup_yaml(file_data, entry.yaml_path)
271
+ unless file_val.nil?
272
+ return [coerce(entry, file_val), :file]
273
+ end
274
+ end
275
+
276
+ [entry.default, :default]
277
+ end
278
+
279
+ def already_coerced?(entry, val)
280
+ case entry.type
281
+ when :bool then val == true || val == false
282
+ when :integer then val.is_a?(Integer)
283
+ when :float then val.is_a?(Float) || val.is_a?(Integer)
284
+ when :enum then entry.enum_values.include?(val)
285
+ else false
286
+ end
287
+ end
288
+
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.
292
+ def lookup_yaml(data, yaml_path)
293
+ 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
303
+ end
304
+ cur = data
305
+ yaml_path.each_with_index do |seg, idx|
306
+ return nil unless cur.is_a?(Hash)
307
+ last = idx == yaml_path.size - 1
308
+ if last
309
+ candidates = [seg, seg.to_sym]
310
+ kebab = seg.tr("_", "-")
311
+ candidates.push(kebab, kebab.to_sym)
312
+ Array(entry && entry.yaml_aliases).each do |alias_leaf|
313
+ candidates.push(alias_leaf, alias_leaf.to_sym)
314
+ end
315
+ candidates.each do |candidate|
316
+ return cur[candidate] if cur.key?(candidate)
317
+ end
318
+ return nil
319
+ else
320
+ # section: strict lower alnum, but accept case-insensitive
321
+ nxt = cur[seg] || cur[seg.to_sym]
322
+ # also try kebab alias for section (should not exist per spec, but be lenient)
323
+ nxt ||= cur[seg.tr("_", "-")] || cur[seg.tr("_", "-").to_sym]
324
+ return nil if nxt.nil?
325
+ cur = nxt
326
+ end
327
+ end
328
+ nil
329
+ end
330
+
331
+ # Build a merged snapshot hash for all entries
332
+ def snapshot(file_data: nil, env: ENV, cli_overrides: {})
333
+ ENTRIES.each_with_object({}) do |entry, h|
334
+ h[entry.key] = resolve(entry.key, file_data: file_data, env: env, cli_overrides: cli_overrides)
335
+ end
336
+ end
337
+
338
+ # Convenience: load file_data from path + snapshot
339
+ def load_snapshot(path: nil, env: ENV, cli_overrides: {})
340
+ path ||= Samagotchi::ConfigFile.global_path(env: env) rescue nil
341
+ file_data = Samagotchi::ConfigFile.read_yaml(env: env, path: path) if path
342
+ snapshot(file_data: file_data, env: env, cli_overrides: cli_overrides)
343
+ end
344
+
345
+ # In-memory store for current process (populated after CLI parse)
346
+ def store
347
+ @store ||= load_snapshot
348
+ end
349
+
350
+ def reload!(path: nil, env: ENV, cli_overrides: {})
351
+ @cli_overrides = cli_overrides.dup
352
+ @store = load_snapshot(path: path, env: env, cli_overrides: cli_overrides)
353
+ ensure
354
+ # The log resolves its file and level from here.
355
+ Samagotchi::Log.invalidate! if defined?(Samagotchi::Log)
356
+ end
357
+
358
+ def cli_overrides
359
+ @cli_overrides ||= {}
360
+ end
361
+
362
+ def get(key)
363
+ get_with_origin(key).first
364
+ end
365
+
366
+ # [value, origin] for the live value #get returns; origin is one of
367
+ # :cli, :env, :file, :default.
368
+ def get_with_origin(key)
369
+ entry = find_by_key(key)
370
+ raise ArgumentError, "unknown config key: #{key}" unless entry
371
+ # Live resolve so ENV changes (as in specs) are reflected without explicit reload
372
+ # Use current ENV and file (via ConfigFile's cached reader), plus any CLI
373
+ # overrides captured via reload!
374
+ path = Samagotchi::ConfigFile.global_path rescue nil
375
+ file_data = Samagotchi::ConfigFile.read_yaml(path: path) if path
376
+ resolve_with_origin(entry.key, file_data: file_data, env: ENV, cli_overrides: cli_overrides)
377
+ end
378
+
379
+ def set_cli_overrides(overrides)
380
+ reload!(cli_overrides: overrides)
381
+ end
382
+
383
+ # Validation for top-level sections
384
+ def validate_yaml_sections(data)
385
+ 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)"
396
+ 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 -)"
400
+ end
401
+ end
402
+ errors
403
+ end
404
+ end
405
+ end
406
+
407
+ # Global config-file access: the single YAML reader (cached per
408
+ # path+mtime+size), plus the map-shaped helpers the registry excludes
409
+ # (hosts, model_aliases) and the recap section resolution. All of it
410
+ # shares Config's precedence (CLI > ENV > file > default) for scalars.
411
+ module ConfigFile
412
+ XDG_CONFIG_HOME_ENV = "XDG_CONFIG_HOME"
413
+ CONFIG_DIR = "samagotchi"
414
+ CONFIG_FILE = "config.yml"
415
+ DEFAULT_MODEL_KEY = "SAMAGOTCHI_DEFAULT_MODEL"
416
+ MODEL_ALIASES_KEY = "model_aliases"
417
+ MODELS_KEY = "models"
418
+
419
+ module_function
420
+
421
+ # Single YAML reader for the global config file. Memoised per
422
+ # (path, mtime, size) so repeated Config.get / map-helper calls within a
423
+ # process parse the file at most once per on-disk change. Returns the
424
+ # parsed Hash or nil (missing file / parse error / non-mapping top level).
425
+ def read_yaml(env: ENV, path: global_path(env: env))
426
+ return nil if path.nil?
427
+ cache = @yaml_cache ||= {}
428
+ unless File.file?(path)
429
+ cache.delete(path)
430
+ return nil
431
+ end
432
+ stat = File.stat(path)
433
+ key = [stat.mtime.to_r, stat.size]
434
+ hit = cache[path]
435
+ return hit[1] if hit && hit[0] == key
436
+
437
+ data = begin
438
+ YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
439
+ rescue StandardError
440
+ nil
441
+ end
442
+ data = nil unless data.is_a?(Hash)
443
+ cache[path] = [key, data]
444
+ data
445
+ end
446
+
447
+ # Print +message+ to stderr unless this process already did. The config
448
+ # is read again for every HostRegistry, worker env and model lookup, so a
449
+ # warning about the file would otherwise repeat several times per run.
450
+ def warn_once(message)
451
+ @warn_once_mutex ||= Mutex.new
452
+ first = @warn_once_mutex.synchronize { (@warned ||= Set.new).add?(message) }
453
+ Samagotchi::Log.warn(:config, "config_warning", echo: message) if first
454
+ end
455
+
456
+ # A `vision:` setting (hosts entry or models: entry): true, false, or nil
457
+ # when unset. Anything else warns once and counts as unset.
458
+ def vision_flag(value, where)
459
+ return nil if value.nil?
460
+ return value if value == true || value == false
461
+
462
+ text = value.to_s.strip.downcase
463
+ return true if text == "true"
464
+ return false if text == "false"
465
+
466
+ warn_once "Warning: #{where}: vision must be true or false; ignored"
467
+ nil
468
+ end
469
+
470
+ # Forget the warnings already printed (specs).
471
+ def reset_warnings!
472
+ @warned = nil
473
+ end
474
+
475
+ # Invalidate the parsed-YAML cache (called after writers modify the file).
476
+ def clear_yaml_cache!(path = nil)
477
+ cache = @yaml_cache
478
+ return if cache.nil?
479
+ if path
480
+ cache.delete(path)
481
+ else
482
+ cache.clear
483
+ end
484
+ end
485
+
486
+ # New unified loader: delegates to Samagotchi::Config registry.
487
+ # Breaking: YAML now expects lower snake dotted paths (default.model)
488
+ # instead of UPPER scalar keys. For compatibility, UPPER keys are warned
489
+ # and ignored — env wins over file as before, now via registry precedence
490
+ # (CLI > ENV > file > default). Returns true if file existed.
491
+ def load_global_env!(env: ENV, path: global_path(env: env))
492
+ existed = File.file?(path)
493
+ raw = read_yaml(env: env, path: path) || {}
494
+ 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
497
+ 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)
500
+ end
501
+ end
502
+ end
503
+ # For process-wide access, prime the Config store (so Config.get works)
504
+ Samagotchi::Config.reload!(path: path, env: env, cli_overrides: {})
505
+ # Keep ENV in sync for any code still reading ENV directly (transition).
506
+ # Only for keys that were actually present in file (not defaults) to
507
+ # avoid polluting ENV with defaults.
508
+ Samagotchi::Config.all_entries.each do |entry|
509
+ next unless entry.env_exposed?
510
+ # check if file actually contained this key (including legacy flat)
511
+ file_val = Samagotchi::Config.lookup_yaml(raw, entry.yaml_path)
512
+ file_val ||= raw[entry.env_key] if raw.key?(entry.env_key)
513
+ Array(entry.aliases).each { |a| file_val ||= raw[a] if raw.key?(a) }
514
+ next if file_val.nil?
515
+ val = Samagotchi::Config.store[entry.key]
516
+ next if val.nil?
517
+ env[entry.env_key] = val.to_s unless env.key?(entry.env_key)
518
+ end
519
+ existed
520
+ end
521
+
522
+ def config_dir(env: ENV)
523
+ config_home = env.fetch(XDG_CONFIG_HOME_ENV, "").to_s.strip
524
+ base_dir = config_home.empty? ? File.expand_path("~/.config") : config_home
525
+ File.join(base_dir, CONFIG_DIR)
526
+ end
527
+
528
+ def global_path(env: ENV)
529
+ File.join(config_dir(env: env), CONFIG_FILE)
530
+ end
531
+
532
+ HOSTS_KEY = "hosts"
533
+ VALID_TRANSPORTS_FOR_CONFIG = %w[llama_cpp mlx omlx].freeze
534
+ # How chi talks to a host: a raw-prompt api (also the Client transport) or
535
+ # openai, the chat loop against <host>/v1.
536
+ VALID_APIS_FOR_CONFIG = (VALID_TRANSPORTS_FOR_CONFIG + %w[openai]).freeze
537
+ HOST_NAME_RE = /\A[a-z0-9][a-z0-9._-]*\z/i
538
+ ENV_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
539
+
540
+ def hosts_config(env: ENV, path: global_path(env: env))
541
+ data = read_yaml(env: env, path: path)
542
+ raw_hosts = data[HOSTS_KEY] if data.is_a?(Hash)
543
+
544
+ # ENV override: SAMAGOTCHI_HOSTS_JSON (used to propagate to workers)
545
+ env_json = env["SAMAGOTCHI_HOSTS_JSON"].to_s.strip
546
+ unless env_json.empty?
547
+ begin
548
+ parsed_env = JSON.parse(env_json)
549
+ raw_hosts = parsed_env if parsed_env.is_a?(Hash)
550
+ rescue StandardError
551
+ nil
552
+ end
553
+ end
554
+
555
+ normalized = {}
556
+ if raw_hosts.is_a?(Hash)
557
+ raw_hosts.each do |raw_name, raw_cfg|
558
+ name = raw_name.to_s.strip
559
+ next if name.empty?
560
+ unless name.match?(HOST_NAME_RE)
561
+ warn_once "Warning: ignoring hosts entry '#{name}': must match /[a-z0-9][a-z0-9._-]*/i"
562
+ next
563
+ end
564
+ lowered = name.downcase
565
+ unless raw_cfg.is_a?(Hash)
566
+ warn_once "Warning: ignoring hosts entry '#{name}': expected mapping"
567
+ next
568
+ end
569
+ host = raw_cfg["host"] || raw_cfg[:host]
570
+ port = raw_cfg["port"] || raw_cfg[:port]
571
+ transport = raw_cfg["transport"] || raw_cfg[:transport]
572
+ api = raw_cfg["api"] || raw_cfg[:api]
573
+ enabled = raw_cfg.key?("enabled") ? raw_cfg["enabled"] : (raw_cfg.key?(:enabled) ? raw_cfg[:enabled] : true)
574
+ if enabled == false || enabled.to_s.strip.downcase == "false"
575
+ next
576
+ end
577
+ url = (raw_cfg["url"] || raw_cfg[:url]).to_s.strip
578
+ api_key_env = (raw_cfg["api_key_env"] || raw_cfg[:api_key_env]).to_s.strip
579
+ # Kept as written; ModelProfile.resolve warns about an unknown one.
580
+ profile = (raw_cfg["profile"] || raw_cfg[:profile]).to_s.strip.downcase
581
+ first_token_timeout = raw_cfg.key?("first_token_timeout") ? raw_cfg["first_token_timeout"] : raw_cfg[:first_token_timeout]
582
+ vision = ConfigFile.vision_flag(raw_cfg.key?("vision") ? raw_cfg["vision"] : raw_cfg[:vision], "hosts entry '#{name}'")
583
+ unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
584
+ warn_once "Warning: hosts entry '#{name}': first_token_timeout must be seconds (0 = off); using the default"
585
+ first_token_timeout = nil
586
+ end
587
+ unless api_key_env.empty? || api_key_env.match?(ENV_NAME_RE)
588
+ warn_once "Warning: ignoring hosts entry '#{name}': api_key_env must be an environment variable name"
589
+ next
590
+ end
591
+ scheme = "http"
592
+ unless url.empty?
593
+ unless host.to_s.strip.empty? && port.to_s.strip.empty?
594
+ warn_once "Warning: ignoring hosts entry '#{name}': give url or host/port, not both"
595
+ next
596
+ end
597
+ uri = begin
598
+ URI.parse(url)
599
+ rescue URI::InvalidURIError
600
+ nil
601
+ end
602
+ unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
603
+ warn_once "Warning: ignoring hosts entry '#{name}': url must be an http(s) URL"
604
+ next
605
+ end
606
+ host = uri.host
607
+ port = uri.port
608
+ scheme = uri.scheme
609
+ url = url.chomp("/")
610
+ end
611
+ host = host.to_s.strip
612
+ if host.empty?
613
+ warn_once "Warning: ignoring hosts entry '#{name}': host is required"
614
+ next
615
+ end
616
+ port_val = port.to_s.strip.empty? ? 8080 : port.to_i
617
+ if port_val <= 0 || port_val > 65535
618
+ warn_once "Warning: ignoring hosts entry '#{name}': invalid port"
619
+ next
620
+ end
621
+ transport_val = transport.to_s.strip.downcase
622
+ if transport_val.empty?
623
+ transport_val = nil
624
+ elsif !VALID_TRANSPORTS_FOR_CONFIG.include?(transport_val)
625
+ warn_once "Warning: ignoring hosts entry '#{name}': unknown transport '#{transport_val}'"
626
+ next
627
+ end
628
+ api_val = api.to_s.strip.downcase
629
+ if api_val.empty?
630
+ api_val = nil
631
+ elsif !VALID_APIS_FOR_CONFIG.include?(api_val)
632
+ warn_once "Warning: ignoring hosts entry '#{name}': unknown api '#{api_val}'"
633
+ next
634
+ elsif VALID_TRANSPORTS_FOR_CONFIG.include?(api_val)
635
+ # A raw-prompt api is the transport; a different transport contradicts it.
636
+ if transport_val && transport_val != api_val
637
+ warn_once "Warning: ignoring hosts entry '#{name}': api '#{api_val}' conflicts with transport '#{transport_val}'"
638
+ next
639
+ end
640
+ transport_val = api_val
641
+ end
642
+ normalized[lowered] = { name: lowered, host: host, port: port_val, transport: transport_val ? transport_val.to_sym : nil,
643
+ api: api_val&.to_sym, original_name: name, scheme: scheme,
644
+ url: url.empty? ? nil : url, api_key_env: api_key_env.empty? ? nil : api_key_env,
645
+ profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
646
+ vision: vision }
647
+ end
648
+ end
649
+
650
+ # If no hosts defined, synthesize "default" from SAMAGOTCHI_SERVER_HOST/PORT
651
+ if normalized.empty?
652
+ default_host = env.fetch("SAMAGOTCHI_SERVER_HOST", "localhost").to_s.strip
653
+ default_host = "localhost" if default_host.empty?
654
+ default_port = env.fetch("SAMAGOTCHI_SERVER_PORT", "8080").to_s.strip
655
+ default_port = default_port.empty? ? 8080 : default_port.to_i
656
+ default_port = 8080 if default_port <= 0 || default_port > 65535
657
+ transport_env = env.fetch("SAMAGOTCHI_SERVER_TRANSPORT", "").to_s.strip.downcase
658
+ transport_sym = VALID_TRANSPORTS_FOR_CONFIG.include?(transport_env) ? transport_env.to_sym : nil
659
+ normalized["default"] = { name: "default", host: default_host, port: default_port, transport: transport_sym, original_name: "default" }
660
+ end
661
+ normalized
662
+ rescue StandardError
663
+ {}
664
+ end
665
+
666
+ # Resolve the `recap:` section through the Config registry so scalar
667
+ # recap settings share the single precedence path (CLI > ENV > file >
668
+ # default). Returns false when explicitly disabled (file `recap: false`
669
+ # or `recap: {enabled: false}` / SAMAGOTCHI_RECAP_ENABLED=false), nil when
670
+ # nothing recap-related is configured, otherwise a Hash of the present
671
+ # values (nil entries for absent ones).
672
+ def recap_config(env: ENV, path: global_path(env: env))
673
+ data = read_yaml(env: env, path: path) || {}
674
+ section = data["recap"]
675
+ return false if section == false
676
+
677
+ opts = { file_data: data, env: env, cli_overrides: Samagotchi::Config.cli_overrides }
678
+ return false if Samagotchi::Config.resolve("recap.enabled", **opts) == false
679
+
680
+ base_url = nonempty_str(Samagotchi::Config.resolve("recap.base_url", **opts))
681
+ host_ref = nonempty_str(Samagotchi::Config.resolve("recap.host_ref", **opts))
682
+ model = nonempty_str(Samagotchi::Config.resolve("recap.model", **opts))
683
+ return nil if base_url.nil? && host_ref.nil? && model.nil?
684
+
685
+ {
686
+ host_ref: host_ref,
687
+ base_url: base_url,
688
+ model: model,
689
+ inactivity: Samagotchi::Config.resolve("recap.inactivity", **opts),
690
+ timeout: Samagotchi::Config.resolve("recap.timeout", **opts),
691
+ min_user_turns: Samagotchi::Config.resolve("recap.min_user_turns", **opts),
692
+ sentences: Samagotchi::Config.resolve("recap.sentences", **opts)
693
+ }
694
+ end
695
+
696
+ # Resolve the `memories:` section: the baseline list of memory entries
697
+ # preloaded into the system prompt. Same shape as CLI `--memory` values:
698
+ # bare names or `scope/name` refs. Accepts a YAML list of strings or a
699
+ # single comma-separated string (commas inside list items are split too).
700
+ # Returns [] when the section is absent, false, or malformed.
701
+ def preloaded_memories(env: ENV, path: global_path(env: env))
702
+ data = read_yaml(env: env, path: path)
703
+ raw = data["memories"] if data.is_a?(Hash)
704
+ return [] if raw.nil? || raw == false
705
+
706
+ items = raw.is_a?(Array) ? raw : [raw]
707
+ items.flat_map { |v| v.to_s.split(",") }.map(&:strip).reject(&:empty?)
708
+ rescue StandardError
709
+ []
710
+ end
711
+
712
+ # Parse a model string that may be qualified as "host_alias:model"
713
+ # Returns [host_alias_or_nil, bare_model]
714
+ def parse_host_qualified_model(raw, hosts: nil)
715
+ value = raw.to_s.strip
716
+ return [nil, value] if value.empty?
717
+ # Try split on first ':' or '/' where prefix matches a known host
718
+ hosts_map = hosts || {}
719
+ # Normalize keys downcase
720
+ lowered_keys = hosts_map.keys.map(&:downcase)
721
+ # Check ':' split
722
+ if value.include?(":")
723
+ prefix, rest = value.split(":", 2)
724
+ if lowered_keys.include?(prefix.strip.downcase) && !rest.strip.empty?
725
+ return [prefix.strip.downcase, rest.strip]
726
+ end
727
+ end
728
+ if value.include?("/")
729
+ prefix, rest = value.split("/", 2)
730
+ if lowered_keys.include?(prefix.strip.downcase) && !rest.strip.empty?
731
+ return [prefix.strip.downcase, rest.strip]
732
+ end
733
+ end
734
+ [nil, value]
735
+ end
736
+
737
+ def hosts_json_for_env(env: ENV, path: global_path(env: env))
738
+ hosts = hosts_config(env: env, path: path)
739
+ # Only serialize if non-default or explicitly configured hosts:
740
+ # include when hosts file exists with hosts: section or when workers need propagation
741
+ return nil if hosts.nil? || hosts.empty?
742
+ # Serialize to JSON with string keys
743
+ simple = hosts.transform_values do |v|
744
+ # A url entry travels as its url (host/port come from it); the API
745
+ # key stays in the environment, which workers inherit.
746
+ location = v[:url] ? { "url" => v[:url] } : { "host" => v[:host], "port" => v[:port] }
747
+ location.merge("transport" => v[:transport]&.to_s, "api" => v[:api]&.to_s, "api_key_env" => v[:api_key_env],
748
+ "profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
749
+ "vision" => v[:vision]).compact
750
+ end
751
+ JSON.generate(simple)
752
+ rescue StandardError
753
+ nil
754
+ end
755
+
756
+ def write_default_model!(model_name, env: ENV, path: global_path(env: env))
757
+ resolved = model_name.to_s.strip
758
+ raise ArgumentError, "model name is required" if resolved.empty?
759
+
760
+ if resolved.include?(":") || resolved.include?("/")
761
+ sep = resolved.include?(":") ? ":" : "/"
762
+ prefix = resolved.split(sep, 2).first.to_s.strip.downcase
763
+ begin
764
+ hosts = hosts_config(env: env, path: path)
765
+ if hosts && !hosts.empty? && !hosts.key?(prefix) && prefix.match?(HOST_NAME_RE)
766
+ end
767
+ rescue StandardError
768
+ nil
769
+ end
770
+ end
771
+
772
+ raw_data = read_yaml(env: env, path: path) || {}
773
+
774
+ # Migrate to new dotted nested form: default.model (Option A)
775
+ raw_data["default"] ||= {}
776
+ if raw_data["default"].is_a?(Hash)
777
+ raw_data["default"]["model"] = resolved
778
+ else
779
+ raw_data["default"] = { "model" => resolved }
780
+ end
781
+ # Remove legacy UPPER key if present
782
+ raw_data.delete(DEFAULT_MODEL_KEY)
783
+ FileUtils.mkdir_p(File.dirname(path))
784
+ tmp = "#{path}.tmp"
785
+ File.write(tmp, YAML.dump(raw_data))
786
+ File.rename(tmp, path)
787
+ clear_yaml_cache!(path)
788
+ env[DEFAULT_MODEL_KEY] = resolved
789
+ # Also sync new Config store if loaded
790
+ Samagotchi::Config.reload!(path: path, env: env, cli_overrides: {})
791
+ true
792
+ end
793
+
794
+ def model_aliases(env: ENV, path: global_path(env: env))
795
+ data = read_yaml(env: env, path: path)
796
+ return {} unless data.is_a?(Hash)
797
+
798
+ raw = data[MODEL_ALIASES_KEY]
799
+ return {} if raw.nil?
800
+ return {} unless raw.is_a?(Hash)
801
+
802
+ raw.each_with_object({}) do |(k, v), result|
803
+ key = k.to_s.strip
804
+ next if key.empty?
805
+ val = v.to_s.strip
806
+ next if val.empty?
807
+
808
+ result[key.downcase] = val
809
+ end
810
+ rescue StandardError
811
+ {}
812
+ end
813
+
814
+ # The top-level `models:` map: per-model settings keyed by model id or
815
+ # alias (downcased), e.g. `models: {my-alias: {profile: qwen36}}`. Values are
816
+ # kept as written (profile downcased; ModelProfile.resolve validates it);
817
+ # an entry that is not a map is skipped.
818
+ def model_settings(env: ENV, path: global_path(env: env))
819
+ data = read_yaml(env: env, path: path)
820
+ raw = data[MODELS_KEY] if data.is_a?(Hash)
821
+ return {} unless raw.is_a?(Hash)
822
+
823
+ raw.each_with_object({}) do |(k, v), result|
824
+ key = k.to_s.strip.downcase
825
+ next if key.empty? || !v.is_a?(Hash)
826
+
827
+ profile = (v["profile"] || v[:profile]).to_s.strip.downcase
828
+ vision = vision_flag(v.key?("vision") ? v["vision"] : v[:vision], "models: #{key}")
829
+ result[key] = { profile: profile.empty? ? nil : profile }
830
+ result[key][:vision] = vision unless vision.nil?
831
+ end
832
+ rescue StandardError
833
+ {}
834
+ end
835
+
836
+ def resolve_model_alias(raw, env: ENV, path: global_path(env: env), hosts: nil)
837
+ value = raw.to_s.strip
838
+ return value if value.empty?
839
+
840
+ aliases = model_aliases(env: env, path: path)
841
+ # Host-qualified handling: "host:alias" -> "host:resolved"
842
+ # This allows /model small-box:small where "small" is an alias.
843
+ if value.include?(":") || value.include?("/")
844
+ hosts_map = hosts || hosts_config(env: env, path: path)
845
+ host, bare = parse_host_qualified_model(value, hosts: hosts_map)
846
+ if host && !bare.to_s.strip.empty?
847
+ resolved_bare = aliases.fetch(bare.downcase, bare)
848
+ # Preserve the separator the user used (: or /)
849
+ sep = value.downcase.include?("#{host}:") ? ":" : (value.downcase.include?("#{host}/") ? "/" : ":")
850
+ return "#{host}#{sep}#{resolved_bare}"
851
+ end
852
+ end
853
+
854
+ aliases.fetch(value.downcase, value)
855
+ end
856
+
857
+ RESERVED_MODEL_ALIASES = %w[clear default none off].freeze
858
+
859
+ def write_model_alias!(alias_name, model_name, env: ENV, path: global_path(env: env))
860
+ alias_key = alias_name.to_s.strip
861
+ raise ArgumentError, "alias name is required" if alias_key.empty?
862
+ raise ArgumentError, "model name is required" if model_name.to_s.strip.empty?
863
+
864
+ lowered_key = alias_key.downcase
865
+ raise ArgumentError, "alias name '#{alias_key}' is reserved" if RESERVED_MODEL_ALIASES.include?(lowered_key)
866
+ raise ArgumentError, "alias name must not contain whitespace" if alias_key.match?(/\s/)
867
+ raise ArgumentError, "alias name must not start with '-'" if alias_key.start_with?("-")
868
+ raise ArgumentError, "alias name must not contain '/'" if alias_key.include?("/")
869
+ unless alias_key.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
870
+ raise ArgumentError, "alias name must match /[a-z0-9][a-z0-9._-]*/i (got '#{alias_key}')"
871
+ end
872
+
873
+ resolved_model = model_name.to_s.strip
874
+ raise ArgumentError, "alias must not point to itself" if lowered_key == resolved_model.downcase
875
+
876
+ raw_data = read_yaml(env: env, path: path) || {}
877
+
878
+ aliases_hash = raw_data[MODEL_ALIASES_KEY]
879
+ unless aliases_hash.is_a?(Hash)
880
+ aliases_hash = {}
881
+ raw_data[MODEL_ALIASES_KEY] = aliases_hash
882
+ end
883
+
884
+ # Normalize existing alias keys to downcase to avoid duplicates like Qwen/qwen
885
+ normalized = {}
886
+ aliases_hash.each do |k, v|
887
+ nk = k.to_s.strip.downcase
888
+ next if nk.empty?
889
+ normalized[nk] = v.to_s.strip unless v.to_s.strip.empty?
890
+ end
891
+ raw_data[MODEL_ALIASES_KEY] = normalized
892
+
893
+ previous = normalized[lowered_key]
894
+ normalized[lowered_key] = resolved_model
895
+
896
+ FileUtils.mkdir_p(File.dirname(path))
897
+ tmp = "#{path}.tmp"
898
+ File.write(tmp, YAML.dump(raw_data))
899
+ File.rename(tmp, path)
900
+ clear_yaml_cache!(path)
901
+ previous
902
+ end
903
+
904
+ def nonempty_str(value)
905
+ str = value.to_s.strip
906
+ str.empty? ? nil : str
907
+ end
908
+ private_class_method :nonempty_str
909
+ end
910
+ end