@oh-my-pi/pi-coding-agent 3.20.1 → 3.21.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/CHANGELOG.md +69 -9
- package/docs/custom-tools.md +3 -3
- package/docs/extensions.md +226 -220
- package/docs/hooks.md +2 -2
- package/docs/sdk.md +3 -3
- package/examples/custom-tools/README.md +2 -2
- package/examples/custom-tools/subagent/index.ts +1 -1
- package/examples/extensions/README.md +76 -74
- package/examples/extensions/todo.ts +2 -5
- package/examples/hooks/custom-compaction.ts +1 -1
- package/examples/hooks/handoff.ts +1 -1
- package/examples/hooks/qna.ts +1 -1
- package/examples/sdk/02-custom-model.ts +1 -1
- package/examples/sdk/12-full-control.ts +1 -1
- package/examples/sdk/README.md +1 -1
- package/package.json +5 -5
- package/src/cli/file-processor.ts +1 -1
- package/src/cli/list-models.ts +1 -1
- package/src/core/agent-session.ts +13 -2
- package/src/core/auth-storage.ts +1 -1
- package/src/core/compaction/branch-summarization.ts +2 -2
- package/src/core/compaction/compaction.ts +2 -2
- package/src/core/compaction/utils.ts +1 -1
- package/src/core/custom-tools/types.ts +1 -1
- package/src/core/extensions/runner.ts +1 -1
- package/src/core/extensions/types.ts +1 -1
- package/src/core/extensions/wrapper.ts +1 -1
- package/src/core/hooks/runner.ts +2 -2
- package/src/core/hooks/types.ts +1 -1
- package/src/core/messages.ts +1 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/model-resolver.ts +1 -1
- package/src/core/sdk.ts +33 -4
- package/src/core/session-manager.ts +11 -22
- package/src/core/settings-manager.ts +66 -1
- package/src/core/slash-commands.ts +12 -5
- package/src/core/system-prompt.ts +27 -3
- package/src/core/title-generator.ts +2 -2
- package/src/core/tools/ask.ts +88 -1
- package/src/core/tools/bash-interceptor.ts +7 -0
- package/src/core/tools/bash.ts +106 -0
- package/src/core/tools/edit-diff.ts +73 -24
- package/src/core/tools/edit.ts +214 -20
- package/src/core/tools/find.ts +155 -0
- package/src/core/tools/gemini-image.ts +279 -56
- package/src/core/tools/git.ts +4 -0
- package/src/core/tools/grep.ts +191 -0
- package/src/core/tools/index.ts +3 -6
- package/src/core/tools/ls.ts +134 -1
- package/src/core/tools/lsp/render.ts +34 -14
- package/src/core/tools/notebook.ts +110 -0
- package/src/core/tools/output.ts +179 -7
- package/src/core/tools/read.ts +122 -9
- package/src/core/tools/render-utils.ts +241 -0
- package/src/core/tools/renderers.ts +40 -828
- package/src/core/tools/review.ts +26 -7
- package/src/core/tools/rulebook.ts +3 -1
- package/src/core/tools/task/index.ts +18 -3
- package/src/core/tools/task/render.ts +5 -0
- package/src/core/tools/task/types.ts +1 -1
- package/src/core/tools/truncate.ts +27 -1
- package/src/core/tools/web-fetch.ts +23 -15
- package/src/core/tools/web-search/index.ts +130 -45
- package/src/core/tools/web-search/providers/anthropic.ts +7 -2
- package/src/core/tools/web-search/providers/exa.ts +2 -1
- package/src/core/tools/web-search/providers/perplexity.ts +6 -1
- package/src/core/tools/web-search/render.ts +5 -0
- package/src/core/tools/web-search/types.ts +13 -0
- package/src/core/tools/write.ts +90 -0
- package/src/core/voice.ts +1 -1
- package/src/main.ts +1 -1
- package/src/modes/interactive/components/assistant-message.ts +1 -1
- package/src/modes/interactive/components/custom-message.ts +1 -1
- package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
- package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
- package/src/modes/interactive/components/footer.ts +1 -1
- package/src/modes/interactive/components/hook-message.ts +1 -1
- package/src/modes/interactive/components/model-selector.ts +1 -1
- package/src/modes/interactive/components/oauth-selector.ts +1 -1
- package/src/modes/interactive/components/settings-defs.ts +49 -0
- package/src/modes/interactive/components/status-line.ts +1 -1
- package/src/modes/interactive/components/tool-execution.ts +93 -538
- package/src/modes/interactive/interactive-mode.ts +19 -7
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/rpc/rpc-client.ts +1 -1
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/prompts/system-prompt.md +4 -0
- package/src/prompts/tools/gemini-image.md +5 -1
- package/src/prompts/tools/output.md +4 -0
- package/src/prompts/tools/web-fetch.md +1 -0
- package/src/prompts/tools/web-search.md +2 -0
- package/src/utils/image-convert.ts +8 -2
- package/src/utils/image-magick.ts +247 -0
- package/src/utils/image-resize.ts +53 -13
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as os from "node:os";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
+
import type { AssistantMessage, ImageContent, Message, OAuthProvider } from "@mariozechner/pi-ai";
|
|
9
10
|
import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
10
|
-
import type { AssistantMessage, ImageContent, Message, OAuthProvider } from "@oh-my-pi/pi-ai";
|
|
11
11
|
import type { SlashCommand } from "@oh-my-pi/pi-tui";
|
|
12
12
|
import {
|
|
13
13
|
CombinedAutocompleteProvider,
|
|
@@ -31,6 +31,7 @@ import { getRecentSessions, type SessionContext, SessionManager } from "../../co
|
|
|
31
31
|
import { loadSlashCommands } from "../../core/slash-commands";
|
|
32
32
|
import { detectNotificationProtocol, isNotificationSuppressed, sendNotification } from "../../core/terminal-notify";
|
|
33
33
|
import { generateSessionTitle, setTerminalTitle } from "../../core/title-generator";
|
|
34
|
+
import { setPreferredImageProvider, setPreferredWebSearchProvider } from "../../core/tools/index";
|
|
34
35
|
import type { TruncationResult } from "../../core/tools/truncate";
|
|
35
36
|
import { VoiceSupervisor } from "../../core/voice-supervisor";
|
|
36
37
|
import { disableProvider, enableProvider } from "../../discovery";
|
|
@@ -1559,7 +1560,10 @@ export class InteractiveMode {
|
|
|
1559
1560
|
case "fileMention": {
|
|
1560
1561
|
// Render compact file mention display
|
|
1561
1562
|
for (const file of message.files) {
|
|
1562
|
-
const text = `${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg("muted", "Read")} ${theme.fg(
|
|
1563
|
+
const text = `${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg("muted", "Read")} ${theme.fg(
|
|
1564
|
+
"accent",
|
|
1565
|
+
file.path,
|
|
1566
|
+
)} ${theme.fg("dim", `(${file.lineCount} lines)`)}`;
|
|
1563
1567
|
this.chatContainer.addChild(new Text(text, 0, 0));
|
|
1564
1568
|
}
|
|
1565
1569
|
break;
|
|
@@ -2362,6 +2366,14 @@ export class InteractiveMode {
|
|
|
2362
2366
|
break;
|
|
2363
2367
|
}
|
|
2364
2368
|
|
|
2369
|
+
// Provider settings - update runtime preferences
|
|
2370
|
+
case "webSearchProvider":
|
|
2371
|
+
setPreferredWebSearchProvider(value as "auto" | "exa" | "perplexity" | "anthropic");
|
|
2372
|
+
break;
|
|
2373
|
+
case "imageProvider":
|
|
2374
|
+
setPreferredImageProvider(value as "auto" | "gemini" | "openrouter");
|
|
2375
|
+
break;
|
|
2376
|
+
|
|
2365
2377
|
// All other settings are handled by the definitions (get/set on SettingsManager)
|
|
2366
2378
|
// No additional side effects needed
|
|
2367
2379
|
}
|
|
@@ -2466,13 +2478,13 @@ export class InteractiveMode {
|
|
|
2466
2478
|
return;
|
|
2467
2479
|
}
|
|
2468
2480
|
|
|
2469
|
-
// Ask about summarization
|
|
2481
|
+
// Ask about summarization (or skip if disabled in settings)
|
|
2470
2482
|
done(); // Close selector first
|
|
2471
2483
|
|
|
2472
|
-
const
|
|
2473
|
-
|
|
2474
|
-
"Create a summary of the branch you're leaving?"
|
|
2475
|
-
|
|
2484
|
+
const branchSummariesEnabled = this.settingsManager.getBranchSummaryEnabled();
|
|
2485
|
+
const wantsSummary = branchSummariesEnabled
|
|
2486
|
+
? await this.showHookConfirm("Summarize branch?", "Create a summary of the branch you're leaving?")
|
|
2487
|
+
: false;
|
|
2476
2488
|
|
|
2477
2489
|
// Set up escape handler and loader if summarizing
|
|
2478
2490
|
let summaryLoader: Loader | undefined;
|
package/src/modes/print-mode.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - `omp --mode json "prompt"` - JSON event stream
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { AssistantMessage, ImageContent } from "@
|
|
9
|
+
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
|
|
10
10
|
import type { AgentSession } from "../core/agent-session";
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Spawns the agent in RPC mode and provides a typed API for all operations.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { ImageContent } from "@mariozechner/pi-ai";
|
|
7
8
|
import type { AgentEvent, AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
8
|
-
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
9
9
|
import type { Subprocess } from "bun";
|
|
10
10
|
import type { SessionStats } from "../../core/agent-session";
|
|
11
11
|
import type { BashResult } from "../../core/bash-executor";
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* Responses and events are emitted as JSON lines on stdout.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { ImageContent, Model } from "@mariozechner/pi-ai";
|
|
8
9
|
import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
9
|
-
import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
|
|
10
10
|
import type { SessionStats } from "../../core/agent-session";
|
|
11
11
|
import type { BashResult } from "../../core/bash-executor";
|
|
12
12
|
import type { CompactionResult } from "../../core/compaction/index";
|
|
@@ -15,6 +15,10 @@ Core behavior:
|
|
|
15
15
|
- If a command fails due to sandboxing or needs elevated access, request approval and rerun.
|
|
16
16
|
- Follow project validation/testing guidance; if checks are not run, suggest them in next steps.
|
|
17
17
|
- Resolve blockers before yielding; do not guess.
|
|
18
|
+
- Use tools to ground answers when external or deterministic info is needed; avoid speculation when a tool can verify.
|
|
19
|
+
- Ask for missing or ambiguous tool parameters instead of guessing; confirm before actions.
|
|
20
|
+
- Minimize tool calls and context usage by narrowing queries and summarizing only what is needed.
|
|
21
|
+
- After each tool result, check relevance; iterate or clarify if results conflict or are insufficient.
|
|
18
22
|
- Use concise, scannable responses; include file paths in backticks; use short bullets for multi-item lists; avoid dumping large files.
|
|
19
23
|
|
|
20
24
|
Documentation:
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
Generate or edit images using
|
|
1
|
+
Generate or edit images using Gemini image models directly or via OpenRouter.
|
|
2
2
|
|
|
3
3
|
Provide a text prompt and optional input images. Use response modalities to request image-only output,
|
|
4
4
|
set aspect ratio or image size, and choose the model explicitly when needed.
|
|
5
|
+
|
|
6
|
+
Prompt tips:
|
|
7
|
+
- Describe subject, composition, style, and lighting in full sentences.
|
|
8
|
+
- For edits, reference the input image and specify the exact changes.
|
|
@@ -21,3 +21,7 @@ Do NOT use when:
|
|
|
21
21
|
- `"raw"` (default): Full output with ANSI codes preserved
|
|
22
22
|
- `"json"`: Structured object with metadata
|
|
23
23
|
- `"stripped"`: Plain text with ANSI codes removed for parsing
|
|
24
|
+
- `offset` (optional): Line number to start reading from (1-indexed)
|
|
25
|
+
- `limit` (optional): Maximum number of lines to read
|
|
26
|
+
|
|
27
|
+
Use offset/limit for line ranges to reduce context usage on large outputs.
|
|
@@ -3,6 +3,8 @@ Allows OMP to search the web and use the results to inform responses
|
|
|
3
3
|
- Returns search result information formatted as search result blocks, including links as markdown hyperlinks
|
|
4
4
|
- Use this tool for accessing information beyond Claude's knowledge cutoff
|
|
5
5
|
- Searches are performed automatically within a single API call
|
|
6
|
+
- Prefer primary sources (papers, official docs) and corroborate key claims with multiple sources
|
|
7
|
+
- Include links for cited sources in the final response
|
|
6
8
|
|
|
7
9
|
Common: system_prompt (guides response style)
|
|
8
10
|
Anthropic-specific: max_tokens
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { convertToPngWithImageMagick } from "./image-magick.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Convert image to PNG format for terminal display.
|
|
3
5
|
* Kitty graphics protocol requires PNG format (f=100).
|
|
6
|
+
* Uses sharp if available, falls back to ImageMagick (magick/convert).
|
|
4
7
|
*/
|
|
5
8
|
export async function convertToPng(
|
|
6
9
|
base64Data: string,
|
|
@@ -11,6 +14,7 @@ export async function convertToPng(
|
|
|
11
14
|
return { data: base64Data, mimeType };
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
// Try sharp first
|
|
14
18
|
try {
|
|
15
19
|
const sharp = (await import("sharp")).default;
|
|
16
20
|
const buffer = Buffer.from(base64Data, "base64");
|
|
@@ -20,7 +24,9 @@ export async function convertToPng(
|
|
|
20
24
|
mimeType: "image/png",
|
|
21
25
|
};
|
|
22
26
|
} catch {
|
|
23
|
-
// Sharp not available
|
|
24
|
-
return null;
|
|
27
|
+
// Sharp not available, try ImageMagick fallback
|
|
25
28
|
}
|
|
29
|
+
|
|
30
|
+
// Fall back to ImageMagick
|
|
31
|
+
return convertToPngWithImageMagick(base64Data, mimeType);
|
|
26
32
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
let imagemagickCommand: string | null | undefined;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect available ImageMagick command.
|
|
5
|
+
* Returns "magick" (IM7) or "convert" (IM6) or null if unavailable.
|
|
6
|
+
*/
|
|
7
|
+
async function detectImageMagick(): Promise<string | null> {
|
|
8
|
+
if (imagemagickCommand !== undefined) {
|
|
9
|
+
return imagemagickCommand;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
for (const cmd of ["magick", "convert"]) {
|
|
13
|
+
try {
|
|
14
|
+
const proc = Bun.spawn([cmd, "-version"], { stdout: "ignore", stderr: "ignore" });
|
|
15
|
+
const code = await proc.exited;
|
|
16
|
+
if (code === 0) {
|
|
17
|
+
imagemagickCommand = cmd;
|
|
18
|
+
return cmd;
|
|
19
|
+
}
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
imagemagickCommand = null;
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run ImageMagick command with buffer input/output.
|
|
29
|
+
*/
|
|
30
|
+
async function runImageMagick(cmd: string, args: string[], input: Buffer): Promise<Buffer> {
|
|
31
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
32
|
+
stdin: new Blob([input]),
|
|
33
|
+
stdout: "pipe",
|
|
34
|
+
stderr: "pipe",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
38
|
+
new Response(proc.stdout).arrayBuffer(),
|
|
39
|
+
new Response(proc.stderr).text(),
|
|
40
|
+
proc.exited,
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
if (exitCode !== 0) {
|
|
44
|
+
throw new Error(`ImageMagick exited with code ${exitCode}: ${stderr}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return Buffer.from(stdout);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Convert image to PNG using ImageMagick.
|
|
52
|
+
* Returns null if ImageMagick is unavailable or conversion fails.
|
|
53
|
+
*/
|
|
54
|
+
export async function convertToPngWithImageMagick(
|
|
55
|
+
base64Data: string,
|
|
56
|
+
_mimeType: string,
|
|
57
|
+
): Promise<{ data: string; mimeType: string } | null> {
|
|
58
|
+
const cmd = await detectImageMagick();
|
|
59
|
+
if (!cmd) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const input = Buffer.from(base64Data, "base64");
|
|
65
|
+
// "-" reads from stdin, "png:-" writes PNG to stdout
|
|
66
|
+
const output = await runImageMagick(cmd, ["-", "png:-"], input);
|
|
67
|
+
return {
|
|
68
|
+
data: output.toString("base64"),
|
|
69
|
+
mimeType: "image/png",
|
|
70
|
+
};
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ImageMagickResizeResult {
|
|
77
|
+
data: string; // base64
|
|
78
|
+
mimeType: string;
|
|
79
|
+
width: number;
|
|
80
|
+
height: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get image dimensions using ImageMagick identify.
|
|
85
|
+
*/
|
|
86
|
+
async function getImageDimensions(cmd: string, buffer: Buffer): Promise<{ width: number; height: number } | null> {
|
|
87
|
+
try {
|
|
88
|
+
// Use identify to get dimensions
|
|
89
|
+
const identifyCmd = cmd === "magick" ? "magick" : "identify";
|
|
90
|
+
const args = cmd === "magick" ? ["identify", "-format", "%w %h", "-"] : ["-format", "%w %h", "-"];
|
|
91
|
+
|
|
92
|
+
const output = await runImageMagick(identifyCmd, args, buffer);
|
|
93
|
+
const [w, h] = output.toString().trim().split(" ").map(Number);
|
|
94
|
+
if (Number.isFinite(w) && Number.isFinite(h)) {
|
|
95
|
+
return { width: w, height: h };
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Fall through
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resize image using ImageMagick.
|
|
105
|
+
* Returns null if ImageMagick is unavailable or operation fails.
|
|
106
|
+
*/
|
|
107
|
+
export async function resizeWithImageMagick(
|
|
108
|
+
base64Data: string,
|
|
109
|
+
_mimeType: string,
|
|
110
|
+
maxWidth: number,
|
|
111
|
+
maxHeight: number,
|
|
112
|
+
maxBytes: number,
|
|
113
|
+
jpegQuality: number,
|
|
114
|
+
): Promise<ImageMagickResizeResult | null> {
|
|
115
|
+
const cmd = await detectImageMagick();
|
|
116
|
+
if (!cmd) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const input = Buffer.from(base64Data, "base64");
|
|
122
|
+
|
|
123
|
+
// Get original dimensions
|
|
124
|
+
const dims = await getImageDimensions(cmd, input);
|
|
125
|
+
if (!dims) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check if already within limits
|
|
130
|
+
if (dims.width <= maxWidth && dims.height <= maxHeight && input.length <= maxBytes) {
|
|
131
|
+
return null; // Signal caller to use original
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Calculate target dimensions maintaining aspect ratio
|
|
135
|
+
let targetWidth = dims.width;
|
|
136
|
+
let targetHeight = dims.height;
|
|
137
|
+
|
|
138
|
+
if (targetWidth > maxWidth) {
|
|
139
|
+
targetHeight = Math.round((targetHeight * maxWidth) / targetWidth);
|
|
140
|
+
targetWidth = maxWidth;
|
|
141
|
+
}
|
|
142
|
+
if (targetHeight > maxHeight) {
|
|
143
|
+
targetWidth = Math.round((targetWidth * maxHeight) / targetHeight);
|
|
144
|
+
targetHeight = maxHeight;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Try PNG first, then JPEG with decreasing quality
|
|
148
|
+
const attempts: Array<{ args: string[]; mimeType: string }> = [
|
|
149
|
+
{ args: ["-", "-resize", `${targetWidth}x${targetHeight}>`, "png:-"], mimeType: "image/png" },
|
|
150
|
+
{
|
|
151
|
+
args: ["-", "-resize", `${targetWidth}x${targetHeight}>`, "-quality", String(jpegQuality), "jpeg:-"],
|
|
152
|
+
mimeType: "image/jpeg",
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
// Add lower quality JPEG attempts
|
|
157
|
+
for (const q of [70, 55, 40]) {
|
|
158
|
+
attempts.push({
|
|
159
|
+
args: ["-", "-resize", `${targetWidth}x${targetHeight}>`, "-quality", String(q), "jpeg:-"],
|
|
160
|
+
mimeType: "image/jpeg",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let best: { buffer: Buffer; mimeType: string } | null = null;
|
|
165
|
+
|
|
166
|
+
for (const attempt of attempts) {
|
|
167
|
+
try {
|
|
168
|
+
const output = await runImageMagick(cmd, attempt.args, input);
|
|
169
|
+
if (output.length <= maxBytes) {
|
|
170
|
+
return {
|
|
171
|
+
data: output.toString("base64"),
|
|
172
|
+
mimeType: attempt.mimeType,
|
|
173
|
+
width: targetWidth,
|
|
174
|
+
height: targetHeight,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (!best || output.length < best.buffer.length) {
|
|
178
|
+
best = { buffer: output, mimeType: attempt.mimeType };
|
|
179
|
+
}
|
|
180
|
+
} catch {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Try progressively smaller dimensions
|
|
184
|
+
const scaleSteps = [0.75, 0.5, 0.35, 0.25];
|
|
185
|
+
for (const scale of scaleSteps) {
|
|
186
|
+
const scaledWidth = Math.round(targetWidth * scale);
|
|
187
|
+
const scaledHeight = Math.round(targetHeight * scale);
|
|
188
|
+
|
|
189
|
+
if (scaledWidth < 100 || scaledHeight < 100) break;
|
|
190
|
+
|
|
191
|
+
for (const q of [85, 70, 55, 40]) {
|
|
192
|
+
try {
|
|
193
|
+
const output = await runImageMagick(
|
|
194
|
+
cmd,
|
|
195
|
+
["-", "-resize", `${scaledWidth}x${scaledHeight}>`, "-quality", String(q), "jpeg:-"],
|
|
196
|
+
input,
|
|
197
|
+
);
|
|
198
|
+
if (output.length <= maxBytes) {
|
|
199
|
+
return {
|
|
200
|
+
data: output.toString("base64"),
|
|
201
|
+
mimeType: "image/jpeg",
|
|
202
|
+
width: scaledWidth,
|
|
203
|
+
height: scaledHeight,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (!best || output.length < best.buffer.length) {
|
|
207
|
+
best = { buffer: output, mimeType: "image/jpeg" };
|
|
208
|
+
}
|
|
209
|
+
} catch {}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Return best attempt even if over limit
|
|
214
|
+
if (best) {
|
|
215
|
+
return {
|
|
216
|
+
data: best.buffer.toString("base64"),
|
|
217
|
+
mimeType: best.mimeType,
|
|
218
|
+
width: targetWidth,
|
|
219
|
+
height: targetHeight,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return null;
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get image dimensions using ImageMagick.
|
|
231
|
+
* Returns null if ImageMagick is unavailable.
|
|
232
|
+
*/
|
|
233
|
+
export async function getImageDimensionsWithImageMagick(
|
|
234
|
+
base64Data: string,
|
|
235
|
+
): Promise<{ width: number; height: number } | null> {
|
|
236
|
+
const cmd = await detectImageMagick();
|
|
237
|
+
if (!cmd) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
243
|
+
return await getImageDimensions(cmd, buffer);
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ImageContent } from "@
|
|
1
|
+
import type { ImageContent } from "@mariozechner/pi-ai";
|
|
2
|
+
import { getImageDimensionsWithImageMagick, resizeWithImageMagick } from "./image-magick.js";
|
|
2
3
|
|
|
3
4
|
export interface ImageResizeOptions {
|
|
4
5
|
maxWidth?: number; // Default: 2000
|
|
@@ -27,6 +28,52 @@ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
|
|
|
27
28
|
jpegQuality: 80,
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Fallback resize using ImageMagick when sharp is unavailable.
|
|
33
|
+
*/
|
|
34
|
+
async function resizeImageWithImageMagick(
|
|
35
|
+
img: ImageContent,
|
|
36
|
+
opts: Required<ImageResizeOptions>,
|
|
37
|
+
): Promise<ResizedImage> {
|
|
38
|
+
// Try to get dimensions first
|
|
39
|
+
const dims = await getImageDimensionsWithImageMagick(img.data);
|
|
40
|
+
const originalWidth = dims?.width ?? 0;
|
|
41
|
+
const originalHeight = dims?.height ?? 0;
|
|
42
|
+
|
|
43
|
+
// Try to resize
|
|
44
|
+
const result = await resizeWithImageMagick(
|
|
45
|
+
img.data,
|
|
46
|
+
img.mimeType,
|
|
47
|
+
opts.maxWidth,
|
|
48
|
+
opts.maxHeight,
|
|
49
|
+
opts.maxBytes,
|
|
50
|
+
opts.jpegQuality,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (result) {
|
|
54
|
+
return {
|
|
55
|
+
data: result.data,
|
|
56
|
+
mimeType: result.mimeType,
|
|
57
|
+
originalWidth,
|
|
58
|
+
originalHeight,
|
|
59
|
+
width: result.width,
|
|
60
|
+
height: result.height,
|
|
61
|
+
wasResized: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ImageMagick not available or resize not needed - return original
|
|
66
|
+
return {
|
|
67
|
+
data: img.data,
|
|
68
|
+
mimeType: img.mimeType,
|
|
69
|
+
originalWidth,
|
|
70
|
+
originalHeight,
|
|
71
|
+
width: originalWidth,
|
|
72
|
+
height: originalHeight,
|
|
73
|
+
wasResized: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
30
77
|
/** Helper to pick the smaller of two buffers */
|
|
31
78
|
function pickSmaller(
|
|
32
79
|
a: { buffer: Buffer; mimeType: string },
|
|
@@ -56,17 +103,8 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
56
103
|
try {
|
|
57
104
|
sharp = (await import("sharp")).default;
|
|
58
105
|
} catch {
|
|
59
|
-
// Sharp not available -
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
data: img.data,
|
|
63
|
-
mimeType: img.mimeType,
|
|
64
|
-
originalWidth: 0,
|
|
65
|
-
originalHeight: 0,
|
|
66
|
-
width: 0,
|
|
67
|
-
height: 0,
|
|
68
|
-
wasResized: false,
|
|
69
|
-
};
|
|
106
|
+
// Sharp not available - try ImageMagick fallback
|
|
107
|
+
return resizeImageWithImageMagick(img, opts);
|
|
70
108
|
}
|
|
71
109
|
|
|
72
110
|
const sharpImg = sharp(buffer);
|
|
@@ -211,5 +249,7 @@ export function formatDimensionNote(result: ResizedImage): string | undefined {
|
|
|
211
249
|
}
|
|
212
250
|
|
|
213
251
|
const scale = result.originalWidth / result.width;
|
|
214
|
-
return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${
|
|
252
|
+
return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${
|
|
253
|
+
result.height
|
|
254
|
+
}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
|
|
215
255
|
}
|