node-red-contrib-copilot 0.0.1 → 0.0.2

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.
@@ -45,6 +45,8 @@ module.exports = function (RED) {
45
45
  // this.credentials.githubToken populated by Node-RED when authMethod === 'token'
46
46
  this._client = null;
47
47
  this._startPromise = null;
48
+ this._modelsCache = null;
49
+ this._modelsCacheAt = 0;
48
50
 
49
51
  this.on('close', async (done) => {
50
52
  if (this._client) {
@@ -55,6 +57,8 @@ module.exports = function (RED) {
55
57
  }
56
58
  this._client = null;
57
59
  this._startPromise = null;
60
+ this._modelsCache = null;
61
+ this._modelsCacheAt = 0;
58
62
  }
59
63
  done();
60
64
  });
@@ -107,20 +111,30 @@ module.exports = function (RED) {
107
111
  },
108
112
  });
109
113
 
114
+ const MODELS_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
115
+
110
116
  // Admin endpoint: GET /copilot/models?configId=<id>
111
117
  // Used by the prompt node editor to populate the model dropdown dynamically.
118
+ // Results are cached on the config node for MODELS_CACHE_TTL_MS to avoid
119
+ // hitting the SDK on every editor open.
112
120
  RED.httpAdmin.get('/copilot/models', RED.auth.needsPermission('copilot-config.read'), async (req, res) => {
113
121
  const configNode = RED.nodes.getNode(req.query.configId);
114
122
  if (!configNode) {
115
123
  return res.status(404).json({ error: 'Config node not found' });
116
124
  }
125
+ const now = Date.now();
126
+ if (configNode._modelsCache && (now - configNode._modelsCacheAt) < MODELS_CACHE_TTL_MS) {
127
+ return res.json(configNode._modelsCache);
128
+ }
117
129
  try {
118
130
  const client = await configNode.getClient();
119
131
  const models = await client.listModels();
120
- res.json(models.map(m => ({
132
+ configNode._modelsCache = models.map(m => ({
121
133
  id: m.id,
122
134
  multiplier: m.billing ? m.billing.multiplier : null,
123
- })));
135
+ }));
136
+ configNode._modelsCacheAt = now;
137
+ res.json(configNode._modelsCache);
124
138
  } catch (err) {
125
139
  res.status(500).json({ error: err.message });
126
140
  }
@@ -8,6 +8,8 @@
8
8
  model: { value: '' },
9
9
  reasoningEffort: { value: '' },
10
10
  timeout: { value: 60000 },
11
+ conversationId: { value: '' },
12
+ sessionTimeout: { value: 30 },
11
13
  },
12
14
  inputs: 1,
13
15
  outputs: 2,
@@ -81,10 +83,20 @@
81
83
  <label for="node-input-timeout"><i class="fa fa-clock-o"></i> Timeout (ms)</label>
82
84
  <input type="number" id="node-input-timeout" placeholder="60000" min="1000">
83
85
  </div>
86
+ <div class="form-row">
87
+ <label for="node-input-conversationId"><i class="fa fa-comments"></i> Conv. ID</label>
88
+ <input type="text" id="node-input-conversationId" placeholder="(leave blank to use node ID)">
89
+ </div>
90
+ <div class="form-row">
91
+ <label for="node-input-sessionTimeout"><i class="fa fa-hourglass-half"></i> Session idle (min)</label>
92
+ <input type="number" id="node-input-sessionTimeout" placeholder="30" min="0">
93
+ </div>
84
94
  </script>
85
95
 
86
96
  <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>
97
+ <p>Send a prompt (with optional attachments) to GitHub Copilot and receive a response.
98
+ Conversations are stateful by default — context is preserved across messages in the same
99
+ conversation thread.</p>
88
100
 
89
101
  <h3>Inputs</h3>
90
102
  <dl class="message-properties">
@@ -102,6 +114,20 @@
102
114
  </dd>
103
115
  <dt class="optional">model <span class="property-type">string</span></dt>
104
116
  <dd>Override the model for this message (e.g. <code>"gpt-5"</code>).</dd>
117
+ <dt class="optional">conversationId <span class="property-type">string</span></dt>
118
+ <dd>
119
+ Identifies the conversation thread to use. If omitted, defaults to the node's
120
+ configured <em>Conv. ID</em>, or the node's own ID if that is also blank.
121
+ Set a per-user value (e.g. a user ID or session token) to maintain separate
122
+ conversation threads through a single node.
123
+ </dd>
124
+ <dt class="optional">reset <span class="property-type">boolean</span></dt>
125
+ <dd>
126
+ When <code>true</code>, destroys the active session for the resolved
127
+ <code>conversationId</code> and swallows the message — nothing is sent to
128
+ Copilot and no output is produced. Use this to start a conversation over
129
+ from scratch. The next message will open a fresh session.
130
+ </dd>
105
131
  </dl>
106
132
 
107
133
  <h3>Outputs</h3>
@@ -110,8 +136,8 @@
110
136
  <dl class="message-properties">
111
137
  <dt>payload <span class="property-type">string</span></dt>
112
138
  <dd>The assistant's response text.</dd>
113
- <dt>sessionId <span class="property-type">string</span></dt>
114
- <dd>The Copilot session ID.</dd>
139
+ <dt>conversationId <span class="property-type">string</span></dt>
140
+ <dd>The conversation thread key used for this message.</dd>
115
141
  <dt>events <span class="property-type">array</span></dt>
116
142
  <dd>All session events emitted during the request.</dd>
117
143
  </dl>
@@ -125,4 +151,17 @@
125
151
  </dl>
126
152
  </li>
127
153
  </ol>
154
+
155
+ <h3>Session lifecycle</h3>
156
+ <p>
157
+ Sessions are created on first use and kept alive between messages so that
158
+ conversation context is preserved. A session is closed when:
159
+ </p>
160
+ <ul>
161
+ <li>A message with <code>msg.reset = true</code> is received.</li>
162
+ <li>The session has been idle longer than the configured <em>Session idle</em>
163
+ timeout (default 30 min; set to <code>0</code> to disable).</li>
164
+ <li>An error occurs — the session is discarded so the next message starts fresh.</li>
165
+ <li>The node is redeployed or Node-RED shuts down.</li>
166
+ </ul>
128
167
  </script>
@@ -4,8 +4,30 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
 
7
- const DEFAULT_MODEL = 'claude-haiku-4.5';
7
+ const DEFAULT_MODEL = 'gpt-4.1';
8
8
  const DEFAULT_TIMEOUT = 60000;
9
+ const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
10
+
11
+ /**
12
+ * Sniffs the file extension from a base64 string or Buffer by checking magic bytes.
13
+ * Returns e.g. '.jpg', '.png', '.gif', '.webp', '.pdf', or '' if unknown.
14
+ */
15
+ function guessExtension(attachment) {
16
+ let buf;
17
+ if (attachment.type === 'base64' && attachment.data) {
18
+ buf = Buffer.from(attachment.data.slice(0, 16), 'base64');
19
+ } else if (attachment.type === 'buffer' && Buffer.isBuffer(attachment.data)) {
20
+ buf = attachment.data.slice(0, 16);
21
+ } else {
22
+ return '';
23
+ }
24
+ if (buf[0] === 0xFF && buf[1] === 0xD8) return '.jpg';
25
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return '.png';
26
+ if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return '.gif';
27
+ if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46) return '.webp';
28
+ if (buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46) return '.pdf';
29
+ return '';
30
+ }
9
31
 
10
32
  /**
11
33
  * Normalises an attachment descriptor into an SDK-compatible `{ type, path }` object.
@@ -26,7 +48,9 @@ function normaliseAttachment(attachment) {
26
48
  }
27
49
 
28
50
  const tmpDir = os.tmpdir();
29
- const name = attachment.name || 'attachment';
51
+ const rawName = attachment.name || 'attachment';
52
+ // If the name has no extension, sniff the format from magic bytes / base64 header
53
+ const name = path.extname(rawName) ? rawName : rawName + guessExtension(attachment);
30
54
  const tempFile = path.join(tmpDir, `nr-copilot-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}`);
31
55
 
32
56
  if (attachment.type === 'base64') {
@@ -56,14 +80,53 @@ module.exports = function (RED) {
56
80
  this.model = config.model || DEFAULT_MODEL;
57
81
  this.reasoningEffort = config.reasoningEffort || undefined;
58
82
  this.timeout = parseInt(config.timeout, 10) || DEFAULT_TIMEOUT;
83
+ this.conversationId = config.conversationId || ''; // '' means use node.id
84
+ const sessionTimeoutMin = parseFloat(config.sessionTimeout);
85
+ this.sessionTimeoutMs = isFinite(sessionTimeoutMin) && sessionTimeoutMin > 0
86
+ ? sessionTimeoutMin * 60 * 1000
87
+ : (sessionTimeoutMin === 0 ? 0 : DEFAULT_SESSION_TIMEOUT_MS);
88
+
89
+ // Map of conversationId → { session, timer|null }
90
+ this._sessions = new Map();
59
91
 
60
92
  const node = this;
61
93
 
94
+ // Destroy a single session entry and clear its idle timer.
95
+ async function destroySession(key) {
96
+ const entry = node._sessions.get(key);
97
+ if (!entry) return;
98
+ node._sessions.delete(key);
99
+ clearTimeout(entry.timer);
100
+ try { await entry.session.destroy(); } catch (_) { /* ignore */ }
101
+ }
102
+
103
+ // Arm (or re-arm) the idle timer for a session.
104
+ function armTimer(key) {
105
+ if (node.sessionTimeoutMs === 0) return null;
106
+ return setTimeout(() => destroySession(key), node.sessionTimeoutMs);
107
+ }
108
+
109
+ this.on('close', async (done) => {
110
+ const keys = [...node._sessions.keys()];
111
+ await Promise.all(keys.map(destroySession));
112
+ done();
113
+ });
114
+
62
115
  this.on('input', async function (msg, send, done) {
63
116
  // Support both old (1-arg send) and new (2-arg) Node-RED APIs
64
117
  send = send || function () { node.send.apply(node, arguments); };
65
118
  done = done || function (err) { if (err) { node.error(err, msg); } };
66
119
 
120
+ // Resolve the conversation key: msg > node config > node.id
121
+ const convKey = msg.conversationId || node.conversationId || node.id;
122
+
123
+ // msg.reset = true: destroy the session and swallow the message
124
+ if (msg.reset) {
125
+ await destroySession(convKey);
126
+ node.status({ fill: 'grey', shape: 'ring', text: 'reset' });
127
+ return done();
128
+ }
129
+
67
130
  node.status({ fill: 'blue', shape: 'dot', text: 'sending…' });
68
131
 
69
132
  // --- Resolve config node ---
@@ -83,14 +146,16 @@ module.exports = function (RED) {
83
146
  prompt = msg.payload;
84
147
  } else if (msg.payload && typeof msg.payload === 'object') {
85
148
  prompt = msg.payload.prompt || '';
86
- rawAttachments = msg.payload.attachments || [];
149
+ const pa = msg.payload.attachments;
150
+ rawAttachments = Array.isArray(pa) ? pa : (pa ? [pa] : []);
87
151
  } else {
88
152
  prompt = String(msg.payload || '');
89
153
  }
90
154
 
91
155
  // msg.attachments can supplement / override
92
- if (Array.isArray(msg.attachments)) {
93
- rawAttachments = rawAttachments.concat(msg.attachments);
156
+ const ma = msg.attachments;
157
+ if (ma) {
158
+ rawAttachments = rawAttachments.concat(Array.isArray(ma) ? ma : [ma]);
94
159
  }
95
160
 
96
161
  // Model can be overridden per-message
@@ -112,19 +177,26 @@ module.exports = function (RED) {
112
177
  return done();
113
178
  }
114
179
 
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 ---
180
+ // --- Get or create a session for this conversation ---
123
181
  let client;
124
182
  let session;
125
183
  try {
126
184
  client = await configNode.getClient();
127
- session = await client.createSession(sessionConfig);
185
+
186
+ let entry = node._sessions.get(convKey);
187
+ if (entry) {
188
+ // Re-arm the idle timer and reuse the live session
189
+ clearTimeout(entry.timer);
190
+ entry.timer = armTimer(convKey);
191
+ session = entry.session;
192
+ } else {
193
+ const { approveAll } = await import('@github/copilot-sdk');
194
+ const sessionConfig = { model, onPermissionRequest: approveAll };
195
+ if (node.reasoningEffort) sessionConfig.reasoningEffort = node.reasoningEffort;
196
+ session = await client.createSession(sessionConfig);
197
+ const timer = armTimer(convKey);
198
+ node._sessions.set(convKey, { session, timer });
199
+ }
128
200
 
129
201
  const messageOptions = { prompt };
130
202
  if (sdkAttachments.length > 0) {
@@ -137,21 +209,19 @@ module.exports = function (RED) {
137
209
  const response = await session.sendAndWait(messageOptions, node.timeout);
138
210
 
139
211
  cleanupTempFiles(tempFiles);
140
- await session.destroy();
141
212
 
142
213
  const responseText = response ? response.data.content : '';
143
214
  node.status({ fill: 'green', shape: 'dot', text: 'done' });
144
215
 
145
216
  msg.payload = responseText;
146
- msg.sessionId = session.sessionId;
217
+ msg.conversationId = convKey;
147
218
  msg.events = events;
148
219
  send([msg, null]);
149
220
  done();
150
221
  } catch (err) {
151
222
  cleanupTempFiles(tempFiles);
152
- if (session) {
153
- try { await session.destroy(); } catch (_) { /* ignore */ }
154
- }
223
+ // On error, discard the session so the next message starts fresh
224
+ await destroySession(convKey);
155
225
  node.status({ fill: 'red', shape: 'ring', text: 'error' });
156
226
  send([null, { payload: err.message, error: err, _msgid: msg._msgid }]);
157
227
  done();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-copilot",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Node-RED nodes for GitHub Copilot via @github/copilot-sdk",
5
5
  "license": "ISC",
6
6
  "author": "George Talusan",