openzoo 0.51.6 → 0.51.7
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 +2 -0
- package/bin/openzoo.js +2 -0
- package/lib/chatgpt-spend.js +185 -0
- package/lib/chatgpt.js +12 -3
- package/lib/ozSpendChip.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,8 @@ Configuration defaults to `~/.openzoo/chatgpt/config.toml`, with a separate desk
|
|
|
35
35
|
|
|
36
36
|
`--model <id>` overrides `deepseek-v4-flash`; `--bearer` stores the non-secret placeholder token instead of requiring an environment variable. `--no-launch` writes config only (no download, install, proxy, or launch); `--no-proxy` skips proxy startup; `--print` previews without side effects. Pass app flags after `--`, for example `openzoo chatgpt -- --ozone-platform=wayland`. `OPENZOO_CHATGPT_BIN` selects an explicit executable. Failed downloads, installation, or launch commands return an error.
|
|
37
37
|
|
|
38
|
+
The **savings pill** is enabled by default: an orange, draggable overlay shows spent and saved, with estimated direct cost in its expanded view. These are **all-time shared proxy totals**, not per-task ChatGPT billing; ordinary ChatGPT chats are excluded. Negative savings appear as extra cost, and unavailable totals are marked disconnected. The bridge reads `/v1/info` outside the renderer and updates only its own overlay, without modifying the signed app or its security policy. It uses a loopback-only, dynamically assigned Chromium debugging port on the OpenZoo desktop profile, exits after the app is gone for 60 seconds, and logs to `<CODEX_HOME>/desktop/openzoo-savings.log`. Use `--no-pill` to launch without the overlay/debugging flag. Run the command again to attach the pill after a separate app restart.
|
|
39
|
+
|
|
38
40
|
Installer references: [ChatGPT downloads](https://chatgpt.com/download/), [Windows MSIX deployment](https://learn.chatgpt.com/docs/enterprise/windows-deployment), [Linux packages](https://learn.chatgpt.com/docs/linux/linux-app).
|
|
39
41
|
|
|
40
42
|
|
package/bin/openzoo.js
CHANGED
|
@@ -155,6 +155,8 @@ usage:
|
|
|
155
155
|
Choose "sign in with API key" (sk-openzoo) if prompted.
|
|
156
156
|
--deb <file> (Linux) --config /path/to/config.toml
|
|
157
157
|
--model <id> (deepseek-v4-flash) --wire responses|chat --bearer
|
|
158
|
+
Savings pill: live shared proxy spend / direct estimate / saved
|
|
159
|
+
--no-pill disables the overlay
|
|
158
160
|
--no-launch (config only) --no-proxy --tunnel --print
|
|
159
161
|
-- <app flags>
|
|
160
162
|
npx openzoo openclaw write the zoo into ~/.openclaw/openclaw.json as a model
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** Read-only savings overlay for the OpenZoo ChatGPT profile. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { cdpSession, listCdpPages } from './ozSpendChip.js';
|
|
7
|
+
|
|
8
|
+
export function chatGptSpendState(info) {
|
|
9
|
+
if (info?.youAreTalkingTo !== 'openzoo proxy') throw new Error('not an OpenZoo proxy');
|
|
10
|
+
const amount = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
11
|
+
const spent = amount(info.spendUsd), would = amount(info.directUsd);
|
|
12
|
+
const comparable = spent !== null && would !== null && (would > 0 || spent === 0);
|
|
13
|
+
// Do not use savedUsd: older proxies clamp negative savings to zero.
|
|
14
|
+
const saved = comparable ? would - spent : null;
|
|
15
|
+
return { spent, would: comparable ? would : null, saved,
|
|
16
|
+
pct: comparable && would > 0 ? 100 * saved / would : null,
|
|
17
|
+
scope: 'All-time · shared OpenZoo proxy', updatedAt: Date.now(), online: true };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Self-contained renderer. Only owns its shadow-root overlay, never chat content. */
|
|
21
|
+
export function renderChatGptSpend(state) {
|
|
22
|
+
let host = document.getElementById('openzoo-chatgpt-spend');
|
|
23
|
+
if (!document.body) return false;
|
|
24
|
+
if (!host) {
|
|
25
|
+
host = document.createElement('div');
|
|
26
|
+
host.id = 'openzoo-chatgpt-spend';
|
|
27
|
+
host.style.cssText = 'position:fixed;right:20px;bottom:90px;z-index:2147483646;max-width:calc(100vw - 24px);-webkit-app-region:no-drag';
|
|
28
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
29
|
+
root.innerHTML = `<style>
|
|
30
|
+
:host{font:12px/1.5 system-ui,sans-serif;color:#fafafa;color-scheme:dark}
|
|
31
|
+
details{max-width:min(470px,calc(100vw - 24px))}
|
|
32
|
+
summary{list-style:none;cursor:grab;touch-action:none;border-radius:999px;background:#e85d1c;color:white;padding:8px 14px;box-shadow:0 4px 18px #0006;font-weight:650;overflow-wrap:anywhere}
|
|
33
|
+
summary::-webkit-details-marker{display:none}summary:focus-visible{outline:2px solid white;outline-offset:3px}
|
|
34
|
+
section{margin-top:8px;padding:12px 14px;border:1px solid #ffffff24;border-radius:14px;background:#18181bef;box-shadow:0 4px 18px #0005}
|
|
35
|
+
dl{display:grid;grid-template-columns:1fr auto;gap:5px 24px;margin:10px 0}dd{margin:0;font-variant-numeric:tabular-nums}
|
|
36
|
+
small{display:block;color:#bfbfc7}p{margin:8px 0 0;color:#bfbfc7}button{margin-top:10px;border:1px solid #ffffff30;border-radius:8px;background:transparent;color:inherit;padding:4px 10px;cursor:pointer}
|
|
37
|
+
</style><details><summary role="button" aria-expanded="false" aria-label="OpenZoo costs and savings"></summary><section>
|
|
38
|
+
<small data-scope></small><dl><dt>Spent</dt><dd data-spent></dd><dt>Would’ve cost · estimate</dt><dd data-would></dd><dt data-saving-label>Saved</dt><dd data-saved></dd></dl>
|
|
39
|
+
<small data-status role="status"></small><p>Direct cost is the proxy’s counterfactual estimate. Totals include other apps using this proxy. Ordinary ChatGPT chats are not included.</p>
|
|
40
|
+
<button type="button">Reset position</button></section></details>`;
|
|
41
|
+
const summary = root.querySelector('summary');
|
|
42
|
+
let drag = null, moved = false;
|
|
43
|
+
const place = (left, top) => {
|
|
44
|
+
const r = host.getBoundingClientRect();
|
|
45
|
+
host.style.left = Math.max(8, Math.min(left, innerWidth - r.width - 8)) + 'px';
|
|
46
|
+
host.style.top = Math.max(8, Math.min(top, innerHeight - r.height - 8)) + 'px';
|
|
47
|
+
host.style.right = host.style.bottom = 'auto';
|
|
48
|
+
};
|
|
49
|
+
document.body.appendChild(host);
|
|
50
|
+
try { const p = JSON.parse(localStorage.getItem('openzoo-chatgpt-spend-position')); if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) place(p.x, p.y); } catch { /* storage unavailable */ }
|
|
51
|
+
summary.addEventListener('pointerdown', e => {
|
|
52
|
+
if (e.button !== 0) return;
|
|
53
|
+
const r = host.getBoundingClientRect();
|
|
54
|
+
drag = { x: e.clientX, y: e.clientY, left: r.left, top: r.top }; moved = false;
|
|
55
|
+
summary.setPointerCapture(e.pointerId);
|
|
56
|
+
});
|
|
57
|
+
summary.addEventListener('pointermove', e => {
|
|
58
|
+
if (!drag) return;
|
|
59
|
+
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
|
|
60
|
+
if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
|
|
61
|
+
if (moved) place(drag.left + dx, drag.top + dy);
|
|
62
|
+
});
|
|
63
|
+
summary.addEventListener('pointerup', () => {
|
|
64
|
+
if (drag && moved) { const r = host.getBoundingClientRect(); try { localStorage.setItem('openzoo-chatgpt-spend-position', JSON.stringify({ x: r.left, y: r.top })); } catch {} }
|
|
65
|
+
drag = null;
|
|
66
|
+
});
|
|
67
|
+
summary.addEventListener('pointercancel', () => { drag = null; });
|
|
68
|
+
summary.addEventListener('click', e => { if (moved) { e.preventDefault(); moved = false; } });
|
|
69
|
+
root.querySelector('button').addEventListener('click', () => {
|
|
70
|
+
try { localStorage.removeItem('openzoo-chatgpt-spend-position'); } catch {}
|
|
71
|
+
host.style.left = host.style.top = 'auto'; host.style.right = '20px'; host.style.bottom = '90px';
|
|
72
|
+
});
|
|
73
|
+
root.querySelector('details').addEventListener('toggle', () => { summary.setAttribute('aria-expanded', String(root.querySelector('details').open)); if (host.style.left !== 'auto' && host.style.left) { const r = host.getBoundingClientRect(); place(r.left, r.top); } });
|
|
74
|
+
addEventListener('resize', () => { if (host.isConnected && host.style.left !== 'auto' && host.style.left) { const r = host.getBoundingClientRect(); place(r.left, r.top); } });
|
|
75
|
+
// A dead bridge must not leave apparently live numbers indefinitely.
|
|
76
|
+
const timer = setInterval(() => {
|
|
77
|
+
if (!host.isConnected) { clearInterval(timer); return; }
|
|
78
|
+
if (Date.now() - (host.__ozUpdate || 0) > 12000) {
|
|
79
|
+
root.querySelector('[data-status]').textContent = 'Disconnected · last known totals';
|
|
80
|
+
root.querySelector('summary').textContent = 'OpenZoo · disconnected';
|
|
81
|
+
}
|
|
82
|
+
}, 3000);
|
|
83
|
+
}
|
|
84
|
+
const root = host.shadowRoot;
|
|
85
|
+
if (!root) return false;
|
|
86
|
+
const usd = n => n === null || !Number.isFinite(n) ? '—' : '$' + Math.abs(n).toFixed(Math.abs(n) < 0.01 ? 4 : 2);
|
|
87
|
+
const more = state.saved !== null && state.saved < 0;
|
|
88
|
+
const savings = state.saved === null ? 'Savings —' : `${more ? 'Extra cost' : 'Saved'} ${usd(state.saved)}`;
|
|
89
|
+
root.querySelector('summary').textContent = state.online ? `OpenZoo · ${usd(state.spent)} spent · ${savings}` : 'OpenZoo · disconnected';
|
|
90
|
+
root.querySelector('[data-spent]').textContent = usd(state.spent);
|
|
91
|
+
root.querySelector('[data-would]').textContent = usd(state.would);
|
|
92
|
+
root.querySelector('[data-saving-label]').textContent = more ? 'Extra cost' : 'Saved';
|
|
93
|
+
root.querySelector('[data-saved]').textContent = usd(state.saved) + (state.pct === null ? '' : ` (${Math.abs(state.pct).toFixed(1)}%)`);
|
|
94
|
+
root.querySelector('[data-scope]').textContent = state.scope;
|
|
95
|
+
root.querySelector('[data-status]').textContent = state.online ? 'Updated ' + new Date(state.updatedAt).toLocaleTimeString() : 'Proxy unavailable · last known totals';
|
|
96
|
+
host.__ozUpdate = Date.now();
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function chatGptSpendSource(state) {
|
|
101
|
+
return `(${renderChatGptSpend.toString()})(${JSON.stringify(state).replace(/</g, '\\u003c')})`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function readChatGptDebugPort(profile) {
|
|
105
|
+
try {
|
|
106
|
+
const n = Number(fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').split(/\r?\n/)[0]);
|
|
107
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
|
|
108
|
+
} catch { return null; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isChatGptRenderer(target, port) {
|
|
112
|
+
try {
|
|
113
|
+
const ws = new URL(target.webSocketDebuggerUrl);
|
|
114
|
+
if (ws.protocol !== 'ws:' || ws.hostname !== '127.0.0.1' && ws.hostname !== 'localhost' || Number(ws.port) !== port) return false;
|
|
115
|
+
const url = new URL(target.url);
|
|
116
|
+
return target.type === 'page' && (url.protocol === 'file:' && /\/index\.html$/.test(url.pathname) || url.protocol === 'app:' && /codex|chatgpt/.test(url.hostname));
|
|
117
|
+
} catch { return false; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Host-side polling avoids changing the signed app or relaxing renderer CSP. */
|
|
121
|
+
export async function watchChatGptSpend({ profile, proxyPort, fetchImpl = fetch, connect = cdpSession,
|
|
122
|
+
sleep = ms => new Promise(r => setTimeout(r, ms)), stopped = () => false, log = console.error } = {}) {
|
|
123
|
+
const sessions = new Map();
|
|
124
|
+
let misses = 0;
|
|
125
|
+
let state = { spent: null, would: null, saved: null, pct: null, scope: 'All-time · shared OpenZoo proxy', online: false, updatedAt: 0 };
|
|
126
|
+
try {
|
|
127
|
+
while (!stopped() && misses < 20) {
|
|
128
|
+
const port = readChatGptDebugPort(profile);
|
|
129
|
+
const pages = port ? (await listCdpPages(port, fetchImpl)).filter(t => isChatGptRenderer(t, port)) : [];
|
|
130
|
+
misses = pages.length ? 0 : misses + 1;
|
|
131
|
+
const live = new Set(pages.map(t => t.webSocketDebuggerUrl));
|
|
132
|
+
for (const [id, session] of sessions) if (!live.has(id)) { session.close(); sessions.delete(id); }
|
|
133
|
+
try {
|
|
134
|
+
const r = await fetchImpl(`http://127.0.0.1:${proxyPort}/v1/info`, { signal: AbortSignal.timeout(2000) });
|
|
135
|
+
if (!r.ok) throw new Error(`proxy HTTP ${r.status}`);
|
|
136
|
+
state = chatGptSpendState(await r.json());
|
|
137
|
+
} catch { state = { ...state, online: false }; }
|
|
138
|
+
for (const page of pages) {
|
|
139
|
+
const id = page.webSocketDebuggerUrl;
|
|
140
|
+
try {
|
|
141
|
+
if (!sessions.has(id)) sessions.set(id, await connect(id, { timeoutMs: 2500, sendOrigin: false }));
|
|
142
|
+
const result = await sessions.get(id).send('Runtime.evaluate', { expression: chatGptSpendSource(state), returnByValue: true });
|
|
143
|
+
if (result.exceptionDetails) throw new Error('savings pill renderer evaluation failed');
|
|
144
|
+
} catch (e) { sessions.get(id)?.close(); sessions.delete(id); log(`openzoo: savings pill: ${e.message}`); }
|
|
145
|
+
}
|
|
146
|
+
await sleep(3000);
|
|
147
|
+
}
|
|
148
|
+
if (misses >= 20) log('openzoo: savings pill stopped: no ChatGPT renderer found for 60s');
|
|
149
|
+
} finally { for (const session of sessions.values()) session.close(); }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function startChatGptSpend({ profile, proxyPort, spawnImpl = spawn } = {}) {
|
|
153
|
+
fs.mkdirSync(profile, { recursive: true });
|
|
154
|
+
const fd = fs.openSync(path.join(profile, 'openzoo-savings.log'), 'a', 0o600);
|
|
155
|
+
try {
|
|
156
|
+
const child = spawnImpl(process.execPath, [fileURLToPath(import.meta.url), '--watch', profile, String(proxyPort)], { detached: true, stdio: ['ignore', fd, fd] });
|
|
157
|
+
await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
|
|
158
|
+
child.unref();
|
|
159
|
+
} finally { fs.closeSync(fd); }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One watcher per profile, including concurrent launcher invocations. */
|
|
163
|
+
export function claimSpendWatcher(profile) {
|
|
164
|
+
const lock = path.join(profile, 'openzoo-savings.lock');
|
|
165
|
+
try {
|
|
166
|
+
const pid = Number(fs.readFileSync(lock, 'utf8'));
|
|
167
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
168
|
+
try { process.kill(pid, 0); return null; } catch (e) { if (e.code !== 'ESRCH') return null; }
|
|
169
|
+
} else if (Date.now() - fs.statSync(lock).mtimeMs < 60000) return null;
|
|
170
|
+
fs.unlinkSync(lock);
|
|
171
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
172
|
+
let fd;
|
|
173
|
+
try { fd = fs.openSync(lock, 'wx', 0o600); } catch (e) { if (e.code === 'EEXIST') return null; throw e; }
|
|
174
|
+
fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd);
|
|
175
|
+
return () => { try { if (fs.readFileSync(lock, 'utf8') === String(process.pid)) fs.unlinkSync(lock); } catch {} };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--watch') {
|
|
179
|
+
const release = claimSpendWatcher(process.argv[3]);
|
|
180
|
+
if (release) {
|
|
181
|
+
process.once('exit', release);
|
|
182
|
+
watchChatGptSpend({ profile: process.argv[3], proxyPort: Number(process.argv[4]) })
|
|
183
|
+
.catch(e => { console.error(e.message); process.exitCode = 1; }).finally(release);
|
|
184
|
+
}
|
|
185
|
+
}
|
package/lib/chatgpt.js
CHANGED
|
@@ -11,6 +11,7 @@ import fs from 'node:fs';
|
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { config } from './config.js';
|
|
14
|
+
import { startChatGptSpend } from './chatgpt-spend.js';
|
|
14
15
|
import { installChatGpt, windowsChatGptApp, runCommand } from './chatgpt-install.js';
|
|
15
16
|
|
|
16
17
|
export const PROVIDER_ID = 'openzoo';
|
|
@@ -245,9 +246,9 @@ async function ensureProxy({ tunnel }) {
|
|
|
245
246
|
export async function setupChatGpt(argv = [], dependencies = {}) {
|
|
246
247
|
const { platform = process.platform, home = os.homedir(), env: sourceEnv = process.env,
|
|
247
248
|
resolve = resolveChatGptApp, install = installChatGpt, proxy = ensureProxy,
|
|
248
|
-
launch = launchChatGpt, findWindows = windowsChatGptApp } = dependencies;
|
|
249
|
+
launch = launchChatGpt, findWindows = windowsChatGptApp, overlay = startChatGptSpend } = dependencies;
|
|
249
250
|
const args = [...argv];
|
|
250
|
-
const opt = { model: null, deb: null, wireApi: 'responses', bearer: false, launch: true, proxy: true, tunnel: false, print: false, configPath: null, rest: [] };
|
|
251
|
+
const opt = { model: null, deb: null, wireApi: 'responses', bearer: false, launch: true, proxy: true, pill: true, tunnel: false, print: false, configPath: null, rest: [] };
|
|
251
252
|
while (args.length) {
|
|
252
253
|
const a = args.shift();
|
|
253
254
|
if (a === '--') { opt.rest.push(...args); break; }
|
|
@@ -261,6 +262,7 @@ export async function setupChatGpt(argv = [], dependencies = {}) {
|
|
|
261
262
|
else if (a === '--config') opt.configPath = value();
|
|
262
263
|
else if (a === '--bearer') opt.bearer = true;
|
|
263
264
|
else if (a === '--no-launch') opt.launch = false;
|
|
265
|
+
else if (a === '--no-pill') opt.pill = false;
|
|
264
266
|
else if (a === '--no-proxy') opt.proxy = false;
|
|
265
267
|
else if (a === '--tunnel') opt.tunnel = true;
|
|
266
268
|
else if (a === '--print') opt.print = true;
|
|
@@ -319,7 +321,14 @@ export async function setupChatGpt(argv = [], dependencies = {}) {
|
|
|
319
321
|
console.log(`launching ${app}`);
|
|
320
322
|
console.log(` Codex / agent mode → the zoo, paid per turn by x402. Choose "sign in with API key" (any key).`);
|
|
321
323
|
console.log(' the plain ChatGPT chat tab still talks to chatgpt.com — that half is not API-shaped.');
|
|
322
|
-
|
|
324
|
+
const appArgs = opt.pill ? ['--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', ...opt.rest] : opt.rest;
|
|
325
|
+
await launch(app, appArgs, { env, platform });
|
|
326
|
+
if (opt.pill) {
|
|
327
|
+
try {
|
|
328
|
+
await overlay({ profile: env.CODEX_ELECTRON_USER_DATA_PATH, proxyPort: config.port });
|
|
329
|
+
console.log('savings pill enabled · spent / estimated direct cost / saved · shared proxy totals');
|
|
330
|
+
} catch (e) { console.error(`openzoo: savings pill unavailable: ${e.message}`); }
|
|
331
|
+
}
|
|
323
332
|
return { file, model, app, launched: true };
|
|
324
333
|
}
|
|
325
334
|
|
package/lib/ozSpendChip.js
CHANGED
|
@@ -758,7 +758,7 @@ function decodeWsFrames(buf) {
|
|
|
758
758
|
return { frames, rest };
|
|
759
759
|
}
|
|
760
760
|
|
|
761
|
-
export function cdpSession(wsUrl, { timeoutMs = 8000 } = {}) {
|
|
761
|
+
export function cdpSession(wsUrl, { timeoutMs = 8000, sendOrigin = true } = {}) {
|
|
762
762
|
return new Promise((resolve, reject) => {
|
|
763
763
|
let u;
|
|
764
764
|
try { u = new URL(wsUrl); } catch (e) { reject(e); return; }
|
|
@@ -793,7 +793,7 @@ export function cdpSession(wsUrl, { timeoutMs = 8000 } = {}) {
|
|
|
793
793
|
+ 'Connection: Upgrade\r\n'
|
|
794
794
|
+ 'Sec-WebSocket-Version: 13\r\n'
|
|
795
795
|
+ `Sec-WebSocket-Key: ${key}\r\n`
|
|
796
|
-
+ `Origin: http://${u.host}\r\n`
|
|
796
|
+
+ (sendOrigin ? `Origin: http://${u.host}\r\n` : '')
|
|
797
797
|
+ '\r\n',
|
|
798
798
|
);
|
|
799
799
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.7",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|