pi-grok-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/model.ts ADDED
@@ -0,0 +1,281 @@
1
+ // Pi extension: registers the `grok` model provider backed by Grok Build over WebSocket ACP.
2
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
3
+ import { Box, Container, Image, Spacer, Text, getCapabilities } from '@earendil-works/pi-tui';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { createHash } from 'node:crypto';
6
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { extname, join } from 'node:path';
9
+ import { agentDir, readConfig } from './config.ts';
10
+ import { grokLogin } from './login.ts';
11
+ import { permissionAnswer, permissionDialog } from './model/permissions.ts';
12
+ import { questionAnswerer } from './model/questions.ts';
13
+ import { GrokModelConnection } from './model/connection.ts';
14
+ import { GrokModelSession, type GrokToolRecord } from './model/session.ts';
15
+ import { createGrokStream, GROK_API, MODEL_IDS } from './model/provider.ts';
16
+ import { createSteerHandler } from './model/steer.ts';
17
+
18
+ type SavedModelSession = { owner: string; grokSessionId: string; serverId: string; cwd: string };
19
+ const ENTRY = 'grok-model-session';
20
+ const MODEL_NAMES: Record<string, string> = { 'grok-4.5': 'Grok 4.5', 'grok-4.6': 'Grok 4.6', 'grok-4.7': 'Grok 4.7', 'grok-4.7-build-fast': 'Grok 4.7 Build Fast' };
21
+ const TOOL_ENTRY = 'grok-tool';
22
+ const COMMAND_ENTRY = 'grok-command';
23
+ /** Marker on the one custom *message* we send (media). pi-agent-core presents custom messages to the model as user
24
+ * messages, so the provider drops any user message that starts with this. */
25
+ export const GROK_DISPLAY_ONLY = '\u200b[grok-display]';
26
+ const STATUS_ICON: Record<GrokToolRecord['status'], string> = { completed: '\u2713', failed: '\u2717', denied: '\u2298' };
27
+ const IMAGE_MIME: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
28
+ // pi-tui sends every inline image as kitty format 100 (PNG). Grok's image_gen writes JPEG, which most kitty
29
+ // implementations (Rio here) reserve rows for and then discard. Convert to PNG once with ImageMagick and cache it.
30
+ // The PNG becomes an ImageContent block on a `grok-media` message, so Pi renders it the way it renders any image.
31
+ function imageContent(path: string): { type: 'image'; data: string; mimeType: string } | undefined {
32
+ const mimeType = IMAGE_MIME[extname(path).toLowerCase()];
33
+ if (!mimeType) return undefined;
34
+ const png = asPng(path, mimeType);
35
+ if (!png) return undefined;
36
+ try { return { type: 'image', data: readFileSync(png.path).toString('base64'), mimeType: png.mimeType }; } catch { return undefined; }
37
+ }
38
+ const PNG_CACHE = join(tmpdir(), 'pi-grok-images', 'png');
39
+ function asPng(path: string, mimeType: string): { path: string; mimeType: string } | undefined {
40
+ if (mimeType === 'image/png') return { path, mimeType };
41
+ const out = join(PNG_CACHE, createHash('sha256').update(path).digest('hex').slice(0, 16) + '.png');
42
+ if (!existsSync(out)) {
43
+ try { mkdirSync(PNG_CACHE, { recursive: true, mode: 0o700 }); execFileSync('magick', [path, '-resize', '1024x1024>', out], { stdio: 'ignore', timeout: 15_000 }); }
44
+ catch { return undefined; }
45
+ }
46
+ return { path: out, mimeType: 'image/png' };
47
+ }
48
+ function oneLine(value: unknown, limit: number): string {
49
+ const text = typeof value === 'string' ? value : JSON.stringify(value ?? '');
50
+ const flat = text.replace(/\s+/g, ' ').trim();
51
+ return flat.length > limit ? flat.slice(0, limit) + '\u2026' : flat;
52
+ }
53
+
54
+ export default async function grokModel(pi: ExtensionAPI) {
55
+ const config = await readConfig();
56
+ const connection = new GrokModelConnection({ url: config.url, secret: config.secret, secretFile: config.secretFile, autoStart: config.autoStartGateway ? { logDir: agentDir } : undefined });
57
+ let current: GrokModelSession | undefined;
58
+ let permissionMode: 'yolo' | 'auto' | 'ask' | 'readonly' = 'auto';
59
+ // Media generated during a turn; flushed as one `grok-media` message after the turn so Pi renders the images
60
+ // through its normal message path (inline, like an attached image) instead of inside the tool card.
61
+ const pendingMedia: GrokToolRecord[] = [];
62
+ function flushMedia() {
63
+ const records = pendingMedia.splice(0);
64
+ for (const r of records) {
65
+ if (!r.mediaPath) continue;
66
+ const image = imageContent(r.mediaPath);
67
+ const content: ({ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string })[] = [{ type: 'text', text: `${GROK_DISPLAY_ONLY}${r.tool}: ${r.mediaPath}` }];
68
+ if (image) content.push(image);
69
+ // triggerTurn:false is required. This runs while Pi still counts the turn as streaming, and the default path
70
+ // steers the message into the agent as new input, which made Grok "look at the image you attached".
71
+ pi.sendMessage({ customType: 'grok-media', content, display: true, details: { tool: r.tool, mediaPath: r.mediaPath, sourcePath: r.sourcePath } }, { triggerTurn: false });
72
+ }
73
+ }
74
+
75
+ function restore(ctx: ExtensionContext) {
76
+ lastCtx = ctx;
77
+ current?.detach();
78
+ const owner = ctx.sessionManager.getSessionId();
79
+ let saved: SavedModelSession | undefined;
80
+ for (const entry of ctx.sessionManager.getBranch()) {
81
+ if (entry.type === 'custom' && entry.customType === ENTRY && (entry.data as SavedModelSession)?.owner === owner) saved = entry.data as SavedModelSession;
82
+ }
83
+ current = configure(new GrokModelSession(connection, owner, ctx.cwd, saved?.serverId), ctx);
84
+ if (saved && saved.cwd === ctx.cwd) current.grokSessionId = saved.grokSessionId;
85
+ }
86
+
87
+ /** Permission answers, hook settings, and the structured tool record sink. */
88
+ function configure(session: GrokModelSession, ctx: ExtensionContext) {
89
+ connection.hasUI = ctx.hasUI;
90
+ session.permission = answerFor(ctx);
91
+ session.ask = questionAnswerer(ctx);
92
+ session.hookSettings = config.hooks;
93
+ session.mediaDir = config.mediaDir;
94
+ session.grokMode = config.grokMode;
95
+ session.permissionMode = permissionMode;
96
+ session.askDialog = ctx.hasUI ? async (tool, input) => (await ctx.ui.confirm(`Grok wants to run ${tool}`, JSON.stringify(input ?? {}, null, 2).slice(0, 2000))) === true : undefined;
97
+ session.onToolRecord = (record) => {
98
+ pi.appendEntry<GrokToolRecord>(TOOL_ENTRY, record);
99
+ if (record.mediaPath) pendingMedia.push(record);
100
+ };
101
+ return session;
102
+ }
103
+
104
+ function answerFor(ctx: ExtensionContext) {
105
+ return permissionAnswer(ctx.hasUI, permissionDialog(ctx), config.headlessPermissions);
106
+ }
107
+
108
+ const stream = createGrokStream(connection, { current: () => current, piTools: config.piTools });
109
+
110
+ pi.registerProvider('grok', {
111
+ baseUrl: config.url,
112
+ apiKey: 'grok-build-login',
113
+ api: GROK_API,
114
+ models: MODEL_IDS.map((id) => ({
115
+ id, name: MODEL_NAMES[id] ?? id, reasoning: true, input: ['text', 'image'], // images spill to a temp file and go by path
116
+ // Efforts as the agent advertises them per model: 4.5 has low|medium|high, the others add xhigh. Nothing below low; no max.
117
+ thinkingLevelMap: { minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: id === 'grok-4.5' ? null : 'xhigh', max: null },
118
+ // Grok advertises totalContextTokens=500000 for these models. Cost per token is unknown; per-turn cost comes from Grok's usage report.
119
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 500_000, maxTokens: 32_000,
120
+ })),
121
+ streamSimple: (model, context, options) => {
122
+ const before = current?.grokSessionId;
123
+ const out = stream(model, context, options);
124
+ // Persist the Grok session id once it exists so reloads can session/load it, and surface media generated this turn.
125
+ void out.result().then((message) => {
126
+ if (current && current.grokSessionId && current.grokSessionId !== before) {
127
+ pi.appendEntry(ENTRY, { owner: current.piSessionId, grokSessionId: current.grokSessionId, serverId: current.serverId, cwd: current.cwd } satisfies SavedModelSession);
128
+ }
129
+ if (message.stopReason !== 'toolUse') flushMedia(); // a toolUse stop continues the same Grok turn; wait for its end
130
+ }).catch(() => {});
131
+ return out;
132
+ },
133
+ });
134
+
135
+ // Pi's default custom-message renderer keeps only text blocks, so images need this renderer. It mirrors Pi's own
136
+ // tool-result image path: kitty needs PNG (asPng already converted), width from settings, path line as caption.
137
+ pi.registerMessageRenderer<{ tool?: string; mediaPath?: string; sourcePath?: string }>('grok-media', (message, { expanded }, theme) => {
138
+ const container = new Container();
139
+ const blocks = typeof message.content === 'string' ? [{ type: 'text', text: message.content } as const] : message.content;
140
+ const caps = getCapabilities();
141
+ for (const block of blocks) {
142
+ if (block.type === 'text') { container.addChild(new Text(theme.fg('dim', block.text.replace(GROK_DISPLAY_ONLY, '')), 0, 0)); continue; }
143
+ if (block.type !== 'image' || !caps.images || (caps.images === 'kitty' && block.mimeType !== 'image/png')) continue;
144
+ container.addChild(new Spacer(1));
145
+ container.addChild(new Image(block.data, block.mimeType, { fallbackColor: (s: string) => theme.fg('dim', s) }, { maxWidthCells: expanded ? 100 : 60 }));
146
+ }
147
+ if (expanded && message.details?.sourcePath) container.addChild(new Text(theme.fg('dim', `source ${message.details.sourcePath}`), 0, 0));
148
+ return container;
149
+ });
150
+
151
+ // Grok-native tool calls are session entries: persisted, rendered, never sent to the model (Grok has its own history).
152
+ // Mid-turn Enter (steer) goes straight into Grok's running turn via x.ai/interject. Alt+Enter (followUp)
153
+ // flows through Pi untouched and becomes the next prompt. Entries render at once, even mid-turn.
154
+ const steer = createSteerHandler({
155
+ hasGrokSession: () => !!current?.grokSessionId,
156
+ interject: (text) => {
157
+ const session = current;
158
+ if (!session?.grokSessionId) throw new Error('no Grok session');
159
+ return connection.agent.request('_x.ai/interject', { sessionId: session.grokSessionId, text });
160
+ },
161
+ record: (text) => pi.appendEntry('grok-steer', { text }),
162
+ notify: (text) => {
163
+ try { lastCtx?.ui.notify(text, 'error'); }
164
+ catch (error) { console.error('Could not display Grok steering notification:', error); }
165
+ },
166
+ });
167
+ let lastCtx: ExtensionContext | undefined;
168
+ pi.on('input', (event, ctx) => {
169
+ if (ctx.model?.provider !== 'grok') return;
170
+ return steer(event);
171
+ });
172
+
173
+ pi.registerEntryRenderer<{ text: string }>('grok-steer', (entry, _opts, theme) => {
174
+ if (!entry.data) return undefined;
175
+ return new Text(theme.fg('dim', `→ steered into Grok's turn: ${entry.data.text.split('\n')[0].slice(0, 160)}`), 0, 0);
176
+ });
177
+
178
+ pi.registerEntryRenderer<{ title: string; body: string }>(COMMAND_ENTRY, (entry, _opts, theme) => {
179
+ const d = entry.data; if (!d) return undefined;
180
+ const box = new Box(1, 0, (s: string) => theme.bg('customMessageBg', s));
181
+ box.addChild(new Text(theme.fg('accent', d.title), 0, 0));
182
+ for (const line of d.body.split('\n')) if (line) box.addChild(new Text(line, 0, 0));
183
+ return box;
184
+ });
185
+
186
+ pi.registerEntryRenderer<GrokToolRecord>(TOOL_ENTRY, (entry, { expanded }, theme) => {
187
+ const r = entry.data; if (!r) return undefined;
188
+ const box = new Box(1, 0, (text: string) => theme.bg('customMessageBg', text));
189
+ const color = r.status === 'completed' ? 'success' : r.status === 'denied' ? 'warning' : 'error';
190
+ const ms = r.durationMs != null ? theme.fg('dim', ` ${r.durationMs}ms`) : '';
191
+ box.addChild(new Text(`${theme.fg(color, STATUS_ICON[r.status])} ${theme.fg('accent', 'grok')} ${r.tool} ${theme.fg('dim', oneLine(r.input, 100))}${ms}`, 0, 0));
192
+ if (r.denyReason) box.addChild(new Text(theme.fg('warning', ` denied: ${oneLine(r.denyReason, 160)}`), 0, 0));
193
+ if (r.mediaPath) {
194
+ box.addChild(new Text(` ${theme.fg('accent', 'saved')} ${r.mediaPath}`, 0, 0));
195
+ if (expanded && r.sourcePath && r.sourcePath !== r.mediaPath) box.addChild(new Text(theme.fg('dim', ` source ${r.sourcePath}`), 0, 0));
196
+ }
197
+ if (expanded && r.output) box.addChild(new Text(theme.fg('dim', ` ${oneLine(r.output, 600)}`), 0, 0));
198
+ if (r.hookContext) box.addChild(new Text(theme.fg('warning', ` check: ${oneLine(r.hookContext, expanded ? 600 : 160)}`), 0, 0));
199
+ return box;
200
+ });
201
+
202
+ // Manual access to Grok harness features that are otherwise model-driven. Prompts go straight to Grok, outside Pi's loop.
203
+ // Only what Pi has no native surface for. Escape cancels, /new starts fresh, reconnect is automatic,
204
+ // and Pi's thinking level sets Grok's reasoning effort, so those are not commands here.
205
+ // Two-level completion: the subcommand, then its arguments. Pi passes everything typed after "/grok ".
206
+ // The first-level descriptions are the lines that used to print above the editor. They stay in the menu under it.
207
+ const COMPLETIONS: Record<string, { args: { value: string; description: string }[]; description: string }> = {
208
+ perms: { description: '(yolo | auto | ask | read-only)', args: [{ value: 'yolo', description: 'expand Pi capabilities; Grok rules still apply' }, { value: 'auto', description: 'mirror Pi\'s tool set' }, { value: 'ask', description: 'confirm each edit or shell call' }, { value: 'read-only', description: 'deny edits and shell' }] },
209
+ plan: { description: '(on | off)', args: [{ value: 'on', description: 'enter plan mode' }, { value: 'off', description: 'leave plan mode' }] },
210
+ goal: { description: '(<objective> | status | pause | resume | clear)', args: [{ value: 'status', description: 'current goal' }, { value: 'pause', description: '' }, { value: 'resume', description: '' }, { value: 'clear', description: '' }] },
211
+ compact: { description: '(note)', args: [] },
212
+ debug: { description: '(brilliant information)', args: [] },
213
+ login: { description: 'sign in to Grok Build (device code)', args: [] },
214
+ };
215
+ pi.registerCommand('grok', {
216
+ description: 'login | perms | plan | goal | compact | debug',
217
+ getArgumentCompletions: (prefix) => {
218
+ const [head, ...rest] = prefix.split(/\s+/);
219
+ if (rest.length === 0) {
220
+ const items = Object.entries(COMPLETIONS).filter(([name]) => name.startsWith(head)).map(([name, c]) => ({ value: name, label: name, description: c.description }));
221
+ return items.length ? items : null;
222
+ }
223
+ const sub = COMPLETIONS[head]; if (!sub) return null;
224
+ const argPrefix = rest.join(' ');
225
+ const items = sub.args.filter((a) => a.value.startsWith(argPrefix)).map((a) => ({ value: `${head} ${a.value}`, label: a.value, description: a.description }));
226
+ return items.length ? items : null;
227
+ },
228
+ handler: async (args, ctx) => {
229
+ const [verb, ...rest] = args.trim().split(/\s+/); const tail = rest.join(' ');
230
+ const session = current;
231
+ // A custom *entry*: rendered at once even mid-turn (Pi holds custom *messages* until the turn ends, and
232
+ // presents them to the model as user messages). Entries never enter model context.
233
+ const show = (title: string, body: string) => pi.appendEntry(COMMAND_ENTRY, { title, body });
234
+ try {
235
+ if (!verb) return;
236
+ if (verb === 'login') {
237
+ // Runs in the background: approval in the browser can take minutes, and Pi's input stays free meanwhile.
238
+ void grokLogin(({ url, code }) => {
239
+ show('Grok login', `Open ${url}\nConfirm the code ${code}. Grok may open the page itself.`);
240
+ ctx.ui.notify(`Grok login: confirm code ${code} at ${url}`, 'info');
241
+ }).then(
242
+ () => ctx.ui.notify('Grok login: signed in. Send your message again.', 'info'),
243
+ (error) => ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error'),
244
+ );
245
+ return;
246
+ }
247
+ if (!session) throw new Error('No Grok model session. Select a grok/* model first.');
248
+ switch (verb) {
249
+ case 'debug': {
250
+ const u = session.usageTotals; const denied = session.hookLog.filter((h) => h.decision === 'deny').length;
251
+ show('Grok debug', [
252
+ `gateway: ${config.url} (${connection.isOpen ? 'connected' : 'not connected'}${connection.launchedGateway ? `, started by this Pi as pid ${connection.launchedGateway}` : ''}${connection.lastDrop ? `, last drop: ${connection.lastDrop}` : ''}; auto-start ${config.autoStartGateway ? 'on' : 'off'})`,
253
+ `grok session: ${session.grokSessionId ?? '(none yet; first message creates it)'}`,
254
+ `mode: ${session.mode}${session.promptActive ? ' (turn running)' : ''}; pi perms: ${session.permissionMode}; grok mode: ${session.grokMode}`,
255
+ `grok context: ${session.lastContextTokens != null ? session.lastContextTokens.toLocaleString() + ' / 500,000' : 'unknown'}`,
256
+ `usage: ${u.turns} turns, ${u.inputTokens.toLocaleString()} in (${u.cachedReadTokens.toLocaleString()} cached), ${u.outputTokens.toLocaleString()} out, $${u.costUsd.toFixed(3)}`,
257
+ `lent Pi tools: ${session.tools.length ? session.tools.map((t) => t.name).join(', ') : 'none'}`,
258
+ `hook decisions: ${session.hookLog.length} (${denied} denied); pending lent-tool calls: ${session.pendingToolCallIds.length}`,
259
+ ].join('\n'));
260
+ return;
261
+ }
262
+ case 'perms': {
263
+ if (!tail) { ctx.ui.notify(`Grok permission mode: ${permissionMode}`, 'info'); return; }
264
+ const chosen = tail === 'read-only' ? 'readonly' : tail;
265
+ if (!['yolo', 'auto', 'ask', 'readonly'].includes(chosen)) throw new Error('Usage: /grok perms yolo | auto | ask | read-only');
266
+ permissionMode = chosen as typeof permissionMode; session.permissionMode = permissionMode;
267
+ ctx.ui.notify(`Grok permission mode: ${permissionMode}${permissionMode === 'ask' && !ctx.hasUI ? ' (no UI: behaves as readonly)' : ''}`, 'info'); return;
268
+ }
269
+ case 'plan': { const mode = tail === 'off' ? 'default' : 'plan'; if (tail && !['on', 'off'].includes(tail)) throw new Error('Usage: /grok plan on | off'); await session.setMode(mode); ctx.ui.notify(`Grok session mode: ${mode}`, 'info'); return; }
270
+ case 'goal': { const r = await session.runCommand(`/goal${tail ? ' ' + tail : ''}`, 600_000); show(`/goal${tail ? ' ' + tail : ''}`, r.text); return; }
271
+ case 'compact': { const r = await session.runCommand(`/compact${tail ? ' ' + tail : ''}`, 600_000); show('/compact', r.text || `done (${r.stopReason})`); return; }
272
+ default: ctx.ui.notify(`Unknown /grok subcommand "${verb}".`, 'info');
273
+ }
274
+ } catch (error) { ctx.ui.notify(`Grok: ${error instanceof Error ? error.message : String(error)}`, 'error'); }
275
+ },
276
+ });
277
+
278
+ pi.on('session_start', (_event, ctx) => restore(ctx));
279
+ pi.on('session_tree', (_event, ctx) => { lastCtx = ctx; current?.detach(); current = configure(new GrokModelSession(connection, ctx.sessionManager.getSessionId(), ctx.cwd), ctx); });
280
+ pi.on('session_shutdown', async () => { current?.detach(); current = undefined; await connection.close(); });
281
+ }