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
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ autoload :Log, File.expand_path("log", __dir__)
5
+ autoload :VERSION, File.expand_path("version", __dir__)
6
+ module MemoryBundle
7
+ autoload :Manifest, File.expand_path("memory_bundle/manifest", __dir__)
8
+ autoload :Provenance, File.expand_path("memory_bundle/provenance", __dir__)
9
+ end
10
+
11
+ # Declarative block renderers (docs/plugins.md, docs/internals/block-renderers.md):
12
+ # a bundle's manifest maps a fenced block's language to the scripts/ files the
13
+ # owner's page loads to draw it:
14
+ #
15
+ # block_renderers:
16
+ # - lang: mermaid
17
+ # load: [mermaid.tiny.js, mermaid_renderer.js]
18
+ #
19
+ # JS draws, in a sandboxed frame; Ruby only routes and serves, so chi web
20
+ # (which runs no plugins) can answer, and a reloaded page draws after the
21
+ # worker has gone. The installer records them in the bundle's provenance;
22
+ # .for_installed reads them from there.
23
+ module BlockRenderers
24
+ # A renderer as the manifest declares it.
25
+ Renderer = Data.define(:lang, :load) do
26
+ def to_h = { "lang" => lang, "load" => load }
27
+ end
28
+
29
+ # One the page may use: the bundle that won the language, and its files.
30
+ Installed = Data.define(:lang, :bundle, :files)
31
+
32
+ class Invalid < ArgumentError; end
33
+
34
+ # A language a block can carry: a fence's first word, downcased
35
+ # (MarkdownRenderer sets it as data-lang under this same pattern).
36
+ LANG = /\A[a-z0-9_+-]{1,32}\z/
37
+
38
+ module_function
39
+
40
+ # The manifest's block_renderers: list.
41
+ # @return [Array<Renderer>] [] when absent
42
+ # @raise [Invalid]
43
+ def parse_list(raw)
44
+ return [] if raw.nil?
45
+ raise Invalid, "block_renderers: must be a list of mappings" unless raw.is_a?(Array)
46
+
47
+ renderers = raw.map { |item| parse(item) }
48
+ twice = renderers.map(&:lang).tally.select { |_lang, count| count > 1 }.keys
49
+ raise Invalid, "block_renderers: #{twice.join(", ")} listed more than once" unless twice.empty?
50
+
51
+ renderers
52
+ end
53
+
54
+ # @raise [Invalid]
55
+ def parse(item)
56
+ raise Invalid, "block_renderers: each item must be a mapping, not #{item.inspect}" unless item.is_a?(Hash)
57
+
58
+ data = item.transform_keys(&:to_s)
59
+ lang = data["lang"]
60
+ unless lang.is_a?(String) && lang.match?(LANG)
61
+ raise Invalid, "block_renderers: lang #{lang.inspect} isn't a fence's language (lowercase letters, digits, _ + -; at most 32)"
62
+ end
63
+
64
+ load = data["load"]
65
+ unless load.is_a?(Array) && !load.empty? && load.all? { |file| file.is_a?(String) && file.match?(MemoryBundle::Manifest::SCRIPT_FILE) }
66
+ raise Invalid, "block_renderers: #{lang}'s load must be a non-empty list of file names in scripts/, not #{load.inspect}"
67
+ end
68
+
69
+ Renderer.new(lang: lang, load: load)
70
+ end
71
+
72
+ # What the installed bundles draw: the first bundle by name wins a
73
+ # language. A bundle whose requires_chi this chi doesn't meet, or whose list
74
+ # doesn't parse (logged), is skipped; a record that doesn't read lists none.
75
+ # @return [Array<Installed>]
76
+ def for_installed
77
+ taken = {}
78
+ MemoryBundle::Provenance.each_installed do |bundle, record|
79
+ manifest = record.manifest
80
+ next if manifest.requires_chi_failure(VERSION)
81
+
82
+ renderers(bundle, manifest).each do |renderer|
83
+ taken[renderer.lang] ||= Installed.new(lang: renderer.lang, bundle: bundle, files: renderer.load)
84
+ end
85
+ end
86
+ taken.values
87
+ end
88
+
89
+ # The installed path of +file+, once a renderer .for_installed lists loads
90
+ # it from +bundle+ (neither name is ever joined into a path before that:
91
+ # they come from the URL) and it is as installed. This code runs in the
92
+ # owner's browser, so it is checked as a plugin's load and a provider's
93
+ # script are; a --dev install's links are not.
94
+ # @return [String, nil] nil when no such renderer loads such a file
95
+ # @raise [Invalid] the file is gone or changed
96
+ def script_path(bundle, file)
97
+ return nil unless for_installed.any? { |renderer| renderer.bundle == bundle && renderer.files.include?(file) }
98
+
99
+ provenance = MemoryBundle::Provenance.new(name: bundle)
100
+ path = File.join(provenance.scripts_dir, file)
101
+ recorded = provenance.record&.scripts&.fetch(file, nil)
102
+ return path if File.file?(path) && MemoryBundle::Provenance.sha_matches?(path, recorded)
103
+
104
+ raise Invalid, "bundle #{bundle}'s scripts/#{file} differs from the installed one (reinstall the bundle)"
105
+ end
106
+
107
+ # Its parsed renderers; a list that doesn't parse is none (logged).
108
+ def renderers(bundle, manifest)
109
+ if (problem = manifest.problems["block_renderers"])
110
+ Log.warn(:web, "block_renderers_unreadable", bundle: bundle, msg: problem)
111
+ end
112
+ manifest.block_renderers
113
+ end
114
+ private_class_method :renderers
115
+ end
116
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../steer"
4
+
5
+ module Samagotchi
6
+ class Bridge
7
+ # Where a CardStore's entries sit in a session's messages: the turns the
8
+ # page draws after each. The messages count, with the rule the page and
9
+ # the recap share (Steer.turn_opening?); the store's turn log only orders
10
+ # the ids. So a turn with no opening (a reminder, a Continue, a failed or
11
+ # rolled-back one, a prompt stopped before the model) counts nothing, and
12
+ # an opening with no id (/continue no's reason, Qwen's recovery line) counts
13
+ # as it does on the page. Nothing orders such an opening against an entry, so
14
+ # an entry says how many it came after (+past_idless+, #idless_after). Pure:
15
+ # no Engine, no file.
16
+ class CardPlaces
17
+ # @param turn_log [Array<String>] the turn ids, oldest first
18
+ # @param messages [Array<Hash>] the messages the page draws
19
+ def initialize(turn_log, messages)
20
+ positions = turn_log.each_with_index.to_h
21
+ @ids = Array(messages).select { |message| Steer.turn_opening?(message) }.map { |message| message[:turn_id] || message["turn_id"] }
22
+ @openings = @ids.size
23
+ @opened = @ids.compact.to_set
24
+ @places = @ids.map { |id| positions[id] }
25
+ index_by_position(@places, turn_log.size)
26
+ @positions = positions
27
+ end
28
+
29
+ # The openings after the last one whose turn is at or before the entry's
30
+ # in the log (none: from the first one whose turn is later; none: 0),
31
+ # less the ones with no id it came after. An entry from an older file
32
+ # (+base_since+) has no id: all the log's turns are later, on top of what
33
+ # it had counted.
34
+ # @param entry [Hash] a store's entry: :turn_id, :base_since
35
+ # @return [Integer]
36
+ def turns_since(entry)
37
+ position = @positions.fetch(entry[:turn_id], -1)
38
+ at_or_before = position >= 0 ? @last_at_or_before[position] : nil
39
+ since = if at_or_before
40
+ @openings - at_or_before - 1 - [entry[:past_idless].to_i, idless_run(at_or_before)].min
41
+ else
42
+ later = @first_after[position + 1]
43
+ later ? @openings - later : 0
44
+ end
45
+ entry[:base_since].to_i + since
46
+ end
47
+
48
+ # The openings with no id right after the one +turn_id+ counts from: what
49
+ # an entry shown now has come after. A rollback can only shorten the run, so
50
+ # a count read back against it never eats a later turn.
51
+ # @return [Integer]
52
+ def idless_after(turn_id)
53
+ position = @positions.fetch(turn_id, -1)
54
+ at_or_before = position >= 0 ? @last_at_or_before[position] : nil
55
+ at_or_before ? idless_run(at_or_before) : 0
56
+ end
57
+
58
+ # Where a log may start and still read an entry at +position+ the same: at
59
+ # the opening that entry counts from, or at the entry's turn with none.
60
+ # @return [Integer] a log position
61
+ def log_start_for(position)
62
+ at_or_before = @last_at_or_before[position]
63
+ at_or_before ? @places[at_or_before] : position
64
+ end
65
+
66
+ # @return [Boolean] whether that turn has an opening in the messages
67
+ def opened?(turn_id)
68
+ @opened.include?(turn_id)
69
+ end
70
+
71
+ private
72
+
73
+ def idless_run(opening)
74
+ @ids.drop(opening + 1).take_while(&:nil?).size
75
+ end
76
+
77
+ # For each log position: the last opening at or before it, and the first
78
+ # one at or after it. One pass each, for the store's many entries.
79
+ def index_by_position(places, size)
80
+ @last_at_or_before = Array.new(size)
81
+ @first_after = Array.new(size + 1)
82
+ places.each_with_index do |place, opening|
83
+ next unless place
84
+
85
+ @last_at_or_before[place] = opening
86
+ @first_after[place] ||= opening
87
+ end
88
+ (1...size).each { |i| @last_at_or_before[i] = [@last_at_or_before[i - 1], @last_at_or_before[i]].compact.max }
89
+ (size - 1).downto(0) { |i| @first_after[i] = [@first_after[i], @first_after[i + 1]].compact.min }
90
+ end
91
+ end
92
+ end
93
+ end