@pi-unipi/unipi 2.1.2 → 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.
Files changed (82) hide show
  1. package/README.md +6 -2
  2. package/package.json +24 -23
  3. package/packages/ask-user/ask-ui.ts +45 -31
  4. package/packages/ask-user/launcher-ui.ts +26 -22
  5. package/packages/ask-user/package.json +3 -3
  6. package/packages/ask-user/settings-tui.ts +22 -9
  7. package/packages/autocomplete/package.json +1 -1
  8. package/packages/autocomplete/src/constants.ts +5 -0
  9. package/packages/btw/package.json +2 -2
  10. package/packages/cocoindex/package.json +2 -2
  11. package/packages/compactor/package.json +4 -4
  12. package/packages/compactor/src/tui/settings-overlay.ts +2 -1
  13. package/packages/core/constants.ts +18 -0
  14. package/packages/core/index.ts +1 -0
  15. package/packages/core/package.json +1 -1
  16. package/packages/core/tui-width.ts +127 -0
  17. package/packages/footer/package.json +3 -3
  18. package/packages/footer/src/tui/settings-tui.ts +2 -1
  19. package/packages/image/README.md +82 -0
  20. package/packages/image/index.ts +1 -0
  21. package/packages/image/package.json +57 -0
  22. package/packages/image/skills/image/SKILL.md +73 -0
  23. package/packages/image/src/commands.ts +17 -0
  24. package/packages/image/src/generate.ts +201 -0
  25. package/packages/image/src/image-source.ts +204 -0
  26. package/packages/image/src/index.ts +99 -0
  27. package/packages/image/src/models.ts +290 -0
  28. package/packages/image/src/recognize.ts +223 -0
  29. package/packages/image/src/settings.ts +149 -0
  30. package/packages/image/src/tools.ts +296 -0
  31. package/packages/image/src/tui/model-selector.ts +279 -0
  32. package/packages/image/src/tui/settings-dialog.ts +236 -0
  33. package/packages/info-screen/package.json +2 -2
  34. package/packages/info-screen/tui/info-overlay.ts +3 -2
  35. package/packages/input-shortcuts/package.json +3 -3
  36. package/packages/kanboard/package.json +2 -2
  37. package/packages/mcp/package.json +2 -2
  38. package/packages/mcp/src/tui/add-overlay.ts +10 -7
  39. package/packages/mcp/src/tui/settings-overlay.ts +10 -7
  40. package/packages/memory/package.json +3 -3
  41. package/packages/milestone/package.json +2 -2
  42. package/packages/notify/README.md +4 -1
  43. package/packages/notify/events.ts +130 -81
  44. package/packages/notify/package.json +3 -3
  45. package/packages/notify/permission-prompt-message.ts +95 -0
  46. package/packages/notify/settings.ts +2 -0
  47. package/packages/notify/skills/configure-notify/SKILL.md +31 -2
  48. package/packages/notify/src/__tests__/event-bus.test.ts +3 -2
  49. package/packages/notify/src/__tests__/permission-prompt-message.test.ts +172 -0
  50. package/packages/notify/tui/gotify-setup.ts +2 -1
  51. package/packages/notify/tui/ntfy-setup.ts +2 -1
  52. package/packages/notify/tui/recap-model-selector.ts +2 -2
  53. package/packages/notify/tui/settings-overlay.ts +2 -1
  54. package/packages/notify/tui/telegram-setup.ts +2 -1
  55. package/packages/ralph/package.json +3 -3
  56. package/packages/subagents/package.json +5 -5
  57. package/packages/subagents/src/__tests__/shutdown-stale-ctx.test.ts +185 -0
  58. package/packages/subagents/src/index.ts +52 -10
  59. package/packages/unipi/index.ts +2 -0
  60. package/packages/updater/package.json +3 -3
  61. package/packages/updater/src/tui/changelog-overlay.ts +2 -2
  62. package/packages/updater/src/tui/readme-overlay.ts +2 -1
  63. package/packages/updater/src/tui/update-overlay.ts +2 -1
  64. package/packages/utility/package.json +2 -2
  65. package/packages/utility/src/tui/badge-settings-tui.ts +2 -2
  66. package/packages/utility/src/tui/util-settings-tui.ts +2 -2
  67. package/packages/web-api/README.md +28 -11
  68. package/packages/web-api/package.json +8 -2
  69. package/packages/web-api/skills/web/SKILL.md +36 -9
  70. package/packages/web-api/src/index.ts +15 -0
  71. package/packages/web-api/src/providers/duckduckgo.ts +71 -27
  72. package/packages/web-api/src/providers/firecrawl.ts +1 -1
  73. package/packages/web-api/src/providers/jina-reader.ts +1 -1
  74. package/packages/web-api/src/providers/jina-search.ts +1 -1
  75. package/packages/web-api/src/providers/perplexity.ts +2 -2
  76. package/packages/web-api/src/providers/serpapi.ts +1 -1
  77. package/packages/web-api/src/providers/tavily.ts +1 -1
  78. package/packages/web-api/src/providers/wigolo-client.ts +196 -0
  79. package/packages/web-api/src/providers/wigolo.ts +117 -0
  80. package/packages/web-api/src/settings.ts +1 -0
  81. package/packages/web-api/src/tools.ts +93 -36
  82. package/packages/workflow/package.json +2 -2
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @pi-unipi/core — TUI width helpers
3
+ *
4
+ * pi-tui's differential renderer throws when a rendered line is wider than the
5
+ * terminal (see `TUI.render` in @earendil-works/pi-tui — it writes
6
+ * `~/.pi/agent/pi-crash.log`, stops the TUI and rethrows). That makes any
7
+ * "minimum width" floor a crash waiting to happen on a narrow terminal:
8
+ *
9
+ * const innerWidth = Math.max(40, width - 2); // ← 42-col lines at width=20
10
+ *
11
+ * The invariant every component must hold is:
12
+ *
13
+ * for every returned line: visibleWidth(line) <= width
14
+ *
15
+ * These helpers make that invariant easy to satisfy. They are pure arithmetic
16
+ * so this module stays free of a pi-tui dependency.
17
+ */
18
+
19
+ /**
20
+ * Terminals narrower than this cannot usefully show a bordered box: two
21
+ * columns go to the border, leaving too little for content. Below the
22
+ * threshold, callers should render borderless (see {@link shouldRenderBorder}).
23
+ */
24
+ export const MIN_BORDERED_WIDTH = 12;
25
+
26
+ /** Smallest width any layout is asked to cope with. */
27
+ export const MIN_RENDER_WIDTH = 1;
28
+
29
+ /**
30
+ * Normalize an incoming render width. Guards against `0`, negative, `NaN`
31
+ * and fractional widths, all of which have been observed during terminal
32
+ * resize races.
33
+ */
34
+ export function normalizeWidth(width: number): number {
35
+ if (!Number.isFinite(width)) return MIN_RENDER_WIDTH;
36
+ return Math.max(MIN_RENDER_WIDTH, Math.floor(width));
37
+ }
38
+
39
+ /**
40
+ * Whether a bordered box fits at this width. When false, render the content
41
+ * without `│` side borders so the full width is usable.
42
+ */
43
+ export function shouldRenderBorder(width: number): boolean {
44
+ return normalizeWidth(width) >= MIN_BORDERED_WIDTH;
45
+ }
46
+
47
+ /**
48
+ * Content width inside a bordered box, i.e. the terminal width minus the two
49
+ * border columns, so that `│ + content + │` is `<= width`.
50
+ *
51
+ * Use this for components that always draw a border. Components that can drop
52
+ * the border on narrow terminals should branch on {@link shouldRenderBorder}
53
+ * and use {@link adaptiveInnerWidth} instead.
54
+ */
55
+ export function boxInnerWidth(width: number): number {
56
+ return Math.max(1, normalizeWidth(width) - 2);
57
+ }
58
+
59
+ /**
60
+ * Content width for components that drop their border on narrow terminals:
61
+ * the box inner width when a border fits, otherwise the full width.
62
+ *
63
+ * Pair with {@link shouldRenderBorder} to decide whether to emit the border
64
+ * characters. Together they guarantee every emitted line is `<= width` at any
65
+ * width down to 1.
66
+ */
67
+ export function adaptiveInnerWidth(width: number): number {
68
+ const w = normalizeWidth(width);
69
+ return shouldRenderBorder(w) ? boxInnerWidth(w) : w;
70
+ }
71
+
72
+ /**
73
+ * Width remaining after reserving `reserved` columns for a prefix, indent or
74
+ * gutter. Never returns less than 1, so it is safe to pass to wrapping and
75
+ * truncation helpers (which throw or misbehave on non-positive widths).
76
+ */
77
+ export function contentWidth(available: number, reserved: number): number {
78
+ return Math.max(1, normalizeWidth(available) - Math.max(0, Math.floor(reserved)));
79
+ }
80
+
81
+ /**
82
+ * Clamp a repeat count to a non-negative integer.
83
+ *
84
+ * `String.prototype.repeat` throws `RangeError: Invalid count value` for
85
+ * negative counts, which crashes the render pass.
86
+ */
87
+ export function safeRepeatCount(count: number): number {
88
+ if (!Number.isFinite(count)) return 0;
89
+ return Math.max(0, Math.floor(count));
90
+ }
91
+
92
+ /** `" ".repeat(n)` that cannot throw. */
93
+ export function safeRepeat(char: string, count: number): string {
94
+ return char.repeat(safeRepeatCount(count));
95
+ }
96
+
97
+ /**
98
+ * Width-keyed render cache.
99
+ *
100
+ * Components that cache their rendered lines must invalidate on width change.
101
+ * pi-tui's `requestRender()` does *not* call `invalidate()`, so a component
102
+ * that caches `string[]` without keying on width will return stale, over-wide
103
+ * lines after the terminal is made narrower — and the next differential frame
104
+ * throws.
105
+ */
106
+ export class WidthKeyedCache {
107
+ private lines: string[] | null = null;
108
+ private width = -1;
109
+
110
+ /** Cached lines for this width, or `null` on miss. */
111
+ get(width: number): string[] | null {
112
+ return this.lines !== null && this.width === normalizeWidth(width) ? this.lines : null;
113
+ }
114
+
115
+ /** Store lines for this width. Returns the lines for convenient chaining. */
116
+ set(width: number, lines: string[]): string[] {
117
+ this.lines = lines;
118
+ this.width = normalizeWidth(width);
119
+ return lines;
120
+ }
121
+
122
+ /** Drop the cache — call from `invalidate()` and on any state change. */
123
+ clear(): void {
124
+ this.lines = null;
125
+ this.width = -1;
126
+ }
127
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -26,13 +26,13 @@
26
26
  "README.md"
27
27
  ],
28
28
  "scripts": {
29
- "test": "node --experimental-strip-types --test tests/**/*.test.ts"
29
+ "test": "npx tsx --test tests/**/*.test.ts"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.1.2"
35
+ "@pi-unipi/core": "2.2.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -15,6 +15,7 @@ import { loadFooterSettings, saveFooterSettings } from "../config.js";
15
15
  import { PRESET_NAMES } from "../presets.js";
16
16
  import { setIconStyle } from "../rendering/icons.js";
17
17
  import type { FooterGroup, FooterSettings, SeparatorStyle, IconStyle } from "../types.js";
18
+ import { boxInnerWidth } from "@pi-unipi/core";
18
19
 
19
20
  // ─── Section types ─────────────────────────────────────────────────────
20
21
 
@@ -457,7 +458,7 @@ class FooterSettingsOverlay {
457
458
  // ─── Render ────────────────────────────────────────────────────────
458
459
 
459
460
  render(width: number): string[] {
460
- const innerWidth = Math.max(22, width - 2);
461
+ const innerWidth = boxInnerWidth(width);
461
462
  const lines: string[] = [];
462
463
 
463
464
  // Header
@@ -0,0 +1,82 @@
1
+ # @pi-unipi/image
2
+
3
+ Image generation and image recognition tools for the agent.
4
+
5
+ ## Tools
6
+
7
+ | Tool | Description |
8
+ |------|-------------|
9
+ | `image_generate` | Generate an image from a text prompt. Returned inline and saved to disk. |
10
+ | `image_recognize` | Analyze an image with a vision model. Accepts a file path, `data:` URL, or base64. |
11
+
12
+ ## Commands
13
+
14
+ | Command | Description |
15
+ |---------|-------------|
16
+ | `/unipi:image-settings` | Configure models, output directory and the recognition system prompt |
17
+
18
+ ## image_generate
19
+
20
+ ```
21
+ image_generate(prompt: "A cutaway diagram of a submarine, technical illustration")
22
+ image_generate(prompt: "...", model: "flux.2-pro")
23
+ ```
24
+
25
+ Models come from pi-ai's image catalog — 34 models including FLUX.2,
26
+ Gemini 3 Pro Image, GPT-5 Image, Recraft and Riverflow — all served through
27
+ **OpenRouter**, so an OpenRouter key is required
28
+ ([get one](https://openrouter.ai/keys)).
29
+
30
+ The `model` parameter is fuzzy-matched, so `flux`, `recraft` and
31
+ `gemini-3-pro` all work. Omit it to use the model chosen in
32
+ `/unipi:image-settings`.
33
+
34
+ Images are returned inline **and** written to `~/.unipi/images` by default;
35
+ the saved path is reported back to the agent. A failed write never discards a
36
+ successfully generated image.
37
+
38
+ ## image_recognize
39
+
40
+ ```
41
+ image_recognize(image: "./screenshot.png")
42
+ image_recognize(image: "./error.png", prompt: "What does the stack trace say?")
43
+ ```
44
+
45
+ Uses any chat model whose input modality includes `image`. A model that cannot
46
+ accept images is rejected up front with a clear message rather than failing
47
+ inside the provider.
48
+
49
+ Input can be a local file path, a `data:` URL, or raw base64. The media type is
50
+ detected from the file's magic numbers, so a `.jpg` that is really a PNG still
51
+ works. Supported: PNG, JPEG, GIF, WebP. Remote URLs are not fetched.
52
+
53
+ Prefer file paths — inlining base64 into the conversation is far more
54
+ expensive in tokens.
55
+
56
+ ## Configuration
57
+
58
+ `~/.unipi/config/image/config.json`:
59
+
60
+ ```json
61
+ {
62
+ "generate": {
63
+ "enabled": true,
64
+ "model": "openrouter/google/gemini-3-pro-image",
65
+ "outputDir": "~/.unipi/images",
66
+ "saveToDisk": true
67
+ },
68
+ "recognize": {
69
+ "enabled": true,
70
+ "model": "",
71
+ "systemPrompt": "You are a precise image analyst…"
72
+ }
73
+ }
74
+ ```
75
+
76
+ - `recognize.model` empty means "use the session's current model".
77
+ - The system prompt is fully customizable, and can be overridden per call.
78
+ - Enabling or disabling a tool takes effect next session, since tools are
79
+ registered at startup.
80
+ - Every read falls back to defaults, so a corrupt config never breaks the tools.
81
+
82
+ Set `UNIPI_IMAGE_CONFIG_DIR` to relocate the config directory (used by tests).
@@ -0,0 +1 @@
1
+ export { default } from "./src/index.js";
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@pi-unipi/image",
3
+ "version": "2.2.0",
4
+ "description": "Image generation and image recognition tools for the Pi coding agent",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "license": "MIT",
8
+ "author": "Neuron Mr White",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
12
+ "directory": "packages/image"
13
+ },
14
+ "homepage": "https://github.com/Neuron-Mr-White/unipi#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/Neuron-Mr-White/unipi/issues"
17
+ },
18
+ "keywords": [
19
+ "pi-package",
20
+ "pi-extension",
21
+ "pi-coding-agent",
22
+ "unipi",
23
+ "image",
24
+ "vision",
25
+ "image-generation"
26
+ ],
27
+ "files": [
28
+ "index.ts",
29
+ "src/**/*.ts",
30
+ "skills/**/*",
31
+ "README.md"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "@pi-unipi/core": "2.2.0"
38
+ },
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-ai": "^0.80.0",
41
+ "@earendil-works/pi-coding-agent": "^0.80.0",
42
+ "@earendil-works/pi-tui": "^0.80.0",
43
+ "typebox": "^1.1.38"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^25.6.0"
47
+ },
48
+ "scripts": {
49
+ "test": "npx tsx --test tests/**/*.test.ts"
50
+ },
51
+ "pi": {
52
+ "extensions": [],
53
+ "skills": [],
54
+ "prompts": [],
55
+ "themes": []
56
+ }
57
+ }
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: image
3
+ description: Generate images from text prompts and analyze images with vision models
4
+ ---
5
+
6
+ # Image Tools
7
+
8
+ Two agent tools: `image_generate` creates images from a text prompt, and
9
+ `image_recognize` analyzes an existing image.
10
+
11
+ ## image_generate
12
+
13
+ ```
14
+ image_generate(prompt: "A cutaway diagram of a submarine, technical illustration, muted blues")
15
+ image_generate(prompt: "...", model: "flux.2-pro")
16
+ ```
17
+
18
+ - `prompt` (required) — describe subject, style, composition and lighting.
19
+ Detail materially improves the result.
20
+ - `model` (optional) — fuzzy-matched against the image catalog
21
+ (`flux`, `gemini-3-pro-image`, `recraft-v4`, …). Omit to use the model
22
+ configured in `/unipi:image-settings`.
23
+
24
+ The image is returned inline and, when `saveToDisk` is on (the default),
25
+ written to the output directory (default `~/.unipi/images`). The saved path is
26
+ reported in the result.
27
+
28
+ **Image generation costs money per call.** Never regenerate an image
29
+ speculatively — only when the user asks for a change.
30
+
31
+ Models are served through OpenRouter, so an OpenRouter key is required:
32
+ https://openrouter.ai/keys
33
+
34
+ ## image_recognize
35
+
36
+ ```
37
+ image_recognize(image: "./screenshot.png")
38
+ image_recognize(image: "./error.png", prompt: "What does the stack trace say?")
39
+ image_recognize(image: "...", model: "claude-sonnet", systemPrompt: "Reply only with the visible text.")
40
+ ```
41
+
42
+ - `image` (required) — a local file path, a `data:` URL, or raw base64.
43
+ **Prefer a file path**: inlining base64 into the conversation is far more
44
+ expensive. Remote URLs are not fetched — download first.
45
+ - `prompt` (optional) — the question to ask. Defaults to a general
46
+ description. A specific question gives a far more useful answer.
47
+ - `model` (optional) — must accept image input. Omit to use the configured
48
+ model, falling back to the session's current model.
49
+ - `systemPrompt` (optional) — override the configured system prompt for one
50
+ call.
51
+
52
+ Supported types: PNG, JPEG, GIF, WebP. The type is detected from the file's
53
+ magic numbers, so a misnamed extension still works.
54
+
55
+ ### When to use it
56
+
57
+ - Reading a screenshot of an error, a stack trace, or failing UI
58
+ - Understanding a design mockup or wireframe before implementing it
59
+ - Extracting content from an architecture diagram or flowchart
60
+ - Checking what a rendered page or chart actually looks like
61
+
62
+ ## Configuration
63
+
64
+ `/unipi:image-settings` configures both tools:
65
+
66
+ - Generation model (picker over the image catalog)
67
+ - Recognition model (picker over vision-capable models only)
68
+ - Enable/disable either tool
69
+ - Output directory and whether to save to disk
70
+ - The recognition system prompt
71
+
72
+ Config lives at `~/.unipi/config/image/config.json`. Toggling a tool on or off
73
+ takes effect on the next session, since tools are registered at startup.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @pi-unipi/image — Slash commands
3
+ */
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { IMAGE_COMMANDS, UNIPI_PREFIX } from "@pi-unipi/core";
7
+
8
+ import { showSettingsDialog } from "./tui/settings-dialog.js";
9
+
10
+ export function registerImageCommands(pi: ExtensionAPI): void {
11
+ pi.registerCommand(`${UNIPI_PREFIX}${IMAGE_COMMANDS.SETTINGS}`, {
12
+ description: "Configure image generation and recognition models",
13
+ handler: async (_args, ctx) => {
14
+ await showSettingsDialog(ctx);
15
+ },
16
+ });
17
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * @pi-unipi/image — Image generation
3
+ *
4
+ * Wraps pi-ai's image API. `generateImages` never rejects — failures come back
5
+ * as `stopReason: "error"` — so every call site must inspect the result rather
6
+ * than relying on try/catch.
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+
12
+ import { getImagesModels, type ImageGenModel, type ImagesModelsLike } from "./models.js";
13
+
14
+ export interface GeneratedImage {
15
+ /** Base64 image data. */
16
+ data: string;
17
+ mimeType: string;
18
+ /** Absolute path, when saved to disk. */
19
+ path?: string;
20
+ }
21
+
22
+ export interface GenerateResult {
23
+ images: GeneratedImage[];
24
+ /** Any accompanying commentary from the model. */
25
+ text: string;
26
+ model: string;
27
+ provider: string;
28
+ }
29
+
30
+ /** Structural view of pi-ai's AssistantImages. */
31
+ interface AssistantImagesLike {
32
+ output?: Array<{ type?: string; text?: string; data?: string; mimeType?: string }>;
33
+ stopReason?: string;
34
+ errorMessage?: string;
35
+ usage?: unknown;
36
+ }
37
+
38
+ /** Injectable generation function, for tests. */
39
+ export type GenerateImagesFn = (
40
+ model: unknown,
41
+ context: { input: Array<{ type: string; text?: string }> },
42
+ options: { apiKey?: string; signal?: AbortSignal },
43
+ ) => Promise<AssistantImagesLike>;
44
+
45
+ /** Extension for a media type, for naming saved files. */
46
+ function extensionFor(mimeType: string): string {
47
+ switch (mimeType) {
48
+ case "image/png":
49
+ return ".png";
50
+ case "image/jpeg":
51
+ return ".jpg";
52
+ case "image/webp":
53
+ return ".webp";
54
+ case "image/gif":
55
+ return ".gif";
56
+ case "image/svg+xml":
57
+ return ".svg";
58
+ default:
59
+ return ".img";
60
+ }
61
+ }
62
+
63
+ /** Filesystem-safe slug from a prompt, for a recognizable filename. */
64
+ export function slugify(prompt: string, maxLength = 40): string {
65
+ const slug = prompt
66
+ .toLowerCase()
67
+ .replace(/[^a-z0-9]+/g, "-")
68
+ .replace(/^-+|-+$/g, "")
69
+ .slice(0, maxLength)
70
+ .replace(/-+$/g, "");
71
+ return slug || "image";
72
+ }
73
+
74
+ /** Build a collision-free filename. */
75
+ export function buildFileName(
76
+ prompt: string,
77
+ mimeType: string,
78
+ index: number,
79
+ now: Date = new Date(),
80
+ ): string {
81
+ const stamp = now.toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
82
+ const suffix = index > 0 ? `-${index + 1}` : "";
83
+ return `${stamp}-${slugify(prompt)}${suffix}${extensionFor(mimeType)}`;
84
+ }
85
+
86
+ /**
87
+ * Write an image to disk.
88
+ * @returns the absolute path, or undefined if the write failed (never throws —
89
+ * a failed save must not discard a successfully generated image).
90
+ */
91
+ export function saveImage(
92
+ outputDir: string,
93
+ fileName: string,
94
+ base64: string,
95
+ ): string | undefined {
96
+ try {
97
+ fs.mkdirSync(outputDir, { recursive: true });
98
+ const target = path.join(outputDir, fileName);
99
+ fs.writeFileSync(target, Buffer.from(base64, "base64"));
100
+ return target;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ export interface GenerateOptions {
107
+ prompt: string;
108
+ model: ImageGenModel;
109
+ /** Fallback key, used only when pi-ai's own auth resolution comes up empty. */
110
+ apiKey?: string;
111
+ signal?: AbortSignal;
112
+ /** Absolute directory for saved images; omit to skip saving. */
113
+ outputDir?: string;
114
+ now?: Date;
115
+ /** Injected images collection, for tests. */
116
+ images?: ImagesModelsLike;
117
+ }
118
+
119
+ /**
120
+ * Generate images and optionally save them.
121
+ * @throws {Error} with an actionable message when generation fails.
122
+ */
123
+ export async function generateImage(options: GenerateOptions): Promise<GenerateResult> {
124
+ const { prompt, model, signal, outputDir, now } = options;
125
+
126
+ if (!prompt.trim()) {
127
+ throw new Error("A non-empty prompt is required.");
128
+ }
129
+
130
+ const imagesApi = options.images ?? (await getImagesModels());
131
+ if (!imagesApi) {
132
+ throw new Error(
133
+ "Image generation is unavailable — this version of pi-ai does not expose an image API.",
134
+ );
135
+ }
136
+
137
+ // Prefer pi-ai's own credential store, then the caller-supplied fallback so
138
+ // a bare OPENROUTER_API_KEY still works.
139
+ let apiKey: string | undefined;
140
+ try {
141
+ apiKey = (await imagesApi.getAuth(model))?.apiKey;
142
+ } catch {
143
+ // Reported as a missing key below.
144
+ }
145
+ apiKey ||= options.apiKey;
146
+
147
+ if (!apiKey) {
148
+ throw new Error(
149
+ `No API key for provider "${model.provider}".\n` +
150
+ `→ Add one with /login, or set the provider's API key environment variable.\n` +
151
+ `→ Image models are served through OpenRouter: https://openrouter.ai/keys`,
152
+ );
153
+ }
154
+
155
+ const result = (await imagesApi.generateImages(
156
+ model,
157
+ { input: [{ type: "text", text: prompt }] },
158
+ { apiKey, ...(signal ? { signal } : {}) },
159
+ )) as AssistantImagesLike;
160
+
161
+ // pi-ai reports failures in-band rather than rejecting.
162
+ if (result.stopReason === "error") {
163
+ throw new Error(result.errorMessage || "Image generation failed.");
164
+ }
165
+ if (result.stopReason === "aborted") {
166
+ throw new Error("Image generation was cancelled.");
167
+ }
168
+
169
+ const images: GeneratedImage[] = [];
170
+ const textParts: string[] = [];
171
+
172
+ for (const part of result.output ?? []) {
173
+ if (part?.type === "image" && typeof part.data === "string" && part.data.length > 0) {
174
+ images.push({ data: part.data, mimeType: part.mimeType || "image/png" });
175
+ } else if (part?.type === "text" && typeof part.text === "string" && part.text.trim()) {
176
+ textParts.push(part.text.trim());
177
+ }
178
+ }
179
+
180
+ if (images.length === 0) {
181
+ throw new Error(
182
+ textParts.length > 0
183
+ ? `The model returned no image. It said: ${textParts.join(" ")}`
184
+ : "The model returned no image.",
185
+ );
186
+ }
187
+
188
+ if (outputDir) {
189
+ images.forEach((image, index) => {
190
+ const fileName = buildFileName(prompt, image.mimeType, index, now);
191
+ image.path = saveImage(outputDir, fileName, image.data);
192
+ });
193
+ }
194
+
195
+ return {
196
+ images,
197
+ text: textParts.join("\n"),
198
+ model: model.id,
199
+ provider: model.provider,
200
+ };
201
+ }