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
@@ -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.turn_prompt?(message) && !shell_line?(message)
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
- # Only commands that may ask take the flag.
323
- return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
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, InstalledBundle#session_settings);
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
- offered[name] = bundle.session_settings
312
- types[name] = bundle.session_setting_types unless bundle.session_setting_types.empty?
313
- if bundle.session_settings.empty? && shipped_settings?(shipped_dir, name)
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 whose
1022
- # turns is an integer zero. Anything unreadable, not JSON, not an
1023
- # object, the wrong type, or turns not an integer reads as "not empty":
1024
- # a broken or wrong-shape cards file keeps the folder instead of
1025
- # raising (Array#/Hash#to_i) or reading as empty.
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
- turns = data.is_a?(Hash) ? data["turns"] : nil
1029
- turns.is_a?(Integer) && turns.zero?
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,
@@ -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. The gate before
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["rule"].to_s == @rule && (repo.nil? || entry["repo"] == repo) }
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
- # kept turns count on from the saved count, so a card an earlier worker
548
- # showed stays that many turns back, and its own cards and notices are
549
- # saved for the next; an open card with actions is on disk, where
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
- return found if found && found != own
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, no turn id), marked at iteration 0, so a worker killed before
175
- # the loop's first boundary leaves the prompt and a mark the next one
176
- # settles. A continue turn (no prompt) is the history, marked.
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
- messages << { role: "user", content: prompt, **author, MessageTime::KEY => MessageTime.iso(MessageTime.now) } unless prompt.to_s.empty?
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
@@ -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.) :turn_rolled_back tells the cards' count
195
- # (Bridge::CardStore) that the turn's prompt is gone.
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
- BundleCommand::InstalledCheck.run(bundle).map { |found| Row.new(component: bundle.name, status: found.text, level: found.level) }
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.150.0"
4
+ VERSION = "0.152.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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. The page's full CSP is a later change.
226
- FRAME_HEADERS = { "X-Frame-Options" => "DENY", "Content-Security-Policy" => "frame-ancestors 'none'" }.freeze
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 || { version: Samagotchi::VERSION, installed: nil, available: nil, update_blocked: nil, auto: "off", restart: "idle", auto_offers: nil }
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
- body = File.binread(full)
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
- def sanitize(html)
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.turn_prompt?(m) }
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 turn_rolled_back used_memories_updated working_set_updated
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.