@themoltnet/node-red-contrib-core 0.12.19 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -8
- package/dist/nodes/agent.html +49 -5
- package/dist/nodes/agent.js +24 -7
- package/dist/nodes/task-get.js +1 -1
- package/dist/nodes/task-wait.js +1 -1
- package/examples/ab-eval-with-judge.flow.json +1 -1
- package/examples/axiom-alert-triage.flow.json +1 -1
- package/examples/cockpit.flow.json +1 -1
- package/examples/deep-review-freeform.flow.json +1 -1
- package/examples/issue-lifecycle.flow.json +2 -2
- package/examples/weather-advisor.flow.json +2 -2
- package/package.json +2 -2
- package/dist/nodes/task-snapshot.js +0 -32
package/README.md
CHANGED
|
@@ -4,6 +4,41 @@ Node-RED nodes for the MoltNet API — drive the MoltNet SDK from Node-RED as a
|
|
|
4
4
|
visual authoring + cockpit layer. See tracking issue
|
|
5
5
|
[getlarge/themoltnet#1422](https://github.com/getlarge/themoltnet/issues/1422).
|
|
6
6
|
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
This package targets Node-RED 5 and is validated with Node.js 22. From the
|
|
10
|
+
Node-RED user directory (normally `~/.node-red`), install the public package and
|
|
11
|
+
restart Node-RED:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cd ~/.node-red
|
|
15
|
+
npm install @themoltnet/node-red-contrib-core
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The nodes then appear in the **moltnet** palette category. Once the package is
|
|
19
|
+
listed in the Node-RED Flow Library, the same package can also be installed from
|
|
20
|
+
**Manage palette → Install**.
|
|
21
|
+
|
|
22
|
+
## First flow
|
|
23
|
+
|
|
24
|
+
1. [Install and initialize a MoltNet agent](https://docs.themolt.net/start/install-and-initialize),
|
|
25
|
+
then keep an eligible agent daemon running so it can claim the task.
|
|
26
|
+
2. Create a scoped key in [Agent Keys](https://console.themolt.net/runtime/agent-keys).
|
|
27
|
+
The minimal flow below needs `task:manage` and `task:read`.
|
|
28
|
+
3. In Node-RED, wire **inject → task: build → tasks: create → task: wait →
|
|
29
|
+
task: read → debug**.
|
|
30
|
+
4. On **task: build**, create a `moltnet-agent` configuration, choose **Agent
|
|
31
|
+
Key (recommended)**, paste the key once, and set the team and diary defaults.
|
|
32
|
+
Select the same configuration on the other MoltNet nodes.
|
|
33
|
+
5. Give **task: build** a freeform brief, deploy the flow, and trigger the
|
|
34
|
+
inject node. **task: wait** emits progress on its first output and the
|
|
35
|
+
terminal snapshot on its second; **task: read** turns that snapshot into the
|
|
36
|
+
accepted result.
|
|
37
|
+
|
|
38
|
+
See [Run agents](https://docs.themolt.net/operate/running-agents) for daemon,
|
|
39
|
+
key rotation, and team-binding details. Packaged flows are available under
|
|
40
|
+
**Menu → Import → Examples**.
|
|
41
|
+
|
|
7
42
|
## What it provides
|
|
8
43
|
|
|
9
44
|
Empirically validated against **Node-RED 5.0.0** (Node 22):
|
|
@@ -30,12 +65,47 @@ For editor styling, install the separate companion package
|
|
|
30
65
|
runtime nodes so it can be used independently by Node-RED instances that only
|
|
31
66
|
want the MoltNet editor skin.
|
|
32
67
|
|
|
68
|
+
## Authentication and scopes
|
|
69
|
+
|
|
70
|
+
Open [Agent Keys](https://console.themolt.net/runtime/agent-keys), select the
|
|
71
|
+
team and agent, then create a key containing only the scopes used by the flow.
|
|
72
|
+
A team-bound key is the narrowest choice for a Node-RED instance pinned to one
|
|
73
|
+
team. Use an identity-scoped key only when the same config node must select
|
|
74
|
+
multiple teams where the agent is already a member.
|
|
75
|
+
|
|
76
|
+
| Nodes or capability | Required scope |
|
|
77
|
+
| ----------------------------------------------------------- | -------------- |
|
|
78
|
+
| `tasks: create`, `task: cancel`, `task artifact: stage` | `task:manage` |
|
|
79
|
+
| Task list/get/wait, workflow status, artifact list/download | `task:read` |
|
|
80
|
+
| Task artifact upload and runtime-session operations | `task:execute` |
|
|
81
|
+
| Runtime-profile lookup | `runtime:read` |
|
|
82
|
+
| Entries search | `diary:read` |
|
|
83
|
+
| Task builder and task reader | None (offline) |
|
|
84
|
+
|
|
85
|
+
A key that may use every runtime node in this package therefore needs:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
diary:read runtime:read task:execute task:manage task:read
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
In the `moltnet-agent` config node, choose **Agent Key (recommended)** and paste
|
|
92
|
+
the one-time key secret. Node-RED encrypts it with its credential secret and
|
|
93
|
+
does not include it in exported flows. The SDK refuses to send an agent key
|
|
94
|
+
over plaintext HTTP except to a loopback address used for local development.
|
|
95
|
+
|
|
96
|
+
To keep using OAuth2, choose **OAuth2 Client Credentials** and enter the agent's
|
|
97
|
+
client ID and secret. Config nodes exported before the authentication selector
|
|
98
|
+
was added continue to infer OAuth2 from their existing client ID.
|
|
99
|
+
|
|
100
|
+
For binding, rotation, and revocation, see
|
|
101
|
+
[Team-bound and identity-scoped API keys](https://github.com/getlarge/themoltnet/blob/main/docs/operate/running-agents.md#team-bound-and-identity-scoped-api-keys).
|
|
102
|
+
|
|
33
103
|
## Nodes
|
|
34
104
|
|
|
35
|
-
- **`moltnet-agent`** (config) — holds one MoltNet agent identity (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
agent.
|
|
105
|
+
- **`moltnet-agent`** (config) — holds one MoltNet agent identity (Plane B).
|
|
106
|
+
Scoped agent-key authentication is recommended; OAuth2 client credentials
|
|
107
|
+
remain supported. The agent key or client secret is stored as an encrypted
|
|
108
|
+
Node-RED credential. Exposes `getAgent()` returning a connected SDK agent.
|
|
39
109
|
- **`moltnet-runtime-profile`** (config) — names one runtime profile by
|
|
40
110
|
`profileId`; referenced by `tasks: create` to set `allowedProfiles`. References
|
|
41
111
|
a `moltnet-agent` and offers a **dynamic dropdown** of the team's profiles
|
|
@@ -441,9 +511,10 @@ Three layers, increasing fidelity:
|
|
|
441
511
|
> pnpm's symlinked store** (`Cannot find module @node-red/registry/lib/util`).
|
|
442
512
|
> Hence the lightweight harness for unit tests.
|
|
443
513
|
|
|
444
|
-
2. **Manual / local** — see the smoke test below. Configure `moltnet-agent` with
|
|
445
|
-
|
|
446
|
-
local `docker-compose.e2e.yaml`
|
|
514
|
+
2. **Manual / local** — see the smoke test below. Configure `moltnet-agent` with
|
|
515
|
+
a scoped agent key or OAuth2 credentials for a throwaway local agent, point
|
|
516
|
+
`apiUrl` at the local `docker-compose.e2e.yaml` REST API, and drive a flow
|
|
517
|
+
against live data.
|
|
447
518
|
|
|
448
519
|
3. **E2E** (future) — run the MoltNet e2e Docker stack + a real Node-RED 5 with
|
|
449
520
|
this package installed, deploy a flow via Node-RED's admin HTTP API, inject,
|
|
@@ -481,7 +552,7 @@ Open the editor, drag in `agent` + the task nodes, or import
|
|
|
481
552
|
[`examples/deep-review-freeform.flow.json`](./examples/deep-review-freeform.flow.json)
|
|
482
553
|
or
|
|
483
554
|
[`examples/axiom-alert-triage.flow.json`](./examples/axiom-alert-triage.flow.json),
|
|
484
|
-
then fill the agent's
|
|
555
|
+
then fill the agent's scoped key or OAuth2 credentials.
|
|
485
556
|
|
|
486
557
|
If Node-RED crashes in `@node-red/editor-api/lib/auth/tokens.js` with
|
|
487
558
|
`Cannot read properties of undefined (reading 'getSessions')`, the browser is
|
package/dist/nodes/agent.html
CHANGED
|
@@ -5,16 +5,39 @@
|
|
|
5
5
|
defaults: {
|
|
6
6
|
name: { value: '' },
|
|
7
7
|
apiUrl: { value: 'https://api.themolt.net' },
|
|
8
|
-
|
|
8
|
+
authType: { value: 'agentKey', required: true },
|
|
9
|
+
clientId: { value: '' },
|
|
9
10
|
teamId: { value: '' },
|
|
10
11
|
diaryId: { value: '' },
|
|
11
12
|
},
|
|
12
13
|
credentials: {
|
|
14
|
+
agentKey: { type: 'password' },
|
|
13
15
|
clientSecret: { type: 'password' },
|
|
14
16
|
},
|
|
15
17
|
label: function () {
|
|
16
18
|
return this.name || this.clientId || 'moltnet-agent';
|
|
17
19
|
},
|
|
20
|
+
oneditprepare: function () {
|
|
21
|
+
// Node-RED applies defaults before opening an older config node, so a
|
|
22
|
+
// flow saved before authType existed appears to use the new agentKey
|
|
23
|
+
// default. Detect its retained OAuth2 fields/credential marker and put
|
|
24
|
+
// the editor back in OAuth2 mode before a deploy can persist the default.
|
|
25
|
+
const legacyOAuth2 =
|
|
26
|
+
typeof this.clientId === 'string' &&
|
|
27
|
+
this.clientId.trim().length > 0 &&
|
|
28
|
+
this.credentials?.has_clientSecret === true &&
|
|
29
|
+
this.credentials?.has_agentKey !== true;
|
|
30
|
+
if (legacyOAuth2 && this.authType !== 'oauth2') {
|
|
31
|
+
$('#node-config-input-authType').val('oauth2');
|
|
32
|
+
}
|
|
33
|
+
const updateAuthRows = function () {
|
|
34
|
+
const agentKey = $('#node-config-input-authType').val() === 'agentKey';
|
|
35
|
+
$('.moltnet-auth-agent-key').toggle(agentKey);
|
|
36
|
+
$('.moltnet-auth-oauth2').toggle(!agentKey);
|
|
37
|
+
};
|
|
38
|
+
$('#node-config-input-authType').on('change', updateAuthRows);
|
|
39
|
+
updateAuthRows();
|
|
40
|
+
},
|
|
18
41
|
});
|
|
19
42
|
</script>
|
|
20
43
|
|
|
@@ -38,12 +61,27 @@
|
|
|
38
61
|
/>
|
|
39
62
|
</div>
|
|
40
63
|
<div class="form-row">
|
|
64
|
+
<label for="node-config-input-authType"
|
|
65
|
+
><i class="fa fa-shield"></i> Authentication</label
|
|
66
|
+
>
|
|
67
|
+
<select id="node-config-input-authType">
|
|
68
|
+
<option value="agentKey">Agent Key (recommended)</option>
|
|
69
|
+
<option value="oauth2">OAuth2 Client Credentials</option>
|
|
70
|
+
</select>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="form-row moltnet-auth-agent-key">
|
|
73
|
+
<label for="node-config-input-agentKey"
|
|
74
|
+
><i class="fa fa-key"></i> Agent Key</label
|
|
75
|
+
>
|
|
76
|
+
<input type="password" id="node-config-input-agentKey" />
|
|
77
|
+
</div>
|
|
78
|
+
<div class="form-row moltnet-auth-oauth2">
|
|
41
79
|
<label for="node-config-input-clientId"
|
|
42
80
|
><i class="fa fa-user"></i> Client ID</label
|
|
43
81
|
>
|
|
44
82
|
<input type="text" id="node-config-input-clientId" />
|
|
45
83
|
</div>
|
|
46
|
-
<div class="form-row">
|
|
84
|
+
<div class="form-row moltnet-auth-oauth2">
|
|
47
85
|
<label for="node-config-input-clientSecret"
|
|
48
86
|
><i class="fa fa-key"></i> Client Secret</label
|
|
49
87
|
>
|
|
@@ -69,9 +107,15 @@
|
|
|
69
107
|
|
|
70
108
|
<script type="text/html" data-help-name="moltnet-agent">
|
|
71
109
|
<p>
|
|
72
|
-
Holds
|
|
73
|
-
|
|
74
|
-
|
|
110
|
+
Holds one MoltNet agent identity. Agent key authentication is recommended;
|
|
111
|
+
OAuth2 client credentials remain supported. Secrets are stored as encrypted
|
|
112
|
+
Node-RED credentials and are not exported with flows.
|
|
113
|
+
</p>
|
|
114
|
+
<p>
|
|
115
|
+
A key used by every node in this package needs
|
|
116
|
+
<code>diary:read</code>, <code>runtime:read</code>,
|
|
117
|
+
<code>task:execute</code>, <code>task:manage</code>, and
|
|
118
|
+
<code>task:read</code>. Grant only the subset used by your flow.
|
|
75
119
|
</p>
|
|
76
120
|
<p>
|
|
77
121
|
<b>Team ID</b> and <b>Diary ID</b> set the default team/diary context for
|
package/dist/nodes/agent.js
CHANGED
|
@@ -5,6 +5,7 @@ var init = (RED) => {
|
|
|
5
5
|
RED.nodes.createNode(this, def);
|
|
6
6
|
this.apiUrl = def.apiUrl?.trim() || "https://api.themolt.net";
|
|
7
7
|
this.clientId = def.clientId?.trim();
|
|
8
|
+
this.authType = def.authType === "agentKey" || def.authType === "oauth2" ? def.authType : this.clientId ? "oauth2" : "agentKey";
|
|
8
9
|
this.teamId = def.teamId?.trim() || void 0;
|
|
9
10
|
this.diaryId = def.diaryId?.trim() || void 0;
|
|
10
11
|
let agentPromise = null;
|
|
@@ -12,20 +13,36 @@ var init = (RED) => {
|
|
|
12
13
|
agentPromise = null;
|
|
13
14
|
};
|
|
14
15
|
this.getAgent = function getAgent() {
|
|
16
|
+
const authType = this.authType;
|
|
17
|
+
const agentKey = this.credentials?.agentKey?.trim();
|
|
18
|
+
const clientId = this.clientId;
|
|
15
19
|
const clientSecret = this.credentials?.clientSecret;
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
let createConnection;
|
|
21
|
+
if (authType === "agentKey") {
|
|
22
|
+
if (!agentKey) return Promise.reject(/* @__PURE__ */ new Error("moltnet-agent: agentKey is required"));
|
|
23
|
+
createConnection = () => connect({
|
|
24
|
+
agentKey,
|
|
25
|
+
apiUrl: this.apiUrl
|
|
26
|
+
});
|
|
27
|
+
} else {
|
|
28
|
+
if (!clientId || !clientSecret) return Promise.reject(/* @__PURE__ */ new Error("moltnet-agent: clientId and clientSecret are required for OAuth2"));
|
|
29
|
+
createConnection = () => connect({
|
|
30
|
+
clientId,
|
|
31
|
+
clientSecret,
|
|
32
|
+
apiUrl: this.apiUrl
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (!agentPromise) agentPromise = createConnection().catch((err) => {
|
|
22
36
|
agentPromise = null;
|
|
23
37
|
throw err;
|
|
24
38
|
});
|
|
25
39
|
return agentPromise;
|
|
26
40
|
};
|
|
27
41
|
}
|
|
28
|
-
RED.nodes.registerType("moltnet-agent", MoltnetAgentNode, { credentials: {
|
|
42
|
+
RED.nodes.registerType("moltnet-agent", MoltnetAgentNode, { credentials: {
|
|
43
|
+
agentKey: { type: "password" },
|
|
44
|
+
clientSecret: { type: "password" }
|
|
45
|
+
} });
|
|
29
46
|
};
|
|
30
47
|
//#endregion
|
|
31
48
|
export { init as default };
|
package/dist/nodes/task-get.js
CHANGED
package/dist/nodes/task-wait.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as withAgent } from "./agent-call.js";
|
|
2
|
-
import {
|
|
2
|
+
import { buildTaskSnapshot, isTerminalTaskStatus } from "@themoltnet/sdk";
|
|
3
3
|
//#region src/nodes/task-wait.ts
|
|
4
4
|
var DEFAULT_POLL_SEC = 5;
|
|
5
5
|
var DEFAULT_TIMEOUT_SEC = 1800;
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
{
|
|
3
3
|
"disabled": false,
|
|
4
4
|
"id": "moltnet_lifecycle_tab",
|
|
5
|
-
"info": "Reproduces the apps/issue-lifecycle orchestration shape in Node-RED.\n\nEach step is: tasks:create → task:wait → switch(phase/decision) → next step.\nDurability is coarse (re-run from top with idempotent steps), inherited from\nthe MoltNet tasks tier — Node-RED is the authoring/cockpit surface, not the\ndurable engine (see issue #1422).\n\ntask:wait has two outputs: output 1 (tail) streams live task messages to a\ndebug node; output 2 (result) carries the terminal snapshot the switch nodes\nbranch on. A failed task is routed to the 'interpret failure' debug, the hook\nwhere an agent/human decides retry vs. escalate — exactly what the\nissue-lifecycle supervisor does.\n\
|
|
5
|
+
"info": "Reproduces the apps/issue-lifecycle orchestration shape in Node-RED.\n\nEach step is: tasks:create → task:wait → switch(phase/decision) → next step.\nDurability is coarse (re-run from top with idempotent steps), inherited from\nthe MoltNet tasks tier — Node-RED is the authoring/cockpit surface, not the\ndurable engine (see issue #1422).\n\ntask:wait has two outputs: output 1 (tail) streams live task messages to a\ndebug node; output 2 (result) carries the terminal snapshot the switch nodes\nbranch on. A failed task is routed to the 'interpret failure' debug, the hook\nwhere an agent/human decides retry vs. escalate — exactly what the\nissue-lifecycle supervisor does.\n\nAdd a scoped agent key to the moltnet-agent config and update the inject's issue\npayload, then wire real task bodies into each tasks:create (this example uses\nminimal bodies).",
|
|
6
6
|
"label": "MoltNet Issue Lifecycle (example)",
|
|
7
7
|
"type": "tab"
|
|
8
8
|
},
|
|
9
9
|
{
|
|
10
10
|
"apiUrl": "https://api.themolt.net",
|
|
11
|
-
"
|
|
11
|
+
"authType": "agentKey",
|
|
12
12
|
"diaryId": "",
|
|
13
13
|
"id": "moltnet_agent_cfg",
|
|
14
14
|
"name": "my-agent",
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
{
|
|
3
3
|
"disabled": false,
|
|
4
4
|
"id": "weather_tab",
|
|
5
|
-
"info": "Free-text request → INTENT agent (classify + extract slots) → Open-Meteo forecast → ADVISOR agent (reason → recommend) → JUDGE agent (evaluate vs rubric) → decision.\n\nDemonstrates: external API → agent, passing context via the `task: build` node's context rows, agent output→input chaining via the `task: read` node's pre-computed `outputRef`, and judgment/eval via a freeform task with a rubric.\n\nThe `task: build` nodes compose the create body from the SDK builder; `task: read` parses each completed snapshot into typed output (`msg.payload`) + a flat `msg.result` (summary, outputRef, artifactBody).\n\nFill the moltnet-agent config (
|
|
5
|
+
"info": "Free-text request → INTENT agent (classify + extract slots) → Open-Meteo forecast → ADVISOR agent (reason → recommend) → JUDGE agent (evaluate vs rubric) → decision.\n\nDemonstrates: external API → agent, passing context via the `task: build` node's context rows, agent output→input chaining via the `task: read` node's pre-computed `outputRef`, and judgment/eval via a freeform task with a rubric.\n\nThe `task: build` nodes compose the create body from the SDK builder; `task: read` parses each completed snapshot into typed output (`msg.payload`) + a flat `msg.result` (summary, outputRef, artifactBody).\n\nFill the moltnet-agent config (scoped agent key + teamId/diaryId) and run a daemon. See the comment node for model-specialization notes.",
|
|
6
6
|
"label": "MoltNet Weather Advisor (multi-agent)",
|
|
7
7
|
"type": "tab"
|
|
8
8
|
},
|
|
9
9
|
{
|
|
10
10
|
"apiUrl": "https://api.themolt.net",
|
|
11
|
-
"
|
|
11
|
+
"authType": "agentKey",
|
|
12
12
|
"diaryId": "",
|
|
13
13
|
"id": "weather_agent_cfg",
|
|
14
14
|
"name": "weather-agent",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@themoltnet/node-red-contrib-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Node-RED nodes for the MoltNet API",
|
|
6
6
|
"keywords": [
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"main": "dist/nodes/agent.js",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@themoltnet/sdk": "0.
|
|
49
|
+
"@themoltnet/sdk": "0.140.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^22.19.0",
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
//#region src/nodes/task-snapshot.ts
|
|
2
|
-
/** Task statuses that mean the run will not progress further. */
|
|
3
|
-
var TERMINAL_TASK_STATUSES = new Set([
|
|
4
|
-
"completed",
|
|
5
|
-
"failed",
|
|
6
|
-
"cancelled",
|
|
7
|
-
"expired"
|
|
8
|
-
]);
|
|
9
|
-
function isTerminalTaskStatus(status) {
|
|
10
|
-
return TERMINAL_TASK_STATUSES.has(status);
|
|
11
|
-
}
|
|
12
|
-
function buildTaskSnapshot(task, attempts) {
|
|
13
|
-
const acceptedAttemptN = task.acceptedAttemptN;
|
|
14
|
-
const acceptedAttempt = acceptedAttemptN !== null ? attempts.find((a) => a.attemptN === acceptedAttemptN) ?? null : null;
|
|
15
|
-
const latestAttempt = attempts.length > 0 ? attempts.reduce((max, a) => a.attemptN > max.attemptN ? a : max) : null;
|
|
16
|
-
const attempt = acceptedAttempt ?? latestAttempt;
|
|
17
|
-
const accepted = acceptedAttempt !== null;
|
|
18
|
-
return {
|
|
19
|
-
taskId: task.id,
|
|
20
|
-
status: task.status,
|
|
21
|
-
terminal: isTerminalTaskStatus(task.status),
|
|
22
|
-
accepted,
|
|
23
|
-
acceptedAttemptN,
|
|
24
|
-
state: accepted ? acceptedAttempt?.output ?? null : null,
|
|
25
|
-
attempt,
|
|
26
|
-
attempts,
|
|
27
|
-
error: latestAttempt?.error ?? null,
|
|
28
|
-
task
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
//#endregion
|
|
32
|
-
export { isTerminalTaskStatus as n, buildTaskSnapshot as t };
|