samagotchi 0.150.0 → 0.151.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 +22 -1
- data/docs/architecture.md +2 -2
- data/docs/bundle-authoring.md +1 -1
- data/docs/configuration.md +1 -1
- data/docs/internals/block-renderers.md +96 -0
- data/docs/internals/bridge.md +53 -17
- data/docs/internals/bundles.md +1 -0
- data/docs/internals/web-frontend.md +2 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +43 -3
- data/lib/samagotchi/block_renderers.rb +115 -0
- data/lib/samagotchi/bridge/card_places.rb +93 -0
- data/lib/samagotchi/bridge/card_store.rb +219 -122
- data/lib/samagotchi/bridge.rb +12 -4
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +20 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +7 -1
- data/lib/samagotchi/memory_bundle/builder.rb +5 -3
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +8 -5
- data/lib/samagotchi/memory_bundle/installed_copy.rb +1 -0
- data/lib/samagotchi/memory_bundle/installer.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +23 -2
- data/lib/samagotchi/memory_bundle/provenance.rb +4 -2
- data/lib/samagotchi/session_chain.rb +2 -7
- data/lib/samagotchi/session_commands.rb +15 -9
- data/lib/samagotchi/session_manager.rb +10 -7
- data/lib/samagotchi/steer.rb +17 -0
- data/lib/samagotchi/terminal_ui.rb +4 -4
- data/lib/samagotchi/turn_checkpoint.rb +11 -5
- data/lib/samagotchi/turn_flow.rb +2 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -12
- data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
- data/lib/samagotchi/web/markdown_renderer.rb +39 -6
- data/lib/samagotchi/web/message_display.rb +3 -3
- data/lib/samagotchi/web/participant_view.rb +1 -1
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/block_frame.js +202 -0
- data/lib/samagotchi/web/public/block_host.js +502 -0
- data/lib/samagotchi/web/public/block_renderers.css +20 -0
- data/lib/samagotchi/web/public/block_source.js +37 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -5
- data/lib/samagotchi/web/public/card.js +17 -10
- data/lib/samagotchi/web/public/cards_place.js +2 -2
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/history_reveal.js +4 -1
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/markdown_place.js +2 -3
- data/lib/samagotchi/web/public/text_roots.js +6 -0
- data/lib/samagotchi/web/public/turn_html.js +4 -1
- data/lib/samagotchi/web/session_show.rb +1 -1
- data/lib/samagotchi/worker.rb +4 -2
- metadata +10 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
|
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
|
|
@@ -1107,6 +1107,8 @@ module Samagotchi
|
|
|
1107
1107
|
# @raise [ArgumentError] a card without a title, or a bad action
|
|
1108
1108
|
def show_card(source:, title:, body: "", actions: [], level: :info, id: nil)
|
|
1109
1109
|
card = Plugin::Card.build(source: source, title: title, body: body, actions: actions, level: level, id: id)
|
|
1110
|
+
command = Thread.current[running_command_key]
|
|
1111
|
+
card[:command] = command if command
|
|
1110
1112
|
@event_routes.route(card, **EventRoutes::CARD_MARKS)
|
|
1111
1113
|
card[:id]
|
|
1112
1114
|
end
|
|
@@ -1126,6 +1128,20 @@ module Samagotchi
|
|
|
1126
1128
|
Thread.current[key] = outer
|
|
1127
1129
|
end
|
|
1128
1130
|
|
|
1131
|
+
# Run a session command's dispatch: a card shown on this thread says
|
|
1132
|
+
# it's that command's (:card's command:), for a reload that no longer has
|
|
1133
|
+
# the command's line above it (Bridge::CardStore). A thread of its own
|
|
1134
|
+
# isn't the command, nor a card shown after it.
|
|
1135
|
+
# @param name [String] the command, "/short"
|
|
1136
|
+
# @return the block's value
|
|
1137
|
+
def running_command(name)
|
|
1138
|
+
outer = Thread.current[running_command_key]
|
|
1139
|
+
Thread.current[running_command_key] = name
|
|
1140
|
+
yield
|
|
1141
|
+
ensure
|
|
1142
|
+
Thread.current[running_command_key] = outer
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1129
1145
|
# Whether an anytime command (/btw) still runs on its thread.
|
|
1130
1146
|
def anytime_running?
|
|
1131
1147
|
@lifecycle_mutex.synchronize { @anytime_threads.any?(&:alive?) }
|
|
@@ -2266,7 +2282,7 @@ module Samagotchi
|
|
|
2266
2282
|
|
|
2267
2283
|
messages = @session&.messages || []
|
|
2268
2284
|
covered = state[:covered].to_i
|
|
2269
|
-
turns_since = Array(messages).drop(covered).count { |m| Steer.
|
|
2285
|
+
turns_since = Array(messages).drop(covered).count { |m| Steer.turn_opening?(m) }
|
|
2270
2286
|
{ text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
|
|
2271
2287
|
end
|
|
2272
2288
|
|
|
@@ -2974,6 +2990,8 @@ module Samagotchi
|
|
|
2974
2990
|
|
|
2975
2991
|
private
|
|
2976
2992
|
|
|
2993
|
+
def running_command_key = :"samagotchi_command_#{object_id}"
|
|
2994
|
+
|
|
2977
2995
|
# The one named seam for replacing a session's conversation (the
|
|
2978
2996
|
# array is replaced, never mutated in place). No lock: a reader gets
|
|
2979
2997
|
# the old array or the new one, and both are whole.
|
|
@@ -269,7 +269,7 @@ module Samagotchi
|
|
|
269
269
|
raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
|
|
270
270
|
raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
|
|
271
271
|
|
|
272
|
-
@warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src)
|
|
272
|
+
@warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
|
|
273
273
|
end
|
|
274
274
|
return {} if @dry_run
|
|
275
275
|
|
|
@@ -300,6 +300,12 @@ module Samagotchi
|
|
|
300
300
|
end
|
|
301
301
|
end
|
|
302
302
|
|
|
303
|
+
# A file only a block renderer's load names: the page loads it, nothing runs it.
|
|
304
|
+
def page_only?(file)
|
|
305
|
+
@manifest.block_renderers.any? { |renderer| renderer.load.include?(file) } &&
|
|
306
|
+
@manifest.context_providers.none? { |provider| provider.cmd.include?(file) }
|
|
307
|
+
end
|
|
308
|
+
|
|
303
309
|
# Copies +src+ to +dest+, or (a dev install) links +dest+ to it. An
|
|
304
310
|
# existing link goes first: a copy over it would write into the source.
|
|
305
311
|
def place(src, dest)
|
|
@@ -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
|
|
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
|
|
|
@@ -179,7 +179,8 @@ module Samagotchi
|
|
|
179
179
|
rules: Dir.exist?(rules_dir) ? Dir.glob(File.join(rules_dir, "*.yml")).sort : [],
|
|
180
180
|
plugin: plugin && File.file?(plugin) ? plugin : nil,
|
|
181
181
|
scripts: installed_scripts(prov, record),
|
|
182
|
-
context_providers: record ? ContextProviders.parse_list(record.context_providers) : []
|
|
182
|
+
context_providers: record ? ContextProviders.parse_list(record.context_providers) : [],
|
|
183
|
+
block_renderers: record ? BlockRenderers.parse_list(record.block_renderers) : []
|
|
183
184
|
)
|
|
184
185
|
end
|
|
185
186
|
|
|
@@ -223,6 +224,7 @@ module Samagotchi
|
|
|
223
224
|
needs: record&.needs,
|
|
224
225
|
scripts: parts.scripts.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
|
|
225
226
|
context_providers: parts.context_providers,
|
|
227
|
+
block_renderers: parts.block_renderers,
|
|
226
228
|
file_descriptions: file_descriptions(record, files_map),
|
|
227
229
|
session_settings: record&.session_settings || [],
|
|
228
230
|
session_setting_types: record&.session_setting_types || {},
|
|
@@ -26,9 +26,9 @@ module Samagotchi
|
|
|
26
26
|
# guardrails basename => recorded sha256 ("" when none)
|
|
27
27
|
# scripts file name => recorded sha256 ("" when none)
|
|
28
28
|
# plugin PluginRef, nil without one
|
|
29
|
-
# needs, context_providers
|
|
30
|
-
# the lists as recorded (nil when absent): Manifest.parse_needs
|
|
31
|
-
# and
|
|
29
|
+
# needs, context_providers, block_renderers
|
|
30
|
+
# the lists as recorded (nil when absent): Manifest.parse_needs,
|
|
31
|
+
# ContextProviders.parse_list and BlockRenderers.parse_list check them
|
|
32
32
|
# includes a profile's recorded members, nil for a plain bundle
|
|
33
33
|
# session_settings
|
|
34
34
|
# the keys a session may set (chi --set), [] when none
|
|
@@ -43,12 +43,12 @@ module Samagotchi
|
|
|
43
43
|
# error why manifest.json couldn't be read (the rest empty), else nil
|
|
44
44
|
class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
|
|
45
45
|
:requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
|
|
46
|
-
:includes, :context_providers, :session_settings, :session_setting_types, :settings,
|
|
46
|
+
:includes, :context_providers, :block_renderers, :session_settings, :session_setting_types, :settings,
|
|
47
47
|
:setting_types, :dev,
|
|
48
48
|
:error)
|
|
49
49
|
OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
|
|
50
50
|
requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
|
|
51
|
-
includes: nil, context_providers: nil, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {}, dev: false,
|
|
51
|
+
includes: nil, context_providers: nil, block_renderers: nil, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {}, dev: false,
|
|
52
52
|
error: nil }.freeze
|
|
53
53
|
|
|
54
54
|
def initialize(name:, **fields)
|
|
@@ -86,6 +86,7 @@ module Samagotchi
|
|
|
86
86
|
guardrails: mapping(h["guardrails"]) { |meta| sha(meta) },
|
|
87
87
|
scripts: mapping(h["scripts"]) { |meta| sha(meta) },
|
|
88
88
|
plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
|
|
89
|
+
block_renderers: h["block_renderers"],
|
|
89
90
|
includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil,
|
|
90
91
|
session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [],
|
|
91
92
|
session_setting_types: mapping(h["session_setting_types"]) { |type| type.to_s },
|
|
@@ -164,6 +165,8 @@ module Samagotchi
|
|
|
164
165
|
def needs? = needs.is_a?(Array) && !needs.empty?
|
|
165
166
|
|
|
166
167
|
def context_providers? = context_providers.is_a?(Array) && !context_providers.empty?
|
|
168
|
+
|
|
169
|
+
def block_renderers? = block_renderers.is_a?(Array) && !block_renderers.empty?
|
|
167
170
|
end
|
|
168
171
|
end
|
|
169
172
|
end
|
|
@@ -42,6 +42,7 @@ module Samagotchi
|
|
|
42
42
|
requires_chi: bundle.requires_chi, needs: bundle.needs,
|
|
43
43
|
# A record read with symbol keys: back to what a manifest.yml holds.
|
|
44
44
|
context_providers: JSON.parse(JSON.generate(Array(bundle.context_providers))),
|
|
45
|
+
block_renderers: JSON.parse(JSON.generate(Array(bundle.block_renderers))),
|
|
45
46
|
session_settings: bundle.session_settings, session_setting_types: bundle.session_setting_types,
|
|
46
47
|
settings: bundle.settings, setting_types: bundle.setting_types
|
|
47
48
|
)
|
|
@@ -516,6 +516,7 @@ module Samagotchi
|
|
|
516
516
|
incoming_dir: @bundle_dir,
|
|
517
517
|
scripts_files: installed.script_files,
|
|
518
518
|
context_providers: @manifest.context_providers,
|
|
519
|
+
block_renderers: @manifest.block_renderers,
|
|
519
520
|
file_descriptions: @manifest.file_descriptions,
|
|
520
521
|
session_settings: @manifest.session_settings,
|
|
521
522
|
session_setting_types: @manifest.session_setting_types,
|
|
@@ -7,6 +7,8 @@ require_relative "../context_providers"
|
|
|
7
7
|
require_relative "bundle_hook"
|
|
8
8
|
|
|
9
9
|
module Samagotchi
|
|
10
|
+
autoload :BlockRenderers, File.expand_path("../block_renderers", __dir__)
|
|
11
|
+
|
|
10
12
|
module MemoryBundle
|
|
11
13
|
# Parses and validates a bundle manifest.yml.
|
|
12
14
|
#
|
|
@@ -41,6 +43,9 @@ module Samagotchi
|
|
|
41
43
|
# cmd: '{ruby} {bundle_dir}/scripts/pr_context.rb {url}'
|
|
42
44
|
# why: GitHub PR # optional
|
|
43
45
|
# every_seconds: 300 # optional
|
|
46
|
+
# block_renderers: # optional — a fence's language → the scripts/
|
|
47
|
+
# - lang: mermaid # files chi web's page loads to draw it
|
|
48
|
+
# load: [lib.js, draw.js] # (BlockRenderers); each one a scripts: key
|
|
44
49
|
# session_settings: [cap] # optional — the settings a session may set
|
|
45
50
|
# # (chi --set cost-cap.cap=3), read by the
|
|
46
51
|
# # plugin from ctx.settings at call time;
|
|
@@ -71,7 +76,7 @@ module Samagotchi
|
|
|
71
76
|
# the description a mapping gave, for the files that have one; hooks:
|
|
72
77
|
# name → BundleHook.
|
|
73
78
|
attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
|
|
74
|
-
:scripts, :context_providers, :session_settings, :session_setting_types, :settings, :setting_types
|
|
79
|
+
:scripts, :context_providers, :block_renderers, :session_settings, :session_setting_types, :settings, :setting_types
|
|
75
80
|
|
|
76
81
|
def initialize(path:)
|
|
77
82
|
@path = path
|
|
@@ -94,6 +99,7 @@ module Samagotchi
|
|
|
94
99
|
@needs = self.class.parse_needs(raw["needs"])
|
|
95
100
|
@scripts = parse_scripts(raw["scripts"])
|
|
96
101
|
@context_providers = parse_context_providers(raw["context_providers"])
|
|
102
|
+
@block_renderers = parse_block_renderers(raw["block_renderers"])
|
|
97
103
|
@includes = parse_includes(raw["includes"])
|
|
98
104
|
@session_settings = self.class.parse_session_settings(raw["session_settings"])
|
|
99
105
|
@session_setting_types = self.class.parse_session_setting_types(raw["session_settings"])
|
|
@@ -163,7 +169,7 @@ module Samagotchi
|
|
|
163
169
|
# Writes a fresh manifest.yml from computed checksums at `dir`.
|
|
164
170
|
# +file_descriptions+ (name → text) writes those files as mappings.
|
|
165
171
|
def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
|
|
166
|
-
plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {},
|
|
172
|
+
plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], block_renderers: [], file_descriptions: {},
|
|
167
173
|
session_settings: [], session_setting_types: {}, settings: nil, setting_types: {})
|
|
168
174
|
FileUtils.mkdir_p(dir)
|
|
169
175
|
manifest = {
|
|
@@ -187,6 +193,7 @@ module Samagotchi
|
|
|
187
193
|
manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
|
|
188
194
|
manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
|
|
189
195
|
manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
196
|
+
manifest["block_renderers"] = block_renderers.map(&:to_h) unless block_renderers.empty?
|
|
190
197
|
manifest["session_settings"] = session_settings_entry(session_settings, session_setting_types) unless Array(session_settings).empty?
|
|
191
198
|
manifest["settings"] = session_settings_entry(settings, setting_types) unless settings.nil?
|
|
192
199
|
File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
|
|
@@ -362,6 +369,20 @@ module Samagotchi
|
|
|
362
369
|
raise ValidationError, e.message
|
|
363
370
|
end
|
|
364
371
|
|
|
372
|
+
# @return [Array<BlockRenderers::Renderer>] each load is a scripts: key
|
|
373
|
+
def parse_block_renderers(raw)
|
|
374
|
+
return [] if raw.nil?
|
|
375
|
+
|
|
376
|
+
renderers = BlockRenderers.parse_list(raw)
|
|
377
|
+
renderers.each do |renderer|
|
|
378
|
+
missing = renderer.load - @scripts.keys
|
|
379
|
+
raise ValidationError, "block_renderers: #{renderer.lang} loads #{missing.join(", ")}, not under scripts:" unless missing.empty?
|
|
380
|
+
end
|
|
381
|
+
renderers
|
|
382
|
+
rescue BlockRenderers::Invalid => e
|
|
383
|
+
raise ValidationError, e.message
|
|
384
|
+
end
|
|
385
|
+
|
|
365
386
|
# includes: → the names in order, duplicates dropped; [] when absent.
|
|
366
387
|
# A meta ships nothing of its own, so files, hooks or a plugin next
|
|
367
388
|
# to it is a ValidationError.
|
|
@@ -92,7 +92,7 @@ module Samagotchi
|
|
|
92
92
|
|
|
93
93
|
begin
|
|
94
94
|
data = JSON.parse(File.read(mjson), symbolize_names: true)
|
|
95
|
-
rescue JSON::ParserError, SystemCallError => e
|
|
95
|
+
rescue JSON::ParserError, EncodingError, SystemCallError => e # a key that isn't UTF-8 can't be a symbol
|
|
96
96
|
if holding.nil? || Dir.exist?(File.join(dir, name, holding.to_s))
|
|
97
97
|
yield name, InstalledBundle.unreadable(name, "manifest.json is unreadable: #{e.message}")
|
|
98
98
|
end
|
|
@@ -172,11 +172,12 @@ module Samagotchi
|
|
|
172
172
|
# @param scripts_files [Hash] file name => installed script; the
|
|
173
173
|
# sha256 of each is recorded (ContextFetch checks it before a run)
|
|
174
174
|
# @param context_providers [Array<ContextProviders::Provider>]
|
|
175
|
+
# @param block_renderers [Array<BlockRenderers::Renderer>]
|
|
175
176
|
# @param dev [Boolean] a dev install: its files are links to the
|
|
176
177
|
# source, recorded as DEV_SHA (no sha256 check) with "dev": true
|
|
177
178
|
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
178
179
|
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, marks: [], incoming_dir: nil, includes: nil,
|
|
179
|
-
scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {},
|
|
180
|
+
scripts_files: {}, context_providers: [], block_renderers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {},
|
|
180
181
|
dev: false)
|
|
181
182
|
FileUtils.mkdir_p(@bundle_dir)
|
|
182
183
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
@@ -289,6 +290,7 @@ module Samagotchi
|
|
|
289
290
|
end
|
|
290
291
|
end
|
|
291
292
|
manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
293
|
+
manifest_data["block_renderers"] = block_renderers.map(&:to_h) unless block_renderers.empty?
|
|
292
294
|
manifest_data["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
|
|
293
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?
|
|
294
296
|
manifest_data["settings"] = settings.map(&:to_s) unless settings.nil?
|
|
@@ -129,7 +129,7 @@ module Samagotchi
|
|
|
129
129
|
# A turn's opening, not a `!shell` line: a command the user ran after
|
|
130
130
|
# the answer leaves it the last word.
|
|
131
131
|
def later_turn?(message)
|
|
132
|
-
Steer.
|
|
132
|
+
Steer.turn_opening?(message)
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
# At most +max+ bytes of +text+'s start, cut on a character.
|
|
@@ -181,18 +181,13 @@ module Samagotchi
|
|
|
181
181
|
|
|
182
182
|
text = msg[:content]
|
|
183
183
|
next unless text.is_a?(String) && !text.strip.empty?
|
|
184
|
-
next if shell_line?(msg)
|
|
184
|
+
next if Steer.shell_line?(msg)
|
|
185
185
|
|
|
186
186
|
return text.strip
|
|
187
187
|
end
|
|
188
188
|
nil
|
|
189
189
|
end
|
|
190
190
|
|
|
191
|
-
# A stored `!shell` line: "!(cmd)\n<output>", no turn_id.
|
|
192
|
-
def shell_line?(message)
|
|
193
|
-
message[:turn_id].nil? && message[:content].is_a?(String) && message[:content].start_with?("!(")
|
|
194
|
-
end
|
|
195
|
-
|
|
196
191
|
def clock(iso)
|
|
197
192
|
iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
|
|
198
193
|
rescue ArgumentError
|
|
@@ -317,16 +317,9 @@ module Samagotchi
|
|
|
317
317
|
text = line.to_s.strip
|
|
318
318
|
entry = @registry.lookup(text)
|
|
319
319
|
return nil unless entry&.handler
|
|
320
|
-
return run_bundle_command(entry, text, confirm: confirm) unless entry.source == "core"
|
|
321
320
|
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
# Who sent it goes to its handler per call, never through an ivar:
|
|
325
|
-
# an anytime command runs on another thread against this instance.
|
|
326
|
-
return continue_command(text, as_parent: as_parent) if entry.id == :continue
|
|
327
|
-
return parent_answers(text, user_ui: user_ui) if entry.id == :parent_answers
|
|
328
|
-
|
|
329
|
-
instance_exec(text, &entry.handler)
|
|
321
|
+
# A card it shows names it (Engine#running_command).
|
|
322
|
+
@engine.running_command(entry.name) { dispatch(entry, text, confirm:, as_parent:, user_ui:) }
|
|
330
323
|
end
|
|
331
324
|
|
|
332
325
|
# /continue [yes|no|no, <reason>]: the continue offer's answer.
|
|
@@ -367,6 +360,19 @@ module Samagotchi
|
|
|
367
360
|
|
|
368
361
|
private
|
|
369
362
|
|
|
363
|
+
def dispatch(entry, text, confirm:, as_parent:, user_ui:)
|
|
364
|
+
return run_bundle_command(entry, text, confirm: confirm) unless entry.source == "core"
|
|
365
|
+
|
|
366
|
+
# Only commands that may ask take the flag.
|
|
367
|
+
return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
|
|
368
|
+
# Who sent it goes to its handler per call, never through an ivar:
|
|
369
|
+
# an anytime command runs on another thread against this instance.
|
|
370
|
+
return continue_command(text, as_parent: as_parent) if entry.id == :continue
|
|
371
|
+
return parent_answers(text, user_ui: user_ui) if entry.id == :parent_answers
|
|
372
|
+
|
|
373
|
+
instance_exec(text, &entry.handler)
|
|
374
|
+
end
|
|
375
|
+
|
|
370
376
|
# The resolved ref (what the session stores and --alias/--default write).
|
|
371
377
|
def model_name = @engine.effective_model_ref
|
|
372
378
|
|
|
@@ -1018,15 +1018,18 @@ module Samagotchi
|
|
|
1018
1018
|
turns.is_a?(Array) && turns.empty?
|
|
1019
1019
|
end
|
|
1020
1020
|
|
|
1021
|
-
# A folder's cards.json is empty only when it is a JSON object
|
|
1022
|
-
#
|
|
1023
|
-
#
|
|
1024
|
-
#
|
|
1025
|
-
#
|
|
1021
|
+
# A folder's cards.json is empty only when it is a JSON object with no
|
|
1022
|
+
# turn in it: an empty turn_log, or an older file's (Bridge::CardStore
|
|
1023
|
+
# version 1) turns an integer zero. Anything unreadable, not JSON, not an
|
|
1024
|
+
# object, the wrong type, or neither of those reads as "not empty": a
|
|
1025
|
+
# broken or wrong-shape cards file keeps the folder instead of raising
|
|
1026
|
+
# (Array#/Hash#to_i) or reading as empty.
|
|
1026
1027
|
def self.cards_empty?(path)
|
|
1027
1028
|
data = read_json(path)
|
|
1028
|
-
|
|
1029
|
-
|
|
1029
|
+
return false unless data.is_a?(Hash)
|
|
1030
|
+
|
|
1031
|
+
turns = data["turns"]
|
|
1032
|
+
data["turn_log"] == [] || (turns.is_a?(Integer) && turns.zero?)
|
|
1030
1033
|
end
|
|
1031
1034
|
|
|
1032
1035
|
# A session-dir JSON file's contents, or nil when it is missing,
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -336,6 +336,23 @@ module Samagotchi
|
|
|
336
336
|
prompt?(message) && (!input?(message) || start)
|
|
337
337
|
end
|
|
338
338
|
|
|
339
|
+
# A stored `!shell` line: "!(cmd)\n<output>", no turn id. A user message,
|
|
340
|
+
# so #turn_prompt? takes it; no turn ran.
|
|
341
|
+
def shell_line?(message)
|
|
342
|
+
return false unless message.is_a?(Hash)
|
|
343
|
+
|
|
344
|
+
content = message[:content] || message["content"]
|
|
345
|
+
(message[:turn_id] || message["turn_id"]).nil? && content.is_a?(String) && content.start_with?("!(")
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# A turn's opening as the page draws it (TURN_PROMPT, bubbles.js): a prompt
|
|
349
|
+
# or a wake turn's note or report, not a `!shell` line. What the recap, the
|
|
350
|
+
# cards' count and "a later turn" go by. #turn_prompt? stays what bounds a
|
|
351
|
+
# turn's slice, where a `!shell` line ends one.
|
|
352
|
+
def turn_opening?(message)
|
|
353
|
+
turn_prompt?(message) && !shell_line?(message)
|
|
354
|
+
end
|
|
355
|
+
|
|
339
356
|
# Call a loop's drain: +at_answer+ goes only to a drain that takes it
|
|
340
357
|
# (the Engine's); a caller's own drain (a queue's #drain) is called bare.
|
|
341
358
|
# A failing drain drains nothing (the loop keeps going).
|
|
@@ -544,13 +544,13 @@ module Samagotchi
|
|
|
544
544
|
|
|
545
545
|
# The session's cards.json and pending_card.json, kept as a worker's
|
|
546
546
|
# Bridge keeps them (it owns the session as a worker would): this REPL's
|
|
547
|
-
#
|
|
548
|
-
#
|
|
549
|
-
#
|
|
547
|
+
# turns go on the same turn log, so a card an earlier worker showed keeps its
|
|
548
|
+
# place above the turns after it, and its own cards and notices are saved
|
|
549
|
+
# for the next; an open card with actions is on disk, where
|
|
550
550
|
# `chi web`'s session hub reads it to mark the session as waiting.
|
|
551
551
|
def keep_cards(session)
|
|
552
552
|
path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
|
|
553
|
-
@cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
|
|
553
|
+
@cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path, messages: -> { @engine.session&.messages }))
|
|
554
554
|
keep_pending_card(session)
|
|
555
555
|
end
|
|
556
556
|
|
|
@@ -171,17 +171,20 @@ module Samagotchi
|
|
|
171
171
|
|
|
172
172
|
# A turn begins (before Engine#run_turn and the worker's running save):
|
|
173
173
|
# the overlay starts as the history plus +prompt+ (text only: no
|
|
174
|
-
# images
|
|
175
|
-
#
|
|
176
|
-
#
|
|
174
|
+
# images), marked at iteration 0, so a worker killed before the loop's
|
|
175
|
+
# first boundary leaves the prompt and a mark the next one settles. The
|
|
176
|
+
# prompt carries +turn_id+, as the first boundary's copy does: a card's
|
|
177
|
+
# place counts from it (Bridge::CardStore). A continue turn (no prompt)
|
|
178
|
+
# is the history, marked.
|
|
177
179
|
# Every mark of the turn carries +enqueued_id+ and a participant's
|
|
178
180
|
# +author+ (InterruptedTurn's).
|
|
179
181
|
# @param prompt [String, nil]
|
|
182
|
+
# @param turn_id [String, nil] the id the turn runs under
|
|
180
183
|
# @param enqueued_id [String, nil] the input the turn runs
|
|
181
184
|
# @param author [Hash] a participant's prompt's author fields
|
|
182
185
|
# (Engine#author_fields), so a kill before the first boundary leaves
|
|
183
186
|
# it theirs; {} for the owner's
|
|
184
|
-
def begin_turn(prompt: nil, enqueued_id: nil, author: {})
|
|
187
|
+
def begin_turn(prompt: nil, turn_id: nil, enqueued_id: nil, author: {})
|
|
185
188
|
@boundaries = 0
|
|
186
189
|
@enqueued_id = enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
|
|
187
190
|
@author = author[:author]
|
|
@@ -190,7 +193,10 @@ module Samagotchi
|
|
|
190
193
|
messages = Array(@session.messages).dup
|
|
191
194
|
# Stamped by the wall clock here, a moment before the turn's own
|
|
192
195
|
# stamp (Engine's Turn#wall), which the turn's first boundary saves.
|
|
193
|
-
|
|
196
|
+
unless prompt.to_s.empty?
|
|
197
|
+
messages << { role: "user", content: prompt, **(turn_id ? { turn_id: turn_id } : {}), **author,
|
|
198
|
+
MessageTime::KEY => MessageTime.iso(MessageTime.now) }
|
|
199
|
+
end
|
|
194
200
|
@session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
|
|
195
201
|
enqueued_id: @enqueued_id, author: @author) }
|
|
196
202
|
end
|
data/lib/samagotchi/turn_flow.rb
CHANGED
|
@@ -191,8 +191,8 @@ module Samagotchi
|
|
|
191
191
|
# Restore the pre-turn checkpoint (!rollback after a Ctrl-C). A pending
|
|
192
192
|
# offer goes too: the turn it offered to continue is gone. (The REPL
|
|
193
193
|
# reads every line as an answer while an offer is pending; a worker
|
|
194
|
-
# also takes !rollback then.)
|
|
195
|
-
# (Bridge::CardStore)
|
|
194
|
+
# also takes !rollback then.) The cards' places follow the messages
|
|
195
|
+
# (Bridge::CardStore).
|
|
196
196
|
# @return [Boolean] false when there is none
|
|
197
197
|
def rollback!
|
|
198
198
|
return false unless @checkpoint
|
|
@@ -200,7 +200,6 @@ module Samagotchi
|
|
|
200
200
|
restore(@checkpoint)
|
|
201
201
|
@checkpoint = nil
|
|
202
202
|
@offer = nil
|
|
203
|
-
@engine.announce({ type: :turn_rolled_back })
|
|
204
203
|
true
|
|
205
204
|
end
|
|
206
205
|
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -74,6 +74,7 @@ require_relative "session_create"
|
|
|
74
74
|
require_relative "session_control"
|
|
75
75
|
require_relative "verify_fresh_routes"
|
|
76
76
|
require_relative "push_routes"
|
|
77
|
+
require_relative "block_renderer_routes"
|
|
77
78
|
require_relative "update_check"
|
|
78
79
|
require_relative "update_ops"
|
|
79
80
|
require_relative "proxy_gate"
|
|
@@ -107,6 +108,7 @@ module Samagotchi
|
|
|
107
108
|
include SessionControl
|
|
108
109
|
include VerifyFreshRoutes
|
|
109
110
|
include PushRoutes
|
|
111
|
+
include BlockRendererRoutes
|
|
110
112
|
include ProxyGate
|
|
111
113
|
|
|
112
114
|
DEFAULT_HOST = BridgeClient::HOST
|
|
@@ -222,8 +224,11 @@ module Samagotchi
|
|
|
222
224
|
private
|
|
223
225
|
|
|
224
226
|
# No page of chi web's may be framed by another (clickjacking): every
|
|
225
|
-
# HTML answer says so.
|
|
226
|
-
|
|
227
|
+
# HTML answer says so. frame-src 'none': a page's own frames can't navigate
|
|
228
|
+
# anywhere, which a block renderer's frame's own CSP doesn't stop (a srcdoc
|
|
229
|
+
# frame still loads under it; docs/internals/block-renderers.md). The page's
|
|
230
|
+
# full CSP is a later change.
|
|
231
|
+
FRAME_HEADERS = { "X-Frame-Options" => "DENY", "Content-Security-Policy" => "frame-ancestors 'none'; frame-src 'none'" }.freeze
|
|
227
232
|
FRAME_DESTS = %w[iframe frame embed object].freeze
|
|
228
233
|
|
|
229
234
|
def unframed(response)
|
|
@@ -315,6 +320,8 @@ module Samagotchi
|
|
|
315
320
|
["DELETE", %r{\A/api/push/subscriptions/([^/]+)\z}, :handle_push_remove, :owner],
|
|
316
321
|
["POST", %r{\A/api/push/test\z}, :handle_push_test, :owner],
|
|
317
322
|
["POST", %r{\A/api/push/presence\z}, :handle_push_presence, :owner],
|
|
323
|
+
["GET", %r{\A/api/block_renderers\z}, :handle_block_renderers, :owner],
|
|
324
|
+
["GET", %r{\A/api/block_renderers/([^/]+)/([^/]+)\z}, :handle_block_renderer_file, :owner],
|
|
318
325
|
["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :participant, :session],
|
|
319
326
|
["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :owner, :session]
|
|
320
327
|
].freeze
|
|
@@ -1800,21 +1807,24 @@ module Samagotchi
|
|
|
1800
1807
|
rel = rel.split("/").reject { |p| p == ".." || p.empty? }.join("/")
|
|
1801
1808
|
full = File.join(@public_dir, rel)
|
|
1802
1809
|
if File.file?(full) && full.start_with?(@public_dir)
|
|
1803
|
-
|
|
1804
|
-
# no-cache + a content ETag: the browser revalidates every asset
|
|
1805
|
-
# (the ES-module imports too) and gets a cheap 304 while it is
|
|
1806
|
-
# unchanged, so a gem upgrade + chi web restart takes effect on the
|
|
1807
|
-
# next load instead of after max-age runs out.
|
|
1808
|
-
etag = %("#{Digest::SHA256.hexdigest(body)[0, 32]}")
|
|
1809
|
-
headers = { "Cache-Control" => "no-cache", "ETag" => etag }
|
|
1810
|
-
return [304, headers, []] if etag_match?(req.get_header("HTTP_IF_NONE_MATCH"), etag)
|
|
1811
|
-
|
|
1812
|
-
[200, headers.merge("Content-Type" => mime_type(full), "Content-Length" => body.bytesize.to_s), [body]]
|
|
1810
|
+
revalidated_body(req, File.binread(full), mime_type(full))
|
|
1813
1811
|
else
|
|
1814
1812
|
not_found(path: req.path_info)
|
|
1815
1813
|
end
|
|
1816
1814
|
end
|
|
1817
1815
|
|
|
1816
|
+
# no-cache + a content ETag: the browser revalidates every asset
|
|
1817
|
+
# (the ES-module imports too) and gets a cheap 304 while it is
|
|
1818
|
+
# unchanged, so a gem upgrade + chi web restart takes effect on the
|
|
1819
|
+
# next load instead of after max-age runs out.
|
|
1820
|
+
def revalidated_body(req, body, content_type)
|
|
1821
|
+
etag = %("#{Digest::SHA256.hexdigest(body)[0, 32]}")
|
|
1822
|
+
headers = { "Cache-Control" => "no-cache", "ETag" => etag }
|
|
1823
|
+
return [304, headers, []] if etag_match?(req.get_header("HTTP_IF_NONE_MATCH"), etag)
|
|
1824
|
+
|
|
1825
|
+
[200, headers.merge("Content-Type" => content_type, "Content-Length" => body.bytesize.to_s), [body]]
|
|
1826
|
+
end
|
|
1827
|
+
|
|
1818
1828
|
def etag_match?(if_none_match, etag)
|
|
1819
1829
|
return false if if_none_match.nil?
|
|
1820
1830
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../block_renderers"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Web
|
|
7
|
+
# What the owner's page asks to draw a fenced block (App::ROUTES, :owner
|
|
8
|
+
# rows; a participant's page shows the source). Included in App: its helpers
|
|
9
|
+
# answer. docs/internals/block-renderers.md.
|
|
10
|
+
module BlockRendererRoutes
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# {renderers: [{lang, bundle, files}]}
|
|
14
|
+
def handle_block_renderers(_req)
|
|
15
|
+
json_response(200, { renderers: BlockRenderers.for_installed.map(&:to_h) })
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# One of a renderer's files. The captures are the URL's and unchecked:
|
|
19
|
+
# BlockRenderers.script_path never makes a path of one that isn't a
|
|
20
|
+
# renderer's. A file changed since the install gets an error, so the page
|
|
21
|
+
# keeps the block as source.
|
|
22
|
+
def handle_block_renderer_file(req, bundle, file)
|
|
23
|
+
path = BlockRenderers.script_path(bundle, file)
|
|
24
|
+
return not_found(path: req.path_info) unless path
|
|
25
|
+
|
|
26
|
+
revalidated_body(req, File.binread(path), "text/javascript; charset=utf-8")
|
|
27
|
+
rescue BlockRenderers::Invalid => e
|
|
28
|
+
error_response(409, "renderer_changed", e.message)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|