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