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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- 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
|