@quo-systems/dock 0.2.0 → 0.2.2
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 +9 -3
- package/beings/carry.ts +101 -0
- package/beings/desk.ts +2 -2
- package/beings/index.ts +3 -1
- package/beings/link.ts +54 -0
- package/beings/look.ts +96 -0
- package/beings/quo-dock.md +163 -277
- package/beings/side.ts +10 -1
- package/beings/user.ts +67 -13
- package/cli/daemon.ts +58 -251
- package/cli/http.ts +70 -0
- package/cli/quo.ts +13 -5
- package/dist/beings/avatar.js +10 -3
- 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 +3 -1
- 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 +29 -3
- package/dist/beings/user.js +71 -14
- package/dist/cli/daemon.d.ts +7 -19
- package/dist/cli/daemon.js +51 -240
- package/dist/cli/http.d.ts +17 -0
- package/dist/cli/http.js +60 -0
- package/dist/cli/quo.js +15 -5
- package/dist/harbor/quo.d.ts +4 -0
- package/dist/harbor/quo.js +53 -0
- package/dist/human/door.d.ts +5 -0
- package/dist/human/door.js +19 -0
- 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 +26 -6
- package/dist/human/tab.d.ts +5 -0
- package/dist/human/tab.js +161 -43
- package/dist/human/web.d.ts +9 -0
- package/dist/human/web.js +95 -0
- package/dist/human/worlds.d.ts +10 -0
- package/dist/human/worlds.js +38 -0
- 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/route.d.ts +12 -0
- package/dist/mcp/route.js +34 -0
- 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 +67 -30
- package/harbor/quo.ts +67 -0
- package/human/door.ts +26 -0
- package/human/guest.ts +26 -0
- package/human/html.ts +58 -10
- package/human/quo-human.md +158 -67
- package/human/screen.ts +34 -8
- package/human/tab.ts +193 -53
- package/human/web.ts +123 -0
- package/human/worlds.ts +52 -0
- package/mcp/http.ts +10 -9
- package/mcp/oauth.ts +20 -17
- package/mcp/pilot.ts +1 -1
- package/mcp/quo-mcp.md +20 -2
- package/mcp/route.ts +39 -0
- package/mcp/server.ts +37 -18
- package/mcp/web/exchange.ts +23 -9
- package/package.json +2 -2
package/cli/quo.ts
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
// quo serve [--dir D] [--http PORT] [--dial URL] the daemon: harbor from disk, the two sockets, the HTTP door on loopback, a socket held to URL
|
|
9
9
|
// quo census [--dir D] [--ward W] [--via S] the empty ask: pk and every being
|
|
10
10
|
// quo boot [--dir D] [--ward W] [--via S] <json> { key, class, public? }
|
|
11
|
-
// quo invite [--dir D] [--ward W] [--via S] <json> { being, id }
|
|
11
|
+
// quo invite [--dir D] [--ward W] [--via S] <json> { being, id }; with routes.json naming web., the answer carries the link
|
|
12
12
|
// quo knock [--dir D] [--ward W] [--via S] <json> { being | { boot, key }, id, invitation, method?, args?, wanted? }
|
|
13
13
|
// quo remove [--dir D] [--ward W] [--via S] <json> { being, id }: a relation out of a being; on the ward pk, an owner
|
|
14
|
+
// quo unboot [--dir D] [--ward W] [--via S] <json> { being }: a being out of the ward, with every relation she holds
|
|
14
15
|
// quo side [--dir D] [--ward W] --as NAME an avatar over stdio for a local MCP client; NAME is the client identity
|
|
15
16
|
// quo run [--dir D] [--ward W] --as NAME --url URL --model NAME [--turns N] a human at a terminal talking to a model through an avatar: a line in, the model's final text out
|
|
16
17
|
// quo pilot [--dir D] [--ward W] [--via S] the owner pilot over stdio: census, boot, invite, knock, remove
|
|
@@ -29,12 +30,13 @@ import { homedir, userInfo } from 'node:os';
|
|
|
29
30
|
import { join } from 'node:path';
|
|
30
31
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
31
32
|
import { DiskHarbor } from '../harbor/disk.ts';
|
|
32
|
-
import { serve } from './daemon.ts';
|
|
33
|
+
import { serve, readRoutes } from './daemon.ts';
|
|
33
34
|
import { ask, side, reach, type Run } from './client.ts';
|
|
35
|
+
import { link, isInvitation } from '../beings/link.ts';
|
|
34
36
|
import { pilotSide } from '../mcp/pilot.ts';
|
|
35
37
|
import { estate } from './estate.ts';
|
|
36
38
|
|
|
37
|
-
const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove']);
|
|
39
|
+
const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove', 'unboot']);
|
|
38
40
|
|
|
39
41
|
function parse(argv: string[]) {
|
|
40
42
|
const flags: Record<string, string> = {};
|
|
@@ -115,13 +117,19 @@ async function main(argv: string[]): Promise<number> {
|
|
|
115
117
|
if (cmd === 'census' || (cmd !== undefined && OWNER_ASKS.has(cmd))) {
|
|
116
118
|
const args = json ? (JSON.parse(json) as Record<string, unknown>) : {};
|
|
117
119
|
const out = await ask(dir, cmd === 'census' ? undefined : cmd, args, ward, via);
|
|
118
|
-
|
|
120
|
+
// an invitation minted here is handed on as a link when the harbor's
|
|
121
|
+
// routes name a web. origin: the world's page with the invitation in
|
|
122
|
+
// the fragment, one string to send to the person it is for
|
|
123
|
+
const minted = cmd === 'invite' && !via && 'result' in out && isInvitation(out.result) ? out.result : null;
|
|
124
|
+
const routes = minted ? await readRoutes(dir) : null;
|
|
125
|
+
if (minted && routes) print({ ...minted, link: link(`${routes.web}/${encodeURIComponent(ward)}`, minted) });
|
|
126
|
+
else print('error' in out ? { error: out.error } : out.result);
|
|
119
127
|
// an error object the ward answered is an ordinary answer, and still a
|
|
120
128
|
// non-zero exit, so a script can tell a boot that happened from one that did not.
|
|
121
129
|
const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
|
|
122
130
|
return failed ? 1 : 0;
|
|
123
131
|
}
|
|
124
|
-
console.error('usage: quo init|serve|census|boot|public|invite|knock|remove|side|run|pilot|reach|estate [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
|
|
132
|
+
console.error('usage: quo init|serve|census|boot|public|invite|knock|remove|unboot|side|run|pilot|reach|estate [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
|
|
125
133
|
return 2;
|
|
126
134
|
}
|
|
127
135
|
|
package/dist/beings/avatar.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// exactly one standing, `user`, and everything her side asks goes through
|
|
5
5
|
// it; the user being reaches her only to push, and she hands the push to
|
|
6
6
|
// every side she has. A side is in-process with her, holds the object, and
|
|
7
|
-
// calls the
|
|
7
|
+
// calls the four methods below: one of the two ways in, `enter` behind a
|
|
8
|
+
// route's proof or `join` with an invitation, then `tools` and `call`.
|
|
9
|
+
// Nothing of a side is in her cells.
|
|
8
10
|
import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
9
11
|
// Her one standing, and the id under which the user being pushes to her.
|
|
10
12
|
// Two ids for one far being, because standings and occupants share one
|
|
@@ -47,8 +49,11 @@ export class Avatar extends Being {
|
|
|
47
49
|
return inv ?? { error: 'no invitation' };
|
|
48
50
|
return this.join(inv);
|
|
49
51
|
}
|
|
50
|
-
// Knock with an invitation, hand the
|
|
51
|
-
// and take her as `user`. The only moment a standing is born here.
|
|
52
|
+
// Knock with an invitation, hand the far being one back so she can push,
|
|
53
|
+
// and take her as `user`. The only moment a standing is born here. A being
|
|
54
|
+
// who answers the hello with an error object is a being who answered: she
|
|
55
|
+
// is taken all the same, and the way back she did not take is dropped, so
|
|
56
|
+
// any world's public being may admit a guest, hello or not.
|
|
52
57
|
async join(invitation) {
|
|
53
58
|
if (this.standings[USER])
|
|
54
59
|
return this.tools();
|
|
@@ -60,6 +65,8 @@ export class Avatar extends Being {
|
|
|
60
65
|
this.occupants.remove(PUSHER);
|
|
61
66
|
return { error: isSilence(out) ? 'silence' : wordOf(out) };
|
|
62
67
|
}
|
|
68
|
+
if (out !== null && typeof out === 'object' && !Array.isArray(out) && 'error' in out)
|
|
69
|
+
this.occupants.remove(PUSHER);
|
|
63
70
|
await this.take(USER, invitation);
|
|
64
71
|
return this.tools();
|
|
65
72
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Being } from '@quo-systems/quo';
|
|
2
|
+
import type { Asker, Blueprint, JsonObject, OccupantRecord, Reply } from '@quo-systems/quo';
|
|
3
|
+
export declare class Carrier extends Being {
|
|
4
|
+
static carries(_occupant: OccupantRecord | undefined, _asker: Asker): boolean;
|
|
5
|
+
static hidden(id: string): boolean;
|
|
6
|
+
private get carried();
|
|
7
|
+
private get looks();
|
|
8
|
+
blueprint(asker: Asker): Promise<Blueprint>;
|
|
9
|
+
answer(asker: Asker, method?: string, args?: JsonObject): Promise<Reply>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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 { sanitise } from './look.js';
|
|
23
|
+
export class Carrier extends Being {
|
|
24
|
+
// Who sees what she carries. Nobody, until a subclass says who.
|
|
25
|
+
static carries(_occupant, _asker) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
// Standings she never carries: a way back to a device is one, by the
|
|
29
|
+
// dock's own convention, and a subclass may name more.
|
|
30
|
+
static hidden(id) {
|
|
31
|
+
return id.startsWith('to:');
|
|
32
|
+
}
|
|
33
|
+
get carried() {
|
|
34
|
+
return (this.cells.carried ??= {});
|
|
35
|
+
}
|
|
36
|
+
get looks() {
|
|
37
|
+
return (this.cells.looks ??= {});
|
|
38
|
+
}
|
|
39
|
+
// Her describe with her standings carried into it, for an asker who may
|
|
40
|
+
// see them. Every other asker gets her describe alone.
|
|
41
|
+
async blueprint(asker) {
|
|
42
|
+
const C = this.constructor;
|
|
43
|
+
const bp = this.describe(asker);
|
|
44
|
+
if (!C.carries(this.occupant(asker), asker))
|
|
45
|
+
return bp;
|
|
46
|
+
const own = new Set(bp.asks.map((a) => a.name));
|
|
47
|
+
const carried = {};
|
|
48
|
+
const groups = {};
|
|
49
|
+
for (const [id, rec] of Object.entries(this.cells.standings)) {
|
|
50
|
+
if (C.hidden(id))
|
|
51
|
+
continue;
|
|
52
|
+
const st = this.standings[id];
|
|
53
|
+
if (!st)
|
|
54
|
+
continue;
|
|
55
|
+
if (!rec.blueprint || rec.seen !== rec.digest)
|
|
56
|
+
await st.ask(); // her ward writes the record
|
|
57
|
+
if (!rec.blueprint)
|
|
58
|
+
continue;
|
|
59
|
+
const names = [];
|
|
60
|
+
for (const a of rec.blueprint.asks) {
|
|
61
|
+
if (a.name === 'look')
|
|
62
|
+
continue;
|
|
63
|
+
const name = `${id}-${a.name}`;
|
|
64
|
+
if (own.has(name))
|
|
65
|
+
continue;
|
|
66
|
+
carried[name] = { id, ask: a.name };
|
|
67
|
+
names.push(name);
|
|
68
|
+
bp.asks.push({ ...a, name });
|
|
69
|
+
}
|
|
70
|
+
const group = { 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))
|
|
82
|
+
if (!(id in groups))
|
|
83
|
+
delete this.looks[id];
|
|
84
|
+
this.cells.carried = carried;
|
|
85
|
+
const notes = bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? bp.notes : {};
|
|
86
|
+
return { asks: bp.asks, notes: { ...notes, standings: groups } };
|
|
87
|
+
}
|
|
88
|
+
async answer(asker, method, args = {}) {
|
|
89
|
+
if (method === undefined)
|
|
90
|
+
return this.blueprint(asker);
|
|
91
|
+
const C = this.constructor;
|
|
92
|
+
const to = Object.hasOwn(this.carried, method) ? this.carried[method] : undefined;
|
|
93
|
+
if (to && C.carries(this.occupant(asker), asker)) {
|
|
94
|
+
const st = this.standings[to.id];
|
|
95
|
+
if (!st)
|
|
96
|
+
return { error: 'unknown ask' };
|
|
97
|
+
const out = await st.ask(to.ask, args);
|
|
98
|
+
if (isSilence(out))
|
|
99
|
+
return out;
|
|
100
|
+
if (isWord(out))
|
|
101
|
+
return { error: wordOf(out) };
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
return super.answer(asker, method, args);
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/beings/desk.d.ts
CHANGED
package/dist/beings/desk.js
CHANGED
|
@@ -33,7 +33,7 @@ export class Desk extends Being {
|
|
|
33
33
|
const user = this.standings[`user:${who.user}`];
|
|
34
34
|
if (!user)
|
|
35
35
|
return { error: 'no such user' };
|
|
36
|
-
const inv = await user.ask('device', who.wake === true ? {
|
|
36
|
+
const inv = await user.ask('device', { client: who.client, ...(who.wake === true ? { wake: true } : {}), ...(who.reach === true ? { reach: true } : {}) });
|
|
37
37
|
if (isSilence(inv))
|
|
38
38
|
return { error: 'silence' };
|
|
39
39
|
if (isWord(inv))
|
package/dist/beings/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { User, DESK } from './user.ts';
|
|
2
2
|
export { Desk, type Proof, type Verified, type Verifier } from './desk.ts';
|
|
3
3
|
export { Avatar, USER, PUSHER } from './avatar.ts';
|
|
4
|
+
export { Carrier } from './carry.ts';
|
|
5
|
+
export { sanitise, hint, hintFor, groups, type Look, type Hint } from './look.ts';
|
package/dist/beings/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The beings every side shares. See quo-
|
|
2
|
+
// The beings every side shares. See quo-dock.md, the trunk.
|
|
3
3
|
export { User, DESK } from './user.js';
|
|
4
4
|
export { Desk } from './desk.js';
|
|
5
5
|
export { Avatar, USER, PUSHER } from './avatar.js';
|
|
6
|
+
export { Carrier } from './carry.js';
|
|
7
|
+
export { sanitise, hint, hintFor, groups } from './look.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Invitation } from '@quo-systems/quo';
|
|
2
|
+
export declare const KEY = "quo";
|
|
3
|
+
export declare function format(inv: Invitation): string;
|
|
4
|
+
export declare function parse(hash: string): Invitation | null;
|
|
5
|
+
export declare function strip(hash: string): string;
|
|
6
|
+
export declare function link(page: string, inv: Invitation): string;
|
|
7
|
+
export declare function isInvitation(v: unknown): v is Invitation;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const KEY = 'quo';
|
|
2
|
+
const HEX = (n) => `[0-9a-f]{${n}}`;
|
|
3
|
+
const FULL = new RegExp(`^(${HEX(128)})\\.(${HEX(64)})\\.(${HEX(64)})$`);
|
|
4
|
+
const PUBLIC = new RegExp(`^${HEX(128)}$`);
|
|
5
|
+
export function format(inv) {
|
|
6
|
+
return inv.heir && inv.secret ? `${inv.ward}.${inv.heir}.${inv.secret}` : inv.ward;
|
|
7
|
+
}
|
|
8
|
+
// The invitation in a fragment, or null. Anything else in the fragment is
|
|
9
|
+
// the page's own and is left alone.
|
|
10
|
+
export function parse(hash) {
|
|
11
|
+
const m = /(?:^#|&)quo=([^&]+)/.exec(hash.startsWith('#') ? hash : `#${hash}`);
|
|
12
|
+
if (!m)
|
|
13
|
+
return null;
|
|
14
|
+
const v = decodeURIComponent(m[1]);
|
|
15
|
+
const full = FULL.exec(v);
|
|
16
|
+
if (full)
|
|
17
|
+
return { ward: full[1], heir: full[2], secret: full[3] };
|
|
18
|
+
return PUBLIC.test(v) ? { ward: v } : null;
|
|
19
|
+
}
|
|
20
|
+
// The fragment without the invitation, so the page keeps whatever else it
|
|
21
|
+
// put there and the capability is gone from the address bar.
|
|
22
|
+
export function strip(hash) {
|
|
23
|
+
const rest = (hash.startsWith('#') ? hash.slice(1) : hash)
|
|
24
|
+
.split('&')
|
|
25
|
+
.filter((p) => !p.startsWith(`${KEY}=`))
|
|
26
|
+
.join('&');
|
|
27
|
+
return rest ? `#${rest}` : '';
|
|
28
|
+
}
|
|
29
|
+
export function link(page, inv) {
|
|
30
|
+
return `${page}#${KEY}=${format(inv)}`;
|
|
31
|
+
}
|
|
32
|
+
// Whether an answer is an invitation: a ward pk, and a heir with its secret
|
|
33
|
+
// or neither. What a guest's form answers with to be let in.
|
|
34
|
+
export function isInvitation(v) {
|
|
35
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v))
|
|
36
|
+
return false;
|
|
37
|
+
const o = v;
|
|
38
|
+
if (typeof o.ward !== 'string' || !PUBLIC.test(o.ward))
|
|
39
|
+
return false;
|
|
40
|
+
const heir = typeof o.heir === 'string', secret = typeof o.secret === 'string';
|
|
41
|
+
return (heir && secret) || (!heir && !secret && !('heir' in o) && !('secret' in o));
|
|
42
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Blueprint, Json } from '@quo-systems/quo';
|
|
2
|
+
export type Hint = {
|
|
3
|
+
title?: string;
|
|
4
|
+
readOnly?: boolean;
|
|
5
|
+
destructive?: boolean;
|
|
6
|
+
idempotent?: boolean;
|
|
7
|
+
icon?: string;
|
|
8
|
+
};
|
|
9
|
+
export type Look = {
|
|
10
|
+
name?: string;
|
|
11
|
+
logo?: string;
|
|
12
|
+
accent?: string;
|
|
13
|
+
background?: string;
|
|
14
|
+
foreground?: string;
|
|
15
|
+
font?: string;
|
|
16
|
+
radius?: number;
|
|
17
|
+
order?: string[];
|
|
18
|
+
asks?: Record<string, Hint>;
|
|
19
|
+
};
|
|
20
|
+
export declare function sanitise(v: Json | undefined): Look;
|
|
21
|
+
export declare const hint: (l: Look | undefined, name: string) => Hint;
|
|
22
|
+
export type Group = {
|
|
23
|
+
asks?: string[];
|
|
24
|
+
look?: Json;
|
|
25
|
+
};
|
|
26
|
+
export declare function groups(bp: Blueprint | null): Record<string, Group>;
|
|
27
|
+
export declare function hintFor(bp: Blueprint | null, mine: Look, name: string): Hint;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const COLOUR = /^#[0-9a-fA-F]{3,8}$/;
|
|
2
|
+
const FONT = /^[\w\s,'"-]{1,80}$/;
|
|
3
|
+
const LOGO = /^data:image\/(svg\+xml|png|jpeg|webp);base64,[A-Za-z0-9+/=]{1,40000}$/;
|
|
4
|
+
const NAME = /^[^<>&]{1,60}$/;
|
|
5
|
+
const ICON = /^[^<>&"']{1,8}$/;
|
|
6
|
+
const str = (v, re) => (typeof v === 'string' && re.test(v) ? v : undefined);
|
|
7
|
+
// The look as a side may use it: every token held to its shape, everything
|
|
8
|
+
// else gone. A value that is not an object is no look at all.
|
|
9
|
+
export function sanitise(v) {
|
|
10
|
+
if (v === null || v === undefined || typeof v !== 'object' || Array.isArray(v))
|
|
11
|
+
return {};
|
|
12
|
+
const l = {};
|
|
13
|
+
const name = str(v.name, NAME);
|
|
14
|
+
if (name !== undefined)
|
|
15
|
+
l.name = name;
|
|
16
|
+
const logo = str(v.logo, LOGO);
|
|
17
|
+
if (logo !== undefined)
|
|
18
|
+
l.logo = logo;
|
|
19
|
+
for (const k of ['accent', 'background', 'foreground']) {
|
|
20
|
+
const c = str(v[k], COLOUR);
|
|
21
|
+
if (c !== undefined)
|
|
22
|
+
l[k] = c;
|
|
23
|
+
}
|
|
24
|
+
const font = str(v.font, FONT);
|
|
25
|
+
if (font !== undefined)
|
|
26
|
+
l.font = font;
|
|
27
|
+
if (typeof v.radius === 'number' && Number.isFinite(v.radius) && v.radius >= 0 && v.radius <= 40)
|
|
28
|
+
l.radius = Math.round(v.radius);
|
|
29
|
+
if (Array.isArray(v.order))
|
|
30
|
+
l.order = v.order.filter((x) => typeof x === 'string');
|
|
31
|
+
if (v.asks !== null && typeof v.asks === 'object' && !Array.isArray(v.asks)) {
|
|
32
|
+
const asks = {};
|
|
33
|
+
for (const [ask, h] of Object.entries(v.asks)) {
|
|
34
|
+
if (h === null || typeof h !== 'object' || Array.isArray(h))
|
|
35
|
+
continue;
|
|
36
|
+
const hint = {};
|
|
37
|
+
const title = str(h.title, NAME);
|
|
38
|
+
if (title !== undefined)
|
|
39
|
+
hint.title = title;
|
|
40
|
+
const icon = str(h.icon, ICON);
|
|
41
|
+
if (icon !== undefined)
|
|
42
|
+
hint.icon = icon;
|
|
43
|
+
for (const k of ['readOnly', 'destructive', 'idempotent'])
|
|
44
|
+
if (typeof h[k] === 'boolean')
|
|
45
|
+
hint[k] = h[k];
|
|
46
|
+
if (Object.keys(hint).length)
|
|
47
|
+
asks[ask] = hint;
|
|
48
|
+
}
|
|
49
|
+
if (Object.keys(asks).length)
|
|
50
|
+
l.asks = asks;
|
|
51
|
+
}
|
|
52
|
+
return l;
|
|
53
|
+
}
|
|
54
|
+
// The one hint a side reads first: whether this ask is in the look at all.
|
|
55
|
+
export const hint = (l, name) => l?.asks?.[name] ?? {};
|
|
56
|
+
export function groups(bp) {
|
|
57
|
+
const n = bp?.notes;
|
|
58
|
+
const g = n !== null && n !== undefined && typeof n === 'object' && !Array.isArray(n) ? n.standings : undefined;
|
|
59
|
+
return g !== null && g !== undefined && typeof g === 'object' && !Array.isArray(g) ? g : {};
|
|
60
|
+
}
|
|
61
|
+
// The hint for one ask, hers or a standing's: a carried ask is looked up
|
|
62
|
+
// under its bare name in that standing's look.
|
|
63
|
+
export function hintFor(bp, mine, name) {
|
|
64
|
+
for (const [id, g] of Object.entries(groups(bp))) {
|
|
65
|
+
if (!g.asks?.includes(name))
|
|
66
|
+
continue;
|
|
67
|
+
const prefix = `${id}-`;
|
|
68
|
+
return hint(sanitise(g.look), name.startsWith(prefix) ? name.slice(prefix.length) : name);
|
|
69
|
+
}
|
|
70
|
+
return hint(mine, name);
|
|
71
|
+
}
|
package/dist/beings/side.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import type { Answer, Json, JsonObject, WordName } from '@quo-systems/quo';
|
|
1
|
+
import type { Answer, Blueprint, Json, JsonObject, Wanted, WordName } from '@quo-systems/quo';
|
|
2
2
|
import type { Avatar } from './avatar.ts';
|
|
3
3
|
export type Serving = {
|
|
4
4
|
close(): Promise<void>;
|
|
5
5
|
};
|
|
6
6
|
export type Side = (avatar: Avatar) => Promise<Serving>;
|
|
7
|
+
export type Subject = {
|
|
8
|
+
tools(): Promise<Blueprint | {
|
|
9
|
+
error: string;
|
|
10
|
+
}>;
|
|
11
|
+
call(name: string, args?: JsonObject, wanted?: Wanted): Promise<Answer>;
|
|
12
|
+
ears: Set<(object: JsonObject) => void>;
|
|
13
|
+
};
|
|
7
14
|
export type Word = {
|
|
8
15
|
word: 'object';
|
|
9
16
|
value: Json;
|
package/dist/beings/user.d.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import type { Asker, Invitation, JsonObject, OccupantRecord } from '@quo-systems/quo';
|
|
2
|
+
import { Carrier } from './carry.ts';
|
|
3
3
|
export declare const DESK = "desk";
|
|
4
4
|
declare const isDesk: (occ: OccupantRecord | undefined) => boolean;
|
|
5
5
|
declare const isDevice: (occ: OccupantRecord | undefined) => boolean;
|
|
6
6
|
declare const mayWake: (occ: OccupantRecord | undefined) => boolean;
|
|
7
|
-
export declare class User extends
|
|
7
|
+
export declare class User extends Carrier {
|
|
8
|
+
static carries(occ: OccupantRecord | undefined): boolean;
|
|
8
9
|
static cells: {
|
|
9
10
|
name: string;
|
|
10
11
|
reports: JsonObject[];
|
|
12
|
+
ways: Record<string, Invitation>;
|
|
11
13
|
};
|
|
12
14
|
static asks: {
|
|
13
15
|
hello: {
|
|
@@ -39,6 +41,9 @@ export declare class User extends Being {
|
|
|
39
41
|
wake: {
|
|
40
42
|
type: string;
|
|
41
43
|
};
|
|
44
|
+
reach: {
|
|
45
|
+
type: string;
|
|
46
|
+
};
|
|
42
47
|
};
|
|
43
48
|
required: string[];
|
|
44
49
|
};
|
|
@@ -67,6 +72,19 @@ export declare class User extends Being {
|
|
|
67
72
|
};
|
|
68
73
|
for: (occ: OccupantRecord | undefined) => boolean;
|
|
69
74
|
};
|
|
75
|
+
forget: {
|
|
76
|
+
description: string;
|
|
77
|
+
input: {
|
|
78
|
+
type: string;
|
|
79
|
+
properties: {
|
|
80
|
+
client: {
|
|
81
|
+
type: string;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
required: string[];
|
|
85
|
+
};
|
|
86
|
+
for: typeof isDesk;
|
|
87
|
+
};
|
|
70
88
|
report: {
|
|
71
89
|
description: string;
|
|
72
90
|
input: {
|
|
@@ -86,6 +104,7 @@ export declare class User extends Being {
|
|
|
86
104
|
welcome: string | null;
|
|
87
105
|
name: import("@quo-systems/quo").Json;
|
|
88
106
|
}>;
|
|
107
|
+
wayBack(id: string, inv: Invitation): Promise<boolean>;
|
|
89
108
|
whoami(_args: JsonObject, asker: Asker): {
|
|
90
109
|
id: string | null;
|
|
91
110
|
client: string | null;
|
|
@@ -98,6 +117,13 @@ export declare class User extends Being {
|
|
|
98
117
|
error?: undefined;
|
|
99
118
|
pushed: import("@quo-systems/quo").Json;
|
|
100
119
|
}>;
|
|
120
|
+
forget(args: JsonObject): {
|
|
121
|
+
error: string;
|
|
122
|
+
forgot?: undefined;
|
|
123
|
+
} | {
|
|
124
|
+
error?: undefined;
|
|
125
|
+
forgot: string;
|
|
126
|
+
};
|
|
101
127
|
chores(): {
|
|
102
128
|
chores: string[];
|
|
103
129
|
};
|
package/dist/beings/user.js
CHANGED
|
@@ -5,40 +5,74 @@
|
|
|
5
5
|
// special only by the id the root chose for them: `desk`, the front desk,
|
|
6
6
|
// who may ask her to invite a device; and each device, whose id is the
|
|
7
7
|
// client identity the desk established.
|
|
8
|
-
import {
|
|
8
|
+
import { isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
9
|
+
import { Carrier } from './carry.js';
|
|
9
10
|
export const DESK = 'desk';
|
|
10
11
|
const isDesk = (occ) => occ?.id === DESK;
|
|
11
12
|
const isDevice = (occ) => occ !== undefined && occ.id !== DESK;
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
// A device is known by its client identity. The desk writes it as a note
|
|
14
|
+
// when it asks her to mint; the root, minting on her directly, writes
|
|
15
|
+
// nothing, and then the id the root chose is the identity.
|
|
16
|
+
const rootMinted = (occ) => isDevice(occ) && occ?.notes.client === undefined;
|
|
17
|
+
const client = (occ) => (typeof occ?.notes.client === 'string' ? occ.notes.client : rootMinted(occ) ? occ.id : null);
|
|
18
|
+
// A device the human allowed to wake her other devices: the note says so,
|
|
19
|
+
// or the root minted it, and the root's word needs no note.
|
|
20
|
+
const mayWake = (occ) => isDesk(occ) || occ?.notes.wake === true || rootMinted(occ);
|
|
21
|
+
// She carries her standings, acme, the calendar, the house, for a device the
|
|
22
|
+
// human allowed to reach them at the exchange: the note says so. A device
|
|
23
|
+
// without the note sees her own asks alone, and a model sees acme only
|
|
24
|
+
// because the human said it may. A device the root minted reaches: the root
|
|
25
|
+
// is the owner of everything she holds and chose to mint it.
|
|
26
|
+
export class User extends Carrier {
|
|
27
|
+
static carries(occ) {
|
|
28
|
+
return isDevice(occ) && (occ?.notes.reach === true || rootMinted(occ));
|
|
29
|
+
}
|
|
30
|
+
// `ways` holds a device's way back that could not be taken when it was
|
|
31
|
+
// handed over: the knock was unreached, a tab whose pk the far harbor had
|
|
32
|
+
// not bound yet, a phone in a tunnel. Nothing delivered spends nothing,
|
|
33
|
+
// so the invitation is tried again at the next push.
|
|
34
|
+
static cells = { name: '', reports: [], ways: {} };
|
|
17
35
|
static asks = {
|
|
18
36
|
hello: { description: 'say hello, and hand back an invitation so she can reach you', input: { type: 'object', properties: { invitation: { type: 'object' } } } },
|
|
19
37
|
whoami: { description: 'who she thinks you are', input: { type: 'object' }, for: isDevice },
|
|
20
|
-
device: { description: 'mint an invitation for a device', input: { type: 'object', properties: { client: { type: 'string' }, wake: { type: 'boolean' } }, required: ['client'] }, for: isDesk },
|
|
38
|
+
device: { description: 'mint an invitation for a device', input: { type: 'object', properties: { client: { type: 'string' }, wake: { type: 'boolean' }, reach: { type: 'boolean' } }, required: ['client'] }, for: isDesk },
|
|
21
39
|
push: { description: 'push an object to a device: wake it with an event', input: { type: 'object', properties: { client: { type: 'string' }, object: { type: 'object' } }, required: ['client', 'object'] }, for: mayWake },
|
|
22
40
|
chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ) => client(occ) === 'agent' },
|
|
41
|
+
forget: { description: 'revoke a device: drop its way in and her way back to it, in one act', input: { type: 'object', properties: { client: { type: 'string' } }, required: ['client'] }, for: isDesk },
|
|
23
42
|
report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
|
|
24
43
|
};
|
|
25
44
|
// Anyone may say hello. A device that hands her an invitation in the args
|
|
26
|
-
// is taken as a standing under its own id, so she can push to it later
|
|
45
|
+
// is taken as a standing under its own id, so she can push to it later;
|
|
46
|
+
// if it cannot be taken now, it is kept and tried at the next push.
|
|
27
47
|
async hello(args, asker) {
|
|
28
48
|
if (asker.id !== undefined && args.invitation && typeof args.invitation === 'object' && !Array.isArray(args.invitation)) {
|
|
29
|
-
|
|
30
|
-
const back = await this.knock(inv);
|
|
31
|
-
if (!isSilence(back) && !isWord(back))
|
|
32
|
-
await this.take(`to:${asker.id}`, inv);
|
|
49
|
+
await this.wayBack(asker.id, args.invitation);
|
|
33
50
|
}
|
|
34
51
|
return { welcome: asker.id ?? null, name: this.cells.name };
|
|
35
52
|
}
|
|
53
|
+
// Take a device's way back, or keep it for later when the knock did not
|
|
54
|
+
// reach. A refusal spends the heir and is final; the way is dropped.
|
|
55
|
+
async wayBack(id, inv) {
|
|
56
|
+
const ways = this.cells.ways;
|
|
57
|
+
const back = await this.knock(inv);
|
|
58
|
+
if (!isSilence(back) && !isWord(back)) {
|
|
59
|
+
await this.take(`to:${id}`, inv);
|
|
60
|
+
delete ways[id];
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if (isWord(back) && wordOf(back) !== 'unreached')
|
|
64
|
+
delete ways[id];
|
|
65
|
+
else
|
|
66
|
+
ways[id] = inv;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
36
69
|
whoami(_args, asker) {
|
|
37
70
|
return { id: asker.id ?? null, client: client(this.occupant(asker)) };
|
|
38
71
|
}
|
|
39
72
|
// The front desk asks; she mints. The client identity goes into the
|
|
40
73
|
// occupant's notes, and that is what every gate reads. `wake` is the
|
|
41
|
-
// human's word at the exchange that this device may wake her others
|
|
74
|
+
// human's word at the exchange that this device may wake her others, and
|
|
75
|
+
// `reach` that it may see and ask what she holds: her standings, carried.
|
|
42
76
|
async device(args) {
|
|
43
77
|
const c = typeof args.client === 'string' ? args.client : null;
|
|
44
78
|
if (c === null)
|
|
@@ -51,17 +85,40 @@ export class User extends Being {
|
|
|
51
85
|
rec.notes.client = c;
|
|
52
86
|
if (args.wake === true)
|
|
53
87
|
rec.notes.wake = true;
|
|
88
|
+
if (args.reach === true)
|
|
89
|
+
rec.notes.reach = true;
|
|
54
90
|
}
|
|
55
91
|
return inv;
|
|
56
92
|
}
|
|
93
|
+
// Her way out and the device's way in are two relations, and Quo keeps
|
|
94
|
+
// them apart. She joins them herself, here, as her own rule: she does
|
|
95
|
+
// not reach a device she no longer admits. Without it a revoked device
|
|
96
|
+
// stops being able to ask her and keeps receiving everything she pushes.
|
|
57
97
|
async push(args) {
|
|
58
98
|
const c = typeof args.client === 'string' ? args.client : null;
|
|
59
|
-
|
|
99
|
+
if (c === null || !this.cells.occupants[c])
|
|
100
|
+
return { error: 'no such device, or it gave no way back' };
|
|
101
|
+
const kept = this.cells.ways[c];
|
|
102
|
+
if (!this.standings[`to:${c}`] && kept)
|
|
103
|
+
await this.wayBack(c, kept);
|
|
104
|
+
const st = this.standings[`to:${c}`];
|
|
60
105
|
if (!st)
|
|
61
106
|
return { error: 'no such device, or it gave no way back' };
|
|
62
107
|
const out = await st.ask('notify', args.object ?? {});
|
|
63
108
|
return isSilence(out) ? { error: 'silence' } : isWord(out) ? { error: wordOf(out) } : { pushed: out };
|
|
64
109
|
}
|
|
110
|
+
// Revocation is one act at her, because only she knows both ids. A side
|
|
111
|
+
// that had to remove two relations by hand could leave half of one
|
|
112
|
+
// standing, and every side would have to remember which half.
|
|
113
|
+
forget(args) {
|
|
114
|
+
const c = typeof args.client === 'string' ? args.client : null;
|
|
115
|
+
if (c === null)
|
|
116
|
+
return { error: 'client is a string' };
|
|
117
|
+
this.occupants.remove(c);
|
|
118
|
+
this.standings.remove(`to:${c}`);
|
|
119
|
+
delete this.cells.ways[c];
|
|
120
|
+
return { forgot: c };
|
|
121
|
+
}
|
|
65
122
|
chores() {
|
|
66
123
|
return { chores: ['census', 'report'] };
|
|
67
124
|
}
|