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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- 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
|