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
@@ -43,14 +43,14 @@ module Samagotchi
43
43
  # @return [Array<Installed>] by name; upgrade is the Shipped when it is newer
44
44
  def installed(shipped: self.shipped)
45
45
  by_name = shipped.to_h { |s| [s.name, s] }
46
- Provenance.each_installed.map do |name, data|
47
- next Installed.new(name: name, error: "manifest.json unreadable") if data[:error]
46
+ Provenance.each_installed.map do |name, bundle|
47
+ next Installed.new(name: name, error: "manifest.json unreadable") if bundle.error?
48
48
 
49
49
  ship = by_name[name]
50
- Installed.new(name: name, version: data[:version], scope: data[:scope],
51
- files: (data[:files] || {}).size, installed_at: data[:installed_at],
52
- upgrade: ship && newer?(ship.version, data[:version]) ? ship : nil,
53
- includes: data[:includes].is_a?(Array) ? data[:includes].map(&:to_s) : nil)
50
+ Installed.new(name: name, version: bundle.version, scope: bundle.scope,
51
+ files: bundle.files.size, installed_at: bundle.installed_at,
52
+ upgrade: ship && newer?(ship.version, bundle.version) ? ship : nil,
53
+ includes: bundle.includes)
54
54
  end
55
55
  end
56
56
 
@@ -99,9 +99,11 @@ module Samagotchi
99
99
  raw.start_with?("sha256:") ? raw[7..] : raw
100
100
  end
101
101
 
102
- # Returns sha256 hex for a hook basename (e.g. "guardrails.rb").
102
+ # Returns sha256 hex for a hook basename (e.g. "guardrails.rb"), nil
103
+ # when it declares none (nothing to verify).
103
104
  def checksum_for_hook(basename)
104
- @hooks[basename]&.hex
105
+ hex = @hooks[basename]&.hex
106
+ hex unless hex.nil? || hex.empty?
105
107
  end
106
108
 
107
109
  # @return [String, nil] the plugin's sha256 hex, "sha256:" stripped
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module MemoryBundle
5
+ # The plugin an installed bundle's record (manifest.json) names: its
6
+ # file under plugin/ and the sha256 recorded at install ("" when none
7
+ # is; either "sha256:<hex>" or a bare hex, Provenance.recorded_sha
8
+ # reads both).
9
+ PluginRef = Data.define(:file, :sha256) do
10
+ def initialize(file:, sha256: "")
11
+ super
12
+ end
13
+
14
+ # @param raw [Hash, PluginRef, nil] the record's plugin: mapping
15
+ # (symbol or string keys)
16
+ # @return [PluginRef, nil] nil when there is none or it names no file
17
+ def self.parse(raw)
18
+ return raw if raw.is_a?(PluginRef)
19
+ return nil unless raw.is_a?(Hash)
20
+
21
+ h = raw.transform_keys(&:to_s)
22
+ file = h["file"].to_s
23
+ file.empty? ? nil : new(file: file, sha256: h["sha256"].to_s)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -46,32 +46,32 @@ module Samagotchi
46
46
  false
47
47
  end
48
48
 
49
- # Whether the installed bundle +name+ (its provenance +data+) is a
50
- # meta: it records includes, or it came from chi and chi ships it as
51
- # a meta.
52
- def installed_meta?(name, data, shipped_dir: SourceNormalizer::SHIPPED_DIR)
53
- return false unless data.is_a?(Hash)
54
- return true if data[:includes].is_a?(Array)
55
-
56
- data[:source].to_s.match?(SourceNormalizer::SHIPPED_SOURCE) && shipped_meta?(File.join(shipped_dir, name), shipped_dir: shipped_dir)
49
+ # Whether the installed bundle +name+ (its record +bundle+, an
50
+ # InstalledBundle or nil) is a meta: it records includes, or it came
51
+ # from chi and chi ships it as a meta.
52
+ def installed_meta?(name, bundle, shipped_dir: SourceNormalizer::SHIPPED_DIR)
53
+ return false unless bundle
54
+ return true if bundle.profile?
55
+
56
+ bundle.source.to_s.match?(SourceNormalizer::SHIPPED_SOURCE) && shipped_meta?(File.join(shipped_dir, name), shipped_dir: shipped_dir)
57
57
  end
58
58
 
59
59
  # The members the meta has recorded: none when it isn't installed,
60
60
  # all of +manifest+'s (the shipped meta, nil when chi ships none) when
61
61
  # its provenance has no includes: an unknown record is taken as
62
62
  # everything.
63
- def recorded(data, manifest)
64
- return [] unless data
65
- return manifest&.includes || [] unless data[:includes].is_a?(Array)
63
+ def recorded(bundle, manifest)
64
+ return [] unless bundle
65
+ return manifest&.includes || [] unless bundle.profile?
66
66
 
67
- data[:includes].map(&:to_s)
67
+ bundle.includes
68
68
  end
69
69
 
70
70
  def installed_names = Provenance.each_installed.map { |name, _| name }
71
71
 
72
72
  # The one rule.
73
- def to_install(manifest, data = Provenance.new(name: manifest.name).read, installed: installed_names)
74
- manifest.includes - recorded(data, manifest) - installed
73
+ def to_install(manifest, bundle = Provenance.new(name: manifest.name).record, installed: installed_names)
74
+ manifest.includes - recorded(bundle, manifest) - installed
75
75
  end
76
76
 
77
77
  # Installs the shipped meta at +dir+: its to_install members from the
@@ -80,13 +80,13 @@ module Samagotchi
80
80
  # @return [InstallResult]
81
81
  def install(dir, shipped_dir: SourceNormalizer::SHIPPED_DIR, chi_version: Samagotchi::VERSION, dry_run: false, force: false)
82
82
  manifest = Manifest.read(dir: dir)
83
- data = Provenance.new(name: manifest.name).read
84
- was = recorded(data, manifest)
83
+ bundle = Provenance.new(name: manifest.name).record
84
+ was = recorded(bundle, manifest)
85
85
  installed = installed_names
86
86
  result = InstallResult.new(name: manifest.name, version: manifest.version, installed: [],
87
87
  already: (manifest.includes - was) & installed, skipped: {}, failed: {})
88
88
 
89
- to_install(manifest, data, installed: installed).each do |member|
89
+ to_install(manifest, bundle, installed: installed).each do |member|
90
90
  install_member(member, shipped_dir, chi_version, dry_run, force, result)
91
91
  end
92
92
  return result if dry_run
@@ -94,7 +94,7 @@ module Samagotchi
94
94
  now = was | result.installed | result.already
95
95
  includes = (manifest.includes & now) + (now - manifest.includes)
96
96
  # Nothing new: the record stays as it is (installed_at too).
97
- return result if data && data[:version] == manifest.version && data[:source] == dir && data[:includes] == includes
97
+ return result if bundle && bundle.version == manifest.version && bundle.source == dir && bundle.includes == includes
98
98
 
99
99
  Provenance.new(name: manifest.name).write(
100
100
  files: {}, scope: "system", version: manifest.version, source_path: dir,
@@ -123,12 +123,12 @@ module Samagotchi
123
123
  # @return [UninstallResult]
124
124
  # @raise [Uninstaller::UninstallError] when +name+ isn't installed
125
125
  def uninstall(name, shipped_dir: SourceNormalizer::SHIPPED_DIR, force: false)
126
- data = Provenance.new(name: name).read
127
- raise Uninstaller::UninstallError, "Bundle '#{name}' is not installed" unless data
126
+ bundle = Provenance.new(name: name).record
127
+ raise Uninstaller::UninstallError, "Bundle '#{name}' is not installed" unless bundle
128
128
 
129
129
  shipped = File.join(shipped_dir, name)
130
130
  manifest = File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil
131
- members = recorded(data, manifest)
131
+ members = recorded(bundle, manifest)
132
132
  result = UninstallResult.new(name: name, removed: [], blocked: {}, gone: false, trash: {}, warnings: [])
133
133
  (members & installed_names).each do |member|
134
134
  uninstaller = Uninstaller.new(name: member, force: force)
@@ -7,6 +7,7 @@ require "fileutils"
7
7
  require_relative "../atomic_file"
8
8
  require_relative "../memory_paths"
9
9
  require_relative "bundle_hook"
10
+ require_relative "installed_bundle"
10
11
 
11
12
  module Samagotchi
12
13
  module MemoryBundle
@@ -47,9 +48,10 @@ module Samagotchi
47
48
  end
48
49
 
49
50
  # The plugin file as installed, or nil when the bundle has none.
50
- def plugin_path(data = read)
51
- file = data && data[:plugin].is_a?(Hash) ? data[:plugin][:file].to_s : ""
52
- file.empty? ? nil : File.join(plugin_dir, file)
51
+ # @param bundle [InstalledBundle, nil] its record (read when not given)
52
+ def plugin_path(bundle = record)
53
+ file = bundle&.plugin&.file
54
+ file ? File.join(plugin_dir, file) : nil
53
55
  end
54
56
 
55
57
  # The plugin's base snapshot (bases/plugin/<file>), for bundle diff.
@@ -57,14 +59,14 @@ module Samagotchi
57
59
  File.join(@bundle_dir, "bases", "plugin", file.to_s)
58
60
  end
59
61
 
60
- # Yields [name, data] for each installed bundle (a dir under
61
- # bundles_dir holding a manifest.json, dot dirs aside), by name. data
62
- # is the manifest with symbol keys, or {error:} when it doesn't parse
63
- # or isn't an object; the rest still come.
62
+ # Yields [name, bundle] for each installed bundle (a dir under
63
+ # bundles_dir holding a manifest.json, dot dirs aside), by name. bundle
64
+ # is its InstalledBundle, one with error set when manifest.json doesn't
65
+ # parse or isn't an object; the rest still come.
64
66
  #
65
67
  # With holding: (:hooks, :guardrails or :plugin) only the bundles
66
68
  # whose manifest has a non-empty mapping under that key come, and a
67
- # manifest that doesn't parse comes as {error:} only when its bundle
69
+ # manifest that doesn't parse comes (with error) only when its bundle
68
70
  # has that dir (hooks/, guardrails/, plugin/): what it would load
69
71
  # can't be checked. One that isn't an object is skipped there.
70
72
  # @param dir [String] the bundles dir (bundles_dir by default)
@@ -79,15 +81,14 @@ module Samagotchi
79
81
  begin
80
82
  data = JSON.parse(File.read(mjson), symbolize_names: true)
81
83
  rescue JSON::ParserError, SystemCallError => e
82
- yield name, { error: "manifest.json is unreadable: #{e.message}" } if holding.nil? || Dir.exist?(File.join(dir, name, holding.to_s))
84
+ if holding.nil? || Dir.exist?(File.join(dir, name, holding.to_s))
85
+ yield name, InstalledBundle.unreadable(name, "manifest.json is unreadable: #{e.message}")
86
+ end
83
87
  next
84
88
  end
85
- if holding
86
- next unless data.is_a?(Hash) && data[holding].is_a?(Hash) && !data[holding].empty?
87
- elsif !data.is_a?(Hash)
88
- data = { error: "manifest.json is not an object" }
89
- end
90
- yield name, data
89
+ next if holding && !(data.is_a?(Hash) && data[holding].is_a?(Hash) && !data[holding].empty?)
90
+
91
+ yield name, InstalledBundle.parse(name, data)
91
92
  end
92
93
  end
93
94
 
@@ -96,10 +97,9 @@ module Samagotchi
96
97
  # A project-scoped record doesn't say which project: one from another
97
98
  # repo counts too, which errs on keeping the file.
98
99
  def self.claimants(file_key, scope:, except: nil, dir: bundles_dir)
99
- each_installed(dir: dir).filter_map do |name, data|
100
- next if name == except || data[:error]
101
- next unless (data[:scope].to_s.empty? ? "system" : data[:scope].to_s) == scope.to_s
102
- next unless data[:files].is_a?(Hash) && data[:files].keys.map(&:to_s).include?(file_key.to_s)
100
+ each_installed(dir: dir).filter_map do |name, bundle|
101
+ next if name == except || bundle.error?
102
+ next unless bundle.effective_scope == scope.to_s && bundle.owns?(file_key)
103
103
 
104
104
  name
105
105
  end
@@ -146,11 +146,10 @@ module Samagotchi
146
146
  bases_dir = File.join(@bundle_dir, "bases")
147
147
  FileUtils.mkdir_p(bases_dir)
148
148
 
149
- # Read existing manifest to merge file entries and find stale files.
150
- # read symbolizes keys; stringify them so incoming string keys replace
151
- # them instead of sitting next to them as duplicates.
152
- existing = read
153
- merged_entries = existing && existing[:files] ? existing[:files].transform_keys(&:to_s) : {}
149
+ # The existing record: its file entries merge with these, and its
150
+ # files and hooks not in this install are stale.
151
+ existing = record
152
+ merged_entries = existing ? existing.files.dup : {}
154
153
 
155
154
  files.each do |file_key, file_path|
156
155
  content = File.read(file_path)
@@ -163,10 +162,9 @@ module Samagotchi
163
162
  end
164
163
 
165
164
  # Prune stale base snapshots for files no longer in the bundle.
166
- if existing && existing[:files]
167
- existing[:files].keys.each do |old_key|
168
- old_key_str = old_key.to_s
169
- next if files.key?(old_key_str) || files.key?(old_key)
165
+ if existing
166
+ existing.files.each_key do |old_key_str|
167
+ next if files.key?(old_key_str) || files.key?(old_key_str.to_sym)
170
168
 
171
169
  old_base = File.join(bases_dir, old_key_str)
172
170
  FileUtils.rm_f(old_base)
@@ -176,8 +174,6 @@ module Samagotchi
176
174
 
177
175
  # Hooks provenance: persist metadata and base snapshots for hooks
178
176
  hooks_map = {}
179
- # existing hooks for pruning
180
- existing_hooks = existing && existing[:hooks] ? existing[:hooks] : {}
181
177
  # Incoming hooks: basename => BundleHook (or its metadata hash)
182
178
  normalized_hooks = {}
183
179
  if hooks.is_a?(Hash)
@@ -189,14 +185,8 @@ module Samagotchi
189
185
  end
190
186
  end
191
187
  # Prune stale hook bases
192
- if existing_hooks.is_a?(Hash)
193
- existing_hooks.keys.each do |old_key|
194
- old_key_str = old_key.to_s
195
- unless normalized_hooks.key?(old_key_str) || normalized_hooks.key?(old_key_str.to_sym)
196
- old_base = File.join(bases_dir, old_key_str)
197
- FileUtils.rm_f(old_base)
198
- end
199
- end
188
+ existing&.hooks&.each_key do |old_key_str|
189
+ FileUtils.rm_f(File.join(bases_dir, old_key_str)) unless normalized_hooks.key?(old_key_str)
200
190
  end
201
191
  # Save base snapshots for hooks where a file path was provided, and
202
192
  # record the sha256 of the installed file itself: the load-time check
@@ -276,7 +266,15 @@ module Samagotchi
276
266
  write_manifest(raw)
277
267
  end
278
268
 
279
- # Reads provenance data (returns nil if not installed).
269
+ # The installed bundle's record, nil when it isn't installed.
270
+ # @return [InstalledBundle, nil]
271
+ # @raise [JSON::ParserError] manifest.json doesn't parse
272
+ def record
273
+ InstalledBundle.read(@name, File.join(@bundle_dir, "manifest.json"))
274
+ end
275
+
276
+ # manifest.json as it is on disk, symbol keys (nil if not installed).
277
+ # Readers take #record.
280
278
  def read
281
279
  manifest_path = File.join(@bundle_dir, "manifest.json")
282
280
  return nil unless File.exist?(manifest_path)
@@ -299,7 +297,7 @@ module Samagotchi
299
297
 
300
298
  # Checks whether the bundle is installed.
301
299
  def installed?
302
- read.nil? ? false : true
300
+ !record.nil?
303
301
  end
304
302
  end
305
303
  end
@@ -45,8 +45,8 @@ module Samagotchi
45
45
  # @return [Array<Row>] by name
46
46
  def plan(shipped_dir: SourceNormalizer::SHIPPED_DIR, chi_version: Samagotchi::VERSION)
47
47
  shipped = Listing.shipped(dir: shipped_dir).to_h { |s| [s.name, s] }
48
- installed.map do |name, data|
49
- plan_row(name, data, shipped[name], shipped_dir, chi_version)
48
+ installed.map do |name, bundle|
49
+ plan_row(name, bundle, shipped[name], shipped_dir, chi_version)
50
50
  end
51
51
  end
52
52
 
@@ -88,24 +88,24 @@ module Samagotchi
88
88
  end
89
89
  end
90
90
 
91
- # [name, data] for each installed bundle but the system one, by name.
91
+ # [name, InstalledBundle] for each installed bundle but the system one, by name.
92
92
  def installed
93
93
  Provenance.each_installed.reject { |name, _| name == SystemBundle::BUNDLE_NAME }
94
94
  end
95
95
 
96
- def plan_row(name, data, ship, shipped_dir, chi_version)
96
+ def plan_row(name, bundle, ship, shipped_dir, chi_version)
97
97
  row = Row.new(name: name, kept: [], replaced: [])
98
- return skip(row, "manifest.json unreadable") if data[:error]
98
+ return skip(row, "manifest.json unreadable") if bundle.error?
99
99
 
100
- row.from = data[:version].to_s
101
- row.scope = data[:scope].to_s.empty? ? "system" : data[:scope].to_s
102
- return skip(row, "not from chi") unless ship && data[:source].to_s.match?(SourceNormalizer::SHIPPED_SOURCE)
100
+ row.from = bundle.version.to_s
101
+ row.scope = bundle.effective_scope
102
+ return skip(row, "not from chi") unless ship && bundle.source.to_s.match?(SourceNormalizer::SHIPPED_SOURCE)
103
103
 
104
104
  row.to = ship.version.to_s
105
105
  return skip(row, "newer than shipped: left") if Listing.newer?(row.from, row.to)
106
106
 
107
107
  row.source_dir = File.join(shipped_dir, ship.source)
108
- return profile_row(row, data, shipped_dir, chi_version) if Profile.shipped_meta?(row.source_dir, shipped_dir: shipped_dir)
108
+ return profile_row(row, bundle, shipped_dir, chi_version) if Profile.shipped_meta?(row.source_dir, shipped_dir: shipped_dir)
109
109
  return row.tap { |r| r.status = :up_to_date; r.to = nil } unless Listing.newer?(row.to, row.from)
110
110
 
111
111
  manifest = Manifest.read(dir: row.source_dir)
@@ -115,20 +115,20 @@ module Samagotchi
115
115
  return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
116
116
  return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
117
117
 
118
- row.kept = conflicts(row, data)
119
- row.replaced = edited_executables(name, data)
118
+ row.kept = conflicts(row, bundle)
119
+ row.replaced = edited_executables(name, bundle)
120
120
  row.status = :would_update
121
121
  row
122
122
  end
123
123
 
124
- def profile_row(row, data, shipped_dir, chi_version)
124
+ def profile_row(row, bundle, shipped_dir, chi_version)
125
125
  manifest = Manifest.read(dir: row.source_dir)
126
126
  newer = Listing.newer?(row.to, row.from)
127
127
  if newer && Manifest.requires_chi_failure(manifest.requires_chi, chi_version)
128
128
  return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
129
129
  end
130
130
 
131
- row.adds = Profile.to_install(manifest, data).reject do |member|
131
+ row.adds = Profile.to_install(manifest, bundle).reject do |member|
132
132
  Manifest.requires_chi_failure(Manifest.read(dir: File.join(shipped_dir, member)).requires_chi, chi_version)
133
133
  rescue Manifest::ValidationError, Psych::Exception
134
134
  false # apply reports it failing
@@ -147,12 +147,10 @@ module Samagotchi
147
147
 
148
148
  # The bundle's .md files whose local edits conflict with the shipped
149
149
  # version (a same-name file it didn't install is skipped, not merged).
150
- def conflicts(row, data)
150
+ def conflicts(row, bundle)
151
151
  provenance = Provenance.new(name: row.name)
152
152
  target_dir = MemoryPaths.scope_dir!(row.scope)
153
- return [] unless data[:files].is_a?(Hash)
154
-
155
- owned = data[:files].keys.map(&:to_s)
153
+ owned = bundle.files.keys
156
154
  (Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort & owned).select do |key|
157
155
  Merger.classify(base_path: provenance.base_path(key), current_path: File.join(target_dir, key),
158
156
  incoming_path: File.join(row.source_dir, key)) == :conflict
@@ -161,22 +159,22 @@ module Samagotchi
161
159
 
162
160
  # Installed hooks, rules and the plugin whose sha differs from the
163
161
  # recorded one (they didn't load), as hooks/F, guardrails/F, plugin/F.
164
- def edited_executables(name, data)
162
+ def edited_executables(name, bundle)
165
163
  provenance = Provenance.new(name: name)
166
164
  edited = []
167
- (data[:hooks] || {}).each do |file, meta|
168
- edited << "hooks/#{file}" if edited?(File.join(provenance.hooks_dir, file.to_s), meta)
165
+ bundle.hooks.each do |file, hook|
166
+ edited << "hooks/#{file}" if edited?(File.join(provenance.hooks_dir, file), hook.sha256)
169
167
  end
170
- (data[:guardrails] || {}).each do |file, meta|
171
- edited << "guardrails/#{file}" if edited?(File.join(provenance.guardrails_dir, file.to_s), meta)
168
+ bundle.guardrails.each do |file, sha|
169
+ edited << "guardrails/#{file}" if edited?(File.join(provenance.guardrails_dir, file), sha)
172
170
  end
173
- plugin = provenance.plugin_path(data)
174
- edited << "plugin/#{File.basename(plugin)}" if plugin && edited?(plugin, data[:plugin])
171
+ plugin = provenance.plugin_path(bundle)
172
+ edited << "plugin/#{File.basename(plugin)}" if plugin && edited?(plugin, bundle.plugin.sha256)
175
173
  edited
176
174
  end
177
175
 
178
- def edited?(path, meta)
179
- recorded = meta.is_a?(Hash) ? meta[:sha256] : nil
176
+ # Whether the file differs from its recorded sha (none recorded: no).
177
+ def edited?(path, recorded)
180
178
  return false if Provenance.recorded_sha(recorded).empty? || !File.file?(path)
181
179
 
182
180
  !Provenance.sha_matches?(path, recorded)
@@ -17,30 +17,28 @@ module Samagotchi
17
17
  # unchecked (no disk reads, nothing counted as missing).
18
18
  def self.bundle_status(name, scope_override: nil)
19
19
  provenance = Provenance.new(name: name)
20
- data = provenance.read
21
- return nil unless data
20
+ bundle = provenance.record
21
+ return nil unless bundle
22
22
 
23
- raw_scope = scope_override || data[:scope]&.to_s
24
- scope = raw_scope.nil? || raw_scope.strip.empty? ? "system" : raw_scope
23
+ scope = (scope_override ? bundle.with(scope: scope_override) : bundle).effective_scope
25
24
  target_dir = MemoryPaths.scope_dir(scope)
26
- files = data[:files] || {}
25
+ files = bundle.files
27
26
  details = {}
28
- files.each do |file_key, meta|
29
- file_key_str = file_key.to_s
27
+ files.each do |file_key_str, meta|
30
28
  next details[file_key_str] = unchecked_file(meta) unless target_dir
31
29
 
32
30
  target_path = File.join(target_dir, file_key_str)
33
31
  base_path = provenance.base_path(file_key_str)
34
- stored_checksum = meta[:checksum] || meta["checksum"]
32
+ stored_checksum = meta[:checksum]
35
33
  current_checksum = File.exist?(target_path) ? Digest::SHA256.hexdigest(File.read(target_path)) : nil
36
34
  base_checksum = File.exist?(base_path) ? Digest::SHA256.hexdigest(File.read(base_path)) : nil
37
35
  modified = base_checksum && current_checksum && base_checksum != current_checksum
38
36
  missing = !File.exist?(target_path)
39
37
  # A model overlay has no index line (Installer#note_overlay).
40
- overlay = ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys.map(&:to_s), target_dir: target_dir)
38
+ overlay = ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys, target_dir: target_dir)
41
39
  index_present = overlay ? nil : index_has_entry?(scope, file_key_str)
42
40
  details[file_key_str] = {
43
- conflict: meta.is_a?(Hash) && meta[:conflict] == true,
41
+ conflict: meta[:conflict] == true,
44
42
  stored_checksum: stored_checksum,
45
43
  current_checksum: current_checksum,
46
44
  base_checksum: base_checksum,
@@ -52,23 +50,23 @@ module Samagotchi
52
50
  base_path: base_path
53
51
  }
54
52
  end
55
- { provenance: data, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
56
- target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
57
- hooks_requires_failure: hooks_requires_failure(data), needs: needs_status(data) }
53
+ { provenance: bundle, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
54
+ target_dir: target_dir, files: details, plugin: plugin_status(provenance, bundle),
55
+ hooks_requires_failure: hooks_requires_failure(bundle), needs: needs_status(bundle) }
58
56
  end
59
57
 
60
58
  # Why none of the bundle's hooks load (this chi doesn't meet its
61
59
  # requires_chi, Hooks::BundleLoader), or nil: it has no hooks or meets it.
62
- def self.hooks_requires_failure(data)
63
- return nil if (data[:hooks] || {}).empty?
60
+ def self.hooks_requires_failure(bundle)
61
+ return nil if bundle.hooks.empty?
64
62
 
65
- Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION)
63
+ Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION)
66
64
  end
67
65
 
68
66
  # The stored needs, each with found: from this process's PATH (the
69
67
  # shell's; a worker's PATH can differ, see docs/memory.md).
70
- def self.needs_status(data, path: ENV["PATH"])
71
- Manifest.parse_needs(data[:needs]).map do |need|
68
+ def self.needs_status(bundle, path: ENV["PATH"])
69
+ Manifest.parse_needs(bundle.needs).map do |need|
72
70
  need.merge(found: BundleNeeds.found?(need[:command], path: path))
73
71
  end
74
72
  rescue Manifest::ValidationError
@@ -86,20 +84,20 @@ module Samagotchi
86
84
  # The installed plugin: {file:, path:, state:, requires_chi:,
87
85
  # requires_failure:}; state is "ok", "modified" (its sha256 differs
88
86
  # from the installed one: it won't load) or "missing". nil without one.
89
- def self.plugin_status(provenance, data)
90
- path = provenance.plugin_path(data)
87
+ def self.plugin_status(provenance, bundle)
88
+ path = provenance.plugin_path(bundle)
91
89
  return nil unless path
92
90
 
93
91
  state = if !File.file?(path) then "missing"
94
- elsif Provenance.sha_matches?(path, data[:plugin][:sha256]) then "ok"
92
+ elsif Provenance.sha_matches?(path, bundle.plugin.sha256) then "ok"
95
93
  else "modified"
96
94
  end
97
- { file: File.basename(path), path: path, state: state, requires_chi: data[:requires_chi],
98
- requires_failure: Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION) }
95
+ { file: File.basename(path), path: path, state: state, requires_chi: bundle.requires_chi,
96
+ requires_failure: Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION) }
99
97
  end
100
98
 
101
99
  def self.unchecked_file(meta)
102
- { conflict: meta.is_a?(Hash) && meta[:conflict] == true, stored_checksum: meta.is_a?(Hash) ? meta[:checksum] || meta["checksum"] : nil,
100
+ { conflict: meta[:conflict] == true, stored_checksum: meta[:checksum],
103
101
  unchecked: true, modified: false, missing: false, index_present: nil, overlay: false }
104
102
  end
105
103
  private_class_method :unchecked_file
@@ -72,10 +72,10 @@ module Samagotchi
72
72
  to = gem_manifest.version.to_s
73
73
 
74
74
  with_lock do
75
- data = Provenance.new(name: BUNDLE_NAME).read
76
- from = data && data[:version].to_s
75
+ bundle = Provenance.new(name: BUNDLE_NAME).record
76
+ from = bundle&.version.to_s
77
77
 
78
- if data.nil?
78
+ if bundle.nil?
79
79
  dry_run ? result(:installed, to: to) : install_fresh(to)
80
80
  elsif installed_newer?(from, to)
81
81
  # Running an older checkout/gem: never downgrade the user's system memories
@@ -174,8 +174,7 @@ module Samagotchi
174
174
  # here and changed in the shipped bundle (a dry run's kept).
175
175
  def conflicting(gem_manifest)
176
176
  provenance = Provenance.new(name: BUNDLE_NAME)
177
- owned = (provenance.read || {})[:files]
178
- owned = owned.is_a?(Hash) ? owned.keys.map(&:to_s) : []
177
+ owned = provenance.record&.files&.keys || []
179
178
  (gem_manifest.files.keys.map(&:to_s).sort & owned).select do |key|
180
179
  Merger.classify(base_path: provenance.base_path(key), current_path: File.join(MemoryPaths.system_dir, key),
181
180
  incoming_path: File.join(GEM_BUNDLE_DIR, key)) == :conflict
@@ -33,17 +33,15 @@ module Samagotchi
33
33
 
34
34
  def run
35
35
  provenance = Provenance.new(name: @name)
36
- data = provenance.read
37
- raise UninstallError, "Bundle '#{@name}' is not installed" unless data
36
+ bundle = provenance.record
37
+ raise UninstallError, "Bundle '#{@name}' is not installed" unless bundle
38
38
 
39
- # Determine scope from flag or provenance
40
- cli_scope = @scope.to_s.strip.empty? ? nil : @scope
41
- raw = cli_scope || data[:scope]&.to_s
42
- target_scope = raw.nil? || raw.strip.empty? ? "system" : raw
39
+ # The scope from the flag, else the record's
40
+ target_scope = @scope.to_s.strip.empty? ? bundle.effective_scope : @scope
43
41
  target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
44
42
 
45
- files = data[:files] || {}
46
- hooks = data[:hooks] || {}
43
+ files = bundle.files
44
+ hooks = bundle.hooks
47
45
  if files.empty? && hooks.empty?
48
46
  # No file list, just remove provenance
49
47
  FileUtils.rm_rf(provenance.bundle_dir)
@@ -99,7 +97,7 @@ module Samagotchi
99
97
  # ── Hooks removal (bundle-owned) ──────────────────────────
100
98
  # Hooks are stored under <bundle_dir>/hooks/ and are not part of the index.
101
99
  # Explicit removal for intent; the final rm_rf sweeps it anyway.
102
- if hooks.is_a?(Hash) && !hooks.empty?
100
+ if hooks.any?
103
101
  hooks_dir = provenance.hooks_dir
104
102
  hooks.each do |hook_key, _meta|
105
103
  hook_key_str = hook_key.to_s
@@ -117,7 +115,7 @@ module Samagotchi
117
115
  FileUtils.rm_rf(provenance.hooks_dir)
118
116
  end
119
117
 
120
- plugin_path = provenance.plugin_path(data)
118
+ plugin_path = provenance.plugin_path(bundle)
121
119
  @removed_files << "plugin/#{File.basename(plugin_path)}" if plugin_path && File.exist?(plugin_path)
122
120
  FileUtils.rm_rf(provenance.bundle_dir)
123
121
  true
@@ -44,8 +44,8 @@ module Samagotchi
44
44
  # @return [Array<String>] the bundles whose plugin loaded
45
45
  def load_installed(registries, failures: nil, settings: {})
46
46
  loaded = []
47
- MemoryBundle::Provenance.each_installed(holding: :plugin) do |bundle_name, data|
48
- ok = load_bundle(bundle_name, data, registries, failures: failures, settings: settings[bundle_name.to_s] || {})
47
+ MemoryBundle::Provenance.each_installed(holding: :plugin) do |bundle_name, bundle|
48
+ ok = load_bundle(bundle_name, bundle, registries, failures: failures, settings: settings[bundle_name.to_s] || {})
49
49
  loaded << bundle_name if ok
50
50
  end
51
51
  loaded
@@ -56,10 +56,10 @@ module Samagotchi
56
56
  end
57
57
 
58
58
  # @return [Boolean] whether the plugin loaded
59
- def load_bundle(bundle_name, data, registries, failures: nil, settings: {})
60
- file = MemoryBundle::Provenance.new(name: bundle_name).plugin_path(data) unless data[:error]
59
+ def load_bundle(bundle_name, bundle, registries, failures: nil, settings: {})
60
+ file = MemoryBundle::Provenance.new(name: bundle_name).plugin_path(bundle) unless bundle.error?
61
61
  basename = file ? File.basename(file) : "plugin"
62
- reason = data[:error] || unloadable_reason(file, data)
62
+ reason = bundle.error || unloadable_reason(file, bundle)
63
63
  return failed(bundle_name, basename, reason, failures) if reason
64
64
 
65
65
  api = nil
@@ -82,12 +82,12 @@ module Samagotchi
82
82
  def label(bundle_name, basename) = "#{basename} (bundle #{bundle_name})"
83
83
 
84
84
  # Why the installed file can't be loaded, or nil.
85
- def unloadable_reason(file, data)
86
- failure = MemoryBundle::Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION)
85
+ def unloadable_reason(file, bundle)
86
+ failure = MemoryBundle::Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION)
87
87
  return failure if failure
88
88
  return "the file is missing" unless file && File.file?(file)
89
89
 
90
- Hooks::BundleLoader.sha_mismatch(file, data[:plugin][:sha256], required: true)
90
+ Hooks::BundleLoader.sha_mismatch(file, bundle.plugin.sha256, required: true)
91
91
  end
92
92
 
93
93
  # module_eval the file into a fresh module in the bundle's namespace
@@ -406,8 +406,8 @@ module Samagotchi
406
406
  # "samagotchi-system 0.1.5 (shipped 0.1.5), other 1.0.0"
407
407
  def bundles_summary
408
408
  shipped = MemoryBundle::Manifest.read(dir: MemoryBundle::SystemBundle::GEM_BUNDLE_DIR).version rescue nil
409
- installed = MemoryBundle::Provenance.each_installed.map do |name, data|
410
- version = data[:version] || "?"
409
+ installed = MemoryBundle::Provenance.each_installed.map do |name, bundle|
410
+ version = bundle.version || "?"
411
411
  label = "#{name} #{version}"
412
412
  label += " (shipped #{shipped})" if name == MemoryBundle::SystemBundle::BUNDLE_NAME
413
413
  label