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.
Files changed (154) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/bin/chi +9 -4
  4. data/docs/configuration.md +4 -4
  5. data/docs/releasing.md +1 -1
  6. data/docs/testing.md +6 -0
  7. data/lib/samagotchi/answer_command.rb +4 -2
  8. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  9. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +2 -2
  11. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  12. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
  14. data/lib/samagotchi/bridge.rb +12 -9
  15. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  16. data/lib/samagotchi/bridge_client.rb +1 -1
  17. data/lib/samagotchi/bundle_command.rb +39 -29
  18. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  20. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
  22. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  23. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  26. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  28. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  30. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  31. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  34. data/lib/samagotchi/cancellation_controller.rb +1 -1
  35. data/lib/samagotchi/cli/command.rb +1 -1
  36. data/lib/samagotchi/client.rb +3 -5
  37. data/lib/samagotchi/commands/registry.rb +2 -2
  38. data/lib/samagotchi/config.rb +38 -14
  39. data/lib/samagotchi/context_note.rb +1 -1
  40. data/lib/samagotchi/context_status.rb +6 -6
  41. data/lib/samagotchi/continue_offer.rb +15 -8
  42. data/lib/samagotchi/debug_log.rb +1 -1
  43. data/lib/samagotchi/desktop/macos.rb +5 -4
  44. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  45. data/lib/samagotchi/engine.rb +17 -17
  46. data/lib/samagotchi/gem_update.rb +2 -1
  47. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  48. data/lib/samagotchi/guardrails/approval.rb +5 -5
  49. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  50. data/lib/samagotchi/guardrails/gate.rb +2 -2
  51. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  52. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  53. data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
  54. data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
  55. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  56. data/lib/samagotchi/hooks/loader.rb +18 -16
  57. data/lib/samagotchi/hooks/registry.rb +10 -2
  58. data/lib/samagotchi/host_registry.rb +1 -3
  59. data/lib/samagotchi/idle_recap.rb +18 -15
  60. data/lib/samagotchi/idle_reminders.rb +6 -3
  61. data/lib/samagotchi/image_store.rb +5 -2
  62. data/lib/samagotchi/kernel_loop.rb +31 -31
  63. data/lib/samagotchi/llm/chat_loop.rb +9 -6
  64. data/lib/samagotchi/llm/errors.rb +2 -2
  65. data/lib/samagotchi/llm/http.rb +12 -12
  66. data/lib/samagotchi/llm/openai_chat.rb +39 -28
  67. data/lib/samagotchi/llm/prompt_cache.rb +1 -1
  68. data/lib/samagotchi/log.rb +2 -2
  69. data/lib/samagotchi/log_subscriber.rb +2 -1
  70. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  71. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  72. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  73. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  74. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  75. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  76. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  77. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  78. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  79. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  80. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  81. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  82. data/lib/samagotchi/memory_bundle.rb +1 -0
  83. data/lib/samagotchi/model_overlay.rb +3 -5
  84. data/lib/samagotchi/model_profile.rb +12 -8
  85. data/lib/samagotchi/note_command.rb +2 -1
  86. data/lib/samagotchi/output_formatter.rb +18 -17
  87. data/lib/samagotchi/owner_lock.rb +2 -1
  88. data/lib/samagotchi/plugin/api.rb +8 -5
  89. data/lib/samagotchi/plugin/context.rb +1 -3
  90. data/lib/samagotchi/plugin/loader.rb +1 -1
  91. data/lib/samagotchi/plugin/service.rb +1 -1
  92. data/lib/samagotchi/plugin/sessions.rb +1 -0
  93. data/lib/samagotchi/plugin_tasks.rb +1 -1
  94. data/lib/samagotchi/prompt.rb +68 -12
  95. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  96. data/lib/samagotchi/question_desk.rb +7 -7
  97. data/lib/samagotchi/recap_setup.rb +1 -1
  98. data/lib/samagotchi/relay_desk.rb +1 -1
  99. data/lib/samagotchi/reminder_store.rb +2 -1
  100. data/lib/samagotchi/send_command.rb +4 -2
  101. data/lib/samagotchi/session.rb +2 -2
  102. data/lib/samagotchi/session_archive_command.rb +5 -1
  103. data/lib/samagotchi/session_commands.rb +1 -3
  104. data/lib/samagotchi/session_manager.rb +2 -4
  105. data/lib/samagotchi/session_metrics.rb +10 -10
  106. data/lib/samagotchi/session_observer.rb +3 -2
  107. data/lib/samagotchi/session_retention.rb +2 -2
  108. data/lib/samagotchi/sessions_command.rb +14 -5
  109. data/lib/samagotchi/system_prompt.rb +19 -8
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  111. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  112. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
  113. data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
  114. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  115. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  116. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  117. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  118. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  119. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  120. data/lib/samagotchi/terminal_ui.rb +9 -5
  121. data/lib/samagotchi/text_diff.rb +12 -12
  122. data/lib/samagotchi/thinking.rb +6 -2
  123. data/lib/samagotchi/thought_stream_splitter.rb +6 -3
  124. data/lib/samagotchi/tool_activity.rb +1 -0
  125. data/lib/samagotchi/tool_call_parser.rb +13 -14
  126. data/lib/samagotchi/tool_declarations.rb +36 -35
  127. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  128. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  129. data/lib/samagotchi/tools/delegate.rb +1 -1
  130. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  131. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  132. data/lib/samagotchi/tools/edit.rb +14 -13
  133. data/lib/samagotchi/tools/execute.rb +3 -5
  134. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  135. data/lib/samagotchi/tools/memory.rb +21 -22
  136. data/lib/samagotchi/tools/read.rb +7 -7
  137. data/lib/samagotchi/tools/task_create.rb +1 -1
  138. data/lib/samagotchi/tools/task_get.rb +1 -1
  139. data/lib/samagotchi/tools/task_list.rb +1 -1
  140. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  141. data/lib/samagotchi/tools/task_stop.rb +1 -1
  142. data/lib/samagotchi/tools/task_wait.rb +3 -4
  143. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  144. data/lib/samagotchi/tools/write.rb +1 -1
  145. data/lib/samagotchi/turn_flow.rb +8 -8
  146. data/lib/samagotchi/turn_note.rb +5 -1
  147. data/lib/samagotchi/version.rb +1 -1
  148. data/lib/samagotchi/vision_support.rb +2 -2
  149. data/lib/samagotchi/web/app.rb +13 -6
  150. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  151. data/lib/samagotchi/web/qr.rb +7 -1
  152. data/lib/samagotchi/web/server.rb +6 -2
  153. data/lib/samagotchi/worker.rb +10 -2
  154. 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, **options)
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?, **options)
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#{after ? "?after=#{URI.encode_www_form_component(after)}" : ""}")
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: OpenAIChat.drop_leading_newlines(message["content"].to_s),
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") ? true : nil
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
@@ -55,7 +55,7 @@ module Samagotchi
55
55
  def content?(content)
56
56
  case content
57
57
  when String then !content.empty?
58
- when Array then content.any? { |part| part.is_a?(Hash) }
58
+ when Array then content.any?(Hash)
59
59
  else false
60
60
  end
61
61
  end
@@ -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.is_a?(Float) ? value.round(3) : 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 = (LogPath.resolve rescue nil) if path == :auto
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
- if respond_to?(handler = :"on_#{type}", true)
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(', ')}" unless @built_files.empty?
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.gsub(/-+/, "-").gsub(/\A-+|-+\z/, "")
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
- raise InstallError, e.message
85
- else
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, meta|
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
- @results[basename] = { status: "would_skip" }
199
- else
200
- @results[basename] = { status: "would_install" }
201
- end
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
- if !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
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) if File.exist?(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
- if expected
266
- dest = File.join(hooks_target, basename)
267
- next unless File.exist?(dest)
268
- actual = Digest::SHA256.hexdigest(File.read(dest))
269
- if actual != expected
270
- @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
271
- end
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
- if expected
324
- actual = Digest::SHA256.hexdigest(File.read(target_path))
325
- if actual != expected
326
- @warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
327
- end
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
- # Keep existing provenance entry by re-using base content
352
- # We still need to include it to prevent pruning, but with base content
353
- provenance_files[file_key] = base_path
354
- else
355
- provenance_files[file_key] = target_path
356
- end
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
- if @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
369
- base_path = provenance.base_path(old_key_str)
370
- target_path = File.join(target_dir, old_key_str)
371
- # Use base if exists to keep old checksum, else target
372
- src = File.exist?(base_path) ? base_path : target_path
373
- provenance_files[old_key_str] = src if File.exist?(src)
374
- end
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(', ')}" unless installed.empty?
455
- lines << "Updated: #{updated.join(', ')}" unless updated.empty?
456
- lines << "Would install: #{would_install.join(', ')}" unless would_install.empty?
457
- lines << "Fast-forward: #{fast_forward.join(', ')}" unless fast_forward.empty?
458
- lines << "Removed (no longer in the bundle): #{removed.join(', ')}#{trash_dir ? " (moved to #{trash_dir})" : ""}" unless removed.empty?
459
- lines << "Would remove (no longer in the bundle): #{would_remove.join(', ')}" unless would_remove.empty?
460
- lines << "Kept (local edits preserved): #{kept.join(', ')}" unless kept.empty?
461
- lines << "Skipped: #{skipped.join(', ')}" unless skipped.empty?
462
- lines << "Conflicts: #{conflicts.join(', ')}" unless conflicts.empty?
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
- { status: "skipped", reason: "already up to date" }
581
- elsif @upgrade && provenance.read
582
- { status: "updated" }
583
- else
584
- { status: "installed" }
585
- end
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, current = candidate.to_s, current.to_s
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