samagotchi 0.151.0 → 0.153.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/docs/bundle-authoring.md +5 -5
  4. data/docs/bundles.md +84 -0
  5. data/docs/cli.md +35 -10
  6. data/docs/configuration.md +19 -14
  7. data/docs/guardrails.md +11 -5
  8. data/docs/hooks.md +4 -3
  9. data/docs/internals/anthropic.md +3 -3
  10. data/docs/internals/attached-context.md +3 -0
  11. data/docs/internals/block-renderers.md +3 -1
  12. data/docs/internals/bundles.md +48 -14
  13. data/docs/internals/engine.md +9 -5
  14. data/docs/internals/objectives.md +6 -4
  15. data/docs/internals/participants.md +1 -1
  16. data/docs/internals/prompt-caching.md +7 -3
  17. data/docs/internals/web-frontend.md +20 -17
  18. data/docs/memory.md +6 -1
  19. data/docs/plugins.md +80 -29
  20. data/docs/releasing.md +8 -3
  21. data/docs/sessions.md +2 -2
  22. data/docs/testing.md +2 -2
  23. data/lib/samagotchi/block_renderers.rb +9 -8
  24. data/lib/samagotchi/bundle_command/check.rb +43 -3
  25. data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
  27. data/lib/samagotchi/bundle_command.rb +88 -10
  28. data/lib/samagotchi/bundle_needs.rb +4 -6
  29. data/lib/samagotchi/bundle_settings_check.rb +14 -13
  30. data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
  38. data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
  39. data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
  40. data/lib/samagotchi/child_env.rb +15 -0
  41. data/lib/samagotchi/context_providers.rb +42 -10
  42. data/lib/samagotchi/engine.rb +37 -9
  43. data/lib/samagotchi/extension_load.rb +4 -3
  44. data/lib/samagotchi/guardrail_wiring.rb +28 -19
  45. data/lib/samagotchi/guardrails/approval.rb +2 -2
  46. data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
  47. data/lib/samagotchi/guardrails/rules.rb +1 -0
  48. data/lib/samagotchi/guardrails/verdict.rb +20 -0
  49. data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
  50. data/lib/samagotchi/llm_context_stale.rb +2 -2
  51. data/lib/samagotchi/memory_bundle/asset_installer.rb +11 -8
  52. data/lib/samagotchi/memory_bundle/builder.rb +36 -30
  53. data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
  54. data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
  55. data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -46
  57. data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -15
  58. data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
  59. data/lib/samagotchi/memory_bundle/installer.rb +90 -16
  60. data/lib/samagotchi/memory_bundle/manifest.rb +146 -6
  61. data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
  62. data/lib/samagotchi/memory_bundle/merger.rb +7 -8
  63. data/lib/samagotchi/memory_bundle/profile.rb +3 -2
  64. data/lib/samagotchi/memory_bundle/provenance.rb +217 -18
  65. data/lib/samagotchi/memory_bundle/shas.rb +4 -3
  66. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
  67. data/lib/samagotchi/memory_bundle/status.rb +11 -10
  68. data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
  69. data/lib/samagotchi/message_parts.rb +3 -2
  70. data/lib/samagotchi/plugin/loader.rb +1 -1
  71. data/lib/samagotchi/session_config.rb +5 -4
  72. data/lib/samagotchi/system_one/record.rb +10 -2
  73. data/lib/samagotchi/toolkit.rb +8 -5
  74. data/lib/samagotchi/toolkit_binding.rb +30 -5
  75. data/lib/samagotchi/toolkit_report_command.rb +9 -5
  76. data/lib/samagotchi/toolkit_suggest.rb +12 -6
  77. data/lib/samagotchi/tools/builtin_calls.rb +14 -10
  78. data/lib/samagotchi/tools/deferred_tools.rb +2 -2
  79. data/lib/samagotchi/tools/delegate.rb +20 -2
  80. data/lib/samagotchi/tools/registry.rb +44 -20
  81. data/lib/samagotchi/tools/tool_index.rb +27 -21
  82. data/lib/samagotchi/update_command.rb +13 -2
  83. data/lib/samagotchi/version.rb +1 -1
  84. data/lib/samagotchi/web/app.rb +2 -1
  85. data/lib/samagotchi/web/public/app.js +61 -357
  86. data/lib/samagotchi/web/public/composer_mode.js +110 -0
  87. data/lib/samagotchi/web/public/owner_claim.js +81 -0
  88. data/lib/samagotchi/web/public/owner_verify.js +112 -0
  89. data/lib/samagotchi/web/public/session_fields.js +3 -3
  90. data/lib/samagotchi/web/public/strip_grip.js +3 -3
  91. data/lib/samagotchi/web/public/strip_hidden.js +65 -0
  92. data/lib/samagotchi/web/public/version_notices.js +132 -0
  93. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  94. data/lib/samagotchi/web/session_hub.rb +6 -0
  95. data/lib/samagotchi/worker.rb +6 -0
  96. metadata +11 -1
@@ -4,10 +4,14 @@ require "json"
4
4
  require "digest"
5
5
  require "date"
6
6
  require "fileutils"
7
+ require "tmpdir"
7
8
  require_relative "../atomic_file"
8
9
  require_relative "../memory_paths"
10
+ require_relative "../version"
9
11
  require_relative "bundle_hook"
12
+ require_relative "file_digest"
10
13
  require_relative "installed_bundle"
14
+ require_relative "manifest"
11
15
 
12
16
  module Samagotchi
13
17
  module MemoryBundle
@@ -15,6 +19,9 @@ module Samagotchi
15
19
  # Each installed bundle gets a directory with:
16
20
  # manifest.json — metadata about the install (merged on reinstall)
17
21
  # bases/ — snapshots of every file as installed (for 3-way merge later)
22
+ # manifest.yml — the bundle's own manifest as installed (#write's
23
+ # manifest_from:), its sha in the record; read live
24
+ # through InstalledManifest
18
25
  class Provenance
19
26
  def self.bundles_dir
20
27
  MemoryPaths.bundles_dir
@@ -59,6 +66,11 @@ module Samagotchi
59
66
  File.join(@bundle_dir, "scripts")
60
67
  end
61
68
 
69
+ # The bundle's manifest.yml as installed (InstalledBundle#kept_manifest_path).
70
+ def kept_manifest_path
71
+ File.join(@bundle_dir, "manifest.yml")
72
+ end
73
+
62
74
  # The plugin file as installed, or nil when the bundle has none.
63
75
  # @param bundle [InstalledBundle, nil] its record (read when not given)
64
76
  def plugin_path(bundle = record)
@@ -100,7 +112,7 @@ module Samagotchi
100
112
  end
101
113
  next if holding && !(data.is_a?(Hash) && data[holding].is_a?(Hash) && !data[holding].empty?)
102
114
 
103
- yield name, InstalledBundle.parse(name, data)
115
+ yield name, InstalledBundle.parse(name, data, File.join(dir, name))
104
116
  end
105
117
  end
106
118
 
@@ -124,8 +136,11 @@ module Samagotchi
124
136
  value.to_s.delete_prefix("sha256:")
125
137
  end
126
138
 
139
+ # The digest of a file, nil when it isn't there (FileDigest). Every
140
+ # caller checks existence first; #sha_matches? reads a missing file as
141
+ # not the recorded one.
127
142
  def self.file_sha(path)
128
- Digest::SHA256.hexdigest(File.binread(path))
143
+ FileDigest.hex(path)
129
144
  end
130
145
 
131
146
  # What a dev install (`chi bundle install --dev`) records instead of
@@ -144,6 +159,24 @@ module Samagotchi
144
159
  unchecked?(recorded) || file_sha(path) == recorded_sha(recorded)
145
160
  end
146
161
 
162
+ # The record's mirror of the manifest keys (InstalledBundle::MIRROR_KEYS)
163
+ # as #write records them from its kwargs: what an older chi reads, and
164
+ # what #adopt_manifest holds a source's manifest against.
165
+ def self.mirror_fields(trust_level:, requires_chi:, needs:, context_providers:, block_renderers:, session_settings:,
166
+ session_setting_types:, settings:, setting_types:)
167
+ fields = {}
168
+ fields["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
169
+ fields["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
170
+ fields["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } if needs.is_a?(Array) && !needs.empty?
171
+ fields["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
172
+ fields["block_renderers"] = block_renderers.map(&:to_h) unless block_renderers.empty?
173
+ fields["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
174
+ fields["session_setting_types"] = session_setting_types.to_h { |k, v| [k.to_s, v.to_s] } unless session_setting_types.to_h.empty?
175
+ fields["settings"] = settings.map(&:to_s) unless settings.nil?
176
+ fields["setting_types"] = setting_types.to_h { |k, v| [k.to_s, v.to_s] } unless setting_types.to_h.empty?
177
+ fields
178
+ end
179
+
147
180
  # Writes provenance for an installation: merges into existing manifest.json
148
181
  # and saves base snapshots. Stale base snapshots (files removed from bundle)
149
182
  # are pruned.
@@ -175,10 +208,18 @@ module Samagotchi
175
208
  # @param block_renderers [Array<BlockRenderers::Renderer>]
176
209
  # @param dev [Boolean] a dev install: its files are links to the
177
210
  # source, recorded as DEV_SHA (no sha256 check) with "dev": true
211
+ # @param manifest_from [String, nil] the source dir: its manifest.yml is
212
+ # kept at #kept_manifest_path (a copy, a link for a dev install),
213
+ # recorded as manifest_sha (DEV_SHA for dev), with installed_by: this
214
+ # chi's VERSION. nil leaves the kept file, and the record's
215
+ # manifest_sha and installed_by, as they are. The keys a record mirrors
216
+ # (requires_chi … setting_types) are still written from the kwargs:
217
+ # an older chi sharing this memories dir reads only them
218
+ # (docs/internals/bundles.md); a key a later manifest gains is never added
178
219
  def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
179
220
  guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, marks: [], incoming_dir: nil, includes: nil,
180
221
  scripts_files: {}, context_providers: [], block_renderers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {},
181
- dev: false)
222
+ dev: false, manifest_from: nil)
182
223
  FileUtils.mkdir_p(@bundle_dir)
183
224
  bases_dir = File.join(@bundle_dir, "bases")
184
225
  FileUtils.mkdir_p(bases_dir)
@@ -265,36 +306,32 @@ module Samagotchi
265
306
  "files" => merged_entries,
266
307
  "hooks" => hooks_map
267
308
  }
268
- manifest_data["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
269
309
  manifest_data["dev"] = true if dev
270
310
  if guardrails_files.is_a?(Hash) && !guardrails_files.empty?
271
311
  manifest_data["guardrails"] = guardrails_files.sort.to_h do |basename, path|
272
- [basename.to_s, { "sha256" => dev ? DEV_SHA : "sha256:#{Digest::SHA256.hexdigest(File.read(path.to_s))}" }]
312
+ [basename.to_s, { "sha256" => dev ? DEV_SHA : FileDigest.sha256(path.to_s) }]
273
313
  end
274
314
  end
275
315
  manifest_data["source_commit"] = source_commit.to_s if source_commit && !source_commit.to_s.empty?
276
316
  FileUtils.rm_rf(File.join(bases_dir, "plugin"))
277
317
  if plugin_file
278
- content = File.binread(plugin_file.to_s)
318
+ content = File.binread(plugin_file.to_s) # the base snapshot's bytes
279
319
  manifest_data["plugin"] = { "file" => File.basename(plugin_file.to_s),
280
- "sha256" => dev ? DEV_SHA : "sha256:#{Digest::SHA256.hexdigest(content)}" }
320
+ "sha256" => dev ? DEV_SHA : FileDigest.sha256(plugin_file.to_s) }
281
321
  FileUtils.mkdir_p(File.join(bases_dir, "plugin"))
282
322
  File.binwrite(plugin_base_path(File.basename(plugin_file.to_s)), content)
283
323
  end
284
- manifest_data["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
285
- manifest_data["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } if needs.is_a?(Array) && !needs.empty?
324
+ manifest_data.merge!(self.class.mirror_fields(trust_level: trust_level, requires_chi: requires_chi, needs: needs,
325
+ context_providers: context_providers, block_renderers: block_renderers,
326
+ session_settings: session_settings, session_setting_types: session_setting_types,
327
+ settings: settings, setting_types: setting_types))
286
328
  manifest_data["includes"] = includes.map(&:to_s) if includes.is_a?(Array)
287
329
  unless scripts_files.empty?
288
330
  manifest_data["scripts"] = scripts_files.sort.to_h do |file, path|
289
- [file.to_s, { "sha256" => dev ? DEV_SHA : "sha256:#{Digest::SHA256.hexdigest(File.binread(path.to_s))}" }]
331
+ [file.to_s, { "sha256" => dev ? DEV_SHA : FileDigest.sha256(path.to_s) }]
290
332
  end
291
333
  end
292
- manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
293
- manifest_data["block_renderers"] = block_renderers.map(&:to_h) unless block_renderers.empty?
294
- manifest_data["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
295
- manifest_data["session_setting_types"] = session_setting_types.to_h { |k, v| [k.to_s, v.to_s] } unless session_setting_types.to_h.empty?
296
- manifest_data["settings"] = settings.map(&:to_s) unless settings.nil?
297
- manifest_data["setting_types"] = setting_types.to_h { |k, v| [k.to_s, v.to_s] } unless setting_types.to_h.empty?
334
+ manifest_data.merge!(kept_manifest_fields(existing, manifest_from, dev))
298
335
 
299
336
  write_manifest(manifest_data)
300
337
  marked.each_key { |key| save_incoming(key, incoming_dir) }
@@ -404,6 +441,39 @@ module Samagotchi
404
441
  nil
405
442
  end
406
443
 
444
+ # Keeps +dir+/manifest.yml for an installed bundle whose record has no
445
+ # usable kept manifest (InstalledBundle#manifest_unkept?: an older chi
446
+ # wrote it, or the file is gone), when +dir+ is the source of what's
447
+ # installed: its manifest names this bundle at the recorded version and
448
+ # says what the record keeps (#manifest_difference), and every sha the
449
+ # record has for a memory, plugin, hook, script or rule is the source
450
+ # file's. Then the file is copied to #kept_manifest_path and the
451
+ # record gains its manifest_sha, and nothing else changes (installed_at,
452
+ # the mirror, installed_by). A dev record is never adopted: its next
453
+ # install --dev links the file. Read-only with dry_run.
454
+ # @return [String, nil] why it wasn't (wouldn't be) kept, nil when it was
455
+ def adopt_manifest(dir, dry_run: false)
456
+ bundle = record
457
+ return "#{@name} isn't installed" unless bundle
458
+ return "#{@name} is a dev install" if bundle.dev
459
+ return "#{@name} keeps its manifest already" unless bundle.manifest_unkept?
460
+
461
+ source = File.join(dir.to_s, "manifest.yml")
462
+ return "#{dir} has no manifest.yml" unless File.file?(source)
463
+
464
+ # One read: the bytes checked are the bytes kept.
465
+ bytes = File.binread(source)
466
+ why = adoption_mismatch(bundle, dir.to_s, bytes)
467
+ return why if why || dry_run
468
+
469
+ AtomicFile.write(kept_manifest_path, bytes)
470
+ raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
471
+ write_manifest(raw.merge("manifest_sha" => "sha256:#{Digest::SHA256.hexdigest(bytes)}"))
472
+ nil
473
+ rescue JSON::ParserError, SystemCallError => e
474
+ "#{@name}'s record can't take it: #{e.message}"
475
+ end
476
+
407
477
  # The installed bundle's record, nil when it isn't installed.
408
478
  # @return [InstalledBundle, nil]
409
479
  # @raise [JSON::ParserError] manifest.json doesn't parse
@@ -428,6 +498,125 @@ module Samagotchi
428
498
  end
429
499
  private :write_manifest
430
500
 
501
+ # The record's manifest_sha and installed_by: a fresh kept copy's, or
502
+ # (no manifest_from) the existing record's, the kept file left alone.
503
+ def kept_manifest_fields(existing, manifest_from, dev)
504
+ if manifest_from.nil?
505
+ return { "manifest_sha" => existing&.manifest_sha, "installed_by" => existing&.installed_by }.compact
506
+ end
507
+
508
+ sha = keep_manifest(File.join(manifest_from.to_s, "manifest.yml"), dev)
509
+ sha ? { "manifest_sha" => sha, "installed_by" => Samagotchi::VERSION } : {}
510
+ end
511
+ private :kept_manifest_fields
512
+
513
+ # +source+ to #kept_manifest_path: a link to its realpath for a dev
514
+ # install (DEV_SHA), else a copy written aside and renamed (a link left
515
+ # by a dev install is replaced, never written through), its sha.
516
+ # A source with no manifest.yml leaves none kept: nil.
517
+ def keep_manifest(source, dev)
518
+ dest = kept_manifest_path
519
+ unless File.file?(source)
520
+ FileUtils.rm_f(dest)
521
+ return nil
522
+ end
523
+
524
+ if dev
525
+ FileUtils.rm_f(dest)
526
+ File.symlink(File.realpath(source), dest)
527
+ return DEV_SHA
528
+ end
529
+ AtomicFile.write(dest, File.binread(source))
530
+ FileDigest.sha256(dest)
531
+ end
532
+ private :keep_manifest
533
+
534
+ # Why +dir+ isn't the source of what +bundle+ installed (#adopt_manifest), nil when it is.
535
+ def adoption_mismatch(bundle, dir, bytes)
536
+ manifest = begin
537
+ parsed(bytes)
538
+ rescue Manifest::ValidationError, Psych::Exception, EncodingError => e
539
+ return "#{dir}'s manifest.yml doesn't read: #{e.message}"
540
+ end
541
+ found = "#{manifest.name} #{manifest.version}"
542
+ return "#{dir} has #{found}, not #{@name} #{bundle.version}" unless manifest.name == @name
543
+ return "#{dir} has #{found}, not #{bundle.version}" unless manifest.version == bundle.version.to_s
544
+
545
+ key = manifest_difference(bundle, manifest)
546
+ return "#{dir}'s manifest.yml isn't the installed one (#{key})" if key
547
+
548
+ installed_shas(bundle, manifest).each do |rel, sha|
549
+ next if self.class.recorded_sha(sha).empty?
550
+ return "#{dir}'s #{rel} isn't the installed one" unless self.class.file_sha(File.join(dir, rel)) == self.class.recorded_sha(sha)
551
+ end
552
+ nil
553
+ end
554
+ private :adoption_mismatch
555
+
556
+ # +bytes+ as a manifest.yml, read where nothing else can change it.
557
+ def parsed(bytes)
558
+ Dir.mktmpdir("chi-adopt-") do |tmp|
559
+ path = File.join(tmp, "manifest.yml")
560
+ File.binwrite(path, bytes)
561
+ Manifest.new(path: path)
562
+ rescue Manifest::ValidationError => e
563
+ raise Manifest::ValidationError, e.message.gsub(tmp, "the source")
564
+ end
565
+ end
566
+ private :parsed
567
+
568
+ # The SINCE entry of a mirrored key whose shape a newer chi reads.
569
+ MIRROR_SINCE = { "session_setting_types" => "session_settings.types", "setting_types" => "settings.types" }.freeze
570
+
571
+ # The first manifest key in which +manifest+ says other than what the
572
+ # record keeps, nil when none: each mirrored key as #write records it (one
573
+ # the record lacks is fine only when an older chi couldn't have known it:
574
+ # Manifest::SINCE has it), then each recorded hook's events, on_error and
575
+ # priority.
576
+ def manifest_difference(bundle, manifest)
577
+ recorded = plain(bundle.mirror)
578
+ source = plain(self.class.mirror_fields(
579
+ trust_level: manifest.trust_level, requires_chi: manifest.requires_chi, needs: manifest.needs,
580
+ context_providers: manifest.context_providers, block_renderers: manifest.block_renderers,
581
+ session_settings: manifest.session_settings, session_setting_types: manifest.session_setting_types,
582
+ settings: manifest.settings, setting_types: manifest.setting_types
583
+ ))
584
+ differing = InstalledBundle::MIRROR_KEYS.find do |key|
585
+ next false if recorded[key] == source[key]
586
+
587
+ !(recorded[key].nil? && Manifest::SINCE.key?(MIRROR_SINCE.fetch(key, key)))
588
+ end
589
+ return differing if differing
590
+
591
+ same_hooks = bundle.hooks.all? do |file, hook|
592
+ theirs = manifest.hooks.fetch(file, BundleHook.new)
593
+ [hook.events, hook.on_error, hook.priority] == [theirs.events, theirs.on_error, theirs.priority]
594
+ end
595
+ same_hooks ? nil : "hooks"
596
+ end
597
+ private :manifest_difference
598
+
599
+ def plain(value) = JSON.parse(JSON.generate(value))
600
+ private :plain
601
+
602
+ # Each file the record has a sha for, by its path in the bundle's source
603
+ # => that sha: the plugin, hooks, scripts, rules, and each memory whose
604
+ # entry has a checksum and no conflict mark (its base is the bundle's text).
605
+ def installed_shas(bundle, manifest)
606
+ shas = bundle.files.each_with_object({}) do |(key, entry), acc|
607
+ acc[key] = entry[:checksum].to_s unless entry[:conflict] || entry[:checksum].to_s.empty?
608
+ end
609
+ if bundle.plugin
610
+ file = manifest.plugin && manifest.plugin[:file].to_s
611
+ shas[file && File.basename(file) == bundle.plugin.file ? file : bundle.plugin.file] = bundle.plugin.sha256
612
+ end
613
+ bundle.hooks.each { |file, hook| shas["hooks/#{file}"] = hook.sha256 }
614
+ bundle.scripts.each { |file, sha| shas["scripts/#{file}"] = sha }
615
+ bundle.guardrails.each { |file, sha| shas["guardrails/#{file}"] = sha }
616
+ shas
617
+ end
618
+ private :installed_shas
619
+
431
620
  def mark_fields(mark)
432
621
  { conflict: true, why: mark.why, missed: mark.missed.empty? ? nil : mark.missed,
433
622
  missed_places: mark.places.zero? ? nil : mark.places }.compact
@@ -435,7 +624,7 @@ module Samagotchi
435
624
  private :mark_fields
436
625
 
437
626
  def incoming_sha(path)
438
- File.exist?(path) ? self.class.file_sha(path) : nil
627
+ FileDigest.hex(path)
439
628
  rescue SystemCallError
440
629
  nil
441
630
  end
@@ -459,11 +648,21 @@ module Samagotchi
459
648
  private :marked_in
460
649
 
461
650
  # Each file that settled loses its saved text; the folder goes with the last.
651
+ # A file key can nest (a manifest's `files:` may name a path), so every file
652
+ # under incoming/ at any depth is looked at, and a dir the prune empties goes
653
+ # with it, deepest first.
462
654
  def prune_incoming(keep)
463
655
  dir = File.join(@bundle_dir, "incoming")
464
656
  return unless Dir.exist?(dir)
465
657
 
466
- (Dir.children(dir) - keep).each { |file| FileUtils.rm_f(File.join(dir, file)) }
658
+ keep = keep.map(&:to_s)
659
+ entries = Dir.glob("**/*", base: dir)
660
+ entries.each do |rel|
661
+ FileUtils.rm_f(File.join(dir, rel)) if File.file?(File.join(dir, rel)) && !keep.include?(rel)
662
+ end
663
+ entries.select { |rel| File.directory?(File.join(dir, rel)) }
664
+ .sort_by { |rel| -rel.count("/") } # deepest first, so a parent is checked after its children
665
+ .each { |rel| Dir.rmdir(File.join(dir, rel)) if Dir.empty?(File.join(dir, rel)) }
467
666
  Dir.rmdir(dir) if Dir.empty?(dir)
468
667
  rescue SystemCallError
469
668
  nil
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
3
  require "yaml"
4
+ require_relative "file_digest"
5
5
 
6
6
  module Samagotchi
7
7
  module MemoryBundle
@@ -100,9 +100,10 @@ module Samagotchi
100
100
 
101
101
  def digest(dir, file)
102
102
  path = File.join(dir, file)
103
- raise Error, "#{File.basename(dir)}/manifest.yml names #{file}, which isn't there" unless File.file?(path)
103
+ hex = FileDigest.hex(path)
104
+ raise Error, "#{File.basename(dir)}/manifest.yml names #{file}, which isn't there" if hex.nil?
104
105
 
105
- Digest::SHA256.hexdigest(File.binread(path))
106
+ hex
106
107
  end
107
108
  private_class_method :digest
108
109
 
@@ -3,6 +3,7 @@
3
3
  require_relative "installer"
4
4
  require_relative "listing"
5
5
  require_relative "manifest"
6
+ require_relative "manifest_standing"
6
7
  require_relative "merger"
7
8
  require_relative "profile"
8
9
  require_relative "provenance"
@@ -51,8 +52,11 @@ module Samagotchi
51
52
  # (Installer#trash_copy_line).
52
53
  # earlier: the merge notes on its record when the plan read it
53
54
  # (FileStanding::MergeNote): this run's own merges aren't among them.
55
+ # manifest: what a row at the shipped version says of its manifest.yml
56
+ # (ManifestStanding.keep's line: would keep or kept it, or why not and
57
+ # the command), nil when nothing.
54
58
  Row = Struct.new(:name, :from, :to, :status, :note, :merged, :behind, :replaced, :source_dir, :scope, :adds, :kept_own,
55
- :trash_copy, :earlier, keyword_init: true) do
59
+ :trash_copy, :earlier, :manifest, keyword_init: true) do
56
60
  def kept = Array(behind).map(&:file)
57
61
  end
58
62
 
@@ -67,6 +71,7 @@ module Samagotchi
67
71
  # A row that retried nothing still has its files that stay behind.
68
72
  row.behind = bundle.behind_files if row.status == :skipped && !bundle.error?
69
73
  row.earlier = bundle.merge_notes
74
+ row.manifest = ManifestStanding.keep(bundle, source_dir: row.source_dir, dry_run: true).line if same_version?(row)
70
75
  row
71
76
  end
72
77
  end
@@ -89,32 +94,55 @@ module Samagotchi
89
94
 
90
95
  # Runs the Installer for each :would_update row, Remerge for each
91
96
  # :would_merge row; the others pass through. A failing bundle doesn't
92
- # stop the rest.
97
+ # stop the rest. A bundle at the shipped version whose record an older
98
+ # chi wrote keeps the shipped manifest.yml (#keep_manifest).
93
99
  # @return [Array<Row>]
94
100
  def apply(rows)
95
- rows.map do |row|
96
- next apply_remerge(row) if row.status == :would_merge
97
- next row unless row.status == :would_update
98
- next apply_profile(row) if row.adds
99
-
100
- installer = Installer.new(source: row.source_dir, name: row.name, scope: row.scope, strict: true, upgrade: true,
101
- shipped_dir: File.dirname(row.source_dir))
102
- installer.run
103
- row.dup.tap do |r|
104
- r.status = :updated
105
- r.merged = installer.merged_files
106
- r.behind = installer.behind
107
- r.trash_copy = installer.trash_copy_line
108
- end
109
- rescue StandardError => e
110
- row.dup.tap do |r|
111
- r.status = :failed
112
- r.note = e.message.lines.first.to_s.strip
113
- r.merged = installer&.merged_files || []
114
- r.behind = installer&.behind || []
115
- r.trash_copy = installer&.trash_copy_line
116
- end
101
+ rows.map { |row| keep_manifest(apply_row(row)) }
102
+ end
103
+
104
+ def apply_row(row)
105
+ return apply_remerge(row) if row.status == :would_merge
106
+ return row unless row.status == :would_update
107
+ return apply_profile(row) if row.adds
108
+
109
+ installer = Installer.new(source: row.source_dir, name: row.name, scope: row.scope, strict: true, upgrade: true,
110
+ shipped_dir: File.dirname(row.source_dir))
111
+ installer.run
112
+ row.dup.tap do |r|
113
+ r.status = :updated
114
+ r.merged = installer.merged_files
115
+ r.behind = installer.behind
116
+ r.trash_copy = installer.trash_copy_line
117
117
  end
118
+ rescue StandardError => e
119
+ row.dup.tap do |r|
120
+ r.status = :failed
121
+ r.note = e.message.lines.first.to_s.strip
122
+ r.merged = installer&.merged_files || []
123
+ r.behind = installer&.behind || []
124
+ r.trash_copy = installer&.trash_copy_line
125
+ end
126
+ end
127
+
128
+ # The statuses of a row at the shipped version, in the plan and after
129
+ # apply: its record can keep the shipped dir's manifest.yml.
130
+ SAME_VERSION = %i[up_to_date would_merge merged].freeze
131
+
132
+ def same_version?(row) = !row.source_dir.nil? && SAME_VERSION.include?(row.status)
133
+
134
+ # ManifestStanding.keep from the shipped dir for a same-version row: a
135
+ # record an older chi wrote keeps the shipped manifest.yml, and the row
136
+ # says so (or why not). A bumped bundle gets the file from its Installer.
137
+ # @return [Row] the row
138
+ def keep_manifest(row)
139
+ return row unless same_version?(row)
140
+
141
+ bundle = Provenance.new(name: row.name).record
142
+ row.manifest = bundle && ManifestStanding.keep(bundle, source_dir: row.source_dir).line
143
+ row
144
+ rescue JSON::ParserError, SystemCallError
145
+ row
118
146
  end
119
147
 
120
148
  def apply_remerge(row)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
3
+ require_relative "file_digest"
4
4
  require_relative "provenance"
5
5
  require_relative "manifest"
6
6
  require_relative "../version"
@@ -77,7 +77,7 @@ module Samagotchi
77
77
 
78
78
  # The file's digest, nil when there is none, :unreadable when it can't be read.
79
79
  def self.checksum(path)
80
- File.exist?(path) ? Digest::SHA256.file(path).hexdigest : nil
80
+ FileDigest.hex(path)
81
81
  rescue SystemCallError
82
82
  :unreadable
83
83
  end
@@ -86,19 +86,19 @@ module Samagotchi
86
86
  # Why none of the bundle's hooks load (this chi doesn't meet its
87
87
  # requires_chi, Hooks::BundleLoader), or nil: it has no hooks or meets it.
88
88
  def self.hooks_requires_failure(bundle)
89
- return nil if bundle.hooks.empty?
89
+ manifest = bundle.manifest
90
+ return nil if manifest.hooks.empty?
90
91
 
91
- Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION)
92
+ manifest.requires_chi_failure
92
93
  end
93
94
 
94
95
  # The stored needs, each with found: from this process's PATH (the
95
- # shell's; a worker's PATH can differ, see docs/memory.md).
96
+ # shell's; a worker's PATH can differ, see docs/memory.md). A needs:
97
+ # that doesn't parse is none.
96
98
  def self.needs_status(bundle, path: ENV["PATH"])
97
- Manifest.parse_needs(bundle.needs).map do |need|
99
+ bundle.manifest.needs.map do |need|
98
100
  need.merge(found: BundleNeeds.found?(need[:command], path: path))
99
101
  end
100
- rescue Manifest::ValidationError
101
- []
102
102
  end
103
103
 
104
104
  # "needs gh (reads PRs) [ok]" / "needs gh [not found]: brew install gh"
@@ -120,8 +120,9 @@ module Samagotchi
120
120
  elsif Provenance.sha_matches?(path, bundle.plugin.sha256) then "ok"
121
121
  else "modified"
122
122
  end
123
- { file: File.basename(path), path: path, state: state, requires_chi: bundle.requires_chi,
124
- requires_failure: Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION) }
123
+ manifest = bundle.manifest
124
+ { file: File.basename(path), path: path, state: state, requires_chi: manifest.requires_chi,
125
+ requires_failure: manifest.requires_chi_failure }
125
126
  end
126
127
 
127
128
  def self.unchecked_file(meta)
@@ -117,7 +117,9 @@ module Samagotchi
117
117
  dry_run ? planned_update(gem_manifest, from, to) : upgrade_existing(from, to)
118
118
  else
119
119
  # Already at desired version — still verify files present (e.g. user deleted one)
120
- # and retry the files marked conflict.
120
+ # and retry the files marked conflict. A record an older chi wrote keeps
121
+ # the shipped manifest.yml (once: then it has it).
122
+ keep_manifest(bundle) unless dry_run
121
123
  verified = verify_files_present(gem_manifest, from, dry_run)
122
124
  next remerge(bundle, verified, dry_run) if verified.status == :up_to_date
123
125
 
@@ -247,11 +249,20 @@ module Samagotchi
247
249
  result(:updated, from: from, to: to, merged: forecast.merged, behind: forecast.behind)
248
250
  end
249
251
 
252
+ # Provenance#adopt_manifest from the shipped dir, for a record without a
253
+ # usable kept manifest. A refusal (a checkout whose bundle changed at the
254
+ # same version) leaves the record reading its mirror, as before.
255
+ def keep_manifest(bundle)
256
+ return unless bundle.manifest_unkept?
257
+
258
+ Provenance.new(name: BUNDLE_NAME).adopt_manifest(GEM_BUNDLE_DIR)
259
+ end
260
+
250
261
  def upgrade_installer
251
262
  Installer.new(source: GEM_BUNDLE_DIR, name: BUNDLE_NAME, scope: SCOPE, force: false, strict: true, upgrade: true,
252
263
  shipped_dir: File.dirname(GEM_BUNDLE_DIR))
253
264
  end
254
- private_class_method :with_lock, :note_newer_installed, :install_fresh, :upgrade_existing, :verify_files_present, :restore_file, :remerge,
265
+ private_class_method :with_lock, :note_newer_installed, :install_fresh, :keep_manifest, :upgrade_existing, :verify_files_present, :restore_file, :remerge,
255
266
  :upgrade_installer, :result, :planned_update
256
267
  end
257
268
  end
@@ -240,8 +240,9 @@ module Samagotchi
240
240
  # +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
241
241
  def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
242
242
  tool_id: nil, marks: {})
243
- # A built-in reached through call_tool reloads as that built-in's
244
- # row, with via (its saved params and label are the built-in's).
243
+ # A chi tool (a built-in or a plugin's) reached through call_tool
244
+ # reloads as that tool's row, with via (its saved params and label
245
+ # are the tool's).
245
246
  # A direct call of an MCP tool reloads as the call_tool row it ran as.
246
247
  call = Tools::BuiltinCalls.unwrap_saved(Tools::BuiltinCalls.wrap_saved(call, output), output)
247
248
  name = call[:name].to_s
@@ -113,7 +113,7 @@ module Samagotchi
113
113
 
114
114
  # Why the installed file can't be loaded, or nil.
115
115
  def unloadable_reason(file, bundle)
116
- failure = MemoryBundle::Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION)
116
+ failure = bundle.manifest.requires_chi_failure
117
117
  return failure if failure
118
118
  return "the file is missing" unless file && File.file?(file)
119
119
 
@@ -13,7 +13,7 @@ module Samagotchi
13
13
  # (RecapSetup). Its
14
14
  # bundles part: {bundle => {key => value}}, string keys, scalar values
15
15
  # (.typed). A bundle offers the keys a session may set in its manifest's
16
- # session_settings: (recorded at install, InstalledBundle#session_settings);
16
+ # session_settings: (recorded at install, InstalledManifest#session_settings);
17
17
  # any other bundle or key is refused, so a typo isn't saved, and so is a
18
18
  # value of another type than the manifest gives the key (session_settings:
19
19
  # {cap: number}). The session keeps them (Session#settings, SessionSetup)
@@ -308,9 +308,10 @@ module Samagotchi
308
308
  notes[name] = "#{name} is installed but its plugin doesn't load: #{reason}"
309
309
  next
310
310
  end
311
- offered[name] = bundle.session_settings
312
- types[name] = bundle.session_setting_types unless bundle.session_setting_types.empty?
313
- if bundle.session_settings.empty? && shipped_settings?(shipped_dir, name)
311
+ manifest = bundle.manifest
312
+ offered[name] = manifest.session_settings
313
+ types[name] = manifest.session_setting_types unless manifest.session_setting_types.empty?
314
+ if manifest.session_settings.empty? && shipped_settings?(shipped_dir, name)
314
315
  notes[name] = "(run `chi update` or `chi bundle upgrade #{name}` to refresh it)"
315
316
  end
316
317
  end
@@ -7,7 +7,8 @@ module Samagotchi
7
7
  module SystemOne
8
8
  # One rule's record: what System 1 has judged for it and what the user
9
9
  # did about those asks. Built from `SystemOne::Log#entries` (judgments
10
- # joined with their last label), filtered to one rule. The gate before
10
+ # joined with their last label), filtered to one rule — an ask two rules
11
+ # raised counts for each of them (its `other_rules`). The gate before
11
12
  # System 1 answers for the user: enough clean allows at the threshold,
12
13
  # and no false allow among the rule's last 20 labelled asks
13
14
  # (docs/guardrails.md, "System 1"). A rule in
@@ -41,7 +42,14 @@ module Samagotchi
41
42
  # rule's. A line with no repo is in no repository's record.
42
43
  def initialize(entries, rule:, repo: nil)
43
44
  @rule = rule.to_s
44
- @entries = Array(entries).select { |entry| entry["rule"].to_s == @rule && (repo.nil? || entry["repo"] == repo) }
45
+ @entries = Array(entries).select { |entry| asks?(entry) && (repo.nil? || entry["repo"] == repo) }
46
+ end
47
+
48
+ # The judgment names the rule the ask was for; an ask two rules raised
49
+ # carries the others in `other_rules`, so it counts in each of their
50
+ # records too.
51
+ def asks?(entry)
52
+ entry["rule"].to_s == @rule || Array(entry["other_rules"]).any? { |other| other.to_s == @rule }
45
53
  end
46
54
 
47
55
  # @param threshold [Float] the probability an auto-allow needs