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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -1
- data/docs/architecture.md +2 -2
- data/docs/bundle-authoring.md +5 -5
- data/docs/cli.md +32 -7
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +4 -0
- data/docs/internals/block-renderers.md +98 -0
- data/docs/internals/bridge.md +53 -17
- data/docs/internals/bundles.md +49 -14
- data/docs/internals/objectives.md +6 -4
- data/docs/internals/web-frontend.md +2 -2
- data/docs/memory.md +7 -2
- data/docs/plugins.md +91 -14
- data/docs/releasing.md +7 -2
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/block_renderers.rb +116 -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/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/try/trace.rb +1 -1
- 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/child_env.rb +15 -0
- data/lib/samagotchi/context_providers.rb +42 -10
- data/lib/samagotchi/engine.rb +47 -6
- 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/rules.rb +1 -0
- data/lib/samagotchi/guardrails/verdict.rb +20 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
- data/lib/samagotchi/memory_bundle/asset_installer.rb +18 -9
- data/lib/samagotchi/memory_bundle/builder.rb +39 -31
- 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 -43
- data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -14
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
- data/lib/samagotchi/memory_bundle/installer.rb +91 -16
- data/lib/samagotchi/memory_bundle/manifest.rb +166 -5
- 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 +220 -19
- 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/plugin/loader.rb +1 -1
- data/lib/samagotchi/session_chain.rb +2 -7
- data/lib/samagotchi/session_commands.rb +15 -9
- data/lib/samagotchi/session_config.rb +5 -4
- data/lib/samagotchi/session_manager.rb +10 -7
- data/lib/samagotchi/steer.rb +17 -0
- data/lib/samagotchi/system_one/record.rb +10 -2
- data/lib/samagotchi/terminal_ui.rb +4 -4
- data/lib/samagotchi/tools/delegate.rb +20 -2
- data/lib/samagotchi/turn_checkpoint.rb +11 -5
- data/lib/samagotchi/turn_flow.rb +2 -3
- data/lib/samagotchi/update_command.rb +13 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +24 -13
- 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_access_routes.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +6 -0
- data/lib/samagotchi/web/session_show.rb +1 -1
- data/lib/samagotchi/worker.rb +4 -2
- metadata +13 -1
|
@@ -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
|
|
|
@@ -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
|
|
@@ -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).
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -159,7 +159,8 @@ module Samagotchi
|
|
|
159
159
|
# repository (a worktree the parent meant to pass as cwd:), which makes
|
|
160
160
|
# every write there an ask for the user (child-boundary). One warning
|
|
161
161
|
# line, however many are named; nil when the task names none, or names
|
|
162
|
-
# only the checkout holding the child's folder (or a subfolder of it)
|
|
162
|
+
# only the checkout holding the child's folder (or a subfolder of it),
|
|
163
|
+
# or main while the child itself runs in a worktree of it.
|
|
163
164
|
# Never raises: a started child must not come back as an Error:.
|
|
164
165
|
# @return [String, nil]
|
|
165
166
|
def self.other_checkout(task, folder, parent:, child_id:, no_cwd:)
|
|
@@ -180,6 +181,9 @@ module Samagotchi
|
|
|
180
181
|
# not the one holding +folder+: the first path token in the task that
|
|
181
182
|
# belongs to one (the deepest containing it), else nil. Reads files
|
|
182
183
|
# only, no git process, so a spec stubbing Process.spawn is unaffected.
|
|
184
|
+
# A child started in a linked worktree is never pointed at main: its
|
|
185
|
+
# work belongs in the worktree, so naming a main-checkout file - mostly
|
|
186
|
+
# to read it - is not a cwd: the parent forgot.
|
|
183
187
|
# @return [String, nil] an absolute checkout folder
|
|
184
188
|
def self.foreign_checkout(task, folder)
|
|
185
189
|
common = MemoryPaths.common_git_dir(folder)
|
|
@@ -187,17 +191,31 @@ module Samagotchi
|
|
|
187
191
|
|
|
188
192
|
checkouts = Guardrails::GitInfo.checkouts(common).map { |checkout| File.realpath(checkout) }
|
|
189
193
|
own = deepest_checkout(File.realpath(folder), checkouts)
|
|
194
|
+
main = main_checkout(common)
|
|
190
195
|
task.to_s.scan(PATH_TOKEN) do |token|
|
|
191
196
|
path = existing_ancestor(File.expand_path(token.sub(/[.:!?]+\z/, ""), folder))
|
|
192
197
|
next if path.nil?
|
|
193
198
|
|
|
194
199
|
found = deepest_checkout(path, checkouts)
|
|
195
|
-
|
|
200
|
+
next if found.nil? || found == own || found == main
|
|
201
|
+
|
|
202
|
+
return found
|
|
196
203
|
end
|
|
197
204
|
nil
|
|
198
205
|
end
|
|
199
206
|
private_class_method :foreign_checkout
|
|
200
207
|
|
|
208
|
+
# The main checkout of a common git dir (the folder holding it), nil for
|
|
209
|
+
# a bare or separate-git-dir layout, which has none.
|
|
210
|
+
def self.main_checkout(common)
|
|
211
|
+
return nil unless File.basename(common) == ".git"
|
|
212
|
+
|
|
213
|
+
File.realpath(File.dirname(common))
|
|
214
|
+
rescue StandardError
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
private_class_method :main_checkout
|
|
218
|
+
|
|
201
219
|
# The checkout containing +path+, matched by whole components (so
|
|
202
220
|
# /x/app is not found in /x/app-fix): the deepest, so a nested worktree's
|
|
203
221
|
# path belongs to that worktree, not to main; nil when none does.
|
|
@@ -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
|
|
|
@@ -10,6 +10,7 @@ require_relative "gem_update"
|
|
|
10
10
|
require_relative "installed_gem"
|
|
11
11
|
require_relative "live_versions"
|
|
12
12
|
require_relative "memory_bundle/file_standing"
|
|
13
|
+
require_relative "memory_bundle/manifest_standing"
|
|
13
14
|
require_relative "memory_bundle/shipped_update"
|
|
14
15
|
require_relative "memory_bundle/system_bundle"
|
|
15
16
|
require_relative "session"
|
|
@@ -331,10 +332,20 @@ module Samagotchi
|
|
|
331
332
|
|
|
332
333
|
require_relative "bundle_command/installed_check"
|
|
333
334
|
bundles.flat_map do |bundle|
|
|
334
|
-
|
|
335
|
+
kept = MemoryBundle::ManifestStanding.keep(bundle, dry_run: @dry_run)
|
|
336
|
+
[manifest_row(kept)].compact +
|
|
337
|
+
BundleCommand::InstalledCheck.run(kept.bundle).map { |found| Row.new(component: bundle.name, status: found.text, level: found.level) }
|
|
335
338
|
end
|
|
336
339
|
end
|
|
337
340
|
|
|
341
|
+
# What an external bundle's manifest.yml came to (ManifestStanding.keep):
|
|
342
|
+
# kept (or would be) is a change, anything else to say a warning.
|
|
343
|
+
def manifest_row(kept)
|
|
344
|
+
return nil unless kept.line
|
|
345
|
+
|
|
346
|
+
Row.new(component: kept.bundle.name, status: kept.line, level: kept.kept ? nil : "warn")
|
|
347
|
+
end
|
|
348
|
+
|
|
338
349
|
def bundle_row(update)
|
|
339
350
|
row = Row.new(component: update.name, from: update.from, to: update.to)
|
|
340
351
|
earlier = update.status == :failed ? [] : Array(update.earlier)
|
|
@@ -343,7 +354,7 @@ module Samagotchi
|
|
|
343
354
|
when :would_update, :updated
|
|
344
355
|
notes = [adds(update.adds), *notes, replaced(update.replaced), *update.kept_own].compact
|
|
345
356
|
done(row, :update, notes.empty? ? nil : notes.join("; "))
|
|
346
|
-
when :would_merge, :merged, :up_to_date then same_version(row, [*notes, update.note].compact, changed: Array(update.merged).any?)
|
|
357
|
+
when :would_merge, :merged, :up_to_date then same_version(row, [*notes, update.note, update.manifest].compact, changed: Array(update.merged).any?)
|
|
347
358
|
when :skipped then skipped(row, [update.note, *notes].join("; "))
|
|
348
359
|
else failed(row, [update.note, *notes].compact.join("; "))
|
|
349
360
|
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -62,6 +62,7 @@ require_relative "editor"
|
|
|
62
62
|
require_relative "../message_parts"
|
|
63
63
|
require_relative "../launch_mode"
|
|
64
64
|
require_relative "session_summary"
|
|
65
|
+
require_relative "session_hub"
|
|
65
66
|
require_relative "project_list"
|
|
66
67
|
require_relative "session_access_routes"
|
|
67
68
|
require_relative "principal"
|
|
@@ -74,6 +75,7 @@ require_relative "session_create"
|
|
|
74
75
|
require_relative "session_control"
|
|
75
76
|
require_relative "verify_fresh_routes"
|
|
76
77
|
require_relative "push_routes"
|
|
78
|
+
require_relative "block_renderer_routes"
|
|
77
79
|
require_relative "update_check"
|
|
78
80
|
require_relative "update_ops"
|
|
79
81
|
require_relative "proxy_gate"
|
|
@@ -107,6 +109,7 @@ module Samagotchi
|
|
|
107
109
|
include SessionControl
|
|
108
110
|
include VerifyFreshRoutes
|
|
109
111
|
include PushRoutes
|
|
112
|
+
include BlockRendererRoutes
|
|
110
113
|
include ProxyGate
|
|
111
114
|
|
|
112
115
|
DEFAULT_HOST = BridgeClient::HOST
|
|
@@ -222,8 +225,11 @@ module Samagotchi
|
|
|
222
225
|
private
|
|
223
226
|
|
|
224
227
|
# No page of chi web's may be framed by another (clickjacking): every
|
|
225
|
-
# HTML answer says so.
|
|
226
|
-
|
|
228
|
+
# HTML answer says so. frame-src 'none': a page's own frames can't navigate
|
|
229
|
+
# anywhere, which a block renderer's frame's own CSP doesn't stop (a srcdoc
|
|
230
|
+
# frame still loads under it; docs/internals/block-renderers.md). The page's
|
|
231
|
+
# full CSP is a later change.
|
|
232
|
+
FRAME_HEADERS = { "X-Frame-Options" => "DENY", "Content-Security-Policy" => "frame-ancestors 'none'; frame-src 'none'" }.freeze
|
|
227
233
|
FRAME_DESTS = %w[iframe frame embed object].freeze
|
|
228
234
|
|
|
229
235
|
def unframed(response)
|
|
@@ -315,6 +321,8 @@ module Samagotchi
|
|
|
315
321
|
["DELETE", %r{\A/api/push/subscriptions/([^/]+)\z}, :handle_push_remove, :owner],
|
|
316
322
|
["POST", %r{\A/api/push/test\z}, :handle_push_test, :owner],
|
|
317
323
|
["POST", %r{\A/api/push/presence\z}, :handle_push_presence, :owner],
|
|
324
|
+
["GET", %r{\A/api/block_renderers\z}, :handle_block_renderers, :owner],
|
|
325
|
+
["GET", %r{\A/api/block_renderers/([^/]+)/([^/]+)\z}, :handle_block_renderer_file, :owner],
|
|
318
326
|
["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :participant, :session],
|
|
319
327
|
["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :owner, :session]
|
|
320
328
|
].freeze
|
|
@@ -1018,7 +1026,7 @@ module Samagotchi
|
|
|
1018
1026
|
def handle_info(req)
|
|
1019
1027
|
return participant_info unless principal(req).owner?
|
|
1020
1028
|
|
|
1021
|
-
chi_state = @hub&.chi_state ||
|
|
1029
|
+
chi_state = @hub&.chi_state || SessionHub::HUBLESS_STATE
|
|
1022
1030
|
json_response(200, { app: "chi-web", **chi_state, pid: Process.pid,
|
|
1023
1031
|
cwd: Dir.pwd, start_dir: start_dir, features: INFO_FEATURES, lan: @lan && @lan[:ip],
|
|
1024
1032
|
proxy: @public_url && { identity_header: @public_url.identity_header } })
|
|
@@ -1800,21 +1808,24 @@ module Samagotchi
|
|
|
1800
1808
|
rel = rel.split("/").reject { |p| p == ".." || p.empty? }.join("/")
|
|
1801
1809
|
full = File.join(@public_dir, rel)
|
|
1802
1810
|
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]]
|
|
1811
|
+
revalidated_body(req, File.binread(full), mime_type(full))
|
|
1813
1812
|
else
|
|
1814
1813
|
not_found(path: req.path_info)
|
|
1815
1814
|
end
|
|
1816
1815
|
end
|
|
1817
1816
|
|
|
1817
|
+
# no-cache + a content ETag: the browser revalidates every asset
|
|
1818
|
+
# (the ES-module imports too) and gets a cheap 304 while it is
|
|
1819
|
+
# unchanged, so a gem upgrade + chi web restart takes effect on the
|
|
1820
|
+
# next load instead of after max-age runs out.
|
|
1821
|
+
def revalidated_body(req, body, content_type)
|
|
1822
|
+
etag = %("#{Digest::SHA256.hexdigest(body)[0, 32]}")
|
|
1823
|
+
headers = { "Cache-Control" => "no-cache", "ETag" => etag }
|
|
1824
|
+
return [304, headers, []] if etag_match?(req.get_header("HTTP_IF_NONE_MATCH"), etag)
|
|
1825
|
+
|
|
1826
|
+
[200, headers.merge("Content-Type" => content_type, "Content-Length" => body.bytesize.to_s), [body]]
|
|
1827
|
+
end
|
|
1828
|
+
|
|
1818
1829
|
def etag_match?(if_none_match, etag)
|
|
1819
1830
|
return false if if_none_match.nil?
|
|
1820
1831
|
|
|
@@ -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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "nokogiri"
|
|
4
4
|
require "uri"
|
|
5
|
+
require_relative "../block_renderers"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
module Web
|
|
@@ -17,6 +18,12 @@ module Samagotchi
|
|
|
17
18
|
ALLOWED_ELEMENTS = %w[a blockquote br code del em h1 h2 h3 h4 h5 h6 hr li ol p pre s strong span table tbody td th thead tr ul].freeze
|
|
18
19
|
DROP_ELEMENTS = %w[iframe object script style svg].freeze
|
|
19
20
|
ALLOWED_PROTOCOLS = %w[http https mailto].freeze
|
|
21
|
+
# Both Commonmarker calls take these: the parse that reads the fences has
|
|
22
|
+
# to see the blocks #render's own parse does.
|
|
23
|
+
OPTIONS = {
|
|
24
|
+
render: { escape: true }, # escape raw HTML from source; highlighter output is still emitted
|
|
25
|
+
extension: { header_ids: nil }
|
|
26
|
+
}.freeze
|
|
20
27
|
|
|
21
28
|
def initialize(enabled: false)
|
|
22
29
|
@enabled = enabled
|
|
@@ -46,10 +53,7 @@ module Samagotchi
|
|
|
46
53
|
def render(markdown)
|
|
47
54
|
return nil unless available?
|
|
48
55
|
|
|
49
|
-
html = Commonmarker.to_html(markdown.to_s, options: {
|
|
50
|
-
render: { escape: true }, # escape raw HTML from source; highlighter output is still emitted
|
|
51
|
-
extension: { header_ids: nil }
|
|
52
|
-
}, plugins: {
|
|
56
|
+
html = Commonmarker.to_html(markdown.to_s, options: OPTIONS, plugins: {
|
|
53
57
|
# An empty theme makes syntect emit scope classes (`keyword`, `string`, ...)
|
|
54
58
|
# instead of inline colours from one fixed theme, so the page's light and
|
|
55
59
|
# dark palettes colour code blocks (the --code-* tokens in index.html).
|
|
@@ -57,7 +61,7 @@ module Samagotchi
|
|
|
57
61
|
})
|
|
58
62
|
return nil unless html
|
|
59
63
|
|
|
60
|
-
sanitize(html)
|
|
64
|
+
sanitize(html, fence_languages(markdown.to_s))
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
private
|
|
@@ -77,7 +81,35 @@ module Samagotchi
|
|
|
77
81
|
@commonmarker_installed = Gem::Specification.find_all_by_name("commonmarker").any?
|
|
78
82
|
end
|
|
79
83
|
|
|
80
|
-
|
|
84
|
+
# Every code block's language, in document order (nil for an indented block
|
|
85
|
+
# or a bare fence): the highlighter drops it from the HTML, the parse tree
|
|
86
|
+
# has it. [] for a text with no fence: its blocks have no language.
|
|
87
|
+
def fence_languages(markdown)
|
|
88
|
+
return [] unless markdown.include?("```") || markdown.include?("~~~")
|
|
89
|
+
|
|
90
|
+
languages = []
|
|
91
|
+
Commonmarker.parse(markdown, options: OPTIONS).walk do |node|
|
|
92
|
+
languages << language_of(node.fence_info) if node.type == :code_block
|
|
93
|
+
end
|
|
94
|
+
languages
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def language_of(info)
|
|
98
|
+
word = info.to_s.split.first.to_s.downcase
|
|
99
|
+
word if word.match?(BlockRenderers::LANG)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# After the walk, which drops every attribute it doesn't know. Blocks and
|
|
103
|
+
# languages pair by position; a count that differs says the two parses
|
|
104
|
+
# disagree, and then no block gets one (it shows as source).
|
|
105
|
+
def mark_languages(fragment, languages)
|
|
106
|
+
blocks = fragment.css("pre")
|
|
107
|
+
return unless blocks.size == languages.size
|
|
108
|
+
|
|
109
|
+
blocks.zip(languages) { |pre, language| pre["data-lang"] = language if language }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def sanitize(html, languages)
|
|
81
113
|
fragment = Nokogiri::HTML5.fragment(html)
|
|
82
114
|
fragment.traverse do |node|
|
|
83
115
|
next unless node.element?
|
|
@@ -90,6 +122,7 @@ module Samagotchi
|
|
|
90
122
|
sanitize_attributes(node)
|
|
91
123
|
end
|
|
92
124
|
end
|
|
125
|
+
mark_languages(fragment, languages)
|
|
93
126
|
fragment.to_html
|
|
94
127
|
end
|
|
95
128
|
|
|
@@ -59,7 +59,7 @@ module Samagotchi
|
|
|
59
59
|
return nil unless saved
|
|
60
60
|
|
|
61
61
|
messages = Array(session.messages)
|
|
62
|
-
since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.
|
|
62
|
+
since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_opening?(m) }
|
|
63
63
|
{ text: saved[:text], turns_since: since, created_at: saved[:created_at] }.compact
|
|
64
64
|
rescue StandardError
|
|
65
65
|
nil
|
|
@@ -288,8 +288,8 @@ module Samagotchi
|
|
|
288
288
|
end
|
|
289
289
|
|
|
290
290
|
# As JSON has them (string keys and values), like a snapshot's.
|
|
291
|
-
def saved_cards(id)
|
|
292
|
-
JSON.parse(JSON.generate(Samagotchi::Bridge::CardStore.saved(Session.session_dir(id, state_dir: default_state_dir))))
|
|
291
|
+
def saved_cards(id, messages: nil)
|
|
292
|
+
JSON.parse(JSON.generate(Samagotchi::Bridge::CardStore.saved(Session.session_dir(id, state_dir: default_state_dir), messages: messages)))
|
|
293
293
|
rescue StandardError
|
|
294
294
|
[]
|
|
295
295
|
end
|
|
@@ -85,7 +85,7 @@ module Samagotchi
|
|
|
85
85
|
llm_context_edited merged_input objectives_updated participants_changed pending_input_merged plugin_init_finished
|
|
86
86
|
plugin_init_started plugin_init_wait prompt_restored question question_relay reasoning_history_refused
|
|
87
87
|
recap_ready reminder_injected sampling_refused session_end session_start side_usage steer_cut thinking_dropped
|
|
88
|
-
thinking_refused tool_dispatch_completed tool_dispatch_started
|
|
88
|
+
thinking_refused tool_dispatch_completed tool_dispatch_started used_memories_updated working_set_updated
|
|
89
89
|
].freeze
|
|
90
90
|
|
|
91
91
|
# A merged input line's saved sources a participant sees: a person's
|
|
@@ -71,6 +71,7 @@ import { createAnnotateBar } from "./annotate_bar.js";
|
|
|
71
71
|
import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify } from "./verify_preset.js";
|
|
72
72
|
import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
|
|
73
73
|
import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
|
|
74
|
+
import { createBlockHost } from "./block_host.js";
|
|
74
75
|
import { copyText, installCopy } from "./copy.js";
|
|
75
76
|
import { installAnswerFold, sweepAnswerFolds } from "./answer_fold.js";
|
|
76
77
|
import { installDetailsMotion } from "./details_motion.js";
|
|
@@ -2119,6 +2120,7 @@ historyEl.addEventListener("click", (e) => {
|
|
|
2119
2120
|
});
|
|
2120
2121
|
turnDom.scrollEl.addEventListener("scroll", annotateBar.schedule, true);
|
|
2121
2122
|
turnRoots().forEach((root) => installCopy(root));
|
|
2123
|
+
createBlockHost({ roots: turnRoots(), sessionId: () => store.selected });
|
|
2122
2124
|
// A steps block, a thinking block and the recap open and close by hand
|
|
2123
2125
|
// with their height animated (details_motion.js), in the history and on
|
|
2124
2126
|
// the stage.
|