@rubytech/create-realagent 1.0.854 → 1.0.856
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/dist/__tests__/account-id-env.test.js +47 -0
- package/dist/__tests__/port-canonicalisation.test.js +1 -0
- package/dist/index.js +51 -17
- package/dist/port-resolution.js +9 -0
- package/package.json +1 -1
- package/payload/platform/lib/account-enumeration/dist/__tests__/validate-env.test.d.ts +2 -0
- package/payload/platform/lib/account-enumeration/dist/__tests__/validate-env.test.d.ts.map +1 -0
- package/payload/platform/lib/account-enumeration/dist/__tests__/validate-env.test.js +55 -0
- package/payload/platform/lib/account-enumeration/dist/__tests__/validate-env.test.js.map +1 -0
- package/payload/platform/lib/account-enumeration/dist/index.d.ts +26 -0
- package/payload/platform/lib/account-enumeration/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/account-enumeration/dist/index.js +13 -0
- package/payload/platform/lib/account-enumeration/dist/index.js.map +1 -1
- package/payload/platform/lib/account-enumeration/src/__tests__/validate-env.test.ts +57 -0
- package/payload/platform/lib/account-enumeration/src/index.ts +44 -0
- package/payload/platform/plugins/cloudflare/references/manual-setup.md +2 -0
- package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.sh +39 -10
- package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.ts +112 -20
- package/payload/platform/plugins/docs/references/cloudflare.md +1 -0
- package/payload/platform/plugins/docs/references/internals.md +2 -0
- package/payload/platform/plugins/whatsapp/PLUGIN.md +1 -1
- package/payload/server/public/assets/{Checkbox-U-H3_oQu.js → Checkbox-BySsatDO.js} +1 -1
- package/payload/server/public/assets/{admin-DZ8Ke7t3.js → admin-CZpefPcA.js} +2 -2
- package/payload/server/public/assets/data-BuuqlV4L.js +1 -0
- package/payload/server/public/assets/graph-CtVITeok.js +1 -0
- package/payload/server/public/assets/jsx-runtime-O5ef8xK8.css +1 -0
- package/payload/server/public/assets/{page-D_6h4ZZy.js → page-Ddc_nKh8.js} +1 -1
- package/payload/server/public/assets/{page-CNKytKTe.js → page-IQBQoOdT.js} +1 -1
- package/payload/server/public/assets/{public-DApUXgoq.js → public-BhyNH7eq.js} +1 -1
- package/payload/server/public/assets/{useAdminFetch-Cex4bYm7.js → useAdminFetch-B3MO55eB.js} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-CI8GpxfU.js → useVoiceRecorder-B_zVS4Oe.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +32 -3
- package/payload/server/public/assets/data-BbczthXl.js +0 -1
- package/payload/server/public/assets/graph-DzK_bDyH.js +0 -1
- package/payload/server/public/assets/jsx-runtime-OD2WKrlG.css +0 -1
- /package/payload/server/public/assets/{jsx-runtime-BjkIZEse.js → jsx-runtime-DnY0498s.js} +0 -0
|
@@ -42,19 +42,29 @@
|
|
|
42
42
|
// "selector drift" from "empty account" — both shapes arrive via
|
|
43
43
|
// stdout).
|
|
44
44
|
|
|
45
|
-
import { appendFileSync } from "node:fs";
|
|
45
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
46
46
|
import { writeFile } from "node:fs/promises";
|
|
47
47
|
import { resolve } from "node:path";
|
|
48
48
|
import { homedir } from "node:os";
|
|
49
49
|
import { fileURLToPath } from "node:url";
|
|
50
50
|
|
|
51
|
-
// CDP
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
// CDP endpoint is resolved lazily inside `main()` so importing this module
|
|
52
|
+
// for in-process tests (`scrapeCurrentPage` / `scrapeDomains` under JSDOM)
|
|
53
|
+
// does not require an installed brand on disk.
|
|
54
|
+
//
|
|
55
|
+
// Task 954 source-of-truth contract:
|
|
56
|
+
// Runtime: brand.json `cdpPort` at `${MAXY_PLATFORM_ROOT}/config/brand.json`
|
|
57
|
+
// is authoritative. Wrapper exports MAXY_PLATFORM_ROOT and BRAND. Missing
|
|
58
|
+
// env / file / field → loud-fail with one of three named reasons. The
|
|
59
|
+
// pre-Task-954 silent CDP-port default made every non-Maxy brand fail
|
|
60
|
+
// `cdp-unreachable` because the helper hit Maxy's port instead of the
|
|
61
|
+
// brand's; Task 787 / NEO4J_URI sets the precedent that runtime config
|
|
62
|
+
// never falls back silently.
|
|
63
|
+
//
|
|
64
|
+
// Test overrides: when BOTH `LIST_CF_DOMAINS_CDP_HOST` and
|
|
65
|
+
// `LIST_CF_DOMAINS_CDP_PORT` are set, they win over brand.json. The
|
|
66
|
+
// existing ECONNREFUSED vitest forces `127.0.0.1:1` (RFC 6335-reserved)
|
|
67
|
+
// this way without an installed brand. Production never sets these.
|
|
58
68
|
|
|
59
69
|
// Phase budgets total 30s (enforced by the shell wrapper's `timeout`).
|
|
60
70
|
// Individual steps get sub-budgets so an early stall does not eat the
|
|
@@ -72,7 +82,14 @@ type Reason =
|
|
|
72
82
|
| "not-signed-in"
|
|
73
83
|
| "navigate-failed"
|
|
74
84
|
| "table-wait-timeout"
|
|
75
|
-
| "runtime-error"
|
|
85
|
+
| "runtime-error"
|
|
86
|
+
// Task 954 — config-class failures. The first three short-circuit before
|
|
87
|
+
// any CDP work begins; the route maps them to `field='config'` so the
|
|
88
|
+
// form renders a config-card naming the brand + path instead of a Retry
|
|
89
|
+
// button (retrying re-fires the same wrongly-resolved spawn — Task 787
|
|
90
|
+
// pattern).
|
|
91
|
+
| "brand-config-missing"
|
|
92
|
+
| "cdp-port-unresolved";
|
|
76
93
|
|
|
77
94
|
// Sticky flag: once a stream-log write fails (EACCES, ENOENT, …), skip
|
|
78
95
|
// subsequent file writes for the rest of this process. Repeated appends to an
|
|
@@ -120,11 +137,85 @@ function die(reason: Reason, detail = ""): never {
|
|
|
120
137
|
process.exit(1);
|
|
121
138
|
}
|
|
122
139
|
|
|
123
|
-
|
|
140
|
+
interface CdpEndpoint {
|
|
141
|
+
host: string;
|
|
142
|
+
port: number;
|
|
143
|
+
source: "env-override" | "brand.json";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Task 954 — single source of truth for the CDP endpoint. Called once from
|
|
147
|
+
// `main()` so the JSDOM scrape test (which imports `scrapeCurrentPage`
|
|
148
|
+
// directly without invoking `main`) does not need an installed brand.
|
|
149
|
+
function resolveCdpEndpoint(): CdpEndpoint {
|
|
150
|
+
const envHost = process.env.LIST_CF_DOMAINS_CDP_HOST;
|
|
151
|
+
const envPortRaw = process.env.LIST_CF_DOMAINS_CDP_PORT;
|
|
152
|
+
if (envHost !== undefined && envPortRaw !== undefined) {
|
|
153
|
+
const envPort = Number(envPortRaw);
|
|
154
|
+
if (!Number.isInteger(envPort) || envPort <= 0 || envPort > 65535) {
|
|
155
|
+
die(
|
|
156
|
+
"cdp-port-unresolved",
|
|
157
|
+
`LIST_CF_DOMAINS_CDP_PORT="${envPortRaw}" is not a valid integer port`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return { host: envHost, port: envPort, source: "env-override" };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const brand = process.env.BRAND;
|
|
164
|
+
if (!brand) {
|
|
165
|
+
die(
|
|
166
|
+
"brand-config-missing",
|
|
167
|
+
"BRAND env var not set by wrapper — refusing to guess brand identity",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const platformRoot = process.env.MAXY_PLATFORM_ROOT;
|
|
171
|
+
if (!platformRoot) {
|
|
172
|
+
die(
|
|
173
|
+
"brand-config-missing",
|
|
174
|
+
"MAXY_PLATFORM_ROOT env var not set — wrapper must export it before spawn",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const brandPath = resolve(platformRoot, "config", "brand.json");
|
|
178
|
+
let raw: string;
|
|
179
|
+
try {
|
|
180
|
+
raw = readFileSync(brandPath, "utf-8");
|
|
181
|
+
} catch (err) {
|
|
182
|
+
// Distinct emission (not via `die`) so the path is named on the same line
|
|
183
|
+
// — config-card rendering on the route side keys off it.
|
|
184
|
+
logPhase(
|
|
185
|
+
`phase=error reason=brand-config-missing path=${brandPath} detail="${(err instanceof Error ? err.message : String(err)).replace(/"/g, "'").slice(0, 200)}"`,
|
|
186
|
+
);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
let parsed: Record<string, unknown>;
|
|
190
|
+
try {
|
|
191
|
+
parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
logPhase(
|
|
194
|
+
`phase=error reason=brand-config-missing path=${brandPath} detail="parse failed: ${(err instanceof Error ? err.message : String(err)).replace(/"/g, "'").slice(0, 160)}"`,
|
|
195
|
+
);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
const cdpPort = parsed.cdpPort;
|
|
199
|
+
if (typeof cdpPort !== "number" || !Number.isInteger(cdpPort) || cdpPort <= 0 || cdpPort > 65535) {
|
|
200
|
+
// Names the keys actually present so a schema-drift incident surfaces
|
|
201
|
+
// immediately ("oh, brand.json renamed `cdpPort` to `cdp_port` in r123").
|
|
202
|
+
const keys = Object.keys(parsed).join(",");
|
|
203
|
+
logPhase(
|
|
204
|
+
`phase=error reason=cdp-port-unresolved brand=${brand} json_keys=${keys}`,
|
|
205
|
+
);
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
// Host stays 127.0.0.1 on the runtime path — vnc.sh binds Chromium's CDP
|
|
209
|
+
// server to localhost only, by construction. brand.json carries the port,
|
|
210
|
+
// not the host, because the host has never varied across brands.
|
|
211
|
+
return { host: "127.0.0.1", port: cdpPort, source: "brand.json" };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function cdpVersion(host: string, port: number): Promise<void> {
|
|
124
215
|
const controller = new AbortController();
|
|
125
216
|
const timer = setTimeout(() => controller.abort(), CONNECT_TIMEOUT_MS);
|
|
126
217
|
try {
|
|
127
|
-
const res = await fetch(`http://${
|
|
218
|
+
const res = await fetch(`http://${host}:${port}/json/version`, { signal: controller.signal });
|
|
128
219
|
if (!res.ok) die("cdp-unreachable", `HTTP ${res.status}`);
|
|
129
220
|
} catch (err) {
|
|
130
221
|
die("cdp-unreachable", err instanceof Error ? err.message : String(err));
|
|
@@ -138,12 +229,12 @@ interface CdpTarget {
|
|
|
138
229
|
webSocketDebuggerUrl: string;
|
|
139
230
|
}
|
|
140
231
|
|
|
141
|
-
async function createTarget(): Promise<CdpTarget> {
|
|
232
|
+
async function createTarget(host: string, port: number): Promise<CdpTarget> {
|
|
142
233
|
// `PUT /json/new?<url>` returns JSON describing the target. The URL goes
|
|
143
234
|
// as a query-string argument (not a ?url= key), matching Chromium's CDP
|
|
144
235
|
// server. about:blank avoids a wasted navigation — the real navigate
|
|
145
236
|
// happens over WS after we attach.
|
|
146
|
-
const endpoint = `http://${
|
|
237
|
+
const endpoint = `http://${host}:${port}/json/new?about:blank`;
|
|
147
238
|
let res: Response;
|
|
148
239
|
try {
|
|
149
240
|
res = await fetch(endpoint, { method: "PUT", signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
|
|
@@ -158,9 +249,9 @@ async function createTarget(): Promise<CdpTarget> {
|
|
|
158
249
|
return target as CdpTarget;
|
|
159
250
|
}
|
|
160
251
|
|
|
161
|
-
async function closeTarget(id: string): Promise<void> {
|
|
252
|
+
async function closeTarget(host: string, port: number, id: string): Promise<void> {
|
|
162
253
|
try {
|
|
163
|
-
await fetch(`http://${
|
|
254
|
+
await fetch(`http://${host}:${port}/json/close/${id}`, {
|
|
164
255
|
signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS),
|
|
165
256
|
});
|
|
166
257
|
} catch {
|
|
@@ -602,19 +693,20 @@ async function waitForDocumentReady(cdp: CdpClient): Promise<void> {
|
|
|
602
693
|
}
|
|
603
694
|
|
|
604
695
|
async function main(): Promise<void> {
|
|
605
|
-
|
|
696
|
+
const endpoint = resolveCdpEndpoint();
|
|
697
|
+
logPhase(`phase=script-start cdp=${endpoint.host}:${endpoint.port} source=${endpoint.source}`);
|
|
606
698
|
|
|
607
|
-
await cdpVersion();
|
|
699
|
+
await cdpVersion(endpoint.host, endpoint.port);
|
|
608
700
|
logPhase(`phase=cdp-connect result=ok`);
|
|
609
701
|
|
|
610
|
-
const target = await createTarget();
|
|
702
|
+
const target = await createTarget(endpoint.host, endpoint.port);
|
|
611
703
|
logPhase(`phase=target-created targetId=${target.id.slice(0, 8)}…`);
|
|
612
704
|
|
|
613
705
|
let cdp: CdpClient;
|
|
614
706
|
try {
|
|
615
707
|
cdp = await CdpClient.connect(target.webSocketDebuggerUrl);
|
|
616
708
|
} catch (err) {
|
|
617
|
-
await closeTarget(target.id);
|
|
709
|
+
await closeTarget(endpoint.host, endpoint.port, target.id);
|
|
618
710
|
die("ws-connect-failed", err instanceof Error ? err.message : String(err));
|
|
619
711
|
}
|
|
620
712
|
|
|
@@ -643,7 +735,7 @@ async function main(): Promise<void> {
|
|
|
643
735
|
die("runtime-error", err instanceof Error ? err.message : String(err));
|
|
644
736
|
} finally {
|
|
645
737
|
cdp.close();
|
|
646
|
-
await closeTarget(target.id);
|
|
738
|
+
await closeTarget(endpoint.host, endpoint.port, target.id);
|
|
647
739
|
}
|
|
648
740
|
}
|
|
649
741
|
|
|
@@ -9,6 +9,7 @@ Each installation has its own Cloudflare account. Sign-in is OAuth in the device
|
|
|
9
9
|
| **Product identity** (Maxy vs Real Agent) | `brand.json` (`productName`, `configDir`) — known at install. |
|
|
10
10
|
| **Cloudflare account identity** | `cert.pem` from OAuth. One account per brand per device. |
|
|
11
11
|
| **Domain scope** (which zones the operator can route) | Live Cloudflare dashboard at form-render time via `list-cf-domains.sh`, not `brand.json`. Brand identity has no authority over which domains the operator's CF account holds. When the scrape returns an unexpected count (e.g. 1 on a two-zone account), the stream log's per-poll `phase=dom-scrape-poll n=<k> count=<n> domains=[…]` trajectory + the on-disk HTML dump at `~/{configDir}/logs/list-cf-domains-<ts>-count<n>-<mode>-pid<pid>.html` (earlier platform fixes — written on every scrape outcome, not just empty ones) give the operator everything they need to triage the cause without re-running. |
|
|
12
|
+
| **CDP port the scrape attaches to** | `brand.json` (`cdpPort`, stamped at install time) — the same brand-scoped port `vnc.sh` binds Chromium to. `list-cf-domains.sh <brand>` requires the brand arg; the helper reads `${MAXY_PLATFORM_ROOT}/config/brand.json` (no silent default). Missing brand arg, missing brand.json, or missing `cdpPort` field each exit 1 with one of three named reasons (`brand-arg-missing`, `brand-config-missing`, `cdp-port-unresolved`); the route maps all three to `field=config` so the form renders a config card naming the brand instead of a Retry button — retrying would re-fire the same wrongly-resolved spawn. |
|
|
12
13
|
| **Local tunnel state** | `~/{configDir}/cloudflared/` — `cert.pem`, `<UUID>.json`, `config.yml`, `tunnel.state`, `alias-domains.json`. |
|
|
13
14
|
|
|
14
15
|
There is no token-based auth for the operator-owned path (Mode A). To switch Cloudflare accounts, run `reset-tunnel.sh` (which deletes the cert and every tunnel on the current account), then run `setup-tunnel.sh` again — `cloudflared tunnel login` inside the setup script will pick a fresh account when you sign in.
|
|
@@ -142,6 +142,8 @@ Multi-tenancy boundary. Every query is scoped to the requesting account. The `AC
|
|
|
142
142
|
|
|
143
143
|
The read filter alone is not sufficient — it correctly *hides* alien-account nodes from every UI but does not prevent them existing. A writer that misresolves `accountId` (literal, undefined, or inferred-from-the-wrong-context) leaks nodes into the graph with no downstream symptom; the read filter then keeps them invisible indefinitely. The write-side doctrine is documented in `.docs/neo4j.md` "Account isolation invariant" — every writer that stamps `n.accountId` must verify the value against `${DATA_ROOT}/accounts/<id>/account.json` before write. The live floor is `writeNodeWithEdges` — every doctrine-primitive write is gated by an `accountId == process.env.ACCOUNT_ID` check (the spawning process validates `ACCOUNT_ID` at boot against the on-disk account set via the `account-enumeration` lib), with `[graph-write] reject reason=invalid-account-id …` as the rejection signal.
|
|
144
144
|
|
|
145
|
+
**Two boot-time surfaces stamp + validate the env** (added 2026-05-07). The brand systemd unit emits `Environment=ACCOUNT_ID=<uuid>` (resolved by the installer from `INSTALL_DIR/data/accounts/<uuid>/account.json`); the Hono boot path then calls `validateAccountIdEnv` against the on-disk set and emits `[graph-health] account-id-env present=true id=<8> matches-on-disk=true` on success or `[graph-health] account-id-env FATAL reason=<missing|no-on-disk-account|mismatch>` + `process.exit(1)` on failure. No fallback — a misconfigured Pi cannot silently boot.
|
|
146
|
+
|
|
145
147
|
---
|
|
146
148
|
|
|
147
149
|
## Query Classification
|
|
@@ -53,7 +53,7 @@ When per-group activation is `mention`, the agent fires only if the inbound mess
|
|
|
53
53
|
|
|
54
54
|
Every `messages.upsert` event (both `notify` and `append`, both `fromMe` directions) writes a `:Message:WhatsAppMessage` row to Neo4j attached to the sessionKey-keyed `:Conversation`. A single capture site at `platform/ui/app/lib/whatsapp/manager.ts` covers inbound, outbound (Baileys echoes agent-sent messages back through `messages.upsert` with `fromMe=true`), and owner-mirror — without touching `outbound/send.ts`. `messageId` namespace is `whatsapp-live:<waName>:<remoteJid>:<msg.key.id>` where `<waName>` is the Baileys credential dirname (e.g. `default`); distinct from the `:Section:Conversation` chunks written by the source-agnostic `conversation-archive` skill — live and archive live in disjoint label spaces. Persist failures are loud (`[whatsapp-persist] FAIL …`) and never block dispatch — silent loss is the worse failure mode.
|
|
55
55
|
|
|
56
|
-
**`accountId` contract.** `n.accountId` on every `:Conversation`, `:Person`, and `:Message:WhatsAppMessage` row stamped by this plugin is the **platform-side UUID** resolved by [`resolvePlatformAccountId()`](../../ui/app/lib/whatsapp/platform-account-id.ts) from `data/accounts/<uuid>/account.json` — NOT the Baileys credential dirname (which is only used as the `messageId`/`sessionKey` namespace token). The boot-time line `[whatsapp-persist] resolved-account-id waname=<dir> uuid=<uuid>` records the resolution. Doctrine: see `.docs/neo4j.md` "Account isolation invariant" — every writer that stamps `n.accountId` must verify the value against `${DATA_ROOT}/accounts/<id>/account.json` before write. The helper loud-throws on zero or multi accounts (Phase 0 single-account invariant), aborting the WhatsApp connection start before any write can occur. The same boot-validated identity (`process.env.ACCOUNT_ID`) backs the central live floor at [`writeNodeWithEdges`](../../lib/graph-write/src/index.ts) — any write whose `accountId` differs from the spawning process's `ACCOUNT_ID` is rejected by the gate; the WhatsApp helper is the writer-side discipline, the gate is the universal floor.
|
|
56
|
+
**`accountId` contract.** `n.accountId` on every `:Conversation`, `:Person`, and `:Message:WhatsAppMessage` row stamped by this plugin is the **platform-side UUID** resolved by [`resolvePlatformAccountId()`](../../ui/app/lib/whatsapp/platform-account-id.ts) from `data/accounts/<uuid>/account.json` — NOT the Baileys credential dirname (which is only used as the `messageId`/`sessionKey` namespace token). The boot-time line `[whatsapp-persist] resolved-account-id waname=<dir> uuid=<uuid>` records the resolution. Doctrine: see `.docs/neo4j.md` "Account isolation invariant" — every writer that stamps `n.accountId` must verify the value against `${DATA_ROOT}/accounts/<id>/account.json` before write. The helper loud-throws on zero or multi accounts (Phase 0 single-account invariant), aborting the WhatsApp connection start before any write can occur. The same boot-validated identity (`process.env.ACCOUNT_ID`) backs the central live floor at [`writeNodeWithEdges`](../../lib/graph-write/src/index.ts) — any write whose `accountId` differs from the spawning process's `ACCOUNT_ID` is rejected by the gate; the WhatsApp helper is the writer-side discipline, the gate is the universal floor. The env itself is stamped onto the brand systemd unit by `buildMaxyUnitFile` and re-validated at every Hono boot (`[graph-health] account-id-env present=true matches-on-disk=true`) — see `.docs/neo4j.md` "Two boot-time surfaces" (added 2026-05-07).
|
|
57
57
|
|
|
58
58
|
## Skills
|
|
59
59
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./jsx-runtime-
|
|
1
|
+
import{t as e}from"./jsx-runtime-DnY0498s.js";var t=e();function n({checked:e,onChange:n,label:r,disabled:i}){return(0,t.jsxs)(`label`,{className:`maxy-checkbox${i?` maxy-checkbox--disabled`:``}`,children:[(0,t.jsx)(`input`,{type:`checkbox`,checked:e,onChange:e=>n(e.target.checked),disabled:i}),(0,t.jsx)(`span`,{className:`maxy-checkbox__box`,children:`✱`}),r&&(0,t.jsx)(`span`,{className:`maxy-checkbox__label`,children:r})]})}export{n as t};
|