samagotchi 0.52.0 → 0.54.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +94 -54
  5. data/docs/configuration.md +1 -2
  6. data/docs/context.md +104 -9
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bundles.md +73 -0
  9. data/docs/internals/context-telemetry.md +8 -5
  10. data/docs/internals/engine.md +16 -7
  11. data/docs/internals/tool-guardrails.md +8 -6
  12. data/docs/plugins.md +2 -1
  13. data/docs/releasing.md +6 -3
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +5 -3
  16. data/lib/samagotchi/analytics_file.rb +35 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  18. data/lib/samagotchi/bundle_command.rb +35 -44
  19. data/lib/samagotchi/bundle_needs.rb +4 -5
  20. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  22. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/child_move.rb +6 -4
  25. data/lib/samagotchi/config.rb +0 -1
  26. data/lib/samagotchi/context_command.rb +1 -1
  27. data/lib/samagotchi/context_providers.rb +11 -14
  28. data/lib/samagotchi/context_status.rb +10 -18
  29. data/lib/samagotchi/engine.rb +132 -135
  30. data/lib/samagotchi/extension_load.rb +102 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
  33. data/lib/samagotchi/host_registry.rb +13 -0
  34. data/lib/samagotchi/kernel_loop.rb +5 -3
  35. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  36. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  37. data/lib/samagotchi/memory_bundle/builder.rb +184 -190
  38. data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
  39. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  40. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  41. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  42. data/lib/samagotchi/memory_bundle/manifest.rb +9 -29
  43. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  44. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  45. data/lib/samagotchi/memory_bundle/provenance.rb +46 -57
  46. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  47. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  48. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  50. data/lib/samagotchi/plugin/loader.rb +8 -8
  51. data/lib/samagotchi/self_report.rb +2 -2
  52. data/lib/samagotchi/send_command.rb +4 -28
  53. data/lib/samagotchi/session_commands.rb +32 -4
  54. data/lib/samagotchi/session_continue.rb +275 -0
  55. data/lib/samagotchi/session_manager.rb +45 -257
  56. data/lib/samagotchi/session_metrics.rb +18 -21
  57. data/lib/samagotchi/sessions_command.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  59. data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
  60. data/lib/samagotchi/terminal_ui.rb +25 -3
  61. data/lib/samagotchi/tool_activity.rb +84 -14
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/web/app.rb +3 -8
  64. data/lib/samagotchi/web/public/activity.js +2 -0
  65. data/lib/samagotchi/web/public/app.js +6 -8
  66. data/lib/samagotchi/web/public/command_complete.js +7 -2
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/index.html +4 -2
  69. data/lib/samagotchi/web/public/question_card.js +9 -1
  70. data/lib/samagotchi/web/public/question_cards.js +5 -3
  71. data/lib/samagotchi/web/public/thinking_chip.js +31 -21
  72. data/lib/samagotchi/web/public/timing.js +8 -2
  73. data/lib/samagotchi/web/public/turn_html.js +10 -6
  74. metadata +9 -1
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "fileutils"
5
5
  require "tmpdir"
6
+ require_relative "bundle_hook"
6
7
  require_relative "manifest"
7
8
  require_relative "placeholder"
8
9
  require_relative "../context_providers"
@@ -22,6 +23,16 @@ module Samagotchi
22
23
  DEFAULT_VERSION = "1.0.0"
23
24
  DEFAULT_SYSTEM_NAME = "chi_system_memories"
24
25
 
26
+ # What a build takes from the installed bundle of the same name (its
27
+ # record and its dir), else from the scope dir:
28
+ # record the InstalledBundle, nil when none is installed (or
29
+ # it doesn't read)
30
+ # hooks basename => BundleHook; hook_files basename => path
31
+ # rules guardrails/*.yml paths
32
+ # plugin the installed plugin file, nil without one
33
+ # scripts file name => path; context_providers the parsed list
34
+ InstalledParts = Data.define(:record, :hooks, :hook_files, :rules, :plugin, :scripts, :context_providers)
35
+
25
36
  attr_reader :built_files, :warnings, :placeholder_warnings, :out_path, :staging_dir
26
37
 
27
38
  def initialize(scope: nil, name: nil, version: nil, description: "", out: nil, files: nil, trust_level: nil)
@@ -50,242 +61,225 @@ module Samagotchi
50
61
  validate_version!(resolved_version)
51
62
  raise BuildError, "invalid scope: #{@scope}" unless %w[system project].include?(@scope)
52
63
 
53
- # Collect *.md files, excluding index.md and hidden files
54
- all_md = Dir.glob(File.join(target_dir, "*.md")).sort
55
- candidates = all_md.reject { |p| File.basename(p) == "index.md" }
56
- candidates.reject! { |p| File.basename(p).start_with?(".") }
64
+ refuse_profile!(resolved_name)
65
+
66
+ candidates = select_memories(target_dir, resolved_name)
67
+ files_map = checksum_memories(candidates)
68
+
69
+ parts = installed_parts(resolved_name, target_dir)
70
+ resolved_out, format = resolve_out_path(@out, resolved_name)
71
+
72
+ staging = Dir.mktmpdir("samagotchi-build-")
73
+ @staging_dir = staging
74
+ begin
75
+ stage_files(staging, candidates, parts)
76
+ write_manifest(staging, resolved_name, resolved_version, files_map, parts)
77
+ write_out(staging, resolved_out, format)
78
+ @out_path = resolved_out
79
+ ensure
80
+ FileUtils.rm_rf(staging) if staging && File.directory?(staging)
81
+ @staging_dir = nil
82
+ end
57
83
 
84
+ {
85
+ out_path: @out_path,
86
+ format: format,
87
+ name: resolved_name,
88
+ version: resolved_version,
89
+ scope: @scope,
90
+ files: @built_files.dup,
91
+ placeholder_warnings: @placeholder_warnings.dup
92
+ }
93
+ end
94
+
95
+ def summary
96
+ lines = []
97
+ lines << "Built #{@built_files.size} file(s) to #{@out_path}"
98
+ lines << "Files: #{@built_files.join(", ")}" unless @built_files.empty?
99
+ lines.concat(@placeholder_warnings.map { |w| "Placeholder: #{w}" }) unless @placeholder_warnings.empty?
100
+ lines.concat(@warnings) unless @warnings.empty?
101
+ lines.join("\n")
102
+ end
103
+
104
+ private
105
+
106
+ # The scope's memories to build: every *.md but index.md and hidden
107
+ # ones; with an allowlist the named ones (each must exist) with their
108
+ # model overlays, else all but those another installed bundle owns.
109
+ # @return [Array<String>] paths
110
+ def select_memories(target_dir, name)
111
+ candidates = Dir.glob(File.join(target_dir, "*.md")).sort
112
+ .reject { |p| File.basename(p) == "index.md" || File.basename(p).start_with?(".") }
58
113
  if candidates.empty?
59
114
  raise BuildError, "No memories to build in #{@scope} scope (#{target_dir}): no .md files found"
60
115
  end
116
+ return leave_out_owned(candidates, name) unless @filter_files && !@filter_files.empty?
61
117
 
62
- # Apply allowlist filter if provided
63
- if @filter_files && !@filter_files.empty?
64
- allow = @filter_files.map { |f| normalize_filter_entry(f) }
65
- # Validate each requested file exists in candidates
66
- candidates_by_basename = candidates.map { |p| [File.basename(p), p] }.to_h
67
- missing = allow.reject { |k| candidates_by_basename.key?(k) }
68
- unless missing.empty?
69
- raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(", ")}"
70
- end
118
+ allow = @filter_files.map { |f| normalize_filter_entry(f) }
119
+ candidates_by_basename = candidates.to_h { |p| [File.basename(p), p] }
120
+ missing = allow.reject { |k| candidates_by_basename.key?(k) }
121
+ raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(", ")}" unless missing.empty?
71
122
 
72
- candidates = with_overlays(allow, candidates_by_basename).map { |k| candidates_by_basename[k] }
73
- else
74
- candidates = leave_out_owned(candidates, resolved_name)
75
- end
123
+ with_overlays(allow, candidates_by_basename).map { |k| candidates_by_basename[k] }
124
+ end
76
125
 
77
- # Compute checksums and detect placeholders
78
- files_map = {}
79
- candidates.each do |path|
126
+ # file key => "sha256:<hex>" for the manifest; fills @built_files and
127
+ # a placeholder warning per memory with {{placeholders}}.
128
+ def checksum_memories(paths)
129
+ paths.to_h do |path|
80
130
  key = File.basename(path)
81
131
  content = File.read(path)
82
- hex = Digest::SHA256.hexdigest(content)
83
- files_map[key] = "sha256:#{hex}"
84
132
  @built_files << key
85
-
86
133
  names = Placeholder.new(content: content).placeholders
87
- unless names.empty?
88
- uniq = names.map(&:strip).uniq.sort
89
- @placeholder_warnings << "#{key}: {{#{uniq.join("}}, {{")}}}"
90
- end
134
+ @placeholder_warnings << "#{key}: {{#{names.map(&:strip).uniq.sort.join("}}, {{")}}}" unless names.empty?
135
+ [key, "sha256:#{Digest::SHA256.hexdigest(content)}"]
91
136
  end
137
+ end
92
138
 
93
- # ── Hooks: collect hooks for the bundle ──────────────────────
94
- hooks_map = {}
95
- hooks_to_copy = [] # Array of [src_path, basename]
96
- # Try provenance first (if bundle with this name is installed)
139
+ # The installed bundle's hooks (a recorded one missing on disk left
140
+ # out; one with no recorded sha gets its file's), guardrail rules,
141
+ # plugin, scripts and context providers. Without hooks or rules in a
142
+ # record, the scope dir's hooks/*.rb and guardrails/*.yml (building
143
+ # from a raw bundle dir that is also the memories dir).
144
+ # @return [InstalledParts]
145
+ def installed_parts(name, target_dir)
97
146
  prov = nil
98
- prov_data = nil
147
+ record = nil
99
148
  begin
100
149
  require_relative "provenance"
101
- prov = Provenance.new(name: resolved_name)
102
- prov_data = prov.read
150
+ prov = Provenance.new(name: name)
151
+ record = prov.record
103
152
  rescue StandardError
104
- prov_data = nil
153
+ record = nil
105
154
  end
106
- if prov_data && prov_data[:hooks].is_a?(Hash) && !prov_data[:hooks].empty?
107
- prov_data[:hooks].each do |k, meta|
108
- basename = k.to_s
155
+
156
+ hooks = {}
157
+ hook_files = {}
158
+ if record&.hooks&.any?
159
+ record.hooks.each do |basename, hook|
109
160
  src = File.join(prov.hooks_dir, basename)
110
- # Skip if hook file missing on disk
111
161
  next unless File.exist?(src)
112
162
 
113
- # meta may be symbol-keyed
114
- m = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
115
- sha = (m["sha256"] || m["checksum"] || "").to_s
116
- if sha.empty?
117
- sha = "sha256:#{Digest::SHA256.hexdigest(File.read(src))}"
118
- elsif !sha.start_with?("sha256:")
119
- sha = "sha256:#{sha}"
120
- end
121
- hooks_map[basename] = {
122
- "sha256" => sha,
123
- "event" => (m["event"] || "").to_s,
124
- "on_error" => (m["on_error"] || "skip").to_s,
125
- "priority" => (m["priority"] || 100).to_i
126
- }
127
- hooks_to_copy << [src, basename]
163
+ hooks[basename] = hook.sha256.empty? ? hook.with(sha256: BundleHook.of_file(src).sha256) : hook
164
+ hook_files[basename] = src
128
165
  end
129
166
  else
130
- # Fallback: scan target_dir/hooks if present (e.g. building from a raw bundle dir that is also the mem dir)
131
- local_hooks_dir = File.join(target_dir, "hooks")
132
- if Dir.exist?(local_hooks_dir)
133
- Dir.glob(File.join(local_hooks_dir, "*.rb")).sort.each do |src|
134
- basename = File.basename(src)
135
- sha = Digest::SHA256.hexdigest(File.read(src))
136
- hooks_map[basename] = {
137
- "sha256" => "sha256:#{sha}",
138
- "event" => "",
139
- "on_error" => "skip",
140
- "priority" => 100
141
- }
142
- hooks_to_copy << [src, basename]
143
- end
167
+ Dir.glob(File.join(target_dir, "hooks", "*.rb")).sort.each do |src|
168
+ hooks[File.basename(src)] = BundleHook.of_file(src)
169
+ hook_files[File.basename(src)] = src
144
170
  end
145
171
  end
146
- # ── Guardrail rules: the installed bundle's, else target_dir's ──
147
- rules_dir = prov && prov_data && prov_data[:guardrails].is_a?(Hash) ? prov.guardrails_dir : File.join(target_dir, "guardrails")
148
- rules_to_copy = Dir.exist?(rules_dir) ? Dir.glob(File.join(rules_dir, "*.yml")).sort : []
149
-
150
- # ── Plugin: the installed bundle's, with its requires_chi ──
151
- plugin_src = prov && prov_data ? prov.plugin_path(prov_data) : nil
152
- plugin_src = nil unless plugin_src && File.file?(plugin_src)
153
- requires_chi = prov_data && prov_data[:requires_chi]
154
- needs = prov_data && prov_data[:needs]
155
-
156
- # ── Scripts and context providers: the installed bundle's ──
157
- scripts_to_copy = installed_scripts(prov, prov_data)
158
- context_providers = prov_data ? ContextProviders.parse_list(prov_data[:context_providers]) : []
159
-
160
- # Determine trust_level for the built bundle
161
- build_trust_level = @trust_level
162
- if (build_trust_level.nil? || build_trust_level.empty?) && prov_data && prov_data[:trust_level]
163
- build_trust_level = prov_data[:trust_level].to_s
164
- end
165
-
166
- # Resolve output path and format
167
- resolved_out, format = resolve_out_path(@out, resolved_name)
168
172
 
169
- # Prepare staging dir (temp)
170
- staging = Dir.mktmpdir("samagotchi-build-")
171
- @staging_dir = staging
172
- begin
173
- candidates.each do |src|
174
- FileUtils.cp(src, File.join(staging, File.basename(src)))
175
- end
176
- # Copy hooks into staging/hooks/
177
- unless hooks_to_copy.empty?
178
- hooks_staging = File.join(staging, "hooks")
179
- FileUtils.mkdir_p(hooks_staging)
180
- hooks_to_copy.each do |src, basename|
181
- FileUtils.cp(src, File.join(hooks_staging, basename))
182
- end
183
- end
173
+ rules_dir = record&.guardrails&.any? ? prov.guardrails_dir : File.join(target_dir, "guardrails")
174
+ plugin = record ? prov.plugin_path(record) : nil
175
+ InstalledParts.new(
176
+ record: record, hooks: hooks, hook_files: hook_files,
177
+ rules: Dir.exist?(rules_dir) ? Dir.glob(File.join(rules_dir, "*.yml")).sort : [],
178
+ plugin: plugin && File.file?(plugin) ? plugin : nil,
179
+ scripts: installed_scripts(prov, record),
180
+ context_providers: record ? ContextProviders.parse_list(record.context_providers) : []
181
+ )
182
+ end
184
183
 
185
- unless rules_to_copy.empty?
186
- rules_staging = File.join(staging, "guardrails")
187
- FileUtils.mkdir_p(rules_staging)
188
- rules_to_copy.each { |src| FileUtils.cp(src, File.join(rules_staging, File.basename(src))) }
189
- end
184
+ # The memories, hooks/, guardrails/, the plugin and scripts/ into the
185
+ # staging dir, laid out as Installer reads a bundle.
186
+ def stage_files(staging, candidates, parts)
187
+ candidates.each { |src| FileUtils.cp(src, File.join(staging, File.basename(src))) }
188
+ unless parts.hook_files.empty?
189
+ FileUtils.mkdir_p(File.join(staging, "hooks"))
190
+ parts.hook_files.each { |basename, src| FileUtils.cp(src, File.join(staging, "hooks", basename)) }
191
+ end
192
+ unless parts.rules.empty?
193
+ FileUtils.mkdir_p(File.join(staging, "guardrails"))
194
+ parts.rules.each { |src| FileUtils.cp(src, File.join(staging, "guardrails", File.basename(src))) }
195
+ end
196
+ FileUtils.cp(parts.plugin, File.join(staging, File.basename(parts.plugin))) if parts.plugin
197
+ return if parts.scripts.empty?
190
198
 
191
- FileUtils.cp(plugin_src, File.join(staging, File.basename(plugin_src))) if plugin_src
199
+ FileUtils.mkdir_p(File.join(staging, "scripts"))
200
+ parts.scripts.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
201
+ end
192
202
 
193
- unless scripts_to_copy.empty?
194
- FileUtils.mkdir_p(File.join(staging, "scripts"))
195
- scripts_to_copy.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
196
- end
203
+ # manifest.yml for the staged bundle: requires_chi, needs and the
204
+ # file descriptions come from the installed bundle's record, the
205
+ # trust_level from the CLI else the record.
206
+ def write_manifest(staging, name, version, files_map, parts)
207
+ record = parts.record
208
+ trust_level = @trust_level
209
+ trust_level = record.trust_level if (trust_level.nil? || trust_level.empty?) && record&.trust_level
210
+ Manifest.write(
211
+ dir: staging,
212
+ name: name,
213
+ version: version,
214
+ scope: @scope,
215
+ description: @description,
216
+ files: files_map,
217
+ hooks: parts.hooks.empty? ? nil : parts.hooks,
218
+ trust_level: trust_level,
219
+ plugin: parts.plugin && { file: File.basename(parts.plugin), sha256: Digest::SHA256.hexdigest(File.binread(parts.plugin)) },
220
+ requires_chi: record&.requires_chi,
221
+ needs: record&.needs,
222
+ scripts: parts.scripts.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
223
+ context_providers: parts.context_providers,
224
+ file_descriptions: file_descriptions(record, files_map)
225
+ )
226
+ end
197
227
 
198
- Manifest.write(
199
- dir: staging,
200
- name: resolved_name,
201
- version: resolved_version,
202
- scope: @scope,
203
- description: @description,
204
- files: files_map,
205
- hooks: hooks_map.empty? ? nil : hooks_map,
206
- trust_level: build_trust_level,
207
- plugin: plugin_src && { file: File.basename(plugin_src), sha256: Digest::SHA256.hexdigest(File.binread(plugin_src)) },
208
- requires_chi: requires_chi,
209
- needs: needs,
210
- scripts: scripts_to_copy.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
211
- context_providers: context_providers,
212
- file_descriptions: file_descriptions(prov_data, files_map)
213
- )
214
-
215
- case format
216
- when :zip
217
- zip_staging(staging, resolved_out)
218
- @out_path = resolved_out
219
- when :tar_gz, :tar, :tgz
220
- tar_staging(staging, resolved_out, format)
221
- @out_path = resolved_out
222
- when :dir
223
- # Copy staging contents to out dir
224
- FileUtils.mkdir_p(resolved_out)
225
- # Guard: refuse to overwrite non-empty dir without force (error)
226
- existing = Dir.entries(resolved_out).reject { |e| e.start_with?(".") }
227
- unless existing.empty?
228
- # Allow if out is the staging itself? no, staging is temp
229
- # Check if out dir already has manifest.yml — treat as existing bundle
230
- if File.exist?(File.join(resolved_out, "manifest.yml"))
231
- raise BuildError, "Output directory already contains a bundle (#{resolved_out}/manifest.yml) — choose different --out or remove it"
232
- end
233
- # If dir has any files, still require explicit handling — error
234
- unless existing.empty?
235
- raise BuildError, "Output directory already exists and is not empty: #{resolved_out}"
236
- end
228
+ # The staged bundle to +out+: a zip or tar archive, or a directory,
229
+ # which must be new or empty (one holding a bundle says so).
230
+ def write_out(staging, out, format)
231
+ case format
232
+ when :zip then zip_staging(staging, out)
233
+ when :tar_gz, :tar, :tgz then tar_staging(staging, out, format)
234
+ when :dir
235
+ FileUtils.mkdir_p(out)
236
+ unless Dir.entries(out).reject { |e| e.start_with?(".") }.empty?
237
+ if File.exist?(File.join(out, "manifest.yml"))
238
+ raise BuildError, "Output directory already contains a bundle (#{out}/manifest.yml) — choose different --out or remove it"
237
239
  end
238
- FileUtils.cp_r("#{staging}/.", resolved_out)
239
- @out_path = resolved_out
240
- else
241
- raise BuildError, "unknown format: #{format}"
240
+
241
+ raise BuildError, "Output directory already exists and is not empty: #{out}"
242
242
  end
243
- ensure
244
- # Clean up staging temp dir unless out_path == staging (not possible for dir)
245
- FileUtils.rm_rf(staging) if staging && File.directory?(staging)
246
- @staging_dir = nil
243
+ FileUtils.cp_r("#{staging}/.", out)
244
+ else
245
+ raise BuildError, "unknown format: #{format}"
247
246
  end
248
-
249
- {
250
- out_path: @out_path,
251
- format: format,
252
- name: resolved_name,
253
- version: resolved_version,
254
- scope: @scope,
255
- files: @built_files.dup,
256
- placeholder_warnings: @placeholder_warnings.dup
257
- }
258
247
  end
259
248
 
260
- def summary
261
- lines = []
262
- lines << "Built #{@built_files.size} file(s) to #{@out_path}"
263
- lines << "Files: #{@built_files.join(", ")}" unless @built_files.empty?
264
- lines.concat(@placeholder_warnings.map { |w| "Placeholder: #{w}" }) unless @placeholder_warnings.empty?
265
- lines.concat(@warnings) unless @warnings.empty?
266
- lines.join("\n")
249
+ # An installed profile (meta bundle) ships only its includes: a build
250
+ # under its name would make a plain bundle of the scope's memories,
251
+ # without the includes, carrying the profile's trust_level.
252
+ def refuse_profile!(name)
253
+ require_relative "profile"
254
+ record = Provenance.new(name: name).record
255
+ return unless Profile.installed_meta?(name, record)
256
+
257
+ shipped = File.join(SourceNormalizer::SHIPPED_DIR, name)
258
+ members = Profile.recorded(record, File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil)
259
+ raise BuildError, "#{name} is an installed profile (includes: #{members.join(", ")}): it ships only its includes, " \
260
+ "so a build can't remake it; pick another --name for these memories"
261
+ rescue JSON::ParserError, SystemCallError, Manifest::ValidationError
262
+ nil
267
263
  end
268
264
 
269
- private
270
-
271
265
  # The installed bundle's scripts its record lists, file name => path
272
266
  # in its scripts/ (a missing one is left out); {} without a record.
273
- def installed_scripts(prov, prov_data)
274
- return {} unless prov && prov_data && prov_data[:scripts].is_a?(Hash)
267
+ def installed_scripts(prov, record)
268
+ return {} unless record
275
269
 
276
- prov_data[:scripts].keys.map(&:to_s).sort.to_h { |file| [file, File.join(prov.scripts_dir, file)] }
277
- .select { |_, path| File.file?(path) }
270
+ record.scripts.keys.sort.to_h { |file| [file, File.join(prov.scripts_dir, file)] }
271
+ .select { |_, path| File.file?(path) }
278
272
  end
279
273
 
280
274
  # The descriptions the installed bundle's record keeps for the files
281
275
  # being built (its manifest's files: mappings), so a rebuild writes
282
276
  # them back; {} without a record.
283
- def file_descriptions(prov_data, files_map)
284
- return {} unless prov_data && prov_data[:files].is_a?(Hash)
277
+ def file_descriptions(record, files_map)
278
+ return {} unless record
285
279
 
286
- prov_data[:files].each_with_object({}) do |(key, entry), acc|
287
- text = entry.is_a?(Hash) ? entry[:description].to_s : ""
288
- acc[key.to_s] = text if files_map.key?(key.to_s) && !text.empty?
280
+ record.files.each_with_object({}) do |(key, entry), acc|
281
+ text = entry[:description].to_s
282
+ acc[key] = text if files_map.key?(key) && !text.empty?
289
283
  end
290
284
  end
291
285
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Samagotchi
6
+ module MemoryBundle
7
+ # One hook's metadata, as a bundle's manifest.yml declares it and its
8
+ # installed record (manifest.json) keeps it: the file's sha256
9
+ # ("sha256:<hex>", "" when none is recorded), the event it registers
10
+ # for ("" when none: it isn't loaded), on_error (skip, log or
11
+ # fail_closed) and priority. The one place the defaults live.
12
+ class BundleHook < Data.define(:sha256, :event, :on_error, :priority)
13
+ DEFAULT_ON_ERROR = "skip"
14
+ DEFAULT_PRIORITY = 100
15
+
16
+ def initialize(sha256: "", event: "", on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
17
+ super
18
+ end
19
+
20
+ # @param raw [Hash, BundleHook, nil] string keys (manifest.yml) or
21
+ # symbol keys (a read record); missing or blank values get the
22
+ # defaults
23
+ def self.parse(raw)
24
+ return raw if raw.is_a?(BundleHook)
25
+
26
+ h = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
27
+ on_error = h["on_error"].to_s.strip
28
+ new(sha256: prefixed(h["sha256"]), event: h["event"].to_s.strip,
29
+ on_error: on_error.empty? ? DEFAULT_ON_ERROR : on_error,
30
+ priority: (h["priority"] || DEFAULT_PRIORITY).to_i)
31
+ end
32
+
33
+ # A hook known only by its file (no declared event): its sha, the defaults.
34
+ def self.of_file(path)
35
+ new(sha256: "sha256:#{Digest::SHA256.hexdigest(File.binread(path))}")
36
+ end
37
+
38
+ # "sha256:<hex>" from either form, "" for none.
39
+ def self.prefixed(sha)
40
+ sha = sha.to_s.strip
41
+ sha.empty? || sha.start_with?("sha256:") ? sha : "sha256:#{sha}"
42
+ end
43
+
44
+ # The sha's hex, "" when none is recorded.
45
+ def hex = sha256.delete_prefix("sha256:")
46
+
47
+ # The string-keyed form manifest.yml and manifest.json hold.
48
+ def to_record = to_h.transform_keys(&:to_s)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "bundle_hook"
5
+ require_relative "plugin_ref"
6
+
7
+ module Samagotchi
8
+ module MemoryBundle
9
+ # An installed bundle's record, as Provenance#write keeps it in
10
+ # <bundles>/<name>/manifest.json. Reading is tolerant: an older record
11
+ # lacks keys a newer chi writes, and a key that is missing or of the
12
+ # wrong shape reads as empty (nil for the optional strings and lists).
13
+ #
14
+ # files file key ("x.md") => { checksum:, conflict:, description: } (symbol keys)
15
+ # hooks basename => BundleHook
16
+ # guardrails basename => recorded sha256 ("" when none)
17
+ # scripts file name => recorded sha256 ("" when none)
18
+ # plugin PluginRef, nil without one
19
+ # needs, context_providers
20
+ # the lists as recorded (nil when absent): Manifest.parse_needs
21
+ # and ContextProviders.parse_list check them
22
+ # includes a profile's recorded members, nil for a plain bundle
23
+ # error why manifest.json couldn't be read (the rest empty), else nil
24
+ class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
25
+ :requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
26
+ :includes, :context_providers, :error)
27
+ OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
28
+ requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
29
+ includes: nil, context_providers: nil, error: nil }.freeze
30
+
31
+ def initialize(name:, **fields)
32
+ super(name: name, **OPTIONAL, **fields)
33
+ end
34
+
35
+ # The record at +path+, nil when there is none.
36
+ # @raise [JSON::ParserError, SystemCallError] it doesn't read
37
+ def self.read(name, path)
38
+ return nil unless File.exist?(path)
39
+
40
+ parse(name, JSON.parse(File.read(path)))
41
+ end
42
+
43
+ # One that couldn't be read: +message+ says why.
44
+ def self.unreadable(name, message) = new(name: name, error: message)
45
+
46
+ # @param raw [Hash] the parsed manifest.json (string or symbol keys);
47
+ # anything else reads as unreadable
48
+ def self.parse(name, raw)
49
+ return unreadable(name, "manifest.json is not an object") unless raw.is_a?(Hash)
50
+
51
+ h = raw.transform_keys(&:to_s)
52
+ new(name: name, version: string(h["version"]), scope: string(h["scope"]), source: string(h["source"]),
53
+ installed_at: string(h["installed_at"]), trust_level: string(h["trust_level"]),
54
+ source_commit: string(h["source_commit"]), requires_chi: string(h["requires_chi"]),
55
+ files: mapping(h["files"]) { |entry| entry.is_a?(Hash) ? entry.transform_keys(&:to_sym) : {} },
56
+ hooks: mapping(h["hooks"]) { |meta| BundleHook.parse(meta) },
57
+ guardrails: mapping(h["guardrails"]) { |meta| sha(meta) },
58
+ scripts: mapping(h["scripts"]) { |meta| sha(meta) },
59
+ plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
60
+ includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil)
61
+ end
62
+
63
+ def self.string(value) = value&.to_s
64
+
65
+ def self.mapping(value, &)
66
+ value.is_a?(Hash) ? value.to_h { |k, v| [k.to_s, yield(v)] } : {}
67
+ end
68
+
69
+ def self.sha(meta) = meta.is_a?(Hash) ? meta.transform_keys(&:to_s)["sha256"].to_s : ""
70
+ private_class_method :string, :mapping, :sha
71
+
72
+ def error? = !error.nil?
73
+
74
+ # The scope its files went to: "system" when none is recorded.
75
+ def effective_scope = scope.to_s.strip.empty? ? "system" : scope
76
+
77
+ # A bundle with no trust_level counts as experimental.
78
+ def experimental? = (trust_level || "experimental") == "experimental"
79
+
80
+ # A profile (meta bundle): it records its members.
81
+ def profile? = !includes.nil?
82
+
83
+ def owns?(file_key) = files.key?(file_key.to_s)
84
+
85
+ def needs? = needs.is_a?(Array) && !needs.empty?
86
+
87
+ def context_providers? = context_providers.is_a?(Array) && !context_providers.empty?
88
+ end
89
+ end
90
+ end