samagotchi 0.53.0 → 0.55.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +94 -54
  6. data/docs/configuration.md +30 -2
  7. data/docs/context.md +104 -9
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/bundles.md +73 -0
  10. data/docs/internals/context-telemetry.md +27 -6
  11. data/docs/internals/engine.md +55 -24
  12. data/docs/internals/tool-guardrails.md +8 -6
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +3 -3
  15. data/docs/testing.md +23 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
  17. data/lib/samagotchi/bundle_command.rb +31 -36
  18. data/lib/samagotchi/bundle_needs.rb +4 -5
  19. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/child_move.rb +6 -4
  24. data/lib/samagotchi/config.rb +2 -1
  25. data/lib/samagotchi/context_command.rb +1 -1
  26. data/lib/samagotchi/context_providers.rb +11 -14
  27. data/lib/samagotchi/context_status.rb +10 -18
  28. data/lib/samagotchi/engine.rb +117 -134
  29. data/lib/samagotchi/extension_load.rb +102 -0
  30. data/lib/samagotchi/file_ref.rb +59 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/kernel_loop.rb +5 -3
  33. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  35. data/lib/samagotchi/memory_bundle/builder.rb +170 -178
  36. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  37. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  38. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  39. data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
  40. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  41. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  42. data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  44. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  47. data/lib/samagotchi/plugin/loader.rb +8 -8
  48. data/lib/samagotchi/self_report.rb +2 -2
  49. data/lib/samagotchi/send_command.rb +1 -1
  50. data/lib/samagotchi/session_continue.rb +275 -0
  51. data/lib/samagotchi/session_manager.rb +9 -253
  52. data/lib/samagotchi/session_metrics.rb +3 -1
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  54. data/lib/samagotchi/terminal_ui.rb +12 -2
  55. data/lib/samagotchi/tool_activity.rb +84 -14
  56. data/lib/samagotchi/tool_row_fields.rb +46 -0
  57. data/lib/samagotchi/tool_runner.rb +7 -11
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +26 -3
  60. data/lib/samagotchi/web/capabilities.rb +13 -9
  61. data/lib/samagotchi/web/editor.rb +38 -0
  62. data/lib/samagotchi/web/message_parts.rb +5 -10
  63. data/lib/samagotchi/web/public/activity.js +35 -18
  64. data/lib/samagotchi/web/public/app.js +240 -1136
  65. data/lib/samagotchi/web/public/commands.js +236 -0
  66. data/lib/samagotchi/web/public/composer.js +101 -0
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/diff_view.js +30 -4
  69. data/lib/samagotchi/web/public/index.html +9 -2
  70. data/lib/samagotchi/web/public/info_bar.js +78 -0
  71. data/lib/samagotchi/web/public/lists_view.js +725 -0
  72. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  73. data/lib/samagotchi/web/public/question_card.js +9 -1
  74. data/lib/samagotchi/web/public/question_cards.js +7 -4
  75. data/lib/samagotchi/web/public/refs.js +134 -0
  76. data/lib/samagotchi/web/public/restore.js +81 -0
  77. data/lib/samagotchi/web/public/session_state.js +52 -0
  78. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  79. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  80. data/lib/samagotchi/web/public/timing.js +8 -2
  81. data/lib/samagotchi/web/public/turn_events.js +17 -258
  82. data/lib/samagotchi/web/public/turn_html.js +15 -7
  83. data/lib/samagotchi/web/server.rb +4 -4
  84. metadata +17 -2
  85. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -3,7 +3,6 @@
3
3
  require "fileutils"
4
4
  require "digest"
5
5
  require_relative "../memory_paths"
6
- require_relative "bundle_hook"
7
6
  require_relative "provenance"
8
7
  require_relative "manifest"
9
8
  require_relative "source"
@@ -11,7 +10,7 @@ require_relative "placeholder"
11
10
  require_relative "index_updater"
12
11
  require_relative "merger"
13
12
  require_relative "trash"
14
- require_relative "../version"
13
+ require_relative "asset_installer"
15
14
  require_relative "../bundle_needs"
16
15
  require_relative "../model_overlay"
17
16
 
@@ -19,13 +18,15 @@ module Samagotchi
19
18
  module MemoryBundle
20
19
  # Installs a bundle into a scoped memories directory.
21
20
  #
22
- # Flow:
23
- # 1. Normalize source → directory (returns [path, owned])
24
- # 2. Read manifest.yml
25
- # 3. Copy .md files to target scope dir (skip or force)
26
- # 4. Verify checksums (strict mode)
27
- # 5. Update index.md for each entry
28
- # 6. Write provenance (.bundles/<name>/)
21
+ # Flow (#run):
22
+ # 1. Normalize source → directory, read manifest.yml (load_source)
23
+ # 2. Copy .md files to the target scope dir and update index.md
24
+ # (install_memories: plain, dry run, or an upgrade's 3-way merge)
25
+ # 3. Hooks, guardrail rules, plugin, scripts into .bundles/<name>/
26
+ # (AssetInstaller)
27
+ # 4. An upgrade prunes the files the bundle dropped
28
+ # 5. Verify checksums (strict mode), warn about missing needs
29
+ # 6. Write provenance (.bundles/<name>/manifest.json, write_provenance)
29
30
  # 7. Detect and report {{placeholders}}
30
31
  class Installer
31
32
  class InstallError < StandardError; end
@@ -52,8 +53,10 @@ module Samagotchi
52
53
  # none moved).
53
54
  def trash_dir = @trash.dir
54
55
 
55
- # The normalized source dir, kept alive past #run when an upgrade has
56
- # conflicts (the agent step reads the incoming files); nil once cleaned.
56
+ # The normalized source dir. An owned one (a git/zip/tar extract) is
57
+ # removed after #run, or kept until #cleanup_source! when an upgrade
58
+ # has conflicts (the agent step reads the incoming files); nil once
59
+ # cleaned.
57
60
  attr_reader :source_dir
58
61
 
59
62
  # Removes the normalized source dir an owned source (git/zip/tar) was
@@ -61,394 +64,49 @@ module Samagotchi
61
64
  # conflict prompt and resolve_conflicts can still read the incoming
62
65
  # files; the caller runs it once the agent step is done. Idempotent.
63
66
  def cleanup_source!
64
- return unless @defer_cleanup && @normalized_dir
67
+ return unless @defer_cleanup && @source_dir
65
68
 
66
- SourceNormalizer.cleanup(@normalized_dir)
67
- @normalized_dir = nil
69
+ SourceNormalizer.cleanup(@source_dir)
70
+ @source_dir = nil
68
71
  @defer_cleanup = false
69
72
  end
70
73
 
71
74
  def run
72
- normalized_dir = nil
73
- source_owned = false
74
- source_commit = nil
75
- manifest = nil
76
- begin
77
- normalized_dir, source_owned, source_commit = SourceNormalizer.normalize(@source)
78
- @bundle_dir = normalized_dir
79
- @normalized_dir = normalized_dir
80
- manifest = Manifest.read(dir: normalized_dir)
81
- rescue SourceNormalizer::UnknownSourceError => e
82
- raise InstallError, "Source normalization failed: #{e.message}"
83
- rescue Manifest::ValidationError => e
84
- raise InstallError, e.message if @strict
85
-
86
- @warnings << "No manifest found — proceeding without strict manifest validation"
87
- end
88
-
89
- # Determine target scope (CLI wins over manifest).
90
- cli_scope = @scope.to_s.strip.empty? ? nil : @scope
91
- target_scope = cli_scope || manifest&.scope || "system"
92
- target_dir = MemoryPaths.scope_dir(target_scope) or raise InstallError, "invalid scope: #{target_scope}"
93
- FileUtils.mkdir_p(target_dir)
94
-
95
- # Copy .md files and update index for each.
96
- @file_descriptions = manifest&.file_descriptions || {}
97
- all_files_in_bundle = []
98
- provenance = Provenance.new(name: @name)
99
- existing_provenance = provenance.read
75
+ load_source
76
+ @target_scope, @target_dir = resolve_target
77
+ @file_descriptions = @manifest&.file_descriptions || {}
78
+ @provenance = Provenance.new(name: @name)
79
+ @existing = @provenance.record
100
80
  # A plain install over an installed bundle: summary points at upgrade.
101
- @reinstall = existing_provenance && !@upgrade && !@force && !@dry_run
102
- # The bundle owns (records, upgrades, removes) only the files it
103
- # wrote: these, from an earlier install, and the ones written now.
104
- # A same-name file that was already there is the user's: skipped
105
- # and never recorded.
106
- owned_before = existing_provenance && existing_provenance[:files].is_a?(Hash) ? existing_provenance[:files].keys.map(&:to_s) : []
107
- owned = []
108
-
109
- Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
110
- file_key = File.basename(file_path)
111
- all_files_in_bundle << file_key
112
- note_overlay(file_key, target_dir)
113
- target_path = File.join(target_dir, file_key)
114
-
115
- if @upgrade && existing_provenance && !@force && File.exist?(target_path) && !owned_before.include?(file_key)
116
- # Not the bundle's (the user's, or another bundle's): no merge.
117
- skip_existing(file_key, file_path, target_path, target_scope)
118
- elsif @upgrade && existing_provenance && !@force && !@dry_run
119
- # 3-way merge path for upgrades
120
- owned << file_key
121
- base_path = provenance.base_path(file_key)
122
- classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
123
- case classification
124
- when :install
125
- FileUtils.cp(file_path, target_path)
126
- @results[file_key] = { status: "installed" }
127
- update_target_index(target_scope, target_path, file_key)
128
- when :fast_forward
129
- FileUtils.cp(file_path, target_path)
130
- @results[file_key] = { status: "updated", reason: "auto-merged (not edited)" }
131
- update_target_index(target_scope, target_path, file_key)
132
- when :keep
133
- @results[file_key] = { status: "kept", reason: "bundle unchanged, local edits preserved" }
134
- update_target_index(target_scope, target_path, file_key)
135
- when :noop
136
- @results[file_key] = { status: "skipped", reason: "already up to date" }
137
- update_target_index(target_scope, target_path, file_key)
138
- when :conflict
139
- @results[file_key] = { status: "conflict", reason: "local edits conflict with bundle update" }
140
- @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path }
141
- @warnings << "Conflict in #{file_key}: local edits conflict with bundle update (use --force to overwrite or resolve interactively)"
142
- update_target_index(target_scope, target_path, file_key)
143
- end
144
- elsif @dry_run
145
- # Dry-run: classify but do not write
146
- if File.exist?(target_path) && !@force
147
- if @upgrade && existing_provenance
148
- base_path = provenance.base_path(file_key)
149
- classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
150
- @results[file_key] = { status: classification.to_s }
151
- @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
152
- elsif FileUtils.identical?(file_path, target_path)
153
- @results[file_key] = { status: "skipped", reason: "already up to date" }
154
- else
155
- @results[file_key] = { status: "would_skip" }
156
- end
157
- else
158
- @results[file_key] = { status: "would_install" }
159
- end
160
- elsif File.exist?(target_path) && !@force
161
- # A re-install keeps what an earlier install wrote.
162
- owned << file_key if owned_before.include?(file_key)
163
- skip_existing(file_key, file_path, target_path, target_scope, owned: owned_before.include?(file_key))
164
- else
165
- FileUtils.cp(file_path, target_path) unless @dry_run
166
- owned << file_key
167
- @results[file_key] = { status: "installed" }
168
- # Update index for newly installed files.
169
- update_target_index(target_scope, target_path, file_key) unless @dry_run
170
- end
171
- end
172
-
173
- # ── Hooks: copy hooks/*.rb into <bundle_dir>/hooks/ ───────────────
174
- hooks_target = File.join(provenance.bundle_dir, "hooks")
175
- all_hooks_in_bundle = []
176
- hooks_files_for_provenance = {}
177
- if manifest
178
- hook_entries = manifest.hooks
179
- # Fallback: if manifest has no hooks but source has hooks/*.rb, discover them
180
- if hook_entries.empty?
181
- discovered = Dir.glob(File.join(normalized_dir, "hooks", "*.rb")).map { |p| File.basename(p) }
182
- discovered.each do |bn|
183
- # Build minimal metadata so copy still happens (event unknown -> skipped by loader but copied for integrity)
184
- hook_entries[bn] = BundleHook.new
185
- end
186
- end
187
-
188
- hook_entries.each do |basename, _meta|
189
- next unless basename.is_a?(String) && !basename.empty?
190
-
191
- all_hooks_in_bundle << basename
192
- src = File.join(normalized_dir, "hooks", basename)
193
- next unless File.exist?(src)
194
-
195
- dest = File.join(hooks_target, basename)
196
-
197
- if @dry_run
198
- @results[basename] = if File.exist?(dest) && !@force
199
- { status: "would_skip" }
200
- else
201
- { status: "would_install" }
202
- end
203
- next
204
- end
205
-
206
- # Local-edit detection for upgrade path (overwrite + warn)
207
- if @upgrade && existing_provenance && File.exist?(dest) && !@force
208
- prev_meta = existing_provenance[:hooks] ? (existing_provenance[:hooks][basename.to_sym] || existing_provenance[:hooks][basename]) : nil
209
- if prev_meta
210
- prev_sha = BundleHook.parse(prev_meta).sha256
211
- if File.exist?(dest) && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
212
- @warnings << "Hook #{basename} was locally modified; overwriting"
213
- end
214
- end
215
- end
216
-
217
- FileUtils.mkdir_p(hooks_target)
218
- unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
219
- FileUtils.cp(src, dest)
220
- @results[basename] = if unchanged
221
- { status: "skipped", reason: "already up to date" }
222
- elsif @upgrade && existing_provenance
223
- { status: "updated" }
224
- else
225
- { status: "installed" }
226
- end
227
- hooks_files_for_provenance[basename] = dest
228
- end
229
- else
230
- # No manifest (non-strict): still copy any hooks/*.rb if present
231
- Dir.glob(File.join(normalized_dir, "hooks", "*.rb")).each do |src|
232
- basename = File.basename(src)
233
- all_hooks_in_bundle << basename
234
- dest = File.join(hooks_target, basename)
235
- if @dry_run
236
- @results[basename] = { status: "would_install" }
237
- else
238
- FileUtils.mkdir_p(hooks_target)
239
- FileUtils.cp(src, dest)
240
- @results[basename] = { status: "installed" }
241
- hooks_files_for_provenance[basename] = dest
242
- end
243
- end
244
- end
245
-
246
- # Prune stale hooks (removed from bundle) — reuse bases pruning via provenance but also remove files
247
- if @upgrade && existing_provenance && existing_provenance[:hooks] && !@dry_run
248
- existing_provenance[:hooks].keys.each do |old_key|
249
- old_key_str = old_key.to_s
250
- next if all_hooks_in_bundle.include?(old_key_str)
251
-
252
- old_dest = File.join(hooks_target, old_key_str)
253
- if File.exist?(old_dest) && !@force
254
- @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
255
- end
256
- FileUtils.rm_f(old_dest)
257
- end
258
- end
259
-
260
- # Verify hook checksums in strict mode (mirror .md block)
261
- if manifest && @strict
262
- all_hooks_in_bundle.each do |basename|
263
- next if @conflicts.key?(basename)
264
-
265
- expected = manifest.checksum_for_hook(basename)
266
- next unless expected
267
-
268
- dest = File.join(hooks_target, basename)
269
- next unless File.exist?(dest)
270
-
271
- actual = Digest::SHA256.hexdigest(File.read(dest))
272
- if actual != expected
273
- @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
274
- end
275
- end
276
- end
277
-
278
- # ── Guardrail rules: guardrails/*.yml into <bundle_dir>/guardrails/ ─
279
- # The bundle's set replaces what an earlier version installed.
280
- guardrails_target = provenance.guardrails_dir
281
- guardrail_files_for_provenance = {}
282
- incoming_rules = Dir.glob(File.join(normalized_dir, "guardrails", "*.yml")).sort
283
- if @dry_run
284
- incoming_rules.each { |src| @results["guardrails/#{File.basename(src)}"] = { status: "would_install" } }
285
- else
286
- # Same bytes as installed: reported as up to date, not "Installed".
287
- unchanged = incoming_rules.select do |src|
288
- dest = File.join(guardrails_target, File.basename(src))
289
- File.file?(dest) && FileUtils.identical?(src, dest)
290
- end
291
- FileUtils.rm_rf(guardrails_target)
292
- unless incoming_rules.empty?
293
- FileUtils.mkdir_p(guardrails_target)
294
- incoming_rules.each do |src|
295
- dest = File.join(guardrails_target, File.basename(src))
296
- FileUtils.cp(src, dest)
297
- @results["guardrails/#{File.basename(src)}"] =
298
- unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
299
- guardrail_files_for_provenance[File.basename(src)] = dest
300
- end
301
- end
302
- end
81
+ @reinstall = @existing && !@upgrade && !@force && !@dry_run
82
+ install_memories
303
83
 
304
- # ── Plugin: <file> into <bundle_dir>/plugin/ (docs/plugins.md) ────
305
- # Like the rules, the bundle's plugin replaces an earlier one.
306
- plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
307
- # ── Scripts: scripts/<file> into <bundle_dir>/scripts/ ────────────
308
- scripts_for_provenance = install_scripts(manifest, normalized_dir, provenance)
309
- warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
310
- warn_rules_requires_chi(manifest, incoming_rules)
84
+ installed = AssetInstaller.new(
85
+ name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
86
+ results: @results, warnings: @warnings, conflicts: @conflicts,
87
+ force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run
88
+ ).run
311
89
 
312
90
  # Files the previous version shipped and this one doesn't.
313
- if @upgrade && existing_provenance && existing_provenance[:files]
314
- prune_dropped_files(existing_provenance[:files], all_files_in_bundle, target_dir, target_scope, provenance)
315
- end
316
-
317
- # Verify checksums if we have a manifest (strict).
318
- if manifest && @strict
319
- all_files_in_bundle.each do |file_key|
320
- next if @conflicts.key?(file_key)
321
-
322
- status = @results[file_key] ? @results[file_key][:status].to_s : nil
323
- # skipped: not copied, so a local edit isn't a bad download.
324
- next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
325
-
326
- # For dry-run, fast_forward would appear as "fast_forward", updated not yet
327
- target_path = File.join(target_dir, file_key)
328
- next unless File.exist?(target_path)
329
-
330
- expected = manifest.checksum_for(file_key)
331
- next unless expected
332
-
333
- actual = Digest::SHA256.hexdigest(File.read(target_path))
334
- if actual != expected
335
- @warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
336
- end
337
- end
91
+ prune_dropped_files(@existing.files, @bundle_md, @target_dir, @target_scope, @provenance) if @upgrade && @existing
92
+ verify_memory_checksums if @manifest && @strict
93
+ warn_missing_needs(@manifest) if @manifest
94
+ if @manifest && !@dry_run
95
+ write_provenance(installed)
338
96
  end
97
+ detect_placeholders
339
98
 
340
- warn_missing_needs(manifest) if manifest
341
-
342
- # Write provenance (only if we had a manifest and not dry_run). An
343
- # upgrade with conflicts writes it too: the new version, the plugin's
344
- # and hooks' new shas, and the old base for each conflicted file,
345
- # marked conflict (chi bundle status shows it). Skipping it left the
346
- # replaced plugin failing its sha check.
347
- if manifest && !@dry_run
348
- provenance_files = {}
349
- if @upgrade && existing_provenance
350
- owned.each do |file_key|
351
- target_path = File.join(target_dir, file_key)
352
- next unless File.exist?(target_path)
353
-
354
- status = @results[file_key] ? @results[file_key][:status].to_s : nil
355
- if %w[kept noop conflict kept_pruned].include?(status)
356
- # Keep old base snapshot — do not overwrite with edited current
357
- # Use base snapshot as source if it exists to preserve old checksum
358
- base_path = provenance.base_path(file_key)
359
- provenance_files[file_key] = if File.exist?(base_path)
360
- # Keep existing provenance entry by re-using base content
361
- # We still need to include it to prevent pruning, but with base content
362
- base_path
363
- else
364
- target_path
365
- end
366
- else
367
- # installed, updated, skipped — use current target (which is incoming for updated)
368
- provenance_files[file_key] = target_path
369
- end
370
- end
371
- # Handle pruned but kept files (bundle removed file but local kept)
372
- if existing_provenance[:files]
373
- existing_provenance[:files].keys.each do |old_key|
374
- old_key_str = old_key.to_s
375
- next if all_files_in_bundle.include?(old_key_str)
376
-
377
- # If it was kept_pruned, include it with base content to prevent pruning
378
- next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
379
-
380
- base_path = provenance.base_path(old_key_str)
381
- target_path = File.join(target_dir, old_key_str)
382
- # Use base if exists to keep old checksum, else target
383
- src = File.exist?(base_path) ? base_path : target_path
384
- provenance_files[old_key_str] = src if File.exist?(src)
385
- end
386
- end
387
- else
388
- owned.each do |file_key|
389
- target_path = File.join(target_dir, file_key)
390
- next unless File.exist?(target_path)
391
-
392
- # Skipped for a local edit: keep the installed base, so the
393
- # next upgrade still sees the edit (not a fast-forward over it).
394
- base_path = provenance.base_path(file_key)
395
- kept = existing_provenance && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
396
- provenance_files[file_key] = kept ? base_path : target_path
397
- end
398
- end
399
- # Build hooks metadata for provenance
400
- hooks_for_provenance = {}
401
- if manifest && manifest.hooks && !manifest.hooks.empty?
402
- hooks_for_provenance = manifest.hooks
403
- elsif hooks_files_for_provenance.any?
404
- hooks_files_for_provenance.each do |basename, path|
405
- next unless File.exist?(path)
406
-
407
- hooks_for_provenance[basename] = BundleHook.of_file(path)
408
- end
409
- end
410
- Provenance.new(name: @name).write(
411
- files: provenance_files,
412
- scope: target_scope,
413
- version: manifest.version,
414
- source_path: @source,
415
- hooks: hooks_for_provenance,
416
- trust_level: manifest.trust_level,
417
- source_commit: source_commit,
418
- hooks_files: hooks_files_for_provenance,
419
- guardrails_files: guardrail_files_for_provenance,
420
- plugin_file: plugin_file_for_provenance,
421
- requires_chi: manifest.requires_chi,
422
- needs: manifest.needs,
423
- conflicts: @conflicts.keys,
424
- scripts_files: scripts_for_provenance,
425
- context_providers: manifest.context_providers,
426
- file_descriptions: manifest.file_descriptions
427
- )
428
- end
429
-
430
- # Detect placeholders.
431
- all_files_in_bundle.each do |file_key|
432
- file_path = File.join(target_dir, file_key)
433
- next unless File.exist?(file_path)
434
-
435
- names = Placeholder.detect_in_file(file_path)
436
- if names.any?
437
- @placeholder_warnings << "#{file_key}: {{#{names.join("}}, {{")}}}"
438
- end
439
- end
440
-
441
- [normalized_dir, manifest]
99
+ [@bundle_dir, @manifest]
442
100
  ensure
443
101
  # An owned source (git/zip/tar) is cleaned up here, unless an upgrade
444
102
  # kept conflicts: the agent step still needs the incoming files, so
445
103
  # the caller runs #cleanup_source! when it is done.
446
- if source_owned
104
+ if @source_owned
447
105
  if @conflicts.any? && !@dry_run
448
106
  @defer_cleanup = true
449
107
  else
450
- SourceNormalizer.cleanup(normalized_dir)
451
- @normalized_dir = nil
108
+ SourceNormalizer.cleanup(@source_dir)
109
+ @source_dir = nil
452
110
  end
453
111
  end
454
112
  end
@@ -483,6 +141,217 @@ module Samagotchi
483
141
 
484
142
  private
485
143
 
144
+ # The source normalized to a dir (@bundle_dir; an owned one, extracted
145
+ # from git/zip/tar, is cleaned up after #run) and its manifest.yml
146
+ # (@manifest, nil when it has none and the install isn't strict).
147
+ def load_source
148
+ @bundle_dir, @source_owned, @source_commit = SourceNormalizer.normalize(@source)
149
+ @source_dir = @bundle_dir
150
+ @manifest = Manifest.read(dir: @bundle_dir)
151
+ rescue SourceNormalizer::UnknownSourceError => e
152
+ raise InstallError, "Source normalization failed: #{e.message}"
153
+ rescue Manifest::ValidationError => e
154
+ raise InstallError, e.message if @strict
155
+
156
+ @warnings << "No manifest found — proceeding without strict manifest validation"
157
+ end
158
+
159
+ # The scope the files go to (the CLI's wins over the manifest's) and
160
+ # its dir, created.
161
+ # @return [Array(String, String)] scope, dir
162
+ def resolve_target
163
+ cli_scope = @scope.to_s.strip.empty? ? nil : @scope
164
+ scope = cli_scope || @manifest&.scope || "system"
165
+ dir = MemoryPaths.scope_dir(scope) or raise InstallError, "invalid scope: #{scope}"
166
+ FileUtils.mkdir_p(dir)
167
+ [scope, dir]
168
+ end
169
+
170
+ # Copies the bundle's *.md files into the target dir and refreshes
171
+ # their index lines: an upgrade merges (merge_memory), a dry run only
172
+ # classifies (preview_memory), a plain install writes the new ones.
173
+ # Sets @bundle_md (every file the bundle ships) and @owned: the bundle
174
+ # owns (records, upgrades, removes) only the files it wrote, these
175
+ # from an earlier install and the ones written now. A same-name file
176
+ # that was already there is the user's: skipped and never recorded.
177
+ def install_memories
178
+ owned_before = @existing ? @existing.files.keys : []
179
+ @bundle_md = []
180
+ @owned = []
181
+ Dir.glob(File.join(@bundle_dir, "*.md")).each do |file_path|
182
+ file_key = File.basename(file_path)
183
+ @bundle_md << file_key
184
+ note_overlay(file_key, @target_dir)
185
+ target_path = File.join(@target_dir, file_key)
186
+ merging = @upgrade && @existing && !@force
187
+
188
+ if merging && File.exist?(target_path) && !owned_before.include?(file_key)
189
+ # Not the bundle's (the user's, or another bundle's): no merge.
190
+ skip_existing(file_key, file_path, target_path, @target_scope)
191
+ elsif merging && !@dry_run
192
+ @owned << file_key
193
+ merge_memory(file_key, file_path, target_path)
194
+ elsif @dry_run
195
+ preview_memory(file_key, file_path, target_path)
196
+ elsif File.exist?(target_path) && !@force
197
+ # A re-install keeps what an earlier install wrote.
198
+ @owned << file_key if owned_before.include?(file_key)
199
+ skip_existing(file_key, file_path, target_path, @target_scope, owned: owned_before.include?(file_key))
200
+ else
201
+ FileUtils.cp(file_path, target_path)
202
+ @owned << file_key
203
+ @results[file_key] = { status: "installed" }
204
+ update_target_index(@target_scope, target_path, file_key)
205
+ end
206
+ end
207
+ end
208
+
209
+ # An upgrade's 3-way merge of one file (Merger.classify against the
210
+ # base the last install recorded); a conflict leaves the local file
211
+ # and is recorded in @conflicts for the agent step.
212
+ def merge_memory(file_key, file_path, target_path)
213
+ base_path = @provenance.base_path(file_key)
214
+ case Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
215
+ when :install
216
+ FileUtils.cp(file_path, target_path)
217
+ @results[file_key] = { status: "installed" }
218
+ when :fast_forward
219
+ FileUtils.cp(file_path, target_path)
220
+ @results[file_key] = { status: "updated", reason: "auto-merged (not edited)" }
221
+ when :keep
222
+ @results[file_key] = { status: "kept", reason: "bundle unchanged, local edits preserved" }
223
+ when :noop
224
+ @results[file_key] = { status: "skipped", reason: "already up to date" }
225
+ when :conflict
226
+ @results[file_key] = { status: "conflict", reason: "local edits conflict with bundle update" }
227
+ @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path }
228
+ @warnings << "Conflict in #{file_key}: local edits conflict with bundle update (use --force to overwrite or resolve interactively)"
229
+ end
230
+ update_target_index(@target_scope, target_path, file_key)
231
+ end
232
+
233
+ # A dry run's verdict for one file, nothing written: an upgrade
234
+ # classifies as merge_memory would (its status is the classification).
235
+ def preview_memory(file_key, file_path, target_path)
236
+ unless File.exist?(target_path) && !@force
237
+ @results[file_key] = { status: "would_install" }
238
+ return
239
+ end
240
+
241
+ if @upgrade && @existing
242
+ base_path = @provenance.base_path(file_key)
243
+ classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
244
+ @results[file_key] = { status: classification.to_s }
245
+ @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
246
+ elsif FileUtils.identical?(file_path, target_path)
247
+ @results[file_key] = { status: "skipped", reason: "already up to date" }
248
+ else
249
+ @results[file_key] = { status: "would_skip" }
250
+ end
251
+ end
252
+
253
+ # Strict mode: a memory written from the bundle whose sha256 differs
254
+ # from its manifest's warns. One that wasn't copied (kept, skipped:
255
+ # a local edit isn't a bad download) or was merged isn't checked.
256
+ def verify_memory_checksums
257
+ @bundle_md.each do |file_key|
258
+ next if @conflicts.key?(file_key)
259
+
260
+ status = @results[file_key] ? @results[file_key][:status].to_s : nil
261
+ next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
262
+
263
+ target_path = File.join(@target_dir, file_key)
264
+ next unless File.exist?(target_path)
265
+
266
+ expected = @manifest.checksum_for(file_key)
267
+ next unless expected
268
+
269
+ actual = Digest::SHA256.hexdigest(File.read(target_path))
270
+ if actual != expected
271
+ @warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
272
+ end
273
+ end
274
+ end
275
+
276
+ # One line per memory with {{placeholders}} left to fill in.
277
+ def detect_placeholders
278
+ @bundle_md.each do |file_key|
279
+ file_path = File.join(@target_dir, file_key)
280
+ next unless File.exist?(file_path)
281
+
282
+ names = Placeholder.detect_in_file(file_path)
283
+ @placeholder_warnings << "#{file_key}: {{#{names.join("}}, {{")}}}" if names.any?
284
+ end
285
+ end
286
+
287
+ # The install's record (Provenance#write), only with a manifest and
288
+ # not on a dry run. An upgrade with conflicts writes it too: the new
289
+ # version, the plugin's and hooks' new shas, and the old base for each
290
+ # conflicted file, marked conflict (chi bundle status shows it).
291
+ # Skipping it left the replaced plugin failing its sha check.
292
+ # @param installed [AssetInstaller::Installed]
293
+ def write_provenance(installed)
294
+ @provenance.write(
295
+ files: @upgrade && @existing ? upgrade_provenance_files : install_provenance_files,
296
+ scope: @target_scope,
297
+ version: @manifest.version,
298
+ source_path: @source,
299
+ # AssetInstaller added the discovered hooks/*.rb to manifest.hooks.
300
+ hooks: @manifest.hooks,
301
+ trust_level: @manifest.trust_level,
302
+ source_commit: @source_commit,
303
+ hooks_files: installed.hook_files,
304
+ guardrails_files: installed.guardrail_files,
305
+ plugin_file: installed.plugin_file,
306
+ requires_chi: @manifest.requires_chi,
307
+ needs: @manifest.needs,
308
+ conflicts: @conflicts.keys,
309
+ scripts_files: installed.script_files,
310
+ context_providers: @manifest.context_providers,
311
+ file_descriptions: @manifest.file_descriptions
312
+ )
313
+ end
314
+
315
+ # file key => the file whose content becomes its base. A file the
316
+ # upgrade left as the user has it (kept, noop, conflict, kept_pruned)
317
+ # keeps its old base, so the next upgrade still sees the local edit;
318
+ # the rest (installed, updated, skipped) take what is on disk now. A
319
+ # file the bundle dropped but the user kept stays recorded too.
320
+ def upgrade_provenance_files
321
+ files = {}
322
+ @owned.each do |file_key|
323
+ target_path = File.join(@target_dir, file_key)
324
+ next unless File.exist?(target_path)
325
+
326
+ status = @results[file_key] ? @results[file_key][:status].to_s : nil
327
+ base_path = @provenance.base_path(file_key)
328
+ files[file_key] = %w[kept noop conflict kept_pruned].include?(status) && File.exist?(base_path) ? base_path : target_path
329
+ end
330
+ @existing.files.each_key do |old_key_str|
331
+ next if @bundle_md.include?(old_key_str)
332
+ next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
333
+
334
+ base_path = @provenance.base_path(old_key_str)
335
+ src = File.exist?(base_path) ? base_path : File.join(@target_dir, old_key_str)
336
+ files[old_key_str] = src if File.exist?(src)
337
+ end
338
+ files
339
+ end
340
+
341
+ # file key => its base's content source for a plain install. Skipped
342
+ # for a local edit: keep the installed base, so the next upgrade still
343
+ # sees the edit (not a fast-forward over it).
344
+ def install_provenance_files
345
+ @owned.each_with_object({}) do |file_key, files|
346
+ target_path = File.join(@target_dir, file_key)
347
+ next unless File.exist?(target_path)
348
+
349
+ base_path = @provenance.base_path(file_key)
350
+ kept = @existing && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
351
+ files[file_key] = kept ? base_path : target_path
352
+ end
353
+ end
354
+
486
355
  # A file already in the target dir that the install doesn't write: the
487
356
  # same bytes are "already up to date", others are skipped with a
488
357
  # --force hint (a dry run: would_skip). Its index line is refreshed
@@ -505,8 +374,7 @@ module Samagotchi
505
374
  # matches what was installed, or with --force; one the user edited is
506
375
  # kept with a note.
507
376
  def prune_dropped_files(previous_files, bundle_files, target_dir, scope, provenance)
508
- previous_files.each do |old_key, meta|
509
- key = old_key.to_s
377
+ previous_files.each do |key, meta|
510
378
  next if bundle_files.include?(key)
511
379
 
512
380
  target = File.join(target_dir, key)
@@ -532,7 +400,7 @@ module Samagotchi
532
400
  # Whether the file on disk is what the previous install wrote: its
533
401
  # recorded checksum, else the base snapshot. Unknown counts as edited.
534
402
  def installed_unchanged?(target, meta, base_path)
535
- recorded = meta.is_a?(Hash) ? meta[:checksum] || meta["checksum"] : nil
403
+ recorded = meta[:checksum]
536
404
  return Provenance.sha_matches?(target, recorded) unless Provenance.recorded_sha(recorded).empty?
537
405
 
538
406
  File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
@@ -555,110 +423,6 @@ module Samagotchi
555
423
  end
556
424
  end
557
425
 
558
- # The hook loader skips every hook of a bundle whose requires_chi this
559
- # chi doesn't meet (Hooks::BundleLoader); say so at install, as
560
- # install_plugin does for the plugin.
561
- def warn_hooks_requires_chi(manifest, hook_files)
562
- return if manifest.nil? || hook_files.empty?
563
-
564
- if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
565
- @warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
566
- end
567
- end
568
-
569
- # GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
570
- # requires_chi this chi doesn't meet, and a session that never loaded
571
- # them records a required load failure (the gate refuses guarded calls
572
- # until chi is updated): say so at install, as the hooks warning does.
573
- def warn_rules_requires_chi(manifest, rule_files)
574
- return if manifest.nil? || rule_files.empty?
575
-
576
- if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
577
- @warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
578
- "(chi update), guarded tool calls are refused"
579
- end
580
- end
581
-
582
- # Copy the manifest's plugin file into the bundle's plugin/ dir,
583
- # warning when its sha256 differs from the declared one or this chi
584
- # doesn't meet requires_chi (the Engine then won't load it).
585
- # @return [String, nil] the installed file (nil: none, or a dry run)
586
- def install_plugin(manifest, source_dir, provenance)
587
- plugin = manifest&.plugin
588
- unless plugin
589
- FileUtils.rm_rf(provenance.plugin_dir) unless @dry_run
590
- return nil
591
- end
592
-
593
- src = File.join(source_dir, plugin[:file])
594
- raise InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
595
-
596
- if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
597
- @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
598
- end
599
- if @dry_run
600
- @results[plugin[:file]] = { status: "would_install" }
601
- return nil
602
- end
603
-
604
- dest = File.join(provenance.plugin_dir, plugin[:file])
605
- unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
606
- FileUtils.rm_rf(provenance.plugin_dir)
607
- FileUtils.mkdir_p(provenance.plugin_dir)
608
- FileUtils.cp(src, dest)
609
- @results[plugin[:file]] = if unchanged
610
- { status: "skipped", reason: "already up to date" }
611
- elsif @upgrade && provenance.read
612
- { status: "updated" }
613
- else
614
- { status: "installed" }
615
- end
616
- expected = manifest.checksum_for_plugin
617
- actual = Digest::SHA256.hexdigest(File.binread(dest))
618
- if @strict && expected && actual != expected
619
- @warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
620
- end
621
- dest
622
- end
623
-
624
- # The manifest's scripts, copied into <bundle_dir>/scripts/ (the
625
- # folder replaced: a script the new version dropped goes). A declared
626
- # sha256 that differs warns, as a plugin's does.
627
- # @return [Hash{String => String}] file name => installed path
628
- def install_scripts(manifest, source_dir, provenance)
629
- scripts = manifest&.scripts || {}
630
- scripts.each_key do |file|
631
- next if File.file?(File.join(source_dir, "scripts", file))
632
-
633
- raise InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have"
634
- end
635
- return {} if @dry_run
636
-
637
- unchanged = scripts.keys.select do |file|
638
- dest = File.join(provenance.scripts_dir, file)
639
- File.file?(dest) && FileUtils.identical?(File.join(source_dir, "scripts", file), dest)
640
- end
641
- FileUtils.rm_rf(provenance.scripts_dir)
642
- return {} if scripts.empty?
643
-
644
- FileUtils.mkdir_p(provenance.scripts_dir)
645
- scripts.to_h do |file, declared|
646
- dest = File.join(provenance.scripts_dir, file)
647
- FileUtils.cp(File.join(source_dir, "scripts", file), dest)
648
- @results["scripts/#{file}"] = if unchanged.include?(file) then { status: "skipped", reason: "already up to date" }
649
- elsif @upgrade && provenance.read then { status: "updated" }
650
- else { status: "installed" }
651
- end
652
- FileUtils.chmod(0o755, dest)
653
- actual = Digest::SHA256.hexdigest(File.binread(dest))
654
- expected = declared.to_s.delete_prefix("sha256:")
655
- if @strict && !expected.empty? && actual != expected
656
- @warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
657
- end
658
- [file, dest]
659
- end
660
- end
661
-
662
426
  # A model overlay (<name>.<key>.md, docs/memory.md) loads only with
663
427
  # its base, under the matching model: it gets no index line (every
664
428
  # model would see it as a memory of its own). Its base is looked for