samagotchi 0.53.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/README.md +1 -1
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +1 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +8 -5
- data/docs/internals/engine.md +16 -7
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundle_command.rb +31 -36
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +0 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +117 -134
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +170 -178
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +9 -253
- data/lib/samagotchi/session_metrics.rb +3 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +12 -2
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -0
- data/lib/samagotchi/web/public/app.js +6 -6
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/index.html +4 -2
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +5 -3
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_html.js +10 -6
- metadata +7 -1
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
147
|
+
record = nil
|
|
102
148
|
begin
|
|
103
149
|
require_relative "provenance"
|
|
104
|
-
prov = Provenance.new(name:
|
|
105
|
-
|
|
150
|
+
prov = Provenance.new(name: name)
|
|
151
|
+
record = prov.record
|
|
106
152
|
rescue StandardError
|
|
107
|
-
|
|
153
|
+
record = nil
|
|
108
154
|
end
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
263
|
-
return unless Profile.installed_meta?(name,
|
|
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(
|
|
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,
|
|
276
|
-
return {} unless
|
|
267
|
+
def installed_scripts(prov, record)
|
|
268
|
+
return {} unless record
|
|
277
269
|
|
|
278
|
-
|
|
279
|
-
|
|
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(
|
|
286
|
-
return {} unless
|
|
277
|
+
def file_descriptions(record, files_map)
|
|
278
|
+
return {} unless record
|
|
287
279
|
|
|
288
|
-
|
|
289
|
-
text = entry
|
|
290
|
-
acc[key
|
|
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
|