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,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "json"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "ipaddr"
|
|
8
|
+
require_relative "../version"
|
|
9
|
+
require_relative "../llm/openai_chat"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
module Bootstrap
|
|
13
|
+
# What `chi bootstrap TARGET` finds at a model server: llama.cpp's native
|
|
14
|
+
# API (/props), an OpenAI-compatible one (/v1/models), one that wants an
|
|
15
|
+
# API key, or nothing. One attempt per request, no retries: a refused
|
|
16
|
+
# port answers at once instead of after chi's usual backoff.
|
|
17
|
+
class Probe
|
|
18
|
+
# The ports `chi bootstrap` with no target tries on localhost.
|
|
19
|
+
LOCAL_PORTS = { 8080 => "llama.cpp", 11434 => "Ollama", 1234 => "LM Studio", 8000 => "vLLM" }.freeze
|
|
20
|
+
DEFAULT_PORT = 8080
|
|
21
|
+
# Connect and read seconds for /props and /v1/models: a remote https
|
|
22
|
+
# handshake needs more than Client#server_props' 1 s / 2 s.
|
|
23
|
+
TIMEOUT = 5
|
|
24
|
+
# Seconds a local-port scan waits per port.
|
|
25
|
+
SCAN_TIMEOUT = 1
|
|
26
|
+
TEST_TIMEOUT = 60
|
|
27
|
+
TEST_PROMPT = "Reply with the word ok."
|
|
28
|
+
|
|
29
|
+
# One place a server may be: root is scheme://host:port (llama.cpp's
|
|
30
|
+
# /props), base the OpenAI API base (<root>/v1, or a URL's own path).
|
|
31
|
+
# url is true when the user gave a URL with a path, which the config
|
|
32
|
+
# then keeps as written.
|
|
33
|
+
Candidate = Data.define(:root, :base, :url) do
|
|
34
|
+
def uri = URI(root)
|
|
35
|
+
def host = uri.host
|
|
36
|
+
def port = uri.port
|
|
37
|
+
def scheme = uri.scheme
|
|
38
|
+
def label = "#{host}:#{port}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# kind: :native, :openai, :needs_key, :unreachable or :unknown.
|
|
42
|
+
# props: the /props JSON (native); models: [LLM::ModelInfo];
|
|
43
|
+
# status: the HTTP status that decided (:unknown, :needs_key);
|
|
44
|
+
# reason: why it's unreachable.
|
|
45
|
+
Result = Data.define(:kind, :candidate, :props, :models, :status, :reason) do
|
|
46
|
+
def self.of(kind, candidate, props: nil, models: [], status: nil, reason: nil)
|
|
47
|
+
new(kind: kind, candidate: candidate, props: props, models: models, status: status, reason: reason)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def native? = kind == :native
|
|
51
|
+
def reached? = %i[native openai].include?(kind)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The target the user typed, as the candidates to try in order.
|
|
55
|
+
# host, host:port, an IP → http://host:port (port 8080 when none)
|
|
56
|
+
# a domain without a scheme → https first, then http
|
|
57
|
+
# a URL → as given; a path is the OpenAI base
|
|
58
|
+
# Raises ArgumentError for something that isn't a host or URL.
|
|
59
|
+
def self.candidates(target)
|
|
60
|
+
text = target.to_s.strip
|
|
61
|
+
raise ArgumentError, "no target" if text.empty?
|
|
62
|
+
|
|
63
|
+
if text.match?(%r{\A[a-z][a-z0-9+.-]*://}i)
|
|
64
|
+
uri = parse_uri(text)
|
|
65
|
+
raise ArgumentError, "not an http(s) URL: #{text}" unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
66
|
+
|
|
67
|
+
root = root_url(uri.scheme, uri.host, uri.port)
|
|
68
|
+
path = uri.path.to_s.chomp("/")
|
|
69
|
+
return [Candidate.new(root: root, base: path.empty? ? "#{root}/v1" : "#{root}#{path}", url: !path.empty?)]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
uri = parse_uri("http://#{text}")
|
|
73
|
+
raise ArgumentError, "not a host or URL: #{text}" unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
74
|
+
|
|
75
|
+
path = uri.path.to_s.chomp("/")
|
|
76
|
+
port = text.match?(%r{:\d+(/|\z)}) ? uri.port : nil
|
|
77
|
+
schemes = domain?(uri.host) ? { "https" => 443, "http" => 80 } : { "http" => DEFAULT_PORT }
|
|
78
|
+
schemes.map do |scheme, default_port|
|
|
79
|
+
root = root_url(scheme, uri.host, port || default_port)
|
|
80
|
+
Candidate.new(root: root, base: path.empty? ? "#{root}/v1" : "#{root}#{path}", url: !path.empty?)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# scheme://host:port, without the port when it is the scheme's own
|
|
85
|
+
# (https://openrouter.ai, not https://openrouter.ai:443): it is written
|
|
86
|
+
# into config.yml as typed.
|
|
87
|
+
def self.root_url(scheme, host, port)
|
|
88
|
+
default = { "http" => 80, "https" => 443 }[scheme]
|
|
89
|
+
port == default ? "#{scheme}://#{host}" : "#{scheme}://#{host}:#{port}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A name with a dot that isn't an IP or localhost: reached over https
|
|
93
|
+
# on 443 first, like a browser would.
|
|
94
|
+
def self.domain?(host)
|
|
95
|
+
return false if host.nil? || host.casecmp?("localhost") || ip?(host)
|
|
96
|
+
|
|
97
|
+
host.include?(".")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.ip?(host)
|
|
101
|
+
IPAddr.new(host.to_s.delete_prefix("[").delete_suffix("]"))
|
|
102
|
+
true
|
|
103
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.parse_uri(text)
|
|
108
|
+
URI.parse(text)
|
|
109
|
+
rescue URI::InvalidURIError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
private_class_method :parse_uri
|
|
113
|
+
|
|
114
|
+
# @param env [Hash] where the API key variable is read
|
|
115
|
+
# @param timeout [Numeric] connect and read seconds per request
|
|
116
|
+
def initialize(env: ENV, timeout: TIMEOUT)
|
|
117
|
+
@env = env
|
|
118
|
+
@timeout = timeout
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Probe the target's candidates in order; the next one is tried only
|
|
122
|
+
# when this one refused the connection or failed its TLS handshake
|
|
123
|
+
# (https → http), not on a timeout.
|
|
124
|
+
# @param key_env [String, nil] the API key's environment variable
|
|
125
|
+
# @return [Result]
|
|
126
|
+
def classify_target(candidates, key_env: nil)
|
|
127
|
+
result = nil
|
|
128
|
+
candidates.each do |candidate|
|
|
129
|
+
result = classify(candidate, key_env: key_env)
|
|
130
|
+
break unless result.kind == :unreachable && result.reason.to_s.match?(/refused|SSL|TLS|certificate/i)
|
|
131
|
+
end
|
|
132
|
+
result
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Result]
|
|
136
|
+
def classify(candidate, key_env: nil)
|
|
137
|
+
props = begin
|
|
138
|
+
status, body = get(URI("#{candidate.root}/props"), key_env)
|
|
139
|
+
status == 200 ? parse_object(body) : nil
|
|
140
|
+
rescue *network_errors => e
|
|
141
|
+
return Result.of(:unreachable, candidate, reason: reason(e))
|
|
142
|
+
end
|
|
143
|
+
return Result.of(:native, candidate, props: props, models: native_models(candidate, props, key_env)) if native_props?(props)
|
|
144
|
+
|
|
145
|
+
begin
|
|
146
|
+
Result.of(:openai, candidate, models: list_models(candidate.base, key_env))
|
|
147
|
+
rescue LLM::ProviderError => e
|
|
148
|
+
kind = [401, 403].include?(e.status) ? :needs_key : :unknown
|
|
149
|
+
Result.of(kind, candidate, status: e.status, reason: e.status ? nil : e.message)
|
|
150
|
+
rescue *network_errors => e
|
|
151
|
+
Result.of(:unreachable, candidate, reason: reason(e))
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The ports in LOCAL_PORTS that answer as a model server, probed in
|
|
156
|
+
# parallel. @return [Array<Result>] the reached ones, in LOCAL_PORTS order
|
|
157
|
+
def scan_local(ports: LOCAL_PORTS.keys)
|
|
158
|
+
scanner = self.class.new(env: @env, timeout: SCAN_TIMEOUT)
|
|
159
|
+
ports.map do |port|
|
|
160
|
+
Thread.new { scanner.classify(Candidate.new(root: "http://localhost:#{port}", base: "http://localhost:#{port}/v1", url: false)) }
|
|
161
|
+
end.map(&:value).select(&:reached?)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# /props for one model (a llama.cpp router loads it on demand), or nil
|
|
165
|
+
# when the server doesn't answer it.
|
|
166
|
+
def model_props(candidate, model, key_env: nil)
|
|
167
|
+
status, body = get(URI("#{candidate.root}/props?#{URI.encode_www_form(model: model)}"), key_env)
|
|
168
|
+
status == 200 ? parse_object(body) : nil
|
|
169
|
+
rescue StandardError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# One short chat request: does the server generate for +model+? Any 200
|
|
174
|
+
# with a choice counts (a thinking model may spend its 16 tokens
|
|
175
|
+
# reasoning). @return [Float] seconds taken
|
|
176
|
+
# @raise [RuntimeError] with what went wrong
|
|
177
|
+
def test_turn(candidate, model, key_env: nil, read_timeout: TEST_TIMEOUT)
|
|
178
|
+
uri = URI("#{candidate.base}/chat/completions")
|
|
179
|
+
request = Net::HTTP::Post.new(uri)
|
|
180
|
+
request["Content-Type"] = "application/json"
|
|
181
|
+
request.body = JSON.generate(model: model, messages: [{ role: "user", content: TEST_PROMPT }], max_tokens: 16,
|
|
182
|
+
stream: false)
|
|
183
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
184
|
+
response = send_request(uri, request, key_env, read_timeout: read_timeout)
|
|
185
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
186
|
+
body = parse_object(response.body)
|
|
187
|
+
unless response.code.to_i == 200 && body && body["choices"].is_a?(Array) && !body["choices"].empty?
|
|
188
|
+
raise "HTTP #{response.code}: #{error_text(body, response.body)}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
elapsed
|
|
192
|
+
rescue *network_errors => e
|
|
193
|
+
raise "#{reason(e)} (#{candidate.label})"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def native_props?(props)
|
|
199
|
+
props.is_a?(Hash) && (props.key?("chat_template") || props.key?("build_info"))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# llama.cpp serves /v1/models too; a server that doesn't gets its
|
|
203
|
+
# model_alias.
|
|
204
|
+
def native_models(candidate, props, key_env)
|
|
205
|
+
models = begin
|
|
206
|
+
list_models(candidate.base, key_env)
|
|
207
|
+
rescue StandardError
|
|
208
|
+
[]
|
|
209
|
+
end
|
|
210
|
+
return models unless models.empty?
|
|
211
|
+
|
|
212
|
+
name = props["model_alias"].to_s.strip
|
|
213
|
+
name.empty? ? [] : [LLM::ModelInfo.new(id: name, context_window: nil, supports_tools: nil, raw: {})]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def list_models(base, key_env)
|
|
217
|
+
LLM::OpenAIChat.new(base_url: base, host_name: URI(base).host, api_key_env: key_env, retries: false,
|
|
218
|
+
timeout: @timeout, env: @env).list_models
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def get(uri, key_env)
|
|
222
|
+
response = send_request(uri, Net::HTTP::Get.new(uri), key_env, read_timeout: @timeout)
|
|
223
|
+
[response.code.to_i, response.body]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def send_request(uri, request, key_env, read_timeout:)
|
|
227
|
+
request["User-Agent"] = Samagotchi::USER_AGENT
|
|
228
|
+
key = key_env && @env[key_env].to_s
|
|
229
|
+
request["Authorization"] = "Bearer #{key}" if key && !key.strip.empty?
|
|
230
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout,
|
|
231
|
+
read_timeout: read_timeout) { |http| http.request(request) }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def network_errors
|
|
235
|
+
[*LLM::HTTP::NETWORK_ERRORS, OpenSSL::SSL::SSLError, Errno::EADDRNOTAVAIL, Errno::ENETDOWN, Errno::EPIPE]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def reason(error)
|
|
239
|
+
case error
|
|
240
|
+
when Errno::ECONNREFUSED then "connection refused"
|
|
241
|
+
when Net::OpenTimeout then "no answer in #{@timeout} s"
|
|
242
|
+
when Timeout::Error, IO::TimeoutError then "timed out"
|
|
243
|
+
when SocketError then "unknown host"
|
|
244
|
+
when OpenSSL::SSL::SSLError then "TLS: #{error.message}"
|
|
245
|
+
else error.message
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def parse_object(text)
|
|
250
|
+
parsed = JSON.parse(text.to_s)
|
|
251
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
252
|
+
rescue JSON::ParserError
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def error_text(body, raw)
|
|
257
|
+
message = body.is_a?(Hash) && (body.dig("error", "message") || body["error"] || body["message"])
|
|
258
|
+
(message || raw.to_s)[0, 200].to_s.strip
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
require_relative "model_profile"
|
|
6
|
+
require_relative "bootstrap/probe"
|
|
7
|
+
require_relative "bootstrap/config_writer"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
# `chi bootstrap [TARGET]`: the first setup. It names a model server, works
|
|
11
|
+
# out what it is (llama.cpp's native API or an OpenAI-compatible one),
|
|
12
|
+
# picks the model, sends one test request and writes config.yml: a fresh
|
|
13
|
+
# file, or a hosts: entry added to an existing one. Prompts only on a
|
|
14
|
+
# terminal; a script gets the list and exit 2 instead.
|
|
15
|
+
class BootstrapCommand
|
|
16
|
+
USAGE = <<~TEXT
|
|
17
|
+
Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--no-test] [--dry-run]
|
|
18
|
+
Finds the model server at TARGET and writes config.yml for it.
|
|
19
|
+
chi bootstrap 192.168.1.29:8081 llama.cpp on the LAN
|
|
20
|
+
chi bootstrap localhost:11434 Ollama
|
|
21
|
+
chi bootstrap https://openrouter.ai/api/v1 --key-env OPENROUTER_API_KEY
|
|
22
|
+
chi bootstrap look on this machine's usual ports
|
|
23
|
+
TARGET host[:port] (port 8080 when none), or a URL; a URL's
|
|
24
|
+
path is the OpenAI API base
|
|
25
|
+
--name NAME the hosts entry's name (default: local, lan, or the
|
|
26
|
+
domain's name)
|
|
27
|
+
--model ID the model, when the server has several
|
|
28
|
+
--key-env VAR the environment variable holding the API key (the
|
|
29
|
+
key itself is never written)
|
|
30
|
+
--no-test skip the test request
|
|
31
|
+
--dry-run show what would be written; write nothing
|
|
32
|
+
With no config.yml it writes one; an existing one gets a hosts:
|
|
33
|
+
entry added (after a backup), and its other lines stay as they are.
|
|
34
|
+
TEXT
|
|
35
|
+
|
|
36
|
+
PICK_SHOWN = 20
|
|
37
|
+
# A test request slower than this says it is waiting (a model loading).
|
|
38
|
+
LOADING_AFTER = 3
|
|
39
|
+
|
|
40
|
+
# @param argv [Array<String>] the arguments after "bootstrap"
|
|
41
|
+
# @param probe [Bootstrap::Probe, nil] (specs)
|
|
42
|
+
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, env: ENV, probe: nil, config_path: nil,
|
|
43
|
+
platform: RbConfig::CONFIG["host_os"])
|
|
44
|
+
@argv = argv.dup
|
|
45
|
+
@stdin = stdin
|
|
46
|
+
@stdout = stdout
|
|
47
|
+
@stderr = stderr
|
|
48
|
+
@env = env
|
|
49
|
+
@probe = probe || Bootstrap::Probe.new(env: env)
|
|
50
|
+
@config_path = config_path || ConfigFile.global_path(env: env)
|
|
51
|
+
@platform = platform
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Integer] 0 written (or already there), 1 failed (a failed
|
|
55
|
+
# test still writes the config), 2 usage or a choice to make
|
|
56
|
+
def run
|
|
57
|
+
options = parse or return 2
|
|
58
|
+
return 0 if options[:help]
|
|
59
|
+
|
|
60
|
+
key_env = options[:key_env]
|
|
61
|
+
return fail!("#{key_env} is not set; export it first (the API key; chi only writes its name)") if key_env && unset?(key_env)
|
|
62
|
+
|
|
63
|
+
result = find(options, key_env) or return @exit
|
|
64
|
+
unlocked = with_key(result, key_env) or return @exit
|
|
65
|
+
result, key_env = unlocked
|
|
66
|
+
describe(result)
|
|
67
|
+
model = pick_model(result, options[:model]) or return @exit
|
|
68
|
+
show_model(result, model, key_env)
|
|
69
|
+
tested = options[:no_test] || test(result, model, key_env)
|
|
70
|
+
written = write(result, model, key_env, options) or return @exit
|
|
71
|
+
written.zero? && tested ? 0 : 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def parse
|
|
77
|
+
options = {}
|
|
78
|
+
until @argv.empty?
|
|
79
|
+
arg = @argv.shift
|
|
80
|
+
case arg
|
|
81
|
+
when "-h", "--help", "help"
|
|
82
|
+
@stdout.puts(USAGE)
|
|
83
|
+
return { help: true }
|
|
84
|
+
when "--name", "--model", "--key-env"
|
|
85
|
+
options[arg.delete_prefix("--").tr("-", "_").to_sym] = @argv.shift or return usage_error("#{arg} needs a value")
|
|
86
|
+
when /\A--(name|model|key-env)=(.+)\z/ then options[Regexp.last_match(1).tr("-", "_").to_sym] = Regexp.last_match(2)
|
|
87
|
+
when "--no-test" then options[:no_test] = true
|
|
88
|
+
when "--dry-run" then options[:dry_run] = true
|
|
89
|
+
when /\A-/ then return usage_error("unknown option #{arg}")
|
|
90
|
+
else
|
|
91
|
+
return usage_error("one TARGET only (got #{options[:target]} and #{arg})") if options[:target]
|
|
92
|
+
|
|
93
|
+
options[:target] = arg
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
if options[:key_env] && !options[:key_env].match?(ConfigFile::ENV_NAME_RE)
|
|
97
|
+
return usage_error("--key-env takes the variable's name (e.g. OPENROUTER_API_KEY), not the key")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
options
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The server's Probe::Result, or nil with @exit set.
|
|
104
|
+
def find(options, key_env)
|
|
105
|
+
return scan unless options[:target]
|
|
106
|
+
|
|
107
|
+
candidates = begin
|
|
108
|
+
Bootstrap::Probe.candidates(options[:target])
|
|
109
|
+
rescue ArgumentError => e
|
|
110
|
+
usage_error(e.message)
|
|
111
|
+
@exit = 2
|
|
112
|
+
return nil
|
|
113
|
+
end
|
|
114
|
+
result = @probe.classify_target(candidates, key_env: key_env)
|
|
115
|
+
case result.kind
|
|
116
|
+
when :unreachable
|
|
117
|
+
fail!("can't reach #{result.candidate.label} (#{result.reason})")
|
|
118
|
+
when :unknown
|
|
119
|
+
detail = result.status ? "HTTP #{result.status}" : result.reason
|
|
120
|
+
fail!("reached #{result.candidate.label} but it answers neither llama.cpp /props nor /v1/models (#{detail})")
|
|
121
|
+
else result
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def scan
|
|
126
|
+
found = @probe.scan_local
|
|
127
|
+
ports = Bootstrap::Probe::LOCAL_PORTS.keys.join(", ")
|
|
128
|
+
return fail!("no model server found on localhost (#{ports}); pass one: chi bootstrap HOST[:PORT]") if found.empty?
|
|
129
|
+
return found.first if found.size == 1
|
|
130
|
+
|
|
131
|
+
labels = found.map { |r| "#{r.candidate.label} (#{Bootstrap::Probe::LOCAL_PORTS[r.candidate.port]}, #{models_count(r)})" }
|
|
132
|
+
unless tty?
|
|
133
|
+
@stderr.puts("chi bootstrap: model servers on this machine:")
|
|
134
|
+
labels.each { |label| @stderr.puts(" #{label}") }
|
|
135
|
+
@stderr.puts("pick one: chi bootstrap localhost:PORT")
|
|
136
|
+
@exit = 2
|
|
137
|
+
return nil
|
|
138
|
+
end
|
|
139
|
+
picked = pick(labels, "server") or return fail!("nothing picked")
|
|
140
|
+
found[labels.index(picked)]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def models_count(result)
|
|
144
|
+
n = result.models.size
|
|
145
|
+
n == 1 ? "1 model" : "#{n} models"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# [result, key_env] once the server lets us in, or nil with @exit set.
|
|
149
|
+
def with_key(result, key_env)
|
|
150
|
+
return [result, key_env] unless result.kind == :needs_key
|
|
151
|
+
|
|
152
|
+
host = result.candidate.label
|
|
153
|
+
return fail!("#{host} refused the key in #{key_env} (HTTP #{result.status})") if key_env
|
|
154
|
+
|
|
155
|
+
unless tty?
|
|
156
|
+
@stderr.puts("chi bootstrap: #{host} wants an API key (HTTP #{result.status}); " \
|
|
157
|
+
"pass the variable that holds it: --key-env VAR")
|
|
158
|
+
@exit = 2
|
|
159
|
+
return nil
|
|
160
|
+
end
|
|
161
|
+
@stdout.print("#{host} wants an API key. API key environment variable (e.g. OPENROUTER_API_KEY): ")
|
|
162
|
+
key_env = @stdin.gets.to_s.strip
|
|
163
|
+
return fail!("no variable given") if key_env.empty?
|
|
164
|
+
return fail!("#{key_env} isn't a variable name") unless key_env.match?(ConfigFile::ENV_NAME_RE)
|
|
165
|
+
return fail!("#{key_env} is not set; export it and run chi bootstrap again") if unset?(key_env)
|
|
166
|
+
|
|
167
|
+
result = @probe.classify(result.candidate, key_env: key_env)
|
|
168
|
+
return fail!("#{host} refused the key in #{key_env} (HTTP #{result.status})") if result.kind == :needs_key
|
|
169
|
+
return fail!("can't reach #{host} (#{result.reason || "HTTP #{result.status}"})") unless result.reached?
|
|
170
|
+
|
|
171
|
+
[result, key_env]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def describe(result)
|
|
175
|
+
if result.native?
|
|
176
|
+
build = result.props["build_info"].to_s
|
|
177
|
+
@stdout.puts("found: llama.cpp at #{result.candidate.root}#{build.empty? ? "" : " (build #{build})"}")
|
|
178
|
+
else
|
|
179
|
+
@stdout.puts("found: an OpenAI-compatible API at #{result.candidate.base}")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def pick_model(result, wanted)
|
|
184
|
+
ids = result.models.map(&:id).reject(&:empty?)
|
|
185
|
+
if wanted
|
|
186
|
+
return wanted if ids.empty?
|
|
187
|
+
|
|
188
|
+
match = ids.find { |id| id == wanted } || ids.find { |id| id.casecmp?(wanted) }
|
|
189
|
+
return match if match
|
|
190
|
+
|
|
191
|
+
@stderr.puts("chi bootstrap: #{result.candidate.label} has no model #{wanted}; it lists:")
|
|
192
|
+
ids.each { |id| @stderr.puts(" #{id}") }
|
|
193
|
+
@exit = 1
|
|
194
|
+
return nil
|
|
195
|
+
end
|
|
196
|
+
return fail!("#{result.candidate.label} lists no models; pass one: --model ID") if ids.empty?
|
|
197
|
+
return ids.first if ids.size == 1
|
|
198
|
+
return pick(ids, "model") || fail!("nothing picked") if tty?
|
|
199
|
+
|
|
200
|
+
@stderr.puts("chi bootstrap: #{result.candidate.label} has #{ids.size} models:")
|
|
201
|
+
ids.each { |id| @stderr.puts(" #{id}") }
|
|
202
|
+
@stderr.puts("pick one: --model ID")
|
|
203
|
+
@exit = 2
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def show_model(result, model, key_env)
|
|
208
|
+
@stdout.puts("model: #{model}")
|
|
209
|
+
return unless result.native?
|
|
210
|
+
|
|
211
|
+
props = @probe.model_props(result.candidate, model, key_env: key_env) || result.props
|
|
212
|
+
n_ctx = props.dig("default_generation_settings", "n_ctx")
|
|
213
|
+
@stdout.puts("context: #{n_ctx} tokens") if n_ctx.is_a?(Integer) && n_ctx.positive?
|
|
214
|
+
profile, evidence = ModelProfile.fingerprint(props)
|
|
215
|
+
@stdout.puts("profile: #{profile} (chat template: #{evidence})") if profile
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def test(result, model, key_env)
|
|
219
|
+
done = Queue.new
|
|
220
|
+
waiting = Thread.new do
|
|
221
|
+
@stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
|
|
222
|
+
end
|
|
223
|
+
seconds = @probe.test_turn(result.candidate, model, key_env: key_env)
|
|
224
|
+
@stdout.puts(format("test: answered in %.1f s", seconds))
|
|
225
|
+
true
|
|
226
|
+
rescue StandardError => e
|
|
227
|
+
@stdout.puts("test: failed: #{e.message}")
|
|
228
|
+
false
|
|
229
|
+
ensure
|
|
230
|
+
done&.push(true)
|
|
231
|
+
waiting&.join
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# 0 written, 1 written but not as planned, nil with @exit on a refusal.
|
|
235
|
+
def write(result, model, key_env, options)
|
|
236
|
+
writer = Bootstrap::ConfigWriter.new(path: @config_path, env: @env)
|
|
237
|
+
fields = host_fields(result.candidate, result.native?, key_env)
|
|
238
|
+
ids = result.models.map(&:id)
|
|
239
|
+
name = begin
|
|
240
|
+
writer.host_name(Bootstrap::ConfigWriter.derived_name(result.candidate.host), requested: options[:name], model_ids: ids)
|
|
241
|
+
rescue Bootstrap::ConfigWriter::Error => e
|
|
242
|
+
usage_error(e.message)
|
|
243
|
+
@exit = 2
|
|
244
|
+
return nil
|
|
245
|
+
end
|
|
246
|
+
outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
|
|
247
|
+
report(outcome, model)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The hosts entry: host/port for a plain http server (api: openai when it
|
|
251
|
+
# isn't llama.cpp), url for https or a URL with its own path.
|
|
252
|
+
def host_fields(candidate, native, key_env)
|
|
253
|
+
fields = if candidate.url || candidate.scheme == "https"
|
|
254
|
+
{ "url" => native ? candidate.root : candidate.base }
|
|
255
|
+
else
|
|
256
|
+
{ "host" => candidate.host, "port" => candidate.port }
|
|
257
|
+
end
|
|
258
|
+
fields["api"] = "openai" unless native
|
|
259
|
+
fields["api_key_env"] = key_env if key_env
|
|
260
|
+
fields
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def report(outcome, model)
|
|
264
|
+
path = outcome.path
|
|
265
|
+
case outcome.kind
|
|
266
|
+
when :dry_run
|
|
267
|
+
@stdout.puts("dry run: would write #{outcome.where} (#{path}):")
|
|
268
|
+
@stdout.puts(outcome.text.gsub(/^/, " "))
|
|
269
|
+
@stdout.puts("and print: #{outcome.model_hint}") if outcome.model_hint
|
|
270
|
+
0
|
|
271
|
+
when :new
|
|
272
|
+
@stdout.puts("config: #{path} (new)")
|
|
273
|
+
next_steps
|
|
274
|
+
0
|
|
275
|
+
when :appended
|
|
276
|
+
@stdout.puts("config: #{path} (hosts entry '#{outcome.name}' added; backup #{File.basename(outcome.backup)})")
|
|
277
|
+
@stdout.puts("default.model: #{outcome.default_model}") if outcome.default_model
|
|
278
|
+
@stdout.puts("add under default: in config.yml:\n#{outcome.model_hint}") if outcome.model_hint
|
|
279
|
+
@stdout.puts("use it: chi --model #{outcome.name}:#{model} (or /model in a session)") unless outcome.default_model
|
|
280
|
+
0
|
|
281
|
+
when :exists
|
|
282
|
+
@stdout.puts("config: #{path} already has this server as '#{outcome.existing}'; nothing written")
|
|
283
|
+
@stdout.puts("use it: chi --model #{outcome.existing}:#{model} (or /model in a session)")
|
|
284
|
+
0
|
|
285
|
+
when :snippet
|
|
286
|
+
@stdout.puts("config: #{path} uses YAML chi won't edit (flow style or anchors); add this yourself:")
|
|
287
|
+
@stdout.puts(outcome.text.gsub(/^/, " "))
|
|
288
|
+
@stdout.puts("use it: chi --model #{outcome.name}:#{model}")
|
|
289
|
+
0
|
|
290
|
+
else
|
|
291
|
+
@stderr.puts("chi bootstrap: the edited #{path} didn't read back as expected; " \
|
|
292
|
+
"it is back as it was (#{File.basename(outcome.backup)}). Add this yourself:")
|
|
293
|
+
@stderr.puts(outcome.text.gsub(/^/, " "))
|
|
294
|
+
1
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def next_steps
|
|
299
|
+
lines = [["chi", "a session"], ["chi web --open", "the web UI"], ["chi bundle list", "optional bundles (guardrails, …)"]]
|
|
300
|
+
lines << ["chi desktop install", "macOS \"Send to chi\" helper"] if @platform.to_s.include?("darwin")
|
|
301
|
+
lines.each_with_index do |(command, what), i|
|
|
302
|
+
@stdout.puts("#{i.zero? ? "next:" : " "} #{command.ljust(20)} # #{what}")
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# A numbered list (the first PICK_SHOWN; a typed word narrows it).
|
|
307
|
+
def pick(items, noun)
|
|
308
|
+
shown = items
|
|
309
|
+
loop do
|
|
310
|
+
shown.first(PICK_SHOWN).each_with_index { |item, i| @stdout.puts(format(" %2d) %s", i + 1, item)) }
|
|
311
|
+
more = shown.size - PICK_SHOWN
|
|
312
|
+
@stdout.puts(" … #{more} more: type part of a name to narrow the list") if more.positive?
|
|
313
|
+
@stdout.print("#{noun} (number#{items.size > PICK_SHOWN ? " or text" : ""}, empty to stop): ")
|
|
314
|
+
answer = @stdin.gets.to_s.strip
|
|
315
|
+
return nil if answer.empty?
|
|
316
|
+
|
|
317
|
+
number = Integer(answer, exception: false)
|
|
318
|
+
return shown[number - 1] if number && number.between?(1, [shown.size, PICK_SHOWN].min)
|
|
319
|
+
|
|
320
|
+
narrowed = items.select { |item| item.downcase.include?(answer.downcase) }
|
|
321
|
+
if narrowed.empty?
|
|
322
|
+
@stdout.puts("no #{noun} matches '#{answer}'")
|
|
323
|
+
else
|
|
324
|
+
shown = narrowed
|
|
325
|
+
return shown.first if shown.size == 1
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def tty?
|
|
331
|
+
@stdin.respond_to?(:tty?) && @stdin.tty?
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def unset?(name) = @env[name].to_s.strip.empty?
|
|
335
|
+
|
|
336
|
+
def fail!(message)
|
|
337
|
+
@stderr.puts("chi bootstrap: #{message}")
|
|
338
|
+
@exit = 1
|
|
339
|
+
nil
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def usage_error(message)
|
|
343
|
+
@stderr.puts("chi bootstrap: #{message} (see chi bootstrap --help)")
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class Bridge
|
|
8
|
+
# The running turn's open card with actions (check-in's Nudge / Keep
|
|
9
|
+
# going / Stop) as a file in the session's folder, pending_card.json
|
|
10
|
+
# {id:, bundle:}, for `chi web`'s session hub: it watches files, not
|
|
11
|
+
# workers, and a tab behind notifies from its summary (notify.js), as
|
|
12
|
+
# for a pending question. A persistent observer, like CardStore.
|
|
13
|
+
#
|
|
14
|
+
# Written when such a card shows; removed when that card comes again
|
|
15
|
+
# without actions (resolved), when the turn ends, and when the Bridge
|
|
16
|
+
# starts or stops. A card with no actions (loop-guard's warn) never
|
|
17
|
+
# writes it, nor does a card between turns.
|
|
18
|
+
class PendingCard
|
|
19
|
+
FILE = "pending_card.json"
|
|
20
|
+
TURN_ENDS = %i[turn_completed turn_canceled turn_failed].freeze
|
|
21
|
+
|
|
22
|
+
# @param session_dir [String] the session's folder
|
|
23
|
+
# @return [Hash, nil] {id:, bundle:}, nil without a readable file
|
|
24
|
+
def self.read(session_dir)
|
|
25
|
+
data = JSON.parse(File.read(File.join(session_dir, FILE)))
|
|
26
|
+
return nil unless data.is_a?(Hash) && !data["id"].to_s.empty?
|
|
27
|
+
|
|
28
|
+
{ id: data["id"].to_s, bundle: data["bundle"].to_s }
|
|
29
|
+
rescue StandardError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(session_dir)
|
|
34
|
+
@path = File.join(session_dir, FILE)
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@id = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(event)
|
|
40
|
+
@mutex.synchronize { fold(event) }
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil # never break the running turn
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# No card is open (a start after a worker that died with one, a stop).
|
|
46
|
+
def clear
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@id = nil
|
|
49
|
+
FileUtils.rm_f(@path)
|
|
50
|
+
end
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def fold(event)
|
|
58
|
+
case event[:type]
|
|
59
|
+
when :card then card(event)
|
|
60
|
+
when *TURN_ENDS then remove
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def card(event)
|
|
65
|
+
asks = Array(event[:actions]).any?
|
|
66
|
+
if event[:in_turn] && asks
|
|
67
|
+
write(event[:id].to_s, event[:source].to_s)
|
|
68
|
+
elsif @id && event[:id].to_s == @id
|
|
69
|
+
remove
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def write(id, bundle)
|
|
74
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
75
|
+
temp = "#{@path}.tmp"
|
|
76
|
+
File.write(temp, JSON.generate({ "id" => id, "bundle" => bundle }))
|
|
77
|
+
File.rename(temp, @path)
|
|
78
|
+
@id = id
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def remove
|
|
82
|
+
return unless @id
|
|
83
|
+
|
|
84
|
+
@id = nil
|
|
85
|
+
FileUtils.rm_f(@path)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|