bloby-bot 0.46.3 → 0.47.1

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.
Files changed (30) hide show
  1. package/bin/cli.js +42 -8
  2. package/dist-bloby/assets/{bloby-BnHElaWD.js → bloby-E-QLmQDW.js} +4 -4
  3. package/dist-bloby/assets/globals-Ci0CEj1X.js +18 -0
  4. package/dist-bloby/assets/globals-DriF_8Q_.css +2 -0
  5. package/dist-bloby/assets/{highlighted-body-OFNGDK62-B4IKFiNq.js → highlighted-body-OFNGDK62-CTiboTVa.js} +1 -1
  6. package/dist-bloby/assets/mermaid-GHXKKRXX-CgVqYCFU.js +1 -0
  7. package/dist-bloby/assets/{onboard-DoRN5jiz.js → onboard-C1uMxuk2.js} +1 -1
  8. package/dist-bloby/bloby.html +3 -3
  9. package/dist-bloby/onboard.html +3 -3
  10. package/package.json +3 -2
  11. package/scripts/postinstall.js +19 -2
  12. package/scripts/sync-pi-models.ts +146 -0
  13. package/shared/config.ts +1 -1
  14. package/supervisor/bloby-agent.ts +2 -0
  15. package/supervisor/chat/OnboardWizard.tsx +327 -2
  16. package/supervisor/harnesses/pi/async-queue.ts +45 -0
  17. package/supervisor/harnesses/pi/auth-storage.ts +56 -0
  18. package/supervisor/harnesses/pi/index.ts +474 -0
  19. package/supervisor/harnesses/pi/models-catalog.generated.ts +579 -0
  20. package/supervisor/harnesses/pi/providers/stream-google.ts +156 -0
  21. package/supervisor/harnesses/pi/providers/stream.ts +21 -0
  22. package/supervisor/harnesses/pi/providers/types.ts +60 -0
  23. package/supervisor/harnesses/pi/session.ts +140 -0
  24. package/supervisor/harnesses/pi/sub-providers.ts +191 -0
  25. package/supervisor/harnesses/pi/test-completion.ts +196 -0
  26. package/supervisor/index.ts +6 -0
  27. package/worker/index.ts +86 -0
  28. package/dist-bloby/assets/globals-BYieEOqL.js +0 -18
  29. package/dist-bloby/assets/globals-BzeCWV3t.css +0 -2
  30. package/dist-bloby/assets/mermaid-GHXKKRXX-32SDjrR3.js +0 -1
@@ -46,6 +46,7 @@ const ACCESS_LABELS: Record<AccessMethod, string> = {
46
46
  /* ── Provider config ── */
47
47
 
48
48
  const PROVIDERS = [
49
+ { id: 'pi', name: 'Bloby', subtitle: 'Built on top of Pi — bring your own model', icon: '/bloby-icon-192.png' },
49
50
  { id: 'anthropic', name: 'Claude', subtitle: 'by Anthropic', icon: '/icons/claude.png' },
50
51
  { id: 'openai', name: 'OpenAI Codex', subtitle: 'ChatGPT Plus / Pro', icon: '/icons/codex.png' },
51
52
  ] as const;
@@ -171,8 +172,34 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
171
172
  const [authState, setAuthState] = useState<Record<string, 'idle' | 'authenticating' | 'connected'>>({
172
173
  anthropic: 'idle',
173
174
  openai: 'idle',
175
+ pi: 'idle',
174
176
  });
175
177
 
178
+ /* ── Bloby (pi) state ── */
179
+ interface PiSubProviderInfo {
180
+ id: string;
181
+ name: string;
182
+ subtitle: string;
183
+ flavor: string;
184
+ baseUrl?: string;
185
+ needsBaseUrl: boolean;
186
+ needsApiKey: boolean;
187
+ apiKeyUrl?: string;
188
+ models: { id: string; label: string }[] | 'dynamic';
189
+ defaultModel?: string;
190
+ }
191
+ const [piSubProviders, setPiSubProviders] = useState<PiSubProviderInfo[]>([]);
192
+ const [piSubProvider, setPiSubProvider] = useState<string>('');
193
+ const [piApiKey, setPiApiKey] = useState('');
194
+ const [piBaseUrl, setPiBaseUrl] = useState('');
195
+ const [piModelId, setPiModelId] = useState('');
196
+ const [piShowKey, setPiShowKey] = useState(false);
197
+ const [piConnecting, setPiConnecting] = useState(false);
198
+ const [piError, setPiError] = useState<string | undefined>();
199
+ const [piTestRunning, setPiTestRunning] = useState(false);
200
+ const [piTestResult, setPiTestResult] = useState<{ ok: boolean; text?: string; error?: string } | null>(null);
201
+ const [piSavedStatus, setPiSavedStatus] = useState<{ subProvider?: string; modelId?: string; baseUrl?: string } | null>(null);
202
+
176
203
  // Anthropic/Claude-specific
177
204
  const [oauthStarted, setOauthStarted] = useState(false);
178
205
  const [anthropicCode, setAnthropicCode] = useState('');
@@ -572,6 +599,130 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
572
599
  setCodexVerificationUrl('');
573
600
  setCodexFlow('device');
574
601
  setOpenaiError(undefined);
602
+ // Pi flow cleanup is per-sub-provider; the load effect handles re-hydration.
603
+ setPiError(undefined);
604
+ setPiTestResult(null);
605
+ };
606
+
607
+ /* ── Bloby (pi) handlers ── */
608
+
609
+ // Load the sub-provider catalog + saved status when the user picks Bloby
610
+ useEffect(() => {
611
+ if (provider !== 'pi') return;
612
+ let cancelled = false;
613
+ (async () => {
614
+ try {
615
+ const [provRes, statusRes] = await Promise.all([
616
+ fetch('/api/auth/pi/providers'),
617
+ fetch('/api/auth/pi/status'),
618
+ ]);
619
+ const provData = await provRes.json();
620
+ const statusData = await statusRes.json();
621
+ if (cancelled) return;
622
+ const list: PiSubProviderInfo[] = provData?.providers || [];
623
+ setPiSubProviders(list);
624
+ if (statusData?.configured) {
625
+ setPiSubProvider(statusData.subProvider || '');
626
+ setPiModelId(statusData.modelId || '');
627
+ setPiBaseUrl(statusData.baseUrl || '');
628
+ setAuthState((s) => ({ ...s, pi: 'connected' }));
629
+ setPiSavedStatus({ subProvider: statusData.subProvider, modelId: statusData.modelId, baseUrl: statusData.baseUrl });
630
+ setModel(`${statusData.subProvider}/${statusData.modelId || ''}`);
631
+ } else if (!piSubProvider && list[0]) {
632
+ setPiSubProvider(list[0].id);
633
+ setPiBaseUrl(list[0].baseUrl || '');
634
+ setPiModelId(list[0].defaultModel || '');
635
+ }
636
+ } catch (err: any) {
637
+ if (!cancelled) setPiError(err?.message || 'Failed to load Bloby providers');
638
+ }
639
+ })();
640
+ return () => { cancelled = true; };
641
+ }, [provider]);
642
+
643
+ const selectedPiSub = piSubProviders.find((p) => p.id === piSubProvider);
644
+
645
+ const choosePiSubProvider = (id: string) => {
646
+ const next = piSubProviders.find((p) => p.id === id);
647
+ setPiSubProvider(id);
648
+ setPiBaseUrl(next?.baseUrl || '');
649
+ setPiModelId(next?.defaultModel || '');
650
+ setPiError(undefined);
651
+ setPiTestResult(null);
652
+ // Picking a new sub-provider invalidates the previously saved auth.
653
+ if (authState.pi === 'connected') {
654
+ setAuthState((s) => ({ ...s, pi: 'idle' }));
655
+ setPiSavedStatus(null);
656
+ }
657
+ };
658
+
659
+ const handlePiConnect = async () => {
660
+ if (!piSubProvider) return;
661
+ setPiError(undefined);
662
+ setPiTestResult(null);
663
+ setPiConnecting(true);
664
+ try {
665
+ const payload = {
666
+ subProvider: piSubProvider,
667
+ apiKey: piApiKey.trim() || undefined,
668
+ baseUrl: piBaseUrl.trim() || undefined,
669
+ modelId: piModelId.trim() || undefined,
670
+ };
671
+ const testRes = await fetch('/api/auth/pi/test', {
672
+ method: 'POST',
673
+ headers: { 'Content-Type': 'application/json' },
674
+ body: JSON.stringify(payload),
675
+ });
676
+ const testData = await testRes.json();
677
+ if (!testData?.ok) {
678
+ setPiError(testData?.error || 'Connection test failed');
679
+ return;
680
+ }
681
+ const saveRes = await fetch('/api/auth/pi/save', {
682
+ method: 'POST',
683
+ headers: { 'Content-Type': 'application/json' },
684
+ body: JSON.stringify(payload),
685
+ });
686
+ const saveData = await saveRes.json();
687
+ if (!saveData?.ok) {
688
+ setPiError(saveData?.error || 'Failed to save credentials');
689
+ return;
690
+ }
691
+ setAuthState((s) => ({ ...s, pi: 'connected' }));
692
+ setPiSavedStatus(saveData.status || null);
693
+ setModel(`${piSubProvider}/${piModelId || selectedPiSub?.defaultModel || ''}`);
694
+ setPiApiKey('');
695
+ } catch (err: any) {
696
+ setPiError(err?.message || 'Connection failed');
697
+ } finally {
698
+ setPiConnecting(false);
699
+ }
700
+ };
701
+
702
+ const handlePiDisconnect = async () => {
703
+ try { await fetch('/api/auth/pi', { method: 'DELETE' }); } catch {}
704
+ setAuthState((s) => ({ ...s, pi: 'idle' }));
705
+ setPiSavedStatus(null);
706
+ setPiTestResult(null);
707
+ setModel('');
708
+ };
709
+
710
+ const handlePiTestCompletion = async () => {
711
+ setPiTestRunning(true);
712
+ setPiTestResult(null);
713
+ try {
714
+ const res = await fetch('/api/auth/pi/completion', {
715
+ method: 'POST',
716
+ headers: { 'Content-Type': 'application/json' },
717
+ body: JSON.stringify({}),
718
+ });
719
+ const data = await res.json();
720
+ setPiTestResult({ ok: !!data?.ok, text: data?.text, error: data?.error });
721
+ } catch (err: any) {
722
+ setPiTestResult({ ok: false, error: err?.message || 'Request failed' });
723
+ } finally {
724
+ setPiTestRunning(false);
725
+ }
575
726
  };
576
727
 
577
728
  /* ── Auth handlers: Anthropic/Claude ── */
@@ -2090,6 +2241,180 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
2090
2241
 
2091
2242
  <div className="border-t border-white/[0.06] mt-4 mb-3" />
2092
2243
 
2244
+ {/* ── Auth flow: Bloby (pi) ── */}
2245
+ {provider === 'pi' && (
2246
+ <div className="space-y-3">
2247
+ {authState.pi === 'connected' && piSavedStatus ? (
2248
+ <>
2249
+ <div className="bg-emerald-500/8 border border-emerald-500/15 rounded-lg px-3.5 py-2.5">
2250
+ <p className="text-emerald-400/90 text-[12px]">
2251
+ Connected — {piSubProviders.find((p) => p.id === piSavedStatus.subProvider)?.name || piSavedStatus.subProvider}
2252
+ {piSavedStatus.modelId ? <> · <span className="font-mono">{piSavedStatus.modelId}</span></> : null}
2253
+ </p>
2254
+ </div>
2255
+
2256
+ <div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-3.5 space-y-2.5">
2257
+ <p className="text-[12px] text-white/55">
2258
+ Send a quick test request to confirm the model is reachable.
2259
+ </p>
2260
+ <button
2261
+ onClick={handlePiTestCompletion}
2262
+ disabled={piTestRunning}
2263
+ className="w-full py-2 px-4 bg-white/[0.06] hover:bg-white/[0.1] text-white text-[12px] font-medium rounded-xl transition-colors flex items-center justify-center gap-2 disabled:opacity-60"
2264
+ >
2265
+ {piTestRunning ? (
2266
+ <><LoaderCircle className="h-3.5 w-3.5 animate-spin" />Sending test request…</>
2267
+ ) : (
2268
+ <>Send test request<ArrowRight className="h-3.5 w-3.5 opacity-60" /></>
2269
+ )}
2270
+ </button>
2271
+ {piTestResult && piTestResult.ok && (
2272
+ <div className="bg-emerald-500/5 border border-emerald-500/15 rounded-lg px-3 py-2">
2273
+ <p className="text-[11px] text-emerald-400/70 mb-1">Model reply</p>
2274
+ <p className="text-[12px] text-white/85 whitespace-pre-wrap">{piTestResult.text}</p>
2275
+ </div>
2276
+ )}
2277
+ {piTestResult && !piTestResult.ok && (
2278
+ <div className="bg-red-500/8 border border-red-500/15 rounded-lg px-3 py-2">
2279
+ <p className="text-[12px] text-red-400/90">{piTestResult.error}</p>
2280
+ </div>
2281
+ )}
2282
+ </div>
2283
+
2284
+ <button
2285
+ onClick={handlePiDisconnect}
2286
+ className="w-full py-1.5 text-white/25 text-[11px] hover:text-white/40 transition-colors flex items-center justify-center gap-1.5"
2287
+ >
2288
+ <RefreshCw className="h-3 w-3" />
2289
+ Use a different model
2290
+ </button>
2291
+ </>
2292
+ ) : (
2293
+ <>
2294
+ <div>
2295
+ <p className="text-[12px] text-white/40 mb-2">Pick an LLM provider — Bloby brings your own model.</p>
2296
+ <div className="grid grid-cols-2 gap-2 max-h-[320px] overflow-y-auto pr-1">
2297
+ {piSubProviders.map((sp) => (
2298
+ <button
2299
+ key={sp.id}
2300
+ type="button"
2301
+ onClick={() => choosePiSubProvider(sp.id)}
2302
+ className={`relative text-left rounded-xl border px-3 py-2.5 transition-colors ${
2303
+ piSubProvider === sp.id
2304
+ ? 'border-[#AF27E3]/50 bg-[#AF27E3]/10'
2305
+ : 'border-white/[0.08] bg-white/[0.02] hover:bg-white/[0.04]'
2306
+ }`}
2307
+ >
2308
+ <p className="text-[12.5px] text-white font-medium leading-tight">{sp.name}</p>
2309
+ <p className="text-[10.5px] text-white/40 mt-0.5 leading-tight">{sp.subtitle}</p>
2310
+ {piSubProvider === sp.id && (
2311
+ <div className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-gradient-brand" />
2312
+ )}
2313
+ </button>
2314
+ ))}
2315
+ </div>
2316
+ </div>
2317
+
2318
+ {selectedPiSub && (
2319
+ <div className="space-y-2 pt-1">
2320
+ {(selectedPiSub.needsBaseUrl || selectedPiSub.flavor === 'openai-completions') && (
2321
+ <div>
2322
+ <label className="text-[11px] text-white/40 font-medium mb-1 block">
2323
+ Base URL{selectedPiSub.needsBaseUrl ? '' : ' (override — optional)'}
2324
+ </label>
2325
+ <input
2326
+ type="text"
2327
+ value={piBaseUrl}
2328
+ onChange={(e) => setPiBaseUrl(e.target.value)}
2329
+ placeholder={selectedPiSub.baseUrl || 'https://example.com/v1'}
2330
+ className={inputSmCls + ' font-mono'}
2331
+ />
2332
+ </div>
2333
+ )}
2334
+
2335
+ {selectedPiSub.needsApiKey && (
2336
+ <div>
2337
+ <label className="text-[11px] text-white/40 font-medium mb-1 block flex items-center justify-between">
2338
+ <span>API key</span>
2339
+ {selectedPiSub.apiKeyUrl && (
2340
+ <button
2341
+ type="button"
2342
+ onClick={() => openExternal(selectedPiSub.apiKeyUrl!)}
2343
+ className="text-white/30 hover:text-white/60 text-[10.5px] flex items-center gap-1"
2344
+ >
2345
+ Get a key <ExternalLink className="h-3 w-3" />
2346
+ </button>
2347
+ )}
2348
+ </label>
2349
+ <div className="relative">
2350
+ <input
2351
+ type={piShowKey ? 'text' : 'password'}
2352
+ value={piApiKey}
2353
+ onChange={(e) => setPiApiKey(e.target.value)}
2354
+ onKeyDown={(e) => e.key === 'Enter' && handlePiConnect()}
2355
+ placeholder="sk-..."
2356
+ className={inputSmCls + ' pr-10 font-mono'}
2357
+ />
2358
+ <button
2359
+ type="button"
2360
+ onClick={() => setPiShowKey((v) => !v)}
2361
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-white/20 hover:text-white/50 transition-colors"
2362
+ >
2363
+ {piShowKey ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
2364
+ </button>
2365
+ </div>
2366
+ </div>
2367
+ )}
2368
+
2369
+ <div>
2370
+ <label className="text-[11px] text-white/40 font-medium mb-1 block">Model</label>
2371
+ {Array.isArray(selectedPiSub.models) ? (
2372
+ <ModelDropdown
2373
+ models={selectedPiSub.models}
2374
+ value={piModelId}
2375
+ onChange={setPiModelId}
2376
+ />
2377
+ ) : (
2378
+ <input
2379
+ type="text"
2380
+ value={piModelId}
2381
+ onChange={(e) => setPiModelId(e.target.value)}
2382
+ placeholder={selectedPiSub.defaultModel || 'model-id'}
2383
+ className={inputSmCls + ' font-mono'}
2384
+ />
2385
+ )}
2386
+ </div>
2387
+
2388
+ {piError && (
2389
+ <div className="bg-red-500/8 border border-red-500/15 rounded-lg px-3 py-2">
2390
+ <p className="text-[12px] text-red-400/90 break-words">{piError}</p>
2391
+ </div>
2392
+ )}
2393
+
2394
+ <button
2395
+ onClick={handlePiConnect}
2396
+ disabled={
2397
+ piConnecting ||
2398
+ !piSubProvider ||
2399
+ (selectedPiSub.needsApiKey && !piApiKey.trim()) ||
2400
+ (selectedPiSub.needsBaseUrl && !piBaseUrl.trim()) ||
2401
+ !piModelId.trim()
2402
+ }
2403
+ className="w-full py-2.5 px-4 bg-gradient-brand hover:opacity-90 text-white text-[13px] font-medium rounded-xl transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
2404
+ >
2405
+ {piConnecting ? (
2406
+ <><LoaderCircle className="h-3.5 w-3.5 animate-spin" />Connecting…</>
2407
+ ) : (
2408
+ <>Test &amp; connect<ArrowRight className="h-3.5 w-3.5 opacity-60" /></>
2409
+ )}
2410
+ </button>
2411
+ </div>
2412
+ )}
2413
+ </>
2414
+ )}
2415
+ </div>
2416
+ )}
2417
+
2093
2418
  {/* ── Auth flow: Anthropic ── */}
2094
2419
  {provider === 'anthropic' && (
2095
2420
  <div className="space-y-2.5">
@@ -2363,8 +2688,8 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
2363
2688
  </div>
2364
2689
  )}
2365
2690
 
2366
- {/* ── Model dropdown (after auth) ── */}
2367
- {isConnected && (
2691
+ {/* ── Model dropdown (after auth) — pi flow renders its own model picker inside its block ── */}
2692
+ {isConnected && provider !== 'pi' && (
2368
2693
  <>
2369
2694
  <div className="border-t border-white/[0.06] mt-4 mb-3" />
2370
2695
  <label className="text-[12px] text-white/40 font-medium mb-1.5 block">
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Async input queue — copy of the helper from `harnesses/claude.ts:37-68`.
3
+ *
4
+ * The Claude harness uses this exact shape as the input prompt to the Claude
5
+ * Agent SDK's long-lived `query()`. The pi session loop uses the same pattern
6
+ * so the non-blocking live-conversation behavior matches Claude byte-for-byte
7
+ * at the queue level: pushMessage() never awaits the model.
8
+ */
9
+ export interface AsyncQueue<T> extends AsyncIterable<T> {
10
+ push(item: T): void;
11
+ end(): void;
12
+ }
13
+
14
+ export function createAsyncQueue<T>(): AsyncQueue<T> {
15
+ const pending: T[] = [];
16
+ let resolve: ((value: IteratorResult<T>) => void) | null = null;
17
+ let done = false;
18
+
19
+ return {
20
+ push(item: T) {
21
+ if (done) return;
22
+ if (resolve) {
23
+ resolve({ value: item, done: false });
24
+ resolve = null;
25
+ } else {
26
+ pending.push(item);
27
+ }
28
+ },
29
+ end() {
30
+ done = true;
31
+ if (resolve) resolve({ value: undefined as any, done: true });
32
+ },
33
+ [Symbol.asyncIterator]() {
34
+ return {
35
+ next(): Promise<IteratorResult<T>> {
36
+ if (pending.length > 0) {
37
+ return Promise.resolve({ value: pending.shift()!, done: false });
38
+ }
39
+ if (done) return Promise.resolve({ value: undefined as any, done: true });
40
+ return new Promise((r) => { resolve = r; });
41
+ },
42
+ };
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pi auth storage — persistent credentials for the Bloby (pi) harness.
3
+ *
4
+ * Stored in ~/.bloby/pi-auth.json (separate from the main config.json so we
5
+ * can wipe/rotate the LLM credentials without touching the rest of the bot
6
+ * config). Iteration 1: a single active sub-provider at a time.
7
+ */
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { DATA_DIR } from '../../../shared/paths.js';
11
+
12
+ export interface PiAuth {
13
+ subProvider: string;
14
+ apiKey?: string;
15
+ baseUrl?: string;
16
+ modelId?: string;
17
+ savedAt: number;
18
+ }
19
+
20
+ const PI_AUTH_PATH = path.join(DATA_DIR, 'pi-auth.json');
21
+
22
+ export function readPiAuth(): PiAuth | null {
23
+ try {
24
+ if (!fs.existsSync(PI_AUTH_PATH)) return null;
25
+ const raw = fs.readFileSync(PI_AUTH_PATH, 'utf-8');
26
+ const parsed = JSON.parse(raw);
27
+ if (!parsed?.subProvider) return null;
28
+ return parsed as PiAuth;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export function writePiAuth(auth: Omit<PiAuth, 'savedAt'>): PiAuth {
35
+ fs.mkdirSync(DATA_DIR, { recursive: true });
36
+ const full: PiAuth = { ...auth, savedAt: Date.now() };
37
+ fs.writeFileSync(PI_AUTH_PATH, JSON.stringify(full, null, 2), { mode: 0o600 });
38
+ return full;
39
+ }
40
+
41
+ export function clearPiAuth(): void {
42
+ try {
43
+ fs.rmSync(PI_AUTH_PATH, { force: true });
44
+ } catch {}
45
+ }
46
+
47
+ export function getPiAuthStatus(): { configured: boolean; subProvider?: string; modelId?: string; baseUrl?: string } {
48
+ const auth = readPiAuth();
49
+ if (!auth) return { configured: false };
50
+ return {
51
+ configured: true,
52
+ subProvider: auth.subProvider,
53
+ modelId: auth.modelId,
54
+ baseUrl: auth.baseUrl,
55
+ };
56
+ }