node-red-contrib-copilot 0.0.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 ADDED
@@ -0,0 +1,223 @@
1
+ # node-red-contrib-copilot
2
+
3
+ ![node-red-contrib-copilot](https://img.shields.io/npm/v/node-red-contrib-copilot?label=npm)
4
+ ![Node-RED](https://img.shields.io/badge/node--red-%3E%3D3.0.0-red)
5
+ ![License](https://img.shields.io/badge/license-ISC-blue)
6
+
7
+ Embed GitHub Copilot into your Node-RED flows. Send prompts and file attachments to any Copilot model and wire the response into the rest of your automation.
8
+
9
+ Built on the [`@github/copilot-sdk`](https://github.com/github/copilot-sdk) — the same engine that powers Copilot CLI.
10
+
11
+ ---
12
+
13
+ ## Requirements
14
+
15
+ ### GitHub Copilot subscription
16
+
17
+ A GitHub Copilot subscription is required. A free tier with limited usage is available — see [GitHub Copilot pricing](https://github.com/features/copilot#pricing).
18
+
19
+ ### Authentication
20
+
21
+ Two methods are supported:
22
+
23
+ | Method | When to use |
24
+ |--------|-------------|
25
+ | **OAuth (default)** | You are logged in via `gh auth login` or the Copilot CLI on the host machine |
26
+ | **Fine-grained PAT** | Headless / containerised deployments; token must have the **Copilot Requests** permission |
27
+
28
+ > ⚠️ Classic PATs with the `copilot` scope do **not** work. You must use a fine-grained PAT with **Copilot Requests** permission.
29
+
30
+ ### Node.js
31
+
32
+ **Node.js v24** is required. The `@github/copilot` CLI binary bundled with the SDK is a native binary that requires a glibc-based (non-musl) environment.
33
+
34
+ ---
35
+
36
+ ## Docker
37
+
38
+ The recommended container is:
39
+
40
+ ```
41
+ nodered/node-red-dev:5.0.0-beta.3-debian
42
+ ```
43
+
44
+ This image provides:
45
+ - Node-RED 5.0 (beta)
46
+ - **Node.js v24** — required by the bundled Copilot CLI binary
47
+ - **Debian (glibc)** — the CLI binary is dynamically linked against glibc; Alpine (musl) is **not** supported
48
+
49
+ ### Quick start
50
+
51
+ ```bash
52
+ docker run -d \
53
+ --name nodered \
54
+ -p 1880:1880 \
55
+ -v /your/data:/data \
56
+ nodered/node-red-dev:5.0.0-beta.3-debian
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Installation
62
+
63
+ ### Via the Node-RED Palette Manager
64
+
65
+ Search for `node-red-contrib-copilot` in the Palette Manager and click **Install**.
66
+
67
+ ### Via npm (inside your Node-RED data directory)
68
+
69
+ ```bash
70
+ cd /your/node-red/data
71
+ npm install node-red-contrib-copilot
72
+ ```
73
+
74
+ ### In Docker
75
+
76
+ ```bash
77
+ docker exec nodered npm install node-red-contrib-copilot --prefix /data
78
+ docker restart nodered
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Nodes
84
+
85
+ ### `copilot-config` (configuration node)
86
+
87
+ Holds credentials and connection settings. Referenced by one or more `copilot` nodes.
88
+
89
+ | Field | Description |
90
+ |-------|-------------|
91
+ | **Name** | Label for this configuration |
92
+ | **Auth method** | `oauth` — use locally stored `gh` credentials; `token` — use a fine-grained PAT |
93
+ | **Token** | Fine-grained PAT with **Copilot Requests** permission (only when auth method is `token`) |
94
+ | **CLI Path** | Override the path to the `copilot` binary (leave blank to use the bundled binary) |
95
+ | **CLI URL** | Connect to an external CLI server instead of spawning a local process |
96
+
97
+ ### `copilot` (prompt node)
98
+
99
+ Sends a prompt to GitHub Copilot and emits the response.
100
+
101
+ #### Inputs
102
+
103
+ | Property | Type | Description |
104
+ |----------|------|-------------|
105
+ | `msg.payload` | `string` | Plain string used as the prompt |
106
+ | `msg.payload` | `object` | `{ prompt: string, attachments: Attachment[] }` |
107
+ | `msg.attachments` | `Attachment[]` | Merged with any attachments in `msg.payload` |
108
+ | `msg.model` | `string` | Override the model for this message only |
109
+
110
+ #### Attachment formats
111
+
112
+ ```js
113
+ // File path (passed directly to the SDK)
114
+ { type: "file", path: "/absolute/path/to/file.png" }
115
+
116
+ // Base64-encoded data
117
+ { type: "base64", data: "<base64 string>", name: "image.jpg" }
118
+
119
+ // Node.js Buffer
120
+ { type: "buffer", data: Buffer.from(...), name: "file.bin" }
121
+
122
+ // Shorthand — type is inferred as "file"
123
+ { path: "/absolute/path/to/file.txt" }
124
+ ```
125
+
126
+ #### Outputs
127
+
128
+ | Output | Property | Type | Description |
129
+ |--------|----------|------|-------------|
130
+ | **1 — Response** | `msg.payload` | `string` | The assistant's response text |
131
+ | | `msg.sessionId` | `string` | Copilot session ID |
132
+ | | `msg.events` | `array` | All events emitted during the session |
133
+ | **2 — Error** | `msg.payload` | `string` | Error message |
134
+ | | `msg.error` | `Error` | The error object |
135
+
136
+ #### Node configuration
137
+
138
+ | Field | Description |
139
+ |-------|-------------|
140
+ | **Config** | Select a `copilot-config` node |
141
+ | **Model** | Dynamically populated from the API — shows token cost multiplier, e.g. `claude-haiku-4.5 (0x)` |
142
+ | **Reasoning** | Reasoning effort hint: `low`, `medium`, `high`, `xhigh` (model-dependent) |
143
+ | **Timeout** | Request timeout in milliseconds (default: 60,000) |
144
+
145
+ ---
146
+
147
+ ## Architecture
148
+
149
+ ```
150
+ Node-RED flow
151
+
152
+ copilot node
153
+
154
+ @github/copilot-sdk (Node.js)
155
+ ↓ JSON-RPC
156
+ Copilot CLI (bundled, spawned as subprocess)
157
+ ↓ HTTPS
158
+ GitHub Copilot API
159
+ ```
160
+
161
+ The SDK manages the CLI process lifecycle automatically. The CLI binary is bundled with the `@github/copilot` package (a dependency of `@github/copilot-sdk`) and is resolved automatically at startup — no manual PATH configuration needed.
162
+
163
+ ---
164
+
165
+ ## Example flow
166
+
167
+ ```json
168
+ [
169
+ { "id": "cfg1", "type": "copilot-config", "name": "My Copilot", "authMethod": "token" },
170
+ { "id": "inj1", "type": "inject", "payload": "What is the capital of France?", "payloadType": "str", "wires": [["prompt1"]] },
171
+ { "id": "prompt1", "type": "copilot-prompt", "copilotConfig": "cfg1", "model": "gpt-4.1", "wires": [["dbg1"], ["err1"]] },
172
+ { "id": "dbg1", "type": "debug", "complete": "payload" },
173
+ { "id": "err1", "type": "debug", "complete": "payload" }
174
+ ]
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Development
180
+
181
+ ```bash
182
+ git clone https://github.com/yourname/node-red-contrib-copilot
183
+ cd node-red-contrib-copilot
184
+ npm install
185
+
186
+ # Unit tests (mocked, no API key required)
187
+ npm test
188
+
189
+ # Integration tests (real API — requires a fine-grained PAT)
190
+ GITHUB_TOKEN=<your-fine-grained-pat> npm run test:integration
191
+ ```
192
+
193
+ ### Deploying to Docker during development
194
+
195
+ ```bash
196
+ npm pack
197
+ cp node-red-contrib-copilot-*.tgz ~/your/node-red/data/
198
+ docker exec nodered npm install /data/node-red-contrib-copilot-*.tgz --prefix /data
199
+ docker restart nodered
200
+ ```
201
+
202
+ ---
203
+
204
+ ## Dependencies
205
+
206
+ | Package | Role |
207
+ |---------|------|
208
+ | [`@github/copilot-sdk`](https://www.npmjs.com/package/@github/copilot-sdk) `0.1.30` | Copilot client — session management, model listing, prompt dispatch |
209
+ | [`@github/copilot`](https://www.npmjs.com/package/@github/copilot) | Copilot CLI binary (bundled, installed transitively via the SDK) |
210
+
211
+ ---
212
+
213
+ ## Billing
214
+
215
+ Each prompt counts against your Copilot premium request quota. The model dropdown shows each model's cost multiplier (e.g. `(0x)` = free/included, `(1x)` = one premium request). See [Requests in GitHub Copilot](https://docs.github.com/en/copilot/concepts/billing/copilot-requests) for details.
216
+
217
+ ---
218
+
219
+ ## License
220
+
221
+ ISC
222
+
223
+ Icon derived from [primer/octicons](https://github.com/primer/octicons) — MIT License © GitHub, Inc.
package/index.js ADDED
@@ -0,0 +1,6 @@
1
+ 'use strict';
2
+
3
+ module.exports = function (RED) {
4
+ require('./nodes/copilot-config/copilot-config')(RED);
5
+ require('./nodes/copilot-prompt/copilot-prompt')(RED);
6
+ };
@@ -0,0 +1,69 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('copilot-config', {
3
+ category: 'config',
4
+ defaults: {
5
+ name: { value: '' },
6
+ authMethod: { value: 'oauth' },
7
+ cliPath: { value: '' },
8
+ cliUrl: { value: '' },
9
+ },
10
+ credentials: {
11
+ githubToken: { type: 'password' },
12
+ },
13
+ label: function () {
14
+ return this.name || 'GitHub Copilot';
15
+ },
16
+ oneditprepare: function () {
17
+ function toggleTokenField() {
18
+ const method = $('#node-config-input-authMethod').val();
19
+ $('#copilot-token-row').toggle(method === 'token');
20
+ }
21
+ $('#node-config-input-authMethod').on('change', toggleTokenField);
22
+ toggleTokenField();
23
+ },
24
+ });
25
+ </script>
26
+
27
+ <script type="text/html" data-template-name="copilot-config">
28
+ <div class="form-row">
29
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
30
+ <input type="text" id="node-config-input-name" placeholder="GitHub Copilot">
31
+ </div>
32
+ <div class="form-row">
33
+ <label for="node-config-input-authMethod"><i class="fa fa-lock"></i> Auth</label>
34
+ <select id="node-config-input-authMethod">
35
+ <option value="oauth">GitHub OAuth (logged-in user)</option>
36
+ <option value="token">Personal Access Token</option>
37
+ </select>
38
+ </div>
39
+ <div class="form-row" id="copilot-token-row">
40
+ <label for="node-config-input-githubToken"><i class="fa fa-key"></i> Token</label>
41
+ <input type="password" id="node-config-input-githubToken" placeholder="ghp_...">
42
+ </div>
43
+ <div class="form-row">
44
+ <label for="node-config-input-cliPath"><i class="fa fa-terminal"></i> CLI Path</label>
45
+ <input type="text" id="node-config-input-cliPath" placeholder="(optional) /path/to/copilot">
46
+ </div>
47
+ <div class="form-row">
48
+ <label for="node-config-input-cliUrl"><i class="fa fa-link"></i> CLI URL</label>
49
+ <input type="text" id="node-config-input-cliUrl" placeholder="(optional) localhost:8080">
50
+ </div>
51
+ </script>
52
+
53
+ <script type="text/html" data-help-name="copilot-config">
54
+ <p>Configuration node for GitHub Copilot.</p>
55
+ <h3>Properties</h3>
56
+ <dl class="message-properties">
57
+ <dt>Auth <span class="property-type">select</span></dt>
58
+ <dd>
59
+ <b>GitHub OAuth</b> — uses credentials stored by the Copilot CLI (<code>gh auth login</code>). No token needed.<br>
60
+ <b>Personal Access Token</b> — fallback for environments where OAuth isn't set up.
61
+ </dd>
62
+ <dt>Token <span class="property-type">password</span></dt>
63
+ <dd>Required when Auth is set to <em>Personal Access Token</em>. Stored as a credential and never sent to the browser.</dd>
64
+ <dt>CLI Path <span class="property-type">string</span></dt>
65
+ <dd>Optional override for the <code>copilot</code> CLI binary. Defaults to the bundled binary in <code>node_modules</code>.</dd>
66
+ <dt>CLI URL <span class="property-type">string</span></dt>
67
+ <dd>Optional URL of an existing Copilot CLI server (e.g. <code>localhost:8080</code>). When set, no CLI process is spawned.</dd>
68
+ </dl>
69
+ </script>
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ // @github/copilot-sdk is ESM-only — must be loaded with dynamic import(), not require().
4
+ // _sdk.load is exposed so tests can inject a mock without needing proxyquire.
5
+ const _sdk = {
6
+ load: () => import('@github/copilot-sdk'),
7
+ };
8
+
9
+ // Resolve the copilot CLI binary bundled with @github/copilot, which is
10
+ // a dependency of @github/copilot-sdk. Using __dirname keeps this reliable
11
+ // regardless of how the module is loaded, and works inside Docker volumes.
12
+ function resolveBundledCliPath() {
13
+ const path = require('path');
14
+ const fs = require('fs');
15
+ // Walk up from this file through each node_modules search path until we find
16
+ // @github/copilot — works in dev (nested) and flat container installs alike.
17
+ let dir = __dirname;
18
+ while (true) {
19
+ const pkgJsonPath = path.join(dir, 'node_modules', '@github', 'copilot', 'package.json');
20
+ if (fs.existsSync(pkgJsonPath)) {
21
+ try {
22
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
23
+ const binEntry = pkgJson.bin && (pkgJson.bin.copilot || Object.values(pkgJson.bin)[0]);
24
+ if (binEntry) {
25
+ const resolved = path.resolve(path.dirname(pkgJsonPath), binEntry);
26
+ if (fs.existsSync(resolved)) return resolved;
27
+ }
28
+ } catch (_) { /* fall through */ }
29
+ }
30
+ const parent = path.dirname(dir);
31
+ if (parent === dir) break; // reached filesystem root
32
+ dir = parent;
33
+ }
34
+ return 'copilot'; // last resort: assume it's on PATH
35
+ }
36
+
37
+ const BUNDLED_CLI_PATH = resolveBundledCliPath();
38
+
39
+ module.exports = function (RED) {
40
+ function CopilotConfigNode(config) {
41
+ RED.nodes.createNode(this, config);
42
+ this.authMethod = config.authMethod || 'oauth'; // 'oauth' | 'token'
43
+ this.cliPath = config.cliPath || undefined;
44
+ this.cliUrl = config.cliUrl || undefined;
45
+ // this.credentials.githubToken populated by Node-RED when authMethod === 'token'
46
+ this._client = null;
47
+ this._startPromise = null;
48
+
49
+ this.on('close', async (done) => {
50
+ if (this._client) {
51
+ try {
52
+ await this._client.stop();
53
+ } catch (err) {
54
+ this.warn('Error stopping CopilotClient: ' + err.message);
55
+ }
56
+ this._client = null;
57
+ this._startPromise = null;
58
+ }
59
+ done();
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Returns a started CopilotClient, creating and starting it on first call.
65
+ * Subsequent calls return the same client (or wait for it to start).
66
+ */
67
+ CopilotConfigNode.prototype.getClient = function () {
68
+ if (this._startPromise) {
69
+ return this._startPromise;
70
+ }
71
+
72
+ const options = {
73
+ autoStart: false,
74
+ autoRestart: true,
75
+ };
76
+
77
+ const token = this.credentials && this.credentials.githubToken;
78
+ if (this.authMethod === 'token' && token) {
79
+ // PAT fallback — explicit token takes priority, disables logged-in user auth
80
+ options.githubToken = token;
81
+ options.useLoggedInUser = false;
82
+ } else {
83
+ // Primary: use GitHub OAuth credentials stored by the Copilot CLI (gh auth)
84
+ options.useLoggedInUser = true;
85
+ }
86
+ if (this.cliPath) {
87
+ options.cliPath = this.cliPath;
88
+ } else if (!this.cliUrl) {
89
+ // Use the bundled CLI binary so the node works without copilot on PATH
90
+ options.cliPath = BUNDLED_CLI_PATH;
91
+ }
92
+ if (this.cliUrl) {
93
+ options.cliUrl = this.cliUrl;
94
+ }
95
+
96
+ // Set _startPromise synchronously to prevent duplicate starts on concurrent calls
97
+ this._startPromise = _sdk.load().then(({ CopilotClient }) => {
98
+ this._client = new CopilotClient(options);
99
+ return this._client.start().then(() => this._client);
100
+ });
101
+ return this._startPromise;
102
+ };
103
+
104
+ RED.nodes.registerType('copilot-config', CopilotConfigNode, {
105
+ credentials: {
106
+ githubToken: { type: 'password' },
107
+ },
108
+ });
109
+
110
+ // Admin endpoint: GET /copilot/models?configId=<id>
111
+ // Used by the prompt node editor to populate the model dropdown dynamically.
112
+ RED.httpAdmin.get('/copilot/models', RED.auth.needsPermission('copilot-config.read'), async (req, res) => {
113
+ const configNode = RED.nodes.getNode(req.query.configId);
114
+ if (!configNode) {
115
+ return res.status(404).json({ error: 'Config node not found' });
116
+ }
117
+ try {
118
+ const client = await configNode.getClient();
119
+ const models = await client.listModels();
120
+ res.json(models.map(m => ({
121
+ id: m.id,
122
+ multiplier: m.billing ? m.billing.multiplier : null,
123
+ })));
124
+ } catch (err) {
125
+ res.status(500).json({ error: err.message });
126
+ }
127
+ });
128
+ };
129
+
130
+ // Exposed for testing — allows injecting a mock SDK without proxyquire
131
+ module.exports._sdk = _sdk;
@@ -0,0 +1,128 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('copilot-prompt', {
3
+ category: 'ai',
4
+ color: '#e8dff5',
5
+ defaults: {
6
+ name: { value: '' },
7
+ copilotConfig: { value: '', type: 'copilot-config', required: true },
8
+ model: { value: '' },
9
+ reasoningEffort: { value: '' },
10
+ timeout: { value: 60000 },
11
+ },
12
+ inputs: 1,
13
+ outputs: 2,
14
+ outputLabels: ['response', 'error'],
15
+ icon: 'copilot.svg',
16
+ label: function () {
17
+ return this.name || 'copilot';
18
+ },
19
+ paletteLabel: 'copilot',
20
+ oneditprepare: function () {
21
+ const nodeId = this.id;
22
+ const savedModel = this.model;
23
+
24
+ function loadModels(configId) {
25
+ if (!configId) return;
26
+ const $select = $('#node-input-model');
27
+ $select.empty().append($('<option>', { value: '', text: '— loading… —', disabled: true, selected: true }));
28
+ $.getJSON(`copilot/models?configId=${encodeURIComponent(configId)}`)
29
+ .done(function (models) {
30
+ $select.empty();
31
+ $select.append($('<option>', { value: '', text: '— select model —' }));
32
+ models.forEach(function (m) {
33
+ const cost = m.multiplier != null ? `(${m.multiplier}x)` : '';
34
+ $select.append($('<option>', { value: m.id, text: `${m.id} ${cost}`.trim() }));
35
+ });
36
+ if (savedModel) $select.val(savedModel);
37
+ if (!$select.val()) $select.prop('selectedIndex', 0);
38
+ })
39
+ .fail(function () {
40
+ $select.empty().append($('<option>', { value: savedModel || '', text: savedModel || '— unable to load —' }));
41
+ });
42
+ }
43
+
44
+ // Load models whenever the config node selection changes
45
+ $('#node-input-copilotConfig').on('change', function () {
46
+ loadModels($(this).val());
47
+ });
48
+
49
+ // Initial load using the already-selected config node
50
+ loadModels($('#node-input-copilotConfig').val());
51
+ },
52
+ });
53
+ </script>
54
+
55
+ <script type="text/html" data-template-name="copilot-prompt">
56
+ <div class="form-row">
57
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
58
+ <input type="text" id="node-input-name" placeholder="copilot">
59
+ </div>
60
+ <div class="form-row">
61
+ <label for="node-input-copilotConfig"><i class="fa fa-cog"></i> Config</label>
62
+ <input type="text" id="node-input-copilotConfig">
63
+ </div>
64
+ <div class="form-row">
65
+ <label for="node-input-model"><i class="fa fa-microchip"></i> Model</label>
66
+ <select id="node-input-model" style="width:70%">
67
+ <option value="">— select a config first —</option>
68
+ </select>
69
+ </div>
70
+ <div class="form-row">
71
+ <label for="node-input-reasoningEffort"><i class="fa fa-brain"></i> Reasoning</label>
72
+ <select id="node-input-reasoningEffort">
73
+ <option value="">— default —</option>
74
+ <option value="low">low</option>
75
+ <option value="medium">medium</option>
76
+ <option value="high">high</option>
77
+ <option value="xhigh">xhigh</option>
78
+ </select>
79
+ </div>
80
+ <div class="form-row">
81
+ <label for="node-input-timeout"><i class="fa fa-clock-o"></i> Timeout (ms)</label>
82
+ <input type="number" id="node-input-timeout" placeholder="60000" min="1000">
83
+ </div>
84
+ </script>
85
+
86
+ <script type="text/html" data-help-name="copilot-prompt">
87
+ <p>Send a prompt (with optional attachments) to GitHub Copilot and receive a response.</p>
88
+
89
+ <h3>Inputs</h3>
90
+ <dl class="message-properties">
91
+ <dt>payload <span class="property-type">string | object</span></dt>
92
+ <dd>
93
+ A plain string is used as the prompt.<br>
94
+ An object may contain <code>{ prompt, attachments }</code>.
95
+ </dd>
96
+ <dt class="optional">attachments <span class="property-type">array</span></dt>
97
+ <dd>
98
+ Array of attachment descriptors. Each may be:<br>
99
+ <code>{ type: "file", path: "/abs/path" }</code><br>
100
+ <code>{ type: "base64", data: "...", name: "img.jpg" }</code><br>
101
+ <code>{ type: "buffer", data: Buffer, name: "file.bin" }</code>
102
+ </dd>
103
+ <dt class="optional">model <span class="property-type">string</span></dt>
104
+ <dd>Override the model for this message (e.g. <code>"gpt-5"</code>).</dd>
105
+ </dl>
106
+
107
+ <h3>Outputs</h3>
108
+ <ol class="node-ports">
109
+ <li>Response
110
+ <dl class="message-properties">
111
+ <dt>payload <span class="property-type">string</span></dt>
112
+ <dd>The assistant's response text.</dd>
113
+ <dt>sessionId <span class="property-type">string</span></dt>
114
+ <dd>The Copilot session ID.</dd>
115
+ <dt>events <span class="property-type">array</span></dt>
116
+ <dd>All session events emitted during the request.</dd>
117
+ </dl>
118
+ </li>
119
+ <li>Error
120
+ <dl class="message-properties">
121
+ <dt>payload <span class="property-type">string</span></dt>
122
+ <dd>Error message.</dd>
123
+ <dt>error <span class="property-type">Error</span></dt>
124
+ <dd>The error object.</dd>
125
+ </dl>
126
+ </li>
127
+ </ol>
128
+ </script>
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const DEFAULT_MODEL = 'claude-haiku-4.5';
8
+ const DEFAULT_TIMEOUT = 60000;
9
+
10
+ /**
11
+ * Normalises an attachment descriptor into an SDK-compatible `{ type, path }` object.
12
+ * Binary and base64 payloads are written to temp files; the caller is responsible
13
+ * for deleting them once the session send has completed.
14
+ *
15
+ * Supported input shapes:
16
+ * { type: 'file', path: '/abs/path' } → passed through
17
+ * { path: '/abs/path' } → shorthand for type=file
18
+ * { type: 'base64', data: '<b64 string>', name: '…' } → decoded to temp file
19
+ * { type: 'buffer', data: Buffer, name: '…' } → written to temp file
20
+ *
21
+ * Returns { sdkAttachment, tempFile } where tempFile is the path to clean up (or null).
22
+ */
23
+ function normaliseAttachment(attachment) {
24
+ if (attachment.type === 'file' || (!attachment.type && attachment.path)) {
25
+ return { sdkAttachment: { type: 'file', path: attachment.path }, tempFile: null };
26
+ }
27
+
28
+ const tmpDir = os.tmpdir();
29
+ const name = attachment.name || 'attachment';
30
+ const tempFile = path.join(tmpDir, `nr-copilot-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}`);
31
+
32
+ if (attachment.type === 'base64') {
33
+ fs.writeFileSync(tempFile, Buffer.from(attachment.data, 'base64'));
34
+ } else if (attachment.type === 'buffer') {
35
+ fs.writeFileSync(tempFile, attachment.data);
36
+ } else {
37
+ throw new Error(`Unsupported attachment type: "${attachment.type}"`);
38
+ }
39
+
40
+ return { sdkAttachment: { type: 'file', path: tempFile, displayName: name }, tempFile };
41
+ }
42
+
43
+ /**
44
+ * Cleans up a list of temp file paths, swallowing any errors.
45
+ */
46
+ function cleanupTempFiles(tempFiles) {
47
+ for (const f of tempFiles) {
48
+ try { fs.unlinkSync(f); } catch (_) { /* ignore */ }
49
+ }
50
+ }
51
+
52
+ module.exports = function (RED) {
53
+ function CopilotPromptNode(config) {
54
+ RED.nodes.createNode(this, config);
55
+ this.configNodeId = config.copilotConfig;
56
+ this.model = config.model || DEFAULT_MODEL;
57
+ this.reasoningEffort = config.reasoningEffort || undefined;
58
+ this.timeout = parseInt(config.timeout, 10) || DEFAULT_TIMEOUT;
59
+
60
+ const node = this;
61
+
62
+ this.on('input', async function (msg, send, done) {
63
+ // Support both old (1-arg send) and new (2-arg) Node-RED APIs
64
+ send = send || function () { node.send.apply(node, arguments); };
65
+ done = done || function (err) { if (err) { node.error(err, msg); } };
66
+
67
+ node.status({ fill: 'blue', shape: 'dot', text: 'sending…' });
68
+
69
+ // --- Resolve config node ---
70
+ const configNode = RED.nodes.getNode(node.configNodeId);
71
+ if (!configNode) {
72
+ node.status({ fill: 'red', shape: 'ring', text: 'no config' });
73
+ const err = new Error('copilot-prompt: no copilot-config node configured');
74
+ send([null, { payload: err.message, error: err, _msgid: msg._msgid }]);
75
+ return done();
76
+ }
77
+
78
+ // --- Parse prompt + attachments from msg ---
79
+ let prompt;
80
+ let rawAttachments = [];
81
+
82
+ if (typeof msg.payload === 'string') {
83
+ prompt = msg.payload;
84
+ } else if (msg.payload && typeof msg.payload === 'object') {
85
+ prompt = msg.payload.prompt || '';
86
+ rawAttachments = msg.payload.attachments || [];
87
+ } else {
88
+ prompt = String(msg.payload || '');
89
+ }
90
+
91
+ // msg.attachments can supplement / override
92
+ if (Array.isArray(msg.attachments)) {
93
+ rawAttachments = rawAttachments.concat(msg.attachments);
94
+ }
95
+
96
+ // Model can be overridden per-message
97
+ const model = msg.model || node.model;
98
+
99
+ // --- Normalise attachments ---
100
+ const tempFiles = [];
101
+ let sdkAttachments;
102
+ try {
103
+ sdkAttachments = rawAttachments.map((a) => {
104
+ const { sdkAttachment, tempFile } = normaliseAttachment(a);
105
+ if (tempFile) tempFiles.push(tempFile);
106
+ return sdkAttachment;
107
+ });
108
+ } catch (err) {
109
+ cleanupTempFiles(tempFiles);
110
+ node.status({ fill: 'red', shape: 'ring', text: 'attachment error' });
111
+ send([null, { payload: err.message, error: err, _msgid: msg._msgid }]);
112
+ return done();
113
+ }
114
+
115
+ // --- Build session config ---
116
+ const { approveAll } = await import('@github/copilot-sdk');
117
+ const sessionConfig = { model, onPermissionRequest: approveAll };
118
+ if (node.reasoningEffort) {
119
+ sessionConfig.reasoningEffort = node.reasoningEffort;
120
+ }
121
+
122
+ // --- Send to Copilot ---
123
+ let client;
124
+ let session;
125
+ try {
126
+ client = await configNode.getClient();
127
+ session = await client.createSession(sessionConfig);
128
+
129
+ const messageOptions = { prompt };
130
+ if (sdkAttachments.length > 0) {
131
+ messageOptions.attachments = sdkAttachments;
132
+ }
133
+
134
+ const events = [];
135
+ session.on((event) => events.push(event));
136
+
137
+ const response = await session.sendAndWait(messageOptions, node.timeout);
138
+
139
+ cleanupTempFiles(tempFiles);
140
+ await session.destroy();
141
+
142
+ const responseText = response ? response.data.content : '';
143
+ node.status({ fill: 'green', shape: 'dot', text: 'done' });
144
+
145
+ msg.payload = responseText;
146
+ msg.sessionId = session.sessionId;
147
+ msg.events = events;
148
+ send([msg, null]);
149
+ done();
150
+ } catch (err) {
151
+ cleanupTempFiles(tempFiles);
152
+ if (session) {
153
+ try { await session.destroy(); } catch (_) { /* ignore */ }
154
+ }
155
+ node.status({ fill: 'red', shape: 'ring', text: 'error' });
156
+ send([null, { payload: err.message, error: err, _msgid: msg._msgid }]);
157
+ done();
158
+ }
159
+ });
160
+ }
161
+
162
+ RED.nodes.registerType('copilot-prompt', CopilotPromptNode);
163
+ };
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
2
+ <!-- Copilot icon from github/primer/octicons — MIT License -->
3
+ <defs>
4
+ <linearGradient id="cg" x1="0%" y1="0%" x2="100%" y2="100%">
5
+ <stop offset="0%" style="stop-color:#a371f7"/>
6
+ <stop offset="100%" style="stop-color:#388bfd"/>
7
+ </linearGradient>
8
+ </defs>
9
+ <path fill="url(#cg)" d="M23.922 16.992c-.861 1.495-5.859 5.023-11.922 5.023-6.063 0-11.061-3.528-11.922-5.023A.641.641 0 0 1 0 16.736v-2.869a.841.841 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.195 10.195 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952 1.399-1.136 3.392-2.093 6.122-2.093 2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.832.832 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256ZM12.172 11h-.344a4.323 4.323 0 0 1-.355.508C10.703 12.455 9.555 13 7.965 13c-1.725 0-2.989-.359-3.782-1.259a2.005 2.005 0 0 1-.085-.104L4 11.741v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.323 4.323 0 0 1-.355-.508h-.016.016Zm.641-2.935c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/>
10
+ <path fill="url(#cg)" d="M14.5 14.25a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1Zm-5 0a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1Z"/>
11
+ </svg>
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "node-red-contrib-copilot",
3
+ "version": "0.0.1",
4
+ "description": "Node-RED nodes for GitHub Copilot via @github/copilot-sdk",
5
+ "license": "ISC",
6
+ "author": "George Talusan",
7
+ "type": "commonjs",
8
+ "main": "index.js",
9
+ "keywords": [
10
+ "node-red",
11
+ "copilot",
12
+ "github",
13
+ "ai"
14
+ ],
15
+ "scripts": {
16
+ "test": "mocha 'test/unit/**/*_spec.js' --timeout 10000",
17
+ "test:integration": "mocha 'test/integration/**/*_spec.js' --timeout 120000"
18
+ },
19
+ "engines": {
20
+ "node": ">=24.0.0",
21
+ "npm": ">=10.0.0"
22
+ },
23
+ "node-red": {
24
+ "version": ">=3.0.0",
25
+ "nodes": {
26
+ "copilot-config": "nodes/copilot-config/copilot-config.js",
27
+ "copilot-prompt": "nodes/copilot-prompt/copilot-prompt.js"
28
+ }
29
+ },
30
+ "dependencies": {
31
+ "@github/copilot-sdk": "0.1.30"
32
+ },
33
+ "devDependencies": {
34
+ "mocha": "^10.8.2",
35
+ "node-red": "^4.1.7",
36
+ "node-red-node-test-helper": "^0.3.6",
37
+ "proxyquire": "^2.1.3",
38
+ "sinon": "^17.0.1"
39
+ }
40
+ }