@quo-systems/dock 0.2.2 → 0.2.4
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.
- package/beings/avatar.ts +8 -1
- package/beings/carry.ts +1 -1
- package/beings/quo-dock.md +53 -39
- package/beings/setup.ts +2 -1
- package/beings/user.ts +48 -1
- package/cli/daemon.ts +51 -25
- package/cli/estate/quo.service +0 -1
- package/dist/beings/avatar.js +9 -2
- package/dist/beings/carry.js +2 -2
- package/dist/beings/setup.js +2 -1
- package/dist/beings/user.d.ts +89 -0
- package/dist/beings/user.js +47 -1
- package/dist/cli/daemon.d.ts +10 -2
- package/dist/cli/daemon.js +51 -23
- package/dist/cli/estate/quo.service +0 -1
- package/dist/harbor/capacitor.d.ts +16 -0
- package/dist/harbor/capacitor.js +135 -0
- package/dist/harbor/disk.js +8 -3
- package/dist/harbor/edge/edge.js +2 -1
- package/dist/harbor/edge/exercise.js +2 -1
- package/dist/harbor/edge/storage.d.ts +0 -3
- package/dist/harbor/edge/storage.js +6 -25
- package/dist/harbor/files.d.ts +2 -1
- package/dist/harbor/files.js +87 -22
- package/dist/harbor/seal.d.ts +3 -0
- package/dist/harbor/seal.js +25 -0
- package/dist/human/door.d.ts +2 -0
- package/dist/human/door.js +19 -7
- package/dist/human/html.d.ts +2 -0
- package/dist/human/html.js +43 -17
- package/dist/human/local.d.ts +10 -0
- package/dist/human/local.js +10 -0
- package/dist/human/screen.js +26 -8
- package/dist/human/tab.d.ts +1 -0
- package/dist/human/tab.js +59 -3
- package/dist/human/tree.d.ts +40 -0
- package/dist/human/tree.js +89 -0
- package/dist/human/web.d.ts +1 -0
- package/dist/human/web.js +36 -8
- package/dist/mcp/oauth.js +2 -2
- package/dist/mcp/route.js +1 -1
- package/dist/mcp/runner.js +5 -2
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/web/exchange.d.ts +5 -9
- package/dist/mcp/web/exchange.js +47 -98
- package/harbor/capacitor.ts +142 -0
- package/harbor/disk.ts +8 -3
- package/harbor/edge/edge.ts +2 -1
- package/harbor/edge/exercise.ts +2 -1
- package/harbor/edge/storage.ts +6 -25
- package/harbor/files.ts +79 -19
- package/harbor/quo-harbor.md +58 -15
- package/harbor/seal.ts +26 -0
- package/human/door.ts +20 -7
- package/human/html.ts +41 -17
- package/human/local.ts +26 -0
- package/human/quo-human.md +114 -19
- package/human/screen.ts +21 -7
- package/human/tab.ts +50 -5
- package/human/tree.ts +129 -0
- package/human/web.ts +36 -9
- package/mcp/oauth.ts +2 -2
- package/mcp/quo-mcp.md +27 -20
- package/mcp/route.ts +1 -1
- package/mcp/runner.ts +9 -6
- package/mcp/server.ts +1 -1
- package/mcp/web/exchange.ts +62 -96
- package/package.json +13 -2
package/dist/harbor/files.js
CHANGED
|
@@ -1,35 +1,99 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The store as a folder on disk, one folder per ward
|
|
2
|
+
// The store as a folder on disk, one folder per ward, in one of two forms.
|
|
3
|
+
//
|
|
4
|
+
// Plain, when the store has no key, what the droplets run:
|
|
3
5
|
//
|
|
4
6
|
// <dir>/wards/<name>/seed 32 bytes, hex, mode 0600
|
|
5
7
|
// <dir>/wards/<name>/partition.json
|
|
6
8
|
// <dir>/wards/<name>/ward.json the ward record
|
|
9
|
+
//
|
|
10
|
+
// Sealed, when the store holds a key, what a device's daemon runs with the
|
|
11
|
+
// key from its Keychain:
|
|
12
|
+
//
|
|
13
|
+
// <dir>/wards/<name>/ward.sealed seed, partition and record as one
|
|
14
|
+
// JSON, sealed under the key, hex
|
|
15
|
+
//
|
|
16
|
+
// and in both:
|
|
17
|
+
//
|
|
7
18
|
// <dir>/reach.json the directory's hints
|
|
8
19
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
20
|
+
// A ward is whole in either form, and a store that meets the other form
|
|
21
|
+
// refuses it by name: a daemon started without its key, or with one against
|
|
22
|
+
// a plain folder, fails loudly instead of booting on what it cannot read.
|
|
23
|
+
// Sealing a plain folder is a deliberate command, never a boot's doing.
|
|
24
|
+
// Every write of a ward goes through a temp file and a rename, and writes
|
|
25
|
+
// are queued per ward so two calls never race on one file.
|
|
11
26
|
import { mkdir, readFile, writeFile, rename, readdir, rm, chmod } from 'node:fs/promises';
|
|
12
27
|
import { existsSync } from 'node:fs';
|
|
13
28
|
import { join } from 'node:path';
|
|
14
29
|
import { arithmetic } from '@quo-systems/quo/ward';
|
|
30
|
+
import { sealKey, seal, open } from './seal.js';
|
|
15
31
|
const { hex, unhex } = arithmetic;
|
|
16
32
|
export class Files {
|
|
17
33
|
dir;
|
|
34
|
+
#key;
|
|
18
35
|
#queues = new Map();
|
|
19
|
-
constructor(dir) {
|
|
36
|
+
constructor(dir, key) {
|
|
20
37
|
this.dir = dir;
|
|
38
|
+
if (key)
|
|
39
|
+
this.#key = sealKey(key);
|
|
21
40
|
}
|
|
22
41
|
#ward(name) {
|
|
23
42
|
return join(this.dir, 'wards', name);
|
|
24
43
|
}
|
|
44
|
+
get sealed() {
|
|
45
|
+
return this.#key !== undefined;
|
|
46
|
+
}
|
|
47
|
+
// The form a folder holds, checked against the form this store speaks.
|
|
48
|
+
#form(name) {
|
|
49
|
+
const wd = this.#ward(name);
|
|
50
|
+
const form = existsSync(join(wd, 'ward.sealed')) ? 'sealed' : existsSync(join(wd, 'seed')) ? 'plain' : undefined;
|
|
51
|
+
if (form === 'sealed' && !this.sealed)
|
|
52
|
+
throw new Error(`ward ${name} in ${this.dir} is sealed and this harbor has no key`);
|
|
53
|
+
if (form === 'plain' && this.sealed)
|
|
54
|
+
throw new Error(`ward ${name} in ${this.dir} is plain and this harbor holds a key`);
|
|
55
|
+
return form;
|
|
56
|
+
}
|
|
57
|
+
// One write at a time per ward, through a temp file and a rename.
|
|
58
|
+
#write(name, file, body) {
|
|
59
|
+
const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
|
|
60
|
+
const tmp = join(this.#ward(name), `${file}.tmp`);
|
|
61
|
+
await writeFile(tmp, body, { mode: 0o600 });
|
|
62
|
+
await rename(tmp, join(this.#ward(name), file));
|
|
63
|
+
});
|
|
64
|
+
this.#queues.set(name, next.catch(() => { }));
|
|
65
|
+
return next;
|
|
66
|
+
}
|
|
67
|
+
async #read(name) {
|
|
68
|
+
const blob = (await readFile(join(this.#ward(name), 'ward.sealed'), 'utf8')).trim();
|
|
69
|
+
return JSON.parse(new TextDecoder().decode(await open(await this.#key, blob)));
|
|
70
|
+
}
|
|
71
|
+
// A sealed ward is rewritten whole, so the read sits inside the queue
|
|
72
|
+
// with the write: two changes to one ward never lose each other's part.
|
|
73
|
+
#keep(name, change) {
|
|
74
|
+
const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
|
|
75
|
+
const wd = this.#ward(name);
|
|
76
|
+
const blob = change(existsSync(join(wd, 'ward.sealed')) ? await this.#read(name) : undefined);
|
|
77
|
+
const sealed = await seal(await this.#key, new TextEncoder().encode(JSON.stringify(blob)));
|
|
78
|
+
await writeFile(join(wd, 'ward.sealed.tmp'), sealed + '\n', { mode: 0o600 });
|
|
79
|
+
await rename(join(wd, 'ward.sealed.tmp'), join(wd, 'ward.sealed'));
|
|
80
|
+
});
|
|
81
|
+
this.#queues.set(name, next.catch(() => { }));
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
25
84
|
async list() {
|
|
26
85
|
const wards = join(this.dir, 'wards');
|
|
27
|
-
return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed'))) : [];
|
|
86
|
+
return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed')) || existsSync(join(wards, n, 'ward.sealed'))) : [];
|
|
28
87
|
}
|
|
29
88
|
async load(name) {
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
89
|
+
const form = this.#form(name);
|
|
90
|
+
if (!form)
|
|
32
91
|
return undefined;
|
|
92
|
+
if (form === 'sealed') {
|
|
93
|
+
const b = await this.#read(name);
|
|
94
|
+
return { seed: unhex(b.seed), partition: b.partition, record: b.record };
|
|
95
|
+
}
|
|
96
|
+
const wd = this.#ward(name);
|
|
33
97
|
return {
|
|
34
98
|
seed: unhex((await readFile(join(wd, 'seed'), 'utf8')).trim()),
|
|
35
99
|
partition: JSON.parse(await readFile(join(wd, 'partition.json'), 'utf8')),
|
|
@@ -37,31 +101,32 @@ export class Files {
|
|
|
37
101
|
};
|
|
38
102
|
}
|
|
39
103
|
async put(name, kept) {
|
|
40
|
-
|
|
41
|
-
if (existsSync(join(wd, 'seed')))
|
|
104
|
+
if (this.#form(name))
|
|
42
105
|
throw new Error(`ward ${name} already exists in ${this.dir}`);
|
|
106
|
+
const wd = this.#ward(name);
|
|
43
107
|
await mkdir(wd, { recursive: true, mode: 0o700 });
|
|
108
|
+
if (this.sealed)
|
|
109
|
+
return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
|
|
44
110
|
await writeFile(join(wd, 'seed'), hex(kept.seed), { mode: 0o600 });
|
|
45
111
|
await chmod(join(wd, 'seed'), 0o600);
|
|
46
112
|
await writeFile(join(wd, 'partition.json'), JSON.stringify(kept.partition) + '\n', { mode: 0o600 });
|
|
47
113
|
await writeFile(join(wd, 'ward.json'), JSON.stringify(kept.record, null, 2) + '\n', { mode: 0o600 });
|
|
48
114
|
}
|
|
49
|
-
save(name, partition) {
|
|
50
|
-
const
|
|
51
|
-
if (!
|
|
52
|
-
return
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
await rename(tmp, join(wd, 'partition.json'));
|
|
57
|
-
});
|
|
58
|
-
this.#queues.set(name, next.catch(() => { }));
|
|
59
|
-
return next;
|
|
115
|
+
async save(name, partition) {
|
|
116
|
+
const form = this.#form(name);
|
|
117
|
+
if (!form)
|
|
118
|
+
return; // a name not kept is nothing
|
|
119
|
+
if (form === 'sealed')
|
|
120
|
+
return this.#keep(name, (b) => ({ ...b, partition }));
|
|
121
|
+
return this.#write(name, 'partition.json', JSON.stringify(partition) + '\n');
|
|
60
122
|
}
|
|
61
123
|
async record(name, record) {
|
|
62
|
-
|
|
124
|
+
const form = this.#form(name);
|
|
125
|
+
if (!form)
|
|
63
126
|
return;
|
|
64
|
-
|
|
127
|
+
if (form === 'sealed')
|
|
128
|
+
return this.#keep(name, (b) => ({ ...b, record }));
|
|
129
|
+
return this.#write(name, 'ward.json', JSON.stringify(record, null, 2) + '\n');
|
|
65
130
|
}
|
|
66
131
|
async take(name) {
|
|
67
132
|
const kept = await this.load(name);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The seal: what a store does to a ward's bytes before they rest on a data
|
|
3
|
+
// store, under one key the secret store holds. One piece with three users,
|
|
4
|
+
// the edge under the platform's secret, the daemon under a key from its
|
|
5
|
+
// environment, the phone under a key from the Keychain. AES-GCM under a
|
|
6
|
+
// 32-byte key, a fresh nonce each time, nonce and ciphertext together as
|
|
7
|
+
// hex. WebCrypto only, so it runs wherever a store does.
|
|
8
|
+
import { arithmetic } from '@quo-systems/quo/ward';
|
|
9
|
+
const { hex, unhex } = arithmetic;
|
|
10
|
+
// Bytes as the platform's crypto wants them: over a plain ArrayBuffer.
|
|
11
|
+
const plain = (b) => new Uint8Array(b);
|
|
12
|
+
export async function sealKey(secret) {
|
|
13
|
+
if (!/^[0-9a-f]{64}$/.test(secret))
|
|
14
|
+
throw new Error('a seal key is 32 bytes as hex');
|
|
15
|
+
return crypto.subtle.importKey('raw', plain(unhex(secret)), 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
16
|
+
}
|
|
17
|
+
export async function seal(key, bytes) {
|
|
18
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
19
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain(bytes)));
|
|
20
|
+
return hex(iv) + hex(ct);
|
|
21
|
+
}
|
|
22
|
+
export async function open(key, sealed) {
|
|
23
|
+
const b = plain(unhex(sealed));
|
|
24
|
+
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, key, b.subarray(12)));
|
|
25
|
+
}
|
package/dist/human/door.d.ts
CHANGED
package/dist/human/door.js
CHANGED
|
@@ -5,15 +5,27 @@
|
|
|
5
5
|
// Quo's page and not the estate's: it says what this is and that a world
|
|
6
6
|
// here is entered by a link someone sends you, and it asks for nothing,
|
|
7
7
|
// since there is nothing a stranger could type that would let them in. A
|
|
8
|
-
// world that wants a face of its own has a public being with a
|
|
9
|
-
// its own page on its own origin; this is the default and nothing
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
import {
|
|
8
|
+
// world that wants a face of its own has a public being with a page and a
|
|
9
|
+
// look, or its own page on its own origin; this is the default and nothing
|
|
10
|
+
// more. It is the first page written in the grammar, `tree.ts`, painted
|
|
11
|
+
// with nobody behind it: no ask, no answer, no standing.
|
|
12
|
+
import { paint, nobody } from './tree.js';
|
|
13
13
|
// The mark: a ring with a gap, one being's voice reaching another's door.
|
|
14
14
|
const MARK = 'data:image/svg+xml;base64,' +
|
|
15
15
|
btoa(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"><circle cx="32" cy="32" r="22" stroke-dasharray="110 30" transform="rotate(-60 32 32)"/><circle cx="32" cy="32" r="5" fill="currentColor" stroke="none"/></svg>`);
|
|
16
|
+
export function doorTree(at) {
|
|
17
|
+
return {
|
|
18
|
+
kind: 'stack',
|
|
19
|
+
of: [
|
|
20
|
+
{ kind: 'image', src: MARK, alt: '' },
|
|
21
|
+
{ kind: 'text', text: 'quo', role: 'title' },
|
|
22
|
+
{ kind: 'text', text: at.world ? `${at.world} at ${at.host}` : at.host, role: 'label' },
|
|
23
|
+
{ kind: 'text', text: at.world ? 'This world is entered by invitation.' : 'The worlds here are entered by invitation.', role: 'lead' },
|
|
24
|
+
{ kind: 'text', text: 'An invitation is a link someone sends you. Open it here, and you are in: no account, no password, nothing to type. What you can do inside is what the world shows you, and it is yours to keep on this device.' },
|
|
25
|
+
{ kind: 'text', text: 'Nothing on this page asks anything of you. If you were sent here without a link, ask the person who sent you for one.', role: 'quiet' },
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
16
29
|
export function door(at) {
|
|
17
|
-
|
|
18
|
-
return `<main class="door"><img class="mark" alt="" src="${MARK}"><h1>quo</h1>${where}<p class="lead">${at.world ? 'This world is entered by invitation.' : 'The worlds here are entered by invitation.'}</p><p>An invitation is a link someone sends you. Open it here, and you are in: no account, no password, nothing to type. What you can do inside is what the world shows you, and it is yours to keep on this device.</p><p class="quiet">Nothing on this page asks anything of you. If you were sent here without a link, ask the person who sent you for one.</p></main>`;
|
|
30
|
+
return `<main class="door">${paint(doorTree(at), nobody)}</main>`;
|
|
19
31
|
}
|
package/dist/human/html.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Ask, Blueprint, Json, JsonObject } from '@quo-systems/quo';
|
|
2
2
|
import { type Word } from '../beings/side.ts';
|
|
3
3
|
import { hintFor, type Look, type Hint } from '../beings/look.ts';
|
|
4
|
+
import { type Node } from './tree.ts';
|
|
4
5
|
export type Property = {
|
|
5
6
|
type?: string;
|
|
6
7
|
description?: string;
|
|
@@ -26,6 +27,7 @@ export declare function face(w: Word, schema?: JsonObject): string;
|
|
|
26
27
|
export type Model = {
|
|
27
28
|
blueprint: Blueprint | null;
|
|
28
29
|
look: Look;
|
|
30
|
+
tree: Node | null;
|
|
29
31
|
notice: string;
|
|
30
32
|
answers: Record<string, Word>;
|
|
31
33
|
pushes: JsonObject[];
|
package/dist/human/html.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SILENCE_TEXT, UNREACHED_TEXT, wordText } from '../beings/side.js';
|
|
2
2
|
import { hint, hintFor, sanitise, groups as grouped } from '../beings/look.js';
|
|
3
|
+
import { paint } from './tree.js';
|
|
3
4
|
export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
4
5
|
const props = (schema) => Object.entries((schema?.properties ?? {}));
|
|
5
6
|
const required = (schema) => new Set((schema?.required ?? []));
|
|
@@ -153,6 +154,8 @@ export function ordered(asks, l) {
|
|
|
153
154
|
return [...want.map((n) => asks.find((a) => a.name === n)).filter((a) => a !== undefined), ...asks.filter((a) => !want.includes(a.name))];
|
|
154
155
|
}
|
|
155
156
|
export { hintFor };
|
|
157
|
+
// The asks that are presentation and never a form: her look and her page.
|
|
158
|
+
const PRESENTATION = new Set(['look', 'page']);
|
|
156
159
|
export function page(m) {
|
|
157
160
|
const bp = m.blueprint;
|
|
158
161
|
const one = (l, prefix = '') => (a) => {
|
|
@@ -162,26 +165,49 @@ export function page(m) {
|
|
|
162
165
|
};
|
|
163
166
|
const groups = grouped(bp);
|
|
164
167
|
const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
?
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
const section = (id) => {
|
|
169
|
+
const g = groups[id];
|
|
170
|
+
const asks = bp && g ? bp.asks.filter((a) => g.asks?.includes(a.name)) : [];
|
|
171
|
+
if (!asks.length)
|
|
172
|
+
return '';
|
|
173
|
+
const kept = sanitise(g.look);
|
|
174
|
+
const l = look(kept);
|
|
175
|
+
const prefix = `${id}-`;
|
|
176
|
+
const want = (kept.order ?? []).map((n) => prefix + n);
|
|
177
|
+
const inOrder = ordered(asks, { order: want });
|
|
178
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
|
|
179
|
+
};
|
|
180
|
+
const far = Object.keys(groups).map(section).join('');
|
|
181
|
+
// Her page, when she answered one: painted from her tree, each name it
|
|
182
|
+
// uses looked up in her describe, so a page shows nothing her gate hid.
|
|
183
|
+
const painter = {
|
|
184
|
+
form: (name) => {
|
|
185
|
+
const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !PRESENTATION.has(x.name));
|
|
186
|
+
return a ? one(m.look)(a) : '';
|
|
187
|
+
},
|
|
188
|
+
answer: (name, as) => {
|
|
189
|
+
const a = bp?.asks.find((x) => x.name === name);
|
|
190
|
+
const w = a ? m.answers[name] : undefined;
|
|
191
|
+
if (!a || !w)
|
|
171
192
|
return '';
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
:
|
|
181
|
-
|
|
193
|
+
if (w.word === 'object' && as === 'text')
|
|
194
|
+
return `<div class="answer">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
|
|
195
|
+
if (w.word === 'object' && as === 'cards' && Array.isArray(w.value))
|
|
196
|
+
return `<div class="cards">${w.value.map((v) => `<div class="card">${view(v)}</div>`).join('')}</div>`;
|
|
197
|
+
if (w.word === 'object' && as === 'list' && Array.isArray(w.value))
|
|
198
|
+
return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
|
|
199
|
+
return face(w, a.output);
|
|
200
|
+
},
|
|
201
|
+
standing: section,
|
|
202
|
+
standings: () => far,
|
|
203
|
+
};
|
|
204
|
+
const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !PRESENTATION.has(a.name)), m.look).map(one(m.look)).join('') : '';
|
|
205
|
+
const asks = m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : own + far;
|
|
182
206
|
const shown = bp && bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? Object.fromEntries(Object.entries(bp.notes).filter(([k]) => k !== 'standings' && k !== 'name')) : bp?.notes;
|
|
183
207
|
const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown)}</aside>` : '';
|
|
184
208
|
const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
|
|
185
209
|
const mine = look(m.look);
|
|
186
|
-
|
|
210
|
+
// a page carries its own title, so the header keeps only the notice; a being painted as forms is headed by her name
|
|
211
|
+
const head = m.tree ? '' : `${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1>`;
|
|
212
|
+
return `<header${mine.style}>${head}<p class="notice">${escape(m.notice)}</p></header>${notes}<main${mine.style}>${asks}</main>${pushes}`;
|
|
187
213
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { JsonObject } from '@quo-systems/quo';
|
|
2
|
+
import type { Subject } from '../beings/side.ts';
|
|
3
|
+
export declare const TAB = "tab";
|
|
4
|
+
type Door = {
|
|
5
|
+
answer(asker: {
|
|
6
|
+
id: string;
|
|
7
|
+
}, method?: string, args?: JsonObject): Promise<unknown>;
|
|
8
|
+
};
|
|
9
|
+
export declare function local(being: Door): Subject;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The asker the tab speaks under to its own beings.
|
|
2
|
+
export const TAB = 'tab';
|
|
3
|
+
export function local(being) {
|
|
4
|
+
const me = { id: TAB };
|
|
5
|
+
return {
|
|
6
|
+
tools: async () => (await being.answer(me)),
|
|
7
|
+
call: (name, args = {}) => being.answer(me, name, args),
|
|
8
|
+
ears: new Set(),
|
|
9
|
+
};
|
|
10
|
+
}
|
package/dist/human/screen.js
CHANGED
|
@@ -10,12 +10,13 @@ import { digest } from '@quo-systems/quo';
|
|
|
10
10
|
import { word } from '../beings/side.js';
|
|
11
11
|
import { isInvitation } from '../beings/link.js';
|
|
12
12
|
import { page, values, hintFor } from './html.js';
|
|
13
|
+
import { sanitiseTree, answersIn } from './tree.js';
|
|
13
14
|
import { sanitise } from '../beings/look.js';
|
|
14
15
|
import { isSilence, isWord } from '@quo-systems/quo';
|
|
15
16
|
export async function screenSide(avatar, surface, options = {}) {
|
|
16
17
|
const after = options.after ?? (async () => { });
|
|
17
18
|
const notice = options.notice ?? '';
|
|
18
|
-
const model = { blueprint: null, look: {}, notice, answers: {}, pushes: [] };
|
|
19
|
+
const model = { blueprint: null, look: {}, tree: null, notice, answers: {}, pushes: [] };
|
|
19
20
|
let seen = null;
|
|
20
21
|
const show = () => surface.show(page(model));
|
|
21
22
|
// Her describe, again: the page follows the digest.
|
|
@@ -28,7 +29,8 @@ export async function screenSide(avatar, surface, options = {}) {
|
|
|
28
29
|
}
|
|
29
30
|
else {
|
|
30
31
|
const d = await digest(bp);
|
|
31
|
-
|
|
32
|
+
const moved = d !== seen;
|
|
33
|
+
if (moved) {
|
|
32
34
|
seen = d;
|
|
33
35
|
model.blueprint = bp;
|
|
34
36
|
for (const k of Object.keys(model.answers))
|
|
@@ -41,12 +43,28 @@ export async function screenSide(avatar, surface, options = {}) {
|
|
|
41
43
|
}
|
|
42
44
|
else
|
|
43
45
|
model.look = {};
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
}
|
|
47
|
+
// her page, after every ask: a tree of values from what she holds now, or nothing and her asks
|
|
48
|
+
// are forms. A cell moved is a page moved, and her describe need not have; so the answers the
|
|
49
|
+
// page shows are run again with it, and the page never stands more than one ask behind her
|
|
50
|
+
const shows = new Set(model.tree ? answersIn(model.tree) : []);
|
|
51
|
+
if (bp.asks.some((a) => a.name === 'page')) {
|
|
52
|
+
const t = await avatar.call('page');
|
|
53
|
+
model.tree = isSilence(t) || isWord(t) ? null : sanitiseTree(t);
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
model.tree = null;
|
|
57
|
+
const wanted = new Set(model.tree ? answersIn(model.tree) : []);
|
|
58
|
+
for (const n of shows)
|
|
59
|
+
if (wanted.has(n))
|
|
60
|
+
delete model.answers[n];
|
|
61
|
+
// a read-only ask that needs nothing typed is run on her behalf once per digest, and one her page
|
|
62
|
+
// shows the answer of after every ask, so the page opens and stays with what it shows
|
|
63
|
+
for (const a of bp.asks) {
|
|
64
|
+
const needs = (a.input.required ?? []).length > 0;
|
|
65
|
+
const run = wanted.has(a.name) || (moved && hintFor(bp, model.look, a.name).readOnly);
|
|
66
|
+
if (run && !needs && !(a.name in model.answers))
|
|
67
|
+
model.answers[a.name] = word(await avatar.call(a.name, {}));
|
|
50
68
|
}
|
|
51
69
|
}
|
|
52
70
|
await after();
|
package/dist/human/tab.d.ts
CHANGED
package/dist/human/tab.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
1
9
|
import { BrowserHarbor } from '../harbor/browser.js';
|
|
2
10
|
import { USER } from '../beings/avatar.js';
|
|
3
11
|
import { parse, strip } from '../beings/link.js';
|
|
@@ -6,6 +14,7 @@ import { domSurface } from './dom.js';
|
|
|
6
14
|
import { guest } from './guest.js';
|
|
7
15
|
import { door } from './door.js';
|
|
8
16
|
import { world, relations, fresh } from './worlds.js';
|
|
17
|
+
import { local } from './local.js';
|
|
9
18
|
const kept = (key, fallback) => {
|
|
10
19
|
try {
|
|
11
20
|
return JSON.parse(localStorage.getItem(key) ?? 'null') ?? fallback;
|
|
@@ -48,7 +57,22 @@ export async function start(cfg, root = document.body) {
|
|
|
48
57
|
root.append(nav, who, status, screen);
|
|
49
58
|
const say = (s) => (status.textContent = s);
|
|
50
59
|
// The harbor in the tab: one database per origin, one ward per world.
|
|
60
|
+
// The world's code first, when its origin serves any: the classes for
|
|
61
|
+
// the tab, handed to the harbor beside the built-in ones before any ward
|
|
62
|
+
// boots, since a being kept from the last visit is booted by class name.
|
|
63
|
+
// A world that serves none has no beings in the tab, and the page is the
|
|
64
|
+
// far being's alone.
|
|
51
65
|
const harbor = new BrowserHarbor('quo');
|
|
66
|
+
const classes = cfg.beings
|
|
67
|
+
? await import(__rewriteRelativeImportExtension(`${cfg.web}/beings.js`)).then((mod) => {
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const [name, v] of Object.entries(mod))
|
|
70
|
+
if (typeof v === 'function' && 'prototype' in v)
|
|
71
|
+
out[name] = v;
|
|
72
|
+
return out;
|
|
73
|
+
})
|
|
74
|
+
: {};
|
|
75
|
+
Object.assign(harbor.classes, classes);
|
|
52
76
|
await harbor.boot();
|
|
53
77
|
harbor.dial(cfg.quo);
|
|
54
78
|
// The world this page is about: the link's, since an invitation names
|
|
@@ -100,16 +124,45 @@ export async function start(cfg, root = document.body) {
|
|
|
100
124
|
who.append(more);
|
|
101
125
|
}
|
|
102
126
|
};
|
|
103
|
-
//
|
|
104
|
-
//
|
|
127
|
+
// The world's beings in the tab, for one relation: one of each class,
|
|
128
|
+
// booted under her key the first time and found there after, since their
|
|
129
|
+
// cells are in the ward and come back with it. Each is a subject of her
|
|
130
|
+
// own with her own side in her own section, so a note she keeps and a
|
|
131
|
+
// form she shows are hers, painted by the same painter as the far page.
|
|
132
|
+
const locals = [];
|
|
133
|
+
const boot = async (rel) => {
|
|
134
|
+
const beings = ward.partition.beings ?? {};
|
|
135
|
+
for (const name of Object.keys(classes)) {
|
|
136
|
+
const key = `${rel.key}-${name}`;
|
|
137
|
+
if (!beings[key]) {
|
|
138
|
+
const out = (await ward.ask('boot', { key, class: name }));
|
|
139
|
+
if (out.error)
|
|
140
|
+
continue;
|
|
141
|
+
await ward.save();
|
|
142
|
+
}
|
|
143
|
+
const being = ward.being(key);
|
|
144
|
+
if (!being)
|
|
145
|
+
continue;
|
|
146
|
+
const section = el('section', '', { class: 'local', 'data-being': key });
|
|
147
|
+
screen.append(section);
|
|
148
|
+
locals.push(await screenSide(local(being), domSurface(section), { after: () => ward.save() }));
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
// In, as one relation: her page, then the world's beings for her. Every
|
|
152
|
+
// call rotates her keys and a same-ward ask never crosses the harbor, so
|
|
153
|
+
// the side saves after each.
|
|
105
154
|
const inside = async (rel, notice, called) => {
|
|
106
155
|
await side?.close();
|
|
156
|
+
for (const l of locals.splice(0))
|
|
157
|
+
await l.close();
|
|
107
158
|
status.remove();
|
|
108
159
|
root.querySelector('form.password')?.remove();
|
|
109
160
|
screen.replaceChildren();
|
|
110
161
|
current = rel;
|
|
111
162
|
keep(AT(pk), rel.key);
|
|
112
|
-
const
|
|
163
|
+
const mine = el('div', '', { class: 'far' });
|
|
164
|
+
screen.append(mine);
|
|
165
|
+
const s = await screenSide(rel.avatar, domSurface(mine), { after: () => ward.save(), notice });
|
|
113
166
|
side = s;
|
|
114
167
|
const bp = s.model.blueprint;
|
|
115
168
|
const notesName = typeof bp?.notes?.name === 'string' ? bp.notes.name : '';
|
|
@@ -118,6 +171,7 @@ export async function start(cfg, root = document.body) {
|
|
|
118
171
|
keep(NAMES(pk), { ...names(), [rel.key]: called });
|
|
119
172
|
switcher();
|
|
120
173
|
people();
|
|
174
|
+
await boot(rel);
|
|
121
175
|
};
|
|
122
176
|
// The way in, from any of the three: a fresh avatar joins, the ward is
|
|
123
177
|
// saved, and she is the one on screen.
|
|
@@ -138,6 +192,8 @@ export async function start(cfg, root = document.body) {
|
|
|
138
192
|
// there, the door page: a link is the only way in, and nothing to type.
|
|
139
193
|
const atDoor = async () => {
|
|
140
194
|
await side?.close();
|
|
195
|
+
for (const l of locals.splice(0))
|
|
196
|
+
await l.close();
|
|
141
197
|
side = null;
|
|
142
198
|
current = null;
|
|
143
199
|
screen.replaceChildren();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Json } from '@quo-systems/quo';
|
|
2
|
+
export type Role = 'title' | 'lead' | 'body' | 'quiet' | 'label';
|
|
3
|
+
export type As = 'auto' | 'text' | 'table' | 'cards' | 'list';
|
|
4
|
+
export type Node = {
|
|
5
|
+
kind: 'stack';
|
|
6
|
+
of: Node[];
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'row';
|
|
9
|
+
of: Node[];
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'text';
|
|
12
|
+
text: string;
|
|
13
|
+
role?: Role;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'image';
|
|
16
|
+
src: string;
|
|
17
|
+
alt?: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'form';
|
|
20
|
+
ask: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'answer';
|
|
23
|
+
ask: string;
|
|
24
|
+
as?: As;
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'standing';
|
|
27
|
+
id: string;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'standings';
|
|
30
|
+
};
|
|
31
|
+
export declare function sanitiseTree(v: Json, depth?: number): Node | null;
|
|
32
|
+
export declare function answersIn(n: Node, out?: string[]): string[];
|
|
33
|
+
export type Painter = {
|
|
34
|
+
form(ask: string): string;
|
|
35
|
+
answer(ask: string, as: As): string;
|
|
36
|
+
standing(id: string): string;
|
|
37
|
+
standings(): string;
|
|
38
|
+
};
|
|
39
|
+
export declare function paint(n: Node, p: Painter): string;
|
|
40
|
+
export declare const nobody: Painter;
|