cipher-security 5.2.0 → 5.3.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/bin/cipher.js +29 -7
- package/lib/bot/bot.js +147 -39
- package/lib/gateway/commands.js +117 -10
- package/package.json +2 -1
package/bin/cipher.js
CHANGED
|
@@ -466,13 +466,29 @@ if (mode === 'native') {
|
|
|
466
466
|
mcp.startStdio();
|
|
467
467
|
// ── Bot command: Signal bot ──────────────────────────────────────────
|
|
468
468
|
} else if (command === 'bot') {
|
|
469
|
-
const { banner, header, info, warn, divider } = await import('../lib/brand.js');
|
|
470
|
-
const
|
|
471
|
-
const phone = commandArgs.find((a, i) => commandArgs[i - 1] === '--phone') || process.env.SIGNAL_PHONE_NUMBER || '';
|
|
469
|
+
const { banner, header, info, warn, success, divider } = await import('../lib/brand.js');
|
|
470
|
+
const { loadConfig, configExists } = await import('../lib/config.js');
|
|
472
471
|
|
|
473
472
|
banner(pkg.version);
|
|
474
473
|
header('Signal Bot');
|
|
475
474
|
|
|
475
|
+
// Load signal config
|
|
476
|
+
let signalCfg = {};
|
|
477
|
+
if (configExists()) {
|
|
478
|
+
try {
|
|
479
|
+
const cfg = loadConfig();
|
|
480
|
+
signalCfg = cfg.signal || {};
|
|
481
|
+
} catch { /* ignore */ }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const svc = commandArgs.find((a, i) => commandArgs[i - 1] === '--service')
|
|
485
|
+
|| process.env.SIGNAL_SERVICE || signalCfg.signal_service || '';
|
|
486
|
+
const phone = commandArgs.find((a, i) => commandArgs[i - 1] === '--phone')
|
|
487
|
+
|| process.env.SIGNAL_PHONE_NUMBER || signalCfg.phone_number || '';
|
|
488
|
+
const whitelist = process.env.SIGNAL_WHITELIST
|
|
489
|
+
? process.env.SIGNAL_WHITELIST.split(',').map(n => n.trim())
|
|
490
|
+
: signalCfg.whitelist || [];
|
|
491
|
+
|
|
476
492
|
if (!svc || !phone) {
|
|
477
493
|
divider();
|
|
478
494
|
warn('Signal is not configured.');
|
|
@@ -481,13 +497,19 @@ if (mode === 'native') {
|
|
|
481
497
|
process.exit(1);
|
|
482
498
|
}
|
|
483
499
|
|
|
484
|
-
info(`Service: ${svc}`);
|
|
485
|
-
info(`Phone: ${phone}`);
|
|
486
500
|
divider();
|
|
487
|
-
|
|
501
|
+
success(`Service: ${svc}`);
|
|
502
|
+
success(`Phone: ${phone}`);
|
|
503
|
+
if (whitelist.length) {
|
|
504
|
+
success(`Whitelist: ${whitelist.join(', ')}`);
|
|
505
|
+
} else {
|
|
506
|
+
warn('Whitelist: none (all senders allowed)');
|
|
507
|
+
}
|
|
508
|
+
divider();
|
|
509
|
+
info('Starting bot... (Ctrl+C to stop)');
|
|
488
510
|
|
|
489
511
|
const { runBot } = await import('../lib/bot/bot.js');
|
|
490
|
-
runBot({ signalService: svc, phoneNumber: phone });
|
|
512
|
+
await runBot({ signalService: svc, phoneNumber: phone, whitelist, sessionTimeout: signalCfg.session_timeout || 3600 });
|
|
491
513
|
// ── Query command: streaming via Gateway or non-streaming via handler ──
|
|
492
514
|
} else if (command === 'query') {
|
|
493
515
|
const queryText = commandArgs.filter(a => !a.startsWith('-')).join(' ');
|
package/lib/bot/bot.js
CHANGED
|
@@ -2,20 +2,18 @@
|
|
|
2
2
|
// Licensed under AGPL-3.0 — see LICENSE file for details.
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* CIPHER Signal Bot —
|
|
5
|
+
* CIPHER Signal Bot — receives messages via signal-cli-rest-api WebSocket,
|
|
6
|
+
* dispatches through the gateway, and replies.
|
|
6
7
|
*/
|
|
7
8
|
|
|
9
|
+
import { WebSocket } from 'ws';
|
|
10
|
+
|
|
8
11
|
// ---------------------------------------------------------------------------
|
|
9
12
|
// Prefix mapping
|
|
10
13
|
// ---------------------------------------------------------------------------
|
|
11
14
|
|
|
12
15
|
const PREFIX_RE = /^(RED|BLUE|PURPLE|PRIVACY|RECON|INCIDENT|ARCHITECT):\s*/i;
|
|
13
16
|
|
|
14
|
-
/**
|
|
15
|
-
* Map 'MODE: query' short-prefix format to '[MODE: MODE] query'.
|
|
16
|
-
* @param {string} text
|
|
17
|
-
* @returns {string}
|
|
18
|
-
*/
|
|
19
17
|
export function mapPrefix(text) {
|
|
20
18
|
const m = text.match(PREFIX_RE);
|
|
21
19
|
if (m) {
|
|
@@ -31,20 +29,15 @@ export function mapPrefix(text) {
|
|
|
31
29
|
// ---------------------------------------------------------------------------
|
|
32
30
|
|
|
33
31
|
const MD_PATTERNS = [
|
|
34
|
-
[/```[^\n]*\n([\s\S]*?)```/g, '$1'],
|
|
35
|
-
[/^#{1,6}\s+/gm, ''],
|
|
36
|
-
[/\*{2}([^*\n]+)\*{2}/g, '$1'],
|
|
37
|
-
[/\*([^*\n]+)\*/g, '$1'],
|
|
38
|
-
[/`([^`\n]+)`/g, '$1'],
|
|
39
|
-
[/\[([^\]]+)\]\([^)]+\)/g, '$1'],
|
|
40
|
-
[/^(\s*)[*-]\s+/gm, '$1• '],
|
|
32
|
+
[/```[^\n]*\n([\s\S]*?)```/g, '$1'],
|
|
33
|
+
[/^#{1,6}\s+/gm, ''],
|
|
34
|
+
[/\*{2}([^*\n]+)\*{2}/g, '$1'],
|
|
35
|
+
[/\*([^*\n]+)\*/g, '$1'],
|
|
36
|
+
[/`([^`\n]+)`/g, '$1'],
|
|
37
|
+
[/\[([^\]]+)\]\([^)]+\)/g, '$1'],
|
|
38
|
+
[/^(\s*)[*-]\s+/gm, '$1• '],
|
|
41
39
|
];
|
|
42
40
|
|
|
43
|
-
/**
|
|
44
|
-
* Strip markdown formatting for plaintext Signal delivery.
|
|
45
|
-
* @param {string} text
|
|
46
|
-
* @returns {string}
|
|
47
|
-
*/
|
|
48
41
|
export function stripMarkdown(text) {
|
|
49
42
|
for (const [pattern, replacement] of MD_PATTERNS) {
|
|
50
43
|
text = text.replace(pattern, replacement);
|
|
@@ -56,15 +49,7 @@ export function stripMarkdown(text) {
|
|
|
56
49
|
// Session manager
|
|
57
50
|
// ---------------------------------------------------------------------------
|
|
58
51
|
|
|
59
|
-
/**
|
|
60
|
-
* In-memory conversation history manager keyed by sender.
|
|
61
|
-
*/
|
|
62
52
|
export class SessionManager {
|
|
63
|
-
/**
|
|
64
|
-
* @param {object} [opts]
|
|
65
|
-
* @param {number} [opts.timeoutSeconds=3600]
|
|
66
|
-
* @param {number} [opts.maxPairs=20]
|
|
67
|
-
*/
|
|
68
53
|
constructor({ timeoutSeconds = 3600, maxPairs = 20 } = {}) {
|
|
69
54
|
this._sessions = new Map();
|
|
70
55
|
this._timeout = timeoutSeconds * 1000;
|
|
@@ -92,9 +77,7 @@ export class SessionManager {
|
|
|
92
77
|
}
|
|
93
78
|
}
|
|
94
79
|
|
|
95
|
-
reset(sender) {
|
|
96
|
-
this._sessions.delete(sender);
|
|
97
|
-
}
|
|
80
|
+
reset(sender) { this._sessions.delete(sender); }
|
|
98
81
|
|
|
99
82
|
cleanup() {
|
|
100
83
|
const now = Date.now();
|
|
@@ -109,22 +92,147 @@ export class SessionManager {
|
|
|
109
92
|
}
|
|
110
93
|
|
|
111
94
|
// ---------------------------------------------------------------------------
|
|
112
|
-
//
|
|
95
|
+
// Signal REST API client
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
async function sendMessage(signalService, phoneNumber, recipient, message) {
|
|
99
|
+
const resp = await fetch(`${signalService}/v2/send`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
message,
|
|
104
|
+
number: phoneNumber,
|
|
105
|
+
recipients: [recipient],
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
if (!resp.ok) {
|
|
109
|
+
throw new Error(`Send failed: ${resp.status} ${await resp.text()}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function sendReaction(signalService, phoneNumber, recipient, emoji, targetTimestamp) {
|
|
114
|
+
try {
|
|
115
|
+
await fetch(`${signalService}/v1/reactions/${phoneNumber}`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
reaction: emoji,
|
|
120
|
+
recipient,
|
|
121
|
+
timestamp: targetTimestamp,
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
} catch { /* best-effort */ }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Bot runner
|
|
113
129
|
// ---------------------------------------------------------------------------
|
|
114
130
|
|
|
115
131
|
/**
|
|
116
132
|
* Start the CIPHER Signal bot.
|
|
117
133
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* 2. Start signal-cli subprocess for receiving messages
|
|
121
|
-
* 3. Register CipherCommand handler
|
|
122
|
-
* 4. Run with exponential-backoff watchdog
|
|
134
|
+
* Connects to signal-cli-rest-api WebSocket, listens for messages,
|
|
135
|
+
* dispatches through the gateway, and replies.
|
|
123
136
|
*
|
|
124
137
|
* @param {object} config
|
|
138
|
+
* @param {string} config.signalService - signal-cli-rest-api URL
|
|
139
|
+
* @param {string} config.phoneNumber - bot's phone number
|
|
140
|
+
* @param {string[]} [config.whitelist] - allowed sender numbers
|
|
141
|
+
* @param {number} [config.sessionTimeout] - session timeout in seconds
|
|
125
142
|
*/
|
|
126
|
-
export function runBot(config) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
143
|
+
export async function runBot(config) {
|
|
144
|
+
const { signalService, phoneNumber, whitelist = [], sessionTimeout = 3600 } = config;
|
|
145
|
+
const sessions = new SessionManager({ timeoutSeconds: sessionTimeout });
|
|
146
|
+
const whitelistSet = new Set(whitelist);
|
|
147
|
+
|
|
148
|
+
const wsUrl = `${signalService.replace('http', 'ws')}/v1/receive/${phoneNumber}`;
|
|
149
|
+
|
|
150
|
+
let reconnectDelay = 1000;
|
|
151
|
+
const maxDelay = 60000;
|
|
152
|
+
|
|
153
|
+
function connect() {
|
|
154
|
+
const ws = new WebSocket(wsUrl);
|
|
155
|
+
|
|
156
|
+
ws.on('open', () => {
|
|
157
|
+
process.stderr.write(` ● Connected to ${wsUrl}\n`);
|
|
158
|
+
reconnectDelay = 1000;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
ws.on('message', async (data) => {
|
|
162
|
+
try {
|
|
163
|
+
const envelope = JSON.parse(data.toString());
|
|
164
|
+
await handleEnvelope(envelope, config, sessions, whitelistSet);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
process.stderr.write(` ✖ Message error: ${err.message}\n`);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
ws.on('close', () => {
|
|
171
|
+
process.stderr.write(` ⚠ WebSocket closed. Reconnecting in ${reconnectDelay / 1000}s...\n`);
|
|
172
|
+
setTimeout(connect, reconnectDelay);
|
|
173
|
+
reconnectDelay = Math.min(reconnectDelay * 2, maxDelay);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
ws.on('error', (err) => {
|
|
177
|
+
process.stderr.write(` ✖ WebSocket error: ${err.message}\n`);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
connect();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function handleEnvelope(envelope, config, sessions, whitelistSet) {
|
|
185
|
+
const msg = envelope.envelope?.dataMessage;
|
|
186
|
+
if (!msg || !msg.message) return;
|
|
187
|
+
|
|
188
|
+
const sender = envelope.envelope?.source;
|
|
189
|
+
if (!sender) return;
|
|
190
|
+
|
|
191
|
+
const text = msg.message.trim();
|
|
192
|
+
if (!text) return;
|
|
193
|
+
|
|
194
|
+
// Whitelist check
|
|
195
|
+
if (whitelistSet.size > 0 && !whitelistSet.has(sender)) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const timestamp = msg.timestamp;
|
|
200
|
+
|
|
201
|
+
// /reset command
|
|
202
|
+
if (text === '/reset') {
|
|
203
|
+
sessions.reset(sender);
|
|
204
|
+
await sendMessage(config.signalService, config.phoneNumber, sender, 'Session cleared.');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// React with brain emoji to acknowledge
|
|
209
|
+
await sendReaction(config.signalService, config.phoneNumber, sender, '🧠', timestamp);
|
|
210
|
+
|
|
211
|
+
// Map prefix and get history
|
|
212
|
+
const mapped = mapPrefix(text);
|
|
213
|
+
const history = sessions.get(sender);
|
|
214
|
+
|
|
215
|
+
// Dispatch through gateway
|
|
216
|
+
let response;
|
|
217
|
+
try {
|
|
218
|
+
const { Gateway } = await import('../gateway/gateway.js');
|
|
219
|
+
const gw = new Gateway({ rag: true });
|
|
220
|
+
response = await gw.send(mapped, { history });
|
|
221
|
+
} catch (err) {
|
|
222
|
+
response = `Error: ${err.message}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Update session and reply
|
|
226
|
+
sessions.update(sender, mapped, response);
|
|
227
|
+
const reply = stripMarkdown(response);
|
|
228
|
+
|
|
229
|
+
// Signal has a ~6000 char limit per message
|
|
230
|
+
if (reply.length > 5500) {
|
|
231
|
+
const chunks = reply.match(/.{1,5500}/gs) || [reply];
|
|
232
|
+
for (const chunk of chunks) {
|
|
233
|
+
await sendMessage(config.signalService, config.phoneNumber, sender, chunk);
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
await sendMessage(config.signalService, config.phoneNumber, sender, reply);
|
|
237
|
+
}
|
|
130
238
|
}
|
package/lib/gateway/commands.js
CHANGED
|
@@ -821,17 +821,124 @@ export async function handleWeb(args = {}) {
|
|
|
821
821
|
// ---------------------------------------------------------------------------
|
|
822
822
|
|
|
823
823
|
export async function handleSetupSignal() {
|
|
824
|
+
const clack = await import('@clack/prompts');
|
|
825
|
+
const { execSync } = await import('node:child_process');
|
|
826
|
+
const { writeConfig, loadConfig, configExists } = await import('../config.js');
|
|
824
827
|
const brand = await import('../brand.js');
|
|
825
|
-
|
|
826
|
-
brand.
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
828
|
+
|
|
829
|
+
brand.banner();
|
|
830
|
+
clack.intro('Signal Bot Setup');
|
|
831
|
+
|
|
832
|
+
// Step 1: Check if signal-cli-rest-api is running
|
|
833
|
+
let signalService = 'http://localhost:8080';
|
|
834
|
+
let apiHealthy = false;
|
|
835
|
+
|
|
836
|
+
clack.log.step('Checking for signal-cli-rest-api...');
|
|
837
|
+
|
|
838
|
+
try {
|
|
839
|
+
const resp = execSync(`curl -sf ${signalService}/v1/about`, { encoding: 'utf-8', timeout: 5000 });
|
|
840
|
+
const info = JSON.parse(resp);
|
|
841
|
+
clack.log.success(`Signal API running (v${info.version}, mode: ${info.mode})`);
|
|
842
|
+
apiHealthy = true;
|
|
843
|
+
} catch {
|
|
844
|
+
clack.log.warn('Signal API not detected at localhost:8080');
|
|
845
|
+
clack.log.info(
|
|
846
|
+
'Start it with Docker:\n\n' +
|
|
847
|
+
' docker compose --profile signal up -d signal-api\n\n' +
|
|
848
|
+
'Or manually:\n\n' +
|
|
849
|
+
' docker run -d --name signal-api \\\n' +
|
|
850
|
+
' -p 8080:8080 -e MODE=json-rpc \\\n' +
|
|
851
|
+
' -v signal-data:/home/.local/share/signal-cli \\\n' +
|
|
852
|
+
' bbernhard/signal-cli-rest-api:0.98'
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
const customUrl = await clack.text({
|
|
856
|
+
message: 'Signal API URL (or press Enter to skip)',
|
|
857
|
+
placeholder: 'http://localhost:8080',
|
|
858
|
+
defaultValue: '',
|
|
859
|
+
});
|
|
860
|
+
if (clack.isCancel(customUrl)) { clack.outro('Cancelled.'); process.exit(0); }
|
|
861
|
+
|
|
862
|
+
if (customUrl) {
|
|
863
|
+
signalService = customUrl;
|
|
864
|
+
try {
|
|
865
|
+
const resp = execSync(`curl -sf ${signalService}/v1/about`, { encoding: 'utf-8', timeout: 5000 });
|
|
866
|
+
clack.log.success('Signal API reachable.');
|
|
867
|
+
apiHealthy = true;
|
|
868
|
+
} catch {
|
|
869
|
+
clack.log.error('Cannot reach Signal API at that URL.');
|
|
870
|
+
clack.outro('Fix the Signal API first, then run cipher setup-signal again.');
|
|
871
|
+
process.exit(1);
|
|
872
|
+
}
|
|
873
|
+
} else {
|
|
874
|
+
clack.outro('Start the Signal API first, then run cipher setup-signal again.');
|
|
875
|
+
process.exit(1);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Step 2: Check registered phone numbers
|
|
880
|
+
let phoneNumber = '';
|
|
881
|
+
try {
|
|
882
|
+
const accounts = JSON.parse(execSync(`curl -sf ${signalService}/v1/accounts`, { encoding: 'utf-8', timeout: 5000 }));
|
|
883
|
+
if (accounts.length > 0) {
|
|
884
|
+
if (accounts.length === 1) {
|
|
885
|
+
phoneNumber = accounts[0];
|
|
886
|
+
clack.log.success(`Registered number: ${phoneNumber}`);
|
|
887
|
+
} else {
|
|
888
|
+
const selected = await clack.select({
|
|
889
|
+
message: 'Select the phone number for the bot',
|
|
890
|
+
options: accounts.map(a => ({ value: a, label: a })),
|
|
891
|
+
});
|
|
892
|
+
if (clack.isCancel(selected)) { clack.outro('Cancelled.'); process.exit(0); }
|
|
893
|
+
phoneNumber = selected;
|
|
894
|
+
}
|
|
895
|
+
} else {
|
|
896
|
+
clack.log.warn('No phone numbers registered with signal-cli.');
|
|
897
|
+
clack.log.info(
|
|
898
|
+
'Register a number:\n\n' +
|
|
899
|
+
` curl -X POST ${signalService}/v1/register/<YOUR_NUMBER>\n` +
|
|
900
|
+
` curl -X POST ${signalService}/v1/verify/<YOUR_NUMBER> -d '{"token":"<CODE>"}'`
|
|
901
|
+
);
|
|
902
|
+
clack.outro('Register a number first, then run cipher setup-signal again.');
|
|
903
|
+
process.exit(1);
|
|
904
|
+
}
|
|
905
|
+
} catch (err) {
|
|
906
|
+
clack.log.error('Failed to query accounts: ' + err.message);
|
|
907
|
+
process.exit(1);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Step 3: Whitelist
|
|
911
|
+
const whitelistInput = await clack.text({
|
|
912
|
+
message: 'Allowed phone numbers (comma-separated, E.164 format)',
|
|
913
|
+
placeholder: '+15551234567,+15559876543',
|
|
914
|
+
defaultValue: '',
|
|
915
|
+
});
|
|
916
|
+
if (clack.isCancel(whitelistInput)) { clack.outro('Cancelled.'); process.exit(0); }
|
|
917
|
+
|
|
918
|
+
const whitelist = whitelistInput
|
|
919
|
+
? whitelistInput.split(',').map(n => n.trim()).filter(Boolean)
|
|
920
|
+
: [];
|
|
921
|
+
|
|
922
|
+
// Step 4: Save config
|
|
923
|
+
let existingConfig = {};
|
|
924
|
+
try {
|
|
925
|
+
if (configExists()) existingConfig = loadConfig();
|
|
926
|
+
} catch { /* ignore */ }
|
|
927
|
+
|
|
928
|
+
const config = {
|
|
929
|
+
...existingConfig,
|
|
930
|
+
signal: {
|
|
931
|
+
signal_service: signalService,
|
|
932
|
+
phone_number: phoneNumber,
|
|
933
|
+
whitelist,
|
|
934
|
+
session_timeout: 3600,
|
|
935
|
+
},
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
const configPath = writeConfig(config);
|
|
939
|
+
|
|
940
|
+
clack.log.success('Signal configuration saved.');
|
|
941
|
+
clack.outro(`Run cipher bot to start. Config: ${configPath}`);
|
|
835
942
|
return {};
|
|
836
943
|
}
|
|
837
944
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cipher-security",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.0",
|
|
4
4
|
"description": "CIPHER — AI Security Engineering Platform CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"@clack/prompts": "^1.1.0",
|
|
26
26
|
"better-sqlite3": "^12.8.0",
|
|
27
27
|
"openai": "^6.32.0",
|
|
28
|
+
"ws": "^8.19.0",
|
|
28
29
|
"yaml": "^2.8.2"
|
|
29
30
|
}
|
|
30
31
|
}
|