@quo-systems/dock 0.2.0 → 0.2.1
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 +6 -2
- package/beings/carry.ts +101 -0
- package/beings/desk.ts +2 -2
- package/beings/index.ts +2 -0
- package/beings/link.ts +54 -0
- package/beings/look.ts +96 -0
- package/beings/quo-dock.md +129 -19
- package/beings/side.ts +10 -1
- package/beings/user.ts +31 -5
- package/cli/daemon.ts +98 -44
- package/cli/quo.ts +3 -2
- package/dist/beings/avatar.js +7 -2
- package/dist/beings/carry.d.ts +10 -0
- package/dist/beings/carry.js +106 -0
- package/dist/beings/desk.d.ts +1 -0
- package/dist/beings/desk.js +1 -1
- package/dist/beings/index.d.ts +2 -0
- package/dist/beings/index.js +2 -0
- package/dist/beings/link.d.ts +7 -0
- package/dist/beings/link.js +42 -0
- package/dist/beings/look.d.ts +27 -0
- package/dist/beings/look.js +71 -0
- package/dist/beings/side.d.ts +8 -1
- package/dist/beings/user.d.ts +26 -2
- package/dist/beings/user.js +32 -5
- package/dist/cli/daemon.d.ts +1 -1
- package/dist/cli/daemon.js +98 -44
- package/dist/cli/quo.js +3 -2
- package/dist/human/guest.d.ts +3 -0
- package/dist/human/guest.js +25 -0
- package/dist/human/html.d.ts +10 -2
- package/dist/human/html.js +61 -10
- package/dist/human/screen.d.ts +8 -3
- package/dist/human/screen.js +25 -6
- package/dist/human/tab.d.ts +2 -0
- package/dist/human/tab.js +127 -42
- package/dist/mcp/http.d.ts +4 -3
- package/dist/mcp/http.js +6 -6
- package/dist/mcp/oauth.d.ts +9 -4
- package/dist/mcp/oauth.js +15 -14
- package/dist/mcp/pilot.js +1 -1
- package/dist/mcp/server.d.ts +5 -10
- package/dist/mcp/server.js +35 -4
- package/dist/mcp/web/exchange.d.ts +5 -2
- package/dist/mcp/web/exchange.js +21 -7
- package/harbor/quo-harbor.md +37 -0
- package/human/guest.ts +26 -0
- package/human/html.ts +58 -10
- package/human/quo-human.md +123 -66
- package/human/screen.ts +28 -7
- package/human/tab.ts +153 -51
- package/mcp/http.ts +10 -9
- package/mcp/oauth.ts +20 -17
- package/mcp/pilot.ts +1 -1
- package/mcp/quo-mcp.md +18 -2
- package/mcp/server.ts +37 -18
- package/mcp/web/exchange.ts +23 -9
- package/package.json +2 -2
package/beings/avatar.ts
CHANGED
|
@@ -51,8 +51,11 @@ export class Avatar extends Being {
|
|
|
51
51
|
return this.join(inv as unknown as Invitation);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
// Knock with an invitation, hand the
|
|
55
|
-
// and take her as `user`. The only moment a standing is born here.
|
|
54
|
+
// Knock with an invitation, hand the far being one back so she can push,
|
|
55
|
+
// and take her as `user`. The only moment a standing is born here. A being
|
|
56
|
+
// who answers the hello with an error object is a being who answered: she
|
|
57
|
+
// is taken all the same, and the way back she did not take is dropped, so
|
|
58
|
+
// any world's public being may admit a guest, hello or not.
|
|
56
59
|
async join(invitation: Invitation): Promise<Blueprint | { error: string }> {
|
|
57
60
|
if (this.standings[USER]) return this.tools();
|
|
58
61
|
const mine = await this.invite(PUSHER);
|
|
@@ -62,6 +65,7 @@ export class Avatar extends Being {
|
|
|
62
65
|
this.occupants.remove(PUSHER);
|
|
63
66
|
return { error: isSilence(out) ? 'silence' : wordOf(out) };
|
|
64
67
|
}
|
|
68
|
+
if (out !== null && typeof out === 'object' && !Array.isArray(out) && 'error' in out) this.occupants.remove(PUSHER);
|
|
65
69
|
await this.take(USER, invitation);
|
|
66
70
|
return this.tools();
|
|
67
71
|
}
|
package/beings/carry.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A carrier: a being who shows the asks of the standings she holds as asks
|
|
3
|
+
// of her own, and forwards. Whoever she carries for sees, in one describe,
|
|
4
|
+
// what she can be asked and what every being she holds can be asked, each
|
|
5
|
+
// under that standing's name; asking one is an ask on that standing, in her
|
|
6
|
+
// name, so the far being sees her and never who is behind her. A user being
|
|
7
|
+
// carries acme for the human's devices; a house being could carry its
|
|
8
|
+
// rooms; nothing here knows what she is.
|
|
9
|
+
//
|
|
10
|
+
// A carried ask is `<id>-<name>`, and the mapping back is kept in her cells
|
|
11
|
+
// and never parsed from the name, so an id with a dash in it is no trouble.
|
|
12
|
+
// The notes say which asks are whose, `standings: { id: { asks, look? } }`,
|
|
13
|
+
// so a screen draws one section per standing and a model side keeps the
|
|
14
|
+
// flat list it needs. Her own asks come first and are never shadowed: a
|
|
15
|
+
// carried name that collides with one of hers is dropped.
|
|
16
|
+
//
|
|
17
|
+
// She asks a standing's describe again when its digest moved, and its
|
|
18
|
+
// `look` once per digest, and keeps both in her cells. So a describe of
|
|
19
|
+
// hers may cost one ask per stale standing, which is the price of a page
|
|
20
|
+
// that is never more than one ask behind.
|
|
21
|
+
import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
22
|
+
import type { Asker, Blueprint, JsonObject, OccupantRecord, Reply } from '@quo-systems/quo';
|
|
23
|
+
import { sanitise, type Look } from './look.ts';
|
|
24
|
+
|
|
25
|
+
type Carried = Record<string, { id: string; ask: string }>;
|
|
26
|
+
type Looks = Record<string, { digest: string | null; look: Look }>;
|
|
27
|
+
|
|
28
|
+
export class Carrier extends Being {
|
|
29
|
+
// Who sees what she carries. Nobody, until a subclass says who.
|
|
30
|
+
static carries(_occupant: OccupantRecord | undefined, _asker: Asker): boolean {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
// Standings she never carries: a way back to a device is one, by the
|
|
34
|
+
// dock's own convention, and a subclass may name more.
|
|
35
|
+
static hidden(id: string): boolean {
|
|
36
|
+
return id.startsWith('to:');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private get carried(): Carried {
|
|
40
|
+
return ((this.cells.carried as Carried | undefined) ??= {});
|
|
41
|
+
}
|
|
42
|
+
private get looks(): Looks {
|
|
43
|
+
return ((this.cells.looks as Looks | undefined) ??= {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Her describe with her standings carried into it, for an asker who may
|
|
47
|
+
// see them. Every other asker gets her describe alone.
|
|
48
|
+
async blueprint(asker: Asker): Promise<Blueprint> {
|
|
49
|
+
const C = this.constructor as typeof Carrier;
|
|
50
|
+
const bp = this.describe(asker);
|
|
51
|
+
if (!C.carries(this.occupant(asker), asker)) return bp;
|
|
52
|
+
const own = new Set(bp.asks.map((a) => a.name));
|
|
53
|
+
const carried: Carried = {};
|
|
54
|
+
const groups: Record<string, { asks: string[]; look?: Look }> = {};
|
|
55
|
+
for (const [id, rec] of Object.entries(this.cells.standings)) {
|
|
56
|
+
if (C.hidden(id)) continue;
|
|
57
|
+
const st = this.standings[id];
|
|
58
|
+
if (!st) continue;
|
|
59
|
+
if (!rec.blueprint || rec.seen !== rec.digest) await st.ask(); // her ward writes the record
|
|
60
|
+
if (!rec.blueprint) continue;
|
|
61
|
+
const names: string[] = [];
|
|
62
|
+
for (const a of rec.blueprint.asks) {
|
|
63
|
+
if (a.name === 'look') continue;
|
|
64
|
+
const name = `${id}-${a.name}`;
|
|
65
|
+
if (own.has(name)) continue;
|
|
66
|
+
carried[name] = { id, ask: a.name };
|
|
67
|
+
names.push(name);
|
|
68
|
+
bp.asks.push({ ...a, name });
|
|
69
|
+
}
|
|
70
|
+
const group: { asks: string[]; look?: Look } = { asks: names };
|
|
71
|
+
if (rec.blueprint.asks.some((a) => a.name === 'look')) {
|
|
72
|
+
const kept = this.looks[id];
|
|
73
|
+
if (!kept || kept.digest !== rec.digest) {
|
|
74
|
+
const l = await st.ask('look');
|
|
75
|
+
this.looks[id] = { digest: rec.digest, look: isSilence(l) || isWord(l) ? {} : sanitise(l) };
|
|
76
|
+
}
|
|
77
|
+
group.look = this.looks[id]!.look;
|
|
78
|
+
}
|
|
79
|
+
groups[id] = group;
|
|
80
|
+
}
|
|
81
|
+
for (const id of Object.keys(this.looks)) if (!(id in groups)) delete this.looks[id];
|
|
82
|
+
this.cells.carried = carried;
|
|
83
|
+
const notes = bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? bp.notes : {};
|
|
84
|
+
return { asks: bp.asks, notes: { ...notes, standings: groups as unknown as JsonObject } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override async answer(asker: Asker, method?: string, args: JsonObject = {}): Promise<Reply> {
|
|
88
|
+
if (method === undefined) return this.blueprint(asker);
|
|
89
|
+
const C = this.constructor as typeof Carrier;
|
|
90
|
+
const to = Object.hasOwn(this.carried, method) ? this.carried[method] : undefined;
|
|
91
|
+
if (to && C.carries(this.occupant(asker), asker)) {
|
|
92
|
+
const st = this.standings[to.id];
|
|
93
|
+
if (!st) return { error: 'unknown ask' };
|
|
94
|
+
const out = await st.ask(to.ask, args);
|
|
95
|
+
if (isSilence(out)) return out;
|
|
96
|
+
if (isWord(out)) return { error: wordOf(out) };
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
return super.answer(asker, method, args);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/beings/desk.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
|
12
12
|
import type { Asker, Json, JsonObject } from '@quo-systems/quo';
|
|
13
13
|
|
|
14
14
|
export type Proof = { kind: string; [more: string]: Json };
|
|
15
|
-
export type Verified = { user: string; client: string; wake?: boolean };
|
|
15
|
+
export type Verified = { user: string; client: string; wake?: boolean; reach?: boolean };
|
|
16
16
|
export type Verifier = (proof: Proof) => Promise<Verified | null> | Verified | null;
|
|
17
17
|
|
|
18
18
|
export class Desk extends Being {
|
|
@@ -38,7 +38,7 @@ export class Desk extends Being {
|
|
|
38
38
|
if (!who) return { error: 'refused' };
|
|
39
39
|
const user = this.standings[`user:${who.user}`];
|
|
40
40
|
if (!user) return { error: 'no such user' };
|
|
41
|
-
const inv = await user.ask('device', who.wake === true ? {
|
|
41
|
+
const inv = await user.ask('device', { client: who.client, ...(who.wake === true ? { wake: true } : {}), ...(who.reach === true ? { reach: true } : {}) });
|
|
42
42
|
if (isSilence(inv)) return { error: 'silence' };
|
|
43
43
|
if (isWord(inv)) return { error: wordOf(inv) };
|
|
44
44
|
return inv;
|
package/beings/index.ts
CHANGED
|
@@ -3,3 +3,5 @@
|
|
|
3
3
|
export { User, DESK } from './user.ts';
|
|
4
4
|
export { Desk, type Proof, type Verified, type Verifier } from './desk.ts';
|
|
5
5
|
export { Avatar, USER, PUSHER } from './avatar.ts';
|
|
6
|
+
export { Carrier } from './carry.ts';
|
|
7
|
+
export { sanitise, hint, hintFor, groups, type Look, type Hint } from './look.ts';
|
package/beings/link.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A link: a world's page with an invitation in the fragment. The page is the
|
|
3
|
+
// hint, where the world lives; the fragment is the invitation, which never
|
|
4
|
+
// leaves the browser: a query string reaches the server, its logs and every
|
|
5
|
+
// referer, and an invitation is a capability. One reserved key, `quo`, the
|
|
6
|
+
// one word no page uses for anything else, and the value is the invitation
|
|
7
|
+
// compact: `ward.heir.secret`, three hex strings, or `ward` alone for a
|
|
8
|
+
// world's public being. The dock's human module reads it, strips it before
|
|
9
|
+
// any page code runs, and joins.
|
|
10
|
+
import type { Invitation } from '@quo-systems/quo';
|
|
11
|
+
|
|
12
|
+
export const KEY = 'quo';
|
|
13
|
+
const HEX = (n: number) => `[0-9a-f]{${n}}`;
|
|
14
|
+
const FULL = new RegExp(`^(${HEX(128)})\\.(${HEX(64)})\\.(${HEX(64)})$`);
|
|
15
|
+
const PUBLIC = new RegExp(`^${HEX(128)}$`);
|
|
16
|
+
|
|
17
|
+
export function format(inv: Invitation): string {
|
|
18
|
+
return inv.heir && inv.secret ? `${inv.ward}.${inv.heir}.${inv.secret}` : inv.ward;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The invitation in a fragment, or null. Anything else in the fragment is
|
|
22
|
+
// the page's own and is left alone.
|
|
23
|
+
export function parse(hash: string): Invitation | null {
|
|
24
|
+
const m = /(?:^#|&)quo=([^&]+)/.exec(hash.startsWith('#') ? hash : `#${hash}`);
|
|
25
|
+
if (!m) return null;
|
|
26
|
+
const v = decodeURIComponent(m[1]!);
|
|
27
|
+
const full = FULL.exec(v);
|
|
28
|
+
if (full) return { ward: full[1]!, heir: full[2]!, secret: full[3]! };
|
|
29
|
+
return PUBLIC.test(v) ? { ward: v } : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The fragment without the invitation, so the page keeps whatever else it
|
|
33
|
+
// put there and the capability is gone from the address bar.
|
|
34
|
+
export function strip(hash: string): string {
|
|
35
|
+
const rest = (hash.startsWith('#') ? hash.slice(1) : hash)
|
|
36
|
+
.split('&')
|
|
37
|
+
.filter((p) => !p.startsWith(`${KEY}=`))
|
|
38
|
+
.join('&');
|
|
39
|
+
return rest ? `#${rest}` : '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function link(page: string, inv: Invitation): string {
|
|
43
|
+
return `${page}#${KEY}=${format(inv)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Whether an answer is an invitation: a ward pk, and a heir with its secret
|
|
47
|
+
// or neither. What a guest's form answers with to be let in.
|
|
48
|
+
export function isInvitation(v: unknown): v is Invitation {
|
|
49
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
|
|
50
|
+
const o = v as Record<string, unknown>;
|
|
51
|
+
if (typeof o.ward !== 'string' || !PUBLIC.test(o.ward)) return false;
|
|
52
|
+
const heir = typeof o.heir === 'string', secret = typeof o.secret === 'string';
|
|
53
|
+
return (heir && secret) || (!heir && !secret && !('heir' in o) && !('secret' in o));
|
|
54
|
+
}
|
package/beings/look.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A look: what a being may say about how she is presented, as values and
|
|
3
|
+
// no more. One optional ask, `look`, answers it, and every side reads the
|
|
4
|
+
// part it understands: a screen reads the tokens and paints her section, a
|
|
5
|
+
// model side reads the hints per ask and writes them as tool annotations.
|
|
6
|
+
// Nothing here is capability, so nothing here is in a blueprint or a digest,
|
|
7
|
+
// and Quo never sees it. The vocabulary is closed: a token that is not
|
|
8
|
+
// listed does not exist, and one that fails its shape is dropped, not
|
|
9
|
+
// fixed. A being cannot paint outside her section or reach a server
|
|
10
|
+
// through a look, because no token can carry a URL, a stylesheet or code.
|
|
11
|
+
import type { Blueprint, Json, JsonObject } from '@quo-systems/quo';
|
|
12
|
+
|
|
13
|
+
// What a side may do with one ask. `title` is a human label instead of the
|
|
14
|
+
// name; `readOnly` says the ask changes nothing, so a screen may run it on
|
|
15
|
+
// its own and a model side marks it so; `destructive` asks for care;
|
|
16
|
+
// `idempotent` says asking twice is asking once; `icon` is one short string
|
|
17
|
+
// for a button. A being who marks an ask read-only and then writes has lied
|
|
18
|
+
// to her own page, and nothing enforces it, the way nothing enforces that
|
|
19
|
+
// her empty ask is safe to repeat.
|
|
20
|
+
export type Hint = { title?: string; readOnly?: boolean; destructive?: boolean; idempotent?: boolean; icon?: string };
|
|
21
|
+
export type Look = {
|
|
22
|
+
name?: string;
|
|
23
|
+
logo?: string; // an image as a data URI, never a URL
|
|
24
|
+
accent?: string; // hex colours
|
|
25
|
+
background?: string;
|
|
26
|
+
foreground?: string;
|
|
27
|
+
font?: string; // a font stack, plain characters
|
|
28
|
+
radius?: number; // 0 to 40, in px
|
|
29
|
+
order?: string[]; // her asks in the order she wants them shown
|
|
30
|
+
asks?: Record<string, Hint>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const COLOUR = /^#[0-9a-fA-F]{3,8}$/;
|
|
34
|
+
const FONT = /^[\w\s,'"-]{1,80}$/;
|
|
35
|
+
const LOGO = /^data:image\/(svg\+xml|png|jpeg|webp);base64,[A-Za-z0-9+/=]{1,40000}$/;
|
|
36
|
+
const NAME = /^[^<>&]{1,60}$/;
|
|
37
|
+
const ICON = /^[^<>&"']{1,8}$/;
|
|
38
|
+
|
|
39
|
+
const str = (v: Json | undefined, re: RegExp): string | undefined => (typeof v === 'string' && re.test(v) ? v : undefined);
|
|
40
|
+
|
|
41
|
+
// The look as a side may use it: every token held to its shape, everything
|
|
42
|
+
// else gone. A value that is not an object is no look at all.
|
|
43
|
+
export function sanitise(v: Json | undefined): Look {
|
|
44
|
+
if (v === null || v === undefined || typeof v !== 'object' || Array.isArray(v)) return {};
|
|
45
|
+
const l: Look = {};
|
|
46
|
+
const name = str(v.name, NAME);
|
|
47
|
+
if (name !== undefined) l.name = name;
|
|
48
|
+
const logo = str(v.logo, LOGO);
|
|
49
|
+
if (logo !== undefined) l.logo = logo;
|
|
50
|
+
for (const k of ['accent', 'background', 'foreground'] as const) {
|
|
51
|
+
const c = str(v[k], COLOUR);
|
|
52
|
+
if (c !== undefined) l[k] = c;
|
|
53
|
+
}
|
|
54
|
+
const font = str(v.font, FONT);
|
|
55
|
+
if (font !== undefined) l.font = font;
|
|
56
|
+
if (typeof v.radius === 'number' && Number.isFinite(v.radius) && v.radius >= 0 && v.radius <= 40) l.radius = Math.round(v.radius);
|
|
57
|
+
if (Array.isArray(v.order)) l.order = v.order.filter((x): x is string => typeof x === 'string');
|
|
58
|
+
if (v.asks !== null && typeof v.asks === 'object' && !Array.isArray(v.asks)) {
|
|
59
|
+
const asks: Record<string, Hint> = {};
|
|
60
|
+
for (const [ask, h] of Object.entries(v.asks as JsonObject)) {
|
|
61
|
+
if (h === null || typeof h !== 'object' || Array.isArray(h)) continue;
|
|
62
|
+
const hint: Hint = {};
|
|
63
|
+
const title = str(h.title, NAME);
|
|
64
|
+
if (title !== undefined) hint.title = title;
|
|
65
|
+
const icon = str(h.icon, ICON);
|
|
66
|
+
if (icon !== undefined) hint.icon = icon;
|
|
67
|
+
for (const k of ['readOnly', 'destructive', 'idempotent'] as const) if (typeof h[k] === 'boolean') hint[k] = h[k];
|
|
68
|
+
if (Object.keys(hint).length) asks[ask] = hint;
|
|
69
|
+
}
|
|
70
|
+
if (Object.keys(asks).length) l.asks = asks;
|
|
71
|
+
}
|
|
72
|
+
return l;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The one hint a side reads first: whether this ask is in the look at all.
|
|
76
|
+
export const hint = (l: Look | undefined, name: string): Hint => l?.asks?.[name] ?? {};
|
|
77
|
+
|
|
78
|
+
// A carrier's notes say which asks are a standing's, and how she looks:
|
|
79
|
+
// `standings: { id: { asks: [name], look? } }`. Every side reads them here.
|
|
80
|
+
export type Group = { asks?: string[]; look?: Json };
|
|
81
|
+
export function groups(bp: Blueprint | null): Record<string, Group> {
|
|
82
|
+
const n = bp?.notes;
|
|
83
|
+
const g = n !== null && n !== undefined && typeof n === 'object' && !Array.isArray(n) ? n.standings : undefined;
|
|
84
|
+
return g !== null && g !== undefined && typeof g === 'object' && !Array.isArray(g) ? (g as Record<string, Group>) : {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The hint for one ask, hers or a standing's: a carried ask is looked up
|
|
88
|
+
// under its bare name in that standing's look.
|
|
89
|
+
export function hintFor(bp: Blueprint | null, mine: Look, name: string): Hint {
|
|
90
|
+
for (const [id, g] of Object.entries(groups(bp))) {
|
|
91
|
+
if (!g.asks?.includes(name)) continue;
|
|
92
|
+
const prefix = `${id}-`;
|
|
93
|
+
return hint(sanitise(g.look), name.startsWith(prefix) ? name.slice(prefix.length) : name);
|
|
94
|
+
}
|
|
95
|
+
return hint(mine, name);
|
|
96
|
+
}
|
package/beings/quo-dock.md
CHANGED
|
@@ -102,7 +102,10 @@ that every arrival is named and judged.
|
|
|
102
102
|
- **Front desk.** A being on the user's harbor that strangers and fresh
|
|
103
103
|
clients meet first. It holds a standing to every user being and can ask
|
|
104
104
|
one for a device invitation. It is the only being that is ever public, and
|
|
105
|
-
only for the first hello.
|
|
105
|
+
only for the first hello. Public is not a kind of being: she is an ordinary
|
|
106
|
+
being who chose to describe herself to strangers and answer them, and what
|
|
107
|
+
she keeps is hers like anyone's. This one keeps little because that is the
|
|
108
|
+
desk's job, not because Quo asks it of her.
|
|
106
109
|
- **Envoy.** A being whose methods call a non-Quo thing and whose describe
|
|
107
110
|
is that thing's surface. A SaaS, an MCP server someone else runs, an
|
|
108
111
|
Arduino behind serial: each enters a world as a being like any other.
|
|
@@ -112,6 +115,12 @@ that every arrival is named and judged.
|
|
|
112
115
|
- **Client identity.** What a credential exchange establishes: this human at
|
|
113
116
|
this browser, this OAuth client for this human, this process on this
|
|
114
117
|
machine. One client identity is one avatar, for life.
|
|
118
|
+
- **Carrier.** A being who shows the asks of the standings she holds as asks
|
|
119
|
+
of her own and forwards. The user being is one: a device the human let
|
|
120
|
+
reach sees acme's asks on her, and acme sees her.
|
|
121
|
+
- **Look.** What a being may say about how she is presented, as values: a
|
|
122
|
+
name, a logo, colours, a font, and a hint per ask. One optional ask,
|
|
123
|
+
`look`, answers it, and every side reads the part it understands.
|
|
115
124
|
- **Route.** A hostname a reverse proxy sends to one process: `web.`, `quo.`,
|
|
116
125
|
`mcp.`. Routes are deployment, never boundaries.
|
|
117
126
|
|
|
@@ -122,7 +131,7 @@ One droplet, one harbor, three routes. Every other placement is a subset.
|
|
|
122
131
|
```
|
|
123
132
|
acme.com, or razvan.com: the dialable part of an estate
|
|
124
133
|
reverse proxy
|
|
125
|
-
web.
|
|
134
|
+
web. one page per world, /<ward>, the credential exchange, ends with an invitation
|
|
126
135
|
quo. the harbor's socket door: the rendezvous for every dialer
|
|
127
136
|
mcp. the model side over HTTP, a credential exchange in front
|
|
128
137
|
quo serve: one process, one harbor, the ask pointer
|
|
@@ -211,6 +220,63 @@ UI and then a tool layer: they build asks, both fall out, and neither side
|
|
|
211
220
|
can do a thing the other cannot, because the gate is one decision for
|
|
212
221
|
describe and for dispatch.
|
|
213
222
|
|
|
223
|
+
## Carrying
|
|
224
|
+
|
|
225
|
+
A device sees one describe, its avatar's one standing, and the human's
|
|
226
|
+
world is many standings: acme, the calendar, the house. The user being
|
|
227
|
+
carries them. Her describe for a device the human allowed to reach shows,
|
|
228
|
+
after her own asks, every ask of every standing she holds, each named
|
|
229
|
+
`<id>-<name>`, and answering one is an ask on that standing in her name. Acme
|
|
230
|
+
sees her id and never the device; the device sees acme without holding a
|
|
231
|
+
key anywhere; the human removes acme once, at her, and every device loses
|
|
232
|
+
it at once. Invariant 1 holds as written: the occupant still holds one
|
|
233
|
+
standing.
|
|
234
|
+
|
|
235
|
+
Carrying is a base, `beings/carry.ts`, and nothing in it knows what a user
|
|
236
|
+
is: a house being could carry its rooms, a company its suppliers. A subclass
|
|
237
|
+
says who may see what she carries, and the user being says a device with the
|
|
238
|
+
`reach` note, written when the human ticked the box at the exchange. The
|
|
239
|
+
notes of a carrier's describe say which asks are whose,
|
|
240
|
+
`standings: { id: { asks, look? } }`, so a screen draws one section per
|
|
241
|
+
standing and a model side keeps the flat list it needs; the mapping back
|
|
242
|
+
from a carried name to the standing is in her cells and never parsed from
|
|
243
|
+
the name. Her own asks come first and are never shadowed. She asks a
|
|
244
|
+
standing's describe again when its digest moved and its `look` once per
|
|
245
|
+
digest, so a describe of hers may cost one ask per stale standing, the price
|
|
246
|
+
of a page that is never more than one ask behind.
|
|
247
|
+
|
|
248
|
+
## The look
|
|
249
|
+
|
|
250
|
+
Three sides, one describe, and nothing hard-wired for any of them. A being
|
|
251
|
+
who wants to say how she is presented answers one more ask, `look`, with
|
|
252
|
+
values and no more, and every side reads the part it understands. The
|
|
253
|
+
vocabulary is closed, in `beings/look.ts`, and a token that fails its shape
|
|
254
|
+
is dropped, not fixed:
|
|
255
|
+
|
|
256
|
+
| token | shape | a screen | a model side |
|
|
257
|
+
| ---------------------- | ------------------------------------- | --------------------------------- | -------------------------- |
|
|
258
|
+
| name | text, no tags | her heading | nothing |
|
|
259
|
+
| logo | an image as a data URI, never a URL | beside the heading | nothing |
|
|
260
|
+
| accent, background, fg | hex colours | CSS variables on her section only | nothing |
|
|
261
|
+
| font | a font stack, plain characters | her section | nothing |
|
|
262
|
+
| radius | 0 to 40 | her corners | nothing |
|
|
263
|
+
| order | her ask names | which come first | tool order |
|
|
264
|
+
| asks.NAME.title | text | the button | the tool's title |
|
|
265
|
+
| asks.NAME.icon | one short string | on the button | nothing |
|
|
266
|
+
| asks.NAME.readOnly | boolean | run on open, nothing to type | `readOnlyHint` |
|
|
267
|
+
| asks.NAME.destructive | boolean | ask once more | `destructiveHint` |
|
|
268
|
+
| asks.NAME.idempotent | boolean | offer a retry after silence | `idempotentHint` |
|
|
269
|
+
|
|
270
|
+
A look is not capability: it is never in a blueprint or a digest, Quo never
|
|
271
|
+
sees it, and a being without one renders and lists exactly as before. The
|
|
272
|
+
`look` ask itself is presentation and is neither a form nor a tool. A
|
|
273
|
+
carrier keeps each standing's look beside her asks in the notes, so acme's
|
|
274
|
+
section on the human's page is painted as acme asked, inside that section
|
|
275
|
+
and nowhere else. No token can carry a URL, a stylesheet or code, so a far
|
|
276
|
+
being cannot paint over the page or reach a server through it. A being who
|
|
277
|
+
marks an ask read-only and then writes has lied to her own page, and nothing
|
|
278
|
+
enforces it, the way nothing enforces that her empty ask is safe to repeat.
|
|
279
|
+
|
|
214
280
|
## Asks only
|
|
215
281
|
|
|
216
282
|
There is no listening in Quo, and none under `packages/dock/`. A being who wants
|
|
@@ -219,8 +285,8 @@ whenever something happens. An event is an ask in the other direction, and
|
|
|
219
285
|
it is the same act next door and across planets; only latency and
|
|
220
286
|
reachability change. It follows that two beings never share memory, not
|
|
221
287
|
even in one ward: a callback, a stream or a bus between them is a link that
|
|
222
|
-
breaks the day one of them
|
|
223
|
-
when a being
|
|
288
|
+
breaks the day one of them is somewhere else, and Quo exists so that nothing
|
|
289
|
+
breaks when she is. Only a ward moves; a being is placed once, at boot.
|
|
224
290
|
|
|
225
291
|
Two consequences every side obeys:
|
|
226
292
|
|
|
@@ -258,20 +324,26 @@ thin as one call. Every route ends the same way:
|
|
|
258
324
|
1. The route authenticates by its own means. Password, passkey, OAuth, a
|
|
259
325
|
bearer token from a client, a nonce from a local process.
|
|
260
326
|
2. The route asks the front desk `device({ proof })` and receives an
|
|
261
|
-
invitation.
|
|
327
|
+
invitation. Which desk is which world: a harbor holds many wards, and
|
|
328
|
+
the human chooses the world at the allow page when there is more than
|
|
329
|
+
one; the grant remembers it.
|
|
262
330
|
3. The route hands the invitation to the avatar it is about to boot, or to
|
|
263
331
|
the one already booted for this client identity if it is a reconnect, in
|
|
264
332
|
which case nothing is handed and the existing standing is used.
|
|
265
333
|
4. The avatar knocks. The heir dies. From here there is no token anywhere.
|
|
266
334
|
|
|
267
|
-
The human decides
|
|
268
|
-
the identity: whether this device may **wake** the human's other devices
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
wake
|
|
273
|
-
|
|
274
|
-
the
|
|
335
|
+
The human decides two more things at step 1, on the same page that names
|
|
336
|
+
the identity: whether this device may **wake** the human's other devices,
|
|
337
|
+
and whether it may **reach** what she holds. Each word travels with the
|
|
338
|
+
proof, the user being writes it as a note on the occupant she mints, and her
|
|
339
|
+
gate reads the note: `push` is shown to the desk and to a device with the
|
|
340
|
+
`wake` note, and her standings are carried for a device with the `reach`
|
|
341
|
+
note, and for nobody else. A device that may wake can hand an agent an event
|
|
342
|
+
through the user being, and she sees who did; a device that may reach sees
|
|
343
|
+
acme on her, and a model sees acme only because the human said it may. A tab
|
|
344
|
+
opened with the owner password reaches, since the owner opened it. The notes
|
|
345
|
+
are written when the occupant is minted; to change one, remove the occupant
|
|
346
|
+
and allow again, because reconnect mints nothing.
|
|
275
347
|
|
|
276
348
|
Tokens, cookies and grants exist for the length of step 1 and are never
|
|
277
349
|
stored on a Quo side. Revocation is `occupants.remove(id)` on the user being:
|
|
@@ -292,7 +364,7 @@ quo pilot [--dir D] [--via S] the owner pilot over stdio: the ward's describe as
|
|
|
292
364
|
quo side [--dir D] --as NAME an avatar over stdio for a local client; NAME is the client identity
|
|
293
365
|
quo run [--dir D] --as NAME --url URL --model NAME a human at a terminal talking to a model through an avatar; a line in, the final text out
|
|
294
366
|
quo census [--dir D] print the census once and exit
|
|
295
|
-
quo boot|public|invite|knock|remove the
|
|
367
|
+
quo boot|public|invite|knock|remove|unboot the six owner asks from a shell, for scripts and for agents under cron; --via S sends them to another ward
|
|
296
368
|
quo reach <pk> <url> a hint for the harbor's directory: that world's ward lives at that quo. route
|
|
297
369
|
quo estate <dir> --domain D an estate folder of the shape every estate has: a droplet's quo directory, its unit and Caddyfile, a package on this dock
|
|
298
370
|
```
|
|
@@ -405,6 +477,8 @@ class bodies and placement, and reaches every piece through the dock.
|
|
|
405
477
|
| side | an avatar speaking to someone outside | screen, MCP server, runner, agent |
|
|
406
478
|
| proof | turning a credential into an invitation | one verifier, a daemon nonce, three kinds |
|
|
407
479
|
| envoy | a non-Quo surface as a being | none built: a name until its first user |
|
|
480
|
+
| carrier | a being showing her standings as her own | the user being; any estate being that extends it |
|
|
481
|
+
| look | how a being is presented, as values | read by the screen and the model side |
|
|
408
482
|
|
|
409
483
|
Two rules keep them general:
|
|
410
484
|
|
|
@@ -474,12 +548,20 @@ above or sending us back to fix it:
|
|
|
474
548
|
becomes an invitation the user being minted, the avatar knocks and takes
|
|
475
549
|
her, and her tool list equals the user being's describe for her;
|
|
476
550
|
reconnect mints nothing; two clients see two tool lists; a push is a
|
|
477
|
-
reverse ask that reaches the side;
|
|
551
|
+
reverse ask that reaches the side; revocation is her sixth ask,
|
|
552
|
+
`forget({ client })`, the desk's alone. The ids that
|
|
478
553
|
carry the conventions: the desk is the occupant the root named `desk`
|
|
479
554
|
at the user being; the desk holds each user being as `user:<name>`; the
|
|
480
555
|
user being holds each device's way back as `to:<client>`; the avatar
|
|
481
556
|
holds the user being as `user` and is pushed to as `user:push`, two ids
|
|
482
557
|
because standings and occupants share one namespace.
|
|
558
|
+
A device is two relations, because a relation in Quo is one direction:
|
|
559
|
+
its way in, the occupant, and her way back, `to:<client>`. Neither
|
|
560
|
+
implies the other, and the door will never join them. She joins them
|
|
561
|
+
herself, as her own rule and not Quo's: she does not push to a device
|
|
562
|
+
she no longer admits, and `forget` drops both in one act, so no side
|
|
563
|
+
has to remember there were two. Before this a revoked device kept
|
|
564
|
+
receiving every push.
|
|
483
565
|
3. Done. The side in `packages/dock/beings/side.ts`: a function of the avatar
|
|
484
566
|
that runs until closed, and `word`, which tells the three words and the
|
|
485
567
|
object apart before any side renders them. The MCP server in
|
|
@@ -642,8 +724,7 @@ above or sending us back to fix it:
|
|
|
642
724
|
a terminal, the same pipe as `quo side` with a model named in the
|
|
643
725
|
hello, the daemon running the runner beside the avatar, a line in a
|
|
644
726
|
turn and the final text a line out, proven with a scripted endpoint
|
|
645
|
-
as a child process in `packages/dock/test/cli.test.ts`.
|
|
646
|
-
client identity the road lists is defined nowhere and was not built.
|
|
727
|
+
as a child process in `packages/dock/test/cli.test.ts`.
|
|
647
728
|
18. The edge terrain: Done. `packages/dock/harbor/edge/`, the core over a Durable
|
|
648
729
|
Object: its storage the store with the seed sealed under a platform
|
|
649
730
|
secret, the deployed worker the code, the object the lease; reached by
|
|
@@ -708,13 +789,41 @@ above or sending us back to fix it:
|
|
|
708
789
|
overwrite a package or take a name that is no domain, and its droplet
|
|
709
790
|
boots, `quo init` into it and a daemon answering the census.
|
|
710
791
|
|
|
792
|
+
23. The human side proper, in flight. What the twelfth sitting decided, in
|
|
793
|
+
the order it lands: **carrying**, `beings/carry.ts`, the user being on
|
|
794
|
+
it, a device seeing acme's asks on her under the `reach` note from the
|
|
795
|
+
exchange, proven in `test/human.test.ts` and `test/mcp.test.ts` on the
|
|
796
|
+
memory harbor with a shop that has a look; **the look**, `beings/look.ts`,
|
|
797
|
+
one closed vocabulary read by the screen as a section per standing under
|
|
798
|
+
its tokens and by the model side as titles and annotations, the `look`
|
|
799
|
+
ask neither a form nor a tool. Then **worlds**: a world is a ward with a
|
|
800
|
+
public being, its guest page is that being's describe rendered by the
|
|
801
|
+
screen, whatever class she is, `human/guest.ts`, and a form whose answer
|
|
802
|
+
is an invitation is the way in; worlds have addresses, `web./<ward>`,
|
|
803
|
+
`web./` the list, a hostname per world the proxy's line; a link is the
|
|
804
|
+
page plus the invitation in the fragment under the reserved key `quo`,
|
|
805
|
+
never in a query string, `beings/link.ts`; the tab is a browser of worlds,
|
|
806
|
+
one ward per joined world, a switcher over them, and a content security
|
|
807
|
+
policy on its shell; the avatar joins a being with no `hello` all the
|
|
808
|
+
same. Proven on the memory harbor with a door that is not a desk and in
|
|
809
|
+
Chromium against the desk. Then **the routes read again against the
|
|
810
|
+
same pieces**: a grant names the world the human allowed the client
|
|
811
|
+
into, the allow page offers the harbor's worlds when it has more than
|
|
812
|
+
one, a session is an identity's in a world, and revocation is per world;
|
|
813
|
+
the MCP endpoint stays one per harbor, `/mcp`, since the grant says
|
|
814
|
+
which world. Done. What the lab taught before any of it was
|
|
815
|
+
written: a standing's blueprint is empty after take until the empty ask
|
|
816
|
+
fills it, so a carrier refreshes on describe; and a dotted name is
|
|
817
|
+
refused by the model side, so a carried name takes a dash.
|
|
818
|
+
|
|
711
819
|
Steps 1 to 22 are in the tree and on the machines, and MCP is complete;
|
|
712
|
-
the documents and the folders agree up to step 22
|
|
713
|
-
|
|
820
|
+
the documents and the folders agree up to step 22, and step 23 is in flight
|
|
821
|
+
as its line says. `next.md` is where the next sitting starts.
|
|
714
822
|
|
|
715
823
|
## Glossary
|
|
716
824
|
|
|
717
825
|
- **avatar**: a being that is someone's voice from outside Quo.
|
|
826
|
+
- **carrier**: a being who shows her standings' asks as her own and forwards.
|
|
718
827
|
- **client identity**: what a credential exchange establishes; one avatar.
|
|
719
828
|
- **envoy**: a being whose methods call a non-Quo thing.
|
|
720
829
|
- **dock**: what every estate needs and nobody writes twice, `packages/dock/`,
|
|
@@ -725,6 +834,7 @@ today. `next.md` is where the next sitting starts.
|
|
|
725
834
|
- **front desk**: the being every fresh client meets; hands out invitations
|
|
726
835
|
minted by user beings.
|
|
727
836
|
- **gate**: `for(occupant, asker)`; the whole permission model.
|
|
837
|
+
- **look**: how a being is presented, as values; one optional ask.
|
|
728
838
|
- **owner pilot**: `quo pilot`, the model side over a ward's owner asks, reaching
|
|
729
839
|
the daemon's socket; with `--via`, a ward elsewhere through a standing.
|
|
730
840
|
- **daemon**: `quo serve`, the one process on a device that holds a harbor.
|
package/beings/side.ts
CHANGED
|
@@ -6,13 +6,22 @@
|
|
|
6
6
|
// a screen, an MCP server, a runner, an event-driven agent. None knows
|
|
7
7
|
// another exists, and none of this file names a platform.
|
|
8
8
|
import { isSilence, isUnreached, isWord, wordOf } from '@quo-systems/quo';
|
|
9
|
-
import type { Answer, Json, JsonObject, WordName } from '@quo-systems/quo';
|
|
9
|
+
import type { Answer, Blueprint, Json, JsonObject, Wanted, WordName } from '@quo-systems/quo';
|
|
10
10
|
import type { Avatar } from './avatar.ts';
|
|
11
11
|
|
|
12
12
|
// A side, running: it holds the avatar until closed.
|
|
13
13
|
export type Serving = { close(): Promise<void> };
|
|
14
14
|
export type Side = (avatar: Avatar) => Promise<Serving>;
|
|
15
15
|
|
|
16
|
+
// What a side speaks for: the empty ask and a named one, and the ears a
|
|
17
|
+
// push reaches. An avatar on her one standing is one; the owner's asks on a
|
|
18
|
+
// ward are another; a guest at a world's public being is a third.
|
|
19
|
+
export type Subject = {
|
|
20
|
+
tools(): Promise<Blueprint | { error: string }>;
|
|
21
|
+
call(name: string, args?: JsonObject, wanted?: Wanted): Promise<Answer>;
|
|
22
|
+
ears: Set<(object: JsonObject) => void>;
|
|
23
|
+
};
|
|
24
|
+
|
|
16
25
|
// The three words for "no object", and the object itself, as every side must
|
|
17
26
|
// tell them apart before rendering. An error object is an ordinary answer
|
|
18
27
|
// that the side may mark; silence and unreached are not objects at all.
|