quilltap 4.4.0-dev.93 → 4.4.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/quilltap.js +42 -0
- package/lib/memory-diff-command.js +355 -0
- package/package.json +5 -3
package/bin/quilltap.js
CHANGED
|
@@ -79,6 +79,7 @@ Subcommands:
|
|
|
79
79
|
db Query encrypted databases
|
|
80
80
|
themes Manage theme bundles
|
|
81
81
|
docs Inspect, read, and export document mounts
|
|
82
|
+
memory-diff <chatId> Dump existing memories and dry-run re-extraction for a chat
|
|
82
83
|
|
|
83
84
|
Options:
|
|
84
85
|
-p, --port <number> Port to listen on (default: 3000)
|
|
@@ -168,6 +169,19 @@ function ensureNativeModules() {
|
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
// Check node-pty: backs the Ariel terminal feature. Loaded dynamically by
|
|
173
|
+
// pty-manager in the standalone server, so resolution must succeed and the
|
|
174
|
+
// native binding's NODE_MODULE_VERSION must match the runtime.
|
|
175
|
+
try {
|
|
176
|
+
require('node-pty');
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
|
|
179
|
+
needsRebuild.push('node-pty');
|
|
180
|
+
} else if (err.code === 'MODULE_NOT_FOUND') {
|
|
181
|
+
needsRebuild.push('node-pty');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
171
185
|
if (needsRebuild.length === 0) return;
|
|
172
186
|
|
|
173
187
|
console.log(` Rebuilding native modules for Node.js ${process.version}...`);
|
|
@@ -237,6 +251,28 @@ function linkNativeModules(standaloneDir) {
|
|
|
237
251
|
|| resolveModuleDir('better-sqlite3');
|
|
238
252
|
linkModule('better-sqlite3', betterSqlite3Dir);
|
|
239
253
|
|
|
254
|
+
// Link node-pty — the standalone tarball strips it (platform-specific),
|
|
255
|
+
// and pty-manager loads it via a dynamic require, so it needs to resolve
|
|
256
|
+
// from standaloneDir/node_modules.
|
|
257
|
+
const nodePtyDir = resolveModuleDir('node-pty');
|
|
258
|
+
linkModule('node-pty', nodePtyDir);
|
|
259
|
+
if (nodePtyDir) {
|
|
260
|
+
// Some npm cache extractions strip the executable bit on spawn-helper,
|
|
261
|
+
// causing pty.spawn() to fail with `posix_spawnp failed`. Restore it.
|
|
262
|
+
const prebuildsDir = path.join(nodePtyDir, 'prebuilds');
|
|
263
|
+
if (fs.existsSync(prebuildsDir)) {
|
|
264
|
+
try {
|
|
265
|
+
for (const entry of fs.readdirSync(prebuildsDir, { withFileTypes: true })) {
|
|
266
|
+
if (!entry.isDirectory()) continue;
|
|
267
|
+
const helper = path.join(prebuildsDir, entry.name, 'spawn-helper');
|
|
268
|
+
if (fs.existsSync(helper)) {
|
|
269
|
+
try { fs.chmodSync(helper, 0o755); } catch { /* best-effort */ }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch { /* best-effort */ }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
240
276
|
// Link sharp
|
|
241
277
|
const sharpDir = resolveModuleDir('sharp');
|
|
242
278
|
linkModule('sharp', sharpDir);
|
|
@@ -869,6 +905,12 @@ if (process.argv[2] === 'db') {
|
|
|
869
905
|
} else if (process.argv[2] === 'docs') {
|
|
870
906
|
const { docsCommand } = require('../lib/docs-commands');
|
|
871
907
|
docsCommand(process.argv.slice(3));
|
|
908
|
+
} else if (process.argv[2] === 'memory-diff') {
|
|
909
|
+
const { memoryDiffCommand } = require('../lib/memory-diff-command');
|
|
910
|
+
memoryDiffCommand(process.argv.slice(3)).catch(err => {
|
|
911
|
+
console.error(`Error: ${err.message}`);
|
|
912
|
+
process.exit(1);
|
|
913
|
+
});
|
|
872
914
|
} else {
|
|
873
915
|
main();
|
|
874
916
|
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `quilltap memory-diff <chatId>` — dump existing memories for a chat and
|
|
5
|
+
* stream a dry-run re-extraction from the running server, writing both to
|
|
6
|
+
* JSON files for diffing.
|
|
7
|
+
*
|
|
8
|
+
* Read-only against the encrypted SQLite (memories table). The dry-run
|
|
9
|
+
* extraction is performed by the running server via
|
|
10
|
+
* POST /api/v1/chats/<chatId>?action=extract-memories-dry-run, which streams
|
|
11
|
+
* NDJSON progress events. Nothing is persisted server-side.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const { resolveDataDir, loadDbKey } = require('./db-helpers');
|
|
17
|
+
|
|
18
|
+
const RESET = '\x1b[0m';
|
|
19
|
+
const BOLD = '\x1b[1m';
|
|
20
|
+
const DIM = '\x1b[2m';
|
|
21
|
+
const GREEN = '\x1b[32m';
|
|
22
|
+
const RED = '\x1b[31m';
|
|
23
|
+
const YELLOW = '\x1b[33m';
|
|
24
|
+
const CYAN = '\x1b[36m';
|
|
25
|
+
|
|
26
|
+
function printMemoryDiffHelp() {
|
|
27
|
+
console.log(`
|
|
28
|
+
Quilltap memory-diff Tool
|
|
29
|
+
|
|
30
|
+
Usage: quilltap memory-diff <chatId> [options]
|
|
31
|
+
|
|
32
|
+
Reads existing memories for a chat from the encrypted SQLite database and
|
|
33
|
+
runs a dry-run re-extraction against the running Quilltap server. Writes:
|
|
34
|
+
<out>/<chatId>-existing.json — current memories from the database
|
|
35
|
+
<out>/<chatId>-extracted.json — what the extraction pipeline would write
|
|
36
|
+
|
|
37
|
+
Nothing is persisted; the server runs the extraction passes in dry-run mode
|
|
38
|
+
so the comparison is non-destructive.
|
|
39
|
+
|
|
40
|
+
Options:
|
|
41
|
+
-d, --data-dir <path> Override data directory (instance root)
|
|
42
|
+
--passphrase <pass> Decrypt .dbkey if peppered
|
|
43
|
+
--port <number> Server port for API calls (default: 3000)
|
|
44
|
+
--out <dir> Output directory (default: cwd)
|
|
45
|
+
--concurrency <number> Parallel turns to process (default: 4, max: 32).
|
|
46
|
+
Cloud cheap-LLMs can handle 8–16; keep this low
|
|
47
|
+
for local Ollama to avoid saturating the model.
|
|
48
|
+
-h, --help Show this help
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
quilltap memory-diff <chatId>
|
|
52
|
+
quilltap memory-diff <chatId> --data-dir ~/iCloud/Quilltap/Friday
|
|
53
|
+
quilltap memory-diff <chatId> --out /tmp/extract-diff
|
|
54
|
+
quilltap memory-diff <chatId> --concurrency 8 # cloud cheap-LLM
|
|
55
|
+
`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseFlags(args) {
|
|
59
|
+
const flags = {
|
|
60
|
+
dataDir: '',
|
|
61
|
+
passphrase: '',
|
|
62
|
+
port: 3000,
|
|
63
|
+
out: process.cwd(),
|
|
64
|
+
concurrency: 4,
|
|
65
|
+
help: false,
|
|
66
|
+
};
|
|
67
|
+
const positional = [];
|
|
68
|
+
let i = 0;
|
|
69
|
+
while (i < args.length) {
|
|
70
|
+
const a = args[i];
|
|
71
|
+
switch (a) {
|
|
72
|
+
case '-d':
|
|
73
|
+
case '--data-dir':
|
|
74
|
+
flags.dataDir = args[++i];
|
|
75
|
+
break;
|
|
76
|
+
case '--passphrase':
|
|
77
|
+
flags.passphrase = args[++i];
|
|
78
|
+
break;
|
|
79
|
+
case '--port': {
|
|
80
|
+
const p = parseInt(args[++i], 10);
|
|
81
|
+
if (isNaN(p) || p < 1 || p > 65535) {
|
|
82
|
+
console.error('Error: --port must be between 1 and 65535');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
flags.port = p;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case '--concurrency': {
|
|
89
|
+
const n = parseInt(args[++i], 10);
|
|
90
|
+
if (isNaN(n) || n < 1 || n > 32) {
|
|
91
|
+
console.error('Error: --concurrency must be between 1 and 32');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
flags.concurrency = n;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case '--out':
|
|
98
|
+
flags.out = args[++i];
|
|
99
|
+
break;
|
|
100
|
+
case '-h':
|
|
101
|
+
case '--help':
|
|
102
|
+
flags.help = true;
|
|
103
|
+
break;
|
|
104
|
+
default:
|
|
105
|
+
if (a.startsWith('-')) {
|
|
106
|
+
console.error(`Unknown option: ${a}`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
positional.push(a);
|
|
110
|
+
}
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
return { flags, positional };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function tryParseJsonColumn(value, fallback) {
|
|
117
|
+
if (value === null || value === undefined || value === '') return fallback;
|
|
118
|
+
if (typeof value !== 'string') return value;
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(value);
|
|
121
|
+
} catch {
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Open quilltap.db read-only and return all memories for a chat. JSON
|
|
128
|
+
* columns are parsed; the binary `embedding` column is dropped from output.
|
|
129
|
+
*/
|
|
130
|
+
async function readExistingMemories(flags, chatId) {
|
|
131
|
+
const dataDir = resolveDataDir(flags.dataDir);
|
|
132
|
+
const dbPath = path.join(dataDir, 'quilltap.db');
|
|
133
|
+
if (!fs.existsSync(dbPath)) {
|
|
134
|
+
console.error(`Database not found: ${dbPath}`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let pepper;
|
|
139
|
+
try {
|
|
140
|
+
pepper = await loadDbKey(dataDir, flags.passphrase);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error(`Error: ${err.message}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let Database;
|
|
147
|
+
try {
|
|
148
|
+
Database = require('better-sqlite3-multiple-ciphers');
|
|
149
|
+
} catch {
|
|
150
|
+
Database = require('better-sqlite3');
|
|
151
|
+
}
|
|
152
|
+
const db = new Database(dbPath, { readonly: true });
|
|
153
|
+
|
|
154
|
+
if (pepper) {
|
|
155
|
+
const keyHex = Buffer.from(pepper, 'base64').toString('hex');
|
|
156
|
+
db.pragma(`key = "x'${keyHex}'"`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
db.prepare('SELECT 1').get();
|
|
161
|
+
} catch (err) {
|
|
162
|
+
db.close();
|
|
163
|
+
console.error(`Cannot open database: ${err.message}`);
|
|
164
|
+
console.error('The database may be encrypted with a different key, or the .dbkey file may be missing.');
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let rows;
|
|
169
|
+
try {
|
|
170
|
+
rows = db.prepare(`
|
|
171
|
+
SELECT id, characterId, aboutCharacterId, chatId, source, sourceMessageId,
|
|
172
|
+
content, summary, keywords, tags, importance, reinforcementCount,
|
|
173
|
+
lastReinforcedAt, relatedMemoryIds, reinforcedImportance,
|
|
174
|
+
createdAt, updatedAt
|
|
175
|
+
FROM memories
|
|
176
|
+
WHERE chatId = ?
|
|
177
|
+
ORDER BY createdAt
|
|
178
|
+
`).all(chatId);
|
|
179
|
+
} finally {
|
|
180
|
+
db.close();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return rows.map(row => ({
|
|
184
|
+
...row,
|
|
185
|
+
keywords: tryParseJsonColumn(row.keywords, []),
|
|
186
|
+
tags: tryParseJsonColumn(row.tags, []),
|
|
187
|
+
relatedMemoryIds: tryParseJsonColumn(row.relatedMemoryIds, []),
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read NDJSON from a Response body, calling `onEvent` for every parsed line.
|
|
193
|
+
* Tolerates split lines across chunks.
|
|
194
|
+
*/
|
|
195
|
+
async function streamNdjson(res, onEvent) {
|
|
196
|
+
const reader = res.body.getReader();
|
|
197
|
+
const decoder = new TextDecoder();
|
|
198
|
+
let buffer = '';
|
|
199
|
+
for (;;) {
|
|
200
|
+
const { value, done } = await reader.read();
|
|
201
|
+
if (done) break;
|
|
202
|
+
buffer += decoder.decode(value, { stream: true });
|
|
203
|
+
let nl;
|
|
204
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
205
|
+
const line = buffer.slice(0, nl).trim();
|
|
206
|
+
buffer = buffer.slice(nl + 1);
|
|
207
|
+
if (!line) continue;
|
|
208
|
+
let event;
|
|
209
|
+
try {
|
|
210
|
+
event = JSON.parse(line);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
process.stderr.write(`${YELLOW}[warn] could not parse server line: ${line.slice(0, 200)}${RESET}\n`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
onEvent(event);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Flush any trailing partial line
|
|
219
|
+
const tail = buffer.trim();
|
|
220
|
+
if (tail) {
|
|
221
|
+
try {
|
|
222
|
+
onEvent(JSON.parse(tail));
|
|
223
|
+
} catch {
|
|
224
|
+
/* swallow */
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function memoryDiffCommand(args) {
|
|
230
|
+
const { flags, positional } = parseFlags(args);
|
|
231
|
+
|
|
232
|
+
if (flags.help || positional.length === 0) {
|
|
233
|
+
printMemoryDiffHelp();
|
|
234
|
+
process.exit(flags.help ? 0 : 1);
|
|
235
|
+
}
|
|
236
|
+
if (positional.length > 1) {
|
|
237
|
+
console.error('Error: only one chatId may be specified');
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const chatId = positional[0];
|
|
242
|
+
|
|
243
|
+
if (!fs.existsSync(flags.out)) {
|
|
244
|
+
try {
|
|
245
|
+
fs.mkdirSync(flags.out, { recursive: true });
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error(`Cannot create output directory ${flags.out}: ${err.message}`);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const existingPath = path.join(flags.out, `${chatId}-existing.json`);
|
|
253
|
+
const extractedPath = path.join(flags.out, `${chatId}-extracted.json`);
|
|
254
|
+
|
|
255
|
+
// -------- 1. Read existing memories from the encrypted DB ----------------
|
|
256
|
+
process.stderr.write(`${BOLD}Reading existing memories${RESET} for chat ${DIM}${chatId}${RESET}...\n`);
|
|
257
|
+
const existing = await readExistingMemories(flags, chatId);
|
|
258
|
+
fs.writeFileSync(existingPath, JSON.stringify(existing, null, 2) + '\n');
|
|
259
|
+
process.stderr.write(` wrote ${GREEN}${existing.length}${RESET} memories to ${existingPath}\n`);
|
|
260
|
+
|
|
261
|
+
// -------- 2. Stream dry-run re-extraction from the server ----------------
|
|
262
|
+
const url =
|
|
263
|
+
`http://localhost:${flags.port}/api/v1/chats/${encodeURIComponent(chatId)}` +
|
|
264
|
+
`?action=extract-memories-dry-run&concurrency=${flags.concurrency}`;
|
|
265
|
+
process.stderr.write(
|
|
266
|
+
`${BOLD}Streaming re-extraction${RESET} (concurrency ${CYAN}${flags.concurrency}${RESET}) from ${DIM}${url}${RESET}\n`
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
let res;
|
|
270
|
+
try {
|
|
271
|
+
res = await fetch(url, { method: 'POST' });
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error(`${RED}Could not reach Quilltap server at http://localhost:${flags.port}: ${err.message}${RESET}`);
|
|
274
|
+
console.error('Start the server (npm run dev) or pass --port to match a non-default port.');
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!res.ok) {
|
|
279
|
+
let body;
|
|
280
|
+
try {
|
|
281
|
+
body = await res.text();
|
|
282
|
+
} catch {
|
|
283
|
+
body = '';
|
|
284
|
+
}
|
|
285
|
+
console.error(`${RED}Server returned ${res.status}: ${body.slice(0, 500)}${RESET}`);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!res.body) {
|
|
290
|
+
console.error(`${RED}Server response had no body${RESET}`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const candidates = [];
|
|
295
|
+
let turnCount = 0;
|
|
296
|
+
let totalCandidates = null;
|
|
297
|
+
let fatal = null;
|
|
298
|
+
|
|
299
|
+
await streamNdjson(res, (event) => {
|
|
300
|
+
switch (event.type) {
|
|
301
|
+
case 'start':
|
|
302
|
+
turnCount = event.turnCount;
|
|
303
|
+
process.stderr.write(` re-extracting ${CYAN}${turnCount}${RESET} turns...\n`);
|
|
304
|
+
break;
|
|
305
|
+
case 'candidate':
|
|
306
|
+
candidates.push(event);
|
|
307
|
+
break;
|
|
308
|
+
case 'turn':
|
|
309
|
+
process.stderr.write(
|
|
310
|
+
` [${String(event.index + 1).padStart(String(turnCount).length)}/${turnCount}] ` +
|
|
311
|
+
`turn ${DIM}${event.sourceMessageId ?? '?'}${RESET}: ` +
|
|
312
|
+
`${GREEN}${event.candidatesAdded}${RESET} candidate(s)\n`
|
|
313
|
+
);
|
|
314
|
+
break;
|
|
315
|
+
case 'turn-error':
|
|
316
|
+
process.stderr.write(
|
|
317
|
+
` ${RED}[${event.index + 1}/${turnCount}] FAILED${RESET}: ${event.error}\n`
|
|
318
|
+
);
|
|
319
|
+
break;
|
|
320
|
+
case 'ping':
|
|
321
|
+
// Server-side heartbeat keeping the connection warm during long
|
|
322
|
+
// first-turn LLM passes; nothing to display.
|
|
323
|
+
break;
|
|
324
|
+
case 'done':
|
|
325
|
+
totalCandidates = event.totalCandidates;
|
|
326
|
+
break;
|
|
327
|
+
case 'fatal':
|
|
328
|
+
fatal = event.error;
|
|
329
|
+
break;
|
|
330
|
+
default:
|
|
331
|
+
process.stderr.write(` ${YELLOW}[unknown event] ${JSON.stringify(event)}${RESET}\n`);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
if (fatal) {
|
|
336
|
+
console.error(`${RED}Server reported fatal error: ${fatal}${RESET}`);
|
|
337
|
+
// Still write what we collected so the user can inspect partial output.
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
fs.writeFileSync(extractedPath, JSON.stringify(candidates, null, 2) + '\n');
|
|
341
|
+
process.stderr.write(` wrote ${GREEN}${candidates.length}${RESET} candidates to ${extractedPath}\n`);
|
|
342
|
+
|
|
343
|
+
if (totalCandidates !== null && totalCandidates !== candidates.length) {
|
|
344
|
+
process.stderr.write(
|
|
345
|
+
` ${YELLOW}note: server reported ${totalCandidates} total candidates but stream delivered ${candidates.length}${RESET}\n`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// -------- 3. Summary on stdout ------------------------------------------
|
|
350
|
+
console.log(`${existing.length} existing → ${candidates.length} re-extracted`);
|
|
351
|
+
|
|
352
|
+
if (fatal) process.exit(2);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
module.exports = { memoryDiffCommand };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quilltap",
|
|
3
|
-
"version": "4.4.0
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Charles Sebold",
|
|
@@ -33,12 +33,14 @@
|
|
|
33
33
|
"README.md"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"@napi-rs/canvas": "^0.1.100",
|
|
36
37
|
"better-sqlite3-multiple-ciphers": "^12.9.0",
|
|
38
|
+
"node-pty": "^1.1.0",
|
|
37
39
|
"sharp": "^0.34.5",
|
|
38
|
-
"tar": "^7.5.
|
|
40
|
+
"tar": "^7.5.15",
|
|
39
41
|
"yauzl": "^3.3.0"
|
|
40
42
|
},
|
|
41
43
|
"engines": {
|
|
42
|
-
"node": ">=
|
|
44
|
+
"node": ">=24.0.0"
|
|
43
45
|
}
|
|
44
46
|
}
|