samagotchi 0.150.0 → 0.152.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/bundle-authoring.md +5 -5
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +1 -1
  7. data/docs/guardrails.md +4 -0
  8. data/docs/internals/block-renderers.md +98 -0
  9. data/docs/internals/bridge.md +53 -17
  10. data/docs/internals/bundles.md +49 -14
  11. data/docs/internals/objectives.md +6 -4
  12. data/docs/internals/web-frontend.md +2 -2
  13. data/docs/memory.md +7 -2
  14. data/docs/plugins.md +91 -14
  15. data/docs/releasing.md +7 -2
  16. data/docs/sessions.md +1 -1
  17. data/lib/samagotchi/block_renderers.rb +116 -0
  18. data/lib/samagotchi/bridge/card_places.rb +93 -0
  19. data/lib/samagotchi/bridge/card_store.rb +219 -122
  20. data/lib/samagotchi/bridge.rb +12 -4
  21. data/lib/samagotchi/bundle_command/check.rb +43 -3
  22. data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
  23. data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
  24. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  25. data/lib/samagotchi/bundle_command.rb +88 -10
  26. data/lib/samagotchi/bundle_needs.rb +4 -6
  27. data/lib/samagotchi/bundle_settings_check.rb +14 -13
  28. data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
  36. data/lib/samagotchi/child_env.rb +15 -0
  37. data/lib/samagotchi/context_providers.rb +42 -10
  38. data/lib/samagotchi/engine.rb +47 -6
  39. data/lib/samagotchi/extension_load.rb +4 -3
  40. data/lib/samagotchi/guardrail_wiring.rb +28 -19
  41. data/lib/samagotchi/guardrails/approval.rb +2 -2
  42. data/lib/samagotchi/guardrails/rules.rb +1 -0
  43. data/lib/samagotchi/guardrails/verdict.rb +20 -0
  44. data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
  45. data/lib/samagotchi/memory_bundle/asset_installer.rb +18 -9
  46. data/lib/samagotchi/memory_bundle/builder.rb +39 -31
  47. data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
  48. data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
  49. data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
  50. data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -43
  51. data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -14
  52. data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
  53. data/lib/samagotchi/memory_bundle/installer.rb +91 -16
  54. data/lib/samagotchi/memory_bundle/manifest.rb +166 -5
  55. data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
  56. data/lib/samagotchi/memory_bundle/merger.rb +7 -8
  57. data/lib/samagotchi/memory_bundle/profile.rb +3 -2
  58. data/lib/samagotchi/memory_bundle/provenance.rb +220 -19
  59. data/lib/samagotchi/memory_bundle/shas.rb +4 -3
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
  61. data/lib/samagotchi/memory_bundle/status.rb +11 -10
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
  63. data/lib/samagotchi/plugin/loader.rb +1 -1
  64. data/lib/samagotchi/session_chain.rb +2 -7
  65. data/lib/samagotchi/session_commands.rb +15 -9
  66. data/lib/samagotchi/session_config.rb +5 -4
  67. data/lib/samagotchi/session_manager.rb +10 -7
  68. data/lib/samagotchi/steer.rb +17 -0
  69. data/lib/samagotchi/system_one/record.rb +10 -2
  70. data/lib/samagotchi/terminal_ui.rb +4 -4
  71. data/lib/samagotchi/tools/delegate.rb +20 -2
  72. data/lib/samagotchi/turn_checkpoint.rb +11 -5
  73. data/lib/samagotchi/turn_flow.rb +2 -3
  74. data/lib/samagotchi/update_command.rb +13 -2
  75. data/lib/samagotchi/version.rb +1 -1
  76. data/lib/samagotchi/web/app.rb +24 -13
  77. data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
  78. data/lib/samagotchi/web/markdown_renderer.rb +39 -6
  79. data/lib/samagotchi/web/message_display.rb +3 -3
  80. data/lib/samagotchi/web/participant_view.rb +1 -1
  81. data/lib/samagotchi/web/public/app.js +2 -0
  82. data/lib/samagotchi/web/public/block_frame.js +202 -0
  83. data/lib/samagotchi/web/public/block_host.js +502 -0
  84. data/lib/samagotchi/web/public/block_renderers.css +20 -0
  85. data/lib/samagotchi/web/public/block_source.js +37 -0
  86. data/lib/samagotchi/web/public/bubbles.js +9 -5
  87. data/lib/samagotchi/web/public/card.js +17 -10
  88. data/lib/samagotchi/web/public/cards_place.js +2 -2
  89. data/lib/samagotchi/web/public/claim_link.js +4 -2
  90. data/lib/samagotchi/web/public/history_reveal.js +4 -1
  91. data/lib/samagotchi/web/public/index.html +2 -0
  92. data/lib/samagotchi/web/public/markdown_place.js +2 -3
  93. data/lib/samagotchi/web/public/text_roots.js +6 -0
  94. data/lib/samagotchi/web/public/turn_html.js +4 -1
  95. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  96. data/lib/samagotchi/web/session_hub.rb +6 -0
  97. data/lib/samagotchi/web/session_show.rb +1 -1
  98. data/lib/samagotchi/worker.rb +4 -2
  99. metadata +13 -1
@@ -961,7 +961,7 @@ module Samagotchi
961
961
  ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
962
962
  command_queued command_ran context_added card hook_notice guardrail_warning
963
963
  plugin_init_started plugin_init_finished working_set_updated participants_changed
964
- verdict_recorded objectives_updated turn_rolled_back].freeze
964
+ verdict_recorded objectives_updated].freeze
965
965
 
966
966
  # Put a transport-level event into the ordered event log. Unlike turn
967
967
  # events it reaches only persistent observers (no turn sink, no memory
@@ -1037,8 +1037,7 @@ module Samagotchi
1037
1037
  end
1038
1038
 
1039
1039
  def show_objective_proposals
1040
- proposals = @objective_proposals_mutex.synchronize { @objective_proposals.slice!(0..) }
1041
- proposals.each do |proposal|
1040
+ drain_objective_proposals.each do |proposal|
1042
1041
  id = "objectives-#{SecureRandom.hex(4)}"
1043
1042
  show_card(source: Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
1044
1043
  actions: [{ label: "Apply", command: proposal.action_line(id) },
@@ -1049,6 +1048,22 @@ module Samagotchi
1049
1048
  end
1050
1049
  private :show_objective_proposals
1051
1050
 
1051
+ # A turn that did not complete (canceled, failed) takes its proposals
1052
+ # off the buffer without showing them: their Apply card would sit after a
1053
+ # turn whose `objectives propose` call is not in the history (a rollback
1054
+ # after Ctrl-C drops it), and leaving them in the buffer would show them
1055
+ # at the end of the next turn.
1056
+ def discard_objective_proposals(turn)
1057
+ dropped = drain_objective_proposals
1058
+ Log.info(:turn, "objectives_proposals_dropped", count: dropped.size, outcome: turn.outcome.to_s) unless dropped.empty?
1059
+ end
1060
+ private :discard_objective_proposals
1061
+
1062
+ def drain_objective_proposals
1063
+ @objective_proposals_mutex.synchronize { @objective_proposals.slice!(0..) }
1064
+ end
1065
+ private :drain_objective_proposals
1066
+
1052
1067
  # The strategy the working set is built under now, as LLMContextView
1053
1068
  # takes it: the running turn's, else the next turn's (#emit_working_set).
1054
1069
  # /working-set's ops act under it.
@@ -1107,6 +1122,8 @@ module Samagotchi
1107
1122
  # @raise [ArgumentError] a card without a title, or a bad action
1108
1123
  def show_card(source:, title:, body: "", actions: [], level: :info, id: nil)
1109
1124
  card = Plugin::Card.build(source: source, title: title, body: body, actions: actions, level: level, id: id)
1125
+ command = Thread.current[running_command_key]
1126
+ card[:command] = command if command
1110
1127
  @event_routes.route(card, **EventRoutes::CARD_MARKS)
1111
1128
  card[:id]
1112
1129
  end
@@ -1126,6 +1143,20 @@ module Samagotchi
1126
1143
  Thread.current[key] = outer
1127
1144
  end
1128
1145
 
1146
+ # Run a session command's dispatch: a card shown on this thread says
1147
+ # it's that command's (:card's command:), for a reload that no longer has
1148
+ # the command's line above it (Bridge::CardStore). A thread of its own
1149
+ # isn't the command, nor a card shown after it.
1150
+ # @param name [String] the command, "/short"
1151
+ # @return the block's value
1152
+ def running_command(name)
1153
+ outer = Thread.current[running_command_key]
1154
+ Thread.current[running_command_key] = name
1155
+ yield
1156
+ ensure
1157
+ Thread.current[running_command_key] = outer
1158
+ end
1159
+
1129
1160
  # Whether an anytime command (/btw) still runs on its thread.
1130
1161
  def anytime_running?
1131
1162
  @lifecycle_mutex.synchronize { @anytime_threads.any?(&:alive?) }
@@ -1732,6 +1763,7 @@ module Samagotchi
1732
1763
  # no Revise on its answer, verdict or not, as a reload does).
1733
1764
  def end_turn(turn, outcome, save: false, exhausted: false)
1734
1765
  seconds = turn.elapsed
1766
+ turn.outcome = outcome
1735
1767
  synchronize_events do
1736
1768
  kept, event = yield(seconds)
1737
1769
  event[:turn_id] = turn.id
@@ -2266,7 +2298,7 @@ module Samagotchi
2266
2298
 
2267
2299
  messages = @session&.messages || []
2268
2300
  covered = state[:covered].to_i
2269
- turns_since = Array(messages).drop(covered).count { |m| Steer.turn_prompt?(m) }
2301
+ turns_since = Array(messages).drop(covered).count { |m| Steer.turn_opening?(m) }
2270
2302
  { text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
2271
2303
  end
2272
2304
 
@@ -2306,9 +2338,13 @@ module Samagotchi
2306
2338
  # :turn_started event's at and its user message's (and reminders') stamp.
2307
2339
  # +verify+, +revises+: a Verify turn's claim, a Revise turn's draft.
2308
2340
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
2309
- :settings, :id, :limit, :resumed, :resume_from, :wall, :verify, :revises) do
2341
+ :settings, :id, :limit, :resumed, :resume_from, :wall, :verify, :revises, :outcome) do
2310
2342
  def resumed? = resumed ? true : false
2311
2343
 
2344
+ # The turn ended as a completed one (not canceled, not failed): the
2345
+ # test the between-turns cards of it (an objectives proposal) use.
2346
+ def completed? = outcome == "completed"
2347
+
2312
2348
  # The turn's boundary events carry the origin only when it names a
2313
2349
  # sender (not TurnOrigin::NONE), so payloads stay unchanged for callers
2314
2350
  # that don't pass one; as its wire Hash (TurnOrigin#to_h), which the
@@ -2432,7 +2468,10 @@ module Samagotchi
2432
2468
  ensure
2433
2469
  release_turn(probe_cancel_before)
2434
2470
  show_context_cards_after_turn
2435
- show_objective_proposals
2471
+ # Only a completed turn shows its proposal cards: a canceled, failed
2472
+ # or rolled-back turn keeps none, and its proposals are dropped here
2473
+ # so the next turn does not show them either.
2474
+ turn.completed? ? show_objective_proposals : discard_objective_proposals(turn)
2436
2475
  end
2437
2476
  end
2438
2477
 
@@ -2974,6 +3013,8 @@ module Samagotchi
2974
3013
 
2975
3014
  private
2976
3015
 
3016
+ def running_command_key = :"samagotchi_command_#{object_id}"
3017
+
2977
3018
  # The one named seam for replacing a session's conversation (the
2978
3019
  # array is replaced, never mutated in place). No lock: a reader gets
2979
3020
  # the old array or the new one, and both are whole.
@@ -87,13 +87,14 @@ module Samagotchi
87
87
  end
88
88
  bundle_dir = File.join(MemoryBundle::Provenance.bundles_dir, bundle_name)
89
89
  hooks_dir = File.join(bundle_dir, "hooks")
90
- if bundle.experimental?
90
+ manifest = bundle.manifest
91
+ if manifest.experimental?
91
92
  Log.info(:hooks, "experimental_bundle", echo: "[hooks] Bundle '#{bundle_name}' is experimental — its hooks may change or misbehave.", bundle: bundle_name)
92
93
  end
93
94
  begin
94
- Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: bundle.hooks, registry: registry,
95
+ Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: manifest.hooks, registry: registry,
95
96
  failures: @hook_failures, settings: settings[bundle_name.to_s] || {},
96
- requires_chi: bundle.requires_chi, hook_failed: @hook_failed)
97
+ manifest: manifest, hook_failed: @hook_failed)
97
98
  rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
98
99
  raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
99
100
 
@@ -228,8 +228,9 @@ module Samagotchi
228
228
  @failures.add("rules (bundle #{bundle_name})", bundle.error, required: true, group: :rules)
229
229
  next
230
230
  end
231
- if (too_new = MemoryBundle::Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION))
232
- rules.concat(too_new_bundle_rules(bundle_name, bundle, too_new))
231
+ manifest = bundle.manifest
232
+ if (too_new = manifest.requires_chi_failure)
233
+ rules.concat(too_new_bundle_rules(bundle_name, manifest, too_new))
233
234
  next
234
235
  end
235
236
  dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
@@ -323,7 +324,8 @@ module Samagotchi
323
324
  # one that passes every gate is allowed once without reaching them
324
325
  # (docs/guardrails.md, "System 1").
325
326
  judgment = begin
326
- system_one_judge(payload, targets: verdict.targets, ask_user: verdict.fresh_ask? || verdict.second_rule_ask?)
327
+ system_one_judge(payload, targets: verdict.targets, ask_user: verdict.fresh_ask? || verdict.second_rule_ask?,
328
+ other_rules: verdict.other_ask_rules)
327
329
  rescue LLM::RequestCancelled
328
330
  return turn_stopped(verdict)
329
331
  end
@@ -367,8 +369,9 @@ module Samagotchi
367
369
  # host) means no line at all. A judgment that fails is logged with p nil,
368
370
  # so the failures are in the log too.
369
371
  # With +ask_user+ (a fresh ask, a second rule's) it is judged and logged, never answered.
372
+ # +other_rules+ are the ids of the other rules asking about the same call.
370
373
  # @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
371
- def system_one_judge(payload, targets: nil, ask_user: false)
374
+ def system_one_judge(payload, targets: nil, ask_user: false, other_rules: nil)
372
375
  return nil if Steer.participant_author?(turn_author)
373
376
 
374
377
  client = @system_one.call
@@ -401,12 +404,16 @@ module Samagotchi
401
404
  auto = !ask_user && system_one_auto_allow?(client, approval, p, entries, targets: targets)
402
405
  decision = system_one_decision(client, auto)
403
406
 
404
- id = log.judgment(id: run_id, session_id: @session_lookup.call&.id,
405
- rule: approval.rule, source: approval.source, tool: approval.tool,
406
- **SystemOne::ApprovalQuestion.call_fields(approval),
407
- repo_name: approval.repo_name, repo: targets&.repo,
408
- p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
409
- latency_ms: latency_ms, decision: decision, origin: "live")
407
+ fields = { session_id: @session_lookup.call&.id, rule: approval.rule, source: approval.source,
408
+ tool: approval.tool, **SystemOne::ApprovalQuestion.call_fields(approval),
409
+ repo_name: approval.repo_name, repo: targets&.repo, p: p,
410
+ threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
411
+ latency_ms: latency_ms, decision: decision, origin: "live" }
412
+ # A call two rules ask about names only one; the others ride along so
413
+ # each rule's record (SystemOne::Record) counts this ask.
414
+ others = Array(other_rules).map(&:to_s).reject(&:empty?) - [approval.rule.to_s]
415
+ fields[:other_rules] = others unless others.empty?
416
+ id = log.judgment(id: run_id, **fields)
410
417
  # A judgment the log cannot hold is no judgment: the user is asked.
411
418
  system_one_run_facts(run_id, approval, targets, id.nil? && auto ? "asked" : decision)
412
419
  { id: id, p: p, auto: auto && !id.nil? }
@@ -520,7 +527,8 @@ module Samagotchi
520
527
  # load the new ones). Never loaded (a chi older than the bundle it
521
528
  # starts with): none, and a required load failure, as the hook loader
522
529
  # does for a guardrail hook, so the calls aren't quietly unguarded.
523
- def too_new_bundle_rules(bundle_name, bundle, reason)
530
+ # @param manifest [MemoryBundle::InstalledManifest]
531
+ def too_new_bundle_rules(bundle_name, manifest, reason)
524
532
  kept = @loaded_bundle_rules[bundle_name]
525
533
  unless kept
526
534
  Log.warn(:guardrails, "bundle_rules_requires_chi", echo: "[samagotchi:guardrails] bundle #{bundle_name}: its rules are not loaded: #{reason}",
@@ -530,29 +538,30 @@ module Samagotchi
530
538
  return []
531
539
  end
532
540
 
533
- key = [bundle_name, bundle.version.to_s]
541
+ key = [bundle_name, manifest.version.to_s]
534
542
  return kept if @kept_noticed[key]
535
543
 
536
544
  @kept_noticed[key] = true
537
- Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: bundle.version.to_s,
538
- requires_chi: bundle.requires_chi.to_s)
539
- @notify.call(kept_notice(bundle_name, bundle))
545
+ Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: manifest.version.to_s,
546
+ requires_chi: manifest.requires_chi.to_s)
547
+ @notify.call(kept_notice(bundle_name, manifest))
540
548
  kept
541
549
  end
542
550
 
543
- def kept_notice(bundle_name, bundle)
551
+ def kept_notice(bundle_name, manifest)
544
552
  id = @session_lookup.call&.id || "ID"
545
- "bundle #{bundle_name} #{bundle.version} needs chi #{bundle.requires_chi} and this session runs chi #{Samagotchi::VERSION}: " \
553
+ "bundle #{bundle_name} #{manifest.version} needs chi #{manifest.requires_chi} and this session runs chi #{Samagotchi::VERSION}: " \
546
554
  "its new rules are not loaded, the ones loaded before still apply. Restart the session to load them " \
547
555
  "(chi sessions stop #{id}, then chi --resume #{id})"
548
556
  end
549
557
 
550
558
  # [path, mtime, size] of config.yml and every installed bundle's
551
- # manifest.json and guardrails/ file.
559
+ # manifest.json, kept manifest.yml (its requires_chi is read from there)
560
+ # and guardrails/ file.
552
561
  def rules_stamp
553
562
  require_relative "memory_bundle/provenance"
554
563
  paths = [Samagotchi::ConfigFile.global_path] +
555
- Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "{manifest.json,guardrails/*}")]
564
+ Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "{manifest.json,manifest.yml,guardrails/*}")]
556
565
  paths.compact.sort.map do |path|
557
566
  stat = File.stat(path)
558
567
  [path, stat.mtime.to_r, stat.size]
@@ -213,8 +213,8 @@ module Samagotchi
213
213
  end
214
214
  end
215
215
 
216
- # What a session answer covers, as Approvals.key_for keys it: a file
217
- # tool's later calls on the same paths, else this one exact call.
216
+ # What a session answer covers, as Guardrails::Approvals.key_for keys
217
+ # it: a file tool's later calls on the same paths, else this one exact call.
218
218
  def session_subject(targets)
219
219
  return "this call" if targets.nil? || targets.command || targets.paths.empty?
220
220
 
@@ -325,6 +325,7 @@ module Samagotchi
325
325
  if rule.verdict == :deny
326
326
  verdict.deny!(rule.reason, rule: rule.id, source: rule.source, decided_by: "rule")
327
327
  else
328
+ verdict.note_ask_rule(rule.id)
328
329
  verdict.ask!(rule.reason, scopes: rule.scopes, rule: rule.id, source: rule.source, decided_by: "rule")
329
330
  verdict.second_rule_asks!(rule.scopes) if asking && asking != rule.id
330
331
  asking ||= rule.id
@@ -63,6 +63,26 @@ module Samagotchi
63
63
  @note = nil
64
64
  @voter = nil
65
65
  @advice = nil
66
+ @ask_rules = []
67
+ end
68
+
69
+ # Every rule id that asked about this call, in the order they voted
70
+ # (Rules#check), including one whose vote did not take over. The ask
71
+ # names a single rule (#rule); the rest are #other_ask_rules.
72
+ attr_reader :ask_rules
73
+
74
+ # A rule that asks, recorded whatever became of its vote.
75
+ def note_ask_rule(rule)
76
+ id = rule.to_s
77
+ @ask_rules << id unless id.empty? || @ask_rules.include?(id)
78
+ self
79
+ end
80
+
81
+ # The rules that asked besides the one the ask names: nil when only one
82
+ # rule asked, so a judgment line carries nothing extra.
83
+ def other_ask_rules
84
+ others = @ask_rules - [rule.to_s]
85
+ others.empty? ? nil : others
66
86
  end
67
87
 
68
88
  # @param rule [String, nil] the rule id, when a rule decided
@@ -6,8 +6,6 @@ require_relative "../log"
6
6
  require_relative "../guardrails/load_failures"
7
7
  require_relative "../memory_bundle/bundle_hook"
8
8
  require_relative "../memory_bundle/provenance"
9
- require_relative "../memory_bundle/manifest"
10
- require_relative "../version"
11
9
 
12
10
  module Samagotchi
13
11
  # Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
@@ -57,17 +55,17 @@ module Samagotchi
57
55
  # @param settings [Hash] the bundle's section of config.yml
58
56
  # `bundles:` (string keys); a hook class whose initialize takes an
59
57
  # argument gets it
60
- # @param requires_chi [String, nil] the bundle's requirement; when
61
- # this chi doesn't meet it no hook loads (each is reported, as the
62
- # plugin loader does)
58
+ # @param manifest [MemoryBundle::InstalledManifest, nil] the installed
59
+ # bundle's manifest; when this chi doesn't meet its requires_chi no
60
+ # hook loads (each is reported, as the plugin loader does)
63
61
  # @param hook_failed [#call, nil] (label, "Class: message", effect:)
64
62
  # when a hook file raises at fire time — the Engine's HookRoutes;
65
63
  # nil when there is no UI to tell (the test kit)
66
64
  # @return [Integer] number of hooks successfully registered
67
- def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {}, requires_chi: nil, hook_failed: nil)
65
+ def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {}, manifest: nil, hook_failed: nil)
68
66
  return 0 unless metadata.is_a?(Hash)
69
67
 
70
- too_old = MemoryBundle::Manifest.requires_chi_failure(requires_chi, Samagotchi::VERSION)
68
+ too_old = manifest&.requires_chi_failure
71
69
  loaded = 0
72
70
  metadata.each do |raw_basename, raw_meta|
73
71
  basename = raw_basename.to_s
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "digest"
4
+ require_relative "file_digest"
5
5
  require_relative "bundle_hook"
6
6
  require_relative "manifest"
7
7
  require_relative "provenance"
@@ -36,8 +36,10 @@ module Samagotchi
36
36
  # @param results, warnings, conflicts the Installer's (results and
37
37
  # warnings are added to)
38
38
  # @param trash [Trash] the Installer's
39
+ # @param chi_version [String] the chi this install is for (the Installer's;
40
+ # a caller that checks its own bundles against another version passes it)
39
41
  def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:, trash:,
40
- force: false, strict: true, upgrade: false, dry_run: false, dev: false)
42
+ force: false, strict: true, upgrade: false, dry_run: false, dev: false, chi_version: Samagotchi::VERSION)
41
43
  @name = name
42
44
  @bundle_dir = source_dir
43
45
  @manifest = manifest
@@ -52,6 +54,7 @@ module Samagotchi
52
54
  @upgrade = upgrade
53
55
  @dry_run = dry_run
54
56
  @dev = dev
57
+ @chi_version = chi_version
55
58
  end
56
59
 
57
60
  # @return [Installed]
@@ -175,7 +178,7 @@ module Samagotchi
175
178
  dest = File.join(@provenance.hooks_dir, basename)
176
179
  next unless File.exist?(dest)
177
180
 
178
- actual = Digest::SHA256.hexdigest(File.read(dest))
181
+ actual = FileDigest.hex(dest)
179
182
  if actual != expected
180
183
  @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
181
184
  end
@@ -229,7 +232,7 @@ module Samagotchi
229
232
  raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
230
233
  raise Installer::InstallError, "plugin #{plugin[:file]} is a symlink (not installed)" if linked?(src)
231
234
 
232
- if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
235
+ if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
233
236
  @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
234
237
  end
235
238
  if @dry_run
@@ -251,7 +254,7 @@ module Samagotchi
251
254
  { status: "installed" }
252
255
  end
253
256
  expected = @manifest.checksum_for_plugin
254
- actual = Digest::SHA256.hexdigest(File.binread(dest))
257
+ actual = FileDigest.hex(dest)
255
258
  if @strict && !@dev && expected && actual != expected
256
259
  @warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
257
260
  end
@@ -269,7 +272,7 @@ module Samagotchi
269
272
  raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
270
273
  raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
271
274
 
272
- @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src)
275
+ @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
273
276
  end
274
277
  return {} if @dry_run
275
278
 
@@ -291,7 +294,7 @@ module Samagotchi
291
294
  else { status: "installed" }
292
295
  end
293
296
  FileUtils.chmod(0o755, dest) unless @dev
294
- actual = Digest::SHA256.hexdigest(File.binread(dest))
297
+ actual = FileDigest.hex(dest)
295
298
  expected = declared.to_s.delete_prefix("sha256:")
296
299
  if @strict && !@dev && !expected.empty? && actual != expected
297
300
  @warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
@@ -300,6 +303,12 @@ module Samagotchi
300
303
  end
301
304
  end
302
305
 
306
+ # A file only a block renderer's load names: the page loads it, nothing runs it.
307
+ def page_only?(file)
308
+ @manifest.block_renderers.any? { |renderer| renderer.load.include?(file) } &&
309
+ @manifest.context_providers.none? { |provider| provider.cmd.include?(file) }
310
+ end
311
+
303
312
  # Copies +src+ to +dest+, or (a dev install) links +dest+ to it. An
304
313
  # existing link goes first: a copy over it would write into the source.
305
314
  def place(src, dest)
@@ -335,7 +344,7 @@ module Samagotchi
335
344
  def warn_hooks_requires_chi
336
345
  return if @manifest.nil? || @bundle_hooks.empty?
337
346
 
338
- if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
347
+ if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
339
348
  @warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
340
349
  end
341
350
  end
@@ -347,7 +356,7 @@ module Samagotchi
347
356
  def warn_rules_requires_chi(rule_files)
348
357
  return if @manifest.nil? || rule_files.empty?
349
358
 
350
- if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
359
+ if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
351
360
  @warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
352
361
  "(chi update), guarded tool calls are refused"
353
362
  end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require_relative "file_digest"
4
5
  require "fileutils"
5
6
  require "tmpdir"
6
7
  require_relative "bundle_hook"
7
8
  require_relative "manifest"
8
9
  require_relative "placeholder"
9
- require_relative "../context_providers"
10
10
  require_relative "../memory_paths"
11
11
  require_relative "../model_overlay"
12
12
 
@@ -30,8 +30,8 @@ module Samagotchi
30
30
  # hooks basename => BundleHook; hook_files basename => path
31
31
  # rules guardrails/*.yml paths
32
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)
33
+ # scripts file name => path; context_providers, block_renderers the parsed lists
34
+ InstalledParts = Data.define(:record, :hooks, :hook_files, :rules, :plugin, :scripts, :context_providers, :block_renderers)
35
35
 
36
36
  attr_reader :built_files, :warnings, :placeholder_warnings, :out_path, :staging_dir
37
37
 
@@ -140,7 +140,8 @@ module Samagotchi
140
140
 
141
141
  # The installed bundle's hooks (a recorded one missing on disk left
142
142
  # out; one with no recorded sha gets its file's), guardrail rules,
143
- # plugin, scripts and context providers. Without hooks or rules in a
143
+ # plugin, scripts, context providers and block renderers (its
144
+ # InstalledManifest's). Without hooks or rules in a
144
145
  # record, the scope dir's hooks/*.rb and guardrails/*.yml (building
145
146
  # from a raw bundle dir that is also the memories dir).
146
147
  # @return [InstalledParts]
@@ -174,12 +175,18 @@ module Samagotchi
174
175
 
175
176
  rules_dir = record&.guardrails&.any? ? prov.guardrails_dir : File.join(target_dir, "guardrails")
176
177
  plugin = record ? prov.plugin_path(record) : nil
178
+ manifest = record&.manifest
179
+ # A key of the record that doesn't parse isn't built as empty: the build refuses, as it always has.
180
+ problem = manifest&.key_problems&.values&.first
181
+ raise BuildError, "the installed #{name}'s record: #{problem}" if problem
182
+
177
183
  InstalledParts.new(
178
184
  record: record, hooks: hooks, hook_files: hook_files,
179
185
  rules: Dir.exist?(rules_dir) ? Dir.glob(File.join(rules_dir, "*.yml")).sort : [],
180
186
  plugin: plugin && File.file?(plugin) ? plugin : nil,
181
187
  scripts: installed_scripts(prov, record),
182
- context_providers: record ? ContextProviders.parse_list(record.context_providers) : []
188
+ context_providers: manifest ? manifest.context_providers : [],
189
+ block_renderers: manifest ? manifest.block_renderers : []
183
190
  )
184
191
  end
185
192
 
@@ -203,32 +210,45 @@ module Samagotchi
203
210
  end
204
211
 
205
212
  # manifest.yml for the staged bundle: requires_chi, needs, session_settings and settings (typed) and the
206
- # file descriptions come from the installed bundle's record, the
207
- # trust_level from the CLI else the record.
213
+ # file descriptions come from the installed bundle's manifest (InstalledManifest), the
214
+ # trust_level and description from the CLI else that manifest. A key of its kept manifest.yml
215
+ # this chi doesn't know is carried as installed (the build is the bundle again), with a warning.
208
216
  def write_manifest(staging, name, version, files_map, parts)
209
- record = parts.record
217
+ manifest = parts.record&.manifest
210
218
  trust_level = @trust_level
211
- trust_level = record.trust_level if (trust_level.nil? || trust_level.empty?) && record&.trust_level
219
+ trust_level = manifest.trust_level if (trust_level.nil? || trust_level.empty?) && manifest&.trust_level
220
+ description = @description.empty? && manifest&.description ? manifest.description : @description
212
221
  Manifest.write(
213
222
  dir: staging,
214
223
  name: name,
215
224
  version: version,
216
225
  scope: @scope,
217
- description: @description,
226
+ description: description,
218
227
  files: files_map,
219
228
  hooks: parts.hooks.empty? ? nil : parts.hooks,
220
229
  trust_level: trust_level,
221
- plugin: parts.plugin && { file: File.basename(parts.plugin), sha256: Digest::SHA256.hexdigest(File.binread(parts.plugin)) },
222
- requires_chi: record&.requires_chi,
223
- needs: record&.needs,
224
- scripts: parts.scripts.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
230
+ plugin: parts.plugin && { file: File.basename(parts.plugin), sha256: FileDigest.sha256(parts.plugin) },
231
+ requires_chi: manifest&.requires_chi,
232
+ needs: manifest&.needs,
233
+ scripts: parts.scripts.transform_values { |src| FileDigest.sha256(src) },
225
234
  context_providers: parts.context_providers,
226
- file_descriptions: file_descriptions(record, files_map),
227
- session_settings: record&.session_settings || [],
228
- session_setting_types: record&.session_setting_types || {},
229
- settings: record&.settings,
230
- setting_types: record&.setting_types || {}
235
+ block_renderers: parts.block_renderers,
236
+ file_descriptions: manifest ? manifest.file_descriptions.slice(*files_map.keys) : {},
237
+ session_settings: manifest&.session_settings || [],
238
+ session_setting_types: manifest&.session_setting_types || {},
239
+ settings: manifest&.settings,
240
+ setting_types: manifest&.setting_types || {}
231
241
  )
242
+ carry_unknown_keys(staging, parts.record, manifest) if manifest&.origin == :kept && !manifest.unknown_keys.empty?
243
+ end
244
+
245
+ def carry_unknown_keys(staging, record, manifest)
246
+ path = File.join(staging, "manifest.yml")
247
+ kept = YAML.load_file(record.kept_manifest_path) || {}
248
+ File.write(path, YAML.dump(YAML.load_file(path).merge(kept.slice(*manifest.unknown_keys))))
249
+ manifest.unknown_keys.each do |key|
250
+ @warnings << "#{Manifest.unknown_key_line(key)}; the build carries it as installed"
251
+ end
232
252
  end
233
253
 
234
254
  # The staged bundle to +out+: a zip or tar archive, or a directory,
@@ -277,18 +297,6 @@ module Samagotchi
277
297
  .select { |_, path| File.file?(path) }
278
298
  end
279
299
 
280
- # The descriptions the installed bundle's record keeps for the files
281
- # being built (its manifest's files: mappings), so a rebuild writes
282
- # them back; {} without a record.
283
- def file_descriptions(record, files_map)
284
- return {} unless record
285
-
286
- record.files.each_with_object({}) do |(key, entry), acc|
287
- text = entry[:description].to_s
288
- acc[key] = text if files_map.key?(key) && !text.empty?
289
- end
290
- end
291
-
292
300
  # Without an allowlist, a memory another installed bundle owns (its
293
301
  # record lists it) isn't the user's to share: left out, one line
294
302
  # each. The bundle being built (same name) keeps its own.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
3
+ require_relative "file_digest"
4
4
 
5
5
  module Samagotchi
6
6
  module MemoryBundle
@@ -38,8 +38,9 @@ module Samagotchi
38
38
  end
39
39
 
40
40
  # A hook known only by its file (no declared event): its sha, the defaults.
41
+ # Callers pass a file that is there (FileDigest is nil for one that isn't).
41
42
  def self.of_file(path)
42
- new(sha256: "sha256:#{Digest::SHA256.hexdigest(File.binread(path))}")
43
+ new(sha256: FileDigest.sha256(path))
43
44
  end
44
45
 
45
46
  # "sha256:<hex>" from either form, "" for none.
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Samagotchi
6
+ module MemoryBundle
7
+ # The one digest of a file on disk, so the sha256 written by a record and
8
+ # the one read back at load time come from the same code.
9
+ #
10
+ # Only files are digested here. Bytes already in memory (a merged file's
11
+ # text, a memory's content that is also its base) keep their own
12
+ # Digest::SHA256.hexdigest call at the place that has them.
13
+ module FileDigest
14
+ # The file's SHA256 hex, read as bytes and streamed. nil when +path+
15
+ # isn't a file (missing, or a directory); an unreadable file raises
16
+ # SystemCallError, which callers that must keep going rescue.
17
+ def self.hex(path)
18
+ return nil unless File.file?(path)
19
+
20
+ Digest::SHA256.file(path).hexdigest
21
+ end
22
+
23
+ # The form a manifest or a record writes for a hook, rule, plugin or
24
+ # script: "sha256:<hex>", nil for a file that isn't there.
25
+ def self.sha256(path)
26
+ hex = hex(path)
27
+ hex && "sha256:#{hex}"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
3
  require "time"
4
+ require_relative "file_digest"
5
5
 
6
6
  module Samagotchi
7
7
  module MemoryBundle
@@ -156,7 +156,7 @@ module Samagotchi
156
156
  # read isn't known to be level. No digest (an older mark): it isn't known
157
157
  # either, and a retry would have settled it.
158
158
  def differs?(path, incoming_sha)
159
- incoming_sha.nil? || Digest::SHA256.file(path).hexdigest != incoming_sha
159
+ incoming_sha.nil? || FileDigest.hex(path) != incoming_sha
160
160
  rescue SystemCallError
161
161
  true
162
162
  end