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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/bundle-authoring.md +1 -1
  5. data/docs/configuration.md +1 -1
  6. data/docs/internals/block-renderers.md +96 -0
  7. data/docs/internals/bridge.md +53 -17
  8. data/docs/internals/bundles.md +1 -0
  9. data/docs/internals/web-frontend.md +2 -2
  10. data/docs/memory.md +1 -1
  11. data/docs/plugins.md +43 -3
  12. data/lib/samagotchi/block_renderers.rb +115 -0
  13. data/lib/samagotchi/bridge/card_places.rb +93 -0
  14. data/lib/samagotchi/bridge/card_store.rb +219 -122
  15. data/lib/samagotchi/bridge.rb +12 -4
  16. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/engine.rb +20 -2
  19. data/lib/samagotchi/memory_bundle/asset_installer.rb +7 -1
  20. data/lib/samagotchi/memory_bundle/builder.rb +5 -3
  21. data/lib/samagotchi/memory_bundle/installed_bundle.rb +8 -5
  22. data/lib/samagotchi/memory_bundle/installed_copy.rb +1 -0
  23. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  24. data/lib/samagotchi/memory_bundle/manifest.rb +23 -2
  25. data/lib/samagotchi/memory_bundle/provenance.rb +4 -2
  26. data/lib/samagotchi/session_chain.rb +2 -7
  27. data/lib/samagotchi/session_commands.rb +15 -9
  28. data/lib/samagotchi/session_manager.rb +10 -7
  29. data/lib/samagotchi/steer.rb +17 -0
  30. data/lib/samagotchi/terminal_ui.rb +4 -4
  31. data/lib/samagotchi/turn_checkpoint.rb +11 -5
  32. data/lib/samagotchi/turn_flow.rb +2 -3
  33. data/lib/samagotchi/version.rb +1 -1
  34. data/lib/samagotchi/web/app.rb +22 -12
  35. data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
  36. data/lib/samagotchi/web/markdown_renderer.rb +39 -6
  37. data/lib/samagotchi/web/message_display.rb +3 -3
  38. data/lib/samagotchi/web/participant_view.rb +1 -1
  39. data/lib/samagotchi/web/public/app.js +2 -0
  40. data/lib/samagotchi/web/public/block_frame.js +202 -0
  41. data/lib/samagotchi/web/public/block_host.js +502 -0
  42. data/lib/samagotchi/web/public/block_renderers.css +20 -0
  43. data/lib/samagotchi/web/public/block_source.js +37 -0
  44. data/lib/samagotchi/web/public/bubbles.js +9 -5
  45. data/lib/samagotchi/web/public/card.js +17 -10
  46. data/lib/samagotchi/web/public/cards_place.js +2 -2
  47. data/lib/samagotchi/web/public/claim_link.js +4 -2
  48. data/lib/samagotchi/web/public/history_reveal.js +4 -1
  49. data/lib/samagotchi/web/public/index.html +2 -0
  50. data/lib/samagotchi/web/public/markdown_place.js +2 -3
  51. data/lib/samagotchi/web/public/text_roots.js +6 -0
  52. data/lib/samagotchi/web/public/turn_html.js +4 -1
  53. data/lib/samagotchi/web/session_show.rb +1 -1
  54. data/lib/samagotchi/worker.rb +4 -2
  55. metadata +10 -1
@@ -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.
@@ -0,0 +1,202 @@
1
+ // One language's renderer in a hidden sandboxed frame: a bundle's JS, whose
2
+ // input is model-written, must not reach a page that controls sessions. The
3
+ // layers and rules are docs/internals/block-renderers.md.
4
+ //
5
+ // `sandbox="allow-scripts"`, never allow-same-origin. The frame has no network
6
+ // and no cookie, so its code comes by postMessage from the page's own
7
+ // authenticated fetch and enters as `script.text` (eval doesn't work: a library's
8
+ // "use strict" keeps its global local; a syntax error doesn't throw, it reaches
9
+ // the frame's error event). The protocol, the handshake first:
10
+ //
11
+ // frame → loaded, page → code {files}, frame → ready | failed {message}
12
+ // page → render {id, source, theme}, frame → done {id, svg} | error {id, message}
13
+ //
14
+ // One render at a time. A timer can't fire while the renderer hangs (the frame
15
+ // shares the page's thread): block_host.js's mark is for that.
16
+ //
17
+ // A module of its own: static JS is cached at unversioned URLs.
18
+
19
+ // Same words as the spike that tested them; the page's frame-src 'none' covers
20
+ // what this doesn't (navigation).
21
+ export const FRAME_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; form-action 'none'";
22
+ export const FRAME_TIMEOUT_MS = 15000;
23
+ // An SVG this size isn't a diagram anyone reads, and it would sit in the DOM.
24
+ export const MAX_SVG_CHARS = 8 * 1024 * 1024;
25
+
26
+ // Runs in the frame, before any bundle code. CSP doesn't stop WebRTC, so its
27
+ // constructors go (a nested srcdoc frame gets a fresh one: the residual risk,
28
+ // docs/internals/block-renderers.md).
29
+ const BOOTSTRAP = `(() => {
30
+ delete window.RTCPeerConnection;
31
+ delete window.webkitRTCPeerConnection;
32
+ const send = (m) => parent.postMessage(m, "*");
33
+ const words = (e) => String((e && e.message) || e).split("\\n")[0].slice(0, 300);
34
+ let renderer = null;
35
+ let loadError = null;
36
+ window.chi = { defineRenderer(r) { renderer = r; } };
37
+ addEventListener("error", (e) => { loadError = loadError || words(e.message || e.error); });
38
+ async function load(files) {
39
+ for (const code of files) {
40
+ const tag = document.createElement("script");
41
+ tag.text = code;
42
+ document.head.append(tag);
43
+ tag.remove();
44
+ if (loadError) return send({ type: "failed", message: loadError });
45
+ }
46
+ if (!renderer || typeof renderer.render !== "function") return send({ type: "failed", message: "no chi.defineRenderer" });
47
+ try { await (renderer.init && renderer.init()); } catch (e) { return send({ type: "failed", message: words(e) }); }
48
+ send({ type: "ready" });
49
+ }
50
+ async function render(m) {
51
+ try {
52
+ const svg = await renderer.render(m.source, m.theme);
53
+ if (typeof svg !== "string") throw new Error("the renderer returned no SVG");
54
+ send({ type: "done", id: m.id, svg });
55
+ } catch (e) {
56
+ send({ type: "error", id: m.id, message: words(e) });
57
+ }
58
+ }
59
+ addEventListener("message", (e) => {
60
+ if (e.source !== parent) return;
61
+ if (e.data.type === "code") load(e.data.files);
62
+ else if (e.data.type === "render") render(e.data);
63
+ });
64
+ send({ type: "loaded" });
65
+ })();`;
66
+
67
+ export function frameSrcdoc() {
68
+ return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${FRAME_CSP}"><script>${BOOTSTRAP}<\/script></head><body></body></html>`;
69
+ }
70
+
71
+ const TYPES = new Set(["loaded", "ready", "failed", "done", "error"]);
72
+
73
+ // What +event+ says, or null: only +frameWindow+'s, of a known type, and read
74
+ // as data from here on (rule 3). No other window `message` listener exists on
75
+ // this page, so this is the only receiver.
76
+ export function frameMessage(event, frameWindow) {
77
+ if (!frameWindow || event.source !== frameWindow) return null;
78
+ const data = event.data;
79
+ if (!data || typeof data !== "object" || !TYPES.has(data.type)) return null;
80
+ return data;
81
+ }
82
+
83
+ // A reason a render or load failed. +timedOut+: the page's timer fired, so the
84
+ // block is suspect (block_host.js keeps its mark); +dropped+: the page let the
85
+ // frame go, which says nothing of the block.
86
+ export class FrameError extends Error {
87
+ constructor(message, { timedOut = false, dropped = false, load = false } = {}) {
88
+ super(message);
89
+ this.timedOut = timedOut;
90
+ this.dropped = dropped;
91
+ this.load = load;
92
+ }
93
+ }
94
+
95
+ // @param files the renderer's file names, in load order
96
+ // @param fetchFile (file) => a promise of its text (block_host.js fetchRendererFile)
97
+ // @return {render(source, theme, {onStart}) → Promise<svg>, drop(), dead()}
98
+ export function createBlockFrame({ files, fetchFile, doc = document, win = window, timeoutMs = FRAME_TIMEOUT_MS }) {
99
+ const iframe = doc.createElement("iframe");
100
+ iframe.setAttribute("sandbox", "allow-scripts");
101
+ iframe.setAttribute("aria-hidden", "true");
102
+ iframe.setAttribute("tabindex", "-1");
103
+ // Off-screen, not display:none: a renderer's layout measures text in it.
104
+ iframe.style.cssText = "position:fixed;left:-10000px;top:0;width:1000px;height:800px;border:0";
105
+
106
+ let dead = false;
107
+ let nextId = 0;
108
+ // What the frame is being waited for: {type?, id?, resolve, reject, timer}.
109
+ let waiting = null;
110
+
111
+ function die(error) {
112
+ if (dead) return;
113
+ dead = true;
114
+ win.removeEventListener("message", onMessage);
115
+ iframe.remove();
116
+ settle((w) => w.reject(error));
117
+ }
118
+
119
+ function settle(answer) {
120
+ const w = waiting;
121
+ waiting = null;
122
+ if (!w) return;
123
+ clearTimeout(w.timer);
124
+ answer(w);
125
+ }
126
+
127
+ // The frame's next answer, bounded.
128
+ function answer(what, loadStage = false) {
129
+ if (dead) return Promise.reject(new FrameError("the frame is gone", { dropped: true }));
130
+ return new Promise((resolve, reject) => {
131
+ const timer = setTimeout(() => die(new FrameError(`timed out (${what})`, { timedOut: !loadStage, load: loadStage })), timeoutMs);
132
+ waiting = { what, resolve, reject, timer, loadStage };
133
+ });
134
+ }
135
+
136
+ let loadedSeen = null;
137
+ function onMessage(event) {
138
+ const m = frameMessage(event, iframe.contentWindow);
139
+ if (!m) return;
140
+ if (m.type === "loaded") { loadedSeen?.(); return; }
141
+ if (!waiting) return;
142
+ if (m.type === "ready" && waiting.what === "ready") settle((w) => w.resolve());
143
+ else if (m.type === "failed" && waiting.what === "ready") {
144
+ settle((w) => w.reject(new FrameError(String(m.message ?? "load failed"), { load: true })));
145
+ } else if (m.type === "done" && m.id === waiting.id) {
146
+ if (typeof m.svg !== "string" || m.svg.length > MAX_SVG_CHARS) settle((w) => w.reject(new FrameError("the renderer's SVG is too large or not text")));
147
+ else settle((w) => w.resolve(m.svg));
148
+ } else if (m.type === "error" && m.id === waiting.id) {
149
+ settle((w) => w.reject(new FrameError(String(m.message ?? "render failed"))));
150
+ }
151
+ }
152
+
153
+ const loaded = new Promise((resolve) => { loadedSeen = resolve; });
154
+ win.addEventListener("message", onMessage);
155
+ iframe.srcdoc = frameSrcdoc();
156
+ doc.body.append(iframe);
157
+
158
+ async function load() {
159
+ const texts = await Promise.all(files.map((file) => fetchFile(file)));
160
+ const handshake = answer("load", true);
161
+ loaded.then(() => {
162
+ // Stop waiting on the handshake's own timer and start the code's.
163
+ if (dead) return;
164
+ settle((w) => w.resolve());
165
+ });
166
+ await handshake;
167
+ const ready = answer("ready", true);
168
+ iframe.contentWindow.postMessage({ type: "code", files: texts }, "*");
169
+ await ready;
170
+ }
171
+
172
+ const ready = load().catch((e) => {
173
+ const error = e instanceof FrameError ? e : new FrameError(String(e?.message || e), { load: true });
174
+ die(error);
175
+ throw error;
176
+ });
177
+ // One render at a time, whatever the last one came to.
178
+ let queue = ready.catch(() => {});
179
+
180
+ function render(source, theme, { onStart = () => {} } = {}) {
181
+ const run = async () => {
182
+ await ready;
183
+ if (dead) throw new FrameError("the frame is gone", { dropped: true });
184
+ onStart();
185
+ const id = ++nextId;
186
+ const done = answer(`render ${id}`);
187
+ waiting.id = id;
188
+ iframe.contentWindow.postMessage({ type: "render", id, source, theme }, "*");
189
+ return done;
190
+ };
191
+ const result = queue.then(run);
192
+ queue = result.catch(() => {});
193
+ return result;
194
+ }
195
+
196
+ return {
197
+ ready,
198
+ render,
199
+ dead: () => dead,
200
+ drop: () => die(new FrameError("dropped", { dropped: true })),
201
+ };
202
+ }