@quo-systems/dock 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +6 -0
  3. package/README.md +27 -0
  4. package/beings/avatar.ts +84 -0
  5. package/beings/desk.ts +46 -0
  6. package/beings/index.ts +5 -0
  7. package/beings/quo-dock.md +698 -0
  8. package/beings/setup.ts +23 -0
  9. package/beings/side.ts +51 -0
  10. package/beings/user.ts +78 -0
  11. package/cli/client.ts +65 -0
  12. package/cli/daemon.ts +582 -0
  13. package/cli/quo.ts +126 -0
  14. package/dist/beings/avatar.d.ts +32 -0
  15. package/dist/beings/avatar.js +82 -0
  16. package/dist/beings/desk.d.ts +42 -0
  17. package/dist/beings/desk.js +43 -0
  18. package/dist/beings/index.d.ts +3 -0
  19. package/dist/beings/index.js +5 -0
  20. package/dist/beings/setup.d.ts +2 -0
  21. package/dist/beings/setup.js +16 -0
  22. package/dist/beings/side.d.ts +25 -0
  23. package/dist/beings/side.js +37 -0
  24. package/dist/beings/user.d.ts +108 -0
  25. package/dist/beings/user.js +74 -0
  26. package/dist/cli/client.d.ts +14 -0
  27. package/dist/cli/client.js +51 -0
  28. package/dist/cli/daemon.d.ts +57 -0
  29. package/dist/cli/daemon.js +581 -0
  30. package/dist/cli/quo.d.ts +2 -0
  31. package/dist/cli/quo.js +121 -0
  32. package/dist/harbor/browser.d.ts +13 -0
  33. package/dist/harbor/browser.js +50 -0
  34. package/dist/harbor/disk.d.ts +19 -0
  35. package/dist/harbor/disk.js +126 -0
  36. package/dist/harbor/edge/edge.d.ts +26 -0
  37. package/dist/harbor/edge/edge.js +123 -0
  38. package/dist/harbor/edge/exercise.d.ts +7 -0
  39. package/dist/harbor/edge/exercise.js +179 -0
  40. package/dist/harbor/edge/storage.d.ts +20 -0
  41. package/dist/harbor/edge/storage.js +81 -0
  42. package/dist/harbor/edge/worker.d.ts +14 -0
  43. package/dist/harbor/edge/worker.js +47 -0
  44. package/dist/harbor/files.d.ts +14 -0
  45. package/dist/harbor/files.js +83 -0
  46. package/dist/harbor/idb.d.ts +15 -0
  47. package/dist/harbor/idb.js +85 -0
  48. package/dist/human/dom.d.ts +2 -0
  49. package/dist/human/dom.js +15 -0
  50. package/dist/human/html.d.ts +32 -0
  51. package/dist/human/html.js +136 -0
  52. package/dist/human/screen.d.ts +11 -0
  53. package/dist/human/screen.js +66 -0
  54. package/dist/human/tab.d.ts +7 -0
  55. package/dist/human/tab.js +68 -0
  56. package/dist/mcp/agent.d.ts +10 -0
  57. package/dist/mcp/agent.js +78 -0
  58. package/dist/mcp/http.d.ts +27 -0
  59. package/dist/mcp/http.js +96 -0
  60. package/dist/mcp/oauth.d.ts +98 -0
  61. package/dist/mcp/oauth.js +241 -0
  62. package/dist/mcp/pilot.d.ts +6 -0
  63. package/dist/mcp/pilot.js +68 -0
  64. package/dist/mcp/runner.d.ts +45 -0
  65. package/dist/mcp/runner.js +94 -0
  66. package/dist/mcp/server.d.ts +9 -0
  67. package/dist/mcp/server.js +63 -0
  68. package/dist/mcp/web/exchange.d.ts +23 -0
  69. package/dist/mcp/web/exchange.js +136 -0
  70. package/harbor/browser.ts +63 -0
  71. package/harbor/disk.ts +120 -0
  72. package/harbor/edge/edge.ts +147 -0
  73. package/harbor/edge/exercise.ts +187 -0
  74. package/harbor/edge/platform.d.ts +28 -0
  75. package/harbor/edge/storage.ts +82 -0
  76. package/harbor/edge/worker.ts +47 -0
  77. package/harbor/edge/wrangler.toml +21 -0
  78. package/harbor/files.ts +89 -0
  79. package/harbor/idb.ts +90 -0
  80. package/harbor/quo-harbor.md +403 -0
  81. package/human/dom.ts +21 -0
  82. package/human/html.ts +164 -0
  83. package/human/quo-human.md +86 -0
  84. package/human/screen.ts +76 -0
  85. package/human/tab.ts +84 -0
  86. package/mcp/agent.ts +94 -0
  87. package/mcp/http.ts +96 -0
  88. package/mcp/oauth.ts +260 -0
  89. package/mcp/pilot.ts +73 -0
  90. package/mcp/quo-mcp.md +262 -0
  91. package/mcp/runner.ts +131 -0
  92. package/mcp/server.ts +67 -0
  93. package/mcp/web/exchange.ts +146 -0
  94. package/package.json +89 -0
@@ -0,0 +1,20 @@
1
+ import { type Kept, type Store, type WardRecord } from '@quo-systems/quo/harbor';
2
+ import type { Storage } from './platform.d.ts';
3
+ export declare function sealKey(secret: string): Promise<CryptoKey>;
4
+ export declare function seal(key: CryptoKey, seed: Uint8Array): Promise<string>;
5
+ export declare function open(key: CryptoKey, sealed: string): Promise<Uint8Array>;
6
+ export declare class DurableStorage implements Store {
7
+ #private;
8
+ readonly storage: Storage;
9
+ readonly key: CryptoKey;
10
+ readonly prefix: string;
11
+ constructor(storage: Storage, key: CryptoKey, prefix?: string);
12
+ list(): Promise<string[]>;
13
+ load(name: string): Promise<Kept | undefined>;
14
+ put(name: string, kept: Kept): Promise<void>;
15
+ save(name: string, partition: Record<string, unknown>): Promise<void>;
16
+ record(name: string, record: WardRecord): Promise<void>;
17
+ take(name: string): Promise<Kept | undefined>;
18
+ hints(): Promise<Record<string, string>>;
19
+ hint(pk: string, url: string): Promise<void>;
20
+ }
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The store as Durable Object storage: one object per harbor, its storage
3
+ // holding one row per ward under `ward:<name>`, seed, partition and record,
4
+ // and one row per hint under `hint:<pk>`. The seed is kept sealed under a
5
+ // key from the platform's secrets, `QUO_SEED_KEY`, so the storage holds
6
+ // ciphertext and the secret store holds the one key, which is what the
7
+ // harbor document's table says the edge does. Values cross as JSON, as
8
+ // they do into a file, because the ward hands the partition out through a
9
+ // guard that structured clone refuses.
10
+ import { arithmetic } from '@quo-systems/quo/ward';
11
+ import { values } from '@quo-systems/quo/harbor';
12
+ // Bytes as the platform's crypto wants them: over a plain ArrayBuffer.
13
+ const plain = (b) => new Uint8Array(b);
14
+ const { hex, unhex } = arithmetic;
15
+ // The seal on a seed: AES-GCM under the platform key, a fresh nonce each
16
+ // time, nonce and ciphertext together as hex.
17
+ export async function sealKey(secret) {
18
+ if (!/^[0-9a-f]{64}$/.test(secret))
19
+ throw new Error('QUO_SEED_KEY is 32 bytes as hex');
20
+ return crypto.subtle.importKey('raw', plain(unhex(secret)), 'AES-GCM', false, ['encrypt', 'decrypt']);
21
+ }
22
+ export async function seal(key, seed) {
23
+ const iv = crypto.getRandomValues(new Uint8Array(12));
24
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain(seed)));
25
+ return hex(iv) + hex(ct);
26
+ }
27
+ export async function open(key, sealed) {
28
+ const b = plain(unhex(sealed));
29
+ return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, key, b.subarray(12)));
30
+ }
31
+ // `prefix` keeps more than one harbor apart in one object's storage: the
32
+ // exercise does that, a deployment never does.
33
+ export class DurableStorage {
34
+ storage;
35
+ key;
36
+ prefix;
37
+ constructor(storage, key, prefix = '') {
38
+ this.storage = storage;
39
+ this.key = key;
40
+ this.prefix = prefix;
41
+ }
42
+ #k(kind, name) {
43
+ return `${this.prefix}${kind}:${name}`;
44
+ }
45
+ async list() {
46
+ const head = this.#k('ward', '');
47
+ return [...(await this.storage.list({ prefix: head })).keys()].map((k) => k.slice(head.length));
48
+ }
49
+ async load(name) {
50
+ const row = await this.storage.get(this.#k('ward', name));
51
+ return row && { seed: await open(this.key, row.seed), partition: values(row.partition), record: row.record };
52
+ }
53
+ async put(name, kept) {
54
+ if ((await this.storage.get(this.#k('ward', name))) !== undefined)
55
+ throw new Error(`ward ${name} already exists here`);
56
+ await this.storage.put(this.#k('ward', name), { seed: await seal(this.key, kept.seed), partition: values(kept.partition), record: kept.record });
57
+ }
58
+ async save(name, partition) {
59
+ const row = await this.storage.get(this.#k('ward', name));
60
+ if (row)
61
+ await this.storage.put(this.#k('ward', name), { ...row, partition: values(partition) });
62
+ }
63
+ async record(name, record) {
64
+ const row = await this.storage.get(this.#k('ward', name));
65
+ if (row)
66
+ await this.storage.put(this.#k('ward', name), { ...row, record });
67
+ }
68
+ async take(name) {
69
+ const kept = await this.load(name);
70
+ if (kept)
71
+ await this.storage.delete(this.#k('ward', name));
72
+ return kept;
73
+ }
74
+ async hints() {
75
+ const head = this.#k('hint', '');
76
+ return Object.fromEntries([...(await this.storage.list({ prefix: head }))].map(([k, v]) => [k.slice(head.length), v]));
77
+ }
78
+ async hint(pk, url) {
79
+ await this.storage.put(this.#k('hint', pk), url);
80
+ }
81
+ }
@@ -0,0 +1,14 @@
1
+ import { EdgeHarbor } from './edge.ts';
2
+ import type { State, Env } from './platform.d.ts';
3
+ export declare class Quo {
4
+ #private;
5
+ readonly state: State;
6
+ readonly env: Env;
7
+ constructor(state: State, env: Env);
8
+ harbor(): Promise<EdgeHarbor>;
9
+ fetch(request: Request): Promise<Response>;
10
+ }
11
+ declare const _default: {
12
+ fetch(request: Request, env: Env): Promise<Response>;
13
+ };
14
+ export default _default;
@@ -0,0 +1,47 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The worker: the edge harbor deployed. One Durable Object class, `Quo`,
3
+ // one instance per harbor name, and the worker routes by the first path
4
+ // segment: `/h/<name>/...` goes to that harbor. A world's quo. route on the
5
+ // edge is `<origin>/h/<name>/quo`, and its health `<origin>/h/<name>/health`.
6
+ // `wrangler.toml` beside this file names the class and the secrets.
7
+ //
8
+ // One thing lives here for the terrain test alone: with `QUO_EXERCISE` set,
9
+ // which no deployment sets, `/exercise` runs the library's conformance
10
+ // suite inside the platform against two harbors in this one object, over
11
+ // two prefixed views of its storage, reaching each other by request
12
+ // through the worker under `/x/<sub>/quo`. One object cannot touch
13
+ // another's storage, the platform says so, and so the two harbors share an
14
+ // object the way two tabs share a page.
15
+ import { EdgeHarbor } from './edge.js';
16
+ import { exercise } from './exercise.js';
17
+ export class Quo {
18
+ state;
19
+ env;
20
+ #harbor;
21
+ constructor(state, env) {
22
+ this.state = state;
23
+ this.env = env;
24
+ }
25
+ harbor() {
26
+ return (this.#harbor ??= EdgeHarbor.open(this.state, this.env));
27
+ }
28
+ async fetch(request) {
29
+ const url = new URL(request.url);
30
+ const m = /^\/h\/([\w.-]+)(\/.*)?$/.exec(url.pathname);
31
+ if (!m)
32
+ return new Response(JSON.stringify({ error: 'no such route' }), { status: 404, headers: { 'content-type': 'application/json' } });
33
+ const path = m[2] ?? '/';
34
+ if (this.env.QUO_EXERCISE && (path === '/exercise' || path.startsWith('/x/')))
35
+ return exercise(request, `${url.origin}/h/${m[1]}`, path, this.state, this.env);
36
+ return (await this.harbor()).fetch(request, path);
37
+ }
38
+ }
39
+ export default {
40
+ async fetch(request, env) {
41
+ const url = new URL(request.url);
42
+ const m = /^\/h\/([\w.-]+)(\/.*)?$/.exec(url.pathname);
43
+ if (!m)
44
+ return new Response(JSON.stringify({ error: 'a harbor is /h/<name>/...' }), { status: 404, headers: { 'content-type': 'application/json' } });
45
+ return env.HARBOR.get(env.HARBOR.idFromName(m[1])).fetch(request);
46
+ },
47
+ };
@@ -0,0 +1,14 @@
1
+ import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
2
+ export declare class Files implements Store {
3
+ #private;
4
+ readonly dir: string;
5
+ constructor(dir: string);
6
+ list(): Promise<string[]>;
7
+ load(name: string): Promise<Kept | undefined>;
8
+ put(name: string, kept: Kept): Promise<void>;
9
+ save(name: string, partition: Record<string, unknown>): Promise<void>;
10
+ record(name: string, record: WardRecord): Promise<void>;
11
+ take(name: string): Promise<Kept | undefined>;
12
+ hints(): Promise<Record<string, string>>;
13
+ hint(pk: string, url: string): Promise<void>;
14
+ }
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The store as a folder on disk, one folder per ward:
3
+ //
4
+ // <dir>/wards/<name>/seed 32 bytes, hex, mode 0600
5
+ // <dir>/wards/<name>/partition.json
6
+ // <dir>/wards/<name>/ward.json the ward record
7
+ // <dir>/reach.json the directory's hints
8
+ //
9
+ // Every write of the partition goes through a temp file and a rename, and
10
+ // writes are queued per ward so two calls never race on one file.
11
+ import { mkdir, readFile, writeFile, rename, readdir, rm, chmod } from 'node:fs/promises';
12
+ import { existsSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { arithmetic } from '@quo-systems/quo/ward';
15
+ const { hex, unhex } = arithmetic;
16
+ export class Files {
17
+ dir;
18
+ #queues = new Map();
19
+ constructor(dir) {
20
+ this.dir = dir;
21
+ }
22
+ #ward(name) {
23
+ return join(this.dir, 'wards', name);
24
+ }
25
+ async list() {
26
+ const wards = join(this.dir, 'wards');
27
+ return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed'))) : [];
28
+ }
29
+ async load(name) {
30
+ const wd = this.#ward(name);
31
+ if (!existsSync(join(wd, 'seed')))
32
+ return undefined;
33
+ return {
34
+ seed: unhex((await readFile(join(wd, 'seed'), 'utf8')).trim()),
35
+ partition: JSON.parse(await readFile(join(wd, 'partition.json'), 'utf8')),
36
+ record: JSON.parse(await readFile(join(wd, 'ward.json'), 'utf8')),
37
+ };
38
+ }
39
+ async put(name, kept) {
40
+ const wd = this.#ward(name);
41
+ if (existsSync(join(wd, 'seed')))
42
+ throw new Error(`ward ${name} already exists in ${this.dir}`);
43
+ await mkdir(wd, { recursive: true, mode: 0o700 });
44
+ await writeFile(join(wd, 'seed'), hex(kept.seed), { mode: 0o600 });
45
+ await chmod(join(wd, 'seed'), 0o600);
46
+ await writeFile(join(wd, 'partition.json'), JSON.stringify(kept.partition) + '\n', { mode: 0o600 });
47
+ await writeFile(join(wd, 'ward.json'), JSON.stringify(kept.record, null, 2) + '\n', { mode: 0o600 });
48
+ }
49
+ save(name, partition) {
50
+ const wd = this.#ward(name);
51
+ if (!existsSync(join(wd, 'seed')))
52
+ return Promise.resolve(); // a name not kept is nothing
53
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
54
+ const tmp = join(wd, 'partition.json.tmp');
55
+ await writeFile(tmp, JSON.stringify(partition) + '\n', { mode: 0o600 });
56
+ await rename(tmp, join(wd, 'partition.json'));
57
+ });
58
+ this.#queues.set(name, next.catch(() => { }));
59
+ return next;
60
+ }
61
+ async record(name, record) {
62
+ if (!existsSync(join(this.#ward(name), 'seed')))
63
+ return;
64
+ await writeFile(join(this.#ward(name), 'ward.json'), JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
65
+ }
66
+ async take(name) {
67
+ const kept = await this.load(name);
68
+ if (!kept)
69
+ return undefined;
70
+ await this.#queues.get(name);
71
+ await rm(this.#ward(name), { recursive: true, force: true });
72
+ return kept;
73
+ }
74
+ async hints() {
75
+ const p = join(this.dir, 'reach.json');
76
+ return existsSync(p) ? JSON.parse(await readFile(p, 'utf8')) : {};
77
+ }
78
+ async hint(pk, url) {
79
+ const all = await this.hints();
80
+ all[pk] = url;
81
+ await writeFile(join(this.dir, 'reach.json'), JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
82
+ }
83
+ }
@@ -0,0 +1,15 @@
1
+ import { type Kept, type Store, type WardRecord } from '@quo-systems/quo/harbor';
2
+ export declare class Idb implements Store {
3
+ #private;
4
+ readonly name: string;
5
+ constructor(name?: string);
6
+ list(): Promise<string[]>;
7
+ load(name: string): Promise<Kept | undefined>;
8
+ put(name: string, kept: Kept): Promise<void>;
9
+ save(name: string, partition: Record<string, unknown>): Promise<void>;
10
+ record(name: string, record: WardRecord): Promise<void>;
11
+ take(name: string): Promise<Kept | undefined>;
12
+ hints(): Promise<Record<string, string>>;
13
+ hint(pk: string, url: string): Promise<void>;
14
+ close(): void;
15
+ }
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The store as IndexedDB, per origin: one database per harbor, one record
3
+ // per ward holding seed, partition and record, and one record for the
4
+ // directory's hints. The honest weakness the harbor document names: a tab
5
+ // has no secret store for bytes, so the seed sits beside the partition,
6
+ // guarded by the origin and nothing more. That is the price of a tab.
7
+ import { values } from '@quo-systems/quo/harbor';
8
+ const WARDS = 'wards', HINTS = 'hints';
9
+ function open(name) {
10
+ return new Promise((ok, no) => {
11
+ const req = indexedDB.open(name, 1);
12
+ req.onupgradeneeded = () => {
13
+ req.result.createObjectStore(WARDS);
14
+ req.result.createObjectStore(HINTS);
15
+ };
16
+ req.onsuccess = () => ok(req.result);
17
+ req.onerror = () => no(req.error ?? new Error('indexedDB would not open'));
18
+ });
19
+ }
20
+ function done(req) {
21
+ return new Promise((ok, no) => {
22
+ req.onsuccess = () => ok(req.result);
23
+ req.onerror = () => no(req.error ?? new Error('indexedDB request failed'));
24
+ });
25
+ }
26
+ export class Idb {
27
+ name;
28
+ #db;
29
+ constructor(name = 'quo') {
30
+ this.name = name;
31
+ }
32
+ #open() {
33
+ return (this.#db ??= open(this.name));
34
+ }
35
+ async #tx(store, mode) {
36
+ return (await this.#open()).transaction(store, mode).objectStore(store);
37
+ }
38
+ async list() {
39
+ return (await done((await this.#tx(WARDS, 'readonly')).getAllKeys())).map(String);
40
+ }
41
+ async load(name) {
42
+ const row = (await done((await this.#tx(WARDS, 'readonly')).get(name)));
43
+ return row && { seed: new Uint8Array(row.seed), partition: values(row.partition), record: row.record };
44
+ }
45
+ async put(name, kept) {
46
+ const st = await this.#tx(WARDS, 'readwrite');
47
+ if ((await done(st.getKey(name))) !== undefined)
48
+ throw new Error(`ward ${name} already exists in ${this.name}`);
49
+ await done(st.put({ seed: kept.seed, partition: values(kept.partition), record: kept.record }, name));
50
+ }
51
+ async save(name, partition) {
52
+ const st = await this.#tx(WARDS, 'readwrite');
53
+ const row = (await done(st.get(name)));
54
+ if (!row)
55
+ return;
56
+ await done(st.put({ ...row, partition: values(partition) }, name));
57
+ }
58
+ async record(name, record) {
59
+ const st = await this.#tx(WARDS, 'readwrite');
60
+ const row = (await done(st.get(name)));
61
+ if (!row)
62
+ return;
63
+ await done(st.put({ ...row, record }, name));
64
+ }
65
+ async take(name) {
66
+ const kept = await this.load(name);
67
+ if (!kept)
68
+ return undefined;
69
+ await done((await this.#tx(WARDS, 'readwrite')).delete(name));
70
+ return kept;
71
+ }
72
+ async hints() {
73
+ const st = await this.#tx(HINTS, 'readonly');
74
+ const keys = (await done(st.getAllKeys())).map(String);
75
+ const vals = (await done(st.getAll()));
76
+ return Object.fromEntries(keys.map((k, i) => [k, vals[i]]));
77
+ }
78
+ async hint(pk, url) {
79
+ await done((await this.#tx(HINTS, 'readwrite')).put(url, pk));
80
+ }
81
+ close() {
82
+ void this.#db?.then((db) => db.close());
83
+ this.#db = undefined;
84
+ }
85
+ }
@@ -0,0 +1,2 @@
1
+ import type { Surface } from './screen.ts';
2
+ export declare function domSurface(root: HTMLElement): Surface;
@@ -0,0 +1,15 @@
1
+ export function domSurface(root) {
2
+ const s = { show: (html) => (root.innerHTML = html), onSubmit: undefined };
3
+ root.addEventListener('submit', (ev) => {
4
+ const f = ev.target;
5
+ const ask = f?.dataset.ask;
6
+ if (!f || ask === undefined)
7
+ return;
8
+ ev.preventDefault();
9
+ const raw = {};
10
+ for (const [k, v] of new FormData(f))
11
+ raw[k] = typeof v === 'string' ? v : '';
12
+ s.onSubmit?.(ask, raw);
13
+ });
14
+ return s;
15
+ }
@@ -0,0 +1,32 @@
1
+ import type { Ask, Blueprint, Json, JsonObject } from '@quo-systems/quo';
2
+ import { type Word } from '../beings/side.ts';
3
+ export type Property = {
4
+ type?: string;
5
+ description?: string;
6
+ enum?: Json[];
7
+ format?: string;
8
+ default?: Json;
9
+ };
10
+ export type Schema = {
11
+ properties?: Record<string, Property>;
12
+ required?: string[];
13
+ };
14
+ export declare const escape: (s: string) => string;
15
+ export declare function field(name: string, p: Property, must: boolean): string;
16
+ export declare function form(ask: Ask): string;
17
+ export type Raw = Record<string, string>;
18
+ export declare function values(ask: Ask, raw: Raw): {
19
+ args: JsonObject;
20
+ } | {
21
+ error: string;
22
+ };
23
+ export declare function view(value: Json, schema?: JsonObject): string;
24
+ export declare function face(w: Word, schema?: JsonObject): string;
25
+ export type Model = {
26
+ blueprint: Blueprint | null;
27
+ notice: string;
28
+ answers: Record<string, Word>;
29
+ pushes: JsonObject[];
30
+ };
31
+ export declare function title(bp: Blueprint | null): string;
32
+ export declare function page(m: Model): string;
@@ -0,0 +1,136 @@
1
+ import { SILENCE_TEXT, UNREACHED_TEXT, wordText } from '../beings/side.js';
2
+ export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
3
+ const props = (schema) => Object.entries((schema?.properties ?? {}));
4
+ const required = (schema) => new Set((schema?.required ?? []));
5
+ // ---- a schema as a form
6
+ // A property is one field by its type. A string is a text input, and a
7
+ // format the browser knows, `password`, `date`, `email`, is the input's
8
+ // type. A number is a number input. A boolean is a checkbox. An enum is a
9
+ // select. An object or an array is a box for JSON, because a form has no
10
+ // better face for a value the schema left open. A description is the label.
11
+ export function field(name, p, must) {
12
+ const id = escape(name);
13
+ const label = `<label for="${id}">${id}${must ? ' <b>*</b>' : ''}${p.description ? ` <small>${escape(p.description)}</small>` : ''}</label>`;
14
+ const req = must ? ' required' : '';
15
+ if (Array.isArray(p.enum)) {
16
+ const options = p.enum.map((v) => `<option value="${escape(String(v))}">${escape(String(v))}</option>`).join('');
17
+ return `<p>${label}<select id="${id}" name="${id}"${req}>${must ? '' : '<option value=""></option>'}${options}</select></p>`;
18
+ }
19
+ if (p.type === 'boolean')
20
+ return `<p><input type="checkbox" id="${id}" name="${id}" value="true">${label}</p>`;
21
+ if (p.type === 'number' || p.type === 'integer')
22
+ return `<p>${label}<input type="number" id="${id}" name="${id}"${p.type === 'integer' ? ' step="1"' : ' step="any"'}${req}></p>`;
23
+ if (p.type === 'string' || p.type === undefined) {
24
+ const kind = p.format === 'password' || p.format === 'date' || p.format === 'email' || p.format === 'time' ? p.format : 'text';
25
+ return `<p>${label}<input type="${kind}" id="${id}" name="${id}"${req}></p>`;
26
+ }
27
+ return `<p>${label}<textarea id="${id}" name="${id}" placeholder="JSON"${req}></textarea></p>`;
28
+ }
29
+ // The ask as a form: its name is the button, its description the legend's
30
+ // small print, and `data-ask` is how the surface says which ask was sent.
31
+ export function form(ask) {
32
+ const must = required(ask.input);
33
+ const fields = props(ask.input)
34
+ .map(([name, p]) => field(name, p, must.has(name)))
35
+ .join('');
36
+ return `<form data-ask="${escape(ask.name)}"><fieldset><legend>${escape(ask.name)}${ask.description ? ` <small>${escape(ask.description)}</small>` : ''}</legend>${fields}<p><button>${escape(ask.name)}</button></p></fieldset></form>`;
37
+ }
38
+ // The strings typed by the schema. An empty field that is not required is
39
+ // left out, so the being sees what the human said and nothing else; an
40
+ // empty required field goes as the empty string, and the being answers.
41
+ // A JSON box that does not parse is an error the side shows before asking.
42
+ export function values(ask, raw) {
43
+ const args = {};
44
+ const must = required(ask.input);
45
+ for (const [name, p] of props(ask.input)) {
46
+ const s = raw[name];
47
+ if (p.type === 'boolean') {
48
+ if (s === 'true')
49
+ args[name] = true;
50
+ else if (must.has(name))
51
+ args[name] = false;
52
+ continue;
53
+ }
54
+ if (s === undefined || (s === '' && !must.has(name)))
55
+ continue;
56
+ if (p.type === 'number' || p.type === 'integer') {
57
+ const n = Number(s);
58
+ if (s === '' || Number.isNaN(n))
59
+ return { error: `${name} is a number` };
60
+ args[name] = n;
61
+ }
62
+ else if (p.type === 'string' || p.type === undefined || Array.isArray(p.enum)) {
63
+ args[name] = s;
64
+ }
65
+ else {
66
+ try {
67
+ args[name] = JSON.parse(s);
68
+ }
69
+ catch {
70
+ return { error: `${name} is not JSON` };
71
+ }
72
+ }
73
+ }
74
+ return { args };
75
+ }
76
+ // ---- an answer as a view
77
+ // A value has one face by its shape. A primitive is text. An object is a
78
+ // table of its keys, in the output schema's order when she declared one and
79
+ // her own order otherwise, each value viewed in turn. A list of objects is
80
+ // one table with a column per key seen; any other list is a list. Null is
81
+ // the word nothing, so that an answer of null is never a blank.
82
+ export function view(value, schema) {
83
+ if (value === null)
84
+ return '<i>nothing</i>';
85
+ if (typeof value !== 'object')
86
+ return escape(String(value));
87
+ if (Array.isArray(value)) {
88
+ if (value.length && value.every((v) => v !== null && typeof v === 'object' && !Array.isArray(v))) {
89
+ const rows = value;
90
+ const keys = [...new Set(rows.flatMap((r) => Object.keys(r)))];
91
+ const head = keys.map((k) => `<th>${escape(k)}</th>`).join('');
92
+ const body = rows.map((r) => `<tr>${keys.map((k) => `<td>${k in r ? view(r[k]) : ''}</td>`).join('')}</tr>`).join('');
93
+ return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
94
+ }
95
+ return value.length ? `<ul>${value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>` : '<i>none</i>';
96
+ }
97
+ const declared = props(schema).map(([k]) => k);
98
+ const keys = [...declared.filter((k) => k in value), ...Object.keys(value).filter((k) => !declared.includes(k))];
99
+ if (!keys.length)
100
+ return '<i>empty</i>';
101
+ return `<table>${keys.map((k) => `<tr><th>${escape(k)}</th><td>${view(value[k], props(schema).find(([n]) => n === k)?.[1])}</td></tr>`).join('')}</table>`;
102
+ }
103
+ // The three words for "no object", and the object, each with a face. An
104
+ // error object is the answer she gave, marked; silence and unreached are
105
+ // what the side says about them, since they carry nothing themselves.
106
+ export function face(w, schema) {
107
+ if (w.word === 'object')
108
+ return `<div class="answer">${view(w.value, schema)}</div>`;
109
+ if (w.word === 'error')
110
+ return `<div class="answer error">${view(w.value)}</div>`;
111
+ if (w.word === 'silence')
112
+ return `<div class="answer silence">${escape(SILENCE_TEXT)}</div>`;
113
+ if (w.word === 'quo')
114
+ return `<div class="answer word">${escape(wordText(w.name))}</div>`;
115
+ return `<div class="answer unreached">${escape(UNREACHED_TEXT)}</div>`;
116
+ }
117
+ // The title is the one hint the notes may carry: a string named `name`.
118
+ // The rest of the notes is shown as a view and read as nothing else.
119
+ export function title(bp) {
120
+ const n = bp?.notes;
121
+ return n !== null && typeof n === 'object' && !Array.isArray(n) && typeof n.name === 'string' && n.name ? n.name : 'quo';
122
+ }
123
+ export function page(m) {
124
+ const bp = m.blueprint;
125
+ const asks = bp
126
+ ? bp.asks
127
+ .map((a) => {
128
+ const w = m.answers[a.name];
129
+ return `<section>${form(a)}${w ? face(w, a.output) : ''}</section>`;
130
+ })
131
+ .join('')
132
+ : '';
133
+ const notes = bp && bp.notes !== null && !(typeof bp.notes === 'object' && !Array.isArray(bp.notes) && !Object.keys(bp.notes).length) ? `<aside class="notes">${view(bp.notes)}</aside>` : '';
134
+ const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
135
+ return `<header><h1>${escape(title(bp))}</h1><p class="notice">${escape(m.notice)}</p></header>${notes}<main>${asks}</main>${pushes}`;
136
+ }
@@ -0,0 +1,11 @@
1
+ import type { Avatar } from '../beings/avatar.ts';
2
+ import { type Serving } from '../beings/side.ts';
3
+ import { type Model, type Raw } from './html.ts';
4
+ export type Surface = {
5
+ show(html: string): void;
6
+ onSubmit: ((ask: string, raw: Raw) => void) | undefined;
7
+ };
8
+ export declare function screenSide(avatar: Avatar, surface: Surface, after?: () => Promise<void>, notice?: string): Promise<Serving & {
9
+ model: Model;
10
+ refresh(): Promise<void>;
11
+ }>;
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The human side: one avatar, one surface. The surface is the least a
3
+ // screen can be: it shows a page and hands back a submitted form, and that
4
+ // is all this side asks of a terrain. A DOM is one surface; a test is
5
+ // another. The side keeps the model, speaks it as a page after every change
6
+ // through `html.ts`, calls the avatar when a form comes back, and re-asks
7
+ // her describe after each call, so a digest that moved is a page that
8
+ // moved. A push is appended and shown the moment it lands.
9
+ import { digest } from '@quo-systems/quo';
10
+ import { word } from '../beings/side.js';
11
+ import { page, values } from './html.js';
12
+ // `after` runs when a call is done, as it does for the model side: a harbor
13
+ // that must write what the ward changed hooks it.
14
+ export async function screenSide(avatar, surface, after = async () => { }, notice = '') {
15
+ const model = { blueprint: null, notice, answers: {}, pushes: [] };
16
+ let seen = null;
17
+ const show = () => surface.show(page(model));
18
+ // Her describe, again: the page follows the digest.
19
+ const refresh = async () => {
20
+ const bp = await avatar.tools();
21
+ if ('error' in bp && !('asks' in bp)) {
22
+ // the last page she gave stays, with the answers under it, and the
23
+ // notice says where the human stands now
24
+ model.notice = `not in: ${bp.error}`;
25
+ }
26
+ else {
27
+ const d = await digest(bp);
28
+ if (d !== seen) {
29
+ seen = d;
30
+ model.blueprint = bp;
31
+ for (const k of Object.keys(model.answers))
32
+ if (!bp.asks.some((a) => a.name === k))
33
+ delete model.answers[k];
34
+ }
35
+ }
36
+ show();
37
+ };
38
+ surface.onSubmit = (name, raw) => void submit(name, raw);
39
+ const submit = async (name, raw) => {
40
+ const ask = model.blueprint?.asks.find((a) => a.name === name);
41
+ if (!ask)
42
+ return;
43
+ const v = values(ask, raw);
44
+ if ('error' in v) {
45
+ model.answers[name] = { word: 'error', value: { error: v.error } };
46
+ return show();
47
+ }
48
+ model.answers[name] = word(await avatar.call(name, v.args));
49
+ await after();
50
+ await refresh();
51
+ };
52
+ const ear = (object) => {
53
+ model.pushes.push(object);
54
+ show();
55
+ };
56
+ avatar.ears.add(ear);
57
+ await refresh();
58
+ return {
59
+ model,
60
+ refresh,
61
+ close: async () => {
62
+ avatar.ears.delete(ear);
63
+ surface.onSubmit = undefined;
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,7 @@
1
+ import { USER } from '../beings/avatar.ts';
2
+ export type Config = {
3
+ quo: string;
4
+ web: string;
5
+ };
6
+ export declare function start(cfg: Config, root?: HTMLElement): Promise<void>;
7
+ export { USER };