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
@@ -23,6 +23,16 @@ module Samagotchi
23
23
  DEFAULT_VERSION = "1.0.0"
24
24
  DEFAULT_SYSTEM_NAME = "chi_system_memories"
25
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
+
26
36
  attr_reader :built_files, :warnings, :placeholder_warnings, :out_path, :staging_dir
27
37
 
28
38
  def initialize(scope: nil, name: nil, version: nil, description: "", out: nil, files: nil, trust_level: nil)
@@ -53,217 +63,199 @@ module Samagotchi
53
63
 
54
64
  refuse_profile!(resolved_name)
55
65
 
56
- # Collect *.md files, excluding index.md and hidden files
57
- all_md = Dir.glob(File.join(target_dir, "*.md")).sort
58
- candidates = all_md.reject { |p| File.basename(p) == "index.md" }
59
- candidates.reject! { |p| File.basename(p).start_with?(".") }
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
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
60
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?(".") }
61
113
  if candidates.empty?
62
114
  raise BuildError, "No memories to build in #{@scope} scope (#{target_dir}): no .md files found"
63
115
  end
116
+ return leave_out_owned(candidates, name) unless @filter_files && !@filter_files.empty?
64
117
 
65
- # Apply allowlist filter if provided
66
- if @filter_files && !@filter_files.empty?
67
- allow = @filter_files.map { |f| normalize_filter_entry(f) }
68
- # Validate each requested file exists in candidates
69
- candidates_by_basename = candidates.map { |p| [File.basename(p), p] }.to_h
70
- missing = allow.reject { |k| candidates_by_basename.key?(k) }
71
- unless missing.empty?
72
- raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(", ")}"
73
- 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?
74
122
 
75
- candidates = with_overlays(allow, candidates_by_basename).map { |k| candidates_by_basename[k] }
76
- else
77
- candidates = leave_out_owned(candidates, resolved_name)
78
- end
123
+ with_overlays(allow, candidates_by_basename).map { |k| candidates_by_basename[k] }
124
+ end
79
125
 
80
- # Compute checksums and detect placeholders
81
- files_map = {}
82
- 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|
83
130
  key = File.basename(path)
84
131
  content = File.read(path)
85
- hex = Digest::SHA256.hexdigest(content)
86
- files_map[key] = "sha256:#{hex}"
87
132
  @built_files << key
88
-
89
133
  names = Placeholder.new(content: content).placeholders
90
- unless names.empty?
91
- uniq = names.map(&:strip).uniq.sort
92
- @placeholder_warnings << "#{key}: {{#{uniq.join("}}, {{")}}}"
93
- end
134
+ @placeholder_warnings << "#{key}: {{#{names.map(&:strip).uniq.sort.join("}}, {{")}}}" unless names.empty?
135
+ [key, "sha256:#{Digest::SHA256.hexdigest(content)}"]
94
136
  end
137
+ end
95
138
 
96
- # ── Hooks: collect hooks for the bundle ──────────────────────
97
- hooks_map = {}
98
- hooks_to_copy = [] # Array of [src_path, basename]
99
- # 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)
100
146
  prov = nil
101
- prov_data = nil
147
+ record = nil
102
148
  begin
103
149
  require_relative "provenance"
104
- prov = Provenance.new(name: resolved_name)
105
- prov_data = prov.read
150
+ prov = Provenance.new(name: name)
151
+ record = prov.record
106
152
  rescue StandardError
107
- prov_data = nil
153
+ record = nil
108
154
  end
109
- if prov_data && prov_data[:hooks].is_a?(Hash) && !prov_data[:hooks].empty?
110
- prov_data[:hooks].each do |k, meta|
111
- basename = k.to_s
155
+
156
+ hooks = {}
157
+ hook_files = {}
158
+ if record&.hooks&.any?
159
+ record.hooks.each do |basename, hook|
112
160
  src = File.join(prov.hooks_dir, basename)
113
- # Skip if hook file missing on disk
114
161
  next unless File.exist?(src)
115
162
 
116
- hook = BundleHook.parse(meta)
117
- hook = hook.with(sha256: BundleHook.of_file(src).sha256) if hook.sha256.empty?
118
- hooks_map[basename] = hook
119
- 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
120
165
  end
121
166
  else
122
- # Fallback: scan target_dir/hooks if present (e.g. building from a raw bundle dir that is also the mem dir)
123
- local_hooks_dir = File.join(target_dir, "hooks")
124
- if Dir.exist?(local_hooks_dir)
125
- Dir.glob(File.join(local_hooks_dir, "*.rb")).sort.each do |src|
126
- basename = File.basename(src)
127
- hooks_map[basename] = BundleHook.of_file(src)
128
- hooks_to_copy << [src, basename]
129
- 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
130
170
  end
131
171
  end
132
- # ── Guardrail rules: the installed bundle's, else target_dir's ──
133
- rules_dir = prov && prov_data && prov_data[:guardrails].is_a?(Hash) ? prov.guardrails_dir : File.join(target_dir, "guardrails")
134
- rules_to_copy = Dir.exist?(rules_dir) ? Dir.glob(File.join(rules_dir, "*.yml")).sort : []
135
-
136
- # ── Plugin: the installed bundle's, with its requires_chi ──
137
- plugin_src = prov && prov_data ? prov.plugin_path(prov_data) : nil
138
- plugin_src = nil unless plugin_src && File.file?(plugin_src)
139
- requires_chi = prov_data && prov_data[:requires_chi]
140
- needs = prov_data && prov_data[:needs]
141
-
142
- # ── Scripts and context providers: the installed bundle's ──
143
- scripts_to_copy = installed_scripts(prov, prov_data)
144
- context_providers = prov_data ? ContextProviders.parse_list(prov_data[:context_providers]) : []
145
-
146
- # Determine trust_level for the built bundle
147
- build_trust_level = @trust_level
148
- if (build_trust_level.nil? || build_trust_level.empty?) && prov_data && prov_data[:trust_level]
149
- build_trust_level = prov_data[:trust_level].to_s
150
- end
151
172
 
152
- # Resolve output path and format
153
- resolved_out, format = resolve_out_path(@out, resolved_name)
154
-
155
- # Prepare staging dir (temp)
156
- staging = Dir.mktmpdir("samagotchi-build-")
157
- @staging_dir = staging
158
- begin
159
- candidates.each do |src|
160
- FileUtils.cp(src, File.join(staging, File.basename(src)))
161
- end
162
- # Copy hooks into staging/hooks/
163
- unless hooks_to_copy.empty?
164
- hooks_staging = File.join(staging, "hooks")
165
- FileUtils.mkdir_p(hooks_staging)
166
- hooks_to_copy.each do |src, basename|
167
- FileUtils.cp(src, File.join(hooks_staging, basename))
168
- end
169
- 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
170
183
 
171
- unless rules_to_copy.empty?
172
- rules_staging = File.join(staging, "guardrails")
173
- FileUtils.mkdir_p(rules_staging)
174
- rules_to_copy.each { |src| FileUtils.cp(src, File.join(rules_staging, File.basename(src))) }
175
- 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?
176
198
 
177
- 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
178
202
 
179
- unless scripts_to_copy.empty?
180
- FileUtils.mkdir_p(File.join(staging, "scripts"))
181
- scripts_to_copy.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
182
- 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
183
227
 
184
- Manifest.write(
185
- dir: staging,
186
- name: resolved_name,
187
- version: resolved_version,
188
- scope: @scope,
189
- description: @description,
190
- files: files_map,
191
- hooks: hooks_map.empty? ? nil : hooks_map,
192
- trust_level: build_trust_level,
193
- plugin: plugin_src && { file: File.basename(plugin_src), sha256: Digest::SHA256.hexdigest(File.binread(plugin_src)) },
194
- requires_chi: requires_chi,
195
- needs: needs,
196
- scripts: scripts_to_copy.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
197
- context_providers: context_providers,
198
- file_descriptions: file_descriptions(prov_data, files_map)
199
- )
200
-
201
- case format
202
- when :zip
203
- zip_staging(staging, resolved_out)
204
- @out_path = resolved_out
205
- when :tar_gz, :tar, :tgz
206
- tar_staging(staging, resolved_out, format)
207
- @out_path = resolved_out
208
- when :dir
209
- # Copy staging contents to out dir
210
- FileUtils.mkdir_p(resolved_out)
211
- # Guard: refuse to overwrite non-empty dir without force (error)
212
- existing = Dir.entries(resolved_out).reject { |e| e.start_with?(".") }
213
- unless existing.empty?
214
- # Allow if out is the staging itself? no, staging is temp
215
- # Check if out dir already has manifest.yml — treat as existing bundle
216
- if File.exist?(File.join(resolved_out, "manifest.yml"))
217
- raise BuildError, "Output directory already contains a bundle (#{resolved_out}/manifest.yml) — choose different --out or remove it"
218
- end
219
- # If dir has any files, still require explicit handling — error
220
- unless existing.empty?
221
- raise BuildError, "Output directory already exists and is not empty: #{resolved_out}"
222
- 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"
223
239
  end
224
- FileUtils.cp_r("#{staging}/.", resolved_out)
225
- @out_path = resolved_out
226
- else
227
- raise BuildError, "unknown format: #{format}"
240
+
241
+ raise BuildError, "Output directory already exists and is not empty: #{out}"
228
242
  end
229
- ensure
230
- # Clean up staging temp dir unless out_path == staging (not possible for dir)
231
- FileUtils.rm_rf(staging) if staging && File.directory?(staging)
232
- @staging_dir = nil
243
+ FileUtils.cp_r("#{staging}/.", out)
244
+ else
245
+ raise BuildError, "unknown format: #{format}"
233
246
  end
234
-
235
- {
236
- out_path: @out_path,
237
- format: format,
238
- name: resolved_name,
239
- version: resolved_version,
240
- scope: @scope,
241
- files: @built_files.dup,
242
- placeholder_warnings: @placeholder_warnings.dup
243
- }
244
247
  end
245
248
 
246
- def summary
247
- lines = []
248
- lines << "Built #{@built_files.size} file(s) to #{@out_path}"
249
- lines << "Files: #{@built_files.join(", ")}" unless @built_files.empty?
250
- lines.concat(@placeholder_warnings.map { |w| "Placeholder: #{w}" }) unless @placeholder_warnings.empty?
251
- lines.concat(@warnings) unless @warnings.empty?
252
- lines.join("\n")
253
- end
254
-
255
- private
256
-
257
249
  # An installed profile (meta bundle) ships only its includes: a build
258
250
  # under its name would make a plain bundle of the scope's memories,
259
251
  # without the includes, carrying the profile's trust_level.
260
252
  def refuse_profile!(name)
261
253
  require_relative "profile"
262
- data = Provenance.new(name: name).read
263
- return unless Profile.installed_meta?(name, data)
254
+ record = Provenance.new(name: name).record
255
+ return unless Profile.installed_meta?(name, record)
264
256
 
265
257
  shipped = File.join(SourceNormalizer::SHIPPED_DIR, name)
266
- members = Profile.recorded(data, File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil)
258
+ members = Profile.recorded(record, File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil)
267
259
  raise BuildError, "#{name} is an installed profile (includes: #{members.join(", ")}): it ships only its includes, " \
268
260
  "so a build can't remake it; pick another --name for these memories"
269
261
  rescue JSON::ParserError, SystemCallError, Manifest::ValidationError
@@ -272,22 +264,22 @@ module Samagotchi
272
264
 
273
265
  # The installed bundle's scripts its record lists, file name => path
274
266
  # in its scripts/ (a missing one is left out); {} without a record.
275
- def installed_scripts(prov, prov_data)
276
- return {} unless prov && prov_data && prov_data[:scripts].is_a?(Hash)
267
+ def installed_scripts(prov, record)
268
+ return {} unless record
277
269
 
278
- prov_data[:scripts].keys.map(&:to_s).sort.to_h { |file| [file, File.join(prov.scripts_dir, file)] }
279
- .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) }
280
272
  end
281
273
 
282
274
  # The descriptions the installed bundle's record keeps for the files
283
275
  # being built (its manifest's files: mappings), so a rebuild writes
284
276
  # them back; {} without a record.
285
- def file_descriptions(prov_data, files_map)
286
- return {} unless prov_data && prov_data[:files].is_a?(Hash)
277
+ def file_descriptions(record, files_map)
278
+ return {} unless record
287
279
 
288
- prov_data[:files].each_with_object({}) do |(key, entry), acc|
289
- text = entry.is_a?(Hash) ? entry[:description].to_s : ""
290
- 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?
291
283
  end
292
284
  end
293
285
 
@@ -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