@gajae-code/coding-agent 0.11.3 → 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 +24 -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/gjc-runtime/gc-runtime.d.ts +9 -0
- package/dist/types/gjc-runtime/state-argv.d.ts +38 -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/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/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/render-utils.d.ts +1 -8
- package/dist/types/tools/resource-gc.d.ts +14 -0
- 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/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/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 +42 -210
- 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/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/telegram-daemon.ts +50 -10
- 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/path-utils.ts +42 -35
- package/src/tools/read.ts +7 -1
- package/src/tools/render-utils.ts +1 -27
- package/src/tools/resource-gc.ts +123 -34
- 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/path-utils.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as url from "node:url";
|
|
5
|
-
import { isEnoent } from "@gajae-code/utils";
|
|
5
|
+
import { isEnoent } from "@gajae-code/utils/fs-error";
|
|
6
6
|
import { InternalUrlRouter } from "../internal-urls";
|
|
7
7
|
import { ToolError } from "./tool-errors";
|
|
8
8
|
|
|
@@ -10,13 +10,9 @@ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
|
10
10
|
const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|raw|conflicts)$/i;
|
|
11
11
|
const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*$/i;
|
|
12
12
|
const FILE_RAW_ONLY_RE = /^raw$/i;
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
const INTERNAL_URL_SELECTOR_PART_RE =
|
|
17
|
-
/^(?:raw|conflicts|L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|-\d+(?:[-+]\d+)?)$/i;
|
|
18
|
-
// Schemes whose host grammar is identifier-shaped, so any trailing
|
|
19
|
-
// `:<selector-chunk>` is unambiguously a read-tool selector.
|
|
13
|
+
// Schemes whose authority grammar is identifier-shaped and may therefore carry
|
|
14
|
+
// a read selector immediately after the authority. Colons in a path, query, or
|
|
15
|
+
// fragment remain part of the URL and are never considered selectors.
|
|
20
16
|
const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
|
|
21
17
|
agent: true,
|
|
22
18
|
artifact: true,
|
|
@@ -148,39 +144,50 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
|
|
|
148
144
|
/**
|
|
149
145
|
* Variant of {@link splitPathAndSel} for internal URLs (`scheme://...`).
|
|
150
146
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* This function iteratively peels selector-shaped chunks (well-formed plus
|
|
159
|
-
* common malformed shapes like `:-N`) so the rest of the read tool can pass a
|
|
160
|
-
* clean URL to the protocol handler and surface selector errors via parseSel
|
|
161
|
-
* instead of as misleading "host invalid" errors from the handler.
|
|
162
|
-
*
|
|
163
|
-
* Falls back to the input unchanged when nothing matches.
|
|
147
|
+
* Artifact authorities are generated identifiers, so their complete tail is
|
|
148
|
+
* unambiguously a selector and malformed selectors can be rejected before
|
|
149
|
+
* resolving the artifact. Other resources may legitimately use colons in
|
|
150
|
+
* their identities, so they only lose strict, recognized selector suffixes.
|
|
151
|
+
* Skill authorities additionally use the active registry to distinguish a
|
|
152
|
+
* skill name from its selector without making path-utils depend on skills.
|
|
164
153
|
*/
|
|
165
|
-
export function splitInternalUrlSel(
|
|
154
|
+
export function splitInternalUrlSel(
|
|
155
|
+
rawPath: string,
|
|
156
|
+
options: { activeSkillNames?: readonly string[] } = {},
|
|
157
|
+
): { path: string; sel?: string } {
|
|
166
158
|
const schemeMatch = rawPath.match(INTERNAL_URL_SCHEME_RE);
|
|
167
159
|
if (!schemeMatch) return { path: rawPath };
|
|
168
|
-
|
|
160
|
+
const scheme = schemeMatch[1].toLowerCase();
|
|
161
|
+
if (!INTERNAL_SCHEMES_WITH_SELECTORS[scheme]) return { path: rawPath };
|
|
169
162
|
|
|
170
163
|
const schemeEnd = schemeMatch[0].length;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
164
|
+
const authorityTerminator = rawPath.slice(schemeEnd).search(/[/?#]/);
|
|
165
|
+
if (authorityTerminator !== -1) return { path: rawPath };
|
|
166
|
+
const authority = rawPath.slice(schemeEnd);
|
|
167
|
+
const firstColon = authority.indexOf(":");
|
|
168
|
+
if (firstColon === -1) return { path: rawPath };
|
|
169
|
+
|
|
170
|
+
if (scheme === "artifact") {
|
|
171
|
+
return { path: rawPath.slice(0, schemeEnd + firstColon), sel: authority.slice(firstColon + 1) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (scheme === "skill") {
|
|
175
|
+
const activeSkillNames = options.activeSkillNames ?? [];
|
|
176
|
+
if (activeSkillNames.includes(authority)) return { path: rawPath };
|
|
177
|
+
const skillName = activeSkillNames
|
|
178
|
+
.filter(name => authority.startsWith(`${name}:`))
|
|
179
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
180
|
+
if (skillName) {
|
|
181
|
+
return {
|
|
182
|
+
path: `${rawPath.slice(0, schemeEnd)}${skillName}`,
|
|
183
|
+
sel: authority.slice(skillName.length + 1),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
181
186
|
}
|
|
182
|
-
|
|
183
|
-
|
|
187
|
+
|
|
188
|
+
const strict = splitPathAndSel(authority);
|
|
189
|
+
if (strict.sel === undefined) return { path: rawPath };
|
|
190
|
+
return { path: `${rawPath.slice(0, schemeEnd)}${strict.path}`, sel: strict.sel };
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
function assertNotInternalUrl(expanded: string, original: string): void {
|
package/src/tools/read.ts
CHANGED
|
@@ -11,6 +11,7 @@ import * as z from "zod/v4";
|
|
|
11
11
|
import { getFileReadCache } from "../edit/file-read-cache";
|
|
12
12
|
import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
|
|
13
13
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
14
|
+
import { getActiveSkills } from "../extensibility/skills";
|
|
14
15
|
import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../hashline/hash";
|
|
15
16
|
import { InternalUrlRouter } from "../internal-urls";
|
|
16
17
|
import { parseInternalUrl } from "../internal-urls/parse";
|
|
@@ -1585,8 +1586,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1585
1586
|
// off the URL and surfaced via parseSel rather than confusing handlers.
|
|
1586
1587
|
const internalRouter = InternalUrlRouter.instance();
|
|
1587
1588
|
if (internalRouter.canHandle(readPath)) {
|
|
1588
|
-
const internalTarget = splitInternalUrlSel(readPath
|
|
1589
|
+
const internalTarget = splitInternalUrlSel(readPath, {
|
|
1590
|
+
activeSkillNames: getActiveSkills().map(skill => skill.name),
|
|
1591
|
+
});
|
|
1589
1592
|
const parsed = parseSel(internalTarget.sel);
|
|
1593
|
+
if (internalTarget.sel !== undefined && parsed.kind === "none") {
|
|
1594
|
+
throw new ToolError(`Invalid internal URL selector "${internalTarget.sel}".`);
|
|
1595
|
+
}
|
|
1590
1596
|
return this.#handleInternalUrl(internalTarget.path, parsed, signal);
|
|
1591
1597
|
}
|
|
1592
1598
|
|
|
@@ -15,10 +15,10 @@ import { pluralize } from "@gajae-code/utils";
|
|
|
15
15
|
import { settings } from "../config/settings";
|
|
16
16
|
import type { Theme } from "../modes/theme/theme";
|
|
17
17
|
import { Hasher } from "../tui/utils";
|
|
18
|
-
import { formatDimensionNote, type ResizedImage } from "../utils/image-resize";
|
|
19
18
|
|
|
20
19
|
export { Ellipsis } from "@gajae-code/natives";
|
|
21
20
|
export { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "@gajae-code/tui";
|
|
21
|
+
export { formatScreenshot } from "./browser/screenshot-format";
|
|
22
22
|
|
|
23
23
|
// =============================================================================
|
|
24
24
|
// Standardized Display Constants
|
|
@@ -591,32 +591,6 @@ export function formatToolWorkingDirectory(workdir: string | undefined, projectD
|
|
|
591
591
|
return replaceTabs(displayWorkdir);
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
-
export function formatScreenshot(opts: {
|
|
595
|
-
saveFullRes: boolean;
|
|
596
|
-
savedMimeType: string;
|
|
597
|
-
savedByteLength: number;
|
|
598
|
-
dest: string;
|
|
599
|
-
resized: ResizedImage;
|
|
600
|
-
}): string[] {
|
|
601
|
-
const lines = ["Screenshot captured"];
|
|
602
|
-
if (opts.saveFullRes) {
|
|
603
|
-
lines.push(
|
|
604
|
-
`Saved: ${opts.savedMimeType} (${(opts.savedByteLength / 1024).toFixed(2)} KB) to ${shortenPath(opts.dest)}`,
|
|
605
|
-
);
|
|
606
|
-
lines.push(
|
|
607
|
-
`Model: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB, ${opts.resized.width}x${opts.resized.height})`,
|
|
608
|
-
);
|
|
609
|
-
} else {
|
|
610
|
-
lines.push(`Format: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB)`);
|
|
611
|
-
lines.push(`Dimensions: ${opts.resized.width}x${opts.resized.height}`);
|
|
612
|
-
}
|
|
613
|
-
const dimensionNote = formatDimensionNote(opts.resized);
|
|
614
|
-
if (dimensionNote) {
|
|
615
|
-
lines.push(dimensionNote);
|
|
616
|
-
}
|
|
617
|
-
return lines;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
594
|
export function wrapBrackets(text: string, theme: Theme): string {
|
|
621
595
|
return `${theme.format.bracketLeft}${text}${theme.format.bracketRight}`;
|
|
622
596
|
}
|
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
|
}
|
|
@@ -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.
|