@quo-systems/dock 0.2.4 → 0.2.5
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/carry.ts +7 -3
- package/beings/desk.ts +5 -1
- package/beings/look.ts +12 -3
- package/beings/quo-dock.md +16 -13
- package/dist/beings/carry.js +10 -4
- package/dist/beings/desk.d.ts +0 -1
- package/dist/beings/desk.js +5 -1
- package/dist/beings/look.d.ts +2 -0
- package/dist/beings/look.js +9 -0
- package/dist/harbor/browser.d.ts +4 -4
- package/dist/harbor/browser.js +18 -9
- package/dist/harbor/capacitor.d.ts +3 -0
- package/dist/harbor/capacitor.js +16 -0
- package/dist/human/html.d.ts +1 -0
- package/dist/human/html.js +37 -18
- package/dist/human/screen.js +16 -10
- package/dist/human/web.js +7 -1
- package/dist/mcp/runner.js +5 -3
- package/dist/mcp/server.js +5 -3
- package/harbor/browser.ts +28 -17
- package/harbor/capacitor.ts +17 -0
- package/harbor/quo-harbor.md +20 -8
- package/human/html.ts +36 -17
- package/human/quo-human.md +17 -6
- package/human/screen.ts +14 -8
- package/human/web.ts +7 -1
- package/mcp/runner.ts +5 -3
- package/mcp/server.ts +5 -3
- package/package.json +3 -2
package/beings/carry.ts
CHANGED
|
@@ -58,16 +58,20 @@ export class Carrier extends Being {
|
|
|
58
58
|
if (!st) continue;
|
|
59
59
|
if (!rec.blueprint || rec.seen !== rec.digest) await st.ask(); // her ward writes the record
|
|
60
60
|
if (!rec.blueprint) continue;
|
|
61
|
+
// her look is kept here once per digest and never carried as an ask;
|
|
62
|
+
// her page follows every call, so it is carried as one and named in
|
|
63
|
+
// the notes, where every side finds it and none takes it for a form
|
|
61
64
|
const names: string[] = [];
|
|
65
|
+
const group: { asks: string[]; look?: Look; page?: string } = { asks: names };
|
|
62
66
|
for (const a of rec.blueprint.asks) {
|
|
63
|
-
if (a.name === 'look'
|
|
67
|
+
if (a.name === 'look') continue;
|
|
64
68
|
const name = `${id}-${a.name}`;
|
|
65
69
|
if (own.has(name)) continue;
|
|
66
70
|
carried[name] = { id, ask: a.name };
|
|
67
|
-
names.push(name);
|
|
68
71
|
bp.asks.push({ ...a, name });
|
|
72
|
+
if (a.name === 'page') group.page = name;
|
|
73
|
+
else names.push(name);
|
|
69
74
|
}
|
|
70
|
-
const group: { asks: string[]; look?: Look } = { asks: names };
|
|
71
75
|
if (rec.blueprint.asks.some((a) => a.name === 'look')) {
|
|
72
76
|
const kept = this.looks[id];
|
|
73
77
|
if (!kept || kept.digest !== rec.digest) {
|
package/beings/desk.ts
CHANGED
|
@@ -25,8 +25,12 @@ export class Desk extends Being {
|
|
|
25
25
|
device: { description: 'trade a proof for a device invitation', input: { type: 'object', properties: { proof: { type: 'object' } }, required: ['proof'] } },
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
// A stranger learns the proof kinds and nothing else. Who lives on this
|
|
29
|
+
// world is not the caller's to know: the verifier names the user, so
|
|
30
|
+
// nobody at the door ever has to, and a world with a thousand of them
|
|
31
|
+
// would otherwise hand the whole list to anyone who knocked.
|
|
28
32
|
hello(_args: JsonObject, asker: Asker) {
|
|
29
|
-
return { desk: true, asker: asker.id ?? null, kinds: Object.keys(Desk.verifiers)
|
|
33
|
+
return { desk: true, asker: asker.id ?? null, kinds: Object.keys(Desk.verifiers) };
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
async device(args: JsonObject) {
|
package/beings/look.ts
CHANGED
|
@@ -75,15 +75,24 @@ export function sanitise(v: Json | undefined): Look {
|
|
|
75
75
|
// The one hint a side reads first: whether this ask is in the look at all.
|
|
76
76
|
export const hint = (l: Look | undefined, name: string): Hint => l?.asks?.[name] ?? {};
|
|
77
77
|
|
|
78
|
-
// A carrier's notes say which asks are a standing's,
|
|
79
|
-
// `standings: { id: { asks: [name], look
|
|
80
|
-
|
|
78
|
+
// A carrier's notes say which asks are a standing's, how she looks, and
|
|
79
|
+
// which carried ask is her page: `standings: { id: { asks: [name], look?,
|
|
80
|
+
// page? } }`. Every side reads them here.
|
|
81
|
+
export type Group = { asks?: string[]; look?: Json; page?: string };
|
|
81
82
|
export function groups(bp: Blueprint | null): Record<string, Group> {
|
|
82
83
|
const n = bp?.notes;
|
|
83
84
|
const g = n !== null && n !== undefined && typeof n === 'object' && !Array.isArray(n) ? n.standings : undefined;
|
|
84
85
|
return g !== null && g !== undefined && typeof g === 'object' && !Array.isArray(g) ? (g as Record<string, Group>) : {};
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
// The asks that are presentation and never a form nor a tool: her look, her
|
|
89
|
+
// page, and the page of every standing she carries, named in her notes.
|
|
90
|
+
export function presentation(bp: Blueprint | null): Set<string> {
|
|
91
|
+
const p = new Set(['look', 'page']);
|
|
92
|
+
for (const g of Object.values(groups(bp))) if (typeof g.page === 'string') p.add(g.page);
|
|
93
|
+
return p;
|
|
94
|
+
}
|
|
95
|
+
|
|
87
96
|
// The hint for one ask, hers or a standing's: a carried ask is looked up
|
|
88
97
|
// under its bare name in that standing's look.
|
|
89
98
|
export function hintFor(bp: Blueprint | null, mine: Look, name: string): Hint {
|
package/beings/quo-dock.md
CHANGED
|
@@ -278,14 +278,15 @@ Carrying is a base, `beings/carry.ts`, and nothing in it knows what a user
|
|
|
278
278
|
is: a house being could carry its rooms, a company its suppliers. A subclass
|
|
279
279
|
says who may see what she carries, and the user being says a device with the
|
|
280
280
|
`reach` note, written when the human ticked the box at the exchange. The
|
|
281
|
-
notes of a carrier's describe say which asks are whose,
|
|
282
|
-
`standings: { id: { asks, look
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
the
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
281
|
+
notes of a carrier's describe say which asks are whose, how each standing
|
|
282
|
+
looks and which carried ask is her page, `standings: { id: { asks, look?,
|
|
283
|
+
page? } }`, so a screen draws one section per standing, paints her page in
|
|
284
|
+
it when she has one, and a model side keeps the flat list it needs with no
|
|
285
|
+
page in it; the mapping back from a carried name to the standing is in her
|
|
286
|
+
cells and never parsed from the name. Her own asks come first and are never
|
|
287
|
+
shadowed. She asks a standing's describe again when its digest moved and its
|
|
288
|
+
`look` once per digest, so a describe of hers may cost one ask per stale
|
|
289
|
+
standing, the price of a page that is never more than one ask behind.
|
|
289
290
|
|
|
290
291
|
## The look
|
|
291
292
|
|
|
@@ -313,11 +314,13 @@ A look is not capability: it is never in a blueprint or a digest, Quo never
|
|
|
313
314
|
sees it, and a being without one renders and lists exactly as before. The
|
|
314
315
|
`look` ask itself is presentation and is neither a form nor a tool, and so
|
|
315
316
|
is `page`, the one other presentation ask, which a screen alone reads and
|
|
316
|
-
`packages/dock/human/quo-human.md` defines
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
317
|
+
`packages/dock/human/quo-human.md` defines. A carrier keeps each standing's
|
|
318
|
+
look beside her asks in the notes, since a look moves with the digest, and
|
|
319
|
+
carries her page as an ask named there, since a page moves with every call;
|
|
320
|
+
so acme's section on the human's page is painted as acme asked, her look
|
|
321
|
+
and her page, inside that section and nowhere else. No token can carry a
|
|
322
|
+
URL, a stylesheet or code, so a far being cannot paint over the page or
|
|
323
|
+
reach a server through it. A being who
|
|
321
324
|
marks an ask read-only and then writes has lied to her own page, and nothing
|
|
322
325
|
enforces it, the way nothing enforces that her empty ask is safe to repeat.
|
|
323
326
|
|
package/dist/beings/carry.js
CHANGED
|
@@ -56,18 +56,24 @@ export class Carrier extends Being {
|
|
|
56
56
|
await st.ask(); // her ward writes the record
|
|
57
57
|
if (!rec.blueprint)
|
|
58
58
|
continue;
|
|
59
|
+
// her look is kept here once per digest and never carried as an ask;
|
|
60
|
+
// her page follows every call, so it is carried as one and named in
|
|
61
|
+
// the notes, where every side finds it and none takes it for a form
|
|
59
62
|
const names = [];
|
|
63
|
+
const group = { asks: names };
|
|
60
64
|
for (const a of rec.blueprint.asks) {
|
|
61
|
-
if (a.name === 'look'
|
|
62
|
-
continue;
|
|
65
|
+
if (a.name === 'look')
|
|
66
|
+
continue;
|
|
63
67
|
const name = `${id}-${a.name}`;
|
|
64
68
|
if (own.has(name))
|
|
65
69
|
continue;
|
|
66
70
|
carried[name] = { id, ask: a.name };
|
|
67
|
-
names.push(name);
|
|
68
71
|
bp.asks.push({ ...a, name });
|
|
72
|
+
if (a.name === 'page')
|
|
73
|
+
group.page = name;
|
|
74
|
+
else
|
|
75
|
+
names.push(name);
|
|
69
76
|
}
|
|
70
|
-
const group = { asks: names };
|
|
71
77
|
if (rec.blueprint.asks.some((a) => a.name === 'look')) {
|
|
72
78
|
const kept = this.looks[id];
|
|
73
79
|
if (!kept || kept.digest !== rec.digest) {
|
package/dist/beings/desk.d.ts
CHANGED
package/dist/beings/desk.js
CHANGED
|
@@ -17,8 +17,12 @@ export class Desk extends Being {
|
|
|
17
17
|
hello: { description: 'how to get in', input: { type: 'object' } },
|
|
18
18
|
device: { description: 'trade a proof for a device invitation', input: { type: 'object', properties: { proof: { type: 'object' } }, required: ['proof'] } },
|
|
19
19
|
};
|
|
20
|
+
// A stranger learns the proof kinds and nothing else. Who lives on this
|
|
21
|
+
// world is not the caller's to know: the verifier names the user, so
|
|
22
|
+
// nobody at the door ever has to, and a world with a thousand of them
|
|
23
|
+
// would otherwise hand the whole list to anyone who knocked.
|
|
20
24
|
hello(_args, asker) {
|
|
21
|
-
return { desk: true, asker: asker.id ?? null, kinds: Object.keys(Desk.verifiers)
|
|
25
|
+
return { desk: true, asker: asker.id ?? null, kinds: Object.keys(Desk.verifiers) };
|
|
22
26
|
}
|
|
23
27
|
async device(args) {
|
|
24
28
|
const proof = args.proof;
|
package/dist/beings/look.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export declare const hint: (l: Look | undefined, name: string) => Hint;
|
|
|
22
22
|
export type Group = {
|
|
23
23
|
asks?: string[];
|
|
24
24
|
look?: Json;
|
|
25
|
+
page?: string;
|
|
25
26
|
};
|
|
26
27
|
export declare function groups(bp: Blueprint | null): Record<string, Group>;
|
|
28
|
+
export declare function presentation(bp: Blueprint | null): Set<string>;
|
|
27
29
|
export declare function hintFor(bp: Blueprint | null, mine: Look, name: string): Hint;
|
package/dist/beings/look.js
CHANGED
|
@@ -58,6 +58,15 @@ export function groups(bp) {
|
|
|
58
58
|
const g = n !== null && n !== undefined && typeof n === 'object' && !Array.isArray(n) ? n.standings : undefined;
|
|
59
59
|
return g !== null && g !== undefined && typeof g === 'object' && !Array.isArray(g) ? g : {};
|
|
60
60
|
}
|
|
61
|
+
// The asks that are presentation and never a form nor a tool: her look, her
|
|
62
|
+
// page, and the page of every standing she carries, named in her notes.
|
|
63
|
+
export function presentation(bp) {
|
|
64
|
+
const p = new Set(['look', 'page']);
|
|
65
|
+
for (const g of Object.values(groups(bp)))
|
|
66
|
+
if (typeof g.page === 'string')
|
|
67
|
+
p.add(g.page);
|
|
68
|
+
return p;
|
|
69
|
+
}
|
|
61
70
|
// The hint for one ask, hers or a standing's: a carried ask is looked up
|
|
62
71
|
// under its bare name in that standing's look.
|
|
63
72
|
export function hintFor(bp, mine, name) {
|
package/dist/harbor/browser.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { BeingClass } from '@quo-systems/quo';
|
|
2
|
-
import { Harbor, type Dialer } from '@quo-systems/quo/harbor';
|
|
3
|
-
import { Idb } from './idb.ts';
|
|
2
|
+
import { Harbor, type Dialer, type Store } from '@quo-systems/quo/harbor';
|
|
4
3
|
export declare const BUILT_IN: Record<string, BeingClass>;
|
|
5
4
|
export declare class BrowserHarbor extends Harbor {
|
|
6
5
|
#private;
|
|
7
|
-
readonly
|
|
6
|
+
readonly name: string;
|
|
8
7
|
readonly dialers: Dialer[];
|
|
9
|
-
constructor(
|
|
8
|
+
constructor(store?: string | Store, classes?: Record<string, BeingClass>);
|
|
10
9
|
boot(): Promise<void>;
|
|
11
10
|
dial(url: string): Dialer;
|
|
11
|
+
wake(): void;
|
|
12
12
|
close(): Promise<void>;
|
|
13
13
|
}
|
package/dist/harbor/browser.js
CHANGED
|
@@ -3,18 +3,21 @@ import { Harbor, dial } from '@quo-systems/quo/harbor';
|
|
|
3
3
|
import { Idb } from './idb.js';
|
|
4
4
|
export const BUILT_IN = { User, Desk, Avatar };
|
|
5
5
|
export class BrowserHarbor extends Harbor {
|
|
6
|
-
|
|
6
|
+
name;
|
|
7
7
|
dialers = [];
|
|
8
|
+
#locked;
|
|
8
9
|
#release;
|
|
9
|
-
constructor(
|
|
10
|
-
const
|
|
11
|
-
super(
|
|
12
|
-
this.
|
|
10
|
+
constructor(store = 'quo', classes = {}) {
|
|
11
|
+
const s = typeof store === 'string' ? new Idb(store) : store;
|
|
12
|
+
super(s, async () => ({ ...BUILT_IN, ...classes }));
|
|
13
|
+
this.name = typeof store === 'string' ? store : 'quo';
|
|
14
|
+
this.#locked = typeof store === 'string';
|
|
13
15
|
}
|
|
14
16
|
// Take the lock and boot every ward kept here. A lock already held is
|
|
15
17
|
// another tab's harbor over this seed, and this one refuses to boot.
|
|
16
18
|
async boot() {
|
|
17
|
-
|
|
19
|
+
if (this.#locked)
|
|
20
|
+
await this.#lease();
|
|
18
21
|
await super.boot();
|
|
19
22
|
}
|
|
20
23
|
// Hold one socket to a world's quo. route.
|
|
@@ -23,10 +26,16 @@ export class BrowserHarbor extends Harbor {
|
|
|
23
26
|
this.dialers.push(d);
|
|
24
27
|
return d;
|
|
25
28
|
}
|
|
29
|
+
// The device is back: a phone out of a pocket, an app to the foreground.
|
|
30
|
+
// Every socket is told, and one that died silently is dialed again.
|
|
31
|
+
wake() {
|
|
32
|
+
for (const d of this.dialers)
|
|
33
|
+
d.wake();
|
|
34
|
+
}
|
|
26
35
|
async close() {
|
|
27
36
|
for (const d of this.dialers.splice(0))
|
|
28
37
|
d.close();
|
|
29
|
-
this.
|
|
38
|
+
this.store.close?.();
|
|
30
39
|
this.#release?.();
|
|
31
40
|
this.#release = undefined;
|
|
32
41
|
}
|
|
@@ -35,7 +44,7 @@ export class BrowserHarbor extends Harbor {
|
|
|
35
44
|
if (!locks)
|
|
36
45
|
return; // no web locks on this terrain: nothing vouches, as in the memory harbor
|
|
37
46
|
const granted = await new Promise((ok) => {
|
|
38
|
-
void locks.request(`quo:${this.
|
|
47
|
+
void locks.request(`quo:${this.name}`, { ifAvailable: true }, (lock) => {
|
|
39
48
|
if (!lock) {
|
|
40
49
|
ok(false);
|
|
41
50
|
return;
|
|
@@ -45,6 +54,6 @@ export class BrowserHarbor extends Harbor {
|
|
|
45
54
|
});
|
|
46
55
|
});
|
|
47
56
|
if (!granted)
|
|
48
|
-
throw new Error(`harbor ${this.
|
|
57
|
+
throw new Error(`harbor ${this.name} is held by another tab`);
|
|
49
58
|
}
|
|
50
59
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import type { BeingClass } from '@quo-systems/quo';
|
|
1
2
|
import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
|
|
3
|
+
import { BrowserHarbor } from './browser.ts';
|
|
4
|
+
export declare function nativeHarbor(name?: string, classes?: Record<string, BeingClass>): Promise<BrowserHarbor>;
|
|
2
5
|
export declare class Native implements Store {
|
|
3
6
|
#private;
|
|
4
7
|
readonly harbor: string;
|
package/dist/harbor/capacitor.js
CHANGED
|
@@ -12,11 +12,27 @@
|
|
|
12
12
|
// with no folder is a reinstall, and the stale key is deleted; a folder
|
|
13
13
|
// with no key is a restore to another device, and the unreadable files are
|
|
14
14
|
// deleted. Either way the harbor starts fresh and there is never a twin.
|
|
15
|
+
//
|
|
16
|
+
// The harbor on a phone is the browser harbor over this store: the app
|
|
17
|
+
// process is its lease, and the App plugin's foreground is its wake.
|
|
15
18
|
import { Capacitor } from '@capacitor/core';
|
|
19
|
+
import { App } from '@capacitor/app';
|
|
16
20
|
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
|
17
21
|
import { SecureStorage, KeychainAccess } from '@aparajita/capacitor-secure-storage';
|
|
18
22
|
import { arithmetic } from '@quo-systems/quo/ward';
|
|
23
|
+
import { BrowserHarbor } from './browser.js';
|
|
19
24
|
import { sealKey, seal, open } from './seal.js';
|
|
25
|
+
// The app's one harbor, booted, told `wake` every time the app comes to
|
|
26
|
+
// the foreground, since a phone asleep loses its sockets silently.
|
|
27
|
+
export async function nativeHarbor(name = 'quo', classes = {}) {
|
|
28
|
+
const h = new BrowserHarbor(await Native.open(name), classes);
|
|
29
|
+
await h.boot();
|
|
30
|
+
await App.addListener('appStateChange', ({ isActive }) => {
|
|
31
|
+
if (isActive)
|
|
32
|
+
h.wake();
|
|
33
|
+
});
|
|
34
|
+
return h;
|
|
35
|
+
}
|
|
20
36
|
const { hex, unhex } = arithmetic;
|
|
21
37
|
// Where the files live: the folder iCloud does not copy on iOS, the app's
|
|
22
38
|
// own files on Android, whose manifest says no backup.
|
package/dist/human/html.d.ts
CHANGED
package/dist/human/html.js
CHANGED
|
@@ -1,5 +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
|
+
import { hint, hintFor, sanitise, groups as grouped, presentation } from '../beings/look.js';
|
|
3
3
|
import { paint } from './tree.js';
|
|
4
4
|
export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
5
5
|
const props = (schema) => Object.entries((schema?.properties ?? {}));
|
|
@@ -154,17 +154,35 @@ export function ordered(asks, l) {
|
|
|
154
154
|
return [...want.map((n) => asks.find((a) => a.name === n)).filter((a) => a !== undefined), ...asks.filter((a) => !want.includes(a.name))];
|
|
155
155
|
}
|
|
156
156
|
export { hintFor };
|
|
157
|
-
// The asks that are presentation and never a form: her look and her page.
|
|
158
|
-
const PRESENTATION = new Set(['look', 'page']);
|
|
159
157
|
export function page(m) {
|
|
160
158
|
const bp = m.blueprint;
|
|
159
|
+
const pres = presentation(bp);
|
|
161
160
|
const one = (l, prefix = '') => (a) => {
|
|
162
161
|
const w = m.answers[a.name];
|
|
163
162
|
const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
|
|
164
163
|
return `<section>${form(a, h)}${w ? face(w, a.output) : ''}</section>`;
|
|
165
164
|
};
|
|
165
|
+
// an answer shown as the page asks: as text, as cards, as a list, or by its shape
|
|
166
|
+
const answer = (name, as) => {
|
|
167
|
+
const a = bp?.asks.find((x) => x.name === name);
|
|
168
|
+
const w = a ? m.answers[name] : undefined;
|
|
169
|
+
if (!a || !w)
|
|
170
|
+
return '';
|
|
171
|
+
if (w.word === 'object' && as === 'text')
|
|
172
|
+
return `<div class="answer">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
|
|
173
|
+
if (w.word === 'object' && as === 'cards' && Array.isArray(w.value))
|
|
174
|
+
return `<div class="cards">${w.value.map((v) => `<div class="card">${view(v)}</div>`).join('')}</div>`;
|
|
175
|
+
if (w.word === 'object' && as === 'list' && Array.isArray(w.value))
|
|
176
|
+
return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
|
|
177
|
+
return face(w, a.output);
|
|
178
|
+
};
|
|
166
179
|
const groups = grouped(bp);
|
|
167
180
|
const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
|
|
181
|
+
// A standing's section. With her page, the far tree is painted inside it
|
|
182
|
+
// through a painter of her own: a name it uses is one of her asks, carried,
|
|
183
|
+
// and never one of the carrier's or another standing's, so a far page
|
|
184
|
+
// reaches nothing outside its section; her title is the page's, and the
|
|
185
|
+
// section keeps her look. Without one, her asks are forms under her name.
|
|
168
186
|
const section = (id) => {
|
|
169
187
|
const g = groups[id];
|
|
170
188
|
const asks = bp && g ? bp.asks.filter((a) => g.asks?.includes(a.name)) : [];
|
|
@@ -173,6 +191,19 @@ export function page(m) {
|
|
|
173
191
|
const kept = sanitise(g.look);
|
|
174
192
|
const l = look(kept);
|
|
175
193
|
const prefix = `${id}-`;
|
|
194
|
+
const tree = m.pages[id];
|
|
195
|
+
if (tree) {
|
|
196
|
+
const hers = {
|
|
197
|
+
form: (name) => {
|
|
198
|
+
const a = asks.find((x) => x.name === prefix + name);
|
|
199
|
+
return a ? one(kept, prefix)(a) : '';
|
|
200
|
+
},
|
|
201
|
+
answer: (name, as) => (asks.some((x) => x.name === prefix + name) ? answer(prefix + name, as) : ''),
|
|
202
|
+
standing: () => '',
|
|
203
|
+
standings: () => '',
|
|
204
|
+
};
|
|
205
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div></section>`;
|
|
206
|
+
}
|
|
176
207
|
const want = (kept.order ?? []).map((n) => prefix + n);
|
|
177
208
|
const inOrder = ordered(asks, { order: want });
|
|
178
209
|
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
|
|
@@ -182,26 +213,14 @@ export function page(m) {
|
|
|
182
213
|
// uses looked up in her describe, so a page shows nothing her gate hid.
|
|
183
214
|
const painter = {
|
|
184
215
|
form: (name) => {
|
|
185
|
-
const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !
|
|
216
|
+
const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !pres.has(x.name));
|
|
186
217
|
return a ? one(m.look)(a) : '';
|
|
187
218
|
},
|
|
188
|
-
answer: (name, as) =>
|
|
189
|
-
const a = bp?.asks.find((x) => x.name === name);
|
|
190
|
-
const w = a ? m.answers[name] : undefined;
|
|
191
|
-
if (!a || !w)
|
|
192
|
-
return '';
|
|
193
|
-
if (w.word === 'object' && as === 'text')
|
|
194
|
-
return `<div class="answer">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
|
|
195
|
-
if (w.word === 'object' && as === 'cards' && Array.isArray(w.value))
|
|
196
|
-
return `<div class="cards">${w.value.map((v) => `<div class="card">${view(v)}</div>`).join('')}</div>`;
|
|
197
|
-
if (w.word === 'object' && as === 'list' && Array.isArray(w.value))
|
|
198
|
-
return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
|
|
199
|
-
return face(w, a.output);
|
|
200
|
-
},
|
|
219
|
+
answer: (name, as) => (pres.has(name) ? '' : answer(name, as)),
|
|
201
220
|
standing: section,
|
|
202
221
|
standings: () => far,
|
|
203
222
|
};
|
|
204
|
-
const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !
|
|
223
|
+
const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !pres.has(a.name)), m.look).map(one(m.look)).join('') : '';
|
|
205
224
|
const asks = m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : own + far;
|
|
206
225
|
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;
|
|
207
226
|
const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown)}</aside>` : '';
|
package/dist/human/screen.js
CHANGED
|
@@ -11,12 +11,12 @@ import { word } from '../beings/side.js';
|
|
|
11
11
|
import { isInvitation } from '../beings/link.js';
|
|
12
12
|
import { page, values, hintFor } from './html.js';
|
|
13
13
|
import { sanitiseTree, answersIn } from './tree.js';
|
|
14
|
-
import { sanitise } from '../beings/look.js';
|
|
14
|
+
import { groups, sanitise } from '../beings/look.js';
|
|
15
15
|
import { isSilence, isWord } from '@quo-systems/quo';
|
|
16
16
|
export async function screenSide(avatar, surface, options = {}) {
|
|
17
17
|
const after = options.after ?? (async () => { });
|
|
18
18
|
const notice = options.notice ?? '';
|
|
19
|
-
const model = { blueprint: null, look: {}, tree: null, notice, answers: {}, pushes: [] };
|
|
19
|
+
const model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [] };
|
|
20
20
|
let seen = null;
|
|
21
21
|
const show = () => surface.show(page(model));
|
|
22
22
|
// Her describe, again: the page follows the digest.
|
|
@@ -47,14 +47,20 @@ export async function screenSide(avatar, surface, options = {}) {
|
|
|
47
47
|
// her page, after every ask: a tree of values from what she holds now, or nothing and her asks
|
|
48
48
|
// are forms. A cell moved is a page moved, and her describe need not have; so the answers the
|
|
49
49
|
// page shows are run again with it, and the page never stands more than one ask behind her
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
const trees = () => [model.tree ? answersIn(model.tree) : [], ...Object.entries(model.pages).map(([id, t]) => (t ? answersIn(t).map((n) => `${id}-${n}`) : []))].flat();
|
|
51
|
+
const shows = new Set(trees());
|
|
52
|
+
const tree = async (name) => {
|
|
53
|
+
const t = await avatar.call(name);
|
|
54
|
+
return isSilence(t) || isWord(t) ? null : sanitiseTree(t);
|
|
55
|
+
};
|
|
56
|
+
model.tree = bp.asks.some((a) => a.name === 'page') ? await tree('page') : null;
|
|
57
|
+
// and the page of every standing she carries, the same way, painted in that standing's section;
|
|
58
|
+
// a name a carried page uses is one of that standing's asks, carried, and nothing else
|
|
59
|
+
model.pages = {};
|
|
60
|
+
for (const [id, g] of Object.entries(groups(bp)))
|
|
61
|
+
if (typeof g.page === 'string' && bp.asks.some((a) => a.name === g.page))
|
|
62
|
+
model.pages[id] = await tree(g.page);
|
|
63
|
+
const wanted = new Set(trees());
|
|
58
64
|
for (const n of shows)
|
|
59
65
|
if (wanted.has(n))
|
|
60
66
|
delete model.answers[n];
|
package/dist/human/web.js
CHANGED
|
@@ -98,7 +98,9 @@ const CSS = [
|
|
|
98
98
|
':root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px;--ink:#1c1b22;--paper:#fbfaf7;--mute:#6b6a73}',
|
|
99
99
|
'@media(prefers-color-scheme:dark){:root{--ink:#ecebe6;--paper:#141318;--mute:#9a99a2}}',
|
|
100
100
|
'html{background:var(--paper);color:var(--ink)}',
|
|
101
|
-
|
|
101
|
+
// one column that is a phone first and a desk after: the same page, more air
|
|
102
|
+
'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:1rem auto;padding:0 1rem}@media(min-width:40rem){body{margin:2rem auto}}',
|
|
103
|
+
'input:not([type=checkbox]),select,textarea{width:100%;max-width:24rem;box-sizing:border-box}',
|
|
102
104
|
'nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}',
|
|
103
105
|
'nav.relations{display:flex;flex-wrap:wrap;gap:.25rem;margin:.5rem 0}nav.relations button{background:transparent;color:inherit;border:1px solid color-mix(in srgb,currentColor 30%,transparent)}nav.relations button[aria-current]{border-color:var(--accent);font-weight:600}',
|
|
104
106
|
'header,main{background:var(--bg);color:var(--fg);font-family:var(--font)}header{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}header .notice{width:100%;margin:0}',
|
|
@@ -112,6 +114,10 @@ const CSS = [
|
|
|
112
114
|
'.page .stack{display:flex;flex-direction:column;gap:.5rem}.page .row{display:flex;flex-wrap:wrap;gap:.5rem 1rem;align-items:baseline}',
|
|
113
115
|
'.page .t-title{font-size:2rem;font-weight:600;letter-spacing:-.02em;margin:.5rem 0}.page .t-lead{font-size:1.25rem;margin:.25rem 0}.page .t-label{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--mute);margin:0}.page .t-quiet{color:var(--mute);font-size:.9rem}.page .t-body{margin:.25rem 0}',
|
|
114
116
|
'.page .picture{max-height:6rem}.cards{display:flex;flex-wrap:wrap;gap:.75rem}.card{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);padding:.5rem .75rem}',
|
|
117
|
+
// a row with a picture is a line about someone or something: the picture small and the words beside it, centred
|
|
118
|
+
'.page .row:has(>.picture){align-items:center;flex-wrap:nowrap}.page .row>.picture{max-height:2.75rem;flex:none}.page .row>.stack{gap:0;min-width:0}.page .row>.stack>p{margin:0}',
|
|
119
|
+
// a far page inside a standing's section: her title is a section's, not the page's
|
|
120
|
+
'section.standing .page{padding:.5rem 0}section.standing .page .t-title{font-size:1.3rem;margin:.25rem 0}',
|
|
115
121
|
// the door page: one column, generous air, the mark, a word, a sentence
|
|
116
122
|
'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
|
|
117
123
|
'main.door .picture{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
|
package/dist/mcp/runner.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
2
|
+
import { presentation } from '../beings/look.js';
|
|
2
3
|
export const TURNS = 10;
|
|
3
4
|
// Her describe, spoken as a tools array. Name, description and input are
|
|
4
5
|
// verbatim, with the one narrowing an endpoint has asked for: an ask that
|
|
5
6
|
// declares no properties is sent with an empty `properties`, because
|
|
6
|
-
// LM Studio refuses a parameters schema without one.
|
|
7
|
-
// presentation, for a screen, and are not functions.
|
|
7
|
+
// LM Studio refuses a parameters schema without one. Her look, her page and
|
|
8
|
+
// every carried page are presentation, for a screen, and are not functions.
|
|
8
9
|
export function tools(bp) {
|
|
10
|
+
const shown = presentation(bp);
|
|
9
11
|
return bp.asks
|
|
10
|
-
.filter((a) =>
|
|
12
|
+
.filter((a) => !shown.has(a.name))
|
|
11
13
|
.map((a) => {
|
|
12
14
|
const t = { type: 'function', function: { name: a.name, parameters: { properties: {}, ...a.input, type: 'object' } } };
|
|
13
15
|
if (a.description !== undefined)
|
package/dist/mcp/server.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
12
12
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
13
13
|
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
14
|
-
import { hintFor, sanitise } from '../beings/look.js';
|
|
14
|
+
import { hintFor, presentation, sanitise } from '../beings/look.js';
|
|
15
15
|
import { isSilence, isWord, digest } from '@quo-systems/quo';
|
|
16
16
|
export const NAME = 'quo';
|
|
17
17
|
export const VERSION = '0.0.0';
|
|
@@ -20,10 +20,12 @@ export const DESCRIBE = { name: 'describe', description: 'the empty ask: her des
|
|
|
20
20
|
// Her describe, spoken as tools, the empty ask first. Name, description and
|
|
21
21
|
// input are verbatim; an output schema crosses when she declared one. Her
|
|
22
22
|
// look, when she has one, is the hints: a title, and the annotations a host
|
|
23
|
-
// reads.
|
|
23
|
+
// reads. Her look, her page and every carried page are presentation, for a
|
|
24
|
+
// screen, and are not tools.
|
|
24
25
|
export function tools(bp, look = {}) {
|
|
26
|
+
const shown = presentation(bp);
|
|
25
27
|
const asks = bp.asks
|
|
26
|
-
.filter((a) =>
|
|
28
|
+
.filter((a) => !shown.has(a.name))
|
|
27
29
|
.map((a) => {
|
|
28
30
|
const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
|
|
29
31
|
if (a.description !== undefined)
|
package/harbor/browser.ts
CHANGED
|
@@ -1,33 +1,38 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The browser harbor: the core over
|
|
3
|
-
// a listener: it holds one socket per world it is connected to, and
|
|
4
|
-
// ask to a pk it does not hold goes down a socket to the listener,
|
|
5
|
-
// the rendezvous.
|
|
6
|
-
// the
|
|
7
|
-
//
|
|
8
|
-
//
|
|
2
|
+
// The browser harbor: the core over a store, in a webview. A dialer and
|
|
3
|
+
// never a listener: it holds one socket per world it is connected to, and
|
|
4
|
+
// every ask to a pk it does not hold goes down a socket to the listener,
|
|
5
|
+
// which is the rendezvous. In a tab the store is IndexedDB under a name,
|
|
6
|
+
// and the lease is a web lock on that name, held for the life of the tab:
|
|
7
|
+
// a second tab on one origin meets the lock and is a screen, not a harbor.
|
|
8
|
+
// In an app the store is handed in, and the process that holds it is the
|
|
9
|
+
// lease: an app has one harbor and nothing else can open its files. The
|
|
10
|
+
// classes are the estate's beings, handed in with the bundle, plus whatever
|
|
11
|
+
// a page hands in beside them.
|
|
9
12
|
import type { BeingClass } from '@quo-systems/quo';
|
|
10
13
|
import { User, Desk, Avatar } from '../beings/index.ts';
|
|
11
|
-
import { Harbor, dial, type Dialer } from '@quo-systems/quo/harbor';
|
|
14
|
+
import { Harbor, dial, type Dialer, type Store } from '@quo-systems/quo/harbor';
|
|
12
15
|
import { Idb } from './idb.ts';
|
|
13
16
|
|
|
14
17
|
export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar };
|
|
15
18
|
|
|
16
19
|
export class BrowserHarbor extends Harbor {
|
|
17
|
-
readonly
|
|
20
|
+
readonly name: string;
|
|
18
21
|
readonly dialers: Dialer[] = [];
|
|
22
|
+
readonly #locked: boolean;
|
|
19
23
|
#release: (() => void) | undefined;
|
|
20
24
|
|
|
21
|
-
constructor(
|
|
22
|
-
const
|
|
23
|
-
super(
|
|
24
|
-
this.
|
|
25
|
+
constructor(store: string | Store = 'quo', classes: Record<string, BeingClass> = {}) {
|
|
26
|
+
const s = typeof store === 'string' ? new Idb(store) : store;
|
|
27
|
+
super(s, async () => ({ ...BUILT_IN, ...classes }));
|
|
28
|
+
this.name = typeof store === 'string' ? store : 'quo';
|
|
29
|
+
this.#locked = typeof store === 'string';
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
// Take the lock and boot every ward kept here. A lock already held is
|
|
28
33
|
// another tab's harbor over this seed, and this one refuses to boot.
|
|
29
34
|
override async boot(): Promise<void> {
|
|
30
|
-
await this.#lease();
|
|
35
|
+
if (this.#locked) await this.#lease();
|
|
31
36
|
await super.boot();
|
|
32
37
|
}
|
|
33
38
|
|
|
@@ -38,9 +43,15 @@ export class BrowserHarbor extends Harbor {
|
|
|
38
43
|
return d;
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
// The device is back: a phone out of a pocket, an app to the foreground.
|
|
47
|
+
// Every socket is told, and one that died silently is dialed again.
|
|
48
|
+
wake(): void {
|
|
49
|
+
for (const d of this.dialers) d.wake();
|
|
50
|
+
}
|
|
51
|
+
|
|
41
52
|
async close(): Promise<void> {
|
|
42
53
|
for (const d of this.dialers.splice(0)) d.close();
|
|
43
|
-
this.
|
|
54
|
+
(this.store as { close?(): void }).close?.();
|
|
44
55
|
this.#release?.();
|
|
45
56
|
this.#release = undefined;
|
|
46
57
|
}
|
|
@@ -49,7 +60,7 @@ export class BrowserHarbor extends Harbor {
|
|
|
49
60
|
const locks = (globalThis.navigator as Navigator | undefined)?.locks;
|
|
50
61
|
if (!locks) return; // no web locks on this terrain: nothing vouches, as in the memory harbor
|
|
51
62
|
const granted = await new Promise<boolean>((ok) => {
|
|
52
|
-
void locks.request(`quo:${this.
|
|
63
|
+
void locks.request(`quo:${this.name}`, { ifAvailable: true }, (lock) => {
|
|
53
64
|
if (!lock) {
|
|
54
65
|
ok(false);
|
|
55
66
|
return;
|
|
@@ -58,6 +69,6 @@ export class BrowserHarbor extends Harbor {
|
|
|
58
69
|
return new Promise<void>((release) => (this.#release = release)); // held until close
|
|
59
70
|
});
|
|
60
71
|
});
|
|
61
|
-
if (!granted) throw new Error(`harbor ${this.
|
|
72
|
+
if (!granted) throw new Error(`harbor ${this.name} is held by another tab`);
|
|
62
73
|
}
|
|
63
74
|
}
|
package/harbor/capacitor.ts
CHANGED
|
@@ -12,13 +12,30 @@
|
|
|
12
12
|
// with no folder is a reinstall, and the stale key is deleted; a folder
|
|
13
13
|
// with no key is a restore to another device, and the unreadable files are
|
|
14
14
|
// deleted. Either way the harbor starts fresh and there is never a twin.
|
|
15
|
+
//
|
|
16
|
+
// The harbor on a phone is the browser harbor over this store: the app
|
|
17
|
+
// process is its lease, and the App plugin's foreground is its wake.
|
|
15
18
|
import { Capacitor } from '@capacitor/core';
|
|
19
|
+
import { App } from '@capacitor/app';
|
|
16
20
|
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
|
17
21
|
import { SecureStorage, KeychainAccess } from '@aparajita/capacitor-secure-storage';
|
|
22
|
+
import type { BeingClass } from '@quo-systems/quo';
|
|
18
23
|
import { arithmetic } from '@quo-systems/quo/ward';
|
|
19
24
|
import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
|
|
25
|
+
import { BrowserHarbor } from './browser.ts';
|
|
20
26
|
import { sealKey, seal, open } from './seal.ts';
|
|
21
27
|
|
|
28
|
+
// The app's one harbor, booted, told `wake` every time the app comes to
|
|
29
|
+
// the foreground, since a phone asleep loses its sockets silently.
|
|
30
|
+
export async function nativeHarbor(name = 'quo', classes: Record<string, BeingClass> = {}): Promise<BrowserHarbor> {
|
|
31
|
+
const h = new BrowserHarbor(await Native.open(name), classes);
|
|
32
|
+
await h.boot();
|
|
33
|
+
await App.addListener('appStateChange', ({ isActive }) => {
|
|
34
|
+
if (isActive) h.wake();
|
|
35
|
+
});
|
|
36
|
+
return h;
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
const { hex, unhex } = arithmetic;
|
|
23
40
|
type Blob = { seed: string; partition: Record<string, unknown>; record: WardRecord };
|
|
24
41
|
|
package/harbor/quo-harbor.md
CHANGED
|
@@ -417,15 +417,27 @@ key is a restore to another device, and the unreadable files are deleted;
|
|
|
417
417
|
either way the harbor starts fresh and there is never a twin. The app that
|
|
418
418
|
holds the store is `packages/app/`, which the dock does not know.
|
|
419
419
|
|
|
420
|
+
The harbor on a phone is the browser harbor, `browser.ts`, over that
|
|
421
|
+
store: the browser harbor takes a name, which is an IndexedDB database
|
|
422
|
+
under a web lock, or a store handed in, whose lease is the process that
|
|
423
|
+
holds it, since an app has one harbor and nothing else can open its
|
|
424
|
+
files. `nativeHarbor` in `capacitor.ts` is that harbor booted and armed:
|
|
425
|
+
the App plugin's foreground event tells it `wake`, which tells every
|
|
426
|
+
dialer, because a phone asleep loses its sockets silently and the wake is
|
|
427
|
+
what dials them back.
|
|
428
|
+
|
|
420
429
|
The proof is `packages/dock/test/terrain/ios.test.ts`, behind
|
|
421
|
-
`npm run check:terrain
|
|
422
|
-
|
|
423
|
-
real folder
|
|
424
|
-
|
|
425
|
-
the
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
430
|
+
`npm run check:terrain`, inside the real app in the iOS Simulator: the
|
|
431
|
+
store suite, untouched, and the custody rule against a real Keychain and
|
|
432
|
+
a real folder; the whole conformance suite over two harbors on native
|
|
433
|
+
stores, both dialing a daemon on the Mac's loopback through the tab's own
|
|
434
|
+
probe; and the wake, the app sent behind another and brought back, its
|
|
435
|
+
dialer told and its socket held again. The app is synced with the test's
|
|
436
|
+
origin as its page, built with xcodebuild, installed fresh and launched
|
|
437
|
+
with simctl; the page loads the bundled exercise, `native.ts`, runs it and
|
|
438
|
+
posts the list back. Two things the plugins taught, held in the store:
|
|
439
|
+
the secure store keeps JSON, so a value is read with the call that
|
|
440
|
+
parses; and mkdir refuses a folder that exists, recursive or not.
|
|
429
441
|
|
|
430
442
|
## The link
|
|
431
443
|
|
package/human/html.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// page(model) the whole page: title, notice, forms, views, pushes
|
|
12
12
|
import type { Ask, Blueprint, Json, JsonObject } from '@quo-systems/quo';
|
|
13
13
|
import { SILENCE_TEXT, UNREACHED_TEXT, wordText, type Word } from '../beings/side.ts';
|
|
14
|
-
import { hint, hintFor, sanitise, groups as grouped, type Look, type Hint } from '../beings/look.ts';
|
|
15
|
-
import { paint, type Node, type Painter } from './tree.ts';
|
|
14
|
+
import { hint, hintFor, sanitise, groups as grouped, presentation, type Look, type Hint } from '../beings/look.ts';
|
|
15
|
+
import { paint, type As, type Node, type Painter } from './tree.ts';
|
|
16
16
|
|
|
17
17
|
// One property of an input schema, as the form needs it.
|
|
18
18
|
export type Property = { type?: string; description?: string; enum?: Json[]; format?: string; default?: Json };
|
|
@@ -142,6 +142,7 @@ export type Model = {
|
|
|
142
142
|
blueprint: Blueprint | null; // her describe for this human, or nothing yet
|
|
143
143
|
look: Look; // her own look, from her `look` ask, or nothing
|
|
144
144
|
tree: Node | null; // her page, from her `page` ask, or nothing: then her asks are painted as forms
|
|
145
|
+
pages: Record<string, Node | null>; // the page of each standing she carries, by id, from its carried `page` ask
|
|
145
146
|
notice: string; // one line about where the human stands: in, not in, an error before an ask
|
|
146
147
|
answers: Record<string, Word>; // the last answer per ask, shown under its form
|
|
147
148
|
pushes: JsonObject[]; // every push from the world, newest last
|
|
@@ -181,18 +182,31 @@ export function ordered(asks: Ask[], l: Look | undefined): Ask[] {
|
|
|
181
182
|
|
|
182
183
|
export { hintFor };
|
|
183
184
|
|
|
184
|
-
// The asks that are presentation and never a form: her look and her page.
|
|
185
|
-
const PRESENTATION = new Set(['look', 'page']);
|
|
186
|
-
|
|
187
185
|
export function page(m: Model): string {
|
|
188
186
|
const bp = m.blueprint;
|
|
189
|
-
const
|
|
187
|
+
const pres = presentation(bp);
|
|
188
|
+
const one =(l: Look | undefined, prefix = '') => (a: Ask) => {
|
|
190
189
|
const w = m.answers[a.name];
|
|
191
190
|
const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
|
|
192
191
|
return `<section>${form(a, h)}${w ? face(w, a.output) : ''}</section>`;
|
|
193
192
|
};
|
|
193
|
+
// an answer shown as the page asks: as text, as cards, as a list, or by its shape
|
|
194
|
+
const answer = (name: string, as: As): string => {
|
|
195
|
+
const a = bp?.asks.find((x) => x.name === name);
|
|
196
|
+
const w = a ? m.answers[name] : undefined;
|
|
197
|
+
if (!a || !w) return '';
|
|
198
|
+
if (w.word === 'object' && as === 'text') return `<div class="answer">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
|
|
199
|
+
if (w.word === 'object' && as === 'cards' && Array.isArray(w.value)) return `<div class="cards">${w.value.map((v) => `<div class="card">${view(v)}</div>`).join('')}</div>`;
|
|
200
|
+
if (w.word === 'object' && as === 'list' && Array.isArray(w.value)) return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
|
|
201
|
+
return face(w, a.output);
|
|
202
|
+
};
|
|
194
203
|
const groups = grouped(bp);
|
|
195
204
|
const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
|
|
205
|
+
// A standing's section. With her page, the far tree is painted inside it
|
|
206
|
+
// through a painter of her own: a name it uses is one of her asks, carried,
|
|
207
|
+
// and never one of the carrier's or another standing's, so a far page
|
|
208
|
+
// reaches nothing outside its section; her title is the page's, and the
|
|
209
|
+
// section keeps her look. Without one, her asks are forms under her name.
|
|
196
210
|
const section = (id: string): string => {
|
|
197
211
|
const g = groups[id];
|
|
198
212
|
const asks = bp && g ? bp.asks.filter((a) => g.asks?.includes(a.name)) : [];
|
|
@@ -200,6 +214,19 @@ export function page(m: Model): string {
|
|
|
200
214
|
const kept = sanitise(g!.look);
|
|
201
215
|
const l = look(kept);
|
|
202
216
|
const prefix = `${id}-`;
|
|
217
|
+
const tree = m.pages[id];
|
|
218
|
+
if (tree) {
|
|
219
|
+
const hers: Painter = {
|
|
220
|
+
form: (name) => {
|
|
221
|
+
const a = asks.find((x) => x.name === prefix + name);
|
|
222
|
+
return a ? one(kept, prefix)(a) : '';
|
|
223
|
+
},
|
|
224
|
+
answer: (name, as) => (asks.some((x) => x.name === prefix + name) ? answer(prefix + name, as) : ''),
|
|
225
|
+
standing: () => '',
|
|
226
|
+
standings: () => '',
|
|
227
|
+
};
|
|
228
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div></section>`;
|
|
229
|
+
}
|
|
203
230
|
const want = (kept.order ?? []).map((n) => prefix + n);
|
|
204
231
|
const inOrder = ordered(asks, { order: want });
|
|
205
232
|
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
|
|
@@ -209,22 +236,14 @@ export function page(m: Model): string {
|
|
|
209
236
|
// uses looked up in her describe, so a page shows nothing her gate hid.
|
|
210
237
|
const painter: Painter = {
|
|
211
238
|
form: (name) => {
|
|
212
|
-
const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !
|
|
239
|
+
const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !pres.has(x.name));
|
|
213
240
|
return a ? one(m.look)(a) : '';
|
|
214
241
|
},
|
|
215
|
-
answer: (name, as) =>
|
|
216
|
-
const a = bp?.asks.find((x) => x.name === name);
|
|
217
|
-
const w = a ? m.answers[name] : undefined;
|
|
218
|
-
if (!a || !w) return '';
|
|
219
|
-
if (w.word === 'object' && as === 'text') return `<div class="answer">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
|
|
220
|
-
if (w.word === 'object' && as === 'cards' && Array.isArray(w.value)) return `<div class="cards">${w.value.map((v) => `<div class="card">${view(v)}</div>`).join('')}</div>`;
|
|
221
|
-
if (w.word === 'object' && as === 'list' && Array.isArray(w.value)) return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
|
|
222
|
-
return face(w, a.output);
|
|
223
|
-
},
|
|
242
|
+
answer: (name, as) => (pres.has(name) ? '' : answer(name, as)),
|
|
224
243
|
standing: section,
|
|
225
244
|
standings: () => far,
|
|
226
245
|
};
|
|
227
|
-
const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !
|
|
246
|
+
const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !pres.has(a.name)), m.look).map(one(m.look)).join('') : '';
|
|
228
247
|
const asks = m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : own + far;
|
|
229
248
|
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;
|
|
230
249
|
const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown as Json)}</aside>` : '';
|
package/human/quo-human.md
CHANGED
|
@@ -55,9 +55,9 @@ running there, from her cells, and painted by the same painter.
|
|
|
55
55
|
- **What opens with the page.** The answers a tree shows are run on open
|
|
56
56
|
when the ask needs nothing typed, beside the read-only asks her look
|
|
57
57
|
marks, so the page opens with what it shows.
|
|
58
|
-
- **The page is presentation.** `page` is asked
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
- **The page is presentation.** `page` is asked after every call, and it is
|
|
59
|
+
never a form and never a tool, hers or a carried one. The user being
|
|
60
|
+
answers one: her name as the title, who this device is as
|
|
61
61
|
an answer, her standings as sections, and the two things a human does
|
|
62
62
|
here by hand, waking another device and calling her by a name, as forms.
|
|
63
63
|
A being does not know her own key, so her name is given: by the desk's
|
|
@@ -99,9 +99,20 @@ classes, and the ward runs them.
|
|
|
99
99
|
same: her page is never more than one ask behind her.
|
|
100
100
|
- **One section each.** Under the far being's page, each being of the world
|
|
101
101
|
has her own section and her own side; the shell's own heading is dropped
|
|
102
|
-
wherever a page carries its title.
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
wherever a page carries its title.
|
|
103
|
+
- **A carried page is painted in her section.** The third source of the one
|
|
104
|
+
grammar, and no new grammar: a standing the user being carries answers
|
|
105
|
+
`page` like anyone, the carrier carries it as `<id>-page` and names it in
|
|
106
|
+
her notes, and the side asks it after every call like her own. The
|
|
107
|
+
section that was her forms under her name becomes her page under her
|
|
108
|
+
look, painted through a painter of her own: a form or an answer it names
|
|
109
|
+
is one of her asks by its bare name, carried, and a name that is not hers,
|
|
110
|
+
or a `standing` inside a page, paints as nothing. So a far page reaches
|
|
111
|
+
nothing outside its section, by construction and not by a shadow root:
|
|
112
|
+
the grammar carries no selector, no script and no stylesheet, the one
|
|
113
|
+
stylesheet is the shell's, and a root of her own would keep the shell's
|
|
114
|
+
stylesheet out of her section for no gain. The section is the root a page
|
|
115
|
+
owns.
|
|
105
116
|
|
|
106
117
|
## How a screen is made
|
|
107
118
|
|
package/human/screen.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { isInvitation } from '../beings/link.ts';
|
|
|
13
13
|
import type { Invitation } from '@quo-systems/quo';
|
|
14
14
|
import { page, values, hintFor, type Model, type Raw } from './html.ts';
|
|
15
15
|
import { sanitiseTree, answersIn } from './tree.ts';
|
|
16
|
-
import { sanitise } from '../beings/look.ts';
|
|
16
|
+
import { groups, sanitise } from '../beings/look.ts';
|
|
17
17
|
import { isSilence, isWord } from '@quo-systems/quo';
|
|
18
18
|
import type { Json } from '@quo-systems/quo';
|
|
19
19
|
|
|
@@ -36,7 +36,7 @@ export type Options = { after?: () => Promise<void>; notice?: string; admit?: (i
|
|
|
36
36
|
export async function screenSide(avatar: Subject, surface: Surface, options: Options = {}): Promise<Serving & { model: Model; refresh(): Promise<void> }> {
|
|
37
37
|
const after = options.after ?? (async () => {});
|
|
38
38
|
const notice = options.notice ?? '';
|
|
39
|
-
const model: Model = { blueprint: null, look: {}, tree: null, notice, answers: {}, pushes: [] };
|
|
39
|
+
const model: Model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [] };
|
|
40
40
|
let seen: string | null = null;
|
|
41
41
|
const show = () => surface.show(page(model));
|
|
42
42
|
|
|
@@ -63,12 +63,18 @@ export async function screenSide(avatar: Subject, surface: Surface, options: Opt
|
|
|
63
63
|
// her page, after every ask: a tree of values from what she holds now, or nothing and her asks
|
|
64
64
|
// are forms. A cell moved is a page moved, and her describe need not have; so the answers the
|
|
65
65
|
// page shows are run again with it, and the page never stands more than one ask behind her
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
const trees = () => [model.tree ? answersIn(model.tree) : [], ...Object.entries(model.pages).map(([id, t]) => (t ? answersIn(t).map((n) => `${id}-${n}`) : []))].flat();
|
|
67
|
+
const shows = new Set(trees());
|
|
68
|
+
const tree = async (name: string) => {
|
|
69
|
+
const t = await avatar.call(name);
|
|
70
|
+
return isSilence(t) || isWord(t) ? null : sanitiseTree(t as Json);
|
|
71
|
+
};
|
|
72
|
+
model.tree = (bp as Blueprint).asks.some((a) => a.name === 'page') ? await tree('page') : null;
|
|
73
|
+
// and the page of every standing she carries, the same way, painted in that standing's section;
|
|
74
|
+
// a name a carried page uses is one of that standing's asks, carried, and nothing else
|
|
75
|
+
model.pages = {};
|
|
76
|
+
for (const [id, g] of Object.entries(groups(bp as Blueprint))) if (typeof g.page === 'string' && (bp as Blueprint).asks.some((a) => a.name === g.page)) model.pages[id] = await tree(g.page);
|
|
77
|
+
const wanted = new Set(trees());
|
|
72
78
|
for (const n of shows) if (wanted.has(n)) delete model.answers[n];
|
|
73
79
|
// a read-only ask that needs nothing typed is run on her behalf once per digest, and one her page
|
|
74
80
|
// shows the answer of after every ask, so the page opens and stays with what it shows
|
package/human/web.ts
CHANGED
|
@@ -125,7 +125,9 @@ const CSS = [
|
|
|
125
125
|
':root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px;--ink:#1c1b22;--paper:#fbfaf7;--mute:#6b6a73}',
|
|
126
126
|
'@media(prefers-color-scheme:dark){:root{--ink:#ecebe6;--paper:#141318;--mute:#9a99a2}}',
|
|
127
127
|
'html{background:var(--paper);color:var(--ink)}',
|
|
128
|
-
|
|
128
|
+
// one column that is a phone first and a desk after: the same page, more air
|
|
129
|
+
'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:1rem auto;padding:0 1rem}@media(min-width:40rem){body{margin:2rem auto}}',
|
|
130
|
+
'input:not([type=checkbox]),select,textarea{width:100%;max-width:24rem;box-sizing:border-box}',
|
|
129
131
|
'nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}',
|
|
130
132
|
'nav.relations{display:flex;flex-wrap:wrap;gap:.25rem;margin:.5rem 0}nav.relations button{background:transparent;color:inherit;border:1px solid color-mix(in srgb,currentColor 30%,transparent)}nav.relations button[aria-current]{border-color:var(--accent);font-weight:600}',
|
|
131
133
|
'header,main{background:var(--bg);color:var(--fg);font-family:var(--font)}header{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}header .notice{width:100%;margin:0}',
|
|
@@ -139,6 +141,10 @@ const CSS = [
|
|
|
139
141
|
'.page .stack{display:flex;flex-direction:column;gap:.5rem}.page .row{display:flex;flex-wrap:wrap;gap:.5rem 1rem;align-items:baseline}',
|
|
140
142
|
'.page .t-title{font-size:2rem;font-weight:600;letter-spacing:-.02em;margin:.5rem 0}.page .t-lead{font-size:1.25rem;margin:.25rem 0}.page .t-label{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--mute);margin:0}.page .t-quiet{color:var(--mute);font-size:.9rem}.page .t-body{margin:.25rem 0}',
|
|
141
143
|
'.page .picture{max-height:6rem}.cards{display:flex;flex-wrap:wrap;gap:.75rem}.card{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);padding:.5rem .75rem}',
|
|
144
|
+
// a row with a picture is a line about someone or something: the picture small and the words beside it, centred
|
|
145
|
+
'.page .row:has(>.picture){align-items:center;flex-wrap:nowrap}.page .row>.picture{max-height:2.75rem;flex:none}.page .row>.stack{gap:0;min-width:0}.page .row>.stack>p{margin:0}',
|
|
146
|
+
// a far page inside a standing's section: her title is a section's, not the page's
|
|
147
|
+
'section.standing .page{padding:.5rem 0}section.standing .page .t-title{font-size:1.3rem;margin:.25rem 0}',
|
|
142
148
|
// the door page: one column, generous air, the mark, a word, a sentence
|
|
143
149
|
'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
|
|
144
150
|
'main.door .picture{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
|
package/mcp/runner.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import type { Blueprint, JsonObject } from '@quo-systems/quo';
|
|
14
14
|
import type { Avatar } from '../beings/avatar.ts';
|
|
15
15
|
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT, type Serving } from '../beings/side.ts';
|
|
16
|
+
import { presentation } from '../beings/look.ts';
|
|
16
17
|
|
|
17
18
|
// Where the model is. `key` is a bearer for endpoints that want one, read
|
|
18
19
|
// from the device and never from cells. `turns` is the ceiling of model
|
|
@@ -32,11 +33,12 @@ type Completion = { choices: { message: { role: 'assistant'; content: string | n
|
|
|
32
33
|
// Her describe, spoken as a tools array. Name, description and input are
|
|
33
34
|
// verbatim, with the one narrowing an endpoint has asked for: an ask that
|
|
34
35
|
// declares no properties is sent with an empty `properties`, because
|
|
35
|
-
// LM Studio refuses a parameters schema without one.
|
|
36
|
-
// presentation, for a screen, and are not functions.
|
|
36
|
+
// LM Studio refuses a parameters schema without one. Her look, her page and
|
|
37
|
+
// every carried page are presentation, for a screen, and are not functions.
|
|
37
38
|
export function tools(bp: Blueprint): Tool[] {
|
|
39
|
+
const shown = presentation(bp);
|
|
38
40
|
return bp.asks
|
|
39
|
-
.filter((a) =>
|
|
41
|
+
.filter((a) => !shown.has(a.name))
|
|
40
42
|
.map((a) => {
|
|
41
43
|
const t: Tool = { type: 'function', function: { name: a.name, parameters: { properties: {}, ...a.input, type: 'object' } } };
|
|
42
44
|
if (a.description !== undefined) t.function.description = a.description;
|
package/mcp/server.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
|
13
13
|
import { ListToolsRequestSchema, CallToolRequestSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
14
14
|
import type { Blueprint, JsonObject } from '@quo-systems/quo';
|
|
15
15
|
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT, type Serving, type Subject } from '../beings/side.ts';
|
|
16
|
-
import { hintFor, sanitise, type Look } from '../beings/look.ts';
|
|
16
|
+
import { hintFor, presentation, sanitise, type Look } from '../beings/look.ts';
|
|
17
17
|
import { isSilence, isWord, digest } from '@quo-systems/quo';
|
|
18
18
|
import type { Json } from '@quo-systems/quo';
|
|
19
19
|
|
|
@@ -28,10 +28,12 @@ export const DESCRIBE: Tool = { name: 'describe', description: 'the empty ask: h
|
|
|
28
28
|
// Her describe, spoken as tools, the empty ask first. Name, description and
|
|
29
29
|
// input are verbatim; an output schema crosses when she declared one. Her
|
|
30
30
|
// look, when she has one, is the hints: a title, and the annotations a host
|
|
31
|
-
// reads.
|
|
31
|
+
// reads. Her look, her page and every carried page are presentation, for a
|
|
32
|
+
// screen, and are not tools.
|
|
32
33
|
export function tools(bp: Blueprint, look: Look = {}): Tool[] {
|
|
34
|
+
const shown = presentation(bp);
|
|
33
35
|
const asks = bp.asks
|
|
34
|
-
.filter((a) =>
|
|
36
|
+
.filter((a) => !shown.has(a.name))
|
|
35
37
|
.map((a) => {
|
|
36
38
|
const t: Tool = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
|
|
37
39
|
if (a.description !== undefined) t.description = a.description;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quo-systems/dock",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "The dock: what every estate on Quo needs and nobody writes twice. A daemon and the quo command, the front desk, the user being and the avatar, harbors on disk, in a tab and on the edge, the model sides and the screen.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"quo",
|
|
@@ -76,10 +76,11 @@
|
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
78
|
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
|
79
|
+
"@capacitor/app": "^8.1.1",
|
|
79
80
|
"@capacitor/core": "^8.5.1",
|
|
80
81
|
"@capacitor/filesystem": "^8.1.3",
|
|
81
82
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
82
|
-
"@quo-systems/quo": "^0.2.
|
|
83
|
+
"@quo-systems/quo": "^0.2.5",
|
|
83
84
|
"esbuild": "^0.28.2",
|
|
84
85
|
"ws": "^8.21.3"
|
|
85
86
|
},
|