indusagi-coding-agent 0.1.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 +2249 -0
- package/README.md +546 -0
- package/dist/cli/args.js +282 -0
- package/dist/cli/config-selector.js +30 -0
- package/dist/cli/file-processor.js +78 -0
- package/dist/cli/list-models.js +91 -0
- package/dist/cli/session-picker.js +31 -0
- package/dist/cli.js +10 -0
- package/dist/config.js +158 -0
- package/dist/core/agent-session.js +2097 -0
- package/dist/core/auth-storage.js +278 -0
- package/dist/core/bash-executor.js +211 -0
- package/dist/core/compaction/branch-summarization.js +241 -0
- package/dist/core/compaction/compaction.js +606 -0
- package/dist/core/compaction/index.js +6 -0
- package/dist/core/compaction/utils.js +137 -0
- package/dist/core/diagnostics.js +1 -0
- package/dist/core/event-bus.js +24 -0
- package/dist/core/exec.js +70 -0
- package/dist/core/export-html/ansi-to-html.js +248 -0
- package/dist/core/export-html/index.js +221 -0
- package/dist/core/export-html/template.css +905 -0
- package/dist/core/export-html/template.html +54 -0
- package/dist/core/export-html/template.js +1549 -0
- package/dist/core/export-html/tool-renderer.js +56 -0
- package/dist/core/export-html/vendor/highlight.min.js +1213 -0
- package/dist/core/export-html/vendor/marked.min.js +6 -0
- package/dist/core/extensions/index.js +8 -0
- package/dist/core/extensions/loader.js +395 -0
- package/dist/core/extensions/runner.js +499 -0
- package/dist/core/extensions/types.js +31 -0
- package/dist/core/extensions/wrapper.js +101 -0
- package/dist/core/footer-data-provider.js +133 -0
- package/dist/core/index.js +8 -0
- package/dist/core/keybindings.js +140 -0
- package/dist/core/messages.js +122 -0
- package/dist/core/model-registry.js +454 -0
- package/dist/core/model-resolver.js +309 -0
- package/dist/core/package-manager.js +1142 -0
- package/dist/core/prompt-templates.js +250 -0
- package/dist/core/resource-loader.js +569 -0
- package/dist/core/sdk.js +225 -0
- package/dist/core/session-manager.js +1078 -0
- package/dist/core/settings-manager.js +430 -0
- package/dist/core/skills.js +339 -0
- package/dist/core/system-prompt.js +136 -0
- package/dist/core/timings.js +24 -0
- package/dist/core/tools/bash.js +226 -0
- package/dist/core/tools/edit-diff.js +242 -0
- package/dist/core/tools/edit.js +145 -0
- package/dist/core/tools/find.js +205 -0
- package/dist/core/tools/grep.js +238 -0
- package/dist/core/tools/index.js +60 -0
- package/dist/core/tools/ls.js +117 -0
- package/dist/core/tools/path-utils.js +52 -0
- package/dist/core/tools/read.js +165 -0
- package/dist/core/tools/truncate.js +204 -0
- package/dist/core/tools/write.js +77 -0
- package/dist/index.js +41 -0
- package/dist/main.js +565 -0
- package/dist/migrations.js +260 -0
- package/dist/modes/index.js +7 -0
- package/dist/modes/interactive/components/armin.js +328 -0
- package/dist/modes/interactive/components/assistant-message.js +86 -0
- package/dist/modes/interactive/components/bash-execution.js +155 -0
- package/dist/modes/interactive/components/bordered-loader.js +47 -0
- package/dist/modes/interactive/components/branch-summary-message.js +41 -0
- package/dist/modes/interactive/components/compaction-summary-message.js +42 -0
- package/dist/modes/interactive/components/config-selector.js +458 -0
- package/dist/modes/interactive/components/countdown-timer.js +27 -0
- package/dist/modes/interactive/components/custom-editor.js +61 -0
- package/dist/modes/interactive/components/custom-message.js +80 -0
- package/dist/modes/interactive/components/diff.js +132 -0
- package/dist/modes/interactive/components/dynamic-border.js +19 -0
- package/dist/modes/interactive/components/extension-editor.js +96 -0
- package/dist/modes/interactive/components/extension-input.js +54 -0
- package/dist/modes/interactive/components/extension-selector.js +70 -0
- package/dist/modes/interactive/components/footer.js +213 -0
- package/dist/modes/interactive/components/index.js +31 -0
- package/dist/modes/interactive/components/keybinding-hints.js +60 -0
- package/dist/modes/interactive/components/login-dialog.js +138 -0
- package/dist/modes/interactive/components/model-selector.js +253 -0
- package/dist/modes/interactive/components/oauth-selector.js +91 -0
- package/dist/modes/interactive/components/scoped-models-selector.js +262 -0
- package/dist/modes/interactive/components/session-selector-search.js +145 -0
- package/dist/modes/interactive/components/session-selector.js +698 -0
- package/dist/modes/interactive/components/settings-selector.js +250 -0
- package/dist/modes/interactive/components/show-images-selector.js +33 -0
- package/dist/modes/interactive/components/skill-invocation-message.js +44 -0
- package/dist/modes/interactive/components/theme-selector.js +43 -0
- package/dist/modes/interactive/components/thinking-selector.js +45 -0
- package/dist/modes/interactive/components/tool-execution.js +608 -0
- package/dist/modes/interactive/components/tree-selector.js +892 -0
- package/dist/modes/interactive/components/user-message-selector.js +109 -0
- package/dist/modes/interactive/components/user-message.js +15 -0
- package/dist/modes/interactive/components/visual-truncate.js +32 -0
- package/dist/modes/interactive/interactive-mode.js +3576 -0
- package/dist/modes/interactive/theme/dark.json +85 -0
- package/dist/modes/interactive/theme/light.json +84 -0
- package/dist/modes/interactive/theme/theme-schema.json +335 -0
- package/dist/modes/interactive/theme/theme.js +938 -0
- package/dist/modes/print-mode.js +96 -0
- package/dist/modes/rpc/rpc-client.js +390 -0
- package/dist/modes/rpc/rpc-mode.js +448 -0
- package/dist/modes/rpc/rpc-types.js +7 -0
- package/dist/utils/changelog.js +86 -0
- package/dist/utils/clipboard-image.js +116 -0
- package/dist/utils/clipboard.js +58 -0
- package/dist/utils/frontmatter.js +25 -0
- package/dist/utils/git.js +5 -0
- package/dist/utils/image-convert.js +34 -0
- package/dist/utils/image-resize.js +180 -0
- package/dist/utils/mime.js +25 -0
- package/dist/utils/photon.js +120 -0
- package/dist/utils/shell.js +164 -0
- package/dist/utils/sleep.js +16 -0
- package/dist/utils/tools-manager.js +186 -0
- package/docs/compaction.md +390 -0
- package/docs/custom-provider.md +538 -0
- package/docs/development.md +69 -0
- package/docs/extensions.md +1733 -0
- package/docs/images/doom-extension.png +0 -0
- package/docs/images/interactive-mode.png +0 -0
- package/docs/images/tree-view.png +0 -0
- package/docs/json.md +79 -0
- package/docs/keybindings.md +162 -0
- package/docs/models.md +193 -0
- package/docs/packages.md +163 -0
- package/docs/prompt-templates.md +67 -0
- package/docs/providers.md +147 -0
- package/docs/rpc.md +1048 -0
- package/docs/sdk.md +957 -0
- package/docs/session.md +412 -0
- package/docs/settings.md +216 -0
- package/docs/shell-aliases.md +13 -0
- package/docs/skills.md +226 -0
- package/docs/terminal-setup.md +65 -0
- package/docs/themes.md +295 -0
- package/docs/tree.md +219 -0
- package/docs/tui.md +887 -0
- package/docs/windows.md +17 -0
- package/examples/README.md +25 -0
- package/examples/extensions/README.md +192 -0
- package/examples/extensions/antigravity-image-gen.ts +414 -0
- package/examples/extensions/auto-commit-on-exit.ts +49 -0
- package/examples/extensions/bookmark.ts +50 -0
- package/examples/extensions/claude-rules.ts +86 -0
- package/examples/extensions/confirm-destructive.ts +59 -0
- package/examples/extensions/custom-compaction.ts +115 -0
- package/examples/extensions/custom-footer.ts +65 -0
- package/examples/extensions/custom-header.ts +73 -0
- package/examples/extensions/custom-provider-anthropic/index.ts +605 -0
- package/examples/extensions/custom-provider-anthropic/package-lock.json +24 -0
- package/examples/extensions/custom-provider-anthropic/package.json +19 -0
- package/examples/extensions/custom-provider-gitlab-duo/index.ts +350 -0
- package/examples/extensions/custom-provider-gitlab-duo/package.json +16 -0
- package/examples/extensions/custom-provider-gitlab-duo/test.ts +83 -0
- package/examples/extensions/dirty-repo-guard.ts +56 -0
- package/examples/extensions/doom-overlay/README.md +46 -0
- package/examples/extensions/doom-overlay/doom/build/doom.js +21 -0
- package/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
- package/examples/extensions/doom-overlay/doom/build.sh +152 -0
- package/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -0
- package/examples/extensions/doom-overlay/doom-component.ts +133 -0
- package/examples/extensions/doom-overlay/doom-engine.ts +173 -0
- package/examples/extensions/doom-overlay/doom-keys.ts +105 -0
- package/examples/extensions/doom-overlay/index.ts +74 -0
- package/examples/extensions/doom-overlay/wad-finder.ts +51 -0
- package/examples/extensions/event-bus.ts +43 -0
- package/examples/extensions/file-trigger.ts +41 -0
- package/examples/extensions/git-checkpoint.ts +53 -0
- package/examples/extensions/handoff.ts +151 -0
- package/examples/extensions/hello.ts +25 -0
- package/examples/extensions/inline-bash.ts +94 -0
- package/examples/extensions/input-transform.ts +43 -0
- package/examples/extensions/interactive-shell.ts +196 -0
- package/examples/extensions/mac-system-theme.ts +47 -0
- package/examples/extensions/message-renderer.ts +60 -0
- package/examples/extensions/modal-editor.ts +86 -0
- package/examples/extensions/model-status.ts +31 -0
- package/examples/extensions/notify.ts +25 -0
- package/examples/extensions/overlay-qa-tests.ts +882 -0
- package/examples/extensions/overlay-test.ts +151 -0
- package/examples/extensions/permission-gate.ts +34 -0
- package/examples/extensions/pirate.ts +47 -0
- package/examples/extensions/plan-mode/README.md +65 -0
- package/examples/extensions/plan-mode/index.ts +341 -0
- package/examples/extensions/plan-mode/utils.ts +168 -0
- package/examples/extensions/preset.ts +399 -0
- package/examples/extensions/protected-paths.ts +30 -0
- package/examples/extensions/qna.ts +120 -0
- package/examples/extensions/question.ts +265 -0
- package/examples/extensions/questionnaire.ts +428 -0
- package/examples/extensions/rainbow-editor.ts +88 -0
- package/examples/extensions/sandbox/index.ts +318 -0
- package/examples/extensions/sandbox/package-lock.json +92 -0
- package/examples/extensions/sandbox/package.json +19 -0
- package/examples/extensions/send-user-message.ts +97 -0
- package/examples/extensions/session-name.ts +27 -0
- package/examples/extensions/shutdown-command.ts +63 -0
- package/examples/extensions/snake.ts +344 -0
- package/examples/extensions/space-invaders.ts +561 -0
- package/examples/extensions/ssh.ts +220 -0
- package/examples/extensions/status-line.ts +40 -0
- package/examples/extensions/subagent/README.md +172 -0
- package/examples/extensions/subagent/agents/planner.md +37 -0
- package/examples/extensions/subagent/agents/reviewer.md +35 -0
- package/examples/extensions/subagent/agents/scout.md +50 -0
- package/examples/extensions/subagent/agents/worker.md +24 -0
- package/examples/extensions/subagent/agents.ts +127 -0
- package/examples/extensions/subagent/index.ts +964 -0
- package/examples/extensions/subagent/prompts/implement-and-review.md +10 -0
- package/examples/extensions/subagent/prompts/implement.md +10 -0
- package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -0
- package/examples/extensions/summarize.ts +196 -0
- package/examples/extensions/timed-confirm.ts +70 -0
- package/examples/extensions/todo.ts +300 -0
- package/examples/extensions/tool-override.ts +144 -0
- package/examples/extensions/tools.ts +147 -0
- package/examples/extensions/trigger-compact.ts +40 -0
- package/examples/extensions/truncated-tool.ts +193 -0
- package/examples/extensions/widget-placement.ts +17 -0
- package/examples/extensions/with-deps/index.ts +36 -0
- package/examples/extensions/with-deps/package-lock.json +31 -0
- package/examples/extensions/with-deps/package.json +22 -0
- package/examples/sdk/01-minimal.ts +22 -0
- package/examples/sdk/02-custom-model.ts +50 -0
- package/examples/sdk/03-custom-prompt.ts +55 -0
- package/examples/sdk/04-skills.ts +46 -0
- package/examples/sdk/05-tools.ts +56 -0
- package/examples/sdk/06-extensions.ts +88 -0
- package/examples/sdk/07-context-files.ts +40 -0
- package/examples/sdk/08-prompt-templates.ts +47 -0
- package/examples/sdk/09-api-keys-and-oauth.ts +48 -0
- package/examples/sdk/10-settings.ts +38 -0
- package/examples/sdk/11-sessions.ts +48 -0
- package/examples/sdk/12-full-control.ts +82 -0
- package/examples/sdk/13-codex-oauth.ts +37 -0
- package/examples/sdk/README.md +144 -0
- package/package.json +85 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execSync, spawn } from "child_process";
|
|
2
|
+
import { platform } from "os";
|
|
3
|
+
import { isWaylandSession } from "./clipboard-image.js";
|
|
4
|
+
export function copyToClipboard(text) {
|
|
5
|
+
const p = platform();
|
|
6
|
+
const options = { input: text, timeout: 5000 };
|
|
7
|
+
try {
|
|
8
|
+
if (p === "darwin") {
|
|
9
|
+
execSync("pbcopy", options);
|
|
10
|
+
}
|
|
11
|
+
else if (p === "win32") {
|
|
12
|
+
execSync("clip", options);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
// Linux - try wl-copy for Wayland, fall back to xclip/xsel for X11
|
|
16
|
+
const isWayland = isWaylandSession();
|
|
17
|
+
if (isWayland) {
|
|
18
|
+
try {
|
|
19
|
+
// Verify wl-copy exists (spawn errors are async and won't be caught)
|
|
20
|
+
execSync("which wl-copy", { stdio: "ignore" });
|
|
21
|
+
// wl-copy with execSync hangs due to fork behavior; use spawn instead
|
|
22
|
+
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
23
|
+
proc.stdin.on("error", () => {
|
|
24
|
+
// Ignore EPIPE errors if wl-copy exits early
|
|
25
|
+
});
|
|
26
|
+
proc.stdin.write(text);
|
|
27
|
+
proc.stdin.end();
|
|
28
|
+
proc.unref();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Fall back to xclip/xsel (works on XWayland)
|
|
32
|
+
try {
|
|
33
|
+
execSync("xclip -selection clipboard", options);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
execSync("xsel --clipboard --input", options);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
try {
|
|
42
|
+
execSync("xclip -selection clipboard", options);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
execSync("xsel --clipboard --input", options);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
52
|
+
if (p === "linux") {
|
|
53
|
+
const tools = isWaylandSession() ? "wl-copy, xclip, or xsel" : "xclip or xsel";
|
|
54
|
+
throw new Error(`Failed to copy to clipboard. Install ${tools}: ${msg}`);
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`Failed to copy to clipboard: ${msg}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { parse } from "yaml";
|
|
2
|
+
const normalizeNewlines = (value) => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
3
|
+
const extractFrontmatter = (content) => {
|
|
4
|
+
const normalized = normalizeNewlines(content);
|
|
5
|
+
if (!normalized.startsWith("---")) {
|
|
6
|
+
return { yamlString: null, body: normalized };
|
|
7
|
+
}
|
|
8
|
+
const endIndex = normalized.indexOf("\n---", 3);
|
|
9
|
+
if (endIndex === -1) {
|
|
10
|
+
return { yamlString: null, body: normalized };
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
yamlString: normalized.slice(4, endIndex),
|
|
14
|
+
body: normalized.slice(endIndex + 4).trim(),
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export const parseFrontmatter = (content) => {
|
|
18
|
+
const { yamlString, body } = extractFrontmatter(content);
|
|
19
|
+
if (!yamlString) {
|
|
20
|
+
return { frontmatter: {}, body };
|
|
21
|
+
}
|
|
22
|
+
const parsed = parse(yamlString);
|
|
23
|
+
return { frontmatter: (parsed ?? {}), body };
|
|
24
|
+
};
|
|
25
|
+
export const stripFrontmatter = (content) => parseFrontmatter(content).body;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { loadPhoton } from "./photon.js";
|
|
2
|
+
/**
|
|
3
|
+
* Convert image to PNG format for terminal display.
|
|
4
|
+
* Kitty graphics protocol requires PNG format (f=100).
|
|
5
|
+
*/
|
|
6
|
+
export async function convertToPng(base64Data, mimeType) {
|
|
7
|
+
// Already PNG, no conversion needed
|
|
8
|
+
if (mimeType === "image/png") {
|
|
9
|
+
return { data: base64Data, mimeType };
|
|
10
|
+
}
|
|
11
|
+
const photon = await loadPhoton();
|
|
12
|
+
if (!photon) {
|
|
13
|
+
// Photon not available, can't convert
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const bytes = new Uint8Array(Buffer.from(base64Data, "base64"));
|
|
18
|
+
const image = photon.PhotonImage.new_from_byteslice(bytes);
|
|
19
|
+
try {
|
|
20
|
+
const pngBuffer = image.get_bytes();
|
|
21
|
+
return {
|
|
22
|
+
data: Buffer.from(pngBuffer).toString("base64"),
|
|
23
|
+
mimeType: "image/png",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
image.free();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Conversion failed
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { loadPhoton } from "./photon.js";
|
|
2
|
+
// 4.5MB - provides headroom below Anthropic's 5MB limit
|
|
3
|
+
const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
|
|
4
|
+
const DEFAULT_OPTIONS = {
|
|
5
|
+
maxWidth: 2000,
|
|
6
|
+
maxHeight: 2000,
|
|
7
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
8
|
+
jpegQuality: 80,
|
|
9
|
+
};
|
|
10
|
+
/** Helper to pick the smaller of two buffers */
|
|
11
|
+
function pickSmaller(a, b) {
|
|
12
|
+
return a.buffer.length <= b.buffer.length ? a : b;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resize an image to fit within the specified max dimensions and file size.
|
|
16
|
+
* Returns the original image if it already fits within the limits.
|
|
17
|
+
*
|
|
18
|
+
* Uses Photon (Rust/WASM) for image processing. If Photon is not available,
|
|
19
|
+
* returns the original image unchanged.
|
|
20
|
+
*
|
|
21
|
+
* Strategy for staying under maxBytes:
|
|
22
|
+
* 1. First resize to maxWidth/maxHeight
|
|
23
|
+
* 2. Try both PNG and JPEG formats, pick the smaller one
|
|
24
|
+
* 3. If still too large, try JPEG with decreasing quality
|
|
25
|
+
* 4. If still too large, progressively reduce dimensions
|
|
26
|
+
*/
|
|
27
|
+
export async function resizeImage(img, options) {
|
|
28
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
29
|
+
const inputBuffer = Buffer.from(img.data, "base64");
|
|
30
|
+
const photon = await loadPhoton();
|
|
31
|
+
if (!photon) {
|
|
32
|
+
// Photon not available, return original image
|
|
33
|
+
return {
|
|
34
|
+
data: img.data,
|
|
35
|
+
mimeType: img.mimeType,
|
|
36
|
+
originalWidth: 0,
|
|
37
|
+
originalHeight: 0,
|
|
38
|
+
width: 0,
|
|
39
|
+
height: 0,
|
|
40
|
+
wasResized: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
let image;
|
|
44
|
+
try {
|
|
45
|
+
image = photon.PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer));
|
|
46
|
+
const originalWidth = image.get_width();
|
|
47
|
+
const originalHeight = image.get_height();
|
|
48
|
+
const format = img.mimeType?.split("/")[1] ?? "png";
|
|
49
|
+
// Check if already within all limits (dimensions AND size)
|
|
50
|
+
const originalSize = inputBuffer.length;
|
|
51
|
+
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
|
|
52
|
+
return {
|
|
53
|
+
data: img.data,
|
|
54
|
+
mimeType: img.mimeType ?? `image/${format}`,
|
|
55
|
+
originalWidth,
|
|
56
|
+
originalHeight,
|
|
57
|
+
width: originalWidth,
|
|
58
|
+
height: originalHeight,
|
|
59
|
+
wasResized: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Calculate initial dimensions respecting max limits
|
|
63
|
+
let targetWidth = originalWidth;
|
|
64
|
+
let targetHeight = originalHeight;
|
|
65
|
+
if (targetWidth > opts.maxWidth) {
|
|
66
|
+
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
|
|
67
|
+
targetWidth = opts.maxWidth;
|
|
68
|
+
}
|
|
69
|
+
if (targetHeight > opts.maxHeight) {
|
|
70
|
+
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
|
|
71
|
+
targetHeight = opts.maxHeight;
|
|
72
|
+
}
|
|
73
|
+
// Helper to resize and encode in both formats, returning the smaller one
|
|
74
|
+
function tryBothFormats(width, height, jpegQuality) {
|
|
75
|
+
const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
|
|
76
|
+
try {
|
|
77
|
+
const pngBuffer = resized.get_bytes();
|
|
78
|
+
const jpegBuffer = resized.get_bytes_jpeg(jpegQuality);
|
|
79
|
+
return pickSmaller({ buffer: pngBuffer, mimeType: "image/png" }, { buffer: jpegBuffer, mimeType: "image/jpeg" });
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
resized.free();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Try to produce an image under maxBytes
|
|
86
|
+
const qualitySteps = [85, 70, 55, 40];
|
|
87
|
+
const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
|
|
88
|
+
let best;
|
|
89
|
+
let finalWidth = targetWidth;
|
|
90
|
+
let finalHeight = targetHeight;
|
|
91
|
+
// First attempt: resize to target dimensions, try both formats
|
|
92
|
+
best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
|
|
93
|
+
if (best.buffer.length <= opts.maxBytes) {
|
|
94
|
+
return {
|
|
95
|
+
data: Buffer.from(best.buffer).toString("base64"),
|
|
96
|
+
mimeType: best.mimeType,
|
|
97
|
+
originalWidth,
|
|
98
|
+
originalHeight,
|
|
99
|
+
width: finalWidth,
|
|
100
|
+
height: finalHeight,
|
|
101
|
+
wasResized: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// Still too large - try JPEG with decreasing quality
|
|
105
|
+
for (const quality of qualitySteps) {
|
|
106
|
+
best = tryBothFormats(targetWidth, targetHeight, quality);
|
|
107
|
+
if (best.buffer.length <= opts.maxBytes) {
|
|
108
|
+
return {
|
|
109
|
+
data: Buffer.from(best.buffer).toString("base64"),
|
|
110
|
+
mimeType: best.mimeType,
|
|
111
|
+
originalWidth,
|
|
112
|
+
originalHeight,
|
|
113
|
+
width: finalWidth,
|
|
114
|
+
height: finalHeight,
|
|
115
|
+
wasResized: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Still too large - reduce dimensions progressively
|
|
120
|
+
for (const scale of scaleSteps) {
|
|
121
|
+
finalWidth = Math.round(targetWidth * scale);
|
|
122
|
+
finalHeight = Math.round(targetHeight * scale);
|
|
123
|
+
if (finalWidth < 100 || finalHeight < 100) {
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
for (const quality of qualitySteps) {
|
|
127
|
+
best = tryBothFormats(finalWidth, finalHeight, quality);
|
|
128
|
+
if (best.buffer.length <= opts.maxBytes) {
|
|
129
|
+
return {
|
|
130
|
+
data: Buffer.from(best.buffer).toString("base64"),
|
|
131
|
+
mimeType: best.mimeType,
|
|
132
|
+
originalWidth,
|
|
133
|
+
originalHeight,
|
|
134
|
+
width: finalWidth,
|
|
135
|
+
height: finalHeight,
|
|
136
|
+
wasResized: true,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Last resort: return smallest version we produced
|
|
142
|
+
return {
|
|
143
|
+
data: Buffer.from(best.buffer).toString("base64"),
|
|
144
|
+
mimeType: best.mimeType,
|
|
145
|
+
originalWidth,
|
|
146
|
+
originalHeight,
|
|
147
|
+
width: finalWidth,
|
|
148
|
+
height: finalHeight,
|
|
149
|
+
wasResized: true,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Failed to load image
|
|
154
|
+
return {
|
|
155
|
+
data: img.data,
|
|
156
|
+
mimeType: img.mimeType,
|
|
157
|
+
originalWidth: 0,
|
|
158
|
+
originalHeight: 0,
|
|
159
|
+
width: 0,
|
|
160
|
+
height: 0,
|
|
161
|
+
wasResized: false,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
if (image) {
|
|
166
|
+
image.free();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Format a dimension note for resized images.
|
|
172
|
+
* This helps the model understand the coordinate mapping.
|
|
173
|
+
*/
|
|
174
|
+
export function formatDimensionNote(result) {
|
|
175
|
+
if (!result.wasResized) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const scale = result.originalWidth / result.width;
|
|
179
|
+
return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
|
|
180
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
3
|
+
const IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
|
4
|
+
const FILE_TYPE_SNIFF_BYTES = 4100;
|
|
5
|
+
export async function detectSupportedImageMimeTypeFromFile(filePath) {
|
|
6
|
+
const fileHandle = await open(filePath, "r");
|
|
7
|
+
try {
|
|
8
|
+
const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES);
|
|
9
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0);
|
|
10
|
+
if (bytesRead === 0) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const fileType = await fileTypeFromBuffer(buffer.subarray(0, bytesRead));
|
|
14
|
+
if (!fileType) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (!IMAGE_MIME_TYPES.has(fileType.mime)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return fileType.mime;
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
await fileHandle.close();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Photon image processing wrapper.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a unified interface to @silvia-odwyer/photon-node that works in:
|
|
5
|
+
* 1. Node.js (development, npm run build)
|
|
6
|
+
* 2. Bun compiled binaries (standalone distribution)
|
|
7
|
+
*
|
|
8
|
+
* The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm')
|
|
9
|
+
* which bakes the build machine's absolute path into Bun compiled binaries.
|
|
10
|
+
*
|
|
11
|
+
* Solution:
|
|
12
|
+
* 1. Patch fs.readFileSync to redirect missing photon_rs_bg.wasm reads
|
|
13
|
+
* 2. Copy photon_rs_bg.wasm next to the executable in build:binary
|
|
14
|
+
*/
|
|
15
|
+
import { createRequire } from "module";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import { fileURLToPath } from "url";
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const fs = require("fs");
|
|
20
|
+
const WASM_FILENAME = "photon_rs_bg.wasm";
|
|
21
|
+
// Lazy-loaded photon module
|
|
22
|
+
let photonModule = null;
|
|
23
|
+
let loadPromise = null;
|
|
24
|
+
function pathOrNull(file) {
|
|
25
|
+
if (typeof file === "string") {
|
|
26
|
+
return file;
|
|
27
|
+
}
|
|
28
|
+
if (file instanceof URL) {
|
|
29
|
+
return fileURLToPath(file);
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function getFallbackWasmPaths() {
|
|
34
|
+
const execDir = path.dirname(process.execPath);
|
|
35
|
+
return [
|
|
36
|
+
path.join(execDir, WASM_FILENAME),
|
|
37
|
+
path.join(execDir, "photon", WASM_FILENAME),
|
|
38
|
+
path.join(process.cwd(), WASM_FILENAME),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
function patchPhotonWasmRead() {
|
|
42
|
+
const originalReadFileSync = fs.readFileSync.bind(fs);
|
|
43
|
+
const fallbackPaths = getFallbackWasmPaths();
|
|
44
|
+
const mutableFs = fs;
|
|
45
|
+
const patchedReadFileSync = ((...args) => {
|
|
46
|
+
const [file, options] = args;
|
|
47
|
+
const resolvedPath = pathOrNull(file);
|
|
48
|
+
if (resolvedPath?.endsWith(WASM_FILENAME)) {
|
|
49
|
+
try {
|
|
50
|
+
return originalReadFileSync(...args);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const err = error;
|
|
54
|
+
if (err?.code && err.code !== "ENOENT") {
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
for (const fallbackPath of fallbackPaths) {
|
|
58
|
+
if (!fs.existsSync(fallbackPath)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (options === undefined) {
|
|
62
|
+
return originalReadFileSync(fallbackPath);
|
|
63
|
+
}
|
|
64
|
+
return originalReadFileSync(fallbackPath, options);
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return originalReadFileSync(...args);
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
mutableFs.readFileSync = patchedReadFileSync;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
Object.defineProperty(fs, "readFileSync", {
|
|
76
|
+
value: patchedReadFileSync,
|
|
77
|
+
writable: true,
|
|
78
|
+
configurable: true,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return () => {
|
|
82
|
+
try {
|
|
83
|
+
mutableFs.readFileSync = originalReadFileSync;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
Object.defineProperty(fs, "readFileSync", {
|
|
87
|
+
value: originalReadFileSync,
|
|
88
|
+
writable: true,
|
|
89
|
+
configurable: true,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Load the photon module asynchronously.
|
|
96
|
+
* Returns cached module on subsequent calls.
|
|
97
|
+
*/
|
|
98
|
+
export async function loadPhoton() {
|
|
99
|
+
if (photonModule) {
|
|
100
|
+
return photonModule;
|
|
101
|
+
}
|
|
102
|
+
if (loadPromise) {
|
|
103
|
+
return loadPromise;
|
|
104
|
+
}
|
|
105
|
+
loadPromise = (async () => {
|
|
106
|
+
const restoreReadFileSync = patchPhotonWasmRead();
|
|
107
|
+
try {
|
|
108
|
+
photonModule = await import("@silvia-odwyer/photon-node");
|
|
109
|
+
return photonModule;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
photonModule = null;
|
|
113
|
+
return photonModule;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
restoreReadFileSync();
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
return loadPromise;
|
|
120
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { delimiter } from "node:path";
|
|
3
|
+
import { spawn, spawnSync } from "child_process";
|
|
4
|
+
import { getBinDir, getSettingsPath } from "../config.js";
|
|
5
|
+
import { SettingsManager } from "../core/settings-manager.js";
|
|
6
|
+
let cachedShellConfig = null;
|
|
7
|
+
/**
|
|
8
|
+
* Find bash executable on PATH (Windows)
|
|
9
|
+
*/
|
|
10
|
+
function findBashOnPath() {
|
|
11
|
+
try {
|
|
12
|
+
const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 });
|
|
13
|
+
if (result.status === 0 && result.stdout) {
|
|
14
|
+
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
|
|
15
|
+
if (firstMatch && existsSync(firstMatch)) {
|
|
16
|
+
return firstMatch;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Ignore errors
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Get shell configuration based on platform.
|
|
27
|
+
* Resolution order:
|
|
28
|
+
* 1. User-specified shellPath in settings.json
|
|
29
|
+
* 2. On Windows: Git Bash in known locations, then bash on PATH
|
|
30
|
+
* 3. On Unix: /bin/bash
|
|
31
|
+
* 4. Fallback: sh
|
|
32
|
+
*/
|
|
33
|
+
export function getShellConfig() {
|
|
34
|
+
if (cachedShellConfig) {
|
|
35
|
+
return cachedShellConfig;
|
|
36
|
+
}
|
|
37
|
+
const settings = SettingsManager.create();
|
|
38
|
+
const customShellPath = settings.getShellPath();
|
|
39
|
+
// 1. Check user-specified shell path
|
|
40
|
+
if (customShellPath) {
|
|
41
|
+
if (existsSync(customShellPath)) {
|
|
42
|
+
cachedShellConfig = { shell: customShellPath, args: ["-c"] };
|
|
43
|
+
return cachedShellConfig;
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ${getSettingsPath()}`);
|
|
46
|
+
}
|
|
47
|
+
if (process.platform === "win32") {
|
|
48
|
+
// 2. Try Git Bash in known locations
|
|
49
|
+
const paths = [];
|
|
50
|
+
const programFiles = process.env.ProgramFiles;
|
|
51
|
+
if (programFiles) {
|
|
52
|
+
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
|
53
|
+
}
|
|
54
|
+
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
|
55
|
+
if (programFilesX86) {
|
|
56
|
+
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
|
57
|
+
}
|
|
58
|
+
for (const path of paths) {
|
|
59
|
+
if (existsSync(path)) {
|
|
60
|
+
cachedShellConfig = { shell: path, args: ["-c"] };
|
|
61
|
+
return cachedShellConfig;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
|
|
65
|
+
const bashOnPath = findBashOnPath();
|
|
66
|
+
if (bashOnPath) {
|
|
67
|
+
cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
|
|
68
|
+
return cachedShellConfig;
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`No bash shell found. Options:\n` +
|
|
71
|
+
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
|
|
72
|
+
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
|
|
73
|
+
` 3. Set shellPath in ${getSettingsPath()}\n\n` +
|
|
74
|
+
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`);
|
|
75
|
+
}
|
|
76
|
+
// Unix: prefer bash over sh
|
|
77
|
+
if (existsSync("/bin/bash")) {
|
|
78
|
+
cachedShellConfig = { shell: "/bin/bash", args: ["-c"] };
|
|
79
|
+
return cachedShellConfig;
|
|
80
|
+
}
|
|
81
|
+
cachedShellConfig = { shell: "sh", args: ["-c"] };
|
|
82
|
+
return cachedShellConfig;
|
|
83
|
+
}
|
|
84
|
+
export function getShellEnv() {
|
|
85
|
+
const binDir = getBinDir();
|
|
86
|
+
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
|
87
|
+
const currentPath = process.env[pathKey] ?? "";
|
|
88
|
+
const pathEntries = currentPath.split(delimiter).filter(Boolean);
|
|
89
|
+
const hasBinDir = pathEntries.includes(binDir);
|
|
90
|
+
const updatedPath = hasBinDir ? currentPath : [binDir, currentPath].filter(Boolean).join(delimiter);
|
|
91
|
+
return {
|
|
92
|
+
...process.env,
|
|
93
|
+
[pathKey]: updatedPath,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Sanitize binary output for display/storage.
|
|
98
|
+
* Removes characters that crash string-width or cause display issues:
|
|
99
|
+
* - Control characters (except tab, newline, carriage return)
|
|
100
|
+
* - Lone surrogates
|
|
101
|
+
* - Unicode Format characters (crash string-width due to a bug)
|
|
102
|
+
* - Characters with undefined code points
|
|
103
|
+
*/
|
|
104
|
+
export function sanitizeBinaryOutput(str) {
|
|
105
|
+
// Use Array.from to properly iterate over code points (not code units)
|
|
106
|
+
// This handles surrogate pairs correctly and catches edge cases where
|
|
107
|
+
// codePointAt() might return undefined
|
|
108
|
+
return Array.from(str)
|
|
109
|
+
.filter((char) => {
|
|
110
|
+
// Filter out characters that cause string-width to crash
|
|
111
|
+
// This includes:
|
|
112
|
+
// - Unicode format characters
|
|
113
|
+
// - Lone surrogates (already filtered by Array.from)
|
|
114
|
+
// - Control chars except \t \n \r
|
|
115
|
+
// - Characters with undefined code points
|
|
116
|
+
const code = char.codePointAt(0);
|
|
117
|
+
// Skip if code point is undefined (edge case with invalid strings)
|
|
118
|
+
if (code === undefined)
|
|
119
|
+
return false;
|
|
120
|
+
// Allow tab, newline, carriage return
|
|
121
|
+
if (code === 0x09 || code === 0x0a || code === 0x0d)
|
|
122
|
+
return true;
|
|
123
|
+
// Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d)
|
|
124
|
+
if (code <= 0x1f)
|
|
125
|
+
return false;
|
|
126
|
+
// Filter out Unicode format characters
|
|
127
|
+
if (code >= 0xfff9 && code <= 0xfffb)
|
|
128
|
+
return false;
|
|
129
|
+
return true;
|
|
130
|
+
})
|
|
131
|
+
.join("");
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Kill a process and all its children (cross-platform)
|
|
135
|
+
*/
|
|
136
|
+
export function killProcessTree(pid) {
|
|
137
|
+
if (process.platform === "win32") {
|
|
138
|
+
// Use taskkill on Windows to kill process tree
|
|
139
|
+
try {
|
|
140
|
+
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
|
141
|
+
stdio: "ignore",
|
|
142
|
+
detached: true,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Ignore errors if taskkill fails
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
// Use SIGKILL on Unix/Linux/Mac
|
|
151
|
+
try {
|
|
152
|
+
process.kill(-pid, "SIGKILL");
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Fallback to killing just the child if process group kill fails
|
|
156
|
+
try {
|
|
157
|
+
process.kill(pid, "SIGKILL");
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Process already dead
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sleep helper that respects abort signal.
|
|
3
|
+
*/
|
|
4
|
+
export function sleep(ms, signal) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
if (signal?.aborted) {
|
|
7
|
+
reject(new Error("Aborted"));
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const timeout = setTimeout(resolve, ms);
|
|
11
|
+
signal?.addEventListener("abort", () => {
|
|
12
|
+
clearTimeout(timeout);
|
|
13
|
+
reject(new Error("Aborted"));
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|