@pi-unipi/image 2.2.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,149 @@
1
+ /**
2
+ * @pi-unipi/image — Settings
3
+ *
4
+ * Config lives at `~/.unipi/config/image/config.json`. Every read is
5
+ * try/catch-to-defaults so a corrupt file can never break the tools.
6
+ *
7
+ * The config directory is overridable via `UNIPI_IMAGE_CONFIG_DIR` so tests
8
+ * do not have to reach into the real home directory.
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+
15
+ /** Default system prompt for image recognition. */
16
+ export const DEFAULT_RECOGNIZE_SYSTEM_PROMPT =
17
+ "You are a precise image analyst assisting a software engineer. " +
18
+ "Describe what is actually visible — never speculate about what is not shown. " +
19
+ "For screenshots, transcribe visible text, UI structure, and any errors verbatim. " +
20
+ "For diagrams, describe the components and their relationships. " +
21
+ "For photographs, describe the subject, setting, and notable detail. " +
22
+ "Be specific and concise; lead with the single most important observation.";
23
+
24
+ /** Default image-generation model. */
25
+ export const DEFAULT_GENERATE_MODEL = "openrouter/google/gemini-3-pro-image";
26
+
27
+ export interface GenerateSettings {
28
+ /** Whether the image_generate tool is registered. */
29
+ enabled: boolean;
30
+ /** Model as "provider/model-id". */
31
+ model: string;
32
+ /** Directory for saved images. `~` is expanded. */
33
+ outputDir: string;
34
+ /** Whether to also write generated images to disk. */
35
+ saveToDisk: boolean;
36
+ }
37
+
38
+ export interface RecognizeSettings {
39
+ /** Whether the image_recognize tool is registered. */
40
+ enabled: boolean;
41
+ /** Model as "provider/model-id". Empty = use the session's current model. */
42
+ model: string;
43
+ /** System prompt sent with every recognition request. */
44
+ systemPrompt: string;
45
+ }
46
+
47
+ export interface ImageConfig {
48
+ generate: GenerateSettings;
49
+ recognize: RecognizeSettings;
50
+ }
51
+
52
+ export const DEFAULT_CONFIG: ImageConfig = {
53
+ generate: {
54
+ enabled: true,
55
+ model: DEFAULT_GENERATE_MODEL,
56
+ outputDir: "~/.unipi/images",
57
+ saveToDisk: true,
58
+ },
59
+ recognize: {
60
+ enabled: true,
61
+ model: "",
62
+ systemPrompt: DEFAULT_RECOGNIZE_SYSTEM_PROMPT,
63
+ },
64
+ };
65
+
66
+ /** Resolve the config directory, honouring the test override. */
67
+ export function getConfigDir(): string {
68
+ const override = process.env.UNIPI_IMAGE_CONFIG_DIR;
69
+ if (override && override.trim().length > 0) return override;
70
+ return path.join(os.homedir(), ".unipi", "config", "image");
71
+ }
72
+
73
+ function getConfigPath(): string {
74
+ return path.join(getConfigDir(), "config.json");
75
+ }
76
+
77
+ /** Expand a leading `~` to the user's home directory. */
78
+ export function expandHome(target: string): string {
79
+ if (target === "~") return os.homedir();
80
+ if (target.startsWith("~/") || target.startsWith("~\\")) {
81
+ return path.join(os.homedir(), target.slice(2));
82
+ }
83
+ return target;
84
+ }
85
+
86
+ function isRecord(value: unknown): value is Record<string, unknown> {
87
+ return value !== null && typeof value === "object" && !Array.isArray(value);
88
+ }
89
+
90
+ /** Merge a loaded section over its defaults, ignoring wrong-typed fields. */
91
+ function mergeSection<T extends object>(defaults: T, loaded: unknown): T {
92
+ if (!isRecord(loaded)) return { ...defaults };
93
+
94
+ const merged: T = { ...defaults };
95
+ for (const key of Object.keys(defaults) as Array<keyof T & string>) {
96
+ const value = loaded[key];
97
+ if (value === undefined || value === null) continue;
98
+ // Only accept a value whose type matches the default's.
99
+ if (typeof value === typeof defaults[key]) {
100
+ merged[key] = value as T[keyof T & string];
101
+ }
102
+ }
103
+ return merged;
104
+ }
105
+
106
+ /** Load config from disk, falling back to defaults on any problem. */
107
+ export function loadConfig(): ImageConfig {
108
+ try {
109
+ const raw = fs.readFileSync(getConfigPath(), "utf-8");
110
+ const parsed: unknown = JSON.parse(raw);
111
+ if (!isRecord(parsed)) return structuredClone(DEFAULT_CONFIG);
112
+
113
+ return {
114
+ generate: mergeSection(DEFAULT_CONFIG.generate, parsed.generate),
115
+ recognize: mergeSection(DEFAULT_CONFIG.recognize, parsed.recognize),
116
+ };
117
+ } catch {
118
+ return structuredClone(DEFAULT_CONFIG);
119
+ }
120
+ }
121
+
122
+ /** Persist config. Returns false instead of throwing when the write fails. */
123
+ export function saveConfig(config: ImageConfig): boolean {
124
+ try {
125
+ const dir = getConfigDir();
126
+ fs.mkdirSync(dir, { recursive: true });
127
+ fs.writeFileSync(getConfigPath(), `${JSON.stringify(config, null, 2)}\n`, "utf-8");
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /** Apply a partial update, merging one level deep. */
135
+ export function updateConfig(partial: Partial<ImageConfig>): ImageConfig {
136
+ const current = loadConfig();
137
+ const next: ImageConfig = {
138
+ generate: { ...current.generate, ...partial.generate },
139
+ recognize: { ...current.recognize, ...partial.recognize },
140
+ };
141
+ saveConfig(next);
142
+ return next;
143
+ }
144
+
145
+ /** Resolved absolute output directory for generated images. */
146
+ export function getOutputDir(config: ImageConfig = loadConfig()): string {
147
+ const dir = config.generate.outputDir?.trim();
148
+ return expandHome(dir && dir.length > 0 ? dir : DEFAULT_CONFIG.generate.outputDir);
149
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * @pi-unipi/image — Agent tool registration
3
+ *
4
+ * Registers `image_generate` and `image_recognize`. Each tool is registered
5
+ * only when enabled in config, so a user who wants just one does not have the
6
+ * other consuming context in the system prompt.
7
+ */
8
+
9
+ import { Type } from "typebox";
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import { IMAGE_TOOLS } from "@pi-unipi/core";
12
+
13
+ import { generateImage } from "./generate.js";
14
+ import { loadImage } from "./image-source.js";
15
+ import {
16
+ formatModelRef,
17
+ listImageGenModels,
18
+ resolveImageGenModel,
19
+ resolveVisionModel,
20
+ splitModelRef,
21
+ type ChatModelRegistry,
22
+ type VisionModel,
23
+ } from "./models.js";
24
+ import { recognizeImage } from "./recognize.js";
25
+ import { getOutputDir, loadConfig } from "./settings.js";
26
+
27
+ /** Error shape shared by both tools. */
28
+ function errorResult(message: string) {
29
+ return {
30
+ content: [{ type: "text" as const, text: message }],
31
+ isError: true,
32
+ details: {},
33
+ };
34
+ }
35
+
36
+ function messageOf(error: unknown): string {
37
+ return error instanceof Error ? error.message : String(error);
38
+ }
39
+
40
+ /** Read the chat model registry off the extension context. */
41
+ function getRegistry(ctx: ExtensionContext): ChatModelRegistry | undefined {
42
+ return (ctx as unknown as { modelRegistry?: ChatModelRegistry }).modelRegistry;
43
+ }
44
+
45
+ /** Resolve an API key for a provider through pi's auth storage. */
46
+ async function resolveApiKey(
47
+ registry: ChatModelRegistry | undefined,
48
+ provider: string,
49
+ ): Promise<string | undefined> {
50
+ try {
51
+ const key = await registry?.getApiKeyForProvider?.(provider);
52
+ if (key) return key;
53
+ } catch {
54
+ // Fall through to the environment.
55
+ }
56
+
57
+ const envName = `${provider.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
58
+ return process.env[envName] || undefined;
59
+ }
60
+
61
+ /** Look up the full pi-ai model object for a vision model. */
62
+ function findChatModel(
63
+ registry: ChatModelRegistry,
64
+ model: VisionModel,
65
+ ): { baseUrl?: string; api?: string } | undefined {
66
+ try {
67
+ return registry.find(model.provider, model.id) as
68
+ | { baseUrl?: string; api?: string }
69
+ | undefined;
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ export function registerImageTools(pi: ExtensionAPI): void {
76
+ const config = loadConfig();
77
+
78
+ if (config.generate.enabled) registerGenerateTool(pi);
79
+ if (config.recognize.enabled) registerRecognizeTool(pi);
80
+ }
81
+
82
+ function registerGenerateTool(pi: ExtensionAPI): void {
83
+ pi.registerTool({
84
+ name: IMAGE_TOOLS.GENERATE,
85
+ label: "Generate Image",
86
+ description:
87
+ "Generate an image from a text prompt using an image model. " +
88
+ "The image is returned inline and, when enabled, saved to disk.",
89
+ promptSnippet: "Generate an image from a text prompt.",
90
+ promptGuidelines: [
91
+ "Use image_generate to create images from a text description.",
92
+ "Write a detailed prompt — subject, style, composition and lighting all help.",
93
+ "Omit model to use the one configured in /unipi:image-settings.",
94
+ "Generated images cost money per call; do not regenerate without being asked.",
95
+ ],
96
+ parameters: Type.Object({
97
+ prompt: Type.String({
98
+ description: "Description of the image to generate. Be specific.",
99
+ }),
100
+ model: Type.Optional(
101
+ Type.String({
102
+ description:
103
+ 'Image model override, e.g. "flux.2-pro" or ' +
104
+ '"openrouter/google/gemini-3-pro-image". Omit to use the configured default.',
105
+ }),
106
+ ),
107
+ }),
108
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
109
+ try {
110
+ const config = loadConfig();
111
+ const models = await listImageGenModels();
112
+
113
+ const requested = params.model?.trim() || config.generate.model;
114
+ const resolved = resolveImageGenModel(requested, models);
115
+ if (typeof resolved === "string") return errorResult(resolved);
116
+
117
+ // pi-ai resolves image auth from its own credential store; only fall
118
+ // back to pi's chat-provider key when that comes up empty.
119
+ const registry = getRegistry(ctx);
120
+ const fallbackKey = await resolveApiKey(registry, resolved.provider);
121
+
122
+ const result = await generateImage({
123
+ prompt: params.prompt,
124
+ model: resolved,
125
+ ...(fallbackKey ? { apiKey: fallbackKey } : {}),
126
+ signal,
127
+ outputDir: config.generate.saveToDisk ? getOutputDir(config) : undefined,
128
+ });
129
+
130
+ const saved = result.images
131
+ .map((image) => image.path)
132
+ .filter((path): path is string => Boolean(path));
133
+
134
+ const summary = [
135
+ `Generated ${result.images.length} image${result.images.length === 1 ? "" : "s"} ` +
136
+ `with ${formatModelRef(resolved)}.`,
137
+ saved.length > 0 ? `Saved to:\n${saved.map((p) => ` ${p}`).join("\n")}` : "",
138
+ config.generate.saveToDisk && saved.length === 0
139
+ ? "Could not write to the output directory — returning the image inline only."
140
+ : "",
141
+ result.text,
142
+ ]
143
+ .filter(Boolean)
144
+ .join("\n");
145
+
146
+ return {
147
+ content: [
148
+ { type: "text" as const, text: summary },
149
+ ...result.images.map((image) => ({
150
+ type: "image" as const,
151
+ data: image.data,
152
+ mimeType: image.mimeType,
153
+ })),
154
+ ],
155
+ details: {
156
+ model: formatModelRef(resolved),
157
+ count: result.images.length,
158
+ paths: saved,
159
+ },
160
+ };
161
+ } catch (error) {
162
+ return errorResult(`Image generation failed: ${messageOf(error)}`);
163
+ }
164
+ },
165
+ });
166
+ }
167
+
168
+ function registerRecognizeTool(pi: ExtensionAPI): void {
169
+ pi.registerTool({
170
+ name: IMAGE_TOOLS.RECOGNIZE,
171
+ label: "Recognize Image",
172
+ description:
173
+ "Analyze an image and answer questions about it using a vision model. " +
174
+ "Accepts a local file path, a data: URL, or base64 image data.",
175
+ promptSnippet: "Analyze an image and answer questions about it.",
176
+ promptGuidelines: [
177
+ "Use image_recognize to read screenshots, diagrams, mockups and photos.",
178
+ "Pass a local file path whenever possible — it is cheaper than inlining base64.",
179
+ "Ask a specific question in `prompt` to focus the analysis.",
180
+ "Remote URLs are not fetched; download the image first.",
181
+ ],
182
+ parameters: Type.Object({
183
+ image: Type.String({
184
+ description:
185
+ "Local file path, data: URL, or base64 image data. " +
186
+ "Supported types: PNG, JPEG, GIF, WebP.",
187
+ }),
188
+ prompt: Type.Optional(
189
+ Type.String({
190
+ description:
191
+ "What to ask about the image. Defaults to a general description.",
192
+ }),
193
+ ),
194
+ model: Type.Optional(
195
+ Type.String({
196
+ description:
197
+ "Vision model override. Must accept image input. " +
198
+ "Omit to use the configured default or the current session model.",
199
+ }),
200
+ ),
201
+ systemPrompt: Type.Optional(
202
+ Type.String({
203
+ description:
204
+ "Override the configured system prompt for this call only.",
205
+ }),
206
+ ),
207
+ }),
208
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
209
+ try {
210
+ const config = loadConfig();
211
+
212
+ const registry = getRegistry(ctx);
213
+ if (!registry) {
214
+ return errorResult(
215
+ "Model registry unavailable — image_recognize needs an active session.",
216
+ );
217
+ }
218
+
219
+ // Precedence: per-call override, configured model, session model.
220
+ const requested =
221
+ params.model?.trim() ||
222
+ config.recognize.model.trim() ||
223
+ currentSessionModel(ctx);
224
+
225
+ if (!requested) {
226
+ return errorResult(
227
+ "No vision model configured.\n" +
228
+ "→ Choose one with /unipi:image-settings, or pass `model`.",
229
+ );
230
+ }
231
+
232
+ const resolved = resolveVisionModel(requested, registry);
233
+ if (typeof resolved === "string") return errorResult(resolved);
234
+
235
+ const image = loadImage(params.image, ctx.cwd ?? process.cwd());
236
+
237
+ const chatModel = findChatModel(registry, resolved);
238
+ const baseUrl = chatModel?.baseUrl;
239
+ if (!baseUrl) {
240
+ return errorResult(
241
+ `Could not determine the API endpoint for ${formatModelRef(resolved)}.`,
242
+ );
243
+ }
244
+
245
+ const apiKey = await resolveApiKey(registry, resolved.provider);
246
+ if (!apiKey) {
247
+ return errorResult(
248
+ `No API key for provider "${resolved.provider}".\n` +
249
+ "→ Sign in with /login, or set the provider's API key environment variable.",
250
+ );
251
+ }
252
+
253
+ const result = await recognizeImage({
254
+ image,
255
+ prompt: params.prompt?.trim() || "Describe this image in detail.",
256
+ systemPrompt: params.systemPrompt?.trim() || config.recognize.systemPrompt,
257
+ apiKey,
258
+ baseUrl,
259
+ api: chatModel?.api ?? "openai-completions",
260
+ modelId: resolved.id,
261
+ signal,
262
+ });
263
+
264
+ const origin = image.path ? ` (${image.path})` : "";
265
+
266
+ return {
267
+ content: [
268
+ {
269
+ type: "text" as const,
270
+ text: `${result.text}\n\n— analyzed with ${formatModelRef(resolved)}${origin}`,
271
+ },
272
+ ],
273
+ details: {
274
+ model: formatModelRef(resolved),
275
+ mimeType: image.mimeType,
276
+ source: image.source,
277
+ ...(image.path ? { path: image.path } : {}),
278
+ },
279
+ };
280
+ } catch (error) {
281
+ return errorResult(`Image recognition failed: ${messageOf(error)}`);
282
+ }
283
+ },
284
+ });
285
+ }
286
+
287
+ /** The session's current model as "provider/id", when discoverable. */
288
+ function currentSessionModel(ctx: ExtensionContext): string {
289
+ const model = (ctx as unknown as { model?: { provider?: string; id?: string } }).model;
290
+ if (model?.provider && model?.id) {
291
+ return formatModelRef({ provider: model.provider, id: model.id });
292
+ }
293
+ return "";
294
+ }
295
+
296
+ export { splitModelRef };