samagotchi 0.22.0 → 0.23.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 +33 -1
- data/bin/chi +9 -4
- data/docs/configuration.md +4 -4
- data/docs/releasing.md +1 -1
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +4 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +2 -2
- data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -6
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
- data/lib/samagotchi/bridge.rb +12 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
- data/lib/samagotchi/bridge_client.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +39 -29
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cancellation_controller.rb +1 -1
- data/lib/samagotchi/cli/command.rb +1 -1
- data/lib/samagotchi/client.rb +3 -5
- data/lib/samagotchi/commands/registry.rb +2 -2
- data/lib/samagotchi/config.rb +38 -14
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +6 -6
- data/lib/samagotchi/continue_offer.rb +15 -8
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/desktop/macos.rb +5 -4
- data/lib/samagotchi/empty_answer_retry.rb +1 -1
- data/lib/samagotchi/engine.rb +17 -17
- data/lib/samagotchi/gem_update.rb +2 -1
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +5 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -2
- data/lib/samagotchi/guardrails/gate.rb +2 -2
- data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
- data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
- data/lib/samagotchi/hooks/loader.rb +18 -16
- data/lib/samagotchi/hooks/registry.rb +10 -2
- data/lib/samagotchi/host_registry.rb +1 -3
- data/lib/samagotchi/idle_recap.rb +18 -15
- data/lib/samagotchi/idle_reminders.rb +6 -3
- data/lib/samagotchi/image_store.rb +5 -2
- data/lib/samagotchi/kernel_loop.rb +31 -31
- data/lib/samagotchi/llm/chat_loop.rb +9 -6
- data/lib/samagotchi/llm/errors.rb +2 -2
- data/lib/samagotchi/llm/http.rb +12 -12
- data/lib/samagotchi/llm/openai_chat.rb +39 -28
- data/lib/samagotchi/llm/prompt_cache.rb +1 -1
- data/lib/samagotchi/log.rb +2 -2
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/memory_bundle/builder.rb +9 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
- data/lib/samagotchi/memory_bundle/installer.rb +67 -57
- data/lib/samagotchi/memory_bundle/listing.rb +5 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
- data/lib/samagotchi/memory_bundle/merger.rb +6 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
- data/lib/samagotchi/memory_bundle/source.rb +7 -3
- data/lib/samagotchi/memory_bundle/status.rb +3 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
- data/lib/samagotchi/memory_bundle/trash.rb +5 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/model_overlay.rb +3 -5
- data/lib/samagotchi/model_profile.rb +12 -8
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/output_formatter.rb +18 -17
- data/lib/samagotchi/owner_lock.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +8 -5
- data/lib/samagotchi/plugin/context.rb +1 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/plugin/service.rb +1 -1
- data/lib/samagotchi/plugin/sessions.rb +1 -0
- data/lib/samagotchi/plugin_tasks.rb +1 -1
- data/lib/samagotchi/prompt.rb +68 -12
- data/lib/samagotchi/prompt_literal_guard.rb +1 -1
- data/lib/samagotchi/question_desk.rb +7 -7
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/relay_desk.rb +1 -1
- data/lib/samagotchi/reminder_store.rb +2 -1
- data/lib/samagotchi/send_command.rb +4 -2
- data/lib/samagotchi/session.rb +2 -2
- data/lib/samagotchi/session_archive_command.rb +5 -1
- data/lib/samagotchi/session_commands.rb +1 -3
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/session_metrics.rb +10 -10
- data/lib/samagotchi/session_observer.rb +3 -2
- data/lib/samagotchi/session_retention.rb +2 -2
- data/lib/samagotchi/sessions_command.rb +14 -5
- data/lib/samagotchi/system_prompt.rb +19 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
- data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
- data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
- data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
- data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
- data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
- data/lib/samagotchi/terminal_ui.rb +9 -5
- data/lib/samagotchi/text_diff.rb +12 -12
- data/lib/samagotchi/thinking.rb +6 -2
- data/lib/samagotchi/thought_stream_splitter.rb +6 -3
- data/lib/samagotchi/tool_activity.rb +1 -0
- data/lib/samagotchi/tool_call_parser.rb +13 -14
- data/lib/samagotchi/tool_declarations.rb +36 -35
- data/lib/samagotchi/tools/ask_user_question.rb +3 -4
- data/lib/samagotchi/tools/builtin_calls.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +0 -1
- data/lib/samagotchi/tools/edit.rb +14 -13
- data/lib/samagotchi/tools/execute.rb +3 -5
- data/lib/samagotchi/tools/list_reminders.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +21 -22
- data/lib/samagotchi/tools/read.rb +7 -7
- data/lib/samagotchi/tools/task_create.rb +1 -1
- data/lib/samagotchi/tools/task_get.rb +1 -1
- data/lib/samagotchi/tools/task_list.rb +1 -1
- data/lib/samagotchi/tools/task_runtime.rb +8 -9
- data/lib/samagotchi/tools/task_stop.rb +1 -1
- data/lib/samagotchi/tools/task_wait.rb +3 -4
- data/lib/samagotchi/tools/web_fetch.rb +2 -4
- data/lib/samagotchi/tools/write.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -8
- data/lib/samagotchi/turn_note.rb +5 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -2
- data/lib/samagotchi/web/app.rb +13 -6
- data/lib/samagotchi/web/markdown_renderer.rb +2 -1
- data/lib/samagotchi/web/qr.rb +7 -1
- data/lib/samagotchi/web/server.rb +6 -2
- data/lib/samagotchi/worker.rb +10 -2
- metadata +20 -20
|
@@ -53,13 +53,28 @@ module Samagotchi
|
|
|
53
53
|
# How much of a 200's non-stream body is read as a possible JSON error.
|
|
54
54
|
PLAIN_ERROR_LIMIT = 64 * 1024
|
|
55
55
|
DEFAULT_MODELS_TTL = 60
|
|
56
|
+
# OpenRouter holds a paid request's estimated cost against the balance
|
|
57
|
+
# while it runs: the input plus the output max_tokens allows (a fixed
|
|
58
|
+
# per-request cap when it is unset), and refuses a request with a 402
|
|
59
|
+
# once the running ones fill the budget. A request that names a sane
|
|
60
|
+
# limit holds less. 32k leaves room for any one step's answer.
|
|
61
|
+
OPENROUTER_MAX_TOKENS = 32_768
|
|
56
62
|
|
|
57
|
-
attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout
|
|
63
|
+
attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout, :default_max_tokens
|
|
58
64
|
|
|
59
65
|
# @param entry [HostRegistry::HostEntry]
|
|
60
|
-
def self.for(entry, **
|
|
66
|
+
def self.for(entry, **)
|
|
61
67
|
new(base_url: entry.openai_base_url, host_name: entry.name, api_key_env: entry.api_key_env,
|
|
62
|
-
remote: entry.remote?,
|
|
68
|
+
remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.openai_base_url)),
|
|
69
|
+
**)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# true when +url+ is OpenRouter's API (openrouter.ai or a subdomain).
|
|
73
|
+
def self.openrouter?(url)
|
|
74
|
+
host = URI(url.to_s).host.to_s.downcase
|
|
75
|
+
host == "openrouter.ai" || host.end_with?(".openrouter.ai")
|
|
76
|
+
rescue URI::Error
|
|
77
|
+
false
|
|
63
78
|
end
|
|
64
79
|
|
|
65
80
|
# Tool arguments as a Hash; "" is {}, invalid JSON stays a String.
|
|
@@ -87,10 +102,13 @@ module Samagotchi
|
|
|
87
102
|
# @param remote [Boolean] a provider on the network, not a local server
|
|
88
103
|
# @param first_token_timeout [Numeric, nil] seconds a streamed answer
|
|
89
104
|
# may take to show something (LLM::HTTP); nil: no limit
|
|
105
|
+
# @param default_max_tokens [Integer, nil] max_tokens for a request
|
|
106
|
+
# whose options name none (OPENROUTER_MAX_TOKENS); nil sends none
|
|
90
107
|
def initialize(base_url:, host_name:, api_key_env: nil, stream: true, retries: true, timeout: nil,
|
|
91
108
|
env: ENV, sleeper: nil, retry_policy: nil, models_ttl: DEFAULT_MODELS_TTL, remote: false,
|
|
92
|
-
first_token_timeout: nil, purpose: "chat")
|
|
109
|
+
first_token_timeout: nil, purpose: "chat", default_max_tokens: nil)
|
|
93
110
|
@remote = remote
|
|
111
|
+
@default_max_tokens = default_max_tokens
|
|
94
112
|
# What its requests are for, in the log (LLM::HTTP::LOGGED_PURPOSES).
|
|
95
113
|
@purpose = purpose
|
|
96
114
|
@first_token_timeout = first_token_timeout
|
|
@@ -163,7 +181,7 @@ module Samagotchi
|
|
|
163
181
|
models = []
|
|
164
182
|
after = nil
|
|
165
183
|
MAX_MODEL_PAGES.times do
|
|
166
|
-
uri = URI("#{@base_url}/models#{
|
|
184
|
+
uri = URI("#{@base_url}/models#{"?after=#{URI.encode_www_form_component(after)}" if after}")
|
|
167
185
|
body = parse_json(@http.fetch(uri, get_request(uri), log_fields: { purpose: "models" }).body, "model list")
|
|
168
186
|
models.concat(model_entries(body).map { |raw| model_info(raw) })
|
|
169
187
|
break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
|
|
@@ -193,6 +211,18 @@ module Samagotchi
|
|
|
193
211
|
nil
|
|
194
212
|
end
|
|
195
213
|
|
|
214
|
+
def self.served_model(payload)
|
|
215
|
+
model = payload.is_a?(Hash) ? payload["model"] : nil
|
|
216
|
+
model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# OpenRouter names the provider that served a request (Baseten,
|
|
220
|
+
# Fireworks, …) in the body's `provider`; other servers send none.
|
|
221
|
+
def self.served_provider(payload)
|
|
222
|
+
provider = payload.is_a?(Hash) ? payload["provider"] : nil
|
|
223
|
+
provider.is_a?(String) && !provider.strip.empty? ? provider : nil
|
|
224
|
+
end
|
|
225
|
+
|
|
196
226
|
private
|
|
197
227
|
|
|
198
228
|
def timeouts(timeout)
|
|
@@ -211,11 +241,12 @@ module Samagotchi
|
|
|
211
241
|
stream: @stream
|
|
212
242
|
}
|
|
213
243
|
body[:stream_options] = { include_usage: true } if @stream
|
|
244
|
+
body[:max_tokens] = @default_max_tokens if @default_max_tokens
|
|
214
245
|
unless Array(tools).empty?
|
|
215
246
|
body[:tools] = tools
|
|
216
247
|
body[:tool_choice] = "auto"
|
|
217
248
|
end
|
|
218
|
-
# A configured value replaces chi's own (temperature); nil drops it.
|
|
249
|
+
# A configured value replaces chi's own (temperature, max_tokens); nil drops it.
|
|
219
250
|
body.merge(options || {}).compact
|
|
220
251
|
end
|
|
221
252
|
|
|
@@ -259,34 +290,15 @@ module Samagotchi
|
|
|
259
290
|
function = call["function"] || {}
|
|
260
291
|
ToolCall.new(id: call["id"], name: function["name"].to_s, arguments: self.class.parse_arguments(function["arguments"]))
|
|
261
292
|
end
|
|
262
|
-
ChatResponse.new(text:
|
|
293
|
+
ChatResponse.new(text: message["content"].to_s,
|
|
263
294
|
reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
|
|
264
295
|
tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
|
|
265
296
|
finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body),
|
|
266
297
|
provider: self.class.served_provider(body))
|
|
267
298
|
end
|
|
268
299
|
|
|
269
|
-
# Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
|
|
270
|
-
# writes after </think> at the start of the answer
|
|
271
|
-
# (https://github.com/incoai/splash/issues/254). Remove once Splash
|
|
272
|
-
# strips it; llama.cpp already does. Streamed, it applies to the chunks
|
|
273
|
-
# until the first text.
|
|
274
|
-
def self.drop_leading_newlines(text) = text.sub(/\A[\r\n]+/, "")
|
|
275
|
-
|
|
276
|
-
def self.served_model(payload)
|
|
277
|
-
model = payload.is_a?(Hash) ? payload["model"] : nil
|
|
278
|
-
model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
279
|
-
end
|
|
280
|
-
|
|
281
300
|
def served_model(payload) = self.class.served_model(payload)
|
|
282
301
|
|
|
283
|
-
# OpenRouter names the provider that served a request (Baseten,
|
|
284
|
-
# Fireworks, …) in the body's `provider`; other servers send none.
|
|
285
|
-
def self.served_provider(payload)
|
|
286
|
-
provider = payload.is_a?(Hash) ? payload["provider"] : nil
|
|
287
|
-
provider.is_a?(String) && !provider.strip.empty? ? provider : nil
|
|
288
|
-
end
|
|
289
|
-
|
|
290
302
|
def served_provider(payload) = self.class.served_provider(payload)
|
|
291
303
|
|
|
292
304
|
# The parsed payload of a `data:` line; nil for blank lines, comments
|
|
@@ -357,7 +369,7 @@ module Samagotchi
|
|
|
357
369
|
listed = raw["supported_parameters"] || raw["capabilities"]
|
|
358
370
|
return nil unless listed.is_a?(Array)
|
|
359
371
|
|
|
360
|
-
listed.include?("tools")
|
|
372
|
+
listed.include?("tools") || nil
|
|
361
373
|
end
|
|
362
374
|
|
|
363
375
|
def cached_models
|
|
@@ -405,7 +417,6 @@ module Samagotchi
|
|
|
405
417
|
@finish_reason = choice["finish_reason"] if choice["finish_reason"]
|
|
406
418
|
delta = choice["delta"].is_a?(Hash) ? choice["delta"] : {}
|
|
407
419
|
content = delta["content"].to_s
|
|
408
|
-
content = OpenAIChat.drop_leading_newlines(content) if @text.empty?
|
|
409
420
|
reasoning = (delta["reasoning_content"] || delta["reasoning"]).to_s
|
|
410
421
|
@text << content
|
|
411
422
|
@reasoning << reasoning
|
data/lib/samagotchi/log.rb
CHANGED
|
@@ -143,7 +143,7 @@ module Samagotchi
|
|
|
143
143
|
def safe_value(key, value)
|
|
144
144
|
return "[redacted]" if key.match?(SECRET_KEY)
|
|
145
145
|
|
|
146
|
-
value = value.
|
|
146
|
+
value = value.round(3) if value.is_a?(Float)
|
|
147
147
|
value.is_a?(String) && value.match?(URL) ? safe_url(value) : value
|
|
148
148
|
end
|
|
149
149
|
|
|
@@ -185,7 +185,7 @@ module Samagotchi
|
|
|
185
185
|
def build_state
|
|
186
186
|
options = @options
|
|
187
187
|
path = options.fetch(:path, :auto)
|
|
188
|
-
path =
|
|
188
|
+
path = LogPath.resolve rescue nil if path == :auto
|
|
189
189
|
level = options[:level] || (Config.get("log.level") rescue nil)
|
|
190
190
|
level = LEVELS.key?(level.to_s.downcase.to_sym) ? level.to_s.downcase.to_sym : DEFAULT_LEVEL
|
|
191
191
|
{ writer: DebugLog.new(path: path), level: LEVELS.fetch(level), mirror: options[:mirror] ? true : false }
|
|
@@ -46,7 +46,8 @@ module Samagotchi
|
|
|
46
46
|
type = event[:type]&.to_sym
|
|
47
47
|
return unless type
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
handler = :"on_#{type}"
|
|
50
|
+
if respond_to?(handler, true)
|
|
50
51
|
send(handler, event)
|
|
51
52
|
elsif ANNOUNCED.key?(type)
|
|
52
53
|
log(:info, type, **event.slice(*ANNOUNCED[type]), **origin(event), **items(event))
|
|
@@ -65,8 +65,9 @@ module Samagotchi
|
|
|
65
65
|
candidates_by_basename = candidates.map { |p| [File.basename(p), p] }.to_h
|
|
66
66
|
missing = allow.reject { |k| candidates_by_basename.key?(k) }
|
|
67
67
|
unless missing.empty?
|
|
68
|
-
raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(
|
|
68
|
+
raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(", ")}"
|
|
69
69
|
end
|
|
70
|
+
|
|
70
71
|
candidates = with_overlays(allow, candidates_by_basename).map { |k| candidates_by_basename[k] }
|
|
71
72
|
else
|
|
72
73
|
candidates = leave_out_owned(candidates, resolved_name)
|
|
@@ -107,6 +108,7 @@ module Samagotchi
|
|
|
107
108
|
src = File.join(prov.hooks_dir, basename)
|
|
108
109
|
# Skip if hook file missing on disk
|
|
109
110
|
next unless File.exist?(src)
|
|
111
|
+
|
|
110
112
|
# meta may be symbol-keyed
|
|
111
113
|
m = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
|
|
112
114
|
sha = (m["sha256"] || m["checksum"] || "").to_s
|
|
@@ -245,7 +247,7 @@ module Samagotchi
|
|
|
245
247
|
def summary
|
|
246
248
|
lines = []
|
|
247
249
|
lines << "Built #{@built_files.size} file(s) to #{@out_path}"
|
|
248
|
-
lines << "Files: #{@built_files.join(
|
|
250
|
+
lines << "Files: #{@built_files.join(", ")}" unless @built_files.empty?
|
|
249
251
|
lines.concat(@placeholder_warnings.map { |w| "Placeholder: #{w}" }) unless @placeholder_warnings.empty?
|
|
250
252
|
lines.concat(@warnings) unless @warnings.empty?
|
|
251
253
|
lines.join("\n")
|
|
@@ -275,6 +277,7 @@ module Samagotchi
|
|
|
275
277
|
|
|
276
278
|
def normalize_scope(val)
|
|
277
279
|
return nil if val.nil? || val.to_s.strip.empty?
|
|
280
|
+
|
|
278
281
|
v = val.to_s.strip.downcase
|
|
279
282
|
%w[project system].include?(v) ? v : nil
|
|
280
283
|
end
|
|
@@ -295,7 +298,7 @@ module Samagotchi
|
|
|
295
298
|
def default_name(scope)
|
|
296
299
|
if scope == "project"
|
|
297
300
|
base = File.basename(MemoryPaths.project_root).gsub(/[^A-Za-z0-9_-]/, "-").downcase
|
|
298
|
-
base = base.
|
|
301
|
+
base = base.squeeze("-").gsub(/\A-+|-+\z/, "")
|
|
299
302
|
base = "project" if base.empty?
|
|
300
303
|
"chi_#{base}_memories"
|
|
301
304
|
else
|
|
@@ -356,6 +359,7 @@ module Samagotchi
|
|
|
356
359
|
if File.exist?(expanded) && !File.directory?(expanded)
|
|
357
360
|
raise BuildError, "Output path exists and is not a directory: #{expanded}"
|
|
358
361
|
end
|
|
362
|
+
|
|
359
363
|
[expanded, :dir]
|
|
360
364
|
end
|
|
361
365
|
end
|
|
@@ -365,6 +369,7 @@ module Samagotchi
|
|
|
365
369
|
if File.exist?(out_path)
|
|
366
370
|
raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
|
|
367
371
|
end
|
|
372
|
+
|
|
368
373
|
# Use zip CLI like source.rb does with unzip
|
|
369
374
|
Dir.chdir(staging) do
|
|
370
375
|
result = system("zip", "-r", out_path, ".")
|
|
@@ -377,6 +382,7 @@ module Samagotchi
|
|
|
377
382
|
if File.exist?(out_path)
|
|
378
383
|
raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
|
|
379
384
|
end
|
|
385
|
+
|
|
380
386
|
# Use tar CLI
|
|
381
387
|
Dir.chdir(staging) do
|
|
382
388
|
cmd = case format
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require "fileutils"
|
|
3
4
|
require "digest"
|
|
4
5
|
require_relative "../atomic_file"
|
|
@@ -16,6 +17,7 @@ module Samagotchi
|
|
|
16
17
|
def self.update_index(scope, entry_name, byte_count, description = nil, source: nil)
|
|
17
18
|
index_path = index_path_for(scope)
|
|
18
19
|
return true unless index_path
|
|
20
|
+
|
|
19
21
|
locked_write(File.dirname(index_path)) do |content|
|
|
20
22
|
new_line = managed_line(entry_name, scope, byte_count, description, source || nil)
|
|
21
23
|
next auto_index_header + "\n\n" + new_line + "\n" if content.nil? || content.strip.empty?
|
|
@@ -32,20 +34,25 @@ module Samagotchi
|
|
|
32
34
|
end
|
|
33
35
|
true
|
|
34
36
|
end
|
|
37
|
+
|
|
35
38
|
def self.managed_pattern(name)
|
|
36
39
|
/^- \*\*#{Regexp.escape(name)}\*\*[ \t]*(?:[·•].*|:.*)?(\r?\n|\z)/
|
|
37
40
|
end
|
|
41
|
+
|
|
38
42
|
def self.remove_index(scope, entry_name)
|
|
39
43
|
index_path = index_path_for(scope)
|
|
40
44
|
return true unless index_path && File.exist?(index_path)
|
|
45
|
+
|
|
41
46
|
locked_write(File.dirname(index_path)) do |content|
|
|
42
47
|
pattern = managed_pattern(entry_name)
|
|
43
48
|
next nil unless content&.match?(pattern)
|
|
49
|
+
|
|
44
50
|
# Clean up extra blank lines: collapse 3+ newlines to 2
|
|
45
51
|
content.gsub(pattern, "").gsub(/\n{3,}/, "\n\n")
|
|
46
52
|
end
|
|
47
53
|
true
|
|
48
54
|
end
|
|
55
|
+
|
|
49
56
|
# Read-modify-write of <dir>/index.md under an exclusive flock on a sidecar
|
|
50
57
|
# <dir>/.index.lock, so sessions in several worktrees of one repository
|
|
51
58
|
# (one shared project folder) don't drop each other's lines. Yields the
|
|
@@ -61,6 +68,7 @@ module Samagotchi
|
|
|
61
68
|
Samagotchi::AtomicFile.write(index_path, updated) unless updated.nil?
|
|
62
69
|
end
|
|
63
70
|
end
|
|
71
|
+
|
|
64
72
|
def self.managed_line(name, scope, byte_count, description, source = nil)
|
|
65
73
|
line = "- **#{name}** · #{scope} · #{date_str} · #{byte_count}"
|
|
66
74
|
line += " · from #{source}" unless source.to_s.strip.empty?
|
|
@@ -68,6 +76,7 @@ module Samagotchi
|
|
|
68
76
|
line += " — #{desc}" unless desc.to_s.empty?
|
|
69
77
|
line
|
|
70
78
|
end
|
|
79
|
+
|
|
71
80
|
def self.extract_description(line)
|
|
72
81
|
if line =~ /\s—\s(.*)\s*\z/
|
|
73
82
|
$1
|
|
@@ -75,6 +84,7 @@ module Samagotchi
|
|
|
75
84
|
$1
|
|
76
85
|
end
|
|
77
86
|
end
|
|
87
|
+
|
|
78
88
|
# The bundle a managed line names ("· from <bundle>", before any
|
|
79
89
|
# " — description"), or nil.
|
|
80
90
|
def self.extract_source(line)
|
|
@@ -86,14 +96,16 @@ module Samagotchi
|
|
|
86
96
|
"Managed entries below are auto-maintained by memory_write " \
|
|
87
97
|
"(name, scope, last-written date, size). Free-form sections are preserved."
|
|
88
98
|
end
|
|
99
|
+
|
|
89
100
|
def self.date_str
|
|
90
101
|
require "date"
|
|
91
102
|
Date.today.iso8601
|
|
92
103
|
end
|
|
104
|
+
|
|
93
105
|
def self.index_path_for(scope)
|
|
94
106
|
base_dir = MemoryPaths.scope_dir(scope.to_s.strip)
|
|
95
107
|
base_dir && File.join(base_dir, "#{MEMORY_INDEX}.md")
|
|
96
108
|
end
|
|
97
109
|
end
|
|
98
110
|
end
|
|
99
|
-
end
|
|
111
|
+
end
|
|
@@ -80,11 +80,9 @@ module Samagotchi
|
|
|
80
80
|
rescue SourceNormalizer::UnknownSourceError => e
|
|
81
81
|
raise InstallError, "Source normalization failed: #{e.message}"
|
|
82
82
|
rescue Manifest::ValidationError => e
|
|
83
|
-
if @strict
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
@warnings << "No manifest found — proceeding without strict manifest validation"
|
|
87
|
-
end
|
|
83
|
+
raise InstallError, e.message if @strict
|
|
84
|
+
|
|
85
|
+
@warnings << "No manifest found — proceeding without strict manifest validation"
|
|
88
86
|
end
|
|
89
87
|
|
|
90
88
|
# Determine target scope (CLI wins over manifest).
|
|
@@ -185,8 +183,9 @@ module Samagotchi
|
|
|
185
183
|
end
|
|
186
184
|
end
|
|
187
185
|
|
|
188
|
-
hook_entries.each do |basename,
|
|
186
|
+
hook_entries.each do |basename, _meta|
|
|
189
187
|
next unless basename.is_a?(String) && !basename.empty?
|
|
188
|
+
|
|
190
189
|
all_hooks_in_bundle << basename
|
|
191
190
|
src = File.join(normalized_dir, "hooks", basename)
|
|
192
191
|
next unless File.exist?(src)
|
|
@@ -194,11 +193,11 @@ module Samagotchi
|
|
|
194
193
|
dest = File.join(hooks_target, basename)
|
|
195
194
|
|
|
196
195
|
if @dry_run
|
|
197
|
-
if File.exist?(dest) && !@force
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
196
|
+
@results[basename] = if File.exist?(dest) && !@force
|
|
197
|
+
{ status: "would_skip" }
|
|
198
|
+
else
|
|
199
|
+
{ status: "would_install" }
|
|
200
|
+
end
|
|
202
201
|
next
|
|
203
202
|
end
|
|
204
203
|
|
|
@@ -207,10 +206,8 @@ module Samagotchi
|
|
|
207
206
|
prev_meta = existing_provenance[:hooks] ? (existing_provenance[:hooks][basename.to_sym] || existing_provenance[:hooks][basename]) : nil
|
|
208
207
|
if prev_meta
|
|
209
208
|
prev_sha = prev_meta[:sha256] || prev_meta["sha256"]
|
|
210
|
-
if File.exist?(dest)
|
|
211
|
-
|
|
212
|
-
@warnings << "Hook #{basename} was locally modified; overwriting"
|
|
213
|
-
end
|
|
209
|
+
if File.exist?(dest) && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
|
|
210
|
+
@warnings << "Hook #{basename} was locally modified; overwriting"
|
|
214
211
|
end
|
|
215
212
|
end
|
|
216
213
|
end
|
|
@@ -249,11 +246,12 @@ module Samagotchi
|
|
|
249
246
|
existing_provenance[:hooks].keys.each do |old_key|
|
|
250
247
|
old_key_str = old_key.to_s
|
|
251
248
|
next if all_hooks_in_bundle.include?(old_key_str)
|
|
249
|
+
|
|
252
250
|
old_dest = File.join(hooks_target, old_key_str)
|
|
253
251
|
if File.exist?(old_dest) && !@force
|
|
254
252
|
@warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
|
|
255
253
|
end
|
|
256
|
-
FileUtils.rm_f(old_dest)
|
|
254
|
+
FileUtils.rm_f(old_dest)
|
|
257
255
|
end
|
|
258
256
|
end
|
|
259
257
|
|
|
@@ -261,14 +259,16 @@ module Samagotchi
|
|
|
261
259
|
if manifest && @strict
|
|
262
260
|
all_hooks_in_bundle.each do |basename|
|
|
263
261
|
next if @conflicts.key?(basename)
|
|
262
|
+
|
|
264
263
|
expected = manifest.checksum_for_hook(basename)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
264
|
+
next unless expected
|
|
265
|
+
|
|
266
|
+
dest = File.join(hooks_target, basename)
|
|
267
|
+
next unless File.exist?(dest)
|
|
268
|
+
|
|
269
|
+
actual = Digest::SHA256.hexdigest(File.read(dest))
|
|
270
|
+
if actual != expected
|
|
271
|
+
@warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
272
272
|
end
|
|
273
273
|
end
|
|
274
274
|
end
|
|
@@ -313,18 +313,21 @@ module Samagotchi
|
|
|
313
313
|
if manifest && @strict
|
|
314
314
|
all_files_in_bundle.each do |file_key|
|
|
315
315
|
next if @conflicts.key?(file_key)
|
|
316
|
+
|
|
316
317
|
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
317
318
|
# skipped: not copied, so a local edit isn't a bad download.
|
|
318
319
|
next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
|
|
320
|
+
|
|
319
321
|
# For dry-run, fast_forward would appear as "fast_forward", updated not yet
|
|
320
322
|
target_path = File.join(target_dir, file_key)
|
|
321
323
|
next unless File.exist?(target_path)
|
|
324
|
+
|
|
322
325
|
expected = manifest.checksum_for(file_key)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
326
|
+
next unless expected
|
|
327
|
+
|
|
328
|
+
actual = Digest::SHA256.hexdigest(File.read(target_path))
|
|
329
|
+
if actual != expected
|
|
330
|
+
@warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
328
331
|
end
|
|
329
332
|
end
|
|
330
333
|
end
|
|
@@ -342,18 +345,19 @@ module Samagotchi
|
|
|
342
345
|
owned.each do |file_key|
|
|
343
346
|
target_path = File.join(target_dir, file_key)
|
|
344
347
|
next unless File.exist?(target_path)
|
|
348
|
+
|
|
345
349
|
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
346
350
|
if %w[kept noop conflict kept_pruned].include?(status)
|
|
347
351
|
# Keep old base snapshot — do not overwrite with edited current
|
|
348
352
|
# Use base snapshot as source if it exists to preserve old checksum
|
|
349
353
|
base_path = provenance.base_path(file_key)
|
|
350
|
-
if File.exist?(base_path)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
354
|
+
provenance_files[file_key] = if File.exist?(base_path)
|
|
355
|
+
# Keep existing provenance entry by re-using base content
|
|
356
|
+
# We still need to include it to prevent pruning, but with base content
|
|
357
|
+
base_path
|
|
358
|
+
else
|
|
359
|
+
target_path
|
|
360
|
+
end
|
|
357
361
|
else
|
|
358
362
|
# installed, updated, skipped — use current target (which is incoming for updated)
|
|
359
363
|
provenance_files[file_key] = target_path
|
|
@@ -364,14 +368,15 @@ module Samagotchi
|
|
|
364
368
|
existing_provenance[:files].keys.each do |old_key|
|
|
365
369
|
old_key_str = old_key.to_s
|
|
366
370
|
next if all_files_in_bundle.include?(old_key_str)
|
|
371
|
+
|
|
367
372
|
# If it was kept_pruned, include it with base content to prevent pruning
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
373
|
+
next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
|
|
374
|
+
|
|
375
|
+
base_path = provenance.base_path(old_key_str)
|
|
376
|
+
target_path = File.join(target_dir, old_key_str)
|
|
377
|
+
# Use base if exists to keep old checksum, else target
|
|
378
|
+
src = File.exist?(base_path) ? base_path : target_path
|
|
379
|
+
provenance_files[old_key_str] = src if File.exist?(src)
|
|
375
380
|
end
|
|
376
381
|
end
|
|
377
382
|
else
|
|
@@ -393,6 +398,7 @@ module Samagotchi
|
|
|
393
398
|
elsif hooks_files_for_provenance.any?
|
|
394
399
|
hooks_files_for_provenance.each do |basename, path|
|
|
395
400
|
next unless File.exist?(path)
|
|
401
|
+
|
|
396
402
|
sha = Digest::SHA256.hexdigest(File.read(path))
|
|
397
403
|
hooks_for_provenance[basename] = { "sha256" => "sha256:#{sha}", "event" => "", "on_error" => "skip", "priority" => 100 }
|
|
398
404
|
end
|
|
@@ -418,6 +424,7 @@ module Samagotchi
|
|
|
418
424
|
all_files_in_bundle.each do |file_key|
|
|
419
425
|
file_path = File.join(target_dir, file_key)
|
|
420
426
|
next unless File.exist?(file_path)
|
|
427
|
+
|
|
421
428
|
names = Placeholder.detect_in_file(file_path)
|
|
422
429
|
if names.any?
|
|
423
430
|
@placeholder_warnings << "#{file_key}: {{#{names.join("}}, {{")}}}"
|
|
@@ -451,15 +458,15 @@ module Samagotchi
|
|
|
451
458
|
removed = @results.select { |_, r| r[:status].to_s == "removed" }.keys
|
|
452
459
|
would_remove = @results.select { |_, r| r[:status].to_s == "would_remove" }.keys
|
|
453
460
|
|
|
454
|
-
lines << "Installed: #{installed.join(
|
|
455
|
-
lines << "Updated: #{updated.join(
|
|
456
|
-
lines << "Would install: #{would_install.join(
|
|
457
|
-
lines << "Fast-forward: #{fast_forward.join(
|
|
458
|
-
lines << "Removed (no longer in the bundle): #{removed.join(
|
|
459
|
-
lines << "Would remove (no longer in the bundle): #{would_remove.join(
|
|
460
|
-
lines << "Kept (local edits preserved): #{kept.join(
|
|
461
|
-
lines << "Skipped: #{skipped.join(
|
|
462
|
-
lines << "Conflicts: #{conflicts.join(
|
|
461
|
+
lines << "Installed: #{installed.join(", ")}" unless installed.empty?
|
|
462
|
+
lines << "Updated: #{updated.join(", ")}" unless updated.empty?
|
|
463
|
+
lines << "Would install: #{would_install.join(", ")}" unless would_install.empty?
|
|
464
|
+
lines << "Fast-forward: #{fast_forward.join(", ")}" unless fast_forward.empty?
|
|
465
|
+
lines << "Removed (no longer in the bundle): #{removed.join(", ")}#{" (moved to #{trash_dir})" if trash_dir}" unless removed.empty?
|
|
466
|
+
lines << "Would remove (no longer in the bundle): #{would_remove.join(", ")}" unless would_remove.empty?
|
|
467
|
+
lines << "Kept (local edits preserved): #{kept.join(", ")}" unless kept.empty?
|
|
468
|
+
lines << "Skipped: #{skipped.join(", ")}" unless skipped.empty?
|
|
469
|
+
lines << "Conflicts: #{conflicts.join(", ")}" unless conflicts.empty?
|
|
463
470
|
lines.concat(@warnings) unless @warnings.empty?
|
|
464
471
|
lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
|
|
465
472
|
lines << "#{@name} is already installed; `chi bundle upgrade #{@name}` updates it and keeps local edits" if @reinstall
|
|
@@ -494,6 +501,7 @@ module Samagotchi
|
|
|
494
501
|
previous_files.each do |old_key, meta|
|
|
495
502
|
key = old_key.to_s
|
|
496
503
|
next if bundle_files.include?(key)
|
|
504
|
+
|
|
497
505
|
target = File.join(target_dir, key)
|
|
498
506
|
next unless File.exist?(target)
|
|
499
507
|
|
|
@@ -504,6 +512,7 @@ module Samagotchi
|
|
|
504
512
|
elsif @force || installed_unchanged?(target, meta, provenance.base_path(key))
|
|
505
513
|
@results[key] = { status: @dry_run ? "would_remove" : "removed", reason: "no longer in the bundle" }
|
|
506
514
|
next if @dry_run
|
|
515
|
+
|
|
507
516
|
@trash.move(target)
|
|
508
517
|
remove_target_index(scope, key)
|
|
509
518
|
else
|
|
@@ -518,6 +527,7 @@ module Samagotchi
|
|
|
518
527
|
def installed_unchanged?(target, meta, base_path)
|
|
519
528
|
recorded = meta.is_a?(Hash) ? meta[:checksum] || meta["checksum"] : nil
|
|
520
529
|
return Provenance.sha_matches?(target, recorded) unless Provenance.recorded_sha(recorded).empty?
|
|
530
|
+
|
|
521
531
|
File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
|
|
522
532
|
end
|
|
523
533
|
|
|
@@ -577,12 +587,12 @@ module Samagotchi
|
|
|
577
587
|
FileUtils.mkdir_p(provenance.plugin_dir)
|
|
578
588
|
FileUtils.cp(src, dest)
|
|
579
589
|
@results[plugin[:file]] = if unchanged
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
590
|
+
{ status: "skipped", reason: "already up to date" }
|
|
591
|
+
elsif @upgrade && provenance.read
|
|
592
|
+
{ status: "updated" }
|
|
593
|
+
else
|
|
594
|
+
{ status: "installed" }
|
|
595
|
+
end
|
|
586
596
|
expected = manifest.checksum_for_plugin
|
|
587
597
|
actual = Digest::SHA256.hexdigest(File.binread(dest))
|
|
588
598
|
if @strict && expected && actual != expected
|
|
@@ -30,8 +30,10 @@ module Samagotchi
|
|
|
30
30
|
|
|
31
31
|
Dir.children(dir).sort.filter_map do |source|
|
|
32
32
|
next unless File.file?(File.join(dir, source, "manifest.yml"))
|
|
33
|
+
|
|
33
34
|
m = Manifest.read(dir: File.join(dir, source))
|
|
34
35
|
next if m.name == SystemBundle::BUNDLE_NAME
|
|
36
|
+
|
|
35
37
|
Shipped.new(name: m.name, source: source, version: m.version, description: m.description, includes: m.includes)
|
|
36
38
|
rescue Manifest::ValidationError, Psych::Exception
|
|
37
39
|
nil
|
|
@@ -70,8 +72,10 @@ module Samagotchi
|
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
def newer?(candidate, current)
|
|
73
|
-
candidate
|
|
75
|
+
candidate = candidate.to_s
|
|
76
|
+
current = current.to_s
|
|
74
77
|
return false unless Gem::Version.correct?(candidate) && Gem::Version.correct?(current)
|
|
78
|
+
|
|
75
79
|
Gem::Version.new(candidate) > Gem::Version.new(current)
|
|
76
80
|
end
|
|
77
81
|
end
|