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/LICENSE +202 -0
- package/README.md +179 -0
- package/dist/scripts/server.js +486 -0
- package/dist/src/client.js +82 -0
- package/dist/src/config.js +89 -0
- package/docs/first-class-model.md +121 -0
- package/docs/usage.md +380 -0
- package/package.json +70 -0
- package/src/client.ts +75 -0
- package/src/config.ts +125 -0
- package/src/launch.ts +55 -0
- package/src/login.ts +44 -0
- package/src/model/connection.ts +239 -0
- package/src/model/hooks.ts +154 -0
- package/src/model/permissions.ts +43 -0
- package/src/model/provider.ts +243 -0
- package/src/model/questions.ts +65 -0
- package/src/model/session.ts +472 -0
- package/src/model/steer.ts +50 -0
- package/src/model.ts +281 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Grok client hooks (x.ai/hooks) for the model provider. Grok keeps executing on its own harness;
|
|
2
|
+
// these run on Pi's side around each native call:
|
|
3
|
+
// pre_tool_use -> gate: mirror Pi's tool capabilities onto Grok's tools, plus configured deny/allow
|
|
4
|
+
// post_tool_use -> enrich: run a check after an edit and hand Grok the findings as additionalContext
|
|
5
|
+
// stop -> hold: run an acceptance command; non-zero output blocks the stop with a reason
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import type { HookSettings } from '../config.ts';
|
|
8
|
+
|
|
9
|
+
export type HookRun = {
|
|
10
|
+
hookCallbackId: string;
|
|
11
|
+
hookEventName: 'pre_tool_use' | 'post_tool_use' | 'stop' | string;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
cwd: string;
|
|
14
|
+
toolName?: string;
|
|
15
|
+
toolUseId?: string;
|
|
16
|
+
toolInput?: unknown;
|
|
17
|
+
toolResult?: unknown;
|
|
18
|
+
durationMs?: number;
|
|
19
|
+
reason?: string;
|
|
20
|
+
stopHookActive?: boolean;
|
|
21
|
+
lastAssistantMessage?: string;
|
|
22
|
+
};
|
|
23
|
+
export type HookReply = { decision?: 'continue' | 'deny' | 'block'; reason?: string; additionalContext?: string; continue?: boolean; stopReason?: string };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Grok stamps every `tool_call` with `_meta["x.ai/tool"]`: its canonical kind (snake_case `ToolKind`) and a
|
|
27
|
+
* `read_only` flag, both exhaustive on Grok's side. That is the primary classification. The name table below is
|
|
28
|
+
* the fallback for calls that arrive without a stamp.
|
|
29
|
+
*/
|
|
30
|
+
export type GrokToolStamp = { name?: string; kind?: string; read_only?: boolean; namespace?: string };
|
|
31
|
+
export type Capability = 'read' | 'write' | 'shell' | 'mcp' | 'other';
|
|
32
|
+
const KIND_CAPABILITY: Record<string, Capability> = {
|
|
33
|
+
read: 'read', search: 'read', list_dir: 'read', list: 'read', lsp: 'read', memory_search: 'read', memory_get: 'read',
|
|
34
|
+
edit: 'write', write: 'write', delete: 'write', move: 'write',
|
|
35
|
+
execute: 'shell',
|
|
36
|
+
// MCP and plugin tools dispatch through `use_tool`; the hook's toolName is the qualified `server__tool`.
|
|
37
|
+
// Grok stamps the dispatcher as mutating and does not forward the server's readOnlyHint, so Pi cannot tell.
|
|
38
|
+
use_tool: 'mcp', search_tool: 'read',
|
|
39
|
+
};
|
|
40
|
+
/** `server__tool` -> server name, for MCP-dispatched calls. */
|
|
41
|
+
export function mcpServerOf(tool: string): string | undefined {
|
|
42
|
+
const i = tool.indexOf('__');
|
|
43
|
+
return i > 0 ? tool.slice(0, i) : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** Grok native tool names by the Pi capability they need (fallback when no stamp is available). */
|
|
46
|
+
export const GROK_TOOL_CLASSES = {
|
|
47
|
+
read: /^(hashline_read|read_file|read|list_dir|ls|hashline_grep|grep|glob|search_tool|codebase_search)$/,
|
|
48
|
+
write: /^(hashline_edit|search_replace|edit|apply_patch|write|write_file|delete_file|move_file)$/,
|
|
49
|
+
shell: /^(run_terminal_command|run_terminal_cmd|bash)$/,
|
|
50
|
+
};
|
|
51
|
+
/** Capability a Grok call needs: stamp kind first, then the name table, then `other`. */
|
|
52
|
+
export function classify(tool: string, stamp?: GrokToolStamp): Capability {
|
|
53
|
+
if (stamp?.kind && KIND_CAPABILITY[stamp.kind]) return KIND_CAPABILITY[stamp.kind];
|
|
54
|
+
if (GROK_TOOL_CLASSES.write.test(tool)) return 'write';
|
|
55
|
+
if (GROK_TOOL_CLASSES.shell.test(tool)) return 'shell';
|
|
56
|
+
if (GROK_TOOL_CLASSES.read.test(tool)) return 'read';
|
|
57
|
+
if (mcpServerOf(tool)) return 'mcp';
|
|
58
|
+
return 'other';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type PiCapabilities = { read: boolean; write: boolean; shell: boolean };
|
|
62
|
+
|
|
63
|
+
/** What Pi's own loop may do, from the tool list Pi handed the model. */
|
|
64
|
+
export function capabilitiesFrom(piToolNames: string[]): PiCapabilities {
|
|
65
|
+
const has = (n: string) => piToolNames.includes(n);
|
|
66
|
+
return { read: has('read') || has('grep') || has('find') || has('ls'), write: has('edit') || has('write'), shell: has('bash') };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function matchesAny(patterns: string[] | undefined, name: string): boolean {
|
|
70
|
+
return (patterns ?? []).some((p) => { try { return new RegExp(`^(?:${p})$`).test(name); } catch { return p === name; } });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type Verdict = { allow: true } | { allow: false; reason: string };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* pre_tool_use: deny when Pi itself may not do this, or when configured.
|
|
77
|
+
* An explicit deny wins over an allow entry; an allow entry wins over the capability mirror.
|
|
78
|
+
*/
|
|
79
|
+
/** Read-only marker a server can place in a tool's `_meta` (Grok forwards `_meta`, not `annotations`). */
|
|
80
|
+
export function metaSaysReadOnly(meta: unknown): boolean {
|
|
81
|
+
const m = (meta ?? {}) as Record<string, unknown>;
|
|
82
|
+
const hint = m.readOnlyHint ?? m['pi/readOnly'] ?? (m.annotations as Record<string, unknown> | undefined)?.readOnlyHint;
|
|
83
|
+
return hint === true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function capabilityGate(piToolNames: string[], settings: HookSettings, toolMeta?: (tool: string) => unknown): (tool: string, stamp?: GrokToolStamp) => Verdict {
|
|
87
|
+
const caps = capabilitiesFrom(piToolNames);
|
|
88
|
+
return (tool, stamp) => {
|
|
89
|
+
if (matchesAny(settings.denyGrokTools, tool)) return { allow: false, reason: `Pi policy denies ${tool} in this session.` };
|
|
90
|
+
if (matchesAny(settings.allowGrokTools, tool)) return { allow: true };
|
|
91
|
+
const need = classify(tool, stamp);
|
|
92
|
+
if (need === 'write' && !caps.write) return { allow: false, reason: 'This Pi session is read-only: no file edits or writes. Report findings instead.' };
|
|
93
|
+
if (need === 'shell' && !caps.shell) return { allow: false, reason: 'This Pi session has no shell access. Use file and search tools instead.' };
|
|
94
|
+
if (need === 'read' && !caps.read) return { allow: false, reason: 'This Pi session cannot read files.' };
|
|
95
|
+
if (need === 'mcp' && !caps.write && !caps.shell) {
|
|
96
|
+
if (metaSaysReadOnly(toolMeta?.(tool))) return { allow: true };
|
|
97
|
+
const server = mcpServerOf(tool) ?? '';
|
|
98
|
+
if (matchesAny(settings.mcpReadOnlyServers, server)) return { allow: true };
|
|
99
|
+
return { allow: false, reason: `This Pi session is read-only and cannot verify that MCP tool ${tool} only reads. Report what you would call instead.` };
|
|
100
|
+
}
|
|
101
|
+
// A stamped kind we do not classify (task, workflow, deploy_app, ...) that Grok itself marks as mutating:
|
|
102
|
+
// in a read-only Pi session that is still a workspace or external mutation, so deny it.
|
|
103
|
+
if (need === 'other' && stamp && stamp.read_only === false && !caps.write && !caps.shell) return { allow: false, reason: `This Pi session is read-only; ${tool} (${stamp.kind ?? 'unknown kind'}) can mutate state.` };
|
|
104
|
+
return { allow: true };
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The file a Grok edit call targets, from its input. */
|
|
109
|
+
export function editedFile(input: unknown): string | undefined {
|
|
110
|
+
const i = (input ?? {}) as Record<string, unknown>;
|
|
111
|
+
return [i.file_path, i.target_file, i.path, i.absolute_path].find((v): v is string => typeof v === 'string');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Syntax-only TypeScript check: strip types with Node's own stripper, then parse as an ES module without running it.
|
|
115
|
+
const TS_SYNTAX_CHECK = 'const {stripTypeScriptTypes}=require("node:module");const vm=require("node:vm");new vm.SourceTextModule(stripTypeScriptTypes(require("fs").readFileSync(process.argv[1],"utf8")))';
|
|
116
|
+
const BUILTIN_CHECKS: { test: RegExp; command: string[] }[] = [
|
|
117
|
+
{ test: /\.(ts|mts|cts)$/, command: ['node', '--no-warnings', '--experimental-vm-modules', '-e', TS_SYNTAX_CHECK, '{file}'] },
|
|
118
|
+
{ test: /\.(js|mjs|cjs)$/, command: ['node', '--check', '{file}'] },
|
|
119
|
+
{ test: /\.py$/, command: ['python3', '-m', 'py_compile', '{file}'] },
|
|
120
|
+
{ test: /\.json$/, command: ['node', '-e', 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))', '{file}'] },
|
|
121
|
+
{ test: /\.rs$/, command: ['rustfmt', '--check', '--edition', '2021', '{file}'] },
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
export type RunResult = { ok: boolean; output: string; command: string };
|
|
125
|
+
|
|
126
|
+
export function run(argv: string[], cwd: string, timeoutMs = 120_000): Promise<RunResult> {
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
execFile(argv[0], argv.slice(1), { cwd, timeout: timeoutMs, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
|
|
129
|
+
const output = `${stdout}${stderr}`.trim();
|
|
130
|
+
resolve({ ok: !error, output: error && !output ? String(error.message) : output, command: argv.join(' ') });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** post_tool_use after an edit: built-in syntax check or the configured command. Findings are returned as text for additionalContext. */
|
|
136
|
+
export async function postEditContext(input: unknown, cwd: string, settings: HookSettings): Promise<string | undefined> {
|
|
137
|
+
const file = editedFile(input);
|
|
138
|
+
if (!file) return undefined;
|
|
139
|
+
let argv: string[] | undefined;
|
|
140
|
+
if (settings.postEditCheck) argv = ['bash', '-lc', settings.postEditCheck.replaceAll('{file}', file)];
|
|
141
|
+
else { const builtin = BUILTIN_CHECKS.find((c) => c.test.test(file)); if (builtin) argv = builtin.command.map((a) => a.replaceAll('{file}', file)); }
|
|
142
|
+
if (!argv) return undefined;
|
|
143
|
+
const result = await run(argv, cwd);
|
|
144
|
+
if (result.ok) return undefined;
|
|
145
|
+
return `Check failed after editing ${file} (${result.command}):\n${result.output.slice(0, 4000)}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** stop: run the acceptance command; non-zero blocks the stop with the output as the reason. */
|
|
149
|
+
export async function stopGate(event: HookRun, settings: HookSettings): Promise<HookReply> {
|
|
150
|
+
if (!settings.stopCheck || event.reason !== 'end_turn') return { decision: 'continue' };
|
|
151
|
+
const result = await run(['bash', '-lc', settings.stopCheck], event.cwd, 600_000);
|
|
152
|
+
if (result.ok) return { decision: 'continue' };
|
|
153
|
+
return { decision: 'block', reason: `Acceptance check failed (${settings.stopCheck}):\n${result.output.slice(0, 8000)}\nFix the cause, rerun the check, then finish.` };
|
|
154
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Answers Grok's native permission prompts for the model provider.
|
|
2
|
+
// Interactive Pi: the existing dialog. Headless Pi: a configured policy, never a silent allow by default.
|
|
3
|
+
import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
|
|
4
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import type { HeadlessPermissionPolicy } from '../config.ts';
|
|
6
|
+
|
|
7
|
+
type Answer = (request: RequestPermissionRequest) => Promise<RequestPermissionResponse>;
|
|
8
|
+
|
|
9
|
+
/** Interactive: Grok's permission prompt as a Pi selection dialog. Dismissal cancels; Grok treats that as a rejection. */
|
|
10
|
+
export function permissionDialog(ctx: Pick<ExtensionContext, 'hasUI' | 'ui'>): Answer {
|
|
11
|
+
return async (request) => {
|
|
12
|
+
if (!ctx.hasUI) return { outcome: { outcome: 'cancelled' } };
|
|
13
|
+
const labels = request.options.map((option, i) => `${i + 1}. ${option.name} (${option.kind})`);
|
|
14
|
+
const details = JSON.stringify(request.toolCall.rawInput ?? {}, null, 2).slice(0, 4000);
|
|
15
|
+
const selected = await ctx.ui.select(`Grok: ${request.toolCall.title}\n${details}`, labels);
|
|
16
|
+
const index = selected === undefined ? -1 : labels.indexOf(selected);
|
|
17
|
+
if (index < 0) return { outcome: { outcome: 'cancelled' } };
|
|
18
|
+
return { outcome: { outcome: 'selected', optionId: request.options[index].optionId } };
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const READ_KINDS = new Set(['read', 'search', 'fetch', 'think']);
|
|
23
|
+
|
|
24
|
+
function pick(request: RequestPermissionRequest, kind: 'allow_once' | 'reject_once'): RequestPermissionResponse {
|
|
25
|
+
const option = request.options.find((o) => o.kind === kind) ?? request.options.find((o) => o.kind.startsWith(kind.split('_')[0]));
|
|
26
|
+
return option ? { outcome: { outcome: 'selected', optionId: option.optionId } } : { outcome: { outcome: 'cancelled' } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function headlessPermission(policy: HeadlessPermissionPolicy): Answer {
|
|
30
|
+
return async (request) => {
|
|
31
|
+
switch (policy) {
|
|
32
|
+
case 'allow': return pick(request, 'allow_once');
|
|
33
|
+
case 'reads': return READ_KINDS.has(request.toolCall.kind ?? 'other') ? pick(request, 'allow_once') : pick(request, 'reject_once');
|
|
34
|
+
case 'deny': return pick(request, 'reject_once');
|
|
35
|
+
default: return { outcome: { outcome: 'cancelled' } };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Use the dialog when Pi has a UI; otherwise the headless policy. */
|
|
41
|
+
export function permissionAnswer(hasUI: boolean, dialog: Answer, policy: HeadlessPermissionPolicy): Answer {
|
|
42
|
+
return hasUI ? dialog : headlessPermission(policy);
|
|
43
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Grok Build as a Pi model. Grok runs its own harness: native tools, permissions, subagents.
|
|
2
|
+
// Pi drives turns, shows the stream, and can lend extra tools through Grok's client-hosted
|
|
3
|
+
// MCP channel; those are the only tool calls that reach Pi's executor.
|
|
4
|
+
import { createAssistantMessageEventStream, getCurrentSystemPrompt, getCurrentTools, type AssistantMessage, type AssistantMessageEventStream, type Message, type Model, type Api, type SimpleStreamOptions, type ToolCall, type ToolResultMessage, type TranscriptContext, type Usage } from '@earendil-works/pi-ai';
|
|
5
|
+
import type { GrokModelConnection } from './connection.ts';
|
|
6
|
+
import { GrokModelSession, type TurnEvent, type GrokTurnUsage } from './session.ts';
|
|
7
|
+
import { selectPiTools, type PiToolPolicy } from '../config.ts';
|
|
8
|
+
|
|
9
|
+
/** Same marker as model.ts; kept here to avoid importing the extension entry from the provider. */
|
|
10
|
+
const GROK_DISPLAY_ONLY = '\u200b[grok-display]';
|
|
11
|
+
/** pi-agent-core presents custom messages (ours and other extensions') to the model as user messages. Ours carry a marker. */
|
|
12
|
+
function isDisplayOnly(m: Message): boolean {
|
|
13
|
+
if (m.role !== 'user') return false;
|
|
14
|
+
const first = typeof m.content === 'string' ? m.content : m.content.find((c) => c.type === 'text')?.text ?? '';
|
|
15
|
+
return first.startsWith(GROK_DISPLAY_ONLY);
|
|
16
|
+
}
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
// Grok's ACP accepts image blocks but does not see them (promptCapabilities.image: false, verified: a red 1x1 PNG
|
|
23
|
+
// answered "Unknown"). Given a file path it reads the image with its own tools and answers correctly, so inbound
|
|
24
|
+
// images become temp files referenced from the prompt text.
|
|
25
|
+
const IMAGE_DIR = join(tmpdir(), 'pi-grok-images');
|
|
26
|
+
/**
|
|
27
|
+
* Write an attached image block to a temp file (0600) and return its path, so Grok (which cannot see ACP
|
|
28
|
+
* image blocks) can read it with its own file tools. Content-addressed: the same bytes reuse the same file.
|
|
29
|
+
*/
|
|
30
|
+
export function spillImageFile(data: string, mimeType: string): string {
|
|
31
|
+
mkdirSync(IMAGE_DIR, { recursive: true, mode: 0o700 });
|
|
32
|
+
const ext = mimeType.split('/')[1]?.replace('jpeg', 'jpg') || 'bin';
|
|
33
|
+
const file = join(IMAGE_DIR, `${createHash('sha256').update(data).digest('hex').slice(0, 16)}.${ext}`);
|
|
34
|
+
try { writeFileSync(file, Buffer.from(data, 'base64'), { flag: 'wx', mode: 0o600 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; }
|
|
35
|
+
return file;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const GROK_API = 'grok-acp' as Api;
|
|
39
|
+
export const MODEL_IDS = ['grok-4.7', 'grok-4.7-build-fast', 'grok-4.6', 'grok-4.5'];
|
|
40
|
+
const BATCH_GRACE_MS = 150;
|
|
41
|
+
const PREAMBLE_LIMIT = 60_000;
|
|
42
|
+
|
|
43
|
+
export interface SessionResolver { current(): GrokModelSession | undefined; piTools?: PiToolPolicy; }
|
|
44
|
+
|
|
45
|
+
function zeroUsage(): Usage { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }; }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Pi usage from Grok's `turn_completed` accounting. Grok's `inputTokens` includes the cached portion
|
|
49
|
+
* (per-sample frames show input_tokens + cache_read_input_tokens = turn inputTokens); Pi keeps them apart.
|
|
50
|
+
* Cost is what Grok reports (USD ticks / 1e9), attributed to input since Grok gives one number per turn.
|
|
51
|
+
*/
|
|
52
|
+
function usageFrom(turn: GrokTurnUsage | undefined): Usage {
|
|
53
|
+
const usage = zeroUsage();
|
|
54
|
+
if (!turn) return usage;
|
|
55
|
+
usage.cacheRead = turn.cachedReadTokens;
|
|
56
|
+
usage.cacheWrite = turn.cacheCreationTokens;
|
|
57
|
+
usage.input = Math.max(0, turn.inputTokens - turn.cachedReadTokens - turn.cacheCreationTokens);
|
|
58
|
+
usage.output = turn.outputTokens;
|
|
59
|
+
// Pi's context token calculation uses message.usage.totalTokens as the active conversation context size.
|
|
60
|
+
// Grok's turn_completed accounting is cumulative across all sub-calls in the turn (which can sum to >500k),
|
|
61
|
+
// whereas turn.contextTokens (from session/prompt response _meta.totalTokens) is Grok's actual context window size.
|
|
62
|
+
usage.totalTokens = turn.contextTokens ?? (usage.input + usage.output + usage.cacheRead + usage.cacheWrite);
|
|
63
|
+
usage.cost = { input: turn.costUsd, output: 0, cacheRead: 0, cacheWrite: 0, total: turn.costUsd };
|
|
64
|
+
return usage;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Messages after the last assistant message: the new user input and/or tool results. */
|
|
68
|
+
/**
|
|
69
|
+
* Messages after the last assistant message, keeping only what is input to Grok: user messages and tool
|
|
70
|
+
* results. Custom messages (our own `grok-media`, `grok-command`, other extensions' notes) are Pi-side display
|
|
71
|
+
* and never become prompt text; otherwise Grok would be told the image it just generated was "attached" and
|
|
72
|
+
* read it back, which Grok Build itself never does.
|
|
73
|
+
*/
|
|
74
|
+
export function splitTail(messages: Message[]): { history: Message[]; tail: Message[] } {
|
|
75
|
+
let last = -1;
|
|
76
|
+
for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === 'assistant') { last = i; break; }
|
|
77
|
+
const tail = messages.slice(last + 1).filter((m) => (m.role === 'user' && !isDisplayOnly(m)) || m.role === 'toolResult');
|
|
78
|
+
return { history: messages.slice(0, last + 1), tail };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function textOf(content: string | { type: string; text?: string; data?: string; mimeType?: string }[]): string {
|
|
82
|
+
if (typeof content === 'string') return content;
|
|
83
|
+
return content.map((c) => {
|
|
84
|
+
if (c.type === 'text') return c.text ?? '';
|
|
85
|
+
if (c.type === 'image' && c.data) { try { return `\n[attached image: ${spillImageFile(c.data, c.mimeType ?? 'image/png')} — read this file to view it]\n`; } catch { return '[image]'; } }
|
|
86
|
+
return `[${c.type}]`;
|
|
87
|
+
}).join('');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Render prior conversation for a fresh Grok session that has no history of its own. */
|
|
91
|
+
export function renderPreamble(history: Message[]): string {
|
|
92
|
+
const lines: string[] = [];
|
|
93
|
+
for (const m of history) {
|
|
94
|
+
if (m.role === 'user') lines.push(`[user]\n${textOf(m.content)}`);
|
|
95
|
+
else if (m.role === 'assistant') {
|
|
96
|
+
const text = m.content.filter((c) => c.type === 'text').map((c) => (c as { text: string }).text).join('');
|
|
97
|
+
const calls = m.content.filter((c) => c.type === 'toolCall').map((c) => { const t = c as ToolCall; return `${t.name}(${JSON.stringify(t.arguments)})`; });
|
|
98
|
+
lines.push(`[assistant]\n${text}${calls.length ? `\n[tool calls] ${calls.join('; ')}` : ''}`);
|
|
99
|
+
} else if (m.role === 'toolResult') lines.push(`[tool result ${m.toolName}]\n${textOf(m.content)}`);
|
|
100
|
+
}
|
|
101
|
+
let text = lines.join('\n\n');
|
|
102
|
+
if (text.length > PREAMBLE_LIMIT) text = '…' + text.slice(-PREAMBLE_LIMIT);
|
|
103
|
+
return text;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function promptTextFor(tail: Message[], orphans: ToolResultMessage[], preamble?: string): string {
|
|
107
|
+
const parts: string[] = [];
|
|
108
|
+
if (preamble) parts.push(`Conversation so far (Pi transcript):\n\n${preamble}\n\n---`);
|
|
109
|
+
for (const r of orphans) parts.push(`[tool result ${r.toolName}${r.isError ? ' (error)' : ''}]\n${textOf(r.content)}`);
|
|
110
|
+
for (const m of tail) if (m.role === 'user') parts.push(textOf(m.content));
|
|
111
|
+
return parts.join('\n\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createGrokStream(connection: GrokModelConnection, sessions: SessionResolver) {
|
|
115
|
+
return function streamGrok(model: Model<Api>, context: TranscriptContext, options?: SimpleStreamOptions): AssistantMessageEventStream {
|
|
116
|
+
const stream = createAssistantMessageEventStream();
|
|
117
|
+
const signal = options?.signal;
|
|
118
|
+
const message: AssistantMessage = { role: 'assistant', content: [], api: model.api, provider: model.provider, model: model.id, usage: zeroUsage(), stopReason: 'pending', timestamp: Date.now() };
|
|
119
|
+
const fail = (reason: 'error' | 'aborted', text: string) => {
|
|
120
|
+
message.stopReason = reason; message.errorMessage = text;
|
|
121
|
+
stream.push({ type: 'error', reason, error: message }); stream.end(message);
|
|
122
|
+
};
|
|
123
|
+
(async () => {
|
|
124
|
+
const session = sessions.current();
|
|
125
|
+
if (!session) throw new Error('No active Pi session for the Grok model provider.');
|
|
126
|
+
await connection.open(signal);
|
|
127
|
+
const isNew = !session.grokSessionId;
|
|
128
|
+
const piTools = getCurrentTools(context.messages);
|
|
129
|
+
session.piToolNames = piTools.map((t) => t.name);
|
|
130
|
+
session.tools = selectPiTools(piTools, sessions.piTools ?? 'extensions');
|
|
131
|
+
await session.attach(getCurrentSystemPrompt(context.messages) || undefined);
|
|
132
|
+
await session.applyEffort(options?.reasoning); // Pi's thinking level drives Grok's reasoning_effort
|
|
133
|
+
signal?.throwIfAborted();
|
|
134
|
+
const { history, tail } = splitTail(context.messages);
|
|
135
|
+
const results = tail.filter((m): m is ToolResultMessage => m.role === 'toolResult');
|
|
136
|
+
const orphans = session.promptActive ? session.resolveToolResults(results) : results;
|
|
137
|
+
if (!session.promptActive) {
|
|
138
|
+
const preamble = isNew && history.length ? renderPreamble(history) : undefined;
|
|
139
|
+
const text = promptTextFor(tail, orphans, preamble);
|
|
140
|
+
if (!text.trim()) throw new Error('Nothing to send to Grok: no user message or tool result after the last assistant message (custom messages are not prompt input).');
|
|
141
|
+
session.startPrompt(text);
|
|
142
|
+
} else if (orphans.length) {
|
|
143
|
+
// Results for calls Grok is not waiting on (e.g. after a restart). Let them reach Grok as text on the next prompt.
|
|
144
|
+
throw new Error(`Tool results ${orphans.map((o) => o.toolCallId).join(', ')} do not match any pending Grok tool call.`);
|
|
145
|
+
}
|
|
146
|
+
stream.push({ type: 'start', partial: message });
|
|
147
|
+
if (session.reconnected) {
|
|
148
|
+
const note = `[grok reconnected after: ${session.reconnected}; session ${session.grokSessionId} reloaded]\n`;
|
|
149
|
+
session.reconnected = undefined;
|
|
150
|
+
const index = message.content.push({ type: 'thinking', thinking: note }) - 1;
|
|
151
|
+
stream.push({ type: 'thinking_start', contentIndex: index, partial: message });
|
|
152
|
+
stream.push({ type: 'thinking_delta', contentIndex: index, delta: note, partial: message });
|
|
153
|
+
stream.push({ type: 'thinking_end', contentIndex: index, content: note, partial: message });
|
|
154
|
+
}
|
|
155
|
+
await new Promise<void>((resolve, reject) => {
|
|
156
|
+
let open: { type: 'text' | 'thinking'; index: number } | undefined;
|
|
157
|
+
let batchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
158
|
+
let finished = false;
|
|
159
|
+
// Declared before consume(): consume() flushes buffered events synchronously, and a buffered completion
|
|
160
|
+
// could reach onAbort/finish (which call detach) before a `const` further down was initialized.
|
|
161
|
+
let detach: () => void = () => {};
|
|
162
|
+
const closeBlock = () => {
|
|
163
|
+
if (!open) return;
|
|
164
|
+
const block = message.content[open.index];
|
|
165
|
+
if (open.type === 'text') stream.push({ type: 'text_end', contentIndex: open.index, content: (block as { text: string }).text, partial: message });
|
|
166
|
+
else stream.push({ type: 'thinking_end', contentIndex: open.index, content: (block as { thinking: string }).thinking, partial: message });
|
|
167
|
+
open = undefined;
|
|
168
|
+
};
|
|
169
|
+
const finish = (reason: 'stop' | 'length' | 'toolUse') => {
|
|
170
|
+
if (finished) return; finished = true;
|
|
171
|
+
clearTimeout(batchTimer); detach(); signal?.removeEventListener('abort', onAbort);
|
|
172
|
+
closeBlock();
|
|
173
|
+
message.stopReason = reason;
|
|
174
|
+
stream.push({ type: 'done', reason, message }); stream.end(message); resolve();
|
|
175
|
+
};
|
|
176
|
+
const onAbort = () => {
|
|
177
|
+
if (finished) return; finished = true;
|
|
178
|
+
clearTimeout(batchTimer); detach();
|
|
179
|
+
session.rejectParked('Pi aborted the turn.');
|
|
180
|
+
session.abandonPrompt(); // cancels on Grok and frees the session for the next Pi message
|
|
181
|
+
closeBlock();
|
|
182
|
+
fail('aborted', 'Request was aborted'); resolve();
|
|
183
|
+
};
|
|
184
|
+
const onEvent = (event: TurnEvent) => {
|
|
185
|
+
if (finished) return;
|
|
186
|
+
switch (event.kind) {
|
|
187
|
+
case 'text':
|
|
188
|
+
case 'thought': {
|
|
189
|
+
const type = event.kind === 'text' ? 'text' : 'thinking';
|
|
190
|
+
if (open && open.type !== type) closeBlock();
|
|
191
|
+
if (!open) {
|
|
192
|
+
const index = message.content.push(type === 'text' ? { type: 'text', text: '' } : { type: 'thinking', thinking: '' }) - 1;
|
|
193
|
+
open = { type, index };
|
|
194
|
+
stream.push(type === 'text' ? { type: 'text_start', contentIndex: index, partial: message } : { type: 'thinking_start', contentIndex: index, partial: message });
|
|
195
|
+
}
|
|
196
|
+
const block = message.content[open.index] as { text?: string; thinking?: string };
|
|
197
|
+
if (type === 'text') { block.text! += event.delta; stream.push({ type: 'text_delta', contentIndex: open.index, delta: event.delta, partial: message }); }
|
|
198
|
+
else { block.thinking! += event.delta; stream.push({ type: 'thinking_delta', contentIndex: open.index, delta: event.delta, partial: message }); }
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
case 'toolcall': {
|
|
202
|
+
closeBlock();
|
|
203
|
+
const toolCall: ToolCall = { type: 'toolCall', id: event.toolCallId, name: event.name, arguments: event.arguments as ToolCall['arguments'] };
|
|
204
|
+
const index = message.content.push(toolCall) - 1;
|
|
205
|
+
stream.push({ type: 'toolcall_start', contentIndex: index, partial: message });
|
|
206
|
+
stream.push({ type: 'toolcall_delta', contentIndex: index, delta: JSON.stringify(event.arguments), partial: message });
|
|
207
|
+
stream.push({ type: 'toolcall_end', contentIndex: index, toolCall, partial: message });
|
|
208
|
+
clearTimeout(batchTimer);
|
|
209
|
+
batchTimer = setTimeout(() => finish('toolUse'), BATCH_GRACE_MS);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
case 'complete': {
|
|
213
|
+
message.usage = usageFrom(event.usage);
|
|
214
|
+
message.rawStopReason = event.response.stopReason;
|
|
215
|
+
// Grok reports `cancelled` both for our session/cancel and for a rejected permission prompt.
|
|
216
|
+
// Only the former is a Pi abort; a rejected prompt is an ordinary end of turn Grok already narrated.
|
|
217
|
+
if (event.response.stopReason === 'cancelled') { if (signal?.aborted) onAbort(); else finish('stop'); return; }
|
|
218
|
+
finish(event.response.stopReason === 'max_tokens' ? 'length' : 'stop');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
case 'error': {
|
|
222
|
+
if (finished) return; finished = true;
|
|
223
|
+
detach(); signal?.removeEventListener('abort', onAbort);
|
|
224
|
+
closeBlock();
|
|
225
|
+
const dropped = /connection closed|socket|EPIPE|closed/i.test(event.error.message);
|
|
226
|
+
fail('error', dropped ? `Grok connection dropped mid-turn (${event.error.message}). Send the message again; the next turn reconnects and reloads the Grok session.` : event.error.message);
|
|
227
|
+
resolve();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
detach = session.consume(onEvent);
|
|
233
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
234
|
+
if (signal?.aborted) onAbort();
|
|
235
|
+
void reject;
|
|
236
|
+
});
|
|
237
|
+
})().catch((error) => {
|
|
238
|
+
const aborted = signal?.aborted || (error instanceof Error && error.name === 'AbortError');
|
|
239
|
+
fail(aborted ? 'aborted' : 'error', error instanceof Error ? error.message : String(error));
|
|
240
|
+
});
|
|
241
|
+
return stream;
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Grok's ask_user_question reaches the client as the ACP extension request `x.ai/ask_user_question`
|
|
2
|
+
// (`_x.ai/ask_user_question` on the wire). The response is one of four outcomes Grok's tool already
|
|
3
|
+
// understands; Pi maps them onto its own dialogs. No new outcome is invented.
|
|
4
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
|
|
6
|
+
export type QuestionOption = { label: string; description: string; preview?: string; id?: string };
|
|
7
|
+
export type Question = { question: string; options: QuestionOption[]; multiSelect?: boolean; multi_select?: boolean };
|
|
8
|
+
export type AskUserQuestionRequest = { sessionId: string; toolCallId: string; questions: Question[]; mode: 'default' | 'plan' };
|
|
9
|
+
export type AskUserQuestionResponse =
|
|
10
|
+
| { outcome: 'accepted'; answers: Record<string, string[]>; annotations?: Record<string, { preview?: string; notes?: string }> }
|
|
11
|
+
| { outcome: 'chat_about_this'; partial_answers: Record<string, string> }
|
|
12
|
+
| { outcome: 'skip_interview'; partial_answers: Record<string, string> }
|
|
13
|
+
| { outcome: 'cancelled' };
|
|
14
|
+
|
|
15
|
+
const OTHER = 'Other';
|
|
16
|
+
const CHAT = 'Chat about this';
|
|
17
|
+
const SKIP = 'Skip interview and plan immediately';
|
|
18
|
+
const DONE = 'Done selecting';
|
|
19
|
+
|
|
20
|
+
export type Answerer = (request: AskUserQuestionRequest) => Promise<AskUserQuestionResponse>;
|
|
21
|
+
|
|
22
|
+
/** Interactive: one Pi dialog per question. Headless: cancelled, which Grok's tool reports to the model as unanswered. */
|
|
23
|
+
export function questionAnswerer(ctx: Pick<ExtensionContext, 'hasUI' | 'ui'>): Answerer {
|
|
24
|
+
return async (request) => {
|
|
25
|
+
if (!ctx.hasUI) return { outcome: 'cancelled' };
|
|
26
|
+
const answers: Record<string, string[]> = {};
|
|
27
|
+
const annotations: Record<string, { preview?: string; notes?: string }> = {};
|
|
28
|
+
const partial = () => Object.fromEntries(Object.entries(answers).map(([q, a]) => [q, a[0] ?? OTHER]));
|
|
29
|
+
for (const [index, q] of request.questions.entries()) {
|
|
30
|
+
const multi = q.multiSelect === true || q.multi_select === true;
|
|
31
|
+
const title = `Grok asks (${index + 1}/${request.questions.length}): ${q.question}`;
|
|
32
|
+
const labels = q.options.map((o) => o.description ? `${o.label} — ${o.description}` : o.label);
|
|
33
|
+
const extras = [OTHER, ...(request.mode === 'plan' ? [CHAT, SKIP] : [])];
|
|
34
|
+
const byLabel = new Map(labels.map((l, i) => [l, q.options[i]]));
|
|
35
|
+
const picked: QuestionOption[] = [];
|
|
36
|
+
for (;;) {
|
|
37
|
+
const menu = multi ? [...labels.filter((l) => !picked.includes(byLabel.get(l)!)), ...(picked.length ? [DONE] : []), ...extras] : [...labels, ...extras];
|
|
38
|
+
const choice = await ctx.ui.select(picked.length ? `${title} (selected: ${picked.map((p) => p.label).join(', ')})` : title, menu);
|
|
39
|
+
if (choice === undefined) return { outcome: 'cancelled' };
|
|
40
|
+
if (choice === CHAT) return { outcome: 'chat_about_this', partial_answers: partial() };
|
|
41
|
+
if (choice === SKIP) return { outcome: 'skip_interview', partial_answers: partial() };
|
|
42
|
+
if (choice === DONE) break;
|
|
43
|
+
if (choice === OTHER) {
|
|
44
|
+
const notes = await ctx.ui.input('Your answer', 'Type a reply for Grok');
|
|
45
|
+
if (notes === undefined) return { outcome: 'cancelled' };
|
|
46
|
+
answers[q.question] = [OTHER];
|
|
47
|
+
annotations[q.question] = { notes };
|
|
48
|
+
picked.length = 0;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
const option = byLabel.get(choice);
|
|
52
|
+
if (!option) continue;
|
|
53
|
+
picked.push(option);
|
|
54
|
+
if (!multi) break;
|
|
55
|
+
}
|
|
56
|
+
if (picked.length) {
|
|
57
|
+
answers[q.question] = picked.map((p) => p.label);
|
|
58
|
+
if (!multi && picked[0].preview) annotations[q.question] = { preview: picked[0].preview };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const response: AskUserQuestionResponse = { outcome: 'accepted', answers };
|
|
62
|
+
if (Object.keys(annotations).length) response.annotations = annotations;
|
|
63
|
+
return response;
|
|
64
|
+
};
|
|
65
|
+
}
|