@quo-systems/dock 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +27 -0
- package/beings/avatar.ts +84 -0
- package/beings/desk.ts +46 -0
- package/beings/index.ts +5 -0
- package/beings/quo-dock.md +698 -0
- package/beings/setup.ts +23 -0
- package/beings/side.ts +51 -0
- package/beings/user.ts +78 -0
- package/cli/client.ts +65 -0
- package/cli/daemon.ts +582 -0
- package/cli/quo.ts +126 -0
- package/dist/beings/avatar.d.ts +32 -0
- package/dist/beings/avatar.js +82 -0
- package/dist/beings/desk.d.ts +42 -0
- package/dist/beings/desk.js +43 -0
- package/dist/beings/index.d.ts +3 -0
- package/dist/beings/index.js +5 -0
- package/dist/beings/setup.d.ts +2 -0
- package/dist/beings/setup.js +16 -0
- package/dist/beings/side.d.ts +25 -0
- package/dist/beings/side.js +37 -0
- package/dist/beings/user.d.ts +108 -0
- package/dist/beings/user.js +74 -0
- package/dist/cli/client.d.ts +14 -0
- package/dist/cli/client.js +51 -0
- package/dist/cli/daemon.d.ts +57 -0
- package/dist/cli/daemon.js +581 -0
- package/dist/cli/quo.d.ts +2 -0
- package/dist/cli/quo.js +121 -0
- package/dist/harbor/browser.d.ts +13 -0
- package/dist/harbor/browser.js +50 -0
- package/dist/harbor/disk.d.ts +19 -0
- package/dist/harbor/disk.js +126 -0
- package/dist/harbor/edge/edge.d.ts +26 -0
- package/dist/harbor/edge/edge.js +123 -0
- package/dist/harbor/edge/exercise.d.ts +7 -0
- package/dist/harbor/edge/exercise.js +179 -0
- package/dist/harbor/edge/storage.d.ts +20 -0
- package/dist/harbor/edge/storage.js +81 -0
- package/dist/harbor/edge/worker.d.ts +14 -0
- package/dist/harbor/edge/worker.js +47 -0
- package/dist/harbor/files.d.ts +14 -0
- package/dist/harbor/files.js +83 -0
- package/dist/harbor/idb.d.ts +15 -0
- package/dist/harbor/idb.js +85 -0
- package/dist/human/dom.d.ts +2 -0
- package/dist/human/dom.js +15 -0
- package/dist/human/html.d.ts +32 -0
- package/dist/human/html.js +136 -0
- package/dist/human/screen.d.ts +11 -0
- package/dist/human/screen.js +66 -0
- package/dist/human/tab.d.ts +7 -0
- package/dist/human/tab.js +68 -0
- package/dist/mcp/agent.d.ts +10 -0
- package/dist/mcp/agent.js +78 -0
- package/dist/mcp/http.d.ts +27 -0
- package/dist/mcp/http.js +96 -0
- package/dist/mcp/oauth.d.ts +98 -0
- package/dist/mcp/oauth.js +241 -0
- package/dist/mcp/pilot.d.ts +6 -0
- package/dist/mcp/pilot.js +68 -0
- package/dist/mcp/runner.d.ts +45 -0
- package/dist/mcp/runner.js +94 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +63 -0
- package/dist/mcp/web/exchange.d.ts +23 -0
- package/dist/mcp/web/exchange.js +136 -0
- package/harbor/browser.ts +63 -0
- package/harbor/disk.ts +120 -0
- package/harbor/edge/edge.ts +147 -0
- package/harbor/edge/exercise.ts +187 -0
- package/harbor/edge/platform.d.ts +28 -0
- package/harbor/edge/storage.ts +82 -0
- package/harbor/edge/worker.ts +47 -0
- package/harbor/edge/wrangler.toml +21 -0
- package/harbor/files.ts +89 -0
- package/harbor/idb.ts +90 -0
- package/harbor/quo-harbor.md +403 -0
- package/human/dom.ts +21 -0
- package/human/html.ts +164 -0
- package/human/quo-human.md +86 -0
- package/human/screen.ts +76 -0
- package/human/tab.ts +84 -0
- package/mcp/agent.ts +94 -0
- package/mcp/http.ts +96 -0
- package/mcp/oauth.ts +260 -0
- package/mcp/pilot.ts +73 -0
- package/mcp/quo-mcp.md +262 -0
- package/mcp/runner.ts +131 -0
- package/mcp/server.ts +67 -0
- package/mcp/web/exchange.ts +146 -0
- package/package.json +89 -0
package/human/html.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A blueprint spoken as HTML, and a form heard back as arguments. Pure:
|
|
3
|
+
// strings in, strings out, no document, no element, no event. This is the
|
|
4
|
+
// whole vocabulary of the screen, so that a page can be proven with no
|
|
5
|
+
// browser at all. Nothing here knows an avatar; the side hands in what she
|
|
6
|
+
// answered and reads out what the human submitted.
|
|
7
|
+
//
|
|
8
|
+
// form(ask) the ask's input schema as fields, one per property
|
|
9
|
+
// values(ask, raw) the submitted strings back as arguments, typed by the schema
|
|
10
|
+
// view(value, schema?) an answer as a view, ordered by the output schema
|
|
11
|
+
// page(model) the whole page: title, notice, forms, views, pushes
|
|
12
|
+
import type { Ask, Blueprint, Json, JsonObject } from '@quo-systems/quo';
|
|
13
|
+
import { SILENCE_TEXT, UNREACHED_TEXT, wordText, type Word } from '../beings/side.ts';
|
|
14
|
+
|
|
15
|
+
// One property of an input schema, as the form needs it.
|
|
16
|
+
export type Property = { type?: string; description?: string; enum?: Json[]; format?: string; default?: Json };
|
|
17
|
+
export type Schema = { properties?: Record<string, Property>; required?: string[] };
|
|
18
|
+
|
|
19
|
+
export const escape = (s: string): string => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
20
|
+
|
|
21
|
+
const props = (schema: JsonObject | undefined): [string, Property][] => Object.entries(((schema as Schema | undefined)?.properties ?? {}) as Record<string, Property>);
|
|
22
|
+
const required = (schema: JsonObject | undefined): Set<string> => new Set(((schema as Schema | undefined)?.required ?? []) as string[]);
|
|
23
|
+
|
|
24
|
+
// ---- a schema as a form
|
|
25
|
+
|
|
26
|
+
// A property is one field by its type. A string is a text input, and a
|
|
27
|
+
// format the browser knows, `password`, `date`, `email`, is the input's
|
|
28
|
+
// type. A number is a number input. A boolean is a checkbox. An enum is a
|
|
29
|
+
// select. An object or an array is a box for JSON, because a form has no
|
|
30
|
+
// better face for a value the schema left open. A description is the label.
|
|
31
|
+
export function field(name: string, p: Property, must: boolean): string {
|
|
32
|
+
const id = escape(name);
|
|
33
|
+
const label = `<label for="${id}">${id}${must ? ' <b>*</b>' : ''}${p.description ? ` <small>${escape(p.description)}</small>` : ''}</label>`;
|
|
34
|
+
const req = must ? ' required' : '';
|
|
35
|
+
if (Array.isArray(p.enum)) {
|
|
36
|
+
const options = p.enum.map((v) => `<option value="${escape(String(v))}">${escape(String(v))}</option>`).join('');
|
|
37
|
+
return `<p>${label}<select id="${id}" name="${id}"${req}>${must ? '' : '<option value=""></option>'}${options}</select></p>`;
|
|
38
|
+
}
|
|
39
|
+
if (p.type === 'boolean') return `<p><input type="checkbox" id="${id}" name="${id}" value="true">${label}</p>`;
|
|
40
|
+
if (p.type === 'number' || p.type === 'integer') return `<p>${label}<input type="number" id="${id}" name="${id}"${p.type === 'integer' ? ' step="1"' : ' step="any"'}${req}></p>`;
|
|
41
|
+
if (p.type === 'string' || p.type === undefined) {
|
|
42
|
+
const kind = p.format === 'password' || p.format === 'date' || p.format === 'email' || p.format === 'time' ? p.format : 'text';
|
|
43
|
+
return `<p>${label}<input type="${kind}" id="${id}" name="${id}"${req}></p>`;
|
|
44
|
+
}
|
|
45
|
+
return `<p>${label}<textarea id="${id}" name="${id}" placeholder="JSON"${req}></textarea></p>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The ask as a form: its name is the button, its description the legend's
|
|
49
|
+
// small print, and `data-ask` is how the surface says which ask was sent.
|
|
50
|
+
export function form(ask: Ask): string {
|
|
51
|
+
const must = required(ask.input);
|
|
52
|
+
const fields = props(ask.input)
|
|
53
|
+
.map(([name, p]) => field(name, p, must.has(name)))
|
|
54
|
+
.join('');
|
|
55
|
+
return `<form data-ask="${escape(ask.name)}"><fieldset><legend>${escape(ask.name)}${ask.description ? ` <small>${escape(ask.description)}</small>` : ''}</legend>${fields}<p><button>${escape(ask.name)}</button></p></fieldset></form>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- the form heard back
|
|
59
|
+
|
|
60
|
+
// What the surface hands back: every field by name, as the string the
|
|
61
|
+
// browser sends. A checkbox not ticked sends nothing.
|
|
62
|
+
export type Raw = Record<string, string>;
|
|
63
|
+
|
|
64
|
+
// The strings typed by the schema. An empty field that is not required is
|
|
65
|
+
// left out, so the being sees what the human said and nothing else; an
|
|
66
|
+
// empty required field goes as the empty string, and the being answers.
|
|
67
|
+
// A JSON box that does not parse is an error the side shows before asking.
|
|
68
|
+
export function values(ask: Ask, raw: Raw): { args: JsonObject } | { error: string } {
|
|
69
|
+
const args: JsonObject = {};
|
|
70
|
+
const must = required(ask.input);
|
|
71
|
+
for (const [name, p] of props(ask.input)) {
|
|
72
|
+
const s = raw[name];
|
|
73
|
+
if (p.type === 'boolean') {
|
|
74
|
+
if (s === 'true') args[name] = true;
|
|
75
|
+
else if (must.has(name)) args[name] = false;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (s === undefined || (s === '' && !must.has(name))) continue;
|
|
79
|
+
if (p.type === 'number' || p.type === 'integer') {
|
|
80
|
+
const n = Number(s);
|
|
81
|
+
if (s === '' || Number.isNaN(n)) return { error: `${name} is a number` };
|
|
82
|
+
args[name] = n;
|
|
83
|
+
} else if (p.type === 'string' || p.type === undefined || Array.isArray(p.enum)) {
|
|
84
|
+
args[name] = s;
|
|
85
|
+
} else {
|
|
86
|
+
try {
|
|
87
|
+
args[name] = JSON.parse(s) as Json;
|
|
88
|
+
} catch {
|
|
89
|
+
return { error: `${name} is not JSON` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { args };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- an answer as a view
|
|
97
|
+
|
|
98
|
+
// A value has one face by its shape. A primitive is text. An object is a
|
|
99
|
+
// table of its keys, in the output schema's order when she declared one and
|
|
100
|
+
// her own order otherwise, each value viewed in turn. A list of objects is
|
|
101
|
+
// one table with a column per key seen; any other list is a list. Null is
|
|
102
|
+
// the word nothing, so that an answer of null is never a blank.
|
|
103
|
+
export function view(value: Json, schema?: JsonObject): string {
|
|
104
|
+
if (value === null) return '<i>nothing</i>';
|
|
105
|
+
if (typeof value !== 'object') return escape(String(value));
|
|
106
|
+
if (Array.isArray(value)) {
|
|
107
|
+
if (value.length && value.every((v) => v !== null && typeof v === 'object' && !Array.isArray(v))) {
|
|
108
|
+
const rows = value as JsonObject[];
|
|
109
|
+
const keys = [...new Set(rows.flatMap((r) => Object.keys(r)))];
|
|
110
|
+
const head = keys.map((k) => `<th>${escape(k)}</th>`).join('');
|
|
111
|
+
const body = rows.map((r) => `<tr>${keys.map((k) => `<td>${k in r ? view(r[k] as Json) : ''}</td>`).join('')}</tr>`).join('');
|
|
112
|
+
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
|
113
|
+
}
|
|
114
|
+
return value.length ? `<ul>${value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>` : '<i>none</i>';
|
|
115
|
+
}
|
|
116
|
+
const declared = props(schema).map(([k]) => k);
|
|
117
|
+
const keys = [...declared.filter((k) => k in value), ...Object.keys(value).filter((k) => !declared.includes(k))];
|
|
118
|
+
if (!keys.length) return '<i>empty</i>';
|
|
119
|
+
return `<table>${keys.map((k) => `<tr><th>${escape(k)}</th><td>${view(value[k] as Json, (props(schema).find(([n]) => n === k)?.[1] as JsonObject | undefined))}</td></tr>`).join('')}</table>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The three words for "no object", and the object, each with a face. An
|
|
123
|
+
// error object is the answer she gave, marked; silence and unreached are
|
|
124
|
+
// what the side says about them, since they carry nothing themselves.
|
|
125
|
+
export function face(w: Word, schema?: JsonObject): string {
|
|
126
|
+
if (w.word === 'object') return `<div class="answer">${view(w.value, schema)}</div>`;
|
|
127
|
+
if (w.word === 'error') return `<div class="answer error">${view(w.value)}</div>`;
|
|
128
|
+
if (w.word === 'silence') return `<div class="answer silence">${escape(SILENCE_TEXT)}</div>`;
|
|
129
|
+
if (w.word === 'quo') return `<div class="answer word">${escape(wordText(w.name))}</div>`;
|
|
130
|
+
return `<div class="answer unreached">${escape(UNREACHED_TEXT)}</div>`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- the page
|
|
134
|
+
|
|
135
|
+
// Everything the screen shows, as data. The side keeps one of these and
|
|
136
|
+
// hands it here after every change; the page is a function of it.
|
|
137
|
+
export type Model = {
|
|
138
|
+
blueprint: Blueprint | null; // her describe for this human, or nothing yet
|
|
139
|
+
notice: string; // one line about where the human stands: in, not in, an error before an ask
|
|
140
|
+
answers: Record<string, Word>; // the last answer per ask, shown under its form
|
|
141
|
+
pushes: JsonObject[]; // every push from the world, newest last
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// The title is the one hint the notes may carry: a string named `name`.
|
|
145
|
+
// The rest of the notes is shown as a view and read as nothing else.
|
|
146
|
+
export function title(bp: Blueprint | null): string {
|
|
147
|
+
const n = bp?.notes;
|
|
148
|
+
return n !== null && typeof n === 'object' && !Array.isArray(n) && typeof n.name === 'string' && n.name ? n.name : 'quo';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function page(m: Model): string {
|
|
152
|
+
const bp = m.blueprint;
|
|
153
|
+
const asks = bp
|
|
154
|
+
? bp.asks
|
|
155
|
+
.map((a) => {
|
|
156
|
+
const w = m.answers[a.name];
|
|
157
|
+
return `<section>${form(a)}${w ? face(w, a.output) : ''}</section>`;
|
|
158
|
+
})
|
|
159
|
+
.join('')
|
|
160
|
+
: '';
|
|
161
|
+
const notes = bp && bp.notes !== null && !(typeof bp.notes === 'object' && !Array.isArray(bp.notes) && !Object.keys(bp.notes).length) ? `<aside class="notes">${view(bp.notes)}</aside>` : '';
|
|
162
|
+
const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
|
|
163
|
+
return `<header><h1>${escape(title(bp))}</h1><p class="notice">${escape(m.notice)}</p></header>${notes}<main>${asks}</main>${pushes}`;
|
|
164
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# The human side
|
|
2
|
+
|
|
3
|
+
This is how a Quo world speaks to a human, and how a human speaks back. It
|
|
4
|
+
is one side of the avatar described in `packages/dock/beings/quo-dock.md`, which
|
|
5
|
+
owns the avatar, the front desk, the credential exchange and the shared
|
|
6
|
+
invariants; this document assumes all of that and adds only what a screen
|
|
7
|
+
needs. It names no transport and no model. A tab is a device with a harbor
|
|
8
|
+
of its own, and where its bytes go is `packages/dock/harbor/quo-harbor.md`.
|
|
9
|
+
|
|
10
|
+
The claim it will make good on: a human needs no page written for them. A
|
|
11
|
+
screen is a blueprint spoken as HTML. The user being's describe for this
|
|
12
|
+
asker becomes the forms the human sees, a submitted form becomes a named
|
|
13
|
+
ask, an answer becomes a view, a push becomes a notification, and the three
|
|
14
|
+
words for "no object" each have a face. Nothing the screen shows exists
|
|
15
|
+
anywhere but in a blueprint, so every world gets a screen for free and no
|
|
16
|
+
world gets a screen it did not describe.
|
|
17
|
+
|
|
18
|
+
What this document decides, in its own sitting:
|
|
19
|
+
|
|
20
|
+
- how a JSON Schema input becomes a form without a designer;
|
|
21
|
+
- how an output schema, and an answer, become a view;
|
|
22
|
+
- what the blueprint's notes may carry as rendering hints without becoming
|
|
23
|
+
state, and what they may never carry;
|
|
24
|
+
- how a standing is a page, and a digest change is a refreshed page;
|
|
25
|
+
- how the same screen is a guest one minute and an occupant the next, and
|
|
26
|
+
what the human sees at each of the three words;
|
|
27
|
+
- how a native app shows many worlds: one screen per ward, a switcher
|
|
28
|
+
between them, and the device's own beings offered to each through gates;
|
|
29
|
+
- what one avatar class, one `side` implementation and one suite look like
|
|
30
|
+
for a screen, proven on the memory harbor with no browser at all.
|
|
31
|
+
|
|
32
|
+
Until that sitting, the only rule in force is the one law from the trunk:
|
|
33
|
+
the human is an occupant, reached through an avatar, seeing what the gate
|
|
34
|
+
shows.
|
|
35
|
+
|
|
36
|
+
## The screen today, minimal on purpose
|
|
37
|
+
|
|
38
|
+
The sitting above has not happened. What stands is the least screen that
|
|
39
|
+
speaks a blueprint as HTML with no page written for any world, so that the
|
|
40
|
+
rest of the dock is not held up by a design. Three pieces under
|
|
41
|
+
`packages/dock/human/`, and the tab that holds them:
|
|
42
|
+
|
|
43
|
+
- `html.ts` is pure, strings in and strings out, and is the screen's whole
|
|
44
|
+
vocabulary. An input schema is a form with one field per property by
|
|
45
|
+
type: a string is a text input, and `password`, `date`, `email` and
|
|
46
|
+
`time` are the input's type; a number is a number input; a boolean is a
|
|
47
|
+
checkbox; an enum is a select; an object or an array is a box for JSON.
|
|
48
|
+
The submitted strings come back as arguments typed by the schema, an
|
|
49
|
+
empty optional field left out. An answer is a view: a primitive is text,
|
|
50
|
+
an object a table of its keys in the output schema's order when she
|
|
51
|
+
declared one, a list of objects one table, null the word nothing. The
|
|
52
|
+
three words for "no object" each have a face, an error object marked,
|
|
53
|
+
silence and each of the ward's words saying what the side says of them.
|
|
54
|
+
The notes may carry one hint, a string `name` that titles the page; the
|
|
55
|
+
rest is shown as a view and read as nothing else.
|
|
56
|
+
- `screen.ts` is the side: one avatar and one surface, where a surface can
|
|
57
|
+
only show a page and hand back a submitted form. The side keeps the
|
|
58
|
+
model, speaks it as a page after every change, calls her when a form
|
|
59
|
+
comes back, and re-asks her describe after every call, so a digest that
|
|
60
|
+
moved is a page that moved. A push is appended and shown as it lands. A
|
|
61
|
+
describe that fails leaves the last page standing and says so in the
|
|
62
|
+
notice.
|
|
63
|
+
- `dom.ts` is the surface on an element, the one file that touches one.
|
|
64
|
+
- `tab.ts` is the harbor in the tab and the exchange, unchanged from the
|
|
65
|
+
first screen, and hands the avatar to the side.
|
|
66
|
+
|
|
67
|
+
Proven in `packages/dock/test/human.test.ts` on the memory harbor with a fake
|
|
68
|
+
surface and no browser, and in `packages/dock/test/terrain/browser.test.ts` in a
|
|
69
|
+
real Chromium against a daemon on loopback, behind `npm run check:terrain`.
|
|
70
|
+
|
|
71
|
+
What it leaves to the sitting above, deliberately: any design, a switcher
|
|
72
|
+
between worlds, the guest's page before the exchange, the native app, and
|
|
73
|
+
every rendering hint beyond `name`. None of it blocks a world from having
|
|
74
|
+
a screen today.
|
|
75
|
+
|
|
76
|
+
## The tab
|
|
77
|
+
|
|
78
|
+
The tab is a device. The page boots a browser harbor on a seed minted into
|
|
79
|
+
the tab's store the first time, one ward with one avatar in it, and dials
|
|
80
|
+
the world's `quo.` route. The exchange happens once: the owner password
|
|
81
|
+
goes to `/tab/login`, which answers a nonce and the ward pk, and the
|
|
82
|
+
avatar in the tab knocks the front desk over the socket with the nonce
|
|
83
|
+
under the `tab` proof kind, is handed the invitation the user being minted,
|
|
84
|
+
and joins her. From then on there is no cookie and no token anywhere, and
|
|
85
|
+
a reload asks nothing: the seed and the standing are in the tab's store.
|
|
86
|
+
Live on the lab at `web.lab.quo.systems/tab`.
|
package/human/screen.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The human side: one avatar, one surface. The surface is the least a
|
|
3
|
+
// screen can be: it shows a page and hands back a submitted form, and that
|
|
4
|
+
// is all this side asks of a terrain. A DOM is one surface; a test is
|
|
5
|
+
// another. The side keeps the model, speaks it as a page after every change
|
|
6
|
+
// through `html.ts`, calls the avatar when a form comes back, and re-asks
|
|
7
|
+
// her describe after each call, so a digest that moved is a page that
|
|
8
|
+
// moved. A push is appended and shown the moment it lands.
|
|
9
|
+
import { digest } from '@quo-systems/quo';
|
|
10
|
+
import type { Blueprint, JsonObject } from '@quo-systems/quo';
|
|
11
|
+
import type { Avatar } from '../beings/avatar.ts';
|
|
12
|
+
import { word, type Serving } from '../beings/side.ts';
|
|
13
|
+
import { page, values, type Model, type Raw } from './html.ts';
|
|
14
|
+
|
|
15
|
+
export type Surface = {
|
|
16
|
+
show(html: string): void;
|
|
17
|
+
// The surface calls this when a form is submitted: the ask's name and
|
|
18
|
+
// the fields as strings. Set once by the side.
|
|
19
|
+
onSubmit: ((ask: string, raw: Raw) => void) | undefined;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// `after` runs when a call is done, as it does for the model side: a harbor
|
|
23
|
+
// that must write what the ward changed hooks it.
|
|
24
|
+
export async function screenSide(avatar: Avatar, surface: Surface, after: () => Promise<void> = async () => {}, notice = ''): Promise<Serving & { model: Model; refresh(): Promise<void> }> {
|
|
25
|
+
const model: Model = { blueprint: null, notice, answers: {}, pushes: [] };
|
|
26
|
+
let seen: string | null = null;
|
|
27
|
+
const show = () => surface.show(page(model));
|
|
28
|
+
|
|
29
|
+
// Her describe, again: the page follows the digest.
|
|
30
|
+
const refresh = async () => {
|
|
31
|
+
const bp = await avatar.tools();
|
|
32
|
+
if ('error' in bp && !('asks' in bp)) {
|
|
33
|
+
// the last page she gave stays, with the answers under it, and the
|
|
34
|
+
// notice says where the human stands now
|
|
35
|
+
model.notice = `not in: ${bp.error}`;
|
|
36
|
+
} else {
|
|
37
|
+
const d = await digest(bp as Blueprint);
|
|
38
|
+
if (d !== seen) {
|
|
39
|
+
seen = d;
|
|
40
|
+
model.blueprint = bp as Blueprint;
|
|
41
|
+
for (const k of Object.keys(model.answers)) if (!(bp as Blueprint).asks.some((a) => a.name === k)) delete model.answers[k];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
show();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
surface.onSubmit = (name, raw) => void submit(name, raw);
|
|
48
|
+
const submit = async (name: string, raw: Raw) => {
|
|
49
|
+
const ask = model.blueprint?.asks.find((a) => a.name === name);
|
|
50
|
+
if (!ask) return;
|
|
51
|
+
const v = values(ask, raw);
|
|
52
|
+
if ('error' in v) {
|
|
53
|
+
model.answers[name] = { word: 'error', value: { error: v.error } };
|
|
54
|
+
return show();
|
|
55
|
+
}
|
|
56
|
+
model.answers[name] = word(await avatar.call(name, v.args));
|
|
57
|
+
await after();
|
|
58
|
+
await refresh();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const ear = (object: JsonObject) => {
|
|
62
|
+
model.pushes.push(object);
|
|
63
|
+
show();
|
|
64
|
+
};
|
|
65
|
+
avatar.ears.add(ear);
|
|
66
|
+
|
|
67
|
+
await refresh();
|
|
68
|
+
return {
|
|
69
|
+
model,
|
|
70
|
+
refresh,
|
|
71
|
+
close: async () => {
|
|
72
|
+
avatar.ears.delete(ear);
|
|
73
|
+
surface.onSubmit = undefined;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
package/human/tab.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The tab: a page in a tab that is a world. The tab boots a browser harbor
|
|
3
|
+
// on a seed minted into IndexedDB the first time, a ward with one avatar in
|
|
4
|
+
// it, and dials the world's quo. route. The exchange, once: the owner
|
|
5
|
+
// password goes to the web route, which mints a nonce the desk honours
|
|
6
|
+
// under the `tab` proof kind; the avatar in the tab knocks the desk over the
|
|
7
|
+
// socket with it, joins the user being, and from then on there is no cookie
|
|
8
|
+
// and no token anywhere. The second time the page opens, she already holds
|
|
9
|
+
// `user` and nothing is asked.
|
|
10
|
+
//
|
|
11
|
+
// The screen itself is the human side in `screen.ts` over the DOM surface
|
|
12
|
+
// in `dom.ts`: her describe as forms, an answer as a view, a push on the
|
|
13
|
+
// page. This file is the harbor and the exchange, and hands her over.
|
|
14
|
+
import type { Invitation } from '@quo-systems/quo';
|
|
15
|
+
import { BrowserHarbor } from '../harbor/browser.ts';
|
|
16
|
+
import { Avatar, USER } from '../beings/avatar.ts';
|
|
17
|
+
import { screenSide } from './screen.ts';
|
|
18
|
+
import { domSurface } from './dom.ts';
|
|
19
|
+
|
|
20
|
+
export type Config = { quo: string; web: string };
|
|
21
|
+
|
|
22
|
+
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, text = '', attrs: Record<string, string> = {}): HTMLElementTagNameMap[K] => {
|
|
23
|
+
const e = document.createElement(tag);
|
|
24
|
+
if (text) e.textContent = text;
|
|
25
|
+
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
|
|
26
|
+
return e;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export async function start(cfg: Config, root: HTMLElement = document.body): Promise<void> {
|
|
30
|
+
const status = el('p', 'booting');
|
|
31
|
+
const screen = el('div');
|
|
32
|
+
root.append(status, screen);
|
|
33
|
+
const say = (s: string) => (status.textContent = s);
|
|
34
|
+
|
|
35
|
+
// The harbor in the tab: one database, one ward, one avatar, for good.
|
|
36
|
+
const harbor = new BrowserHarbor('quo');
|
|
37
|
+
await harbor.boot();
|
|
38
|
+
const main = harbor.wards.get('main') ?? (await harbor.create('main', 'me'));
|
|
39
|
+
if (!main.being('me')) await main.ask('boot', { key: 'me', class: 'Avatar' });
|
|
40
|
+
const me = main.being('me') as Avatar;
|
|
41
|
+
harbor.dial(cfg.quo);
|
|
42
|
+
|
|
43
|
+
// The screen: every call rotates her keys and a same-ward ask never
|
|
44
|
+
// crosses the harbor, so the side saves after each one.
|
|
45
|
+
const show = async (notice: string) => {
|
|
46
|
+
status.remove();
|
|
47
|
+
await screenSide(me, domSurface(screen), () => main.save(), notice);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Already in: reconnect, nothing minted.
|
|
51
|
+
const had = await me.tools();
|
|
52
|
+
if ('asks' in had) return show('in, as before');
|
|
53
|
+
|
|
54
|
+
// The exchange, once: the owner password for a nonce, the nonce for an
|
|
55
|
+
// invitation the user being mints, the invitation for a standing.
|
|
56
|
+
const form = el('form');
|
|
57
|
+
const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
|
|
58
|
+
const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
|
|
59
|
+
const go = el('button', 'enter');
|
|
60
|
+
form.append(identity, password, go);
|
|
61
|
+
root.insertBefore(form, screen);
|
|
62
|
+
say(`not in (${had.error}): the owner password opens the world`);
|
|
63
|
+
form.onsubmit = async (ev) => {
|
|
64
|
+
ev.preventDefault();
|
|
65
|
+
say('asking the world');
|
|
66
|
+
let res: Response;
|
|
67
|
+
try {
|
|
68
|
+
res = await fetch(`${cfg.web}/tab/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return say(`the world did not answer: ${(e as Error).message}`);
|
|
71
|
+
}
|
|
72
|
+
if (!res.ok) return say(`refused: ${(await res.json().catch(() => ({ error: res.status })) as { error?: string }).error ?? res.status}`);
|
|
73
|
+
const { nonce, ward } = (await res.json()) as { nonce: string; ward: string };
|
|
74
|
+
password.value = '';
|
|
75
|
+
say('knocking');
|
|
76
|
+
const got = await me.enter({ ward } as Invitation, { kind: 'tab', nonce });
|
|
77
|
+
await main.save(); // the knock went through her own door, which the harbor never sees
|
|
78
|
+
if (!('asks' in got)) return say(`not in: ${got.error}`);
|
|
79
|
+
form.remove();
|
|
80
|
+
await show(`in, as ${identity.value}`);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export { USER };
|
package/mcp/agent.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The model side as an event-driven agent: the world is the loop. The user
|
|
3
|
+
// being holds a standing to the agent's avatar and asks her when something
|
|
4
|
+
// happens; that ask is a push, and her `notify` answers at once, so the ask
|
|
5
|
+
// is never held open. The push lands in her inbox, which is the queue, in
|
|
6
|
+
// her cells. This side drains the inbox one event at a time: it starts a
|
|
7
|
+
// run with the event, and when the run ends she asks the user being back on
|
|
8
|
+
// her own standing with the result. One run at a time, in the order the
|
|
9
|
+
// events came, across a restart too, because the queue is in the partition
|
|
10
|
+
// and not in this process. What a run may do is the gate; how it behaves
|
|
11
|
+
// is its constitution, the folder it runs in.
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import type { Json, JsonObject } from '@quo-systems/quo';
|
|
14
|
+
import type { Avatar } from '../beings/avatar.ts';
|
|
15
|
+
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT, type Serving } from '../beings/side.ts';
|
|
16
|
+
|
|
17
|
+
// A run: the event in, a result out. Two exist here: any function, and a
|
|
18
|
+
// process in a folder. The runner of runner.ts is a third when handed as a
|
|
19
|
+
// function.
|
|
20
|
+
export type Run = (event: JsonObject) => Promise<Json>;
|
|
21
|
+
|
|
22
|
+
// The ask on the user being that takes the result. Gated to devices; the
|
|
23
|
+
// agent's client identity is what the gate reads.
|
|
24
|
+
export const REPORT = 'report';
|
|
25
|
+
|
|
26
|
+
// `claude -p` or any CLI agent: the command runs in the agent's folder with
|
|
27
|
+
// the event, as JSON, as its last argument. The result is what it printed.
|
|
28
|
+
// A non-zero exit is an error object with what it said on stderr.
|
|
29
|
+
export function processRun(command: string, args: string[], dir: string): Run {
|
|
30
|
+
return (event) =>
|
|
31
|
+
new Promise((ok) => {
|
|
32
|
+
const child = spawn(command, [...args, JSON.stringify(event)], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
33
|
+
let out = '';
|
|
34
|
+
let err = '';
|
|
35
|
+
child.stdout.on('data', (c: Buffer) => (out += c.toString()));
|
|
36
|
+
child.stderr.on('data', (c: Buffer) => (err += c.toString()));
|
|
37
|
+
child.on('error', (e) => ok({ error: e.message }));
|
|
38
|
+
child.on('close', (code) => ok(code === 0 ? { output: out.trim() } : { error: `exit ${code}`, stderr: err.trim() }));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type Agent = Serving & {
|
|
43
|
+
// Resolves when the queue is empty and no run is in flight.
|
|
44
|
+
idle(): Promise<void>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// `after` runs when her cells changed and when an ask is done, as in the
|
|
48
|
+
// other sides: a harbor that must write what the ward changed hooks it.
|
|
49
|
+
export function agentSide(avatar: Avatar, run: Run, after: () => Promise<void> = async () => {}): Agent {
|
|
50
|
+
let draining: Promise<void> = Promise.resolve();
|
|
51
|
+
let open = true;
|
|
52
|
+
const inbox = () => avatar.cells.inbox as JsonObject[];
|
|
53
|
+
|
|
54
|
+
async function one(event: JsonObject): Promise<void> {
|
|
55
|
+
let result: Json;
|
|
56
|
+
try {
|
|
57
|
+
result = await run(event);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
result = { error: e instanceof Error ? e.message : String(e) };
|
|
60
|
+
}
|
|
61
|
+
// The report is an ask like any other. Silence and unreached are told
|
|
62
|
+
// apart and dropped: a report is never retried, and never held.
|
|
63
|
+
const w = word(await avatar.call(REPORT, { event, result }));
|
|
64
|
+
if (w.word === 'silence' || w.word === 'unreached' || w.word === 'quo') say({ dropped: w.word === 'silence' ? SILENCE_TEXT : w.word === 'quo' ? wordText(w.name) : UNREACHED_TEXT });
|
|
65
|
+
await after();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// One at a time, first in first out, until the inbox is empty.
|
|
69
|
+
function drain(): Promise<void> {
|
|
70
|
+
draining = draining.then(async () => {
|
|
71
|
+
while (open && inbox().length > 0) {
|
|
72
|
+
const event = inbox().shift()!;
|
|
73
|
+
await after(); // the event left the queue before the run starts: a crash mid-run loses it, and never runs it twice
|
|
74
|
+
await one(event);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return draining;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A dropped report is told to her other sides, if any: the agent itself has no ear for it.
|
|
81
|
+
const say = (object: JsonObject) => { for (const ear of avatar.ears) if (ear !== ear0) ear(object); };
|
|
82
|
+
const ear0 = () => void drain();
|
|
83
|
+
avatar.ears.add(ear0);
|
|
84
|
+
void drain(); // whatever waited while no side was up
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
idle: () => draining,
|
|
88
|
+
close: async () => {
|
|
89
|
+
open = false;
|
|
90
|
+
avatar.ears.delete(ear0);
|
|
91
|
+
await draining;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
package/mcp/http.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The MCP endpoint on the mcp route: streamable HTTP, one session per
|
|
3
|
+
// client connection, one avatar per client identity. A bearer names the
|
|
4
|
+
// identity, the identity names the avatar, and the side from `server.ts`
|
|
5
|
+
// runs beside her exactly as it does over stdio. A session belongs to the
|
|
6
|
+
// identity that opened it; a bearer of another identity on the same session
|
|
7
|
+
// is refused. Sessions live in memory and die with the daemon, which is
|
|
8
|
+
// what a reconnect is for: the standing is the state, and it is on disk. A
|
|
9
|
+
// client that goes away without a DELETE leaves a session behind; one idle
|
|
10
|
+
// for an hour is dropped, since HTTP tells the server nothing else.
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
13
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
14
|
+
import type { Avatar } from '../beings/avatar.ts';
|
|
15
|
+
import type { Serving } from '../beings/side.ts';
|
|
16
|
+
import { mcpSide } from './server.ts';
|
|
17
|
+
|
|
18
|
+
export type Session = { identity: string; transport: StreamableHTTPServerTransport; serving: Serving; touched: number };
|
|
19
|
+
export const SESSION_IDLE = 60 * 60 * 1000;
|
|
20
|
+
export type Resolve = (identity: string) => Promise<{ avatar?: Avatar; error?: string }>;
|
|
21
|
+
|
|
22
|
+
export class McpHttp {
|
|
23
|
+
readonly sessions = new Map<string, Session>();
|
|
24
|
+
readonly resolve: Resolve;
|
|
25
|
+
readonly after: () => Promise<void>;
|
|
26
|
+
now: () => number = () => Date.now();
|
|
27
|
+
// What to do with an identity the user being has removed: the route's
|
|
28
|
+
// revoke, so the client's tokens go with the occupant. Set by whoever
|
|
29
|
+
// mounts the route beside the credential exchange.
|
|
30
|
+
gone: (identity: string) => Promise<void> = async () => {};
|
|
31
|
+
constructor(resolve: Resolve, after: () => Promise<void> = async () => {}) {
|
|
32
|
+
this.resolve = resolve;
|
|
33
|
+
this.after = after;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// `identity` is what the bearer named; the caller has already turned a
|
|
37
|
+
// stranger away. A request on a known session goes to it. A request with
|
|
38
|
+
// no session opens one, if it is an initialize; anything else is 400.
|
|
39
|
+
async handle(req: IncomingMessage, res: ServerResponse, identity: string): Promise<void> {
|
|
40
|
+
const id = req.headers['mcp-session-id'];
|
|
41
|
+
const sid = Array.isArray(id) ? id[0] : id;
|
|
42
|
+
const json = (status: number, body: unknown) => {
|
|
43
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
44
|
+
res.end(JSON.stringify(body));
|
|
45
|
+
};
|
|
46
|
+
this.sweep();
|
|
47
|
+
if (sid !== undefined) {
|
|
48
|
+
const s = this.sessions.get(sid);
|
|
49
|
+
if (!s) return json(404, { jsonrpc: '2.0', error: { code: -32001, message: 'no such session' }, id: null });
|
|
50
|
+
if (s.identity !== identity) return json(403, { jsonrpc: '2.0', error: { code: -32003, message: 'not your session' }, id: null });
|
|
51
|
+
s.touched = this.now();
|
|
52
|
+
await s.transport.handleRequest(req, res);
|
|
53
|
+
if (req.method === 'DELETE') this.drop(sid);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (req.method !== 'POST') return json(400, { jsonrpc: '2.0', error: { code: -32000, message: 'no session' }, id: null });
|
|
57
|
+
const found = await this.resolve(identity);
|
|
58
|
+
// A session opens on her describe, and admit already asked it. The one
|
|
59
|
+
// word the door says for an identity the user being removed is
|
|
60
|
+
// `removed`, under the key it bound for her avatar: in MCP's vocabulary
|
|
61
|
+
// that is 401, the client drops its token and starts the exchange again,
|
|
62
|
+
// and the route forgets the grant.
|
|
63
|
+
if (found.error === 'removed') {
|
|
64
|
+
await this.gone(identity);
|
|
65
|
+
for (const [sid, s] of this.sessions) if (s.identity === identity) this.drop(sid);
|
|
66
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
67
|
+
return void res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'removed: the user being removed this identity' }, id: null }));
|
|
68
|
+
}
|
|
69
|
+
if (!found.avatar) return json(500, { jsonrpc: '2.0', error: { code: -32002, message: found.error ?? 'no avatar' }, id: null });
|
|
70
|
+
const transport = new StreamableHTTPServerTransport({
|
|
71
|
+
sessionIdGenerator: () => randomUUID(),
|
|
72
|
+
onsessioninitialized: (s) => {
|
|
73
|
+
this.sessions.set(s, { identity, transport, serving, touched: this.now() });
|
|
74
|
+
},
|
|
75
|
+
onsessionclosed: (s) => this.drop(s),
|
|
76
|
+
});
|
|
77
|
+
const serving = await mcpSide(found.avatar, transport, this.after);
|
|
78
|
+
await transport.handleRequest(req, res);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
sweep() {
|
|
82
|
+
const now = this.now();
|
|
83
|
+
for (const [sid, s] of this.sessions) if (now - s.touched > SESSION_IDLE) this.drop(sid);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
drop(sid: string) {
|
|
87
|
+
const s = this.sessions.get(sid);
|
|
88
|
+
if (!s) return;
|
|
89
|
+
this.sessions.delete(sid);
|
|
90
|
+
void s.serving.close().catch(() => {});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async close() {
|
|
94
|
+
for (const sid of [...this.sessions.keys()]) this.drop(sid);
|
|
95
|
+
}
|
|
96
|
+
}
|