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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 24e1f2b0d0bd36a11e7fc2de449630696a6d7e3431c24b6def08a70dd3d27487
|
|
4
|
+
data.tar.gz: 42ae4e62a4474d254ee294a887a856fde273d99f7a7d6b5adc961f08eaf2fb7d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fde71cbc5d64e63d34a0c85cae516c8f0f37aecda9d1b099803f023b00e31a95653374c3f8725fa82e4dcfd72aefd65b1de4d36f3a6823f0a55f6028a3a25aa4
|
|
7
|
+
data.tar.gz: 5ead8e63f46e4b8fdb10ea3b19706c8733a0ff1e30d5f129fc3dd4e1595c92d13d6a0da424335963bcc1683e3309eb73958470951b02607b7255ba960b201405
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and commands may change between minor versions. How releases are made:
|
|
|
7
7
|
[docs/releasing.md](docs/releasing.md).
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.151.0] - 2026-11-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- A bundle can declare `block_renderers:` in its manifest: a fenced code block's language and the `scripts/` files that draw it in chi web. See docs/plugins.md (Block renderers).
|
|
15
|
+
- chi web draws such a block as a picture in answers and closed steps, once a bundle for its language is installed and `web.markdown` is on. A toggle shows the source; a block that can't be drawn stays source, with one muted line. The renderer runs in a sandboxed frame with no access to the page or its cookie, and a CSP that closes its network but for WebRTC.
|
|
16
|
+
- [chi-mermaid](https://github.com/dm1try/chi-mermaid), a separate bundle, draws ```` ```mermaid ```` blocks this way and tells the model it can write them: `chi bundle install https://github.com/dm1try/chi-mermaid`.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- After a reload, a card a command showed names it (`from /short`) in the web, since the command's line isn't there any more.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- A card or notice keeps its place in the web after a reload, however many turns came after it: a finished task's or an attached context's wake no longer sends it above earlier prompts, and a reminder or a Continue no longer pushes it toward the bottom. A card now stays with the turn it came after, also through a `!rollback` or a restart. A session's existing cards keep the places they have. Going back to an older chi on a session afterwards clears that session's cards.
|
|
25
|
+
- A saved recap sits where it belongs in the web after a task or context wake, or a `!cmd`.
|
|
26
|
+
- A card shown after a failed turn stays below that turn's prompt after a reload, as it was live.
|
|
27
|
+
|
|
28
|
+
Update with `chi update`.
|
|
29
|
+
|
|
10
30
|
## [0.150.0] - 2026-11-05
|
|
11
31
|
|
|
12
32
|
### Changed
|
|
@@ -5670,7 +5690,8 @@ and long-lived sessions.
|
|
|
5670
5690
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
5671
5691
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
5672
5692
|
|
|
5673
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
5693
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.151.0...HEAD
|
|
5694
|
+
[0.151.0]: https://github.com/dm1try/samagotchi/compare/v0.150.0...v0.151.0
|
|
5674
5695
|
[0.150.0]: https://github.com/dm1try/samagotchi/compare/v0.149.0...v0.150.0
|
|
5675
5696
|
[0.149.0]: https://github.com/dm1try/samagotchi/compare/v0.148.0...v0.149.0
|
|
5676
5697
|
[0.148.0]: https://github.com/dm1try/samagotchi/compare/v0.147.0...v0.148.0
|
data/docs/architecture.md
CHANGED
|
@@ -210,7 +210,7 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
|
|
|
210
210
|
| UI | `Samagotchi::TerminalUI` | Interactive REPL (Reline), rendering (ANSI, spinner, status line), REPL commands. Delegates all core work to an `Engine`. |
|
|
211
211
|
| Model loops and adapters | `KernelLoop`, `LLM::ChatLoop`, `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::AnthropicMessages`, `LLM::HTTP` | The model↔tool loops and the HTTP adapters they talk through (see "Model loops and adapters"). |
|
|
212
212
|
| Bridge (SSE/HTTP) ([internals/bridge.md](internals/bridge.md)) | `Samagotchi::Bridge`, `SessionManager` | The **single live client transport**: an SSE read stream + HTTP POST turn/cancel/answer surface that attaches to a worker's existing `Engine` via `Engine#subscribe`. Every session worker starts it (bound `127.0.0.1`, localhost-only), and it answers only a request that names its secret (`X-Chi-Bridge`, from the worker's 0600 `bridge.json`). |
|
|
213
|
-
| Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request needs the access token, this machine's on loopback too (`App#token_gate`: the `chi_token` cookie, on loopback a value derived from the token (`Token.loopback_cookie`), or `Authorization: Bearer`; chi web prints a `Local:` link with a one-time code; [CLI](cli.md#chi-web-on-your-phone)). Experimental: behind an auth proxy (`web.public_url`, `Web::ProxyGate`) every request needs the token, this machine's too, and one that came through the proxy also the configured identity header for the owner ([configuration.md](configuration.md#behind-an-auth-proxy)). A request goes through, in order: the Host check (`local_host_header?`: loopback names, the LAN address, the public host), the cross-site check (`cross_site?`: Origin this server's or the public origin, no `Sec-Fetch-Site` cross-site/same-site on a POST or `/api`), the frame check (`framed?`: a frame/embed/object load from another origin, 403 `framed`; every HTML answer carries `X-Frame-Options: DENY` and `frame-ancestors 'none'
|
|
213
|
+
| Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request needs the access token, this machine's on loopback too (`App#token_gate`: the `chi_token` cookie, on loopback a value derived from the token (`Token.loopback_cookie`), or `Authorization: Bearer`; chi web prints a `Local:` link with a one-time code; [CLI](cli.md#chi-web-on-your-phone)). Experimental: behind an auth proxy (`web.public_url`, `Web::ProxyGate`) every request needs the token, this machine's too, and one that came through the proxy also the configured identity header for the owner ([configuration.md](configuration.md#behind-an-auth-proxy)). A request goes through, in order: the Host check (`local_host_header?`: loopback names, the LAN address, the public host), the cross-site check (`cross_site?`: Origin this server's or the public origin, no `Sec-Fetch-Site` cross-site/same-site on a POST or `/api`), the frame check (`framed?`: a frame/embed/object load from another origin, 403 `framed`; every HTML answer carries `X-Frame-Options: DENY` and `frame-ancestors 'none'; frame-src 'none'`, so a page's own frames can't navigate either), the token gate (`token_gate` → `principal_for`, `proxied_principal` for a proxied request; the `?token=` / `POST /api/token` trade sets the cookie, `__Host-chi_token` through the proxy; with `web.public_url` a direct request takes Bearer only, and with it or LAN mode chi's token-less `/api/info` probe gets `probe_info`), then the route's permission (`Principal#permits?`). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. Markdown (`web.markdown`) is rendered here only (`Web::MarkdownRenderer`): the answers it serves, card bodies, and `POST /api/render` for a text the page already shows. `App` keeps the gate, `ROUTES` and the helpers the handlers share; its handlers live in mixins it includes, each a `Web::` module with private methods: `SessionAccessRoutes` (`web/session_access_routes.rb`), `ParticipantAccess` (`web/participant_access.rb`), `ParticipantsAdmin` (`web/participants_admin.rb`), `ParticipantReview` (`web/participant_review.rb`), `MessageDisplay` (`web/message_display.rb`), `SessionShow` (`web/session_show.rb`), `SessionCreate` (`web/session_create.rb`), `SessionControl` (`web/session_control.rb`), `PushRoutes` (`web/push_routes.rb`) and `ProxyGate` (`web/proxy_gate.rb`). Exact paths in `GATE_FREE_PATHS` are served before the token gate (the fragment trade's script, and the Home Screen app's manifest, service worker and icons). `bin/chi web` entrypoint. |
|
|
214
214
|
| Sessions | `Samagotchi::Session`, `SessionManager` | File-based `~/.local/state/samagotchi/sessions/<uuid>.json` + sidecar `input/`/`output/`; retention (14d/500) + ordering (`updated_at desc`). |
|
|
215
215
|
|
|
216
216
|
- `bin/chi` in REPL mode (see `LaunchMode` above) builds `TerminalUI`. `TerminalUI#run` is the single
|
|
@@ -632,7 +632,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
|
|
|
632
632
|
| LLM context strategies | `llm_context_strategy.rb`, `llm_context_view.rb`, `llm_context_stale.rb`, `llm_context_forget.rb` | What a request sends of the history (none, stale, forget) ([internals/llm-context-forget.md](internals/llm-context-forget.md)). |
|
|
633
633
|
| Session chains | `session_continue.rb` (`SessionContinue`), `session_chain.rb` (`SessionChain`) | A session that continues an earlier one. `SessionChain.opener_for` gives the text web `continue →` prefills in the new link: the previous link's own first typed message when that link continues one itself, else nil (the 201's `opener`; not saved) ([sessions.md](sessions.md)). |
|
|
634
634
|
| Review sessions | `review_start.rb` (`ReviewStart`), `session_review.rb` (`SessionReview`, the session's `review` field), `review_command.rb` (`ReviewCommand`, `chi review`), `review_apply.rb` (`ReviewApply`) | A session made to be shared: idle, its subjects project files checked by `Guardrails::ReviewPaths`, a fork of a working session from its recap (`--from`); Apply quotes a participant's review turn into the owner's composer ([sessions.md](sessions.md#review-sessions), [internals/participants.md](internals/participants.md)). |
|
|
635
|
-
| chi web's routes | `web/app.rb` (`Web::App`: the gate, `ROUTES`, the shared helpers), `web/message_display.rb` (`Web::MessageDisplay`: saved messages and cards as display items, `POST /api/render`), `web/session_show.rb` (`Web::SessionShow`: `GET /api/sessions/:id` with its `cards`, `tail` and `timing` re-reads, `GET …/output`, the timing payload), `web/session_create.rb` (`Web::SessionCreate`: `POST /api/sessions`, a continue, a review fork), `web/session_control.rb` (`Web::SessionControl`: answer, command, turn and images, cancel, stop, restart, archive, delete), `web/push_routes.rb` (`Web::PushRoutes`: `/api/push`, its subscriptions, prefs, test and presence) | The handlers `App`'s `ROUTES` name, in the file that holds their kind; each mixin is included in `App` and reads its helpers and ivars, so `app.send(:handle_show, …)` and the specs that stub `App` methods work as before. |
|
|
635
|
+
| chi web's routes | `web/app.rb` (`Web::App`: the gate, `ROUTES`, the shared helpers), `web/message_display.rb` (`Web::MessageDisplay`: saved messages and cards as display items, `POST /api/render`), `web/session_show.rb` (`Web::SessionShow`: `GET /api/sessions/:id` with its `cards`, `tail` and `timing` re-reads, `GET …/output`, the timing payload), `web/session_create.rb` (`Web::SessionCreate`: `POST /api/sessions`, a continue, a review fork), `web/session_control.rb` (`Web::SessionControl`: answer, command, turn and images, cancel, stop, restart, archive, delete), `web/push_routes.rb` (`Web::PushRoutes`: `/api/push`, its subscriptions, prefs, test and presence), `web/block_renderer_routes.rb` (`Web::BlockRendererRoutes`: `GET /api/block_renderers` and one renderer's file, [internals/block-renderers.md](internals/block-renderers.md)) | The handlers `App`'s `ROUTES` name, in the file that holds their kind; each mixin is included in `App` and reads its helpers and ivars, so `app.send(:handle_show, …)` and the specs that stub `App` methods work as before. |
|
|
636
636
|
| Web Push | `web/push.rb` (`Web::Push`: `Runtime`, `preflight!`, the `web.push.*` settings), `web/push/crypto.rb` (`Push::Crypto`: RFC 8291 aes128gcm, RFC 8292 VAPID), `web/push/store.rb` (`Push::Store`, `Store::Sub`: `web-push/vapid.pem`, `subscriptions.json`), `web/push/client.rb` (`Push::Client`, `Client::Result`), `web/push/attention.rb` (`Push::Attention`: notify.js's rules in Ruby, shared cases in `spec/fixtures/attention_cases.json`), `web/push/presence.rb` (`Push::Presence`: the pages' "in front" reports), `web/push/notifier.rb` (`Push::Notifier`, a hub subscriber with a sender thread), `web/push_routes.rb` (`Web::PushRoutes`), `web/private_files.rb` (`Web::PrivateFiles`, shared with the grants), `public/sw.js`, `public/push.js`, `public/manifest.webmanifest` | chi web pushes what the tab notifies for (approvals, questions, step-limit questions, cards, failed turns; done turns to a device that asks) to the owner's Home Screen app over an https public URL: the Notifier turns the hub's new attentions into show pushes after a hold, unless a chi web page is in front, at most 30 an hour, with one retry and a restart catch-up (`sent.json`) (each carries the keys still open, and the badge; no close push: every push shows a notification); the bell (`notifications.js`) subscribes the device, and the service worker and the page close the answered ones. See [internals/web-push.md](internals/web-push.md). |
|
|
637
637
|
| chi web's updates | `web/update_check.rb` (`Web::UpdateCheck`), `web/update_ops.rb` (`Web::UpdateOps`), `web/auto_update.rb` (`Web::AutoUpdate`) | The RubyGems checker and its `update_check.json`; the install, worker-restart and chi-web-restart operations (one at a time) the owner's routes and AutoUpdate share; `update.auto: install`'s thread (install when no turn runs, then idle workers and chi web). See [configuration](configuration.md#unattended-updates-updateauto). |
|
|
638
638
|
| Behind an auth proxy | `web/public_url.rb` (`Web::PublicUrl`: `web.public_url`, `web.proxy.identity_header`, `web.proxy.owner`), `web/proxy_gate.rb` (`Web::ProxyGate`, mixed into App; `RawHeaderNames`, prepended to WEBrick's request by `Web::Server`), `web/link_codes.rb` (`Web::LinkCodes`: the links' one-time codes), `proxy_trace.rb` (`ProxyTrace`), `web/public/token_fragment.js`, `web/public/auth_lost.js`, `web/tunnel.rb` + `web/tunnel/tailscale.rb` (`Web::Tunnel`, `Web::Tunnel::Tailscale`: `chi web --tunnel tailscale`, Tailscale Serve set for one run and the PublicUrl it makes) | Experimental: chi web behind an auth proxy. Every request needs the token; a proxied one's owner also needs the identity header (one clean value under one name); the `__Host-` cookie, an absolute `Location` from the public URL, https links with a one-time code in the fragment; the page's lost-sign-in banner. Bridges refuse proxy traces ([configuration.md](configuration.md#behind-an-auth-proxy)). |
|
data/docs/bundle-authoring.md
CHANGED
|
@@ -60,7 +60,7 @@ plugin:
|
|
|
60
60
|
`bundles: my-bundle:` as a Hash with string keys, once, when the plugin loads. A value a session may set
|
|
61
61
|
(`chi --set my-bundle.KEY=V`, listed in the manifest's `session_settings:`, typed with `{KEY: number}` so a
|
|
62
62
|
value of another type is refused) is read from `ctx.settings` at call time. List what config.yml may set in `settings:`
|
|
63
|
-
(`[greeting]`, or `{greeting: string}`): a typo there then warns. `chi bundle check --set KEY=VALUE` gives the sandboxed load a setting.
|
|
63
|
+
(`[greeting]`, or `{greeting: string}`): a typo there then warns. `chi bundle check --set KEY=VALUE` gives the sandboxed load a setting. A bundle can also draw a fenced block in chi web's answers, with no plugin code: `block_renderers:` and a script calling `chi.defineRenderer` ([plugins.md](plugins.md#block-renderers)).
|
|
64
64
|
|
|
65
65
|
## What a hook sees
|
|
66
66
|
|
data/docs/configuration.md
CHANGED
|
@@ -1892,7 +1892,7 @@ What changes once `web.public_url` is set:
|
|
|
1892
1892
|
and a crafted link could swap your session): a link's one-time code and the 401 page's paste box do it, the paste
|
|
1893
1893
|
box's redirect going to the public URL (never to a client-sent `X-Forwarded-Host`). The page accepts `POST`s from the public `https://` origin only (a sibling port on the same
|
|
1894
1894
|
domain, or `http://`, is refused). No page of chi web's may be framed by another (`X-Frame-Options: DENY`,
|
|
1895
|
-
`frame-ancestors 'none'`, in every mode).
|
|
1895
|
+
`frame-ancestors 'none'`, in every mode), and frames inside its pages can't navigate (`frame-src 'none'`).
|
|
1896
1896
|
- Links carry a **one-time code** in the fragment, never a token: `chi web` prints `Proxied: https://…/#code=chc_…`
|
|
1897
1897
|
(the owner's: once, within 10 minutes; `chi web` again prints a new one), and a participant's link (`chi share`,
|
|
1898
1898
|
the Invite dialog) is `https://<public url>/#code=chc_…&s=ID` (once, until the grant expires). A fragment isn't sent
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Block renderers: bundles that draw fenced blocks in chi web
|
|
2
|
+
|
|
3
|
+
A bundle's JS turns a fenced block in an answer (```` ```mermaid ````) into a picture in the owner's page. No tool, no
|
|
4
|
+
toolkit. The bundle's side is [plugins.md](../plugins.md#block-renderers); this page is the mechanism and the security
|
|
5
|
+
model. The TUI is untouched: it shows a fence as source.
|
|
6
|
+
|
|
7
|
+
## The path
|
|
8
|
+
|
|
9
|
+
1. `MarkdownRenderer` keeps the language: syntect drops it from the HTML, so a second parse walks the `code_block` nodes
|
|
10
|
+
in document order and zips them with the fragment's `<pre>`s, setting `data-lang` after sanitize's walk. Only the first
|
|
11
|
+
word, downcased, matching `BlockRenderers::LANG`. A count that differs sets nothing. **`web.markdown` off means no
|
|
12
|
+
`<pre data-lang>`, so nothing draws.**
|
|
13
|
+
2. `BlockRenderers.for_installed` (manifest `block_renderers:`, read from provenance, so chi web needs no plugins) lists
|
|
14
|
+
who draws what: `GET /api/block_renderers`, `:owner`. `GET /api/block_renderers/:bundle/:file` serves a file, `:owner`:
|
|
15
|
+
the URL's names go through `BlockRenderers.script_path` (membership in a listed renderer's `load` before any path is
|
|
16
|
+
made), then `Provenance.sha_matches?`; a changed file is 409 `renderer_changed`. ETag + `no-cache`, as static files.
|
|
17
|
+
3. `block_host.js` (`createBlockHost`, wired in `app.js`; `participant.js` isn't): one `MutationObserver` per turn root,
|
|
18
|
+
filtered to `TEXTS` (`text_roots.js`), so cards, the asking slot, question replies and delegate reports never draw, and
|
|
19
|
+
every path that adds a block to a root is seen the same way. One `IntersectionObserver` per scroller, with an explicit `root`
|
|
20
|
+
(with `root: null`, `rootMargin` doesn't pass an `overflow: auto` in between). Closed `<details>` fire on open. **A
|
|
21
|
+
move isn't over when it is seen:** a block waiting to come near is watched by its scroller's observer, so one that moves
|
|
22
|
+
to another scroller (the stage's `.ts-scroll` hands a long answer to `#history`) is watched there, or it never draws.
|
|
23
|
+
4. `block_frame.js` (`createBlockFrame`): one hidden frame per language, made on first need, one render at a time, 15 s per
|
|
24
|
+
call. A timeout, a dead frame or a session switch drops it. Handshake `loaded` → `code {files}` → `ready | failed`,
|
|
25
|
+
then `render {id, source, theme}` → `done {id, svg} | error {id, message}`.
|
|
26
|
+
5. On success a `<figure class="block-drawn">` goes right before the `<pre>`, which stays in the DOM, `hidden`, so whatever
|
|
27
|
+
reads text (copy, a claim's walk, fold thresholds) sees what it did. **The DOM is the state** (`block_source.js`): a Revise
|
|
28
|
+
clone arrives with both. On failure the `<pre>` stays, marked `data-block-failed`, with one muted line: an attribute CSS
|
|
29
|
+
shows, never markup, and not in the bubble's `textContent`, which the page's dedupe and an annotation read (`copy.js`'s rule). CSS content can't be selected, so a click on the line copies its words.
|
|
30
|
+
The toggle has an icon and an `aria-label`, no text (`copy.js`'s rule). A jump into the source shows it (`revealInHistory`).
|
|
31
|
+
6. Cache: a `Map` on `lang + theme + source`, about 200 entries, holding the `data:` URL; a later `bubble.innerHTML` wipes the
|
|
32
|
+
figure and the observer redraws in the same pass. No `crypto.subtle`: it's undefined on a plain-http LAN address. A system
|
|
33
|
+
theme change redraws through the figure (a hidden `<pre>` never intersects).
|
|
34
|
+
7. The view: a viewer at the bottom stays there (`scroll.js keepFollowing`); a block drawn above a reader's view keeps
|
|
35
|
+
what they read in place (checked in Chromium and WebKit).
|
|
36
|
+
8. **A maximum picture, in the page, for any renderer** (`block_host.js oversize`): 50,000 px a side and 100M px. Mermaid's
|
|
37
|
+
secure keys don't cover diagram sizes, so a model's `%%{init:{"sequence":{"height":50000000}}}%%` otherwise gives an image
|
|
38
|
+
33M px tall, which breaks the session's scrolling. Eval 0012's real diagrams reach about 2,600 px a side and 3.5M px;
|
|
39
|
+
a chain of 240 edges or a sequence of 1,000 messages still fits. **The decoded and displayed size is what counts.** The root's box comes
|
|
40
|
+
from `DOMParser`, never a regex over model-influenced XML (exponents, a comment holding `<svg`, a `>` in an attribute, entities and
|
|
41
|
+
prefixes all fooled one), and a vast one isn't decoded. Then the decoded size is checked: over the maximum, or a width or
|
|
42
|
+
height of 0 (WebKit's 0 x 150 for a viewBox it can't hold), the block stays source with its line. `.block-img`'s CSS
|
|
43
|
+
`max-height` and natural `max-width` say the same number, a backstop whatever the parsing gets wrong. The decode is bounded too
|
|
44
|
+
(15 s, the mark on until it ends). A very wide picture stays at least 120 px tall and scrolls sideways instead of a sliver.
|
|
45
|
+
|
|
46
|
+
## Security model
|
|
47
|
+
|
|
48
|
+
The bundle's JS is trusted (installing a bundle already means trusting it, [plugins.md](../plugins.md#trust)). **Its input
|
|
49
|
+
is model-written**, so a renderer bug or an injected diagram must not reach a page that controls sessions.
|
|
50
|
+
|
|
51
|
+
| Layer | What it stops |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `<iframe sandbox="allow-scripts">`, never `allow-same-origin` | reading the parent; reading gated responses |
|
|
54
|
+
| CSP meta in the srcdoc (`block_frame.js FRAME_CSP`) | fetch, POST, beacon, image, CSS url, prefetch/preload. Without it WebKit sent the session cookie on the frame's GET |
|
|
55
|
+
| Page header `frame-src 'none'` (`App::FRAME_HEADERS`; srcdoc frames still load and run under it) | self-navigation, meta refresh, anchor click, which leak past the frame's CSP in both browsers |
|
|
56
|
+
| `delete window.RTCPeerConnection` + `webkitRTCPeerConnection` in the bootstrap, before any bundle code | the plain WebRTC call only |
|
|
57
|
+
| Output only as `<img src="data:…">` | scripts and remote images inside the SVG don't run or load |
|
|
58
|
+
|
|
59
|
+
**Hard rules.**
|
|
60
|
+
|
|
61
|
+
1. Never `allow-same-origin`. Never `innerHTML` the frame's output. SVG goes only into a `data:` URL for an `<img>`.
|
|
62
|
+
2. **Never `blob:` for the SVG.** A blob inherits chi web's origin, and the browser's own "Open Image in New Tab" and drag-to-tab
|
|
63
|
+
navigate to it, where its script reads a gated route with chi's origin and cookie. Opened as a document, a `data:` SVG
|
|
64
|
+
has origin `null` and can't read chi's API; WebKit may still attach a same-host cookie to a request it makes (seen with a
|
|
65
|
+
test cookie to another port), so what holds is that the answer stays unreadable, not that no cookie is ever sent.
|
|
66
|
+
3. The page accepts messages only where `e.source === frame.contentWindow`, of known types, and treats them as data
|
|
67
|
+
(`frameMessage`). No other window `message` listener exists on the page.
|
|
68
|
+
4. Code enters the frame by postMessage from the page's own authenticated fetch (the frame's CSP closes its network but for
|
|
69
|
+
WebRTC, the residual risk below, and it has no cookie),
|
|
70
|
+
then as `script.text = code`. `eval` doesn't work: a library's `"use strict"` keeps its global local. A syntax error doesn't
|
|
71
|
+
throw: it reaches the frame's error event, as a failed load. `fetchRendererFile` requires `res.ok` and a JS content type, so a
|
|
72
|
+
proxy's sign-in HTML is never passed in as code.
|
|
73
|
+
|
|
74
|
+
**Residual risk, accepted for v1.** Code running in the frame can send what the frame holds over WebRTC: the diagram sources it
|
|
75
|
+
has rendered. A nested `srcdoc` frame gets a fresh `RTCPeerConnection` and still sends packets, in both browsers, and no CSP
|
|
76
|
+
stops WebRTC. It can't reach the page, cookies or sessions, and it needs a renderer compromised through its input. To limit
|
|
77
|
+
what it holds, the frame is dropped on a session switch.
|
|
78
|
+
|
|
79
|
+
**The hang.** The frame shares the page's thread, so a timer can't fire during a hang, and the model's input drives the
|
|
80
|
+
renderer's runtime. So a source over 64 KB stays source, and before each render a `sessionStorage` mark (`chi_block_rendering:`,
|
|
81
|
+
per block) is written and cleared after; a block whose mark is still there on load stays source with its muted line. A timeout
|
|
82
|
+
keeps the mark too. It is per tab: a new tab tries again. A reload during a render isn't a hang: `pagehide` clears the marks of renders
|
|
83
|
+
under 15 s old. A page truly hung never runs it. A render that ends after holding on over 10 s (`SLOW_RENDER_MS`) draws now and keeps its
|
|
84
|
+
mark, so it stalls the page once and not on every load; several such blocks still stall once each, and one under the bound stalls every
|
|
85
|
+
load. Real Mermaid's worst was about 1 s, so that takes a bad renderer. A mark of a render still running isn't a leftover: a bubble
|
|
86
|
+
rewritten or cloned meanwhile has its blocks wait for that render (`coming`), and each draws into whichever is connected.
|
|
87
|
+
No real hang has been produced; this is a guard.
|
|
88
|
+
|
|
89
|
+
A navigation a hostile renderer tries is blocked, but in Chromium it leaves the frame on an error page, so that render times out at
|
|
90
|
+
15 s and the frame is dropped. Not a concern for a working renderer, and the page stays usable meanwhile.
|
|
91
|
+
|
|
92
|
+
## Not done
|
|
93
|
+
|
|
94
|
+
Firefox isn't tested: if its CSP blocks srcdoc under `frame-src 'none'`, the frame never answers and blocks stay source.
|
|
95
|
+
Interactive output (a visible frame running the model's own code) isn't built: nothing secret could be in such a frame, and a busy
|
|
96
|
+
loop would stall the page.
|
data/docs/internals/bridge.md
CHANGED
|
@@ -15,7 +15,8 @@ rescues its own errors: an observer must never break the running turn.
|
|
|
15
15
|
|-------|------------------|-------|
|
|
16
16
|
| `RingBuffer` | `ring_buffer.rb` | The last `ring_capacity` (256) raw events by `event_seq`, in memory, for a reconnect's replay. The capture observer feeds it. |
|
|
17
17
|
| `TurnAccumulator` | `turn_accumulator.rb` | The turn in progress, folded from events. |
|
|
18
|
-
| `CardStore` | `card_store.rb` | The last cards and notices, saved in `cards.json
|
|
18
|
+
| `CardStore` | `card_store.rb` | The last cards and notices, saved in `cards.json`, each anchored to its turn. |
|
|
19
|
+
| `CardPlaces` | `card_places.rb` | Pure: where `CardStore`'s entries sit in the messages the page draws (`turns_since`). |
|
|
19
20
|
| `PendingCard` | `pending_card.rb` | `pending_card.json` `{id:, bundle:}`: the running turn's open card with actions (check-in's Nudge), written and removed with that card, so chi web's hub, which watches files and not workers, can notify. |
|
|
20
21
|
|
|
21
22
|
Per connection there is an `SSEWriter` (with a `BoundedQueue`); `ClientEvent` and `EventId` are pure helpers. The
|
|
@@ -49,25 +50,60 @@ The cards a plugin or the Engine shows (`Engine#show_card`), hook notices, load
|
|
|
49
50
|
the "asking again" and steer-cut rows, ✂ rows and questions (with how they were answered), for a UI that joins later:
|
|
50
51
|
`snapshot[:cards]` and `GET /tail`'s `cards`.
|
|
51
52
|
|
|
52
|
-
- **
|
|
53
|
-
`
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
- **Anchored to its turn.** The store keeps a **turn log**, every id it saw at `:turn_started`, oldest first. An entry
|
|
54
|
+
holds `turn_id`: the running turn's, else the log's last (nil before any); a load warning, which the Engine announces
|
|
55
|
+
inside the first turn, takes the one before it. `list(messages:)` computes `turns_since` from the messages the page draws
|
|
56
|
+
(`CardPlaces`, the snapshot's own list; a stopped session's file for `.saved`): the turn openings (`Steer.turn_opening?`, as
|
|
57
|
+
the page's `TURN_PROMPT` and the recap count) after the last one whose id is in the log at or before the entry's, else from
|
|
58
|
+
the first one whose id is later in the log, else 0. The log only orders; the messages count. So a turn with no opening (a
|
|
59
|
+
reminder, a Continue, a failed or rolled-back one, a prompt stopped before the model) moves nothing, a wake's note does, and
|
|
60
|
+
an opening with no id (`/continue no`'s reason, Qwen's recovery line) counts as on the page. Nothing orders such an opening
|
|
61
|
+
against an entry, so the store reads the session's messages (its `messages:` lambda) when an entry is shown between turns, and
|
|
62
|
+
at the turn's end for what a running turn showed that isn't its own: `past_idless` is how many of them, right after the opening
|
|
63
|
+
it counts from, the entry already came after, and they don't count for it. A `!rollback` usually only shortens that run, and the count
|
|
64
|
+
is capped at the run still there, so a stale number doesn't eat a later turn. It can also move an entry's counting point back to a
|
|
65
|
+
run the entry never recorded (T1, `/continue no, too slow`, a card, a held T2 stopped, a second card, `!rollback`: the second card,
|
|
66
|
+
anchored to T2, now counts from T1 and reads above the reason and the first card). That rare inversion is accepted. A turn's own rows don't read it: they count such a line, which sits below them. `current` is a card of the running turn. A card that
|
|
67
|
+
comes mid-turn but isn't the turn's (an anytime command's, such as `/btw`) goes after that turn (`during`), and so does a
|
|
68
|
+
between-turns notice. A card with an earlier card's id replaces it in place and keeps its anchor. A `list` with no messages
|
|
69
|
+
(`?cards=1`'s bodies, `chi bundle try`'s trace) has no `turns_since`.
|
|
70
|
+
- **A turn that leaves nothing** is read, not told: an in-turn entry not `current` whose turn has no opening in the messages
|
|
71
|
+
(a `!rollback`, a failed turn with nothing kept, a prompt stopped before the model) lists as a between-turns entry at its
|
|
72
|
+
place, a card without its actions and a hook's notice without its step; its other rows, having no step to sit in, aren't
|
|
73
|
+
listed. Nothing in the entry changes. A kept Continue or reminder turn never has an opening, so its entries are `continued`
|
|
74
|
+
and stay rows of the turn before; a failed one loses the mark at its end. The page draws a failed prompt at the history's
|
|
75
|
+
end, so an entry pushed idle after it lists `after_failed` (the store's saved `failed_turn`) and goes below it, until the
|
|
76
|
+
next turn starts.
|
|
77
|
+
- **A command's card.** `SessionCommands#run` wraps its dispatch in `Engine#running_command`, so a card shown on that thread
|
|
78
|
+
carries `command:` (the event's field, `show_card` adds it). The store keeps the first show's (a replacement keeps it, even
|
|
79
|
+
none) and lists `label: "from /short"`, never `command`. The page draws it after a reload, where the command's line is gone.
|
|
58
80
|
- **Caps.** `CAPACITY` (20) each for notices, ✂ rows (`LLM_CONTEXT_TYPES`) and cards, so one burst cannot push out
|
|
59
81
|
another kind. An open question (an approval too) is never pushed out.
|
|
60
82
|
- **Saved.** With a `path` (`cards.json` in the session's folder) the store rewrites the file after each event in
|
|
61
|
-
`SAVED_ON`, and a later worker's store starts from it, so a turn's cards outlive the worker; `CardStore.saved(dir)`
|
|
62
|
-
what chi web reads for a session no worker runs.
|
|
63
|
-
and an
|
|
64
|
-
`
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
`SAVED_ON`, and a later worker's store starts from it, so a turn's cards outlive the worker; `CardStore.saved(dir, messages:)`
|
|
84
|
+
is what chi web reads for a session no worker runs. The file is `{version: 2, turn_log:, entries:}`. The log is pruned at
|
|
85
|
+
a turn's start and end and at an idle show (a running turn's rows reuse the start's, as finding it walks the messages) to start at the opening the earliest anchor counts from (all of it while an entry has none, or for a store with no
|
|
86
|
+
`messages:`), so a restart reads an entry as the live store did; an old card therefore keeps it whole. `seed` drops an entry
|
|
87
|
+
whose `turn_id`, `base_since` or `past_idless` isn't an id or a non-negative integer. Load warnings
|
|
88
|
+
are the worker's own (each start announces them) and an unanswered question waits for no one: neither is saved or seeded. An
|
|
89
|
+
entry from an earlier worker lists with `earlier: true`; one shown during a turn comes back **without its actions** (their
|
|
90
|
+
turn no longer runs), while a between-turns card keeps its buttons.
|
|
91
|
+
- **Version 1.** A file from before the log (top-level `turns`, each entry its own, maybe `running`) keeps its places, drift
|
|
92
|
+
included: `seed` turns each count into `base_since`, new openings add to it, and such an entry never reads as having left
|
|
93
|
+
nothing (no id to check). The next save writes version 2 with `base_since` kept. `SessionManager.cards_empty?` reads both.
|
|
94
|
+
- **An older chi on a version 2 file.** Its `seed` wants an integer `turns`, finds none, and starts with no entries. A chi web not
|
|
95
|
+
yet restarted only reads: it lists no cards for that session until restarted, and the file stays whole. An older **writer** does
|
|
96
|
+
damage: at its first saved event, `:turn_started`, it writes `{"version":1,"turns":0,"running":true,"entries":[]}` over the file
|
|
97
|
+
(`{"version":1,"turns":1,"entries":[]}` after that first turn), so that session's cards go, and with them the answered approvals
|
|
98
|
+
`chi guardrails replay` reads. A plain upgrade doesn't reach this: a worker already running seeded before and keeps its own version 1
|
|
99
|
+
file, and a spawn runs the newest chi installed (`SessionManager.worker_command`), unless that activation raises LoadError: the boot
|
|
100
|
+
line then falls back to the spawner's or the home's pinned version, which can be older. Otherwise it takes an older process starting
|
|
101
|
+
on a session a newer one wrote: a downgrade, a chi home whose newest chi is older (`home_older_than_spawner`), or a REPL or `bin/chi`
|
|
102
|
+
from an older gem or checkout on the same state dir. Nothing on this side stops
|
|
103
|
+
it: an older chi can't be changed, and having it read the file would mean writing fake `turns` counters into every file.
|
|
104
|
+
- **A killed turn needs nothing special.** `TurnCheckpoint` keeps a running turn's prompt in the session file, with its id from
|
|
105
|
+
the start (`Worker#run_engine_turn` settles it first), so it counts as any opening. A REPL keeps no running turn in the
|
|
106
|
+
session, so a kill takes the prompt along and there's nothing to count.
|
|
71
107
|
|
|
72
108
|
## SSEWriter
|
|
73
109
|
|
data/docs/internals/bundles.md
CHANGED
|
@@ -48,6 +48,7 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
|
|
|
48
48
|
| `Remerge` | the retry at the same version, memory files only, for a file marked `conflict`: `#run(dry_run:)` → a `Result` (`merged`, `settled`, `stamped`, `behind`, `warnings`, `trash_copy`, `trash_dir`). It merges what merges and settles those entries (`Provenance#resolve_conflicts`, the index line refreshed), leaving `version`, `installed_at` and the assets alone: the Installer would `rm_rf` guardrails, plugin and scripts under live sessions. Every file it leaves is in `behind`, with why: one not shipped now (`MemoryFiles.select`) keeps its mark, base and text; one whose `incoming_sha` isn't the shipped text (a checkout at one VERSION) is left alone, no git runs, and it reads as its mark says; a mark with no digest (older chi) is tried as well, and a real conflict then records it (`Provenance#refresh_marks`, which writes only when the mark would read differently, so a conflict at each start writes nothing). A mark whose digest matches is tried on every run, since the file or git may have changed. `noop` and `keep` settle quietly; `fast_forward` waits for the next upgrade, reading as behind until then; `install` is a missing file, which isn't behind, and a warning says so. A file it can't read or replace stays as it is, saying why. Called from `ShippedUpdate.plan_row`/`apply` (`chi update`) and `SystemBundle.sync`'s equal-version branch, under its lock (each chi start and `chi update`); a bundle chi doesn't ship retries through `chi bundle upgrade`. No marks: nothing is read beyond the record. One marked file costs one git run per Engine start, about 12 ms on the Studio (0.1 ms with none) |
|
|
49
49
|
| `Provenance` | the `.bundles/<name>/` dir: `#write` the record + bases (its `files:` is the full list: a key left out is dropped, a nil value keeps the entry with no base; `marks:` are the files left behind, each a `FileStanding::Behind`, and `incoming_dir:` the bundle text each speaks of, recorded as `incoming_sha` and saved at `#incoming_path`; an older mark that stays keeps `legacy_base`), `#resolve_conflicts` (base to the incoming file, mark and its fields (`MARK_FIELDS`) and saved text gone; a file whose incoming copy is gone keeps both), `#refresh_marks` (what a retry found: why, missed, the digest and `legacy_base`, the saved text), `#note_merged` and `#clear_merge_notes(notes)` (`MERGE_NOTE_FIELDS`, kept through `#write`; the saved text is best effort, after the manifest, so it can't leave a plugin under the old record's sha; any other failure writing the record still can, until a retry), `#record` → `InstalledBundle`, `.each_installed`, `.claimants` (which bundles own a file) |
|
|
50
50
|
| `InstalledBundle`, `BundleHook`, `PluginRef` | value objects for the record; tolerant of older records (missing keys read as empty). `BundleHook#events` is the manifest's `event:` or its `events:` list (written back as `event:` for one, as an older chi reads it); `Hooks::BundleLoader#load_hook_file` builds the hook once and registers it under each |
|
|
51
|
+
| `BlockRenderers` (`lib/samagotchi/block_renderers.rb`) | a manifest's `block_renderers:`: `.parse_list`, and what chi web asks without loading plugins: `.for_installed` (first bundle by name wins a language; a record with an error, an unmet `requires_chi` or a list that doesn't parse is skipped) and `.script_path` (`load` membership first, then `Provenance.sha_matches?`). `Manifest` checks each `load` is a `scripts:` key; the files are ordinary scripts everywhere else ([block-renderers.md](block-renderers.md)) |
|
|
51
52
|
| `Builder` | `chi bundle build`: memories (+ the installed bundle's hooks, rules, plugin, scripts) → zip/tar/dir |
|
|
52
53
|
| `Profile` | meta bundles (`includes:`): install the members, record which; `InstallResult#kept_own` names a new member's memory files the user already had, with other bytes (`kept_own_line`, printed by `chi bundle`, `chi update` and `chi bootstrap`); a dry run runs each member's Installer as a dry run too, so it names them as well |
|
|
53
54
|
| `SystemBundle`, `ShippedUpdate` | the built-in system bundle (`ensure!` at Engine start, `sync` in `chi update`) and `chi update`'s upgrade of shipped bundles; both drive the Installer with `upgrade: true` and `Remerge` at an equal version (`ShippedUpdate::Row`'s `:would_merge`, `:merged`; `SystemBundle::Result`'s `:merged`). `SystemBundle` also puts back a memory missing at an equal version, that file alone, with no Installer (`verify_files_present`, `Provenance#record_restored`). `ensure!` echoes a merge in one line, in the row's words. That reaches a terminal launch; a worker has only its log (`Log.configure(stderr: false)`), so every merge leaves a note on the file's record (`Installer#note_merges`, a failed run's too, and `Remerge#note_merges`; `Provenance#note_merged`: version, time, trash dir). `chi bundle status` shows it, and `chi update` reads the notes before the run (`SystemBundle.merge_notes`, `ShippedUpdate::Row#earlier`), names them in the row and clears after it those exact ones and the notes of the files that run merged (a worker's meanwhile, on another file, stays), unless it was a dry run, the row failed or has no place for them (a skipped system row), or the run is unattended (`ChildEnv::UNATTENDED_UPDATE`, which `Web::UpdateOps` sets for `update.auto`, an env marker so a hand-over's exec keeps it and an older chi ignores it; `--json` alone says nothing, as a person or script may use it). `chi bundle upgrade` clears the notes of what it printed, and `--force` those of the files it replaced. `--json`'s `merged` is this run's, `earlier` the notes'. `ensure!` says what an upgrade left behind and nothing of a file already behind, which `Result#behind` still has for the row. A plan or sync that can't read a file is that bundle's failed row, in `Trash.failure_message`'s words (`ShippedUpdate.plan_row`, `SystemBundle.sync`). `ShippedUpdate.external` is the rest: what `chi update` only checks |
|
|
@@ -23,7 +23,7 @@ page. A factory dep a new version stops passing is the same hazard the other way
|
|
|
23
23
|
out of `send_turn.js`, `session_actions.js` and `lists_view.js`); a page loaded before keeps all its old modules
|
|
24
24
|
until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`, `details_motion.js`,
|
|
25
25
|
`turn_range.js`, `history_reveal.js`, `latest.js`, `session_payload.js`, `method_api.js`, `session_store.js`,
|
|
26
|
-
`session_fields.js`, `feature_gates.js`, `revise_stack.js`, `revise_switch.js`, `shell_command.js`, `stale_params.js`, `popover_modal.js`, `more_sheet.js`, `hash_intent.js` (what the hash asks for: session, all, start or keep), `worker_features.js`, `block_open.js` and the `objectives_*.js` modules are new modules for that reason.
|
|
26
|
+
`session_fields.js`, `feature_gates.js`, `revise_stack.js`, `revise_switch.js`, `shell_command.js`, `stale_params.js`, `popover_modal.js`, `more_sheet.js`, `hash_intent.js` (what the hash asks for: session, all, start or keep), `worker_features.js`, `block_open.js`, `text_roots.js`, `block_source.js`, `block_frame.js`, `block_host.js` and the `objectives_*.js` modules are new modules for that reason. The last three `block_*` draw a bundle's fenced blocks: [block-renderers.md](block-renderers.md).
|
|
27
27
|
|
|
28
28
|
## Two pages, two models
|
|
29
29
|
|
|
@@ -50,7 +50,7 @@ change.
|
|
|
50
50
|
| **`store.current`** (`session_state.js`: `emptySessionState` / `sessionStateFrom`; per field: `session_fields.js FIELDS`) | the open session's fields, about 40 (info bar, chips, verdicts, timing, worker, stream cursor) | `store.update` / `setStatus` from app.js, `stream_handlers.js`, `send_turn.js` (`firstPreview`, `owner`), `session_actions.js` (`status`); `forgetSession`'s `replaceCurrent`. Two writers in place, on purpose (D8 in `FIELDS`): live_timing.js's `activeTurn`, verdict_fresh.js's running list | the store runs the field's redraws (`FIELDS[key].redraws`) |
|
|
51
51
|
| **`store.sessions`** (the hub's projection) | every card | `store.update({sessions})` from app.js: `openSessionEvents` (`applySessionEvent`), `refresh` (the list fallback), `addCreatedSession`, `applyView` (re-sorted), `replaceSelectedCard` (`setLiveStatus` through `setStatus`, `readTurnEnd`, `setUsedMemories`), the stream's close (`withoutWorker`); `session_actions.js` (`setSessions`) | the store: `lists` and `infoBar`. **Always a new array**; an entry is replaced, never changed in place. `followSelectedSession` copies the open one's owner and worker (`fieldsFromCard`) and, when not streaming, its status (`setStatus(…, "hub")`) into `store.current` |
|
|
52
52
|
| **Factory closures**: the `create*` factories (popovers, panels, views) | each component's own state: open-for, busy, error, its own fetched list | the factory | an `onChange` hook back into app.js, or a getter the factory reads at draw time |
|
|
53
|
-
| **The history DOM** | the conversation as drawn: which turn a bubble belongs to (`data-turn-id`, `data-answer-turn`), kept command bubbles, which texts were shown, unnamed badges, folded answers and long prompts (`.folded`), Revise stamps (`data-revises` on a prompt; a Verify turn's `data-verify`) and the stacks' folded versions (`.stack-hidden`, `.stack-stub.open`); the switcher's version on view is `revise_stack.js`'s own (a copy `.stack-view` before the top answer, the top `.stack-swapped`; never stored) | `bubbles.js`, `cards_place.js`, `question_cards.js`, `turn_block.js`/`stage_view.js`, `answer_fold.js`, `revise_stack.js`, `stream_handlers.js` (a live `turn_completed`'s `revises` and `verify` onto its prompt) | read back as state: `turn_range.js turnElements` (one walker for a turn's elements), `keptCommandBubbles`, `seedSeenFromDom`, `nameUnnamedBadges`/`nameVerdictCards`, `answer_fold.js` and `revise_stack.js` (their observers decorate every path that fills `#history`; the stacks are `stacks()` of the stamps, read again on each change, never a store field), `history_reveal.js revealInHistory` (the one way a jump unfolds what hides its target) |
|
|
53
|
+
| **The history DOM** | the conversation as drawn: which turn a bubble belongs to (`data-turn-id`, `data-answer-turn`), what opens a turn (`TURN_PROMPT`, `bubbles.js`: a prompt or a wake's note, the same turns `Steer.turn_opening?` counts for a card's or the recap's `turns_since`), kept command bubbles, which texts were shown, unnamed badges, folded answers and long prompts (`.folded`), Revise stamps (`data-revises` on a prompt; a Verify turn's `data-verify`) and the stacks' folded versions (`.stack-hidden`, `.stack-stub.open`); the switcher's version on view is `revise_stack.js`'s own (a copy `.stack-view` before the top answer, the top `.stack-swapped`; never stored) | `bubbles.js`, `cards_place.js`, `question_cards.js`, `turn_block.js`/`stage_view.js`, `answer_fold.js`, `revise_stack.js`, `stream_handlers.js` (a live `turn_completed`'s `revises` and `verify` onto its prompt) | read back as state: `turn_range.js turnElements` (one walker for a turn's elements), `keptCommandBubbles`, `seedSeenFromDom`, `nameUnnamedBadges`/`nameVerdictCards`, `answer_fold.js` and `revise_stack.js` (their observers decorate every path that fills `#history`; the stacks are `stacks()` of the stamps, read again on each change, never a store field), `history_reveal.js revealInHistory` (the one way a jump unfolds what hides its target) |
|
|
54
54
|
| **localStorage** (`storage.js` `readStored`/`writeStored`, guarded) | per-browser choices only | the module that owns the choice | read at start (see Storage keys) |
|
|
55
55
|
| **SSE** | see Streams | the server | owner page: events go into the DOM directly; participant page: an event marks the data stale |
|
|
56
56
|
|
data/docs/memory.md
CHANGED
|
@@ -557,7 +557,7 @@ for a file another bundle installed.
|
|
|
557
557
|
plugin and scripts to `PATH` but still copies the memories at install, so
|
|
558
558
|
an edit to one of them in `PATH` isn't seen until the next install.
|
|
559
559
|
- **The rest of the manifest** (`plugin:`, `requires_chi`, `session_settings:`,
|
|
560
|
-
`context_providers:`, `requires_chi_next`) is in
|
|
560
|
+
`context_providers:`, `block_renderers:`, `requires_chi_next`) is in
|
|
561
561
|
[Plugins](plugins.md) and [internals/bundles.md](internals/bundles.md).
|
|
562
562
|
|
|
563
563
|
## Bundle profiles: core and dev
|
data/docs/plugins.md
CHANGED
|
@@ -944,7 +944,9 @@ completed turn stays in its step.
|
|
|
944
944
|
|
|
945
945
|
Between turns, a card goes above the first turn prompt after it, live and
|
|
946
946
|
after a reload. A failed turn whose prompt went back to its sender isn't a
|
|
947
|
-
turn: its prompt line comes after the cards
|
|
947
|
+
turn: its prompt line comes after the cards shown before it, and before those
|
|
948
|
+
shown after. After a reload a card a command showed names it (`from /short`),
|
|
949
|
+
its line being gone.
|
|
948
950
|
|
|
949
951
|
An [anytime command](#anytime-true)'s cards show as it shows them, after its
|
|
950
952
|
line, in every UI, whether a turn runs or not.
|
|
@@ -961,8 +963,8 @@ then a new turn) starts from them. Load warnings and a question still
|
|
|
961
963
|
waiting when the worker went are not saved.
|
|
962
964
|
|
|
963
965
|
The event is `{type: :card, id:, source:, title:, body:, level:, actions:,
|
|
964
|
-
in_turn:}` (`source` is the bundle),
|
|
965
|
-
title.
|
|
966
|
+
in_turn:}` (`source` is the bundle), plus `command:` when a session command
|
|
967
|
+
showed it (`"/short"`), logged as `card` with its source, id and title.
|
|
966
968
|
|
|
967
969
|
## Side answers: `ctx.ask_model`
|
|
968
970
|
|
|
@@ -1351,6 +1353,41 @@ context_providers:
|
|
|
1351
1353
|
the PATH may be another one, such as macOS's 2.6).
|
|
1352
1354
|
- The command follows [the contract](context.md#the-command-contract).
|
|
1353
1355
|
|
|
1356
|
+
## Block renderers
|
|
1357
|
+
|
|
1358
|
+
A bundle can draw a fenced code block as a picture in chi web's answers: the model writes ```` ```mermaid ````, the
|
|
1359
|
+
page shows the drawing. No tool, no plugin code in the web server: chi web runs no plugins, and a reloaded page must
|
|
1360
|
+
draw after the worker has gone, so JS draws and Ruby only routes and serves.
|
|
1361
|
+
|
|
1362
|
+
```yaml
|
|
1363
|
+
# manifest.yml
|
|
1364
|
+
scripts:
|
|
1365
|
+
draw.js: sha256:… # scripts/draw.js in the bundle
|
|
1366
|
+
block_renderers:
|
|
1367
|
+
- lang: mermaid # a fence's first word, downcased: [a-z0-9_+-], at most 32
|
|
1368
|
+
load: [draw.js] # scripts: keys, loaded into the frame in this order
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
- `load:` names files under `scripts:`, so they install and check as any script does (`chi bundle check`,
|
|
1372
|
+
`rake bundles:sha`), and one changed after the install isn't served until the bundle is reinstalled; a `--dev`
|
|
1373
|
+
install's links aren't checked. A bundle with no memories installs fine, but `chi bundle build` still needs one:
|
|
1374
|
+
it refuses with "No memories to build".
|
|
1375
|
+
- The first installed bundle (by name) with a language draws it. A bundle whose `requires_chi` this chi doesn't meet
|
|
1376
|
+
draws nothing.
|
|
1377
|
+
- **What it needs of the page:** `web.markdown` on (a default-off setting, and the commonmarker gem). With it off a
|
|
1378
|
+
fence has no language in the page and nothing draws, though a hint in the bundle's prompt section still reaches
|
|
1379
|
+
the model: say so in the bundle's README.
|
|
1380
|
+
- **Where it draws:** the owner's page only, in answers and closed steps. Participants' pages, cards, question
|
|
1381
|
+
replies, the asking slot, delegate reports and the doc viewer show the source, and so does the terminal.
|
|
1382
|
+
- **The script's side:** the last file calls `chi.defineRenderer({ init, render })`. `init()` (optional, may be async)
|
|
1383
|
+
runs once; `render(source, theme)` returns the SVG as a string, or a promise of one. Throw to keep the block as
|
|
1384
|
+
source. `theme` is `{name, bg, panel, text, muted, border, accent, font}`, from the page's tokens. The script runs in
|
|
1385
|
+
a sandboxed frame with no cookie and no access to the page, and the CSP closes its network, but for WebRTC (what the
|
|
1386
|
+
frame holds, the diagrams it rendered, could leave that way). The SVG is shown as an `<img>`, so its scripts, links
|
|
1387
|
+
and remote images don't run: it has to stand alone (no `foreignObject`, every namespace prefix bound) and stay under 50,000 px a
|
|
1388
|
+
side and 100M px. Over that, or if the image doesn't decode, the block stays source. The
|
|
1389
|
+
layers and the residual risk are in [internals/block-renderers.md](internals/block-renderers.md).
|
|
1390
|
+
|
|
1354
1391
|
## Stable for bundles
|
|
1355
1392
|
|
|
1356
1393
|
Bundles normally use only `chi.*` and `ctx`; the constants and helpers below
|
|
@@ -1396,6 +1433,9 @@ fails on a reference in neither (docs/testing.md). In `chi update`'s re-check of
|
|
|
1396
1433
|
an installed bundle the same scan warns only for a name this chi no longer has
|
|
1397
1434
|
(`Boundary.gone?`): a reference that still resolves gets no row.
|
|
1398
1435
|
|
|
1436
|
+
A bundle's `block_renderers:` and the frame's `chi.defineRenderer` ([Block renderers](#block-renderers)) are stable
|
|
1437
|
+
too. They aren't Ruby, so the scan above doesn't see them.
|
|
1438
|
+
|
|
1399
1439
|
**Used by shipped bundles today, but internal** (may change without notice; a
|
|
1400
1440
|
bundle that needs one should ask for an API instead):
|
|
1401
1441
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
autoload :Log, File.expand_path("log", __dir__)
|
|
5
|
+
autoload :VERSION, File.expand_path("version", __dir__)
|
|
6
|
+
module MemoryBundle
|
|
7
|
+
autoload :Manifest, File.expand_path("memory_bundle/manifest", __dir__)
|
|
8
|
+
autoload :Provenance, File.expand_path("memory_bundle/provenance", __dir__)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Declarative block renderers (docs/plugins.md, docs/internals/block-renderers.md):
|
|
12
|
+
# a bundle's manifest maps a fenced block's language to the scripts/ files the
|
|
13
|
+
# owner's page loads to draw it:
|
|
14
|
+
#
|
|
15
|
+
# block_renderers:
|
|
16
|
+
# - lang: mermaid
|
|
17
|
+
# load: [mermaid.tiny.js, mermaid_renderer.js]
|
|
18
|
+
#
|
|
19
|
+
# JS draws, in a sandboxed frame; Ruby only routes and serves, so chi web
|
|
20
|
+
# (which runs no plugins) can answer, and a reloaded page draws after the
|
|
21
|
+
# worker has gone. The installer records them in the bundle's provenance;
|
|
22
|
+
# .for_installed reads them from there.
|
|
23
|
+
module BlockRenderers
|
|
24
|
+
# A renderer as the manifest declares it.
|
|
25
|
+
Renderer = Data.define(:lang, :load) do
|
|
26
|
+
def to_h = { "lang" => lang, "load" => load }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# One the page may use: the bundle that won the language, and its files.
|
|
30
|
+
Installed = Data.define(:lang, :bundle, :files)
|
|
31
|
+
|
|
32
|
+
class Invalid < ArgumentError; end
|
|
33
|
+
|
|
34
|
+
# A language a block can carry: a fence's first word, downcased
|
|
35
|
+
# (MarkdownRenderer sets it as data-lang under this same pattern).
|
|
36
|
+
LANG = /\A[a-z0-9_+-]{1,32}\z/
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# The manifest's block_renderers: list.
|
|
41
|
+
# @return [Array<Renderer>] [] when absent
|
|
42
|
+
# @raise [Invalid]
|
|
43
|
+
def parse_list(raw)
|
|
44
|
+
return [] if raw.nil?
|
|
45
|
+
raise Invalid, "block_renderers: must be a list of mappings" unless raw.is_a?(Array)
|
|
46
|
+
|
|
47
|
+
renderers = raw.map { |item| parse(item) }
|
|
48
|
+
twice = renderers.map(&:lang).tally.select { |_lang, count| count > 1 }.keys
|
|
49
|
+
raise Invalid, "block_renderers: #{twice.join(", ")} listed more than once" unless twice.empty?
|
|
50
|
+
|
|
51
|
+
renderers
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @raise [Invalid]
|
|
55
|
+
def parse(item)
|
|
56
|
+
raise Invalid, "block_renderers: each item must be a mapping, not #{item.inspect}" unless item.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
data = item.transform_keys(&:to_s)
|
|
59
|
+
lang = data["lang"]
|
|
60
|
+
unless lang.is_a?(String) && lang.match?(LANG)
|
|
61
|
+
raise Invalid, "block_renderers: lang #{lang.inspect} isn't a fence's language (lowercase letters, digits, _ + -; at most 32)"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
load = data["load"]
|
|
65
|
+
unless load.is_a?(Array) && !load.empty? && load.all? { |file| file.is_a?(String) && file.match?(MemoryBundle::Manifest::SCRIPT_FILE) }
|
|
66
|
+
raise Invalid, "block_renderers: #{lang}'s load must be a non-empty list of file names in scripts/, not #{load.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Renderer.new(lang: lang, load: load)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# What the installed bundles draw: the first bundle by name wins a
|
|
73
|
+
# language. A bundle whose requires_chi this chi doesn't meet, or whose list
|
|
74
|
+
# doesn't parse (logged), is skipped; a record that doesn't read lists none.
|
|
75
|
+
# @return [Array<Installed>]
|
|
76
|
+
def for_installed
|
|
77
|
+
taken = {}
|
|
78
|
+
MemoryBundle::Provenance.each_installed do |bundle, record|
|
|
79
|
+
next unless record.block_renderers?
|
|
80
|
+
next if MemoryBundle::Manifest.requires_chi_failure(record.requires_chi, VERSION)
|
|
81
|
+
|
|
82
|
+
renderers(bundle, record).each do |renderer|
|
|
83
|
+
taken[renderer.lang] ||= Installed.new(lang: renderer.lang, bundle: bundle, files: renderer.load)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
taken.values
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The installed path of +file+, once a renderer .for_installed lists loads
|
|
90
|
+
# it from +bundle+ (neither name is ever joined into a path before that:
|
|
91
|
+
# they come from the URL) and it is as installed. This code runs in the
|
|
92
|
+
# owner's browser, so it is checked as a plugin's load and a provider's
|
|
93
|
+
# script are; a --dev install's links are not.
|
|
94
|
+
# @return [String, nil] nil when no such renderer loads such a file
|
|
95
|
+
# @raise [Invalid] the file is gone or changed
|
|
96
|
+
def script_path(bundle, file)
|
|
97
|
+
return nil unless for_installed.any? { |renderer| renderer.bundle == bundle && renderer.files.include?(file) }
|
|
98
|
+
|
|
99
|
+
provenance = MemoryBundle::Provenance.new(name: bundle)
|
|
100
|
+
path = File.join(provenance.scripts_dir, file)
|
|
101
|
+
recorded = provenance.record&.scripts&.fetch(file, nil)
|
|
102
|
+
return path if File.file?(path) && MemoryBundle::Provenance.sha_matches?(path, recorded)
|
|
103
|
+
|
|
104
|
+
raise Invalid, "bundle #{bundle}'s scripts/#{file} differs from the installed one (reinstall the bundle)"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def renderers(bundle, record)
|
|
108
|
+
parse_list(record.block_renderers)
|
|
109
|
+
rescue ArgumentError => e # Invalid is one; so are a record's bytes that aren't UTF-8
|
|
110
|
+
Log.warn(:web, "block_renderers_unreadable", bundle: bundle, msg: e.message)
|
|
111
|
+
[]
|
|
112
|
+
end
|
|
113
|
+
private_class_method :renderers
|
|
114
|
+
end
|
|
115
|
+
end
|