@quo-systems/quo 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/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@quo-systems/quo",
3
+ "version": "0.1.0",
4
+ "description": "Quo: an object asks another object and gets an answer, without knowing where it is. Being, Ward, Harbor.",
5
+ "keywords": [
6
+ "quo",
7
+ "protocol",
8
+ "capability",
9
+ "object-capability",
10
+ "rpc",
11
+ "ed25519",
12
+ "x25519"
13
+ ],
14
+ "author": "Razvan Gherghina",
15
+ "license": "Apache-2.0",
16
+ "type": "module",
17
+ "engines": {
18
+ "node": ">=22.18"
19
+ },
20
+ "exports": {
21
+ ".": "./src/being/index.ts",
22
+ "./ward": "./src/ward/index.ts",
23
+ "./harbor": "./src/harbor/index.ts",
24
+ "./conformance": "./src/conformance/index.ts"
25
+ },
26
+ "scripts": {
27
+ "check": "npm run typecheck && npm run lint && npm test && npm run check:estate",
28
+ "check:estate": "node --test \"estate/test/*.test.ts\"",
29
+ "typecheck": "tsc",
30
+ "typecheck:watch": "tsc --watch",
31
+ "lint": "npm run lint:ts && npm run lint:md",
32
+ "lint:ts": "oxlint --type-aware",
33
+ "lint:md": "markdownlint-cli2",
34
+ "lint:fix": "oxlint --type-aware --fix && markdownlint-cli2 --fix",
35
+ "test": "node --test \"test/*.test.ts\"",
36
+ "check:terrain": "node --test \"test/terrain/*.test.ts\" \"estate/test/terrain/*.test.ts\"",
37
+ "test:being": "node --test test/being.test.ts",
38
+ "test:ward": "node --test test/ward.test.ts",
39
+ "prepublishOnly": "npm run check && npm run check:terrain"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^26.4.1",
43
+ "bun": "^1.4.0",
44
+ "deno": "^2.9.6",
45
+ "esbuild": "^0.28.2",
46
+ "markdownlint-cli2": "^0.23.2",
47
+ "oxlint": "^1.81.0",
48
+ "oxlint-tsgolint": "^7.0.2001",
49
+ "playwright": "^1.62.1",
50
+ "typescript": "^7.0.2",
51
+ "workerd": "^1.20260904.1"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "files": [
57
+ "src",
58
+ "vectors",
59
+ "SPEC.md",
60
+ "README.md",
61
+ "LICENSE",
62
+ "NOTICE"
63
+ ]
64
+ }
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The base class most beings extend. It writes `answer` for you: the empty
3
+ // ask becomes a blueprint from the asks you declare, filtered per asker; a
4
+ // named ask becomes a method call; anything not declared, or hidden from
5
+ // this asker, is an error object. Underneath it is still one function, and
6
+ // a class that writes that function by hand is a being just the same.
7
+ import type { Ask, Asker, Blueprint, Cells, Invitation, JsonObject, Wanted, OccupantRecord, Occupants, Reply, Schema, Stance, Standings, Answer } from './types.ts';
8
+
9
+ // One declared ask. `for` decides whether this asker sees it, and so whether
10
+ // this asker may call it: what she shows is what she can be asked.
11
+ export type AskSpec = {
12
+ description?: string;
13
+ input?: Schema;
14
+ output?: Schema;
15
+ for?: (occupant: OccupantRecord | undefined, asker: Asker) => boolean;
16
+ };
17
+
18
+ // Names a subclass may not use for an ask, because they are the base's own.
19
+ const RESERVED = new Set(['answer', 'describe', 'stance', 'cells', 'standings', 'occupants', 'occupant', 'invite', 'knock', 'take', 'constructor']);
20
+
21
+ export class Being {
22
+ // Her cells' defaults. Merged in at birth, only where a key is missing, so
23
+ // a restart keeps what she wrote.
24
+ static cells: JsonObject = {};
25
+ // What she can be asked. Declaration order is blueprint order.
26
+ static asks: Record<string, AskSpec> = {};
27
+
28
+ readonly stance: Stance;
29
+
30
+ constructor(stance: Stance) {
31
+ this.stance = stance;
32
+ const C = this.constructor as typeof Being;
33
+ for (const name of Object.keys(C.asks)) {
34
+ if (RESERVED.has(name)) throw new Error(`ask '${name}' is a reserved name`);
35
+ if (typeof (this as unknown as Record<string, unknown>)[name] !== 'function') throw new Error(`ask '${name}' has no method`);
36
+ }
37
+ for (const [k, v] of Object.entries(C.cells)) if (!(k in stance.cells)) stance.cells[k] = structuredClone(v);
38
+ }
39
+
40
+ get cells(): Cells {
41
+ return this.stance.cells;
42
+ }
43
+ get standings(): Standings {
44
+ return this.stance.standings;
45
+ }
46
+ get occupants(): Occupants {
47
+ return this.stance.occupants;
48
+ }
49
+ invite(id: string): Promise<Invitation | null> {
50
+ return this.stance.occupants.invite(id);
51
+ }
52
+ knock(invitation: Invitation, method?: string, args?: JsonObject, wanted?: Wanted): Promise<Answer> {
53
+ return this.stance.standings.knock(invitation, method, args, wanted);
54
+ }
55
+ take(id: string, invitation: Invitation): Promise<string | null> {
56
+ return this.stance.standings.take(id, invitation);
57
+ }
58
+ // The occupant record for whoever is at the door. Undefined at a public being.
59
+ occupant(asker: Asker): OccupantRecord | undefined {
60
+ return asker.id === undefined ? undefined : this.cells.occupants[asker.id];
61
+ }
62
+
63
+ // Her blueprint for this asker. Override to shape it by hand.
64
+ describe(asker: Asker): Blueprint {
65
+ const C = this.constructor as typeof Being;
66
+ const rec = this.occupant(asker);
67
+ const asks: Ask[] = [];
68
+ for (const [name, spec] of Object.entries(C.asks)) {
69
+ if (spec.for && !spec.for(rec, asker)) continue;
70
+ const ask: Ask = { name, input: spec.input ?? { type: 'object' } };
71
+ if (spec.description !== undefined) ask.description = spec.description;
72
+ if (spec.output !== undefined) ask.output = spec.output;
73
+ asks.push(ask);
74
+ }
75
+ return { asks, notes: {} };
76
+ }
77
+
78
+ // The one function. Override to wrap it; call super to keep the dispatch.
79
+ async answer(asker: Asker, method?: string, args: JsonObject = {}): Promise<Reply> {
80
+ if (method === undefined) return this.describe(asker);
81
+ const C = this.constructor as typeof Being;
82
+ // Declared, by her, on purpose. `asks` is an ordinary object, so a bare
83
+ // lookup would also find every name on Object's prototype: `valueOf`
84
+ // would answer with her stance, `toString` with a string, and neither is
85
+ // an ask she wrote. Only her own keys are asks, which is what describe
86
+ // has always shown. What she shows is what she can be asked.
87
+ const spec = typeof method === 'string' && Object.hasOwn(C.asks, method) ? C.asks[method] : undefined;
88
+ if (!spec || (spec.for && !spec.for(this.occupant(asker), asker))) return { error: 'unknown ask' };
89
+ const fn = (this as unknown as Record<string, (args: JsonObject, asker: Asker) => Reply | Promise<Reply>>)[method];
90
+ return fn.call(this, args ?? {}, asker);
91
+ }
92
+ }
@@ -0,0 +1,31 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The digest: SHA-256, as hex, over the JCS (RFC 8785) canonical form of a
3
+ // blueprint. Same bytes from every language. WebCrypto only, so it runs
4
+ // wherever the language runs.
5
+ import type { Json } from './types.ts';
6
+
7
+ // A value I-JSON has no room for: a key left empty, a function, a symbol.
8
+ // None of them cross an edge, so none of them may reach a digest. A key
9
+ // carrying one is dropped and a slot carrying one is null, which is what
10
+ // crossing does to them, so the digest names what arrived and not what she
11
+ // happened to be holding.
12
+ const absent = (v: unknown): boolean => v === undefined || typeof v === 'function' || typeof v === 'symbol';
13
+
14
+ // JCS for I-JSON values: sorted keys, no whitespace, JSON escaping. Numbers
15
+ // are serialized as ES does, which is what RFC 8785 specifies.
16
+ export const canonical = (v: Json): string =>
17
+ Array.isArray(v)
18
+ ? `[${v.map((slot) => (absent(slot) ? 'null' : canonical(slot))).join(',')}]`
19
+ : v !== null && typeof v === 'object'
20
+ ? `{${Object.keys(v)
21
+ .filter((k) => !absent(v[k]))
22
+ .sort()
23
+ .map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`)
24
+ .join(',')}}`
25
+ : JSON.stringify(v);
26
+
27
+ const hex = (bytes: ArrayBuffer): string =>
28
+ Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, '0')).join('');
29
+
30
+ export const digest = async (blueprint: Json): Promise<string> =>
31
+ hex(await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical(blueprint))));
@@ -0,0 +1,7 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // @quo-systems/quo — the Being side. What a being author imports, if anything.
3
+ export { Being, type AskSpec } from './being.ts';
4
+ export { silence, isSilence, unreached, isUnreached } from './silence.ts';
5
+ export { digest, canonical } from './digest.ts';
6
+ export { OWNER, PUBLIC, RESERVED_IDS } from './types.ts';
7
+ export type * from './types.ts';
@@ -0,0 +1,14 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Silence is one distinguished value. Null is an answer. Silence is no answer.
3
+ import { UNREACHED_KEY, type Unreached } from './types.ts';
4
+
5
+ export const silence: unique symbol = Symbol.for('quo.silence');
6
+ export const isSilence = (x: unknown): x is typeof silence => x === silence;
7
+
8
+ // Unreached is the ward's own word: no far door was reached. It carries no
9
+ // reason. Safe to retry, because nothing was delivered. A being cannot
10
+ // produce it: a ward that sees it come out of a being treats it as silence.
11
+ const UNREACHED: Unreached = Object.freeze({ [UNREACHED_KEY]: true });
12
+ export const unreached = (): Unreached => UNREACHED;
13
+ export const isUnreached = (x: unknown): x is Unreached =>
14
+ x !== null && typeof x === 'object' && (x as Record<symbol, unknown>)[UNREACHED_KEY] === true;
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The Being side of Quo, as types. Everything a being author can hold.
3
+ // Nothing here knows a runtime, a key, or a wire.
4
+ import type { silence } from './silence.ts';
5
+
6
+ // I-JSON. Everything that crosses an edge.
7
+ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
8
+ export type JsonObject = { [key: string]: Json };
9
+
10
+ // The asker: her own id for the being at the door. {} at a public being.
11
+ export type Asker = { id: string } | { id?: undefined };
12
+
13
+ // The ids the ward speaks under, and no being may mint. An asker id is hers:
14
+ // she chooses the strings, and the ward has no business in her namespace. But
15
+ // the ward must sometimes stand at her door itself — its describe asks every
16
+ // being under OWNER — and an id it shares with one of her occupants is two
17
+ // different parties wearing one name. She could not tell them apart, and the
18
+ // ward would shape its describe through an occupant's gate.
19
+ //
20
+ // So the ward's words are shouted, and refused at the mint. A word can only
21
+ // be reserved before anyone has used it: a partition already holding an
22
+ // occupant of that name could never have it taken back. PUBLIC guards nothing
23
+ // today — a public asker is {} and carries no id at all — and that is exactly
24
+ // why it is claimed now, while claiming it is free.
25
+ export const OWNER = 'OWNER';
26
+ export const PUBLIC = 'PUBLIC';
27
+ export const RESERVED_IDS: readonly string[] = [OWNER, PUBLIC];
28
+
29
+ // An invitation is a value. Opaque to her: the far ward's pk, a heir pk,
30
+ // and the heir's secret. Without a heir it addresses a ward's public being.
31
+ export type Invitation = { ward: string; heir?: string; secret?: string };
32
+
33
+ // A blueprint is an MCP tool list plus notes.
34
+ export type Schema = JsonObject;
35
+ export type Ask = { name: string; description?: string; input: Schema; output?: Schema };
36
+ export type Blueprint = { asks: Ask[]; notes: Json };
37
+
38
+ export type StandingRecord = { id: string; digest: string | null; blueprint: Blueprint | null; seen: string | null };
39
+ export type OccupantRecord = { id: string; notes: JsonObject };
40
+ export type Cells = {
41
+ standings: Record<string, StandingRecord>;
42
+ occupants: Record<string, OccupantRecord>;
43
+ [hers: string]: Json;
44
+ };
45
+
46
+ export type Silence = typeof silence;
47
+ export type Unreached = { readonly [K in typeof UNREACHED_KEY]: true };
48
+ export const UNREACHED_KEY: unique symbol = Symbol.for('quo.unreached');
49
+
50
+ // What comes back from an ask or a knock.
51
+ export type Answer = Json | Silence | Unreached;
52
+ // What a being answers.
53
+ export type Reply = Json | Silence;
54
+
55
+ // What a being may ask for when she wants to say so herself: the time this
56
+ // one ask may spend. Optional, and so is saying anything at all: an ask that
57
+ // says nothing gets her ward's default, which is the ordinary way to ask.
58
+ export type Wanted = { time?: number };
59
+
60
+ export type Standing = {
61
+ readonly id: string;
62
+ ask(method?: string, args?: JsonObject, wanted?: Wanted): Promise<Answer>;
63
+ };
64
+
65
+ export type Standings = {
66
+ knock(invitation: Invitation, method?: string, args?: JsonObject, wanted?: Wanted): Promise<Answer>;
67
+ take(id: string, invitation: Invitation): Promise<string | null>;
68
+ remove(id: string): void;
69
+ } & { readonly [id: string]: Standing | undefined };
70
+
71
+ export type Occupants = {
72
+ invite(id: string): Promise<Invitation | null>; // awaitable: a key is minted
73
+
74
+ remove(id: string): void;
75
+ };
76
+
77
+ // The stance. What every being, in every language, is handed at birth.
78
+ export type Stance = {
79
+ readonly cells: Cells;
80
+ readonly occupants: Occupants;
81
+ readonly standings: Standings;
82
+ };
83
+
84
+ // The raw shape of a being. Anything with these two is a being.
85
+ export interface BeingLike {
86
+ answer(asker: Asker, method?: string, args?: JsonObject): Reply | Promise<Reply>;
87
+ }
88
+ export type BeingClass = new (stance: Stance) => BeingLike;
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The five assertions the conformance suite makes, and no more. Node has all
3
+ // five in `node:assert/strict`, and naming that module would tie the suite to
4
+ // one terrain -- the suite is the ward's truth, and the ward's truth has to be
5
+ // checkable wherever a ward runs. So they are written here, in the language
6
+ // alone, and `test/assert.test.ts` holds them to Node's own behaviour.
7
+ export class Failed extends Error {}
8
+
9
+ const show = (v: unknown): string => {
10
+ if (typeof v === 'symbol') return v.toString();
11
+ if (typeof v === 'bigint') return `${v}n`;
12
+ if (v === undefined) return 'undefined';
13
+ if (typeof v === 'function') return `[function ${v.name}]`;
14
+ try {
15
+ return JSON.stringify(v) ?? Object.prototype.toString.call(v);
16
+ } catch {
17
+ return Object.prototype.toString.call(v); // a cycle, or a null prototype
18
+ }
19
+ };
20
+ const fail = (why: string, message?: string): never => {
21
+ throw new Failed(message ? `${message}: ${why}` : why);
22
+ };
23
+
24
+ // Structural, and strict about shape the way `node:assert/strict` is: a value
25
+ // is not equal to one of another kind, and an array is not equal to an object
26
+ // that happens to hold the same keys.
27
+ export function same(a: unknown, b: unknown): boolean {
28
+ if (Object.is(a, b)) return true;
29
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
30
+ if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
31
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
32
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((x, i) => same(x, b[i]));
33
+ if (a instanceof Uint8Array && b instanceof Uint8Array) return a.length === b.length && a.every((x, i) => x === b[i]);
34
+ if (a instanceof Map || b instanceof Map || a instanceof Set || b instanceof Set) return false; // the suite has none, and a wrong yes is worse than a refusal
35
+ const ka = Reflect.ownKeys(a), kb = Reflect.ownKeys(b);
36
+ if (ka.length !== kb.length) return false;
37
+ return ka.every((k) => kb.includes(k) && same((a as Record<PropertyKey, unknown>)[k], (b as Record<PropertyKey, unknown>)[k]));
38
+ }
39
+
40
+ // `ok` narrows, the way `node:assert/strict`'s does: the suite leans on it to
41
+ // turn a `string | null` into a `string`. That needs the explicit type below,
42
+ // so the interface is written out rather than inferred.
43
+ export type Assert = {
44
+ ok(value: unknown, message?: string): asserts value;
45
+ equal(actual: unknown, expected: unknown, message?: string): void;
46
+ notEqual(actual: unknown, expected: unknown, message?: string): void;
47
+ deepEqual(actual: unknown, expected: unknown, message?: string): void;
48
+ match(actual: string, re: RegExp, message?: string): void;
49
+ };
50
+
51
+ export const assert: Assert = {
52
+ ok(value: unknown, message?: string): asserts value {
53
+ if (!value) fail(`${show(value)} is not truthy`, message);
54
+ },
55
+ equal(actual: unknown, expected: unknown, message?: string): void {
56
+ if (!Object.is(actual, expected)) fail(`${show(actual)} !== ${show(expected)}`, message);
57
+ },
58
+ notEqual(actual: unknown, expected: unknown, message?: string): void {
59
+ if (Object.is(actual, expected)) fail(`${show(actual)} === ${show(expected)}`, message);
60
+ },
61
+ deepEqual(actual: unknown, expected: unknown, message?: string): void {
62
+ if (!same(actual, expected)) fail(`${show(actual)} is not ${show(expected)}`, message);
63
+ },
64
+ match(actual: string, re: RegExp, message?: string): void {
65
+ if (typeof actual !== 'string' || !re.test(actual)) fail(`${show(actual)} does not match ${String(re)}`, message);
66
+ },
67
+ };
@@ -0,0 +1,129 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The fixture beings. Fixed, so that the ward is what varies. A printer, a
3
+ // shop, a customer on the base class, and one raw being with no base at all.
4
+ import { Being } from '../being/being.ts';
5
+ import { isSilence, isUnreached } from '../being/silence.ts';
6
+ import type { Asker, Blueprint, Invitation, JsonObject, OccupantRecord, Reply, Stance } from '../being/types.ts';
7
+
8
+ // A printer. Invites whom she is told to, prints for her occupants. Her
9
+ // blueprint lives in her cells so a test can change her shape.
10
+ export class Printer extends Being {
11
+ static override cells = { jobs: [] as JsonObject[], asks: [{ name: 'print', input: { type: 'object' } }] as JsonObject[] };
12
+ static override asks = { print: { input: { type: 'object' } } };
13
+ override describe(): Blueprint {
14
+ return { asks: this.cells.asks as never, notes: { model: 'LX-2' } };
15
+ }
16
+ print({ doc }: JsonObject, asker: Asker) {
17
+ (this.cells.jobs as JsonObject[]).push({ by: asker.id ?? null, doc: doc ?? null });
18
+ return { printed: doc ?? null };
19
+ }
20
+ }
21
+
22
+ // A shop. Invites customers in her own time. Keeps a printer as a standing.
23
+ // Takes a gold customer back if the customer offers a way. Counts every ask
24
+ // per occupant, by wrapping answer.
25
+ export class Shop extends Being {
26
+ static override cells = { printerId: null as string | null, sales: [] as JsonObject[], minted: 0 };
27
+ static override asks = {
28
+ hello: { input: { type: 'object' } },
29
+ buy: { input: { type: 'object', properties: { item: { type: 'string' } } } },
30
+ refund: { input: { type: 'object' }, for: (rec: OccupantRecord | undefined) => rec?.notes.tier === 'gold' },
31
+ };
32
+
33
+ override async answer(asker: Asker, method?: string, args?: JsonObject): Promise<Reply> {
34
+ const rec = this.occupant(asker);
35
+ if (rec && method !== undefined) rec.notes.asks = (Number(rec.notes.asks) || 0) + 1;
36
+ return super.answer(asker, method, args);
37
+ }
38
+
39
+ // Outside any ask. She mints, her ward seals, she hands it out by mail.
40
+ async invite(tier = 'plain'): Promise<Invitation> {
41
+ const id = `cust${++(this.cells.minted as number)}`;
42
+ const inv = (await super.invite(id))!;
43
+ this.cells.occupants[id].notes.tier = tier;
44
+ return inv;
45
+ }
46
+
47
+ async keepPrinter(invitation: Invitation): Promise<boolean> {
48
+ const bp = await this.knock(invitation);
49
+ if (isSilence(bp) || isUnreached(bp)) return false;
50
+ this.cells.printerId = await this.take('prn', invitation);
51
+ return true;
52
+ }
53
+
54
+ async hello({ invitation }: JsonObject, asker: Asker) {
55
+ const rec = this.occupant(asker);
56
+ if (rec?.notes.tier === 'gold' && invitation) {
57
+ const back = await this.knock(invitation as Invitation, 'hi');
58
+ if (!isSilence(back) && !isUnreached(back)) await this.take(`vip-${asker.id}`, invitation as Invitation);
59
+ }
60
+ return { welcome: true };
61
+ }
62
+
63
+ async buy({ item }: JsonObject, asker: Asker) {
64
+ if (typeof item !== 'string') return { error: 'no such item' }; // args are hers to check
65
+ const id = this.cells.printerId as string | null;
66
+ const printer = id ? this.standings[id] : undefined;
67
+ if (!printer) return { error: 'no printer' };
68
+ const out = await printer.ask('print', { doc: `receipt for ${item}` });
69
+ if (isUnreached(out)) return { error: 'printer unreachable', retry: true };
70
+ if (isSilence(out)) return { error: 'printer refused' };
71
+ const st = this.cells.standings[id!];
72
+ if (st.seen !== st.digest) this.occupant(asker)!.notes.printerChanged = true;
73
+ (this.cells.sales as JsonObject[]).push({ item: item ?? null, by: asker.id ?? null });
74
+ return { ok: true, receipt: (out as JsonObject).printed };
75
+ }
76
+
77
+ refund() {
78
+ return { ok: true };
79
+ }
80
+ }
81
+
82
+ // A customer. Consumes an invitation, and only then decides to keep the
83
+ // shop. Offers the shop a way back so the shop can reach her.
84
+ export class Customer extends Being {
85
+ static override cells = { heard: [] as JsonObject[] };
86
+ static override asks = { hi: { input: { type: 'object' } } };
87
+
88
+ async join(invitation: Invitation) {
89
+ const mine = (await this.invite('shop-back'))!;
90
+ const out = await this.knock(invitation, 'hello', { invitation: mine as never });
91
+ if (isSilence(out) || isUnreached(out)) return out; // nothing was born
92
+ await this.take('shop', invitation); // now, and only now
93
+ return out;
94
+ }
95
+ buy(item: string) {
96
+ return this.standings.shop!.ask('buy', { item });
97
+ }
98
+ learn() {
99
+ return this.standings.shop!.ask();
100
+ }
101
+ hi(_args: JsonObject, asker: Asker) {
102
+ (this.cells.heard as JsonObject[]).push({ from: asker.id ?? null, method: 'hi' });
103
+ return { heard: 'hi' };
104
+ }
105
+ }
106
+
107
+ // The raw shape. No base class, no import from the kit. A being all the same.
108
+ export class Echo {
109
+ s: Stance;
110
+ constructor(stance: Stance) {
111
+ this.s = stance;
112
+ }
113
+ answer(asker: Asker, method?: string, args: JsonObject = {}): Reply {
114
+ if (method === undefined) return { asks: [{ name: 'echo', input: {} }], notes: {} };
115
+ return { from: asker.id ?? null, ...args };
116
+ }
117
+ }
118
+
119
+ // A member of an estate. Nothing scripted: she invites whom she is told to,
120
+ // knocks where she is told to, and answers `ping` with the asker's own name.
121
+ // The estate chapter drives her, and what it drives is the graph, not her.
122
+ export class Member extends Being {
123
+ static override cells = { heard: [] as JsonObject[] };
124
+ static override asks = { ping: { input: { type: 'object' } } };
125
+ ping(_args: JsonObject, asker: Asker) {
126
+ (this.cells.heard as JsonObject[]).push({ from: asker.id ?? null });
127
+ return { pong: asker.id ?? null };
128
+ }
129
+ }