samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "log"
4
+
5
+ module Samagotchi
6
+ # How a turn's answer is shown, apart from what the model said: an
7
+ # after_turn hook (or a plugin's on(:after_turn)) calls event[:present]
8
+ # with a block that gets the current display text and returns the new
9
+ # one. The result is kept as `display` on the answer's model message in
10
+ # session.json; the web renders it instead of `content`. It is display
11
+ # only: nothing that talks to a model reads it (the payload builders take
12
+ # the fields they send, and the copies handed to hooks, plugins and the
13
+ # recap leave it out, see .strip).
14
+ #
15
+ # The target is the stored conversation's last message when it is the
16
+ # model's answer; a turn that ended any other way (cancelled, failed,
17
+ # empty: a turn note is last) has none and event[:present] does nothing.
18
+ class AnswerDisplay
19
+ KEY = :display
20
+ # A display text longer than this is refused (the display stays as it
21
+ # was): a hook must not bloat session.json or the page.
22
+ MAX_CHARS = 200_000
23
+
24
+ # A message as a model-facing reader may see it: without `display`.
25
+ # The same object when it has none.
26
+ def self.strip(message)
27
+ return message unless message.is_a?(Hash) && (message.key?(KEY) || message.key?(KEY.to_s))
28
+
29
+ message.reject { |key, _| key.to_s == KEY.to_s }
30
+ end
31
+
32
+ def self.strip_all(messages)
33
+ Array(messages).map { |message| strip(message) }
34
+ end
35
+
36
+ # @return [Hash, nil] the message event[:present] changes
37
+ attr_reader :target
38
+ # @return [String, nil] the display text so far
39
+ attr_reader :text
40
+
41
+ # @param messages [Array<Hash>] the conversation the turn stored
42
+ def initialize(messages)
43
+ last = Array(messages).last
44
+ @target = last if last.is_a?(Hash) && field(last, :role).to_s == "model"
45
+ @original = @target && (field(@target, KEY) || field(@target, :content)).to_s
46
+ @text = @original
47
+ end
48
+
49
+ # True when a hook set a display text other than what was shown before.
50
+ def changed?
51
+ !@target.nil? && @text != @original
52
+ end
53
+
54
+ # event[:present]: call it with a block; the block gets the current
55
+ # display text (the answer's content until a hook changed it) and
56
+ # returns the new one. A block that raises, returns something other than
57
+ # a String, or returns more than MAX_CHARS leaves the text unchanged
58
+ # (logged, named by event[:hook]).
59
+ # @param event [Hash] the after_turn event (for its :hook label)
60
+ # @return [Proc] returns the display text after the call, or nil when
61
+ # the turn has no answer to present
62
+ def presenter(event)
63
+ lambda do |&block|
64
+ next nil unless @target
65
+ next @text unless block
66
+
67
+ apply(block, event[:hook])
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def apply(block, hook)
74
+ value = begin
75
+ block.call(@text.dup)
76
+ rescue StandardError => e
77
+ return reject(hook, "raised #{e.class}: #{e.message}")
78
+ end
79
+ return reject(hook, "returned #{value.class}, not a String") unless value.is_a?(String)
80
+ return reject(hook, "returned #{value.length} characters (max #{MAX_CHARS})") if value.length > MAX_CHARS
81
+
82
+ @text = value.dup
83
+ end
84
+
85
+ def reject(hook, why)
86
+ Log.warn(:hooks, "present_rejected", echo: "[samagotchi:hooks] #{hook}: present #{why}; display unchanged",
87
+ hook: hook.to_s)
88
+ @text
89
+ end
90
+
91
+ def field(message, key)
92
+ message.key?(key) ? message[key] : message[key.to_s]
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+
7
+ require_relative "session"
8
+
9
+ module Samagotchi
10
+ # A session's archive marker, <session dir>/archived (JSON):
11
+ # {"archived_at": t} archived: hidden from every list, kept by the
12
+ # retention sweep, not counted in its max_count
13
+ # {"unarchived_at": t} archived once, then unarchived: the sweep ages
14
+ # the session from max(updated_at, unarchived_at)
15
+ # A separate file, not a session.json field: Session#save rewrites that
16
+ # file from memory (and bumps updated_at) at every turn.
17
+ module ArchiveStore
18
+ FILE = "archived"
19
+ # Input a human typed: a web tab, a chi TUI, `chi send`, and nil (a
20
+ # worker's initial prompt; a web client may send none). Delegates,
21
+ # plugins and reminders are not (an allowlist, so a new automatic
22
+ # origin stays out).
23
+ USER_CLIENT_PREFIXES = %w[web: tui:].freeze
24
+ USER_CLIENT_IDS = ["cli:send"].freeze
25
+
26
+ # @return [Hash, nil] the marker (string keys), nil when none or unreadable
27
+ def self.read(session_dir)
28
+ data = JSON.parse(File.read(File.join(session_dir, FILE)))
29
+ data.is_a?(Hash) ? data : nil
30
+ rescue StandardError
31
+ nil
32
+ end
33
+
34
+ def self.archived?(session_dir)
35
+ read(session_dir)&.key?("archived_at") == true
36
+ end
37
+
38
+ # @return [Time, nil] when the session was last unarchived
39
+ def self.unarchived_at(session_dir)
40
+ value = read(session_dir)&.fetch("unarchived_at", nil)
41
+ value && Time.iso8601(value.to_s)
42
+ rescue ArgumentError
43
+ nil
44
+ end
45
+
46
+ # @return [Boolean] whether the marker was written (false: the session
47
+ # file is gone, so its dir is never recreated)
48
+ def self.archive(session_id, state_dir:)
49
+ write(session_id, { "archived_at" => Time.now.iso8601(3) }, state_dir: state_dir)
50
+ end
51
+
52
+ # @return [Boolean] whether it was archived (and is not now)
53
+ def self.unarchive(session_id, state_dir:)
54
+ return false unless archived?(Session.session_dir(session_id, state_dir: state_dir))
55
+
56
+ write(session_id, { "unarchived_at" => Time.now.iso8601(3) }, state_dir: state_dir)
57
+ end
58
+
59
+ # Whether +client_id+ (a turn's origin) is a human's input.
60
+ def self.user_input?(client_id)
61
+ return true if client_id.nil?
62
+
63
+ id = client_id.to_s
64
+ USER_CLIENT_IDS.include?(id) || USER_CLIENT_PREFIXES.any? { |prefix| id.start_with?(prefix) }
65
+ end
66
+
67
+ # A human's input came into the session: it is back in the lists. Its
68
+ # children stay archived. Never raises.
69
+ # @return [Boolean] whether it was archived
70
+ def self.user_input(session_id, state_dir:)
71
+ return false if session_id.nil?
72
+
73
+ unarchive(session_id, state_dir: state_dir)
74
+ rescue StandardError
75
+ false
76
+ end
77
+
78
+ def self.write(session_id, record, state_dir:)
79
+ return false unless File.exist?(File.join(state_dir, "#{session_id}#{Session::FILE_EXT}"))
80
+
81
+ dir = Session.session_dir(session_id, state_dir: state_dir)
82
+ FileUtils.mkdir_p(dir)
83
+ path = File.join(dir, FILE)
84
+ File.write("#{path}.tmp", JSON.generate(record))
85
+ File.rename("#{path}.tmp", path)
86
+ true
87
+ end
88
+ private_class_method :write
89
+ end
90
+ end
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "fileutils"
6
+ require "ipaddr"
7
+ require_relative "../config"
8
+
9
+ module Samagotchi
10
+ module Bootstrap
11
+ # Writes the host `chi bootstrap` found into config.yml. With no file it
12
+ # writes a small commented one; an existing file is the edge case and
13
+ # gets a hosts: entry inserted as text (YAML.dump would drop the user's
14
+ # comments), after a backup, and is checked by parsing it again: every
15
+ # other key must be unchanged, or the backup goes back.
16
+ class ConfigWriter
17
+ Error = Class.new(StandardError)
18
+
19
+ # kind: :new (a fresh file), :appended, :exists (the host is already
20
+ # there as +existing+; nothing written), :snippet (a file chi can't
21
+ # edit safely: paste +text+ yourself), :failed (written, but the check
22
+ # failed and the backup is back), :dry_run (+text+ is what would be
23
+ # written or inserted, +where+ says where)
24
+ # model_hint: a line for the user to add when default: exists without model
25
+ Outcome = Data.define(:kind, :path, :backup, :text, :where, :name, :existing, :default_model, :model_hint) do
26
+ def self.of(kind, path:, backup: nil, text: nil, where: nil, name: nil, existing: nil, default_model: nil,
27
+ model_hint: nil)
28
+ new(kind: kind, path: path, backup: backup, text: text, where: where, name: name, existing: existing,
29
+ default_model: default_model, model_hint: model_hint)
30
+ end
31
+ end
32
+
33
+ HOSTS_LINE_RE = /\Ahosts:[ \t]*(#.*)?\z/
34
+ DEFAULT_INDENT = 2
35
+ SERVER_TRANSPORTS = ConfigFile::VALID_TRANSPORTS_FOR_CONFIG
36
+
37
+ # The host part of a name: localhost/127.x → local, another IP → lan,
38
+ # a domain → its second-level label (api.openai.com → openai).
39
+ def self.derived_name(host)
40
+ text = host.to_s.downcase.delete_prefix("[").delete_suffix("]")
41
+ address = begin
42
+ IPAddr.new(text)
43
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
44
+ nil
45
+ end
46
+ return "local" if text == "localhost" || address&.loopback?
47
+ return "lan" if address
48
+
49
+ labels = text.split(".").reject(&:empty?)
50
+ name = (labels.length >= 2 ? labels[-2] : labels.first).to_s.gsub(/[^a-z0-9._-]/, "-")
51
+ name.match?(ConfigFile::HOST_NAME_RE) ? name : "host"
52
+ end
53
+
54
+ attr_reader :path
55
+
56
+ # @param path [String] config.yml (a symlink is written through)
57
+ # @param env [Hash] the environment for the check (SAMAGOTCHI_HOSTS_JSON
58
+ # is dropped: it would replace the file's hosts)
59
+ # @param now [Proc] the time for the backup's name and the header
60
+ def initialize(path:, env: ENV, now: -> { Time.now })
61
+ @path = path
62
+ @env = env.to_h.except("SAMAGOTCHI_HOSTS_JSON")
63
+ @now = now
64
+ end
65
+
66
+ def exists? = File.file?(@path)
67
+
68
+ # The file's data: {} for an empty file, nil when there is none or it
69
+ # doesn't parse.
70
+ def data
71
+ return @data if defined?(@data)
72
+
73
+ @data = exists? ? (parse(File.read(@path)) || {}) : nil
74
+ rescue StandardError
75
+ @data = nil
76
+ end
77
+
78
+ # The hosts entries the file routes to today, name → fields: its
79
+ # hosts:, or with none the `default` chi makes from server.*.
80
+ def current_hosts
81
+ raw = data.is_a?(Hash) ? data["hosts"] : nil
82
+ return raw.to_h { |name, fields| [name.to_s.downcase, fields.is_a?(Hash) ? fields : {}] } if raw.is_a?(Hash)
83
+ return {} if data.is_a?(Hash) && data.key?("hosts")
84
+
85
+ { "default" => server_entry }
86
+ end
87
+
88
+ # The name of an existing entry that points at the same server, or nil.
89
+ def duplicate_of(fields)
90
+ current_hosts.find { |_, existing| same_server?(existing, fields) }&.first
91
+ end
92
+
93
+ # The entry's name: +requested+ as given (it must be free), else
94
+ # +base+, with -2, -3… when the name is taken or is the prefix of a
95
+ # model id (chi would read "openai/gpt-x" as host openai, model gpt-x).
96
+ def host_name(base, requested: nil, model_ids: [])
97
+ taken = current_hosts.keys
98
+ if requested
99
+ name = requested.to_s.strip
100
+ raise Error, "--name must match #{ConfigFile::HOST_NAME_RE.source}" unless name.match?(ConfigFile::HOST_NAME_RE)
101
+ raise Error, "config.yml already has a host named '#{name}'; pick another --name" if taken.include?(name.downcase)
102
+
103
+ return name
104
+ end
105
+
106
+ prefixes = [*model_ids, configured_default_model].compact.map { |id| id.to_s.split(%r{[:/]}, 2).first.downcase }
107
+ name = base
108
+ n = 1
109
+ name = "#{base}-#{n += 1}" while taken.include?(name) || prefixes.include?(name)
110
+ name
111
+ end
112
+
113
+ # Write the entry. @param fields [Hash] the hosts entry (string keys,
114
+ # in the order written) @param model [String] the model id
115
+ # @return [Outcome]
116
+ def write(name:, fields:, model:, dry_run: false)
117
+ default_model = "#{name}:#{model}"
118
+ return write_new(name, fields, default_model, dry_run) unless exists?
119
+
120
+ existing = duplicate_of(fields)
121
+ return Outcome.of(:exists, path: @path, name: name, existing: existing) if existing
122
+
123
+ append(name, fields, default_model, dry_run)
124
+ end
125
+
126
+ private
127
+
128
+ def write_new(name, fields, default_model, dry_run)
129
+ text = <<~YAML
130
+ # Written by chi bootstrap on #{@now.call.strftime("%Y-%m-%d")}; see docs/configuration.md
131
+ default:
132
+ model: #{scalar(default_model)}
133
+ hosts:
134
+ #{entry_lines(name, fields, DEFAULT_INDENT, DEFAULT_INDENT).join}# More settings: docs/configuration.md#all-settings
135
+ YAML
136
+ return Outcome.of(:dry_run, path: @path, text: text, where: "a new file", name: name, default_model: default_model) if dry_run
137
+
138
+ FileUtils.mkdir_p(File.dirname(@path))
139
+ atomic_write(@path, text)
140
+ Outcome.of(:new, path: @path, name: name, default_model: default_model)
141
+ end
142
+
143
+ def append(name, fields, default_model, dry_run)
144
+ original = File.binread(@path).force_encoding(Encoding::UTF_8)
145
+ return snippet(name, fields) unless data.is_a?(Hash) && !anchors?(original)
146
+
147
+ eol = original.include?("\r\n") ? "\r\n" : "\n"
148
+ lines = original.split(/(?<=\n)/)
149
+ lines[-1] = "#{lines[-1]}#{eol}" if lines.any? && !lines[-1].end_with?("\n")
150
+ hosts_at = lines.index { |line| line.chomp.match?(HOSTS_LINE_RE) }
151
+ return snippet(name, fields) if hosts_at.nil? && data.key?("hosts")
152
+
153
+ default_entry = hosts_at || !routed? ? nil : server_entry
154
+ add_model, model_hint = default_model_change(default_model)
155
+
156
+ if hosts_at
157
+ at, child, step = block_end(lines, hosts_at)
158
+ inserted = entry_lines(name, fields, child, step)
159
+ where = "in hosts:, after line #{at}"
160
+ lines.insert(at, *inserted.map { |line| line.sub(/\n\z/, eol) })
161
+ else
162
+ inserted = ["hosts:\n", *(default_entry ? entry_lines("default", default_entry, DEFAULT_INDENT, DEFAULT_INDENT) : []),
163
+ *entry_lines(name, fields, DEFAULT_INDENT, DEFAULT_INDENT)]
164
+ where = "a new hosts: section at the end"
165
+ lines.concat(inserted.map { |line| line.sub(/\n\z/, eol) })
166
+ end
167
+ if add_model
168
+ model_lines = ["default:\n", " model: #{scalar(default_model)}\n"]
169
+ inserted += model_lines
170
+ lines.concat(model_lines.map { |line| line.sub(/\n\z/, eol) })
171
+ end
172
+ text = lines.join
173
+ if dry_run
174
+ return Outcome.of(:dry_run, path: @path, text: inserted.join, where: where, name: name,
175
+ default_model: add_model ? default_model : nil, model_hint: model_hint)
176
+ end
177
+
178
+ real = File.realpath(@path)
179
+ backup = backup_path(real)
180
+ FileUtils.cp(real, backup, preserve: true)
181
+ atomic_write(real, text)
182
+ expected = expected_data(name, fields, default_entry, add_model ? default_model : nil)
183
+ unless written_ok?(real, expected, name)
184
+ atomic_write(real, File.binread(backup))
185
+ return Outcome.of(:failed, path: @path, backup: backup, text: snippet_text(name, fields), name: name)
186
+ end
187
+
188
+ Outcome.of(:appended, path: @path, backup: backup, name: name, default_model: add_model ? default_model : nil,
189
+ model_hint: model_hint)
190
+ end
191
+
192
+ # Where a new entry goes in the hosts: block starting at +hosts_at+:
193
+ # its end (the first entry is the default host, so never first),
194
+ # before trailing blank lines and comments shallower than an entry.
195
+ # @return [index, entry indent, field indent step]
196
+ def block_end(lines, hosts_at)
197
+ stop = hosts_at + 1
198
+ stop += 1 while stop < lines.length && inside_block?(lines[stop])
199
+ children = lines[(hosts_at + 1)...stop].reject { |line| blank_or_comment?(line) }
200
+ child = children.first ? indent(children.first) : DEFAULT_INDENT
201
+ field = children.find { |line| indent(line) > child }
202
+ step = field ? indent(field) - child : child
203
+ at = stop
204
+ at -= 1 while at > hosts_at + 1 && (lines[at - 1].strip.empty? || (comment?(lines[at - 1]) && indent(lines[at - 1]) < child))
205
+ [at, child, step]
206
+ end
207
+
208
+ def inside_block?(line)
209
+ return true if line.strip.empty?
210
+ return false if line.start_with?("---", "...")
211
+
212
+ line.start_with?(" ", "\t") || comment?(line)
213
+ end
214
+
215
+ def blank_or_comment?(line) = line.strip.empty? || comment?(line)
216
+ def comment?(line) = line.lstrip.start_with?("#")
217
+ def indent(line) = line[/\A */].length
218
+
219
+ def entry_lines(name, fields, child, step)
220
+ ["#{" " * child}#{name}:\n", *fields.map { |key, value| "#{" " * (child + step)}#{key}: #{scalar(value)}\n" }]
221
+ end
222
+
223
+ # Strings double-quoted (JSON's quoting is valid YAML): model ids hold
224
+ # "/" and ":".
225
+ def scalar(value) = value.is_a?(Integer) ? value.to_s : JSON.generate(value.to_s)
226
+
227
+ def snippet(name, fields)
228
+ Outcome.of(:snippet, path: @path, text: snippet_text(name, fields), name: name)
229
+ end
230
+
231
+ def snippet_text(name, fields) = "hosts:\n#{entry_lines(name, fields, DEFAULT_INDENT, DEFAULT_INDENT).join}"
232
+
233
+ # [add default.model?, a line to print instead]
234
+ def default_model_change(default_model)
235
+ return [false, nil] if configured_default_model
236
+ return [false, " model: #{scalar(default_model)} # under default:"] if data.key?("default")
237
+
238
+ [true, nil]
239
+ end
240
+
241
+ def configured_default_model
242
+ return nil unless data.is_a?(Hash)
243
+
244
+ section = data["default"]
245
+ value = section.is_a?(Hash) ? section["model"] : nil
246
+ value ||= data[ConfigFile::DEFAULT_MODEL_KEY]
247
+ value.to_s.strip.empty? ? nil : value.to_s.strip
248
+ end
249
+
250
+ # Whether bare model names go somewhere today: a default.model or a
251
+ # server: section. Then a new hosts: block keeps that route as `default`.
252
+ def routed?
253
+ configured_default_model || data.key?("server") || data.keys.any? { |key| key.to_s.start_with?("SAMAGOTCHI_SERVER_") }
254
+ end
255
+
256
+ # The `default` hosts entry chi derives from server.* when the file has
257
+ # no hosts:, written out so a new hosts: block doesn't reroute bare models.
258
+ def server_entry
259
+ file = data.is_a?(Hash) ? data : {}
260
+ host = Config.resolve("server.host", file_data: file, env: {}, cli_overrides: {}).to_s.strip
261
+ port = Config.resolve("server.port", file_data: file, env: {}, cli_overrides: {}).to_i
262
+ transport = Config.resolve("server.transport", file_data: file, env: {}, cli_overrides: {}).to_s.strip.downcase
263
+ entry = { "host" => host.empty? ? "localhost" : host, "port" => port.positive? ? port : 8080 }
264
+ entry["transport"] = transport if SERVER_TRANSPORTS.include?(transport) && transport != "llama_cpp"
265
+ entry
266
+ end
267
+
268
+ def same_server?(existing, fields)
269
+ a = location(existing)
270
+ b = location(fields)
271
+ a && b && a == b
272
+ end
273
+
274
+ # [scheme-less host, port, path] of an entry.
275
+ def location(fields)
276
+ url = fields["url"].to_s.strip
277
+ unless url.empty?
278
+ uri = URI.parse(url)
279
+ return [uri.host.to_s.downcase, uri.port, uri.path.to_s.chomp("/")]
280
+ end
281
+ host = fields["host"].to_s.strip.downcase
282
+ return nil if host.empty?
283
+
284
+ port = fields["port"].to_s.strip.empty? ? 8080 : fields["port"].to_i
285
+ [host, port, fields["api"].to_s == "openai" ? "/v1" : ""]
286
+ rescue URI::InvalidURIError
287
+ nil
288
+ end
289
+
290
+ def expected_data(name, fields, default_entry, default_model)
291
+ expected = Marshal.load(Marshal.dump(data))
292
+ hosts = expected["hosts"].is_a?(Hash) ? expected["hosts"] : {}
293
+ hosts["default"] = default_entry if default_entry
294
+ hosts[name] = fields
295
+ expected["hosts"] = hosts
296
+ expected["default"] = { "model" => default_model } if default_model
297
+ expected
298
+ end
299
+
300
+ def written_ok?(real, expected, name)
301
+ parse(File.read(real)) == expected &&
302
+ ConfigFile.hosts_config(env: @env, path: real).key?(name.downcase)
303
+ rescue StandardError
304
+ false
305
+ end
306
+
307
+ def parse(text) = YAML.safe_load(text, permitted_classes: [], aliases: false)
308
+
309
+ # Anchors and aliases: chi's reader refuses aliases, and an insert
310
+ # can't know what an anchor shares.
311
+ def anchors?(text)
312
+ stack = [Psych.parse_stream(text)]
313
+ until stack.empty?
314
+ node = stack.pop
315
+ return true if node.is_a?(Psych::Nodes::Alias) || (node.respond_to?(:anchor) && node.anchor)
316
+
317
+ stack.concat(Array(node.children)) if node.respond_to?(:children)
318
+ end
319
+ false
320
+ rescue Psych::Exception
321
+ true
322
+ end
323
+
324
+ def backup_path(real)
325
+ stamp = @now.call.utc.strftime("%Y%m%dT%H%M%SZ")
326
+ candidate = "#{real}.bak-#{stamp}"
327
+ n = 1
328
+ candidate = "#{real}.bak-#{stamp}-#{n += 1}" while File.exist?(candidate)
329
+ candidate
330
+ end
331
+
332
+ def atomic_write(target, text)
333
+ tmp = "#{target}.tmp-#{Process.pid}"
334
+ File.binwrite(tmp, text)
335
+ File.chmod(File.stat(target).mode & 0o7777, tmp) if File.exist?(target)
336
+ File.rename(tmp, target)
337
+ ensure
338
+ FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
339
+ end
340
+ end
341
+ end
342
+ end