@elvatis_com/openclaw-cli-bridge-elvatis 0.2.23 → 0.2.26

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.
@@ -0,0 +1,428 @@
1
+ /**
2
+ * grok-client.ts
3
+ *
4
+ * HTTP client that sends chat completion requests to grok.com's internal REST API
5
+ * using an authenticated browser session (cookies).
6
+ *
7
+ * Endpoint: POST https://grok.com/rest/app-chat/conversations/new
8
+ * Response: Server-Sent Events (SSE) stream
9
+ *
10
+ * This mimics what the grok.com web UI does internally.
11
+ */
12
+
13
+ import type { BrowserContext } from "playwright";
14
+
15
+ // ──────────────────────────────────────────────────────────────────────────────
16
+ // Types
17
+ // ──────────────────────────────────────────────────────────────────────────────
18
+
19
+ export interface ChatMessage {
20
+ role: "system" | "user" | "assistant";
21
+ content: string;
22
+ }
23
+
24
+ export interface GrokCompleteOptions {
25
+ messages: ChatMessage[];
26
+ model?: string; // "grok-3" | "grok-3-fast" | "grok-3-mini" | "grok-3-mini-fast"
27
+ stream?: boolean;
28
+ maxTokens?: number;
29
+ temperature?: number;
30
+ timeoutMs?: number;
31
+ }
32
+
33
+ export interface GrokCompleteResult {
34
+ content: string;
35
+ model: string;
36
+ finishReason: string;
37
+ /** estimated — grok.com doesn't expose exact token counts */
38
+ promptTokens?: number;
39
+ completionTokens?: number;
40
+ }
41
+
42
+ /** SSE token event from grok.com */
43
+ interface GrokTokenEvent {
44
+ result?: {
45
+ response?: {
46
+ token?: string;
47
+ finalMetadata?: {
48
+ inputTokenCount?: number;
49
+ outputTokenCount?: number;
50
+ };
51
+ modelResponse?: {
52
+ responseId?: string;
53
+ message?: string;
54
+ };
55
+ };
56
+ isSoftStop?: boolean;
57
+ };
58
+ error?: string;
59
+ }
60
+
61
+ // ──────────────────────────────────────────────────────────────────────────────
62
+ // Model ID mapping: OpenAI-style → grok.com internal IDs
63
+ // ──────────────────────────────────────────────────────────────────────────────
64
+
65
+ const MODEL_MAP: Record<string, string> = {
66
+ "grok-3": "grok-3",
67
+ "grok-3-fast": "grok-3-fast",
68
+ "grok-3-mini": "grok-3-mini",
69
+ "grok-3-mini-fast": "grok-3-mini-fast",
70
+ // aliases
71
+ "grok": "grok-3",
72
+ "grok-fast": "grok-3-fast",
73
+ "grok-mini": "grok-3-mini",
74
+ };
75
+
76
+ function resolveModel(model?: string): string {
77
+ if (!model) return "grok-3";
78
+ return MODEL_MAP[model] ?? model;
79
+ }
80
+
81
+ // ──────────────────────────────────────────────────────────────────────────────
82
+ // Request builder
83
+ // ──────────────────────────────────────────────────────────────────────────────
84
+
85
+ /** Build the request body for grok.com's internal API */
86
+ function buildRequestBody(opts: GrokCompleteOptions): Record<string, unknown> {
87
+ const model = resolveModel(opts.model);
88
+
89
+ // Combine messages into a single user prompt (grok.com web doesn't expose multi-turn directly)
90
+ // System prompt → prepended to first user message
91
+ const systemMsgs = opts.messages.filter((m) => m.role === "system");
92
+ const convMsgs = opts.messages.filter((m) => m.role !== "system");
93
+
94
+ let userPrompt = "";
95
+ if (systemMsgs.length > 0) {
96
+ userPrompt = systemMsgs.map((m) => m.content).join("\n") + "\n\n";
97
+ }
98
+
99
+ // Build conversation history for multi-turn
100
+ const history: Array<{ role: string; content: string }> = [];
101
+ for (let i = 0; i < convMsgs.length - 1; i++) {
102
+ history.push({ role: convMsgs[i].role, content: convMsgs[i].content });
103
+ }
104
+ const lastMsg = convMsgs[convMsgs.length - 1];
105
+ userPrompt += lastMsg?.content ?? "";
106
+
107
+ return {
108
+ temporary: false,
109
+ modelName: model,
110
+ message: userPrompt,
111
+ fileAttachments: [],
112
+ imageAttachments: [],
113
+ disableSearch: false,
114
+ enableImageGeneration: false,
115
+ returnImageBytes: false,
116
+ returnRawGrokInXaiRequest: false,
117
+ enableSideBySide: false,
118
+ isReasoning: model.includes("mini"), // mini models support reasoning
119
+ conversationHistory: history,
120
+ toolOverrides: {},
121
+ enableCustomization: false,
122
+ deepsearchPreset: "",
123
+ isPreset: false,
124
+ };
125
+ }
126
+
127
+ // ──────────────────────────────────────────────────────────────────────────────
128
+ // SSE parser
129
+ // ──────────────────────────────────────────────────────────────────────────────
130
+
131
+ function parseSSELine(line: string): GrokTokenEvent | null {
132
+ if (!line.startsWith("data: ")) return null;
133
+ const data = line.slice(6).trim();
134
+ if (data === "[DONE]") return null;
135
+ try {
136
+ return JSON.parse(data) as GrokTokenEvent;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ // ──────────────────────────────────────────────────────────────────────────────
143
+ // Main client function
144
+ // ──────────────────────────────────────────────────────────────────────────────
145
+
146
+ const GROK_API_URL = "https://grok.com/rest/app-chat/conversations/new";
147
+ const DEFAULT_TIMEOUT_MS = 120_000;
148
+
149
+ /**
150
+ * Complete a chat via grok.com's internal API using a browser session context.
151
+ * Uses page.evaluate to make the fetch from inside the authenticated browser context.
152
+ */
153
+ export async function grokComplete(
154
+ context: BrowserContext,
155
+ opts: GrokCompleteOptions,
156
+ log: (msg: string) => void
157
+ ): Promise<GrokCompleteResult> {
158
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
159
+ const model = resolveModel(opts.model);
160
+ const body = buildRequestBody(opts);
161
+
162
+ log(`grok-client: POST ${GROK_API_URL} model=${model}`);
163
+
164
+ // Open a background page in the authenticated context
165
+ const page = await context.newPage();
166
+
167
+ try {
168
+ // Navigate to grok.com first to ensure cookies are sent correctly
169
+ await page.goto("https://grok.com", {
170
+ waitUntil: "domcontentloaded",
171
+ timeout: 15_000,
172
+ });
173
+
174
+ // Make the API call from within the page (inherits cookies automatically)
175
+ const result = await page.evaluate(
176
+ async ({ url, requestBody, timeout }: { url: string; requestBody: unknown; timeout: number }) => {
177
+ const controller = new AbortController();
178
+ const timer = setTimeout(() => controller.abort(), timeout);
179
+
180
+ try {
181
+ const resp = await fetch(url, {
182
+ method: "POST",
183
+ headers: {
184
+ "Content-Type": "application/json",
185
+ Accept: "text/event-stream",
186
+ },
187
+ body: JSON.stringify(requestBody),
188
+ credentials: "include",
189
+ signal: controller.signal,
190
+ });
191
+
192
+ if (!resp.ok) {
193
+ const errText = await resp.text().catch(() => "");
194
+ return {
195
+ error: `HTTP ${resp.status}: ${errText.substring(0, 300)}`,
196
+ content: "",
197
+ };
198
+ }
199
+
200
+ const reader = resp.body!.getReader();
201
+ const decoder = new TextDecoder();
202
+ let fullText = "";
203
+ let buffer = "";
204
+ let inputTokens = 0;
205
+ let outputTokens = 0;
206
+ let finishReason = "stop";
207
+
208
+ while (true) {
209
+ const { done, value } = await reader.read();
210
+ if (done) break;
211
+
212
+ buffer += decoder.decode(value, { stream: true });
213
+ const lines = buffer.split("\n");
214
+ buffer = lines.pop() ?? "";
215
+
216
+ for (const line of lines) {
217
+ if (!line.startsWith("data: ")) continue;
218
+ const data = line.slice(6).trim();
219
+ if (data === "[DONE]") {
220
+ finishReason = "stop";
221
+ continue;
222
+ }
223
+ try {
224
+ const evt = JSON.parse(data);
225
+ const response = evt?.result?.response;
226
+ if (response?.token) {
227
+ fullText += response.token;
228
+ }
229
+ if (response?.finalMetadata) {
230
+ inputTokens = response.finalMetadata.inputTokenCount ?? 0;
231
+ outputTokens = response.finalMetadata.outputTokenCount ?? 0;
232
+ }
233
+ if (evt?.result?.isSoftStop) {
234
+ finishReason = "stop";
235
+ }
236
+ if (evt?.error) {
237
+ return { error: String(evt.error), content: fullText };
238
+ }
239
+ } catch {
240
+ // ignore parse errors on individual SSE lines
241
+ }
242
+ }
243
+ }
244
+
245
+ return {
246
+ content: fullText,
247
+ inputTokens,
248
+ outputTokens,
249
+ finishReason,
250
+ };
251
+ } finally {
252
+ clearTimeout(timer);
253
+ }
254
+ },
255
+ { url: GROK_API_URL, requestBody: body, timeout: timeoutMs }
256
+ );
257
+
258
+ if ("error" in result && result.error) {
259
+ throw new Error(`grok.com API error: ${result.error}`);
260
+ }
261
+
262
+ log(
263
+ `grok-client: done — ${result.outputTokens ?? "?"} output tokens`
264
+ );
265
+
266
+ return {
267
+ content: result.content ?? "",
268
+ model,
269
+ finishReason: result.finishReason ?? "stop",
270
+ promptTokens: result.inputTokens,
271
+ completionTokens: result.outputTokens,
272
+ };
273
+ } finally {
274
+ await page.close();
275
+ }
276
+ }
277
+
278
+ // ──────────────────────────────────────────────────────────────────────────────
279
+ // Streaming variant — yields tokens via callback
280
+ // ──────────────────────────────────────────────────────────────────────────────
281
+
282
+ export async function grokCompleteStream(
283
+ context: BrowserContext,
284
+ opts: GrokCompleteOptions,
285
+ onToken: (token: string) => void,
286
+ log: (msg: string) => void
287
+ ): Promise<GrokCompleteResult> {
288
+ // grok.com streams via SSE; we accumulate on the JS side and call onToken per chunk.
289
+ // Because page.evaluate can't stream back to Node, we use a polling approach:
290
+ // write tokens to window.__grokTokenBuf, poll from Node side.
291
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
292
+ const model = resolveModel(opts.model);
293
+ const body = buildRequestBody(opts);
294
+
295
+ log(`grok-client: streaming POST ${GROK_API_URL} model=${model}`);
296
+
297
+ const page = await context.newPage();
298
+
299
+ try {
300
+ await page.goto("https://grok.com", {
301
+ waitUntil: "domcontentloaded",
302
+ timeout: 15_000,
303
+ });
304
+
305
+ // Initialize token buffer on the page
306
+ await page.evaluate(() => {
307
+ (window as unknown as Record<string, unknown>).__grokTokenBuf = [];
308
+ (window as unknown as Record<string, unknown>).__grokDone = false;
309
+ (window as unknown as Record<string, unknown>).__grokError = null;
310
+ (window as unknown as Record<string, unknown>).__grokMeta = null;
311
+ });
312
+
313
+ // Start the fetch in the page (non-blocking — we poll from Node)
314
+ await page.evaluate(
315
+ async ({ url, requestBody, timeout }: { url: string; requestBody: unknown; timeout: number }) => {
316
+ const w = window as unknown as Record<string, unknown>;
317
+ const controller = new AbortController();
318
+ setTimeout(() => controller.abort(), timeout);
319
+
320
+ (async () => {
321
+ try {
322
+ const resp = await fetch(url, {
323
+ method: "POST",
324
+ headers: {
325
+ "Content-Type": "application/json",
326
+ Accept: "text/event-stream",
327
+ },
328
+ body: JSON.stringify(requestBody),
329
+ credentials: "include",
330
+ signal: controller.signal,
331
+ });
332
+
333
+ if (!resp.ok) {
334
+ const errText = await resp.text().catch(() => "");
335
+ w.__grokError = `HTTP ${resp.status}: ${errText.substring(0, 300)}`;
336
+ w.__grokDone = true;
337
+ return;
338
+ }
339
+
340
+ const reader = resp.body!.getReader();
341
+ const decoder = new TextDecoder();
342
+ let buffer = "";
343
+
344
+ while (true) {
345
+ const { done, value } = await reader.read();
346
+ if (done) break;
347
+ buffer += decoder.decode(value, { stream: true });
348
+ const lines = buffer.split("\n");
349
+ buffer = lines.pop() ?? "";
350
+
351
+ for (const line of lines) {
352
+ if (!line.startsWith("data: ")) continue;
353
+ const data = line.slice(6).trim();
354
+ if (data === "[DONE]") continue;
355
+ try {
356
+ const evt = JSON.parse(data);
357
+ const response = evt?.result?.response;
358
+ if (response?.token) {
359
+ (w.__grokTokenBuf as string[]).push(response.token);
360
+ }
361
+ if (response?.finalMetadata) {
362
+ w.__grokMeta = response.finalMetadata;
363
+ }
364
+ if (evt?.error) {
365
+ w.__grokError = String(evt.error);
366
+ }
367
+ } catch {
368
+ // ignore
369
+ }
370
+ }
371
+ }
372
+ } catch (e: unknown) {
373
+ w.__grokError = String(e);
374
+ } finally {
375
+ w.__grokDone = true;
376
+ }
377
+ })();
378
+ },
379
+ { url: GROK_API_URL, requestBody: body, timeout: timeoutMs }
380
+ );
381
+
382
+ // Poll the token buffer from Node side
383
+ let fullContent = "";
384
+ const pollInterval = 100; // ms
385
+ const deadline = Date.now() + timeoutMs;
386
+
387
+ while (Date.now() < deadline) {
388
+ const state = await page.evaluate(() => {
389
+ const w = window as unknown as Record<string, unknown>;
390
+ const tokens = (w.__grokTokenBuf as string[]).splice(0);
391
+ return {
392
+ tokens,
393
+ done: w.__grokDone as boolean,
394
+ error: w.__grokError as string | null,
395
+ meta: w.__grokMeta as { inputTokenCount?: number; outputTokenCount?: number } | null,
396
+ };
397
+ });
398
+
399
+ for (const token of state.tokens) {
400
+ onToken(token);
401
+ fullContent += token;
402
+ }
403
+
404
+ if (state.error) {
405
+ throw new Error(`grok.com stream error: ${state.error}`);
406
+ }
407
+
408
+ if (state.done) {
409
+ log(
410
+ `grok-client: stream done — ${state.meta?.outputTokenCount ?? "?"} tokens`
411
+ );
412
+ return {
413
+ content: fullContent,
414
+ model,
415
+ finishReason: "stop",
416
+ promptTokens: state.meta?.inputTokenCount,
417
+ completionTokens: state.meta?.outputTokenCount,
418
+ };
419
+ }
420
+
421
+ await new Promise((r) => setTimeout(r, pollInterval));
422
+ }
423
+
424
+ throw new Error(`grok.com stream timeout after ${timeoutMs}ms`);
425
+ } finally {
426
+ await page.close();
427
+ }
428
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * grok-session.ts
3
+ *
4
+ * Manages a persistent grok.com browser session using Playwright.
5
+ *
6
+ * Auth flow:
7
+ * 1. First run: open Chromium, navigate to grok.com → user logs in manually via X.com OAuth
8
+ * 2. On success: save cookies + localStorage to SESSION_PATH
9
+ * 3. Subsequent runs: restore session from file, verify still valid
10
+ * 4. If session expired: repeat step 1
11
+ *
12
+ * The saved session file is stored at ~/.openclaw/grok-session.json
13
+ */
14
+
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join, dirname } from "node:path";
18
+ import type { Browser, BrowserContext, Cookie } from "playwright";
19
+
20
+ export const DEFAULT_SESSION_PATH = join(
21
+ homedir(),
22
+ ".openclaw",
23
+ "grok-session.json"
24
+ );
25
+
26
+ export const GROK_HOME = "https://grok.com";
27
+ export const GROK_API_BASE = "https://grok.com/api";
28
+
29
+ /** Stored session data */
30
+ export interface GrokSession {
31
+ cookies: Cookie[];
32
+ savedAt: number; // epoch ms
33
+ userAgent?: string;
34
+ }
35
+
36
+ /** Result of a session check */
37
+ export interface SessionCheckResult {
38
+ valid: boolean;
39
+ reason?: string;
40
+ }
41
+
42
+ // ──────────────────────────────────────────────────────────────────────────────
43
+ // Persistence helpers
44
+ // ──────────────────────────────────────────────────────────────────────────────
45
+
46
+ export function loadSession(sessionPath: string): GrokSession | null {
47
+ if (!existsSync(sessionPath)) return null;
48
+ try {
49
+ const raw = readFileSync(sessionPath, "utf-8");
50
+ const parsed = JSON.parse(raw) as GrokSession;
51
+ if (!parsed.cookies || !Array.isArray(parsed.cookies)) return null;
52
+ return parsed;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export function saveSession(
59
+ sessionPath: string,
60
+ session: GrokSession
61
+ ): void {
62
+ mkdirSync(dirname(sessionPath), { recursive: true });
63
+ writeFileSync(sessionPath, JSON.stringify(session, null, 2), "utf-8");
64
+ }
65
+
66
+ export function deleteSession(sessionPath: string): void {
67
+ if (existsSync(sessionPath)) {
68
+ unlinkSync(sessionPath);
69
+ }
70
+ }
71
+
72
+ // ──────────────────────────────────────────────────────────────────────────────
73
+ // Session validation
74
+ // ──────────────────────────────────────────────────────────────────────────────
75
+
76
+ const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
77
+
78
+ export function isSessionExpiredByAge(session: GrokSession): boolean {
79
+ return Date.now() - session.savedAt > SESSION_MAX_AGE_MS;
80
+ }
81
+
82
+ /**
83
+ * Verify the session is still valid by making a lightweight API call.
84
+ * Returns {valid: true} if the session works, {valid: false, reason} otherwise.
85
+ */
86
+ export async function verifySession(
87
+ context: BrowserContext,
88
+ log: (msg: string) => void
89
+ ): Promise<SessionCheckResult> {
90
+ const page = await context.newPage();
91
+ try {
92
+ log("verifying grok session...");
93
+ await page.goto(GROK_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
94
+
95
+ // If we see Sign In link → not logged in
96
+ const signIn = page.locator('a[href*="sign-in"], a[href*="/login"]');
97
+ const signInVisible = await signIn.isVisible().catch(() => false);
98
+ if (signInVisible) {
99
+ return { valid: false, reason: "sign-in link visible — session expired" };
100
+ }
101
+
102
+ // If we see the chat input → logged in
103
+ const chatInput = page.locator('textarea, [placeholder*="mind"], [aria-label*="message"]');
104
+ const chatVisible = await chatInput.isVisible().catch(() => false);
105
+ if (chatVisible) {
106
+ log("session valid ✅");
107
+ return { valid: true };
108
+ }
109
+
110
+ // Ambiguous — try API endpoint
111
+ const resp = await page.evaluate(async () => {
112
+ try {
113
+ const r = await fetch("https://grok.com/rest/app-chat/conversations", {
114
+ method: "GET",
115
+ credentials: "include",
116
+ });
117
+ return { status: r.status };
118
+ } catch (e: unknown) {
119
+ return { status: 0, error: String(e) };
120
+ }
121
+ });
122
+
123
+ if (resp.status === 200 || resp.status === 204) {
124
+ log("session valid via API check ✅");
125
+ return { valid: true };
126
+ }
127
+ if (resp.status === 401 || resp.status === 403) {
128
+ return { valid: false, reason: `API returned ${resp.status}` };
129
+ }
130
+
131
+ log(`session check ambiguous (status ${resp.status}) — assuming valid`);
132
+ return { valid: true };
133
+ } finally {
134
+ await page.close();
135
+ }
136
+ }
137
+
138
+ // ──────────────────────────────────────────────────────────────────────────────
139
+ // Interactive login (opens visible browser window)
140
+ // ──────────────────────────────────────────────────────────────────────────────
141
+
142
+ export async function runInteractiveLogin(
143
+ browser: Browser,
144
+ sessionPath: string,
145
+ log: (msg: string) => void,
146
+ timeoutMs = 5 * 60 * 1000
147
+ ): Promise<GrokSession> {
148
+ log("opening browser for grok.com login — please sign in with your X account...");
149
+
150
+ const context = await browser.newContext({
151
+ viewport: { width: 1280, height: 800 },
152
+ });
153
+ const page = await context.newPage();
154
+
155
+ await page.goto(GROK_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
156
+
157
+ // Wait for sign-in to complete: look for chat textarea to appear
158
+ log(`waiting for login (timeout: ${timeoutMs / 1000}s)...`);
159
+ await page.waitForSelector(
160
+ 'textarea, [placeholder*="mind"], [aria-label*="message"]',
161
+ { timeout: timeoutMs }
162
+ );
163
+
164
+ log("login detected — saving session...");
165
+ const cookies = await context.cookies();
166
+ const userAgent = await page.evaluate(() => navigator.userAgent);
167
+
168
+ const session: GrokSession = {
169
+ cookies,
170
+ savedAt: Date.now(),
171
+ userAgent,
172
+ };
173
+
174
+ saveSession(sessionPath, session);
175
+ log(`session saved to ${sessionPath}`);
176
+
177
+ await context.close();
178
+ return session;
179
+ }
180
+
181
+ // ──────────────────────────────────────────────────────────────────────────────
182
+ // Context factory: create a BrowserContext with restored cookies
183
+ // ──────────────────────────────────────────────────────────────────────────────
184
+
185
+ export async function createContextFromSession(
186
+ browser: Browser,
187
+ session: GrokSession
188
+ ): Promise<BrowserContext> {
189
+ const context = await browser.newContext({
190
+ userAgent: session.userAgent,
191
+ viewport: { width: 1280, height: 800 },
192
+ });
193
+ await context.addCookies(session.cookies);
194
+ return context;
195
+ }