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
|
@@ -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.
|
|
@@ -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
|
+
}
|