@quo-systems/dock 0.1.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/README.md +9 -2
- 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 +185 -38
- package/beings/setup.ts +3 -1
- package/beings/side.ts +10 -1
- package/beings/user.ts +31 -5
- package/cli/daemon.ts +98 -44
- package/cli/estate/Caddyfile +25 -0
- package/cli/estate/quo.service +36 -0
- package/cli/estate.ts +44 -0
- package/cli/quo.ts +14 -3
- 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/setup.js +4 -1
- 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/estate/Caddyfile +25 -0
- package/dist/cli/estate/quo.service +36 -0
- package/dist/cli/estate.d.ts +5 -0
- package/dist/cli/estate.js +46 -0
- package/dist/cli/quo.js +14 -3
- package/dist/harbor/edge/exercise.js +3 -1
- package/dist/harbor/edge/platform.d.ts +28 -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 +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 +11 -4
- package/dist/mcp/oauth.js +32 -21
- package/dist/mcp/pilot.d.ts +3 -4
- package/dist/mcp/pilot.js +19 -65
- package/dist/mcp/server.d.ts +6 -4
- package/dist/mcp/server.js +48 -11
- package/dist/mcp/web/exchange.d.ts +5 -2
- package/dist/mcp/web/exchange.js +21 -7
- package/harbor/edge/exercise.ts +2 -1
- 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 +39 -23
- package/mcp/pilot.ts +26 -65
- package/mcp/quo-mcp.md +34 -19
- package/mcp/server.ts +52 -19
- package/mcp/web/exchange.ts +23 -9
- package/package.json +7 -3
package/dist/cli/quo.js
CHANGED
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
// quo invite [--dir D] [--ward W] [--via S] <json> { being, id }
|
|
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
|
|
17
18
|
// quo reach [--dir D] <pk> <url> a hint for the harbor's directory: that ward pk lives at that quo. route
|
|
19
|
+
// quo estate <dir> --domain D an estate folder of the shape every estate has: a droplet's quo directory, unit and Caddyfile, on this dock
|
|
18
20
|
//
|
|
19
21
|
// D defaults to $QUO_DIR, then ~/.quo. W defaults to main. init takes
|
|
20
22
|
// --user NAME for the user being, defaulting to the device's user. run
|
|
@@ -31,7 +33,8 @@ import { DiskHarbor } from '../harbor/disk.js';
|
|
|
31
33
|
import { serve } from './daemon.js';
|
|
32
34
|
import { ask, side, reach } from './client.js';
|
|
33
35
|
import { pilotSide } from '../mcp/pilot.js';
|
|
34
|
-
|
|
36
|
+
import { estate } from './estate.js';
|
|
37
|
+
const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove', 'unboot']);
|
|
35
38
|
function parse(argv) {
|
|
36
39
|
const flags = {};
|
|
37
40
|
const rest = [];
|
|
@@ -78,7 +81,7 @@ async function main(argv) {
|
|
|
78
81
|
}
|
|
79
82
|
if (cmd === 'pilot') {
|
|
80
83
|
await ask(dir, undefined, {}, ward); // no daemon is a clear word now, not at the first tool call
|
|
81
|
-
const serving = await pilotSide(dir, ward, new StdioServerTransport(),
|
|
84
|
+
const serving = await pilotSide(dir, ward, new StdioServerTransport(), via);
|
|
82
85
|
process.stdin.once('end', () => void serving.close().then(() => process.exit(0)));
|
|
83
86
|
return -1;
|
|
84
87
|
}
|
|
@@ -91,6 +94,14 @@ async function main(argv) {
|
|
|
91
94
|
process.once('SIGTERM', stop);
|
|
92
95
|
return -1; // stays up
|
|
93
96
|
}
|
|
97
|
+
if (cmd === 'estate') {
|
|
98
|
+
if (!json || !flags.domain) {
|
|
99
|
+
console.error('quo estate needs <dir> --domain D');
|
|
100
|
+
return 2;
|
|
101
|
+
}
|
|
102
|
+
print(await estate(json, flags.domain));
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
94
105
|
if (cmd === 'reach') {
|
|
95
106
|
if (!json || !more) {
|
|
96
107
|
console.error('quo reach needs <pk> <url>');
|
|
@@ -109,7 +120,7 @@ async function main(argv) {
|
|
|
109
120
|
const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
|
|
110
121
|
return failed ? 1 : 0;
|
|
111
122
|
}
|
|
112
|
-
console.error('usage: quo init|serve|census|boot|invite|knock|remove|side|run|pilot|reach [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
|
|
123
|
+
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]');
|
|
113
124
|
return 2;
|
|
114
125
|
}
|
|
115
126
|
main(process.argv.slice(2)).then((code) => {
|
|
@@ -63,7 +63,9 @@ function probe(base, state, env) {
|
|
|
63
63
|
learn(i, w);
|
|
64
64
|
}
|
|
65
65
|
wardOf.set(key, name);
|
|
66
|
-
assert.deepEqual(await w.ask('boot', { key, class: Class.name
|
|
66
|
+
assert.deepEqual(await w.ask('boot', { key, class: Class.name }), { booted: key });
|
|
67
|
+
if (isPublic)
|
|
68
|
+
assert.deepEqual(await w.ask('public', { key }), { public: key });
|
|
67
69
|
if (Object.keys(cells).length)
|
|
68
70
|
Object.assign(at(name).p.beings[key], cells);
|
|
69
71
|
return {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The edge platform, as much of it as the harbor touches and no more. The
|
|
3
|
+
// vendor's own type package declares every global a worker has, and it
|
|
4
|
+
// collides with the DOM library this tree compiles against; these few
|
|
5
|
+
// lines are what the edge harbor and worker actually call.
|
|
6
|
+
export type Storage = {
|
|
7
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
8
|
+
get<T = unknown>(keys: string[]): Promise<Map<string, T>>;
|
|
9
|
+
put(key: string, value: unknown): Promise<void>;
|
|
10
|
+
delete(key: string): Promise<boolean>;
|
|
11
|
+
list<T = unknown>(options?: { prefix?: string }): Promise<Map<string, T>>;
|
|
12
|
+
};
|
|
13
|
+
export type State = { id: { toString(): string; name?: string }; storage: Storage };
|
|
14
|
+
export type Stub = { fetch(input: string | Request, init?: RequestInit): Promise<Response> };
|
|
15
|
+
export type Namespace = { idFromName(name: string): unknown; get(id: unknown): Stub };
|
|
16
|
+
export type Env = { HARBOR: Namespace; QUO_SEED_KEY?: string; QUO_ROOT?: string; QUO_EXERCISE?: string };
|
|
17
|
+
// The server end of a WebSocketPair: the standard surface plus accept.
|
|
18
|
+
export type ServerSocket = { accept(): void; readyState: number; binaryType: string; send(data: string | Uint8Array): void; close(): void; addEventListener(type: string, fn: (e: never) => void): void };
|
|
19
|
+
declare global {
|
|
20
|
+
// eslint-disable-next-line no-var
|
|
21
|
+
var WebSocketPair: new () => { 0: ServerSocket; 1: ServerSocket };
|
|
22
|
+
interface ResponseInit {
|
|
23
|
+
webSocket?: unknown;
|
|
24
|
+
}
|
|
25
|
+
interface Response {
|
|
26
|
+
readonly webSocket?: unknown;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A guest at a world's door: the world's public being, whatever class she
|
|
3
|
+
// is, as a subject the screen can render. Her describe for a stranger is the
|
|
4
|
+
// guest page; a form on it is a knock on the public invitation; and an
|
|
5
|
+
// answer that is an invitation is the way in, which the tab takes by
|
|
6
|
+
// joining. Nothing here knows the desk: the desk's `device` is one ask
|
|
7
|
+
// that answers an invitation, and any being may write another.
|
|
8
|
+
import { isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
9
|
+
export function guest(avatar, ward) {
|
|
10
|
+
const at = { ward };
|
|
11
|
+
return {
|
|
12
|
+
tools: async () => {
|
|
13
|
+
const bp = await avatar.knock(at);
|
|
14
|
+
if (isSilence(bp))
|
|
15
|
+
return { error: 'silence' };
|
|
16
|
+
if (isWord(bp))
|
|
17
|
+
return { error: wordOf(bp) };
|
|
18
|
+
if (bp === null || typeof bp !== 'object' || Array.isArray(bp) || !Array.isArray(bp.asks))
|
|
19
|
+
return { error: 'nobody is home' };
|
|
20
|
+
return bp;
|
|
21
|
+
},
|
|
22
|
+
call: (name, args = {}, wanted) => avatar.knock(at, name, args, wanted),
|
|
23
|
+
ears: new Set(),
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/human/html.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Ask, Blueprint, Json, JsonObject } from '@quo-systems/quo';
|
|
2
2
|
import { type Word } from '../beings/side.ts';
|
|
3
|
+
import { hintFor, type Look, type Hint } from '../beings/look.ts';
|
|
3
4
|
export type Property = {
|
|
4
5
|
type?: string;
|
|
5
6
|
description?: string;
|
|
@@ -13,7 +14,7 @@ export type Schema = {
|
|
|
13
14
|
};
|
|
14
15
|
export declare const escape: (s: string) => string;
|
|
15
16
|
export declare function field(name: string, p: Property, must: boolean): string;
|
|
16
|
-
export declare function form(ask: Ask): string;
|
|
17
|
+
export declare function form(ask: Ask, h?: Hint): string;
|
|
17
18
|
export type Raw = Record<string, string>;
|
|
18
19
|
export declare function values(ask: Ask, raw: Raw): {
|
|
19
20
|
args: JsonObject;
|
|
@@ -24,9 +25,16 @@ export declare function view(value: Json, schema?: JsonObject): string;
|
|
|
24
25
|
export declare function face(w: Word, schema?: JsonObject): string;
|
|
25
26
|
export type Model = {
|
|
26
27
|
blueprint: Blueprint | null;
|
|
28
|
+
look: Look;
|
|
27
29
|
notice: string;
|
|
28
30
|
answers: Record<string, Word>;
|
|
29
31
|
pushes: JsonObject[];
|
|
30
32
|
};
|
|
31
|
-
export declare function title(bp: Blueprint | null): string;
|
|
33
|
+
export declare function title(bp: Blueprint | null, l?: Look): string;
|
|
34
|
+
export declare function look(l: Look | undefined): {
|
|
35
|
+
style: string;
|
|
36
|
+
head: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function ordered(asks: Ask[], l: Look | undefined): Ask[];
|
|
39
|
+
export { hintFor };
|
|
32
40
|
export declare function page(m: Model): string;
|
package/dist/human/html.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SILENCE_TEXT, UNREACHED_TEXT, wordText } from '../beings/side.js';
|
|
2
|
+
import { hint, hintFor, sanitise, groups as grouped } from '../beings/look.js';
|
|
2
3
|
export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
3
4
|
const props = (schema) => Object.entries((schema?.properties ?? {}));
|
|
4
5
|
const required = (schema) => new Set((schema?.required ?? []));
|
|
@@ -28,12 +29,14 @@ export function field(name, p, must) {
|
|
|
28
29
|
}
|
|
29
30
|
// The ask as a form: its name is the button, its description the legend's
|
|
30
31
|
// small print, and `data-ask` is how the surface says which ask was sent.
|
|
31
|
-
export function form(ask) {
|
|
32
|
+
export function form(ask, h = {}) {
|
|
32
33
|
const must = required(ask.input);
|
|
33
34
|
const fields = props(ask.input)
|
|
34
35
|
.map(([name, p]) => field(name, p, must.has(name)))
|
|
35
36
|
.join('');
|
|
36
|
-
|
|
37
|
+
const label = `${h.icon ? `${escape(h.icon)} ` : ''}${escape(h.title ?? ask.name)}`;
|
|
38
|
+
const care = h.destructive ? ' data-confirm="true"' : '';
|
|
39
|
+
return `<form data-ask="${escape(ask.name)}"${care}><fieldset><legend>${label}${ask.description ? ` <small>${escape(ask.description)}</small>` : ''}</legend>${fields}<p><button>${label}</button></p></fieldset></form>`;
|
|
37
40
|
}
|
|
38
41
|
// The strings typed by the schema. An empty field that is not required is
|
|
39
42
|
// left out, so the being sees what the human said and nothing else; an
|
|
@@ -116,21 +119,69 @@ export function face(w, schema) {
|
|
|
116
119
|
}
|
|
117
120
|
// The title is the one hint the notes may carry: a string named `name`.
|
|
118
121
|
// The rest of the notes is shown as a view and read as nothing else.
|
|
119
|
-
export function title(bp) {
|
|
122
|
+
export function title(bp, l = {}) {
|
|
123
|
+
if (l.name)
|
|
124
|
+
return l.name;
|
|
120
125
|
const n = bp?.notes;
|
|
121
126
|
return n !== null && typeof n === 'object' && !Array.isArray(n) && typeof n.name === 'string' && n.name ? n.name : 'quo';
|
|
122
127
|
}
|
|
128
|
+
// ---- a look
|
|
129
|
+
// A look as the page paints it: the tokens become CSS variables on one
|
|
130
|
+
// section, and the name and the logo its heading. The shape of every token
|
|
131
|
+
// is the dock's, `beings/look.ts`; this only writes what survived it.
|
|
132
|
+
export function look(l) {
|
|
133
|
+
if (!l)
|
|
134
|
+
return { style: '', head: '' };
|
|
135
|
+
const vars = [];
|
|
136
|
+
if (l.accent)
|
|
137
|
+
vars.push(`--accent:${l.accent}`);
|
|
138
|
+
if (l.background)
|
|
139
|
+
vars.push(`--bg:${l.background}`);
|
|
140
|
+
if (l.foreground)
|
|
141
|
+
vars.push(`--fg:${l.foreground}`);
|
|
142
|
+
if (l.font)
|
|
143
|
+
vars.push(`--font:${l.font}`);
|
|
144
|
+
if (l.radius !== undefined)
|
|
145
|
+
vars.push(`--radius:${l.radius}px`);
|
|
146
|
+
const logo = l.logo ? `<img class="logo" alt="" src="${l.logo}">` : '';
|
|
147
|
+
const name = l.name ? escape(l.name) : '';
|
|
148
|
+
return { style: vars.length ? ` style="${vars.join(';')}"` : '', head: logo || name ? `<h2>${logo}${name}</h2>` : '' };
|
|
149
|
+
}
|
|
150
|
+
// Her asks in the order her look asks for, the rest after in her own order.
|
|
151
|
+
export function ordered(asks, l) {
|
|
152
|
+
const want = l?.order ?? [];
|
|
153
|
+
return [...want.map((n) => asks.find((a) => a.name === n)).filter((a) => a !== undefined), ...asks.filter((a) => !want.includes(a.name))];
|
|
154
|
+
}
|
|
155
|
+
export { hintFor };
|
|
123
156
|
export function page(m) {
|
|
124
157
|
const bp = m.blueprint;
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
158
|
+
const one = (l, prefix = '') => (a) => {
|
|
159
|
+
const w = m.answers[a.name];
|
|
160
|
+
const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
|
|
161
|
+
return `<section>${form(a, h)}${w ? face(w, a.output) : ''}</section>`;
|
|
162
|
+
};
|
|
163
|
+
const groups = grouped(bp);
|
|
164
|
+
const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
|
|
165
|
+
const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && a.name !== 'look'), m.look).map(one(m.look)).join('') : '';
|
|
166
|
+
const far = bp
|
|
167
|
+
? Object.entries(groups)
|
|
168
|
+
.map(([id, g]) => {
|
|
169
|
+
const asks = bp.asks.filter((a) => g.asks?.includes(a.name));
|
|
170
|
+
if (!asks.length)
|
|
171
|
+
return '';
|
|
172
|
+
const kept = sanitise(g.look);
|
|
173
|
+
const l = look(kept);
|
|
174
|
+
const prefix = `${id}-`;
|
|
175
|
+
const want = (kept.order ?? []).map((n) => prefix + n);
|
|
176
|
+
const inOrder = ordered(asks, { order: want });
|
|
177
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
|
|
130
178
|
})
|
|
131
179
|
.join('')
|
|
132
180
|
: '';
|
|
133
|
-
const
|
|
181
|
+
const asks = own + far;
|
|
182
|
+
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
|
+
const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown)}</aside>` : '';
|
|
134
184
|
const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
|
|
135
|
-
|
|
185
|
+
const mine = look(m.look);
|
|
186
|
+
return `<header${mine.style}>${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1><p class="notice">${escape(m.notice)}</p></header>${notes}<main${mine.style}>${asks}</main>${pushes}`;
|
|
136
187
|
}
|
package/dist/human/screen.d.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
1
|
+
import { type Serving, type Subject } from '../beings/side.ts';
|
|
2
|
+
import type { Invitation } from '@quo-systems/quo';
|
|
3
3
|
import { type Model, type Raw } from './html.ts';
|
|
4
4
|
export type Surface = {
|
|
5
5
|
show(html: string): void;
|
|
6
6
|
onSubmit: ((ask: string, raw: Raw) => void) | undefined;
|
|
7
7
|
};
|
|
8
|
-
export
|
|
8
|
+
export type Options = {
|
|
9
|
+
after?: () => Promise<void>;
|
|
10
|
+
notice?: string;
|
|
11
|
+
admit?: (invitation: Invitation) => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
export declare function screenSide(avatar: Subject, surface: Surface, options?: Options): Promise<Serving & {
|
|
9
14
|
model: Model;
|
|
10
15
|
refresh(): Promise<void>;
|
|
11
16
|
}>;
|
package/dist/human/screen.js
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
// moved. A push is appended and shown the moment it lands.
|
|
9
9
|
import { digest } from '@quo-systems/quo';
|
|
10
10
|
import { word } from '../beings/side.js';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
import { isInvitation } from '../beings/link.js';
|
|
12
|
+
import { page, values, hintFor } from './html.js';
|
|
13
|
+
import { sanitise } from '../beings/look.js';
|
|
14
|
+
import { isSilence, isWord } from '@quo-systems/quo';
|
|
15
|
+
export async function screenSide(avatar, surface, options = {}) {
|
|
16
|
+
const after = options.after ?? (async () => { });
|
|
17
|
+
const notice = options.notice ?? '';
|
|
18
|
+
const model = { blueprint: null, look: {}, notice, answers: {}, pushes: [] };
|
|
16
19
|
let seen = null;
|
|
17
20
|
const show = () => surface.show(page(model));
|
|
18
21
|
// Her describe, again: the page follows the digest.
|
|
@@ -31,6 +34,19 @@ export async function screenSide(avatar, surface, after = async () => { }, notic
|
|
|
31
34
|
for (const k of Object.keys(model.answers))
|
|
32
35
|
if (!bp.asks.some((a) => a.name === k))
|
|
33
36
|
delete model.answers[k];
|
|
37
|
+
// her look, once per digest: how she wants her page painted, and what each ask is
|
|
38
|
+
if (bp.asks.some((a) => a.name === 'look')) {
|
|
39
|
+
const l = await avatar.call('look');
|
|
40
|
+
model.look = isSilence(l) || isWord(l) ? {} : sanitise(l);
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
model.look = {};
|
|
44
|
+
// a read-only ask that needs nothing typed is run on her behalf, so the page opens with what it shows
|
|
45
|
+
for (const a of bp.asks) {
|
|
46
|
+
const needs = (a.input.required ?? []).length > 0;
|
|
47
|
+
if (hintFor(bp, model.look, a.name).readOnly && !needs && !(a.name in model.answers))
|
|
48
|
+
model.answers[a.name] = word(await avatar.call(a.name, {}));
|
|
49
|
+
}
|
|
34
50
|
}
|
|
35
51
|
}
|
|
36
52
|
show();
|
|
@@ -45,8 +61,11 @@ export async function screenSide(avatar, surface, after = async () => { }, notic
|
|
|
45
61
|
model.answers[name] = { word: 'error', value: { error: v.error } };
|
|
46
62
|
return show();
|
|
47
63
|
}
|
|
48
|
-
|
|
64
|
+
const out = await avatar.call(name, v.args);
|
|
49
65
|
await after();
|
|
66
|
+
if (options.admit && isInvitation(out))
|
|
67
|
+
return options.admit(out);
|
|
68
|
+
model.answers[name] = word(out);
|
|
50
69
|
await refresh();
|
|
51
70
|
};
|
|
52
71
|
const ear = (object) => {
|
package/dist/human/tab.d.ts
CHANGED
package/dist/human/tab.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import { BrowserHarbor } from '../harbor/browser.js';
|
|
2
2
|
import { Avatar, USER } from '../beings/avatar.js';
|
|
3
|
+
import { parse, strip, isInvitation } from '../beings/link.js';
|
|
3
4
|
import { screenSide } from './screen.js';
|
|
4
5
|
import { domSurface } from './dom.js';
|
|
6
|
+
import { guest } from './guest.js';
|
|
7
|
+
import { escape } from './html.js';
|
|
8
|
+
const WORLDS = 'quo-worlds';
|
|
9
|
+
const worlds = () => {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(localStorage.getItem(WORLDS) ?? '{}');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const remember = (pk, url, name) => {
|
|
18
|
+
try {
|
|
19
|
+
localStorage.setItem(WORLDS, JSON.stringify({ ...worlds(), [pk]: { url, name } }));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
/* a tab with no storage still works, and forgets */
|
|
23
|
+
}
|
|
24
|
+
};
|
|
5
25
|
const el = (tag, text = '', attrs = {}) => {
|
|
6
26
|
const e = document.createElement(tag);
|
|
7
27
|
if (text)
|
|
@@ -11,58 +31,123 @@ const el = (tag, text = '', attrs = {}) => {
|
|
|
11
31
|
return e;
|
|
12
32
|
};
|
|
13
33
|
export async function start(cfg, root = document.body) {
|
|
34
|
+
// The link, first and once: the invitation leaves the address bar before
|
|
35
|
+
// any other code sees it.
|
|
36
|
+
const linked = parse(location.hash);
|
|
37
|
+
if (linked)
|
|
38
|
+
history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
|
|
39
|
+
const nav = el('nav', '', { class: 'worlds' });
|
|
14
40
|
const status = el('p', 'booting');
|
|
15
41
|
const screen = el('div');
|
|
16
|
-
root.append(status, screen);
|
|
42
|
+
root.append(nav, status, screen);
|
|
17
43
|
const say = (s) => (status.textContent = s);
|
|
18
|
-
// The harbor in the tab: one database
|
|
44
|
+
// The harbor in the tab: one database per origin, one ward per world.
|
|
19
45
|
const harbor = new BrowserHarbor('quo');
|
|
20
46
|
await harbor.boot();
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
47
|
+
const name = `w-${cfg.pk.slice(0, 16)}`;
|
|
48
|
+
const ward = harbor.wards.get(name) ?? (await harbor.create(name, 'me'));
|
|
49
|
+
if (!ward.being('me'))
|
|
50
|
+
await ward.ask('boot', { key: 'me', class: 'Avatar' });
|
|
51
|
+
const me = ward.being('me');
|
|
25
52
|
harbor.dial(cfg.quo);
|
|
26
|
-
|
|
53
|
+
const switcher = () => {
|
|
54
|
+
nav.replaceChildren();
|
|
55
|
+
const known = worlds();
|
|
56
|
+
for (const [pk, w] of Object.entries(known)) {
|
|
57
|
+
const a = el('a', w.name || pk.slice(0, 8), { href: w.url });
|
|
58
|
+
if (pk === cfg.pk)
|
|
59
|
+
a.setAttribute('aria-current', 'page');
|
|
60
|
+
nav.append(a);
|
|
61
|
+
}
|
|
62
|
+
const here = worlds()[cfg.pk];
|
|
63
|
+
if (!here)
|
|
64
|
+
nav.append(el('span', `${cfg.ward} at ${new URL(cfg.web).host}`));
|
|
65
|
+
};
|
|
66
|
+
switcher();
|
|
67
|
+
// Joined: her page. Every call rotates her keys and a same-ward ask never
|
|
27
68
|
// crosses the harbor, so the side saves after each one.
|
|
28
|
-
const
|
|
69
|
+
const inside = async (notice) => {
|
|
29
70
|
status.remove();
|
|
30
|
-
|
|
71
|
+
screen.replaceChildren();
|
|
72
|
+
const side = await screenSide(me, domSurface(screen), { after: () => ward.save(), notice });
|
|
73
|
+
remember(cfg.pk, location.origin + location.pathname, side.model.look.name || (typeof side.model.blueprint?.notes?.name === 'string' ? side.model.blueprint.notes.name : cfg.ward));
|
|
74
|
+
switcher();
|
|
31
75
|
};
|
|
32
|
-
//
|
|
33
|
-
const
|
|
34
|
-
if ('asks' in had)
|
|
35
|
-
return show('in, as before');
|
|
36
|
-
// The exchange, once: the owner password for a nonce, the nonce for an
|
|
37
|
-
// invitation the user being mints, the invitation for a standing.
|
|
38
|
-
const form = el('form');
|
|
39
|
-
const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
|
|
40
|
-
const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
|
|
41
|
-
const go = el('button', 'enter');
|
|
42
|
-
form.append(identity, password, go);
|
|
43
|
-
root.insertBefore(form, screen);
|
|
44
|
-
say(`not in (${had.error}): the owner password opens the world`);
|
|
45
|
-
form.onsubmit = async (ev) => {
|
|
46
|
-
ev.preventDefault();
|
|
47
|
-
say('asking the world');
|
|
48
|
-
let res;
|
|
49
|
-
try {
|
|
50
|
-
res = await fetch(`${cfg.web}/tab/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
|
|
51
|
-
}
|
|
52
|
-
catch (e) {
|
|
53
|
-
return say(`the world did not answer: ${e.message}`);
|
|
54
|
-
}
|
|
55
|
-
if (!res.ok)
|
|
56
|
-
return say(`refused: ${(await res.json().catch(() => ({ error: res.status }))).error ?? res.status}`);
|
|
57
|
-
const { nonce, ward } = (await res.json());
|
|
58
|
-
password.value = '';
|
|
76
|
+
// The way in, from any of the three: join, save, and be inside.
|
|
77
|
+
const admit = async (inv, notice) => {
|
|
59
78
|
say('knocking');
|
|
60
|
-
const got = await me.
|
|
61
|
-
await
|
|
62
|
-
if (!('asks' in got))
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
const got = await me.join(inv);
|
|
80
|
+
await ward.save(); // the knock went through her own door, which the harbor never sees
|
|
81
|
+
if (!('asks' in got)) {
|
|
82
|
+
say(`not in: ${got.error}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
await inside(notice);
|
|
66
86
|
};
|
|
87
|
+
// Already in: reconnect, nothing minted.
|
|
88
|
+
if (me.standings[USER]) {
|
|
89
|
+
const had = await me.tools();
|
|
90
|
+
if ('asks' in had)
|
|
91
|
+
return inside('in, as before');
|
|
92
|
+
say(`not in (${had.error})`);
|
|
93
|
+
}
|
|
94
|
+
if (linked)
|
|
95
|
+
return admit(linked, 'in, by the link');
|
|
96
|
+
// A guest: the public being's describe as a page; a form whose answer is
|
|
97
|
+
// an invitation lets the guest in.
|
|
98
|
+
say(`at the door of ${cfg.ward}`);
|
|
99
|
+
const door = guest(me, cfg.pk);
|
|
100
|
+
const side = await screenSide(door, domSurface(screen), {
|
|
101
|
+
after: () => ward.save(),
|
|
102
|
+
notice: `a guest at ${cfg.ward}: what she shows strangers`,
|
|
103
|
+
admit: async (inv) => {
|
|
104
|
+
await side.close();
|
|
105
|
+
await admit(inv, `in, as a guest of ${cfg.ward}`);
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
// The dock's own way in, when the desk is at the door: the owner password
|
|
109
|
+
// for a nonce, and the desk's `device` form submitted with it.
|
|
110
|
+
if (side.model.blueprint?.asks.some((a) => a.name === 'device')) {
|
|
111
|
+
const form = el('form', '', { class: 'password' });
|
|
112
|
+
const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
|
|
113
|
+
const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
|
|
114
|
+
const go = el('button', 'enter as the owner');
|
|
115
|
+
form.append(identity, password, go);
|
|
116
|
+
root.insertBefore(form, screen);
|
|
117
|
+
form.onsubmit = (ev) => {
|
|
118
|
+
ev.preventDefault();
|
|
119
|
+
void (async () => {
|
|
120
|
+
say('asking the world');
|
|
121
|
+
let res;
|
|
122
|
+
try {
|
|
123
|
+
res = await fetch(`${cfg.web}/${encodeURIComponent(cfg.ward)}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
say(`the world did not answer: ${e.message}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
password.value = '';
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
say(`refused: ${(await res.json().catch(() => ({ error: res.status }))).error ?? res.status}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const { nonce } = (await res.json());
|
|
135
|
+
const inv = await door.call('device', { proof: { kind: 'tab', nonce } });
|
|
136
|
+
await ward.save();
|
|
137
|
+
if (!isInvitation(inv)) {
|
|
138
|
+
say(`not in: ${escape(JSON.stringify(inv))}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
form.remove();
|
|
142
|
+
await side.close();
|
|
143
|
+
await admit(inv, `in, as ${identity.value}`);
|
|
144
|
+
})();
|
|
145
|
+
};
|
|
146
|
+
}
|
|
67
147
|
}
|
|
148
|
+
// The page hands the config in a JSON script, which a content security
|
|
149
|
+
// policy allows where an inline script is not.
|
|
150
|
+
const config = document.getElementById('quo');
|
|
151
|
+
if (config?.textContent)
|
|
152
|
+
void start(JSON.parse(config.textContent));
|
|
68
153
|
export { USER };
|
package/dist/mcp/http.d.ts
CHANGED
|
@@ -4,12 +4,13 @@ import type { Avatar } from '../beings/avatar.ts';
|
|
|
4
4
|
import type { Serving } from '../beings/side.ts';
|
|
5
5
|
export type Session = {
|
|
6
6
|
identity: string;
|
|
7
|
+
ward: string;
|
|
7
8
|
transport: StreamableHTTPServerTransport;
|
|
8
9
|
serving: Serving;
|
|
9
10
|
touched: number;
|
|
10
11
|
};
|
|
11
12
|
export declare const SESSION_IDLE: number;
|
|
12
|
-
export type Resolve = (identity: string) => Promise<{
|
|
13
|
+
export type Resolve = (identity: string, ward: string) => Promise<{
|
|
13
14
|
avatar?: Avatar;
|
|
14
15
|
error?: string;
|
|
15
16
|
}>;
|
|
@@ -18,9 +19,9 @@ export declare class McpHttp {
|
|
|
18
19
|
readonly resolve: Resolve;
|
|
19
20
|
readonly after: () => Promise<void>;
|
|
20
21
|
now: () => number;
|
|
21
|
-
gone: (identity: string) => Promise<void>;
|
|
22
|
+
gone: (identity: string, ward: string) => Promise<void>;
|
|
22
23
|
constructor(resolve: Resolve, after?: () => Promise<void>);
|
|
23
|
-
handle(req: IncomingMessage, res: ServerResponse, identity: string): Promise<void>;
|
|
24
|
+
handle(req: IncomingMessage, res: ServerResponse, identity: string, ward?: string): Promise<void>;
|
|
24
25
|
sweep(): void;
|
|
25
26
|
drop(sid: string): void;
|
|
26
27
|
close(): Promise<void>;
|
package/dist/mcp/http.js
CHANGED
|
@@ -28,7 +28,7 @@ export class McpHttp {
|
|
|
28
28
|
// `identity` is what the bearer named; the caller has already turned a
|
|
29
29
|
// stranger away. A request on a known session goes to it. A request with
|
|
30
30
|
// no session opens one, if it is an initialize; anything else is 400.
|
|
31
|
-
async handle(req, res, identity) {
|
|
31
|
+
async handle(req, res, identity, ward = 'main') {
|
|
32
32
|
const id = req.headers['mcp-session-id'];
|
|
33
33
|
const sid = Array.isArray(id) ? id[0] : id;
|
|
34
34
|
const json = (status, body) => {
|
|
@@ -40,7 +40,7 @@ export class McpHttp {
|
|
|
40
40
|
const s = this.sessions.get(sid);
|
|
41
41
|
if (!s)
|
|
42
42
|
return json(404, { jsonrpc: '2.0', error: { code: -32001, message: 'no such session' }, id: null });
|
|
43
|
-
if (s.identity !== identity)
|
|
43
|
+
if (s.identity !== identity || s.ward !== ward)
|
|
44
44
|
return json(403, { jsonrpc: '2.0', error: { code: -32003, message: 'not your session' }, id: null });
|
|
45
45
|
s.touched = this.now();
|
|
46
46
|
await s.transport.handleRequest(req, res);
|
|
@@ -50,16 +50,16 @@ export class McpHttp {
|
|
|
50
50
|
}
|
|
51
51
|
if (req.method !== 'POST')
|
|
52
52
|
return json(400, { jsonrpc: '2.0', error: { code: -32000, message: 'no session' }, id: null });
|
|
53
|
-
const found = await this.resolve(identity);
|
|
53
|
+
const found = await this.resolve(identity, ward);
|
|
54
54
|
// A session opens on her describe, and admit already asked it. The one
|
|
55
55
|
// word the door says for an identity the user being removed is
|
|
56
56
|
// `removed`, under the key it bound for her avatar: in MCP's vocabulary
|
|
57
57
|
// that is 401, the client drops its token and starts the exchange again,
|
|
58
58
|
// and the route forgets the grant.
|
|
59
59
|
if (found.error === 'removed') {
|
|
60
|
-
await this.gone(identity);
|
|
60
|
+
await this.gone(identity, ward);
|
|
61
61
|
for (const [sid, s] of this.sessions)
|
|
62
|
-
if (s.identity === identity)
|
|
62
|
+
if (s.identity === identity && s.ward === ward)
|
|
63
63
|
this.drop(sid);
|
|
64
64
|
res.writeHead(401, { 'content-type': 'application/json' });
|
|
65
65
|
return void res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'removed: the user being removed this identity' }, id: null }));
|
|
@@ -69,7 +69,7 @@ export class McpHttp {
|
|
|
69
69
|
const transport = new StreamableHTTPServerTransport({
|
|
70
70
|
sessionIdGenerator: () => randomUUID(),
|
|
71
71
|
onsessioninitialized: (s) => {
|
|
72
|
-
this.sessions.set(s, { identity, transport, serving, touched: this.now() });
|
|
72
|
+
this.sessions.set(s, { identity, ward, transport, serving, touched: this.now() });
|
|
73
73
|
},
|
|
74
74
|
onsessionclosed: (s) => this.drop(s),
|
|
75
75
|
});
|