@gajae-code/coding-agent 0.11.2 → 0.11.4
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 +34 -0
- package/dist/types/cli/launch-disposition.d.ts +23 -0
- package/dist/types/cli/worktree-cli.d.ts +8 -25
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/worktree.d.ts +7 -32
- package/dist/types/config/settings-schema.d.ts +85 -10
- package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
- package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +8 -3
- package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
- package/dist/types/modes/controllers/input-controller.d.ts +8 -2
- package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
- package/dist/types/sdk/broker/broker.d.ts +1 -0
- package/dist/types/sdk/broker/discovery.d.ts +10 -0
- package/dist/types/sdk/broker/ensure.d.ts +24 -1
- package/dist/types/sdk/broker/internal.d.ts +3 -0
- package/dist/types/sdk/broker/session-index.d.ts +12 -0
- package/dist/types/sdk/broker/state-version.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +2 -0
- package/dist/types/sdk/session.d.ts +2 -0
- package/dist/types/tools/ask.d.ts +39 -12
- package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
- package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
- package/dist/types/tools/path-utils.d.ts +9 -14
- package/dist/types/tools/read.d.ts +2 -0
- package/dist/types/tools/render-utils.d.ts +1 -8
- package/dist/types/tools/resource-gc.d.ts +14 -0
- package/dist/types/tools/subagent.d.ts +1 -0
- package/dist/types/utils/file-mentions.d.ts +3 -3
- package/dist/types/utils/image-loading.d.ts +23 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/utils/pasted-image-loading.d.ts +39 -0
- package/dist/types/utils/pasted-image-path.d.ts +21 -19
- package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
- package/dist/types/web/scrapers/types.d.ts +1 -5
- package/package.json +24 -8
- package/scripts/run-threat-model-check.ts +60 -0
- package/src/cli/launch-disposition.ts +42 -0
- package/src/cli/worktree-cli.ts +9 -287
- package/src/cli.ts +10 -9
- package/src/commands/gc.ts +11 -1
- package/src/commands/sdk.ts +3 -2
- package/src/commands/worktree.ts +10 -53
- package/src/config/model-profiles.ts +15 -15
- package/src/config/settings-schema.ts +55 -10
- package/src/eval/js/shared/runtime.ts +1 -1
- package/src/gjc-runtime/gc-render.ts +19 -1
- package/src/gjc-runtime/gc-runtime.ts +68 -3
- package/src/gjc-runtime/state-argv.ts +292 -0
- package/src/gjc-runtime/state-runtime.ts +51 -212
- package/src/main.ts +99 -22
- package/src/modes/components/custom-editor.ts +103 -32
- package/src/modes/components/custom-provider-wizard.ts +26 -1
- package/src/modes/components/session-observer-overlay.ts +10 -23
- package/src/modes/components/tool-transcript-format.ts +72 -0
- package/src/modes/components/transcript-viewer-overlay.ts +42 -2
- package/src/modes/controllers/input-controller.ts +98 -37
- package/src/modes/controllers/selector-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +13 -2
- package/src/modes/prompt-action-autocomplete.ts +7 -0
- package/src/sdk/broker/broker.ts +139 -32
- package/src/sdk/broker/discovery.ts +125 -0
- package/src/sdk/broker/ensure.ts +103 -19
- package/src/sdk/broker/internal.ts +10 -0
- package/src/sdk/broker/session-index.ts +228 -15
- package/src/sdk/broker/state-version.ts +5 -2
- package/src/sdk/bus/index.ts +4 -1
- package/src/sdk/bus/telegram-daemon.ts +50 -10
- package/src/sdk/host/host.ts +13 -1
- package/src/sdk/session.ts +4 -1
- package/src/session/internal/managed-session-scope.ts +15 -1
- package/src/tools/ask.ts +216 -41
- package/src/tools/bash-allowed-prefixes.ts +79 -29
- package/src/tools/browser/launch.ts +3 -1
- package/src/tools/browser/readable.ts +1 -1
- package/src/tools/browser/screenshot-format.ts +34 -0
- package/src/tools/browser/tab-protocol.ts +3 -0
- package/src/tools/browser/tab-supervisor.ts +125 -122
- package/src/tools/browser/tab-worker-entry.ts +33 -14
- package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
- package/src/tools/browser/tab-worker-smoke.ts +87 -0
- package/src/tools/browser/tab-worker.ts +7 -5
- package/src/tools/output-meta.ts +6 -2
- package/src/tools/path-utils.ts +42 -35
- package/src/tools/read.ts +189 -64
- package/src/tools/render-utils.ts +1 -27
- package/src/tools/resource-gc.ts +123 -34
- package/src/tools/subagent.ts +43 -2
- package/src/utils/file-mentions.ts +4 -4
- package/src/utils/image-loading.ts +85 -34
- package/src/utils/image-resize.ts +16 -9
- package/src/utils/pasted-image-loading.ts +514 -0
- package/src/utils/pasted-image-path.ts +223 -98
- package/src/web/scrapers/html-to-markdown.ts +68 -0
- package/src/web/scrapers/readthedocs.ts +1 -1
- package/src/web/scrapers/types.ts +1 -68
- package/src/web/search/providers/openai-compatible.ts +76 -23
package/src/tools/resource-gc.ts
CHANGED
|
@@ -75,12 +75,31 @@ const defaultDeps: ResourceGcDeps = {
|
|
|
75
75
|
|
|
76
76
|
// ── Controller state (process-global; tabs/browsers are module-global too) ──────────────────
|
|
77
77
|
const activeSessions = new Map<string, Settings>();
|
|
78
|
-
|
|
78
|
+
|
|
79
|
+
interface ScheduleOwner {
|
|
80
|
+
generation: number;
|
|
81
|
+
token: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface DeferredSchedule {
|
|
85
|
+
generation: number;
|
|
86
|
+
deadline: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface WorkOwner {
|
|
90
|
+
generation: number;
|
|
91
|
+
source: "timer" | "external";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let pendingTimer: NodeJS.Timeout | null = null;
|
|
95
|
+
let pendingDeadline: number | null = null;
|
|
96
|
+
let pendingOwner: ScheduleOwner | null = null;
|
|
97
|
+
let deferredSchedule: DeferredSchedule | null = null;
|
|
98
|
+
let inProgressOwner: WorkOwner | null = null;
|
|
79
99
|
let stopped = false;
|
|
80
|
-
// Bumped on every stop so an in-flight tick from a previous schedule cannot reschedule after a
|
|
81
|
-
// stop+re-register and leak a duplicate timer.
|
|
82
100
|
let timerGeneration = 0;
|
|
83
|
-
let
|
|
101
|
+
let nextTimerToken = 0;
|
|
102
|
+
let schedulerNow = (): number => performance.now();
|
|
84
103
|
let rssWarningActive = false;
|
|
85
104
|
let lastScreenshotScanAt = 0;
|
|
86
105
|
let deps: ResourceGcDeps = defaultDeps;
|
|
@@ -96,8 +115,20 @@ export interface ResourceGcRegistration {
|
|
|
96
115
|
* session unregisters.
|
|
97
116
|
*/
|
|
98
117
|
export function registerResourceGcSession(reg: ResourceGcRegistration): () => void {
|
|
118
|
+
const isNewSession = !activeSessions.has(reg.sessionId);
|
|
99
119
|
activeSessions.set(reg.sessionId, reg.settings);
|
|
100
|
-
|
|
120
|
+
stopped = false;
|
|
121
|
+
if (isNewSession) {
|
|
122
|
+
const deadline = schedulerNow() + resolveSweepIntervalMs(reg.settings);
|
|
123
|
+
if (
|
|
124
|
+
inProgressOwner?.generation === timerGeneration &&
|
|
125
|
+
(pendingDeadline === null || pendingDeadline <= deadline)
|
|
126
|
+
) {
|
|
127
|
+
deferSchedule(timerGeneration, deadline);
|
|
128
|
+
} else {
|
|
129
|
+
requestSchedule(deadline);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
101
132
|
let unregistered = false;
|
|
102
133
|
return () => {
|
|
103
134
|
if (unregistered) return;
|
|
@@ -113,51 +144,78 @@ function currentSweepIntervalMs(): number {
|
|
|
113
144
|
return Number.isFinite(min) ? min : DEFAULT_SWEEP_INTERVAL_MS;
|
|
114
145
|
}
|
|
115
146
|
|
|
116
|
-
function
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
147
|
+
function clearPendingSchedule(): void {
|
|
148
|
+
if (pendingTimer) clearTimeout(pendingTimer);
|
|
149
|
+
pendingTimer = null;
|
|
150
|
+
pendingDeadline = null;
|
|
151
|
+
pendingOwner = null;
|
|
120
152
|
}
|
|
121
153
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
154
|
+
function requestSchedule(deadline: number): void {
|
|
155
|
+
if (stopped || activeSessions.size === 0) return;
|
|
156
|
+
if (pendingDeadline !== null && pendingDeadline <= deadline) return;
|
|
157
|
+
clearPendingSchedule();
|
|
158
|
+
const owner = { generation: timerGeneration, token: ++nextTimerToken };
|
|
159
|
+
pendingDeadline = deadline;
|
|
160
|
+
pendingOwner = owner;
|
|
161
|
+
pendingTimer = setTimeout(
|
|
162
|
+
() => {
|
|
163
|
+
void handleTimerCallback(owner, deadline);
|
|
164
|
+
},
|
|
165
|
+
Math.max(0, deadline - schedulerNow()),
|
|
166
|
+
);
|
|
167
|
+
pendingTimer.unref?.();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function deferSchedule(generation: number, deadline: number): void {
|
|
171
|
+
if (generation !== timerGeneration) return;
|
|
172
|
+
if (deferredSchedule?.generation === generation) {
|
|
173
|
+
deferredSchedule.deadline = Math.min(deferredSchedule.deadline, deadline);
|
|
127
174
|
return;
|
|
128
175
|
}
|
|
129
|
-
|
|
130
|
-
timer = setTimeout(() => {
|
|
131
|
-
void tickAndReschedule(generation);
|
|
132
|
-
}, currentSweepIntervalMs());
|
|
133
|
-
timer.unref?.();
|
|
176
|
+
deferredSchedule = { generation, deadline };
|
|
134
177
|
}
|
|
135
178
|
|
|
136
|
-
async function
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
179
|
+
async function handleTimerCallback(owner: ScheduleOwner, deadline: number): Promise<void> {
|
|
180
|
+
if (pendingOwner?.generation !== owner.generation || pendingOwner.token !== owner.token) return;
|
|
181
|
+
pendingTimer = null;
|
|
182
|
+
pendingDeadline = null;
|
|
183
|
+
pendingOwner = null;
|
|
184
|
+
if (inProgressOwner) {
|
|
185
|
+
deferSchedule(owner.generation, deadline);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
await runTick(owner.generation, "timer");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function reconcileCurrentSchedule(): void {
|
|
192
|
+
if (stopped || activeSessions.size === 0 || inProgressOwner) return;
|
|
193
|
+
const normalDeadline = schedulerNow() + currentSweepIntervalMs();
|
|
194
|
+
const deferredDeadline = deferredSchedule?.generation === timerGeneration ? deferredSchedule.deadline : null;
|
|
195
|
+
if (deferredDeadline !== null) deferredSchedule = null;
|
|
196
|
+
requestSchedule(deferredDeadline === null ? normalDeadline : Math.min(deferredDeadline, normalDeadline));
|
|
141
197
|
}
|
|
142
198
|
|
|
143
199
|
function stopTimer(): void {
|
|
144
200
|
stopped = true;
|
|
145
201
|
timerGeneration++;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
timer = null;
|
|
149
|
-
}
|
|
202
|
+
clearPendingSchedule();
|
|
203
|
+
deferredSchedule = null;
|
|
150
204
|
}
|
|
151
205
|
|
|
152
|
-
async function runTick(): Promise<void> {
|
|
153
|
-
if (
|
|
154
|
-
|
|
206
|
+
async function runTick(generation = timerGeneration, source: WorkOwner["source"] = "external"): Promise<void> {
|
|
207
|
+
if (inProgressOwner || activeSessions.size === 0) return;
|
|
208
|
+
const owner: WorkOwner = { generation, source };
|
|
209
|
+
inProgressOwner = owner;
|
|
155
210
|
try {
|
|
156
211
|
await sweepOnce(deps);
|
|
157
212
|
} catch (err) {
|
|
158
213
|
logger.debug("resource GC sweep failed", { error: (err as Error).message });
|
|
159
214
|
} finally {
|
|
160
|
-
|
|
215
|
+
if (inProgressOwner === owner) inProgressOwner = null;
|
|
216
|
+
// A stale completion only releases its own work lock. Once that lock is released, current
|
|
217
|
+
// generation demand (which may have deferred behind it) can be reconciled independently.
|
|
218
|
+
reconcileCurrentSchedule();
|
|
161
219
|
}
|
|
162
220
|
}
|
|
163
221
|
|
|
@@ -268,24 +326,55 @@ export function __setResourceGcDepsForTest(overrides: Partial<ResourceGcDeps>):
|
|
|
268
326
|
deps = { ...defaultDeps, ...overrides };
|
|
269
327
|
}
|
|
270
328
|
|
|
329
|
+
export function __setResourceGcSchedulerNowForTest(now: () => number): void {
|
|
330
|
+
schedulerNow = now;
|
|
331
|
+
}
|
|
332
|
+
|
|
271
333
|
export async function __runResourceGcTickForTest(): Promise<void> {
|
|
272
334
|
await runTick();
|
|
273
335
|
}
|
|
274
336
|
|
|
337
|
+
export async function __runResourceGcTimerCallbackForTest(
|
|
338
|
+
owner: { generation: number; token: number },
|
|
339
|
+
deadline: number,
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
await handleTimerCallback(owner, deadline);
|
|
342
|
+
}
|
|
343
|
+
|
|
275
344
|
export function __getResourceGcStateForTest(): {
|
|
276
345
|
timerActive: boolean;
|
|
277
346
|
sessionCount: number;
|
|
278
347
|
rssWarningActive: boolean;
|
|
279
348
|
inProgress: boolean;
|
|
349
|
+
generation: number;
|
|
350
|
+
pendingDeadline: number | null;
|
|
351
|
+
pendingOwner: { generation: number; token: number } | null;
|
|
352
|
+
deferredDeadline: number | null;
|
|
353
|
+
deferredGeneration: number | null;
|
|
354
|
+
activeGeneration: number | null;
|
|
280
355
|
} {
|
|
281
|
-
return {
|
|
356
|
+
return {
|
|
357
|
+
timerActive: pendingTimer !== null,
|
|
358
|
+
sessionCount: activeSessions.size,
|
|
359
|
+
rssWarningActive,
|
|
360
|
+
inProgress: inProgressOwner !== null,
|
|
361
|
+
generation: timerGeneration,
|
|
362
|
+
pendingDeadline,
|
|
363
|
+
pendingOwner: pendingOwner ? { ...pendingOwner } : null,
|
|
364
|
+
deferredDeadline: deferredSchedule?.deadline ?? null,
|
|
365
|
+
deferredGeneration: deferredSchedule?.generation ?? null,
|
|
366
|
+
activeGeneration: inProgressOwner?.generation ?? null,
|
|
367
|
+
};
|
|
282
368
|
}
|
|
283
369
|
|
|
284
370
|
export function __resetResourceGcForTest(): void {
|
|
285
371
|
stopTimer();
|
|
286
372
|
activeSessions.clear();
|
|
287
|
-
|
|
373
|
+
inProgressOwner = null;
|
|
374
|
+
deferredSchedule = null;
|
|
288
375
|
rssWarningActive = false;
|
|
289
376
|
lastScreenshotScanAt = 0;
|
|
377
|
+
nextTimerToken = 0;
|
|
378
|
+
schedulerNow = (): number => performance.now();
|
|
290
379
|
deps = defaultDeps;
|
|
291
380
|
}
|
package/src/tools/subagent.ts
CHANGED
|
@@ -17,6 +17,10 @@ const MAX_LIST_LIMIT = 50;
|
|
|
17
17
|
const RECEIPT_PREVIEW_WIDTH = 280;
|
|
18
18
|
const PREVIEW_WIDTH = 2_000;
|
|
19
19
|
const FULL_PREVIEW_WIDTH = 12_000;
|
|
20
|
+
const RECEIPT_PREVIEW_BYTES = 1_024;
|
|
21
|
+
const PREVIEW_BYTES = 8_192;
|
|
22
|
+
const FULL_PREVIEW_BYTES = 49_152;
|
|
23
|
+
|
|
20
24
|
const STEER_QUEUED_GUIDANCE =
|
|
21
25
|
"The steer message is queued for the subagent's next steering boundary and has not necessarily taken effect yet.";
|
|
22
26
|
|
|
@@ -783,7 +787,40 @@ function isSubagentJob(job: AsyncJob): boolean {
|
|
|
783
787
|
}
|
|
784
788
|
|
|
785
789
|
function sanitizeText(text: string, width: number): string {
|
|
786
|
-
return
|
|
790
|
+
return capCodePointsAndBytes(
|
|
791
|
+
truncateToWidth(replaceTabs(text), width, Ellipsis.Unicode),
|
|
792
|
+
width,
|
|
793
|
+
previewByteCap(width),
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function previewByteCap(width: number): number {
|
|
798
|
+
return width === FULL_PREVIEW_WIDTH
|
|
799
|
+
? FULL_PREVIEW_BYTES
|
|
800
|
+
: width === PREVIEW_WIDTH
|
|
801
|
+
? PREVIEW_BYTES
|
|
802
|
+
: RECEIPT_PREVIEW_BYTES;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export function capCodePointsAndBytes(text: string, maxCodePoints: number, maxBytes: number): string {
|
|
806
|
+
const ellipsis = "…";
|
|
807
|
+
const collapsed = text.replace(/…{2,}$/u, ellipsis);
|
|
808
|
+
const codePoints = [...collapsed];
|
|
809
|
+
if (codePoints.length <= maxCodePoints && Buffer.byteLength(collapsed) <= maxBytes) return collapsed;
|
|
810
|
+
|
|
811
|
+
const source = collapsed.endsWith(ellipsis) ? collapsed.slice(0, -ellipsis.length) : collapsed;
|
|
812
|
+
const ellipsisBytes = Buffer.byteLength(ellipsis);
|
|
813
|
+
let preview = "";
|
|
814
|
+
let previewBytes = 0;
|
|
815
|
+
let previewCodePoints = 0;
|
|
816
|
+
for (const codePoint of source) {
|
|
817
|
+
const codePointBytes = Buffer.byteLength(codePoint);
|
|
818
|
+
if (previewCodePoints + 1 + 1 > maxCodePoints || previewBytes + codePointBytes + ellipsisBytes > maxBytes) break;
|
|
819
|
+
preview += codePoint;
|
|
820
|
+
previewBytes += codePointBytes;
|
|
821
|
+
previewCodePoints++;
|
|
822
|
+
}
|
|
823
|
+
return `${preview}${ellipsis}`;
|
|
787
824
|
}
|
|
788
825
|
|
|
789
826
|
function previewJobOutput(
|
|
@@ -799,7 +836,11 @@ function previewJobOutput(
|
|
|
799
836
|
const width =
|
|
800
837
|
verbosity === "full" ? FULL_PREVIEW_WIDTH : verbosity === "preview" ? PREVIEW_WIDTH : RECEIPT_PREVIEW_WIDTH;
|
|
801
838
|
const normalized = replaceTabs(source.text);
|
|
802
|
-
const preview =
|
|
839
|
+
const preview = capCodePointsAndBytes(
|
|
840
|
+
truncateToWidth(normalized, width, Ellipsis.Unicode),
|
|
841
|
+
width,
|
|
842
|
+
previewByteCap(width),
|
|
843
|
+
);
|
|
803
844
|
return { type: source.type, preview, truncated: preview !== normalized };
|
|
804
845
|
}
|
|
805
846
|
|
|
@@ -160,11 +160,11 @@ async function resolveMentionPath(
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
/**
|
|
163
|
-
* Default inline cap for auto-read file mentions,
|
|
164
|
-
*
|
|
165
|
-
*
|
|
163
|
+
* Default inline cap for auto-read file mentions, aligned with the read-tool's
|
|
164
|
+
* 10 KiB bare-read receipt so an incidental `@path` mention injects a bounded
|
|
165
|
+
* snippet. Overridable per call.
|
|
166
166
|
*/
|
|
167
|
-
export const DEFAULT_FILE_MENTION_INLINE_BYTES =
|
|
167
|
+
export const DEFAULT_FILE_MENTION_INLINE_BYTES = 10 * 1024;
|
|
168
168
|
|
|
169
169
|
function buildTextOutput(textContent: string, maxInlineBytes?: number): { output: string; lineCount: number } {
|
|
170
170
|
const cap = maxInlineBytes && maxInlineBytes > 0 ? maxInlineBytes : DEFAULT_FILE_MENTION_INLINE_BYTES;
|
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import type { ImageContent } from "@gajae-code/ai";
|
|
3
3
|
import { formatBytes, readImageMetadata, SUPPORTED_IMAGE_MIME_TYPES } from "@gajae-code/utils";
|
|
4
4
|
import { resolveReadPath } from "../tools/path-utils";
|
|
5
|
-
import { formatDimensionNote,
|
|
5
|
+
import { formatDimensionNote, resizeImageBuffer } from "./image-resize";
|
|
6
6
|
|
|
7
7
|
export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
|
|
8
8
|
export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES;
|
|
@@ -14,6 +14,26 @@ export interface LoadImageInputOptions {
|
|
|
14
14
|
maxBytes?: number;
|
|
15
15
|
resolvedPath?: string;
|
|
16
16
|
detectedMimeType?: string;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LoadImageInputBytesOptions {
|
|
21
|
+
inputBuffer: Buffer;
|
|
22
|
+
resolvedPath: string;
|
|
23
|
+
mimeType: string;
|
|
24
|
+
autoResize: boolean;
|
|
25
|
+
maxBytes?: number;
|
|
26
|
+
maxOutputBytes?: number;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TransformedImageInput {
|
|
31
|
+
resolvedPath: string;
|
|
32
|
+
mimeType: string;
|
|
33
|
+
buffer: Uint8Array;
|
|
34
|
+
textNote: string;
|
|
35
|
+
dimensionNote?: string;
|
|
36
|
+
bytes: number;
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
export interface LoadedImageInput {
|
|
@@ -38,9 +58,7 @@ export class ImageInputTooLargeError extends Error {
|
|
|
38
58
|
}
|
|
39
59
|
|
|
40
60
|
export async function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null> {
|
|
41
|
-
if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType))
|
|
42
|
-
return image;
|
|
43
|
-
}
|
|
61
|
+
if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) return image;
|
|
44
62
|
try {
|
|
45
63
|
const bytes = Buffer.from(image.data, "base64");
|
|
46
64
|
const data = await new Bun.Image(bytes).png().toBase64();
|
|
@@ -50,53 +68,86 @@ export async function ensureSupportedImageInput(image: ImageContent): Promise<Im
|
|
|
50
68
|
}
|
|
51
69
|
}
|
|
52
70
|
|
|
53
|
-
|
|
71
|
+
/** Transform trusted source bytes without materializing a base64 payload. */
|
|
72
|
+
export async function transformImageInputBytes(options: LoadImageInputBytesOptions): Promise<TransformedImageInput> {
|
|
54
73
|
const maxBytes = options.maxBytes ?? MAX_IMAGE_INPUT_BYTES;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
: await readImageMetadata(resolvedPath);
|
|
59
|
-
const mimeType = metadata?.mimeType;
|
|
60
|
-
if (!mimeType) return null;
|
|
61
|
-
|
|
62
|
-
const stat = await Bun.file(resolvedPath).stat();
|
|
63
|
-
if (stat.size > maxBytes) {
|
|
64
|
-
throw new ImageInputTooLargeError(stat.size, maxBytes);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const inputBuffer = await fs.readFile(resolvedPath);
|
|
68
|
-
if (inputBuffer.byteLength > maxBytes) {
|
|
69
|
-
throw new ImageInputTooLargeError(inputBuffer.byteLength, maxBytes);
|
|
74
|
+
options.signal?.throwIfAborted();
|
|
75
|
+
if (options.inputBuffer.byteLength > maxBytes) {
|
|
76
|
+
throw new ImageInputTooLargeError(options.inputBuffer.byteLength, maxBytes);
|
|
70
77
|
}
|
|
71
78
|
|
|
72
|
-
let
|
|
73
|
-
let outputMimeType = mimeType;
|
|
74
|
-
let outputBytes = inputBuffer.byteLength;
|
|
79
|
+
let outputBuffer: Uint8Array = options.inputBuffer;
|
|
80
|
+
let outputMimeType = options.mimeType;
|
|
75
81
|
let dimensionNote: string | undefined;
|
|
76
82
|
|
|
77
83
|
if (options.autoResize) {
|
|
84
|
+
options.signal?.throwIfAborted();
|
|
78
85
|
try {
|
|
79
|
-
const resized = await
|
|
80
|
-
|
|
86
|
+
const resized = await resizeImageBuffer(
|
|
87
|
+
options.inputBuffer,
|
|
88
|
+
options.mimeType,
|
|
89
|
+
options.maxOutputBytes === undefined ? undefined : { maxBytes: options.maxOutputBytes },
|
|
90
|
+
);
|
|
91
|
+
options.signal?.throwIfAborted();
|
|
92
|
+
outputBuffer = resized.buffer;
|
|
81
93
|
outputMimeType = resized.mimeType;
|
|
82
|
-
outputBytes = resized.buffer.byteLength;
|
|
83
94
|
dimensionNote = formatDimensionNote(resized);
|
|
84
95
|
} catch {
|
|
85
|
-
|
|
96
|
+
options.signal?.throwIfAborted();
|
|
97
|
+
// Keep the original image when optional resize fails.
|
|
86
98
|
}
|
|
87
99
|
}
|
|
88
100
|
|
|
101
|
+
options.signal?.throwIfAborted();
|
|
89
102
|
let textNote = `Read image file [${outputMimeType}]`;
|
|
90
|
-
if (dimensionNote) {
|
|
91
|
-
textNote += `\n${dimensionNote}`;
|
|
92
|
-
}
|
|
93
|
-
|
|
103
|
+
if (dimensionNote) textNote += `\n${dimensionNote}`;
|
|
94
104
|
return {
|
|
95
|
-
resolvedPath,
|
|
105
|
+
resolvedPath: options.resolvedPath,
|
|
96
106
|
mimeType: outputMimeType,
|
|
97
|
-
|
|
107
|
+
buffer: outputBuffer,
|
|
98
108
|
textNote,
|
|
99
109
|
dimensionNote,
|
|
100
|
-
bytes:
|
|
110
|
+
bytes: outputBuffer.byteLength,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function materializeImageInput(transformed: TransformedImageInput): LoadedImageInput {
|
|
115
|
+
return {
|
|
116
|
+
resolvedPath: transformed.resolvedPath,
|
|
117
|
+
mimeType: transformed.mimeType,
|
|
118
|
+
data: Buffer.from(transformed.buffer).toBase64(),
|
|
119
|
+
textNote: transformed.textNote,
|
|
120
|
+
dimensionNote: transformed.dimensionNote,
|
|
121
|
+
bytes: transformed.bytes,
|
|
101
122
|
};
|
|
102
123
|
}
|
|
124
|
+
|
|
125
|
+
/** Build a model image payload from bytes already read through a trusted descriptor. */
|
|
126
|
+
export async function loadImageInputBytes(options: LoadImageInputBytesOptions): Promise<LoadedImageInput> {
|
|
127
|
+
return materializeImageInput(await transformImageInputBytes(options));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function loadImageInput(options: LoadImageInputOptions): Promise<LoadedImageInput | null> {
|
|
131
|
+
const maxBytes = options.maxBytes ?? MAX_IMAGE_INPUT_BYTES;
|
|
132
|
+
const resolvedPath = options.resolvedPath ?? resolveReadPath(options.path, options.cwd);
|
|
133
|
+
options.signal?.throwIfAborted();
|
|
134
|
+
const metadata = options.detectedMimeType
|
|
135
|
+
? { mimeType: options.detectedMimeType }
|
|
136
|
+
: await readImageMetadata(resolvedPath);
|
|
137
|
+
const mimeType = metadata?.mimeType;
|
|
138
|
+
if (!mimeType) return null;
|
|
139
|
+
|
|
140
|
+
options.signal?.throwIfAborted();
|
|
141
|
+
const stat = await Bun.file(resolvedPath).stat();
|
|
142
|
+
if (stat.size > maxBytes) throw new ImageInputTooLargeError(stat.size, maxBytes);
|
|
143
|
+
|
|
144
|
+
const inputBuffer = await fs.readFile(resolvedPath, { signal: options.signal });
|
|
145
|
+
return loadImageInputBytes({
|
|
146
|
+
inputBuffer,
|
|
147
|
+
resolvedPath,
|
|
148
|
+
mimeType,
|
|
149
|
+
autoResize: options.autoResize,
|
|
150
|
+
maxBytes,
|
|
151
|
+
signal: options.signal,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -21,14 +21,14 @@ export interface ResizedImage {
|
|
|
21
21
|
|
|
22
22
|
// 500KB target — aggressive compression; Anthropic's 5MB per-image cap is rarely the
|
|
23
23
|
// binding constraint once images are downsized to 1568px (Anthropic's internal threshold).
|
|
24
|
-
const
|
|
24
|
+
export const DEFAULT_IMAGE_RESIZE_MAX_BYTES = 500 * 1024;
|
|
25
25
|
|
|
26
26
|
const DEFAULT_OPTIONS: Required<Omit<ImageResizeOptions, "excludeWebP">> = {
|
|
27
27
|
// Anthropic's "internal recommended size" — Anthropic model internally caps images at
|
|
28
28
|
// 1568px on the longest edge before vision processing.
|
|
29
29
|
maxWidth: 1568,
|
|
30
30
|
maxHeight: 1568,
|
|
31
|
-
maxBytes:
|
|
31
|
+
maxBytes: DEFAULT_IMAGE_RESIZE_MAX_BYTES,
|
|
32
32
|
jpegQuality: 80,
|
|
33
33
|
};
|
|
34
34
|
|
|
@@ -72,14 +72,17 @@ Buffer.prototype.toBase64 = function (this: Buffer) {
|
|
|
72
72
|
* Backed by `Bun.Image`: a chainable native pipeline that runs decode/transform/encode
|
|
73
73
|
* off the JS thread when the terminal (`.bytes()`) is awaited.
|
|
74
74
|
*/
|
|
75
|
-
export async function
|
|
75
|
+
export async function resizeImageBuffer(
|
|
76
|
+
inputBuffer: Buffer,
|
|
77
|
+
inputMimeType: string,
|
|
78
|
+
options?: ImageResizeOptions,
|
|
79
|
+
): Promise<ResizedImage> {
|
|
76
80
|
const excludeWebP = options?.excludeWebP ?? isWebPExcluded();
|
|
77
81
|
const opts = { ...DEFAULT_OPTIONS, ...options, excludeWebP };
|
|
78
|
-
const inputBuffer = Buffer.from(img.data, "base64");
|
|
79
82
|
|
|
80
83
|
try {
|
|
81
84
|
const { width: originalWidth, height: originalHeight, format } = await new Bun.Image(inputBuffer).metadata();
|
|
82
|
-
const sourceMime =
|
|
85
|
+
const sourceMime = inputMimeType ?? `image/${format}`;
|
|
83
86
|
|
|
84
87
|
// Fast path: already within dimensions AND well under budget.
|
|
85
88
|
// Threshold is 1/4 of budget — if already that compact, don't re-encode.
|
|
@@ -102,7 +105,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
102
105
|
height: originalHeight,
|
|
103
106
|
wasResized: false,
|
|
104
107
|
get data() {
|
|
105
|
-
return
|
|
108
|
+
return inputBuffer.toBase64();
|
|
106
109
|
},
|
|
107
110
|
};
|
|
108
111
|
}
|
|
@@ -272,24 +275,28 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
272
275
|
// When the caller demanded WebP exclusion AND the original is WebP,
|
|
273
276
|
// returning the original buffer would silently violate that contract,
|
|
274
277
|
// so surface an explicit error instead.
|
|
275
|
-
if (excludeWebP && (
|
|
278
|
+
if (excludeWebP && (inputMimeType === "image/webp" || !inputMimeType)) {
|
|
276
279
|
throw new Error("resizeImage: failed to decode image and cannot honor excludeWebP for a WebP source");
|
|
277
280
|
}
|
|
278
281
|
return {
|
|
279
282
|
buffer: inputBuffer,
|
|
280
|
-
mimeType:
|
|
283
|
+
mimeType: inputMimeType,
|
|
281
284
|
originalWidth: 0,
|
|
282
285
|
originalHeight: 0,
|
|
283
286
|
width: 0,
|
|
284
287
|
height: 0,
|
|
285
288
|
wasResized: false,
|
|
286
289
|
get data() {
|
|
287
|
-
return
|
|
290
|
+
return inputBuffer.toBase64();
|
|
288
291
|
},
|
|
289
292
|
};
|
|
290
293
|
}
|
|
291
294
|
}
|
|
292
295
|
|
|
296
|
+
export function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {
|
|
297
|
+
return resizeImageBuffer(Buffer.from(img.data, "base64"), img.mimeType, options);
|
|
298
|
+
}
|
|
299
|
+
|
|
293
300
|
/**
|
|
294
301
|
* Format a dimension note for resized images.
|
|
295
302
|
* This helps the model understand the coordinate mapping.
|