Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/tools.js

22.8 KiB, 5 runs

created by r2519314175:1451, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* tools.js — what Daimond can do for you, and what more it could.
2 *
3 * THE UNIT ON THIS PANEL IS A CAPABILITY, NOT A FUNCTION THE MODEL CALLS.
4 *
5 * It used to be the other way round: twenty-two rows, each one the wire name of a tool
6 * in the registry, each with a sentence under it. That is a manifest, and a manifest
7 * puts the work of assembling "Daimond can use a website" out of `web_open`,
8 * `web_snapshot`, `web_click`, `web_type`, `web_scroll`, `web_read` and `web_close` onto
9 * the reader. Nobody does that work, so nobody knows what they have. The word "function"
10 * does not appear on this surface at all.
11 *
12 * So a row here is one thing Daimond does, said in those terms, and it OPENS to the
13 * functions that make it possible — which is where `web_click` belongs: available to
14 * anyone who wants it, in front of nobody who does not.
15 *
16 * WHERE THE ROWS COME FROM, and neither source is copied:
17 *
18 * the functions `builtin_tools()` on the wasm module — the registry the agent is
19 * actually handed, so the panel cannot promise something that is not
20 * there, nor hide something that is. Each entry carries the `pack` key
21 * it is sold under, empty for one Daimond ships.
22 * the shelf `GET /api/tools` — the gateway states which packs are on sale, what
23 * they cost, and whether this account holds them, because it is the
24 * gateway that charges.
25 *
26 * The only thing written down here is WHICH CAPABILITY A FUNCTION BELONGS TO. That map
27 * cannot invent a function: a name in it that the registry does not report simply has no
28 * members, and a function the registry reports that the map does not place falls into
29 * `other`, which is visible and which `dev/verify_toolspanel.mjs` requires to be empty.
30 * A tool added to the Rust registry therefore shows up on this panel either in its
31 * capability or as a loud failure in the gate — never silently missing.
32 *
33 * INCLUDED VERSUS SOLD is the gateway's answer and never this file's guess. A capability
34 * is on the shelf when every function in it belongs to one pack AND THE GATEWAY IS
35 * SELLING THAT PACK. A pack the catalogue has stopped listing is not sold, so the
36 * capability sits under Included and everyone keeps it — which is exactly what the
37 * gateway does with the entitlement, and the two must not disagree. Nothing that is
38 * free today can be drawn as buyable, because nothing here decides that.
39 *
40 * A pack the catalogue sells that names no function in the registry — the shape the
41 * first Research pack will arrive in — is a shelf row of its own, carrying the
42 * catalogue's name, blurb and price. It needs no code here to appear.
43 */
44(function () {
45 'use strict';
46
47 // The wasm module, resolved against THIS script rather than the document, so the app
48 // still finds it when served from a sub-path. Same reasoning as graph.js.
49 var SELF = (document.currentScript && document.currentScript.src) || '';
50 var PKG = SELF ? new URL('../pkg/oxedyne_daimond.js', SELF).href
51 : '../pkg/oxedyne_daimond.js';
52
53 var deps = null; // { builtins, onCount }
54 var els = {};
55 var state = {
56 builtin: [], // [{tool, blurb, pack}] — the registry, verbatim
57 packs: [], // [{tool, name, blurb, price_minor, unlocked, currency}]
58 err: '',
59 busy: false,
60 loaded: false,
61 open: {}, // capability id -> disclosed, kept across redraws
62 };
63
64 // ── The gateway, and a session that has gone ───────────────
65 //
66 // Both calls below go through `DaimondGateway.gwFetch`, which meets a 401 by
67 // renewing the session once and asking once more. The gateway's session lives
68 // an hour and only an unlock ever minted one, so an hour into a sitting
69 // `GET /api/tools` came back 401 and this panel said the account service
70 // could not be reached -- which was untrue, and which no amount of reopening
71 // the panel would clear.
72 //
73 // Safe to repeat here, and this is why: `common::authed_account` is the first
74 // statement of both `tools_impl` and `pack_impl` in the gateway, so a 401 is
75 // proof that nothing happened -- no body parsed, no checkout session made.
76
77 function esc(s) {
78 return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
79 return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
80 });
81 }
82
83 /// A string, from the catalogue.
84 ///
85 /// This panel's strings used to be held in a local English table beside the
86 /// catalogue's, because the keys were new. They are in `www/i18n/en.js` now
87 /// and every locale carries them, so the copy is gone: two tables of the same
88 /// sentences are two tables that will eventually disagree, and the one an
89 /// editor never opens is the one that goes stale.
90 function t(k, v) {
91 return window.DaimondI18n ? DaimondI18n.t(k, v) : k;
92 }
93
94 /// An unlock is a charge, so its price is quoted the way it will be taken:
95 /// in US dollars, with the converted figure beside it when the user is
96 /// reading in something else.
97 function fmtPrice(n, cur) {
98 return window.DaimondGateway
99 ? DaimondGateway.fmtBilled(n, cur || 'usd')
100 : ('$' + (n / 100).toFixed(2));
101 }
102
103 // ── The capabilities ────────────────────────────────────────
104 //
105 // One entry per thing Daimond does, in the order a reader meets them: their own files
106 // first, the world outside last. `fns` names functions in the Rust registry; a name
107 // the registry does not report contributes nothing, which is how `dispatch` and
108 // `graph` sit here ready for the day `builtin_tools()` reports them (today it reads
109 // `Tool::browser()`, which holds neither) without this file claiming they are present.
110 //
111 // THE OFFICE TRIO IS IN `files` AND NOT IN A ROW OF ITS OWN. `sheet_read`, `doc_edit`
112 // and `sheet_write` are the file tools said in the only vocabulary those formats leave:
113 // a .docx is a compressed archive, so `file_edit` has no bytes to match a phrase
114 // against and `doc_edit` is what does, and a workbook's useful unit is a rectangle of
115 // cells rather than the file. The REACH is identical — the same workspace, the same
116 // paths, and `write_targets` in src/tools.rs puts them through `file_write`'s own door,
117 // so the bounds, a skill's allow-list and the absolute-path refusal all apply unchanged.
118 // A row of their own would offer the reader a thing to grant or withhold separately
119 // when there is none: whoever has `files` can already overwrite that same .docx whole
120 // and delete it. Every other row here differs from `files` in what it REACHES — your
121 // cloud, your screen, your machine, the web, a worker, the graph — and none of them
122 // splits on a file's format, which is the only thing these three do.
123
124 var CAPS = [
125 { id: 'files', fns: ['file_read', 'file_write', 'file_edit', 'file_list',
126 'file_search', 'file_glob', 'file_delete', 'file_move',
127 'dir_create',
128 'sheet_read', 'doc_edit', 'sheet_write',
129 // The text of a file whose text is a PICTURE: the OCR
130 // counterpart to file_read, reaching the same workspace path.
131 'ocr'] },
132 { id: 'cloud', fns: ['file_fetch'] },
133 { id: 'work', fns: ['artefact_add'] },
134 // `say` sat here beside `file_show` until folding stopped being a tool: an answer
135 // is written at two depths in the model's own prose now, so there is no function
136 // to list and nothing for a person to grant or withhold.
137 { id: 'show', fns: ['file_show'] },
138 // A CAPABILITY OF ITS OWN, and not a member of `show`. Both reach the person
139 // rather than the workspace, and there the likeness stops: `file_show` hands
140 // something over and this asks for something back. It is also the one row on
141 // this panel a reader might want to withhold for a reason nothing else here
142 // covers -- being interrupted -- so folding it into `show` would offer them a
143 // grant that does not match the thing they are granting.
144 { id: 'asking', fns: ['ask'] },
145 // `runs` is here and not in a capability of its own: it says what the machine hand
146 // is still running and stops one of them, and a person who granted the starting of
147 // commands has already granted this. Withholding it would not stop a command being
148 // left behind -- it would only stop Daimond being able to clear it up.
149 { id: 'machine', fns: ['run', 'runs', 'shell'] },
150 // ITS OWN CAPABILITY, not a member of `machine`, because it is the one tool that
151 // runs a process OUTSIDE the fence -- a named verifier from the tracked tree, never
152 // a command the model wrote. That is a different thing to grant from `run`, so it is
153 // a different thing to withhold.
154 { id: 'checking', fns: ['verify'] },
155 // ONE ROW FOR BOTH, and the reason is that they reach the same place: the Social
156 // panel and the forge behind it. Reading it and publishing on it are not the same
157 // ACT -- one asks nobody anything and the other is put to the user every time --
158 // but this panel's rows are about what Daimond can REACH, and a person granting or
159 // withholding "the Social panel" is deciding about one surface. The publishing
160 // half's own gate is not a setting and is not here: it cannot be turned off, so
161 // there is nothing for a reader to be offered.
162 { id: 'social', fns: ['social_read', 'social_send'] },
163 { id: 'reading', fns: ['web_fetch', 'web_search'] },
164 { id: 'browsing', fns: ['web_open', 'web_snapshot', 'web_read', 'web_click',
165 'web_type', 'web_scroll', 'web_close'] },
166 { id: 'typeset', fns: ['typst_compile'] },
167 { id: 'dispatch', fns: ['spawn_agent'] },
168 { id: 'graph', fns: ['link_list', 'link_add', 'link_remove'] },
169 // Mail. All four are sold in the Email pack, so this becomes a shelf row with
170 // its functions disclosed under it -- and `mail_draft` writes a draft for the
171 // user rather than sending, which is the whole of a model's reach to the wire.
172 { id: 'email', fns: ['mail_list', 'mail_search', 'mail_read', 'mail_draft'] },
173 ];
174
175 /// The capability a function belongs to, or `other` for one nobody has placed.
176 function capOf(name) {
177 for (var i = 0; i < CAPS.length; i++) {
178 if (CAPS[i].fns.indexOf(name) >= 0) return CAPS[i].id;
179 }
180 return 'other';
181 }
182
183 /// The pack listing for a key, or null when the catalogue is not selling it.
184 ///
185 /// This is the whole of the included-versus-sold rule. A function carries the pack key
186 /// it *would* be sold under; whether it IS sold is this lookup, and the gateway is the
187 /// only thing that answers it.
188 function saleOf(pack) {
189 if (!pack) return null;
190 for (var i = 0; i < state.packs.length; i++) {
191 if (state.packs[i].tool === pack) return state.packs[i];
192 }
193 return null;
194 }
195
196 /// The panel's rows, assembled from the registry and the catalogue.
197 ///
198 /// Returns `{ included: [row], shelf: [row] }` where a row is
199 /// `{ id, name, blurb, fns:[{name, blurb, pack}], sale, owned }`. `sale` is the
200 /// catalogue entry when this row is a pack and null when it is included.
201 function rows() {
202 var byCap = {};
203 var order = [];
204 state.builtin.forEach(function (fn) {
205 var id = capOf(fn.tool);
206 if (!byCap[id]) { byCap[id] = []; order.push(id); }
207 byCap[id].push(fn);
208 });
209
210 var included = [], shelf = [], claimed = {};
211
212 // Capabilities in the panel's own order, then anything unplaced, so a function
213 // the map has not caught up with is at the bottom rather than absent.
214 var ids = CAPS.map(function (c) { return c.id; });
215 order.forEach(function (id) { if (ids.indexOf(id) < 0) ids.push(id); });
216
217 ids.forEach(function (id) {
218 var fns = byCap[id];
219 if (!fns || !fns.length) return;
220 var row = {
221 id: id,
222 name: t('tools.cap.' + id + '.name'),
223 blurb: t('tools.cap.' + id + '.blurb'),
224 fns: fns.map(function (f) {
225 return { name: f.tool, blurb: f.blurb, sale: saleOf(f.pack) };
226 }),
227 sale: null,
228 owned: true,
229 };
230 // A capability is SOLD only when every one of its functions is sold under
231 // one and the same pack the catalogue is currently listing. A mixed
232 // capability stays included and the sold function carries the note itself,
233 // because "half of this is for sale" is not a thing a row can honestly say.
234 var sales = row.fns.map(function (f) { return f.sale; });
235 var first = sales[0];
236 if (first && sales.every(function (s) { return s === first; })) {
237 row.sale = first;
238 row.owned = !!first.unlocked;
239 claimed[first.tool] = 1;
240 shelf.push(row);
241 } else {
242 included.push(row);
243 }
244 });
245
246 // Packs the catalogue sells that name no function this build reports — Email
247 // today, Research next. The catalogue owns their words, so they are shown in
248 // them, and they disclose nothing because there is nothing to disclose.
249 state.packs.forEach(function (p) {
250 if (claimed[p.tool]) return;
251 shelf.push({
252 id: 'pack:' + p.tool,
253 name: p.name || p.tool,
254 blurb: p.blurb || '',
255 fns: [],
256 sale: p,
257 owned: !!p.unlocked,
258 });
259 });
260
261 // Owned before for-sale: what you have is not a shop.
262 shelf.sort(function (a, b) { return (b.owned ? 1 : 0) - (a.owned ? 1 : 0); });
263 return { included: included, shelf: shelf };
264 }
265
266 /// How much of Daimond this account can reach, out of how much there is. The number
267 /// the rail row shows, and the reason a user opens the panel at all.
268 ///
269 /// Counted in CAPABILITIES, which is the unit this panel deals in. A rail that
270 /// counted functions and a panel that listed capabilities would be two answers to one
271 /// question.
272 function counts() {
273 var r = rows();
274 var have = r.included.length + r.shelf.filter(function (x) { return x.owned; }).length;
275 return { have: have, all: r.included.length + r.shelf.length };
276 }
277
278 // ── Telling the build what was not bought ───────────────────
279 //
280 // `/api/tools` is the only thing that knows which packs this account holds, and the
281 // wasm is the only thing that can refuse a tool. This is the wire between them, and
282 // until now there was none: `set_locked_packs` existed, `Tool::guard` read what it
283 // set, `www/js/typst.js` asked `tool_locked` before building the compiler -- and
284 // NOTHING IN THE PAGE EVER CALLED THE SETTER. The gate was complete and unreached,
285 // so a pack the gateway was selling ran free on every device.
286 //
287 // Only a read that SUCCEEDED pushes. A gateway that could not be reached leaves the
288 // last good answer standing rather than replacing it with "nothing is locked": a
289 // device that has never reached the gateway locks nothing, which is the honest
290 // default, but a network blink is not a reason to hand over a pack.
291
292 /// Hand the wasm the packs this account has not bought.
293 async function pushLocks() {
294 var locked = state.packs
295 .filter(function (p) { return !p.unlocked; })
296 .map(function (p) { return p.tool; })
297 .join(',');
298 try {
299 var mod = await import(PKG);
300 mod.set_locked_packs(locked);
301 } catch (e) {
302 // No module yet: `reload()` runs again once it is up, from daimond.js.
303 }
304 }
305
306 /// Ask the gateway what this account may do. A gateway that cannot be reached is not the
307 /// same as an account that owns nothing, so the built-ins still render and the shelf says
308 /// plainly that it could not ask.
309 async function load() {
310 try {
311 state.builtin = deps.builtins();
312 } catch (e) {
313 state.builtin = [];
314 }
315 try {
316 if (window.DaimondGateway && !DaimondGateway.state().authed) {
317 await DaimondGateway.bootstrap();
318 }
319 var r = await DaimondGateway.gwFetch('/api/tools', { credentials: 'same-origin' });
320 var j = await r.json();
321 if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
322 state.packs = j.tools || [];
323 state.err = '';
324 await pushLocks();
325 } catch (e) {
326 // The listing goes, the engine's locks stay. The two then disagree for as
327 // long as the gateway is down -- the panel showing a pack as included that
328 // the engine will refuse -- and that is the right way round: the banner says
329 // in words that what is unlocked here is unknown, whereas keeping the last
330 // listing would make a positive claim of OWNERSHIP out of stale data, and
331 // pushing an empty lock list would hand a pack to somebody who has not
332 // bought one. A wrong chip is cheaper than either.
333 state.packs = [];
334 state.err = t('tools.unreachable');
335 }
336 state.loaded = true;
337 render();
338 if (deps.onCount) deps.onCount(counts());
339 }
340
341 /// Buy a pack. The gateway makes the session; the price on the button came from the
342 /// same table the till reads, so what is quoted is what is charged.
343 async function unlock(pack) {
344 if (state.busy) return;
345 state.busy = true;
346 render();
347 try {
348 if (!window.DaimondGateway) throw new Error(t('tools.no_service'));
349 var r = await DaimondGateway.gwFetch('/api/checkout/pack', {
350 method: 'POST',
351 headers: { 'content-type': 'application/json' },
352 credentials: 'same-origin',
353 body: JSON.stringify({ pack: pack }),
354 });
355 // A 401 that survived the renewal is a session this device cannot get
356 // back, not a fault in the purchase. The gateway's own words for it
357 // ("No valid session.") say nothing a user can act on.
358 if (r.status === 401) throw new Error(t('tools.unreachable'));
359 var j = await r.json();
360 if (!r.ok || !j.ok || !j.url) throw new Error((j && j.error) || ('HTTP ' + r.status));
361 window.location = j.url;
362 } catch (e) {
363 state.err = (e && e.message) ? e.message : String(e);
364 state.busy = false;
365 render();
366 }
367 }
368
369 function html(s) {
370 var n = document.createElement('div');
371 n.innerHTML = s;
372 return n.firstElementChild || n;
373 }
374
375 function el(tag, cls, text) {
376 var n = document.createElement(tag);
377 if (cls) n.className = cls;
378 if (text != null) n.textContent = text;
379 return n;
380 }
381
382 /// One capability, closed, with everything it needs to be opened.
383 ///
384 /// The disclosure is a button and a `hidden` block rather than `<details>`: `hidden`
385 /// is genuinely not rendered, so "the function is not on screen until you open it" is
386 /// a fact a verifier can measure instead of a claim about a shadow root.
387 function capCard(row) {
388 var card = el('div', 'cap');
389 card.setAttribute('data-cap', row.id);
390 card.setAttribute('data-state',
391 !row.sale ? 'included' : (row.owned ? 'owned' : 'locked'));
392
393 var head = el('div', 'cap-head');
394 var txt = el('div', 'cap-text');
395 txt.appendChild(el('div', 'cap-name', row.name));
396 if (row.blurb) txt.appendChild(el('div', 'cap-blurb', row.blurb));
397 if (row.sale && !row.owned) {
398 txt.appendChild(el('div', 'cap-why',
399 t('tools.locked_why', { pack: row.sale.name || row.sale.tool })));
400 }
401 head.appendChild(txt);
402
403 var side = el('div', 'cap-side');
404 if (row.sale && !row.owned) {
405 var b = el('button', 'tools-buy');
406 b.disabled = state.busy;
407 b.textContent = t('tools.unlock_price',
408 { price: fmtPrice(row.sale.price_minor, row.sale.currency) });
409 b.title = t('billing.usd_note');
410 b.setAttribute('data-buy', row.sale.tool);
411 b.addEventListener('click', function () { unlock(row.sale.tool); });
412 side.appendChild(b);
413 } else {
414 var chip = el('span', 'cap-chip' + (row.sale ? ' on' : ''),
415 t(row.sale ? 'tools.status_owned' : 'tools.status_included'));
416 side.appendChild(chip);
417 }
418 head.appendChild(side);
419 card.appendChild(head);
420
421 if (!row.fns.length) return card;
422
423 var listId = 'cap-fns-' + row.id.replace(/[^a-z0-9]+/gi, '-');
424 var body = el('div', 'cap-fns');
425 body.id = listId;
426 row.fns.forEach(function (f) {
427 var line = el('div', 'cap-fn');
428 line.setAttribute('data-fn', f.name);
429 line.appendChild(el('code', 'cap-fn-name', f.name));
430 var w = el('span', 'cap-fn-blurb', f.blurb);
431 line.appendChild(w);
432 // A function sold under a pack this row did not become — a mixed
433 // capability — says so on its own line, because the row above did not.
434 if (f.sale && !f.sale.unlocked && !row.sale) {
435 line.appendChild(el('span', 'cap-fn-pack',
436 t('tools.fn_pack', { pack: f.sale.name || f.sale.tool })));
437 }
438 body.appendChild(line);
439 });
440
441 var more = el('button', 'cap-more');
442 more.setAttribute('aria-controls', listId);
443 var draw = function () {
444 var on = !!state.open[row.id];
445 more.setAttribute('aria-expanded', on ? 'true' : 'false');
446 more.textContent = on ? t('tools.collapse')
447 : t('tools.expand', { n: row.fns.length });
448 body.hidden = !on;
449 };
450 more.addEventListener('click', function () {
451 state.open[row.id] = !state.open[row.id];
452 draw();
453 });
454 draw();
455 card.appendChild(more);
456 card.appendChild(body);
457 return card;
458 }
459
460 function render() {
461 if (!els.body) return;
462 els.body.innerHTML = '';
463
464 var r = rows();
465 var c = counts();
466
467 els.body.appendChild(html('<div class="tools-intro">' + esc(t('tools.intro')) + '</div>'));
468 els.body.appendChild(html('<div class="tools-count">' + t('tools.count', c) + '</div>'));
469
470 if (state.err) els.body.appendChild(html('<div class="tools-err">' + esc(state.err) + '</div>'));
471
472 els.body.appendChild(html('<div class="tools-sec">' + esc(t('tools.sec_included')) + '</div>'));
473 r.included.forEach(function (row) { els.body.appendChild(capCard(row)); });
474
475 // The shelf is drawn whether or not anything is on it. It is the answer to "is
476 // there more?", and a section that vanishes when the answer is "not yet" makes
477 // the reader wonder whether they missed it — and leaves nowhere for the first
478 // pack to appear without a change here, which is how a shelf ends up unreachable.
479 els.body.appendChild(html('<div class="tools-sec">' + esc(t('tools.sec_packs')) + '</div>'));
480 if (r.shelf.length) {
481 r.shelf.forEach(function (row) { els.body.appendChild(capCard(row)); });
482 els.body.appendChild(html('<div class="tools-fine">' + esc(t('tools.packs_fine')) + '</div>'));
483 if (window.DaimondI18n && DaimondI18n.currency() !== 'USD') {
484 els.body.appendChild(html('<div class="tools-fine">' + esc(t('billing.usd_note')) + '</div>'));
485 }
486 } else {
487 els.body.appendChild(html('<div class="tools-none">' + esc(t('tools.packs_none')) + '</div>'));
488 }
489 }
490
491 /// Show the panel, on the stage: the dock holds a noun as a list, the stage holds the
492 /// noun under inspection, and a thing with a price on it is being inspected.
493 function show() {
494 DaimondPanels.show('tools');
495 DaimondPanels.reflow();
496 load();
497 }
498
499 function init(d) {
500 deps = d;
501 els.body = document.getElementById('tools-body');
502 if (!els.body) return;
503 // The count on the rail row is the reason anyone opens this, so it is fetched once at
504 // boot rather than waiting for the panel to be opened for the first time.
505 load();
506 }
507
508 window.DaimondTools = {
509 init: init,
510 show: show,
511 reload: load,
512 counts: counts,
513 // For the gate, which asks what this panel decided rather than what it drew.
514 rows: rows,
515 };
516
517 // The panel is drawn from state already in hand, so a language or currency
518 // change can simply draw it again.
519 if (window.DaimondI18n) DaimondI18n.onChange(function () { if (els.body) render(); });
520})();