@steipete/oracle 0.5.2 → 0.5.4
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 +3 -1
- package/dist/.DS_Store +0 -0
- package/dist/bin/oracle-cli.js +11 -9
- package/dist/src/browser/actions/assistantResponse.js +3 -2
- package/dist/src/browser/actions/attachments.js +267 -49
- package/dist/src/browser/actions/promptComposer.js +47 -3
- package/dist/src/browser/chromeLifecycle.js +13 -4
- package/dist/src/browser/constants.js +7 -4
- package/dist/src/browser/index.js +10 -5
- package/dist/src/cli/tui/index.js +40 -10
- package/dist/src/oracle/errors.js +1 -1
- package/dist/src/oracle/run.js +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,8 @@ Oracle bundles your prompt and files so another AI can answer with real context.
|
|
|
17
17
|
|
|
18
18
|
Install globally: `npm install -g @steipete/oracle`
|
|
19
19
|
|
|
20
|
+
Use `npx -y @steipete/oracle …` (not `pnpx`)—pnpx's sandboxed cache can’t load the sqlite bindings and will throw missing `node_sqlite3.node` errors.
|
|
21
|
+
|
|
20
22
|
```bash
|
|
21
23
|
# Copy the bundle and paste into ChatGPT
|
|
22
24
|
npx @steipete/oracle --render --copy -p "Review the TS data layer for schema drift" --file "src/**/*.ts,*/*.test.ts"
|
|
@@ -38,7 +40,7 @@ npx @steipete/oracle status --hours 72
|
|
|
38
40
|
npx @steipete/oracle session <id> --render
|
|
39
41
|
|
|
40
42
|
# TUI (interactive, only for humans)
|
|
41
|
-
npx @steipete/oracle
|
|
43
|
+
npx @steipete/oracle tui
|
|
42
44
|
```
|
|
43
45
|
|
|
44
46
|
Engine auto-picks API when `OPENAI_API_KEY` is set, otherwise browser; browser is stable on macOS and works on Linux and Windows. On Linux pass `--browser-chrome-path/--browser-cookie-path` if detection fails; on Windows prefer `--browser-manual-login` or inline cookies if decryption is blocked.
|
package/dist/.DS_Store
CHANGED
|
Binary file
|
package/dist/bin/oracle-cli.js
CHANGED
|
@@ -49,7 +49,6 @@ const CLI_ENTRYPOINT = fileURLToPath(import.meta.url);
|
|
|
49
49
|
const rawCliArgs = process.argv.slice(2);
|
|
50
50
|
const userCliArgs = rawCliArgs[0] === CLI_ENTRYPOINT ? rawCliArgs.slice(1) : rawCliArgs;
|
|
51
51
|
const isTty = process.stdout.isTTY;
|
|
52
|
-
const tuiEnabled = () => isTty && process.env.ORACLE_NO_TUI !== '1';
|
|
53
52
|
const program = new Command();
|
|
54
53
|
let introPrinted = false;
|
|
55
54
|
program.hook('preAction', () => {
|
|
@@ -66,8 +65,8 @@ program.hook('preAction', (thisCommand) => {
|
|
|
66
65
|
if (userCliArgs.some((arg) => arg === '--help' || arg === '-h')) {
|
|
67
66
|
return;
|
|
68
67
|
}
|
|
69
|
-
if (userCliArgs.length === 0
|
|
70
|
-
//
|
|
68
|
+
if (userCliArgs.length === 0) {
|
|
69
|
+
// Let the root action handle zero-arg entry (help + hint to `oracle tui`).
|
|
71
70
|
return;
|
|
72
71
|
}
|
|
73
72
|
const opts = thisCommand.optsWithGlobals();
|
|
@@ -212,6 +211,13 @@ program
|
|
|
212
211
|
token: commandOptions.token,
|
|
213
212
|
});
|
|
214
213
|
});
|
|
214
|
+
program
|
|
215
|
+
.command('tui')
|
|
216
|
+
.description('Launch the interactive terminal UI for humans (no automation).')
|
|
217
|
+
.action(async () => {
|
|
218
|
+
await sessionStore.ensureStorage();
|
|
219
|
+
await launchTui({ version: VERSION, printIntro: false });
|
|
220
|
+
});
|
|
215
221
|
const sessionCommand = program
|
|
216
222
|
.command('session [id]')
|
|
217
223
|
.description('Attach to a stored session or list recent sessions when no ID is provided.')
|
|
@@ -422,12 +428,8 @@ async function runRootCommand(options) {
|
|
|
422
428
|
console.log(chalk.dim(`Remote browser host detected: ${remoteHost}`));
|
|
423
429
|
}
|
|
424
430
|
if (userCliArgs.length === 0) {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
console.log(chalk.yellow('No prompt or subcommand supplied. See `oracle --help` for usage.'));
|
|
430
|
-
program.help({ error: false });
|
|
431
|
+
console.log(chalk.yellow('No prompt or subcommand supplied. Run `oracle --help` or `oracle tui` for the TUI.'));
|
|
432
|
+
program.outputHelp();
|
|
431
433
|
return;
|
|
432
434
|
}
|
|
433
435
|
const retentionHours = typeof options.retainHours === 'number' ? options.retainHours : undefined;
|
|
@@ -291,8 +291,9 @@ function buildResponseObserverExpression(timeoutMs) {
|
|
|
291
291
|
if (!stop) {
|
|
292
292
|
return;
|
|
293
293
|
}
|
|
294
|
-
const
|
|
295
|
-
|
|
294
|
+
const isStopButton =
|
|
295
|
+
stop.getAttribute('data-testid') === 'stop-button' || stop.getAttribute('aria-label')?.toLowerCase()?.includes('stop');
|
|
296
|
+
if (isStopButton) {
|
|
296
297
|
return;
|
|
297
298
|
}
|
|
298
299
|
dispatchClickSequence(stop);
|
|
@@ -7,32 +7,145 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
|
|
|
7
7
|
if (!dom) {
|
|
8
8
|
throw new Error('DOM domain unavailable while uploading attachments.');
|
|
9
9
|
}
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
const isAttachmentPresent = async (name) => {
|
|
11
|
+
const check = await runtime.evaluate({
|
|
12
|
+
expression: `(() => {
|
|
13
|
+
const expected = ${JSON.stringify(name.toLowerCase())};
|
|
14
|
+
const selectors = [
|
|
15
|
+
'[data-testid*="attachment"]',
|
|
16
|
+
'[data-testid*="chip"]',
|
|
17
|
+
'[data-testid*="upload"]'
|
|
18
|
+
];
|
|
19
|
+
const chips = selectors.some((selector) =>
|
|
20
|
+
Array.from(document.querySelectorAll(selector)).some((node) =>
|
|
21
|
+
(node?.textContent || '').toLowerCase().includes(expected),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
if (chips) return true;
|
|
25
|
+
const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
|
|
26
|
+
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
27
|
+
);
|
|
28
|
+
if (cardTexts.some((text) => text.includes(expected))) return true;
|
|
29
|
+
|
|
30
|
+
const filesPill = Array.from(document.querySelectorAll('button,div')).some((node) => {
|
|
31
|
+
const text = (node?.textContent || '').toLowerCase();
|
|
32
|
+
return /\bfiles\b/.test(text) && text.includes('file');
|
|
33
|
+
});
|
|
34
|
+
if (filesPill) return true;
|
|
35
|
+
|
|
36
|
+
const inputs = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
|
|
37
|
+
Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(expected)),
|
|
38
|
+
);
|
|
39
|
+
return inputs;
|
|
40
|
+
})()`,
|
|
41
|
+
returnByValue: true,
|
|
42
|
+
});
|
|
43
|
+
return Boolean(check?.result?.value);
|
|
44
|
+
};
|
|
45
|
+
// New ChatGPT UI hides the real file input behind a composer "+" menu; click it pre-emptively.
|
|
46
|
+
await Promise.resolve(runtime.evaluate({
|
|
47
|
+
expression: `(() => {
|
|
48
|
+
const selectors = [
|
|
49
|
+
'#composer-plus-btn',
|
|
50
|
+
'button[data-testid="composer-plus-btn"]',
|
|
51
|
+
'[data-testid*="plus"]',
|
|
52
|
+
'button[aria-label*="add"]',
|
|
53
|
+
'button[aria-label*="attachment"]',
|
|
54
|
+
'button[aria-label*="file"]',
|
|
55
|
+
];
|
|
56
|
+
for (const selector of selectors) {
|
|
57
|
+
const el = document.querySelector(selector);
|
|
58
|
+
if (el instanceof HTMLElement) {
|
|
59
|
+
el.click();
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
18
62
|
}
|
|
63
|
+
return false;
|
|
64
|
+
})()`,
|
|
65
|
+
returnByValue: true,
|
|
66
|
+
})).catch(() => undefined);
|
|
67
|
+
await delay(250);
|
|
68
|
+
// Helper to click the upload menu item (if present) to reveal the real attachment input.
|
|
69
|
+
await Promise.resolve(runtime.evaluate({
|
|
70
|
+
expression: `(() => {
|
|
71
|
+
const menuItems = Array.from(document.querySelectorAll('[data-testid*="upload"],[data-testid*="attachment"], [role="menuitem"], [data-radix-collection-item]'));
|
|
72
|
+
for (const el of menuItems) {
|
|
73
|
+
const text = (el.textContent || '').toLowerCase();
|
|
74
|
+
const tid = el.getAttribute?.('data-testid')?.toLowerCase?.() || '';
|
|
75
|
+
if (tid.includes('upload') || tid.includes('attachment') || text.includes('upload') || text.includes('file')) {
|
|
76
|
+
if (el instanceof HTMLElement) { el.click(); return true; }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
})()`,
|
|
81
|
+
returnByValue: true,
|
|
82
|
+
})).catch(() => undefined);
|
|
83
|
+
const expectedName = path.basename(attachment.path);
|
|
84
|
+
if (await isAttachmentPresent(expectedName)) {
|
|
85
|
+
logger(`Attachment already present: ${path.basename(attachment.path)}`);
|
|
86
|
+
return;
|
|
19
87
|
}
|
|
20
|
-
|
|
21
|
-
|
|
88
|
+
// Find a real input; prefer non-image accept fields and tag it for DOM.setFileInputFiles.
|
|
89
|
+
const markResult = await runtime.evaluate({
|
|
90
|
+
expression: `(() => {
|
|
91
|
+
const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
|
|
92
|
+
const acceptIsImageOnly = (accept) => {
|
|
93
|
+
if (!accept) return false;
|
|
94
|
+
const parts = String(accept)
|
|
95
|
+
.split(',')
|
|
96
|
+
.map((p) => p.trim().toLowerCase())
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
return parts.length > 0 && parts.every((p) => p.startsWith('image/'));
|
|
99
|
+
};
|
|
100
|
+
const nonImage = inputs.filter((el) => !acceptIsImageOnly(el.getAttribute('accept')));
|
|
101
|
+
const target = (nonImage.length ? nonImage[nonImage.length - 1] : inputs[inputs.length - 1]) ?? null;
|
|
102
|
+
if (target) {
|
|
103
|
+
target.setAttribute('data-oracle-upload-target', 'true');
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
})()`,
|
|
108
|
+
returnByValue: true,
|
|
109
|
+
});
|
|
110
|
+
const marked = Boolean(markResult?.result?.value);
|
|
111
|
+
if (!marked) {
|
|
112
|
+
await logDomFailure(runtime, logger, 'file-input-missing');
|
|
22
113
|
throw new Error('Unable to locate ChatGPT file attachment input.');
|
|
23
114
|
}
|
|
24
|
-
await dom.
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
throw new Error('Attachment did not register with the ChatGPT composer in time.');
|
|
115
|
+
const documentNode = await dom.getDocument();
|
|
116
|
+
const resultNode = await dom.querySelector({ nodeId: documentNode.root.nodeId, selector: 'input[type="file"][data-oracle-upload-target="true"]' });
|
|
117
|
+
if (!resultNode?.nodeId) {
|
|
118
|
+
await logDomFailure(runtime, logger, 'file-input-missing');
|
|
119
|
+
throw new Error('Unable to locate ChatGPT file attachment input.');
|
|
30
120
|
}
|
|
31
|
-
|
|
32
|
-
|
|
121
|
+
const resolvedNodeId = resultNode.nodeId;
|
|
122
|
+
const dispatchEvents = FILE_INPUT_SELECTORS
|
|
123
|
+
.map((selector) => `
|
|
124
|
+
(() => {
|
|
125
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
126
|
+
if (el instanceof HTMLInputElement) {
|
|
127
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
128
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
131
|
+
`)
|
|
132
|
+
.join('\\n');
|
|
133
|
+
const tryFileInput = async () => {
|
|
134
|
+
await dom.setFileInputFiles({ nodeId: resolvedNodeId, files: [attachment.path] });
|
|
135
|
+
await runtime.evaluate({ expression: `(function(){${dispatchEvents} return true;})()`, returnByValue: true });
|
|
136
|
+
};
|
|
137
|
+
await tryFileInput();
|
|
138
|
+
if (await waitForAttachmentAnchored(runtime, expectedName, 20_000)) {
|
|
139
|
+
await waitForAttachmentVisible(runtime, expectedName, 20_000, logger);
|
|
140
|
+
logger('Attachment queued (file input)');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
await logDomFailure(runtime, logger, 'file-upload-missing');
|
|
144
|
+
throw new Error('Attachment did not register with the ChatGPT composer in time.');
|
|
33
145
|
}
|
|
34
|
-
export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
|
|
146
|
+
export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNames = [], logger) {
|
|
35
147
|
const deadline = Date.now() + timeoutMs;
|
|
148
|
+
const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
|
|
36
149
|
const expression = `(() => {
|
|
37
150
|
const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
|
|
38
151
|
let button = null;
|
|
@@ -58,24 +171,44 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
|
|
|
58
171
|
return text.includes('upload') || text.includes('processing') || text.includes('uploading');
|
|
59
172
|
});
|
|
60
173
|
});
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
174
|
+
const attachmentSelectors = ['[data-testid*="chip"]', '[data-testid*="attachment"]', '[data-testid*="upload"]'];
|
|
175
|
+
const attachedNames = [];
|
|
176
|
+
for (const selector of attachmentSelectors) {
|
|
177
|
+
for (const node of Array.from(document.querySelectorAll(selector))) {
|
|
178
|
+
const text = node?.textContent?.toLowerCase?.();
|
|
179
|
+
if (text) attachedNames.push(text);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
|
|
183
|
+
if (!(input instanceof HTMLInputElement) || !input.files?.length) continue;
|
|
184
|
+
for (const file of Array.from(input.files)) {
|
|
185
|
+
if (file?.name) attachedNames.push(file.name.toLowerCase());
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
|
|
189
|
+
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
67
190
|
);
|
|
68
|
-
|
|
191
|
+
attachedNames.push(...cardTexts.filter(Boolean));
|
|
192
|
+
const filesPills = Array.from(document.querySelectorAll('button,div'))
|
|
193
|
+
.map((node) => (node?.textContent || '').toLowerCase())
|
|
194
|
+
.filter((text) => /\bfiles\b/.test(text));
|
|
195
|
+
attachedNames.push(...filesPills);
|
|
196
|
+
const filesAttached = attachedNames.length > 0;
|
|
197
|
+
return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached, attachedNames };
|
|
69
198
|
})()`;
|
|
70
199
|
while (Date.now() < deadline) {
|
|
71
200
|
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
72
201
|
const value = result?.value;
|
|
73
202
|
if (value && !value.uploading) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
203
|
+
const attached = new Set((value.attachedNames ?? []).map((name) => name.toLowerCase()));
|
|
204
|
+
const missing = expectedNormalized.filter((name) => !attached.has(name));
|
|
205
|
+
if (missing.length === 0) {
|
|
206
|
+
if (value.state === 'ready') {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (value.state === 'missing' && value.filesAttached) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
79
212
|
}
|
|
80
213
|
}
|
|
81
214
|
await delay(250);
|
|
@@ -85,15 +218,83 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
|
|
|
85
218
|
throw new Error('Attachments did not finish uploading before timeout.');
|
|
86
219
|
}
|
|
87
220
|
export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs, logger) {
|
|
88
|
-
|
|
221
|
+
// Attachments can take a few seconds to render in the composer (headless/remote Chrome is slower),
|
|
222
|
+
// so respect the caller-provided timeout instead of capping at 2s.
|
|
223
|
+
const deadline = Date.now() + timeoutMs;
|
|
89
224
|
const expression = `(() => {
|
|
90
225
|
const expected = ${JSON.stringify(expectedName)};
|
|
226
|
+
const normalized = expected.toLowerCase();
|
|
227
|
+
const matchNode = (node) => {
|
|
228
|
+
if (!node) return false;
|
|
229
|
+
const text = (node.textContent || '').toLowerCase();
|
|
230
|
+
const aria = node.getAttribute?.('aria-label')?.toLowerCase?.() ?? '';
|
|
231
|
+
const title = node.getAttribute?.('title')?.toLowerCase?.() ?? '';
|
|
232
|
+
const testId = node.getAttribute?.('data-testid')?.toLowerCase?.() ?? '';
|
|
233
|
+
const alt = node.getAttribute?.('alt')?.toLowerCase?.() ?? '';
|
|
234
|
+
return [text, aria, title, testId, alt].some((value) => value.includes(normalized));
|
|
235
|
+
};
|
|
236
|
+
|
|
91
237
|
const turns = Array.from(document.querySelectorAll('article[data-testid^="conversation-turn"]'));
|
|
92
238
|
const userTurns = turns.filter((node) => node.querySelector('[data-message-author-role="user"]'));
|
|
93
239
|
const lastUser = userTurns[userTurns.length - 1];
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
240
|
+
if (lastUser) {
|
|
241
|
+
const turnMatch = Array.from(lastUser.querySelectorAll('*')).some(matchNode);
|
|
242
|
+
if (turnMatch) return { found: true, userTurns: userTurns.length, source: 'turn' };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const composerSelectors = [
|
|
246
|
+
'[data-testid*="composer"]',
|
|
247
|
+
'form textarea',
|
|
248
|
+
'form [data-testid*="attachment"]',
|
|
249
|
+
'[data-testid*="upload"]',
|
|
250
|
+
'[data-testid*="chip"]',
|
|
251
|
+
'form',
|
|
252
|
+
'button',
|
|
253
|
+
'label'
|
|
254
|
+
];
|
|
255
|
+
const composerMatch = composerSelectors.some((selector) =>
|
|
256
|
+
Array.from(document.querySelectorAll(selector)).some(matchNode),
|
|
257
|
+
);
|
|
258
|
+
if (composerMatch) {
|
|
259
|
+
return { found: true, userTurns: userTurns.length, source: 'composer' };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const attachmentSelectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]'];
|
|
263
|
+
const attachmentMatch = attachmentSelectors.some((selector) =>
|
|
264
|
+
Array.from(document.querySelectorAll(selector)).some(matchNode),
|
|
265
|
+
);
|
|
266
|
+
if (attachmentMatch) {
|
|
267
|
+
return { found: true, userTurns: userTurns.length, source: 'attachments' };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
|
|
271
|
+
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
272
|
+
);
|
|
273
|
+
if (cardTexts.some((text) => text.includes(normalized))) {
|
|
274
|
+
return { found: true, userTurns: userTurns.length, source: 'attachment-cards' };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const filesPills = Array.from(document.querySelectorAll('button,div')).map((node) =>
|
|
278
|
+
(node?.textContent || '').toLowerCase(),
|
|
279
|
+
);
|
|
280
|
+
if (filesPills.some((text) => /\bfiles\b/.test(text))) {
|
|
281
|
+
return { found: true, userTurns: userTurns.length, source: 'files-pill' };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const attrMatch = Array.from(document.querySelectorAll('[aria-label], [title], [data-testid]')).some(matchNode);
|
|
285
|
+
if (attrMatch) {
|
|
286
|
+
return { found: true, userTurns: userTurns.length, source: 'attrs' };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const bodyMatch = (document.body?.innerText || '').toLowerCase().includes(normalized);
|
|
290
|
+
if (bodyMatch) {
|
|
291
|
+
return { found: true, userTurns: userTurns.length, source: 'body' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
|
|
295
|
+
Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
|
|
296
|
+
);
|
|
297
|
+
return { found: inputHit, userTurns: userTurns.length, source: inputHit ? 'input' : undefined };
|
|
97
298
|
})()`;
|
|
98
299
|
while (Date.now() < deadline) {
|
|
99
300
|
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
@@ -107,31 +308,48 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
|
|
|
107
308
|
await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-visible');
|
|
108
309
|
throw new Error('Attachment did not appear in ChatGPT composer.');
|
|
109
310
|
}
|
|
110
|
-
async function
|
|
311
|
+
async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
|
|
111
312
|
const deadline = Date.now() + timeoutMs;
|
|
112
313
|
const expression = `(() => {
|
|
113
|
-
const
|
|
314
|
+
const normalized = ${JSON.stringify(expectedName.toLowerCase())};
|
|
315
|
+
const selectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]'];
|
|
114
316
|
for (const selector of selectors) {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
const names = Array.from(input.files ?? []).map((file) => file?.name ?? '');
|
|
121
|
-
if (names.some((name) => name === ${JSON.stringify(expectedName)})) {
|
|
122
|
-
return { matched: true, names };
|
|
317
|
+
for (const node of Array.from(document.querySelectorAll(selector))) {
|
|
318
|
+
const text = (node?.textContent || '').toLowerCase();
|
|
319
|
+
if (text.includes(normalized)) {
|
|
320
|
+
return { found: true, text };
|
|
123
321
|
}
|
|
124
322
|
}
|
|
125
323
|
}
|
|
126
|
-
|
|
324
|
+
const cards = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
|
|
325
|
+
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
326
|
+
);
|
|
327
|
+
if (cards.some((text) => text.includes(normalized))) {
|
|
328
|
+
return { found: true, text: cards.find((t) => t.includes(normalized)) };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const filesPills = Array.from(document.querySelectorAll('button,div')).map((node) =>
|
|
332
|
+
(node?.textContent || '').toLowerCase(),
|
|
333
|
+
);
|
|
334
|
+
if (filesPills.some((text) => /\bfiles\b/.test(text))) {
|
|
335
|
+
return { found: true, text: filesPills.find((t) => /\bfiles\b/.test(t)) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// As a last resort, treat file inputs that hold the target name as anchored. Some UIs delay chip rendering.
|
|
339
|
+
const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
|
|
340
|
+
Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
|
|
341
|
+
);
|
|
342
|
+
if (inputHit) {
|
|
343
|
+
return { found: true, text: 'input-only' };
|
|
344
|
+
}
|
|
345
|
+
return { found: false };
|
|
127
346
|
})()`;
|
|
128
347
|
while (Date.now() < deadline) {
|
|
129
348
|
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
130
|
-
|
|
131
|
-
if (matched) {
|
|
349
|
+
if (result?.value?.found) {
|
|
132
350
|
return true;
|
|
133
351
|
}
|
|
134
|
-
await delay(
|
|
352
|
+
await delay(200);
|
|
135
353
|
}
|
|
136
354
|
return false;
|
|
137
355
|
}
|
|
@@ -11,6 +11,7 @@ const ENTER_KEY_EVENT = {
|
|
|
11
11
|
const ENTER_KEY_TEXT = '\r';
|
|
12
12
|
export async function submitPrompt(deps, prompt, logger) {
|
|
13
13
|
const { runtime, input } = deps;
|
|
14
|
+
await waitForDomReady(runtime, logger);
|
|
14
15
|
const encodedPrompt = JSON.stringify(prompt);
|
|
15
16
|
const focusResult = await runtime.evaluate({
|
|
16
17
|
expression: `(() => {
|
|
@@ -89,7 +90,7 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
89
90
|
})()`,
|
|
90
91
|
});
|
|
91
92
|
}
|
|
92
|
-
const clicked = await attemptSendButton(runtime);
|
|
93
|
+
const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames);
|
|
93
94
|
if (!clicked) {
|
|
94
95
|
await input.dispatchKeyEvent({
|
|
95
96
|
type: 'keyDown',
|
|
@@ -109,7 +110,27 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
109
110
|
await verifyPromptCommitted(runtime, prompt, 30_000, logger);
|
|
110
111
|
await clickAnswerNowIfPresent(runtime, logger);
|
|
111
112
|
}
|
|
112
|
-
async function
|
|
113
|
+
async function waitForDomReady(Runtime, logger) {
|
|
114
|
+
const deadline = Date.now() + 10_000;
|
|
115
|
+
while (Date.now() < deadline) {
|
|
116
|
+
const { result } = await Runtime.evaluate({
|
|
117
|
+
expression: `(() => {
|
|
118
|
+
const ready = document.readyState === 'complete';
|
|
119
|
+
const composer = document.querySelector('[data-testid*="composer"]') || document.querySelector('form');
|
|
120
|
+
const fileInput = document.querySelector('input[type="file"]');
|
|
121
|
+
return { ready, composer: Boolean(composer), fileInput: Boolean(fileInput) };
|
|
122
|
+
})()`,
|
|
123
|
+
returnByValue: true,
|
|
124
|
+
});
|
|
125
|
+
const value = result?.value;
|
|
126
|
+
if (value?.ready && value.composer) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
await delay(150);
|
|
130
|
+
}
|
|
131
|
+
logger?.('Page did not reach ready/composer state within 10s; continuing cautiously.');
|
|
132
|
+
}
|
|
133
|
+
async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
113
134
|
const script = `(() => {
|
|
114
135
|
${buildClickDispatcher()}
|
|
115
136
|
const selectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
|
|
@@ -133,8 +154,31 @@ async function attemptSendButton(Runtime) {
|
|
|
133
154
|
dispatchClickSequence(button);
|
|
134
155
|
return 'clicked';
|
|
135
156
|
})()`;
|
|
136
|
-
const deadline = Date.now() +
|
|
157
|
+
const deadline = Date.now() + 8_000;
|
|
137
158
|
while (Date.now() < deadline) {
|
|
159
|
+
const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
|
|
160
|
+
if (needAttachment) {
|
|
161
|
+
const ready = await Runtime.evaluate({
|
|
162
|
+
expression: `(() => {
|
|
163
|
+
const names = ${JSON.stringify(attachmentNames.map((n) => n.toLowerCase()))};
|
|
164
|
+
const match = (n, name) => (n?.textContent || '').toLowerCase().includes(name);
|
|
165
|
+
const chipsReady = names.every((name) =>
|
|
166
|
+
Array.from(document.querySelectorAll('[data-testid*="chip"],[data-testid*="attachment"],a,div,span')).some((node) => match(node, name)),
|
|
167
|
+
);
|
|
168
|
+
const inputsReady = names.every((name) =>
|
|
169
|
+
Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
|
|
170
|
+
Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(name)),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
return chipsReady || inputsReady;
|
|
174
|
+
})()`,
|
|
175
|
+
returnByValue: true,
|
|
176
|
+
});
|
|
177
|
+
if (!ready?.result?.value) {
|
|
178
|
+
await delay(150);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
138
182
|
const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
|
|
139
183
|
if (result.value === 'clicked') {
|
|
140
184
|
return true;
|
|
@@ -32,7 +32,7 @@ export async function launchChrome(config, userDataDir, logger) {
|
|
|
32
32
|
logger(`Launched Chrome${pidLabel} on port ${launcher.port}${hostLabel}`);
|
|
33
33
|
return Object.assign(launcher, { host: connectHost ?? '127.0.0.1' });
|
|
34
34
|
}
|
|
35
|
-
export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logger) {
|
|
35
|
+
export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logger, opts) {
|
|
36
36
|
const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT'];
|
|
37
37
|
let handling;
|
|
38
38
|
const handleSignal = (signal) => {
|
|
@@ -40,14 +40,23 @@ export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logge
|
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
42
|
handling = true;
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
const inFlight = opts?.isInFlight?.() ?? false;
|
|
44
|
+
const leaveRunning = keepBrowser || inFlight;
|
|
45
|
+
if (leaveRunning) {
|
|
46
|
+
logger(`Received ${signal}; leaving Chrome running${inFlight ? ' (assistant response pending)' : ''}`);
|
|
45
47
|
}
|
|
46
48
|
else {
|
|
47
49
|
logger(`Received ${signal}; terminating Chrome process`);
|
|
48
50
|
}
|
|
49
51
|
void (async () => {
|
|
50
|
-
if (
|
|
52
|
+
if (leaveRunning) {
|
|
53
|
+
// Ensure reattach hints are written before we exit.
|
|
54
|
+
await opts?.emitRuntimeHint?.().catch(() => undefined);
|
|
55
|
+
if (inFlight) {
|
|
56
|
+
logger('Session still in flight; reattach with "oracle session <slug>" to continue.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
51
60
|
try {
|
|
52
61
|
await chrome.kill();
|
|
53
62
|
}
|
|
@@ -29,6 +29,9 @@ export const FILE_INPUT_SELECTORS = [
|
|
|
29
29
|
'input[type="file"][multiple]:not([accept])',
|
|
30
30
|
'input[type="file"][multiple]',
|
|
31
31
|
'input[type="file"]:not([accept])',
|
|
32
|
+
'form input[type="file"][accept]',
|
|
33
|
+
'input[type="file"][accept]',
|
|
34
|
+
'input[type="file"]',
|
|
32
35
|
'input[type="file"][data-testid*="file"]',
|
|
33
36
|
];
|
|
34
37
|
// Legacy single selectors kept for compatibility with older call-sites
|
|
@@ -48,11 +51,11 @@ export const UPLOAD_STATUS_SELECTORS = [
|
|
|
48
51
|
];
|
|
49
52
|
export const STOP_BUTTON_SELECTOR = '[data-testid="stop-button"]';
|
|
50
53
|
export const SEND_BUTTON_SELECTORS = [
|
|
51
|
-
'[data-testid="send-button"]',
|
|
52
|
-
'button[data-testid
|
|
53
|
-
'button[
|
|
54
|
-
'button[aria-label*="Send"]',
|
|
54
|
+
'button[data-testid="send-button"]',
|
|
55
|
+
'button[data-testid*="composer-send"]',
|
|
56
|
+
'form button[type="submit"]',
|
|
55
57
|
'button[type="submit"][data-testid*="send"]',
|
|
58
|
+
'button[aria-label*="Send"]',
|
|
56
59
|
];
|
|
57
60
|
export const SEND_BUTTON_SELECTOR = SEND_BUTTON_SELECTORS[0];
|
|
58
61
|
export const MODEL_BUTTON_SELECTOR = '[data-testid="model-switcher-dropdown-button"]';
|
|
@@ -98,7 +98,10 @@ export async function runBrowserMode(options) {
|
|
|
98
98
|
const chromeHost = chrome.host ?? '127.0.0.1';
|
|
99
99
|
let removeTerminationHooks = null;
|
|
100
100
|
try {
|
|
101
|
-
removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger
|
|
101
|
+
removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, {
|
|
102
|
+
isInFlight: () => runStatus !== 'complete',
|
|
103
|
+
emitRuntimeHint,
|
|
104
|
+
});
|
|
102
105
|
}
|
|
103
106
|
catch {
|
|
104
107
|
// ignore failure; cleanup still happens below
|
|
@@ -251,6 +254,7 @@ export async function runBrowserMode(options) {
|
|
|
251
254
|
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
252
255
|
logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
|
|
253
256
|
}
|
|
257
|
+
const attachmentNames = attachments.map((a) => path.basename(a.path));
|
|
254
258
|
if (attachments.length > 0) {
|
|
255
259
|
if (!DOM) {
|
|
256
260
|
throw new Error('Chrome DOM domain unavailable while uploading attachments.');
|
|
@@ -260,10 +264,10 @@ export async function runBrowserMode(options) {
|
|
|
260
264
|
await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger);
|
|
261
265
|
}
|
|
262
266
|
const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
|
|
263
|
-
await raceWithDisconnect(waitForAttachmentCompletion(Runtime, waitBudget, logger));
|
|
267
|
+
await raceWithDisconnect(waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger));
|
|
264
268
|
logger('All attachments uploaded');
|
|
265
269
|
}
|
|
266
|
-
await raceWithDisconnect(submitPrompt({ runtime: Runtime, input: Input }, promptText, logger));
|
|
270
|
+
await raceWithDisconnect(submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, promptText, logger));
|
|
267
271
|
stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
|
|
268
272
|
const answer = await raceWithDisconnect(waitForAssistantResponse(Runtime, config.timeoutMs, logger));
|
|
269
273
|
answerText = answer.text;
|
|
@@ -632,6 +636,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
632
636
|
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
633
637
|
logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
|
|
634
638
|
}
|
|
639
|
+
const attachmentNames = attachments.map((a) => path.basename(a.path));
|
|
635
640
|
if (attachments.length > 0) {
|
|
636
641
|
if (!DOM) {
|
|
637
642
|
throw new Error('Chrome DOM domain unavailable while uploading attachments.');
|
|
@@ -642,10 +647,10 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
642
647
|
await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
|
|
643
648
|
}
|
|
644
649
|
const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
|
|
645
|
-
await waitForAttachmentCompletion(Runtime, waitBudget, logger);
|
|
650
|
+
await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
|
|
646
651
|
logger('All attachments uploaded');
|
|
647
652
|
}
|
|
648
|
-
await submitPrompt({ runtime: Runtime, input: Input }, promptText, logger);
|
|
653
|
+
await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, promptText, logger);
|
|
649
654
|
stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
|
|
650
655
|
const answer = await waitForAssistantResponse(Runtime, config.timeoutMs, logger);
|
|
651
656
|
answerText = answer.text;
|
|
@@ -21,6 +21,8 @@ const PAGE_SIZE = 10;
|
|
|
21
21
|
export async function launchTui({ version, printIntro = true }) {
|
|
22
22
|
const userConfig = (await loadUserConfig()).config;
|
|
23
23
|
const rich = isTty();
|
|
24
|
+
let pagingFailures = 0;
|
|
25
|
+
let exitMessageShown = false;
|
|
24
26
|
if (printIntro) {
|
|
25
27
|
if (rich) {
|
|
26
28
|
console.log(chalk.bold('🧿 oracle'), `${version}`, dim('— Whispering your tokens to the silicon sage'));
|
|
@@ -76,15 +78,31 @@ export async function launchTui({ version, printIntro = true }) {
|
|
|
76
78
|
prompt
|
|
77
79
|
.then(({ selection: answer }) => resolve(answer))
|
|
78
80
|
.catch((error) => {
|
|
79
|
-
|
|
81
|
+
pagingFailures += 1;
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
if (message.includes('SIGINT') || message.includes('force closed the prompt')) {
|
|
84
|
+
console.log(chalk.green('🧿 Closing the book. See you next prompt.'));
|
|
85
|
+
exitMessageShown = true;
|
|
86
|
+
resolve('__exit__');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.error(chalk.red('Paging failed; returning to recent list.'), message);
|
|
90
|
+
if (message.includes('setRawMode') || message.includes('EIO') || pagingFailures >= 3) {
|
|
91
|
+
console.error(chalk.red('Terminal input unavailable; exiting TUI.'), dim('Try `stty sane` then rerun oracle, or use `oracle recent`.'));
|
|
92
|
+
resolve('__exit__');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
80
95
|
resolve('__reset__');
|
|
81
96
|
});
|
|
82
97
|
});
|
|
83
98
|
if (process.env.ORACLE_DEBUG_TUI === '1') {
|
|
84
99
|
console.error(`[tui] selection=${JSON.stringify(selection)}`);
|
|
85
100
|
}
|
|
101
|
+
pagingFailures = 0;
|
|
86
102
|
if (selection === '__exit__') {
|
|
87
|
-
|
|
103
|
+
if (!exitMessageShown) {
|
|
104
|
+
console.log(chalk.green('🧿 Closing the book. See you next prompt.'));
|
|
105
|
+
}
|
|
88
106
|
return;
|
|
89
107
|
}
|
|
90
108
|
if (selection === '__ask__') {
|
|
@@ -156,14 +174,26 @@ async function showSessionDetail(sessionId) {
|
|
|
156
174
|
...(isRunning ? [{ name: 'Refresh', value: 'refresh' }] : []),
|
|
157
175
|
{ name: 'Back', value: 'back' },
|
|
158
176
|
];
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
177
|
+
let next;
|
|
178
|
+
try {
|
|
179
|
+
({ next } = await inquirer.prompt([
|
|
180
|
+
{
|
|
181
|
+
name: 'next',
|
|
182
|
+
type: 'select',
|
|
183
|
+
message: 'Actions',
|
|
184
|
+
choices: actions,
|
|
185
|
+
},
|
|
186
|
+
]));
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
190
|
+
if (message.includes('SIGINT') || message.includes('force closed the prompt')) {
|
|
191
|
+
console.log(chalk.green('🧿 Closing the book. See you next prompt.'));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
console.error(chalk.red('Paging failed; returning to session list.'), message);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
167
197
|
if (next === 'back') {
|
|
168
198
|
return;
|
|
169
199
|
}
|
|
@@ -101,7 +101,7 @@ export function toTransportError(error, model) {
|
|
|
101
101
|
messageText.includes('does not exist') ||
|
|
102
102
|
messageText.includes('unknown model') ||
|
|
103
103
|
messageText.includes('model_not_found'))) {
|
|
104
|
-
return new OracleTransportError('model-unavailable', 'gpt-5.1-pro is not yet available on this API base.
|
|
104
|
+
return new OracleTransportError('model-unavailable', 'gpt-5.1-pro is not yet available on this API base. Using gpt-5-pro until OpenAI enables it. // TODO: Remove once gpt-5.1-pro is available', apiError);
|
|
105
105
|
}
|
|
106
106
|
if (apiError.status === 404 || apiError.status === 405) {
|
|
107
107
|
return new OracleTransportError('unsupported-endpoint', 'HTTP 404/405 from the Responses API; this base URL or gateway likely does not expose /v1/responses. Set OPENAI_BASE_URL to api.openai.com/v1, update your Azure API version/deployment, or use the browser engine.', apiError);
|
package/dist/src/oracle/run.js
CHANGED
|
@@ -186,14 +186,17 @@ export async function runOracle(options, deps = {}) {
|
|
|
186
186
|
: DEFAULT_TIMEOUT_NON_PRO_MS / 1000
|
|
187
187
|
: options.timeoutSeconds;
|
|
188
188
|
const timeoutMs = timeoutSeconds * 1000;
|
|
189
|
+
const apiModelFromConfig = modelConfig.apiModel ?? modelConfig.model;
|
|
190
|
+
const modelDowngraded = apiModelFromConfig === 'gpt-5.1-pro';
|
|
191
|
+
const resolvedApiModelId = modelDowngraded ? 'gpt-5-pro' : apiModelFromConfig;
|
|
189
192
|
// Track the concrete model id we dispatch to (especially for Gemini preview aliases)
|
|
190
193
|
const effectiveModelId = options.effectiveModelId ??
|
|
191
194
|
(options.model.startsWith('gemini')
|
|
192
195
|
? resolveGeminiModelId(options.model)
|
|
193
|
-
:
|
|
196
|
+
: resolvedApiModelId);
|
|
194
197
|
const headerModelLabel = richTty ? chalk.cyan(modelConfig.model) : modelConfig.model;
|
|
195
198
|
const requestBody = buildRequestBody({
|
|
196
|
-
modelConfig,
|
|
199
|
+
modelConfig: { ...modelConfig, apiModel: resolvedApiModelId },
|
|
197
200
|
systemPrompt,
|
|
198
201
|
userPrompt: promptWithFiles,
|
|
199
202
|
searchEnabled,
|
|
@@ -219,6 +222,9 @@ export async function runOracle(options, deps = {}) {
|
|
|
219
222
|
if (baseUrl) {
|
|
220
223
|
log(dim(`Base URL: ${formatBaseUrlForLog(baseUrl)}`));
|
|
221
224
|
}
|
|
225
|
+
if (modelDowngraded) {
|
|
226
|
+
log(dim('gpt-5.1-pro is not yet available via API; sending request with gpt-5-pro instead.'));
|
|
227
|
+
}
|
|
222
228
|
if (options.background && !supportsBackground) {
|
|
223
229
|
log(dim('Background runs are not supported for this model; streaming in foreground instead.'));
|
|
224
230
|
}
|
package/package.json
CHANGED