@howaboua/pi-codex-conversion 1.0.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.
@@ -0,0 +1,171 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import {
4
+ createReadTool,
5
+ type AgentToolResult,
6
+ type ExtensionAPI,
7
+ type ExtensionContext,
8
+ type ToolDefinition,
9
+ } from "@mariozechner/pi-coding-agent";
10
+ import { Type, type TSchema } from "@sinclair/typebox";
11
+ import { Text } from "@mariozechner/pi-tui";
12
+
13
+ const VIEW_IMAGE_UNSUPPORTED_MESSAGE = "view_image is not allowed because you do not support image inputs";
14
+ const DETAIL_DESCRIPTION =
15
+ "Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.";
16
+
17
+ interface ViewImageParams {
18
+ path: string;
19
+ detail?: string;
20
+ }
21
+
22
+ interface ViewImageReader {
23
+ execute: (toolCallId: string, params: { path: string }, signal?: AbortSignal) => Promise<AgentToolResult<unknown>>;
24
+ }
25
+
26
+ interface ViewImageReaders {
27
+ resized: ViewImageReader;
28
+ original: ViewImageReader;
29
+ }
30
+
31
+ interface CreateViewImageToolOptions {
32
+ allowOriginalDetail?: boolean;
33
+ createReaders?: (cwd: string) => ViewImageReaders;
34
+ }
35
+
36
+ type ViewImageParameters = ReturnType<typeof createViewImageParameters>;
37
+
38
+ function createViewImageParameters(allowOriginalDetail: boolean) {
39
+ const properties: Record<string, TSchema> = {
40
+ path: Type.String({ description: "Local filesystem path to an image file" }),
41
+ };
42
+ if (allowOriginalDetail) {
43
+ properties.detail = Type.Optional(Type.String({ description: DETAIL_DESCRIPTION }));
44
+ }
45
+ return Type.Object(properties);
46
+ }
47
+
48
+ export function parseViewImageParams(params: unknown): ViewImageParams {
49
+ if (!params || typeof params !== "object" || !("path" in params) || typeof params.path !== "string") {
50
+ throw new Error("view_image requires a string 'path' parameter");
51
+ }
52
+ let detail: string | undefined;
53
+ if ("detail" in params) {
54
+ const rawDetail = params.detail;
55
+ if (rawDetail === null || rawDetail === undefined) {
56
+ detail = undefined;
57
+ } else if (typeof rawDetail !== "string") {
58
+ throw new Error("view_image.detail must be a string when provided");
59
+ } else {
60
+ detail = rawDetail;
61
+ }
62
+ }
63
+ if (detail !== undefined && detail !== "original") {
64
+ throw new Error(
65
+ `view_image.detail only supports \`original\`; omit \`detail\` for default resized behavior, got \`${detail}\``,
66
+ );
67
+ }
68
+ return { path: params.path, detail };
69
+ }
70
+
71
+ function resolveViewImagePath(path: string, cwd: string): string {
72
+ return isAbsolute(path) ? path : resolve(cwd, path);
73
+ }
74
+
75
+ async function ensureViewImagePathIsFile(path: string, cwd: string): Promise<string> {
76
+ const absolutePath = resolveViewImagePath(path, cwd);
77
+ let metadata;
78
+ try {
79
+ metadata = await stat(absolutePath);
80
+ } catch (error) {
81
+ throw new Error(`unable to locate image at \`${absolutePath}\`: ${error instanceof Error ? error.message : String(error)}`);
82
+ }
83
+ if (!metadata.isFile()) {
84
+ throw new Error(`image path \`${absolutePath}\` is not a file`);
85
+ }
86
+ return absolutePath;
87
+ }
88
+
89
+ function normalizeViewImageResult(result: AgentToolResult<unknown>): AgentToolResult<unknown> {
90
+ const imageContent = result.content.find((item) => item.type === "image");
91
+ if (!imageContent || imageContent.type !== "image") {
92
+ throw new Error("view_image expected an image file. Use exec_command for text files.");
93
+ }
94
+ return {
95
+ ...result,
96
+ content: [imageContent],
97
+ };
98
+ }
99
+
100
+ function createDefaultViewImageReaders(cwd: string): ViewImageReaders {
101
+ return {
102
+ resized: createReadTool(cwd),
103
+ original: createReadTool(cwd, { autoResizeImages: false }),
104
+ };
105
+ }
106
+
107
+ function supportsImageInputs(model: ExtensionContext["model"]): boolean {
108
+ return Array.isArray(model?.input) && model.input.includes("image");
109
+ }
110
+
111
+ // Pi exposes image input support on models, but not Codex's finer-grained
112
+ // original-detail capability flag. Keep the heuristic narrow to image-capable
113
+ // Codex-family models until Pi surfaces an explicit capability.
114
+ export function supportsOriginalImageDetail(model: ExtensionContext["model"]): boolean {
115
+ const provider = (model?.provider ?? "").toLowerCase();
116
+ const api = (model?.api ?? "").toLowerCase();
117
+ const id = (model?.id ?? "").toLowerCase();
118
+ return supportsImageInputs(model) && (provider.includes("codex") || api.includes("codex") || id.includes("codex"));
119
+ }
120
+
121
+ export function createViewImageTool(options: CreateViewImageToolOptions = {}): ToolDefinition<ViewImageParameters> {
122
+ const allowOriginalDetail = options.allowOriginalDetail ?? false;
123
+ const parameters = createViewImageParameters(allowOriginalDetail);
124
+ const createReaders = options.createReaders ?? createDefaultViewImageReaders;
125
+
126
+ return {
127
+ name: "view_image",
128
+ label: "view_image",
129
+ description:
130
+ "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).",
131
+ promptSnippet: "View a local image from the filesystem.",
132
+ promptGuidelines: ["Use view_image only for image files. Use exec_command for text-file inspection."],
133
+ parameters,
134
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
135
+ if (!supportsImageInputs(ctx.model)) {
136
+ throw new Error(VIEW_IMAGE_UNSUPPORTED_MESSAGE);
137
+ }
138
+ const typedParams = parseViewImageParams(params);
139
+ if (typedParams.detail === "original" && !allowOriginalDetail) {
140
+ throw new Error("view_image.detail is not available for the current model");
141
+ }
142
+ await ensureViewImagePathIsFile(typedParams.path, ctx.cwd);
143
+ const readers = createReaders(ctx.cwd);
144
+ const reader = typedParams.detail === "original" ? readers.original : readers.resized;
145
+ const result = await reader.execute(toolCallId, { path: typedParams.path }, signal);
146
+ return normalizeViewImageResult(result);
147
+ },
148
+ renderCall(args, theme) {
149
+ return new Text(
150
+ `${theme.fg("toolTitle", theme.bold("view_image"))} ${theme.fg("accent", typeof args.path === "string" ? args.path : "")}`,
151
+ 0,
152
+ 0,
153
+ );
154
+ },
155
+ renderResult(result, { isPartial, expanded }, theme) {
156
+ if (isPartial) {
157
+ return new Text(theme.fg("warning", "Loading image..."), 0, 0);
158
+ }
159
+ const textBlock = result.content.find((item) => item.type === "text");
160
+ let text = theme.fg("success", "Image loaded");
161
+ if (expanded && textBlock?.type === "text") {
162
+ text += `\n${theme.fg("dim", textBlock.text)}`;
163
+ }
164
+ return new Text(text, 0, 0);
165
+ },
166
+ };
167
+ }
168
+
169
+ export function registerViewImageTool(pi: ExtensionAPI, options: CreateViewImageToolOptions = {}): void {
170
+ pi.registerTool(createViewImageTool(options));
171
+ }
@@ -0,0 +1,145 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { Text } from "@mariozechner/pi-tui";
4
+ import { renderWriteStdinCall } from "./codex-rendering.ts";
5
+ import type { ExecSessionManager, UnifiedExecResult } from "./exec-session-manager.ts";
6
+ import { formatUnifiedExecResult } from "./unified-exec-format.ts";
7
+
8
+ const WRITE_STDIN_PARAMETERS = Type.Object({
9
+ session_id: Type.Number({ description: "Identifier of the running unified exec session." }),
10
+ chars: Type.Optional(Type.String({ description: "Bytes to write to stdin. May be empty to poll." })),
11
+ yield_time_ms: Type.Optional(Type.Number({ description: "How long to wait (in milliseconds) for output before yielding." })),
12
+ max_output_tokens: Type.Optional(Type.Number({ description: "Maximum number of tokens to return. Excess output will be truncated." })),
13
+ });
14
+
15
+ interface WriteStdinParams {
16
+ session_id: number;
17
+ chars?: string;
18
+ yield_time_ms?: number;
19
+ max_output_tokens?: number;
20
+ }
21
+
22
+ interface FormattedExecTranscript {
23
+ output: string;
24
+ sessionId?: number;
25
+ exitCode?: number;
26
+ }
27
+
28
+ function parseFormattedExecTranscript(text: string): FormattedExecTranscript {
29
+ const marker = "\nOutput:\n";
30
+ const markerIndex = text.indexOf(marker);
31
+ const output = markerIndex !== -1 ? text.slice(markerIndex + marker.length) : text;
32
+ const sessionMatch = text.match(/Process running with session ID (\d+)/);
33
+ const exitCodeMatch = text.match(/Process exited with code (-?\d+)/);
34
+ return {
35
+ output,
36
+ sessionId: sessionMatch ? Number(sessionMatch[1]) : undefined,
37
+ exitCode: exitCodeMatch ? Number(exitCodeMatch[1]) : undefined,
38
+ };
39
+ }
40
+
41
+ function renderTerminalText(text: string): string {
42
+ let committed = "";
43
+ let line: string[] = [];
44
+ let cursor = 0;
45
+
46
+ for (const char of text) {
47
+ switch (char) {
48
+ case "\r":
49
+ cursor = 0;
50
+ break;
51
+ case "\n":
52
+ committed += `${line.join("")}\n`;
53
+ line = [];
54
+ cursor = 0;
55
+ break;
56
+ case "\b":
57
+ cursor = Math.max(0, cursor - 1);
58
+ break;
59
+ default:
60
+ if (cursor > line.length) {
61
+ line.push(...Array.from({ length: cursor - line.length }, () => " "));
62
+ }
63
+ line[cursor] = char;
64
+ cursor += 1;
65
+ break;
66
+ }
67
+ }
68
+
69
+ return committed + line.join("");
70
+ }
71
+
72
+ function getResultState(result: { details?: unknown; content: Array<{ type: string; text?: string }> }): FormattedExecTranscript {
73
+ const details = isUnifiedExecResult(result.details) ? result.details : undefined;
74
+ const content = result.content.find((item) => item.type === "text");
75
+ if (details) {
76
+ return {
77
+ output: details.output,
78
+ sessionId: details.session_id,
79
+ exitCode: details.exit_code,
80
+ };
81
+ }
82
+ if (content?.type === "text") {
83
+ return parseFormattedExecTranscript(content.text ?? "");
84
+ }
85
+ return { output: "" };
86
+ }
87
+
88
+ function parseWriteStdinParams(params: unknown): WriteStdinParams {
89
+ if (!params || typeof params !== "object" || !("session_id" in params) || typeof params.session_id !== "number") {
90
+ throw new Error("write_stdin requires numeric 'session_id'");
91
+ }
92
+ const chars = "chars" in params && typeof params.chars === "string" ? params.chars : undefined;
93
+ const yield_time_ms = "yield_time_ms" in params && typeof params.yield_time_ms === "number" ? params.yield_time_ms : undefined;
94
+ const max_output_tokens =
95
+ "max_output_tokens" in params && typeof params.max_output_tokens === "number" ? params.max_output_tokens : undefined;
96
+ return { session_id: params.session_id, chars, yield_time_ms, max_output_tokens };
97
+ }
98
+
99
+ function isUnifiedExecResult(details: unknown): details is UnifiedExecResult {
100
+ return typeof details === "object" && details !== null;
101
+ }
102
+
103
+ export function registerWriteStdinTool(pi: ExtensionAPI, sessions: ExecSessionManager): void {
104
+ pi.registerTool({
105
+ name: "write_stdin",
106
+ label: "write_stdin",
107
+ description: "Writes characters to an existing unified exec session and returns recent output.",
108
+ promptSnippet: "Write to an exec session.",
109
+ parameters: WRITE_STDIN_PARAMETERS,
110
+ async execute(_toolCallId, params) {
111
+ const typed = parseWriteStdinParams(params);
112
+ const command = sessions.getSessionCommand(typed.session_id);
113
+ let result: UnifiedExecResult;
114
+ try {
115
+ result = await sessions.write(typed);
116
+ } catch (error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ throw new Error(`write_stdin failed: ${message}`);
119
+ }
120
+ return {
121
+ content: [{ type: "text", text: formatUnifiedExecResult(result, command) }],
122
+ details: result,
123
+ };
124
+ },
125
+ renderCall(args, theme) {
126
+ const sessionId = typeof args.session_id === "number" ? args.session_id : "?";
127
+ const input = typeof args.chars === "string" ? args.chars : undefined;
128
+ const command = typeof sessionId === "number" ? sessions.getSessionCommand(sessionId) : undefined;
129
+ return new Text(renderWriteStdinCall(sessionId, input, command, theme), 0, 0);
130
+ },
131
+ renderResult(result, { expanded, isPartial }, theme) {
132
+ if (isPartial || !expanded) return undefined;
133
+ const state = getResultState(result);
134
+ const output = renderTerminalText(state.output);
135
+ let text = theme.fg("dim", output || "(no output)");
136
+ if (state.sessionId !== undefined) {
137
+ text += `\n${theme.fg("accent", `Session ${state.sessionId} still running`)}`;
138
+ }
139
+ if (state.exitCode !== undefined) {
140
+ text += `\n${theme.fg("muted", `Exit code: ${state.exitCode}`)}`;
141
+ }
142
+ return new Text(text, 0, 0);
143
+ },
144
+ });
145
+ }