feat(hooks): add read-image-resizer hook
Intercepts Read tool output with image attachments and resizes to comply with Anthropic API limits (≤1568px long edge, ≤5MB). Only activates for Anthropic provider sessions and appends resize metadata (original/new resolution, token count) to tool output. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ImageAttachment, ImageDimensions } from "./types"
|
||||
import { parseImageDimensions } from "./image-dimensions"
|
||||
import { calculateTargetDimensions, resizeImage } from "./image-resizer"
|
||||
import { log } from "../../shared"
|
||||
import { getSessionModel } from "../../shared/session-model-state"
|
||||
const SUPPORTED_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
const TOKEN_DIVISOR = 750
|
||||
interface ResizeEntry {
|
||||
filename: string
|
||||
originalDims: ImageDimensions | null
|
||||
resizedDims: ImageDimensions | null
|
||||
status: "resized" | "within-limits" | "resize-skipped" | "unknown-dims"
|
||||
}
|
||||
function isReadTool(toolName: string): boolean {
|
||||
return toolName.toLowerCase() === "read"
|
||||
}
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
function isImageAttachmentRecord(
|
||||
value: Record<string, unknown>,
|
||||
): value is Record<string, unknown> & ImageAttachment {
|
||||
const filename = value.filename
|
||||
return (
|
||||
typeof value.mime === "string" &&
|
||||
typeof value.url === "string" &&
|
||||
(typeof filename === "undefined" || typeof filename === "string")
|
||||
)
|
||||
}
|
||||
function extractImageAttachments(output: Record<string, unknown>): ImageAttachment[] {
|
||||
const attachmentsValue = output.attachments
|
||||
if (!Array.isArray(attachmentsValue)) {
|
||||
return []
|
||||
}
|
||||
const attachments: ImageAttachment[] = []
|
||||
for (const attachmentValue of attachmentsValue) {
|
||||
const attachmentRecord = asRecord(attachmentValue)
|
||||
if (!attachmentRecord) {
|
||||
continue
|
||||
}
|
||||
|
||||
const mime = attachmentRecord.mime
|
||||
const url = attachmentRecord.url
|
||||
if (typeof mime !== "string" || typeof url !== "string") {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalizedMime = mime.toLowerCase()
|
||||
if (!SUPPORTED_IMAGE_MIMES.has(normalizedMime)) {
|
||||
continue
|
||||
}
|
||||
|
||||
attachmentRecord.mime = normalizedMime
|
||||
attachmentRecord.url = url
|
||||
if (isImageAttachmentRecord(attachmentRecord)) {
|
||||
attachments.push(attachmentRecord)
|
||||
}
|
||||
}
|
||||
|
||||
return attachments
|
||||
}
|
||||
function calculateTokens(width: number, height: number): number {
|
||||
return Math.ceil((width * height) / TOKEN_DIVISOR)
|
||||
}
|
||||
function formatResizeAppendix(entries: ResizeEntry[]): string {
|
||||
const header = entries.some((entry) => entry.status === "resized") ? "[Image Resize Info]" : "[Image Info]"
|
||||
const lines = [`\n\n${header}`]
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.status === "unknown-dims" || !entry.originalDims) {
|
||||
lines.push(`- ${entry.filename}: dimensions could not be parsed`)
|
||||
continue
|
||||
}
|
||||
|
||||
const original = entry.originalDims
|
||||
const originalText = `${original.width}x${original.height}`
|
||||
const originalTokens = calculateTokens(original.width, original.height)
|
||||
|
||||
if (entry.status === "within-limits") {
|
||||
lines.push(`- ${entry.filename}: ${originalText} (within limits, tokens: ${originalTokens})`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.status === "resize-skipped") {
|
||||
lines.push(`- ${entry.filename}: ${originalText} (resize skipped, tokens: ${originalTokens})`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!entry.resizedDims) {
|
||||
lines.push(`- ${entry.filename}: ${originalText} (resize skipped, tokens: ${originalTokens})`)
|
||||
continue
|
||||
}
|
||||
|
||||
const resized = entry.resizedDims
|
||||
const resizedText = `${resized.width}x${resized.height}`
|
||||
const resizedTokens = calculateTokens(resized.width, resized.height)
|
||||
lines.push(
|
||||
`- ${entry.filename}: ${originalText} -> ${resizedText} (resized, tokens: ${originalTokens} -> ${resizedTokens})`,
|
||||
)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
function resolveFilename(attachment: ImageAttachment, index: number): string {
|
||||
if (attachment.filename && attachment.filename.trim().length > 0) {
|
||||
return attachment.filename
|
||||
}
|
||||
|
||||
return `image-${index + 1}`
|
||||
}
|
||||
export function createReadImageResizerHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown },
|
||||
) => {
|
||||
if (!isReadTool(input.tool)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionModel = getSessionModel(input.sessionID)
|
||||
if (sessionModel?.providerID !== "anthropic") {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof output.output !== "string") {
|
||||
return
|
||||
}
|
||||
|
||||
const outputRecord = output as Record<string, unknown>
|
||||
const attachments = extractImageAttachments(outputRecord)
|
||||
if (attachments.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const entries: ResizeEntry[] = []
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
const filename = resolveFilename(attachment, index)
|
||||
|
||||
try {
|
||||
const originalDims = parseImageDimensions(attachment.url, attachment.mime)
|
||||
if (!originalDims) {
|
||||
entries.push({ filename, originalDims: null, resizedDims: null, status: "unknown-dims" })
|
||||
continue
|
||||
}
|
||||
|
||||
const targetDims = calculateTargetDimensions(originalDims.width, originalDims.height)
|
||||
if (!targetDims) {
|
||||
entries.push({
|
||||
filename,
|
||||
originalDims,
|
||||
resizedDims: null,
|
||||
status: "within-limits",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const resizedResult = await resizeImage(attachment.url, attachment.mime, targetDims)
|
||||
if (!resizedResult) {
|
||||
entries.push({
|
||||
filename,
|
||||
originalDims,
|
||||
resizedDims: null,
|
||||
status: "resize-skipped",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
attachment.url = resizedResult.resizedDataUrl
|
||||
|
||||
entries.push({
|
||||
filename,
|
||||
originalDims: resizedResult.original,
|
||||
resizedDims: resizedResult.resized,
|
||||
status: "resized",
|
||||
})
|
||||
} catch (error) {
|
||||
log("[read-image-resizer] attachment processing failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
filename,
|
||||
})
|
||||
entries.push({ filename, originalDims: null, resizedDims: null, status: "unknown-dims" })
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
output.output += formatResizeAppendix(entries)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user