@ridit/lens 0.3.7 → 0.3.9
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/dist/index.mjs +105368 -274002
- package/package.json +13 -19
- package/src/colors.ts +15 -15
- package/src/commands/chat.tsx +32 -23
- package/src/commands/provider.tsx +11 -238
- package/src/commands/repo.tsx +66 -120
- package/src/commands/timeline.tsx +11 -22
- package/src/components/ChatView.tsx +238 -0
- package/src/components/Message.tsx +46 -0
- package/src/components/ToolCall.tsx +67 -0
- package/src/components/chat/ChatView.tsx +550 -0
- package/src/components/chat/Message.tsx +152 -0
- package/src/components/chat/StatusBar.tsx +214 -0
- package/src/components/chat/TextArea.tsx +173 -176
- package/src/components/provider/ApiKeyStep.tsx +207 -199
- package/src/components/provider/ModelStep.tsx +90 -88
- package/src/components/provider/ProviderSetup.tsx +331 -0
- package/src/components/provider/ProviderTypeStep.tsx +53 -61
- package/src/components/repo/StepRow.tsx +68 -69
- package/src/components/timeline/TimelineView.tsx +840 -0
- package/src/components/toolcall-utils.ts +103 -0
- package/src/components/watch/RunView.tsx +497 -0
- package/src/hooks/useChatInput.ts +49 -0
- package/src/hooks/useCommandHandler.ts +117 -0
- package/src/index.tsx +386 -139
- package/src/utils/git.ts +149 -155
- package/src/utils/repo.ts +62 -69
- package/src/utils/thinking.tsx +64 -0
- package/src/utils/watch.ts +165 -307
- package/tests/message.test.ts +38 -0
- package/tests/toolcall-utils.test.ts +111 -0
- package/tsconfig.json +8 -24
- package/CLAUDE.md +0 -50
- package/LENS.md +0 -48
- package/LICENSE +0 -21
- package/README.md +0 -93
- package/addons/README.md +0 -55
- package/addons/clean-cache.js +0 -48
- package/addons/generate-readme.js +0 -67
- package/addons/git-stats.js +0 -29
- package/addons/run-tests.js +0 -127
- package/src/commands/commit.tsx +0 -668
- package/src/commands/review.tsx +0 -294
- package/src/commands/run.tsx +0 -56
- package/src/commands/task.tsx +0 -36
- package/src/components/chat/ChatMessage.tsx +0 -195
- package/src/components/chat/ChatOverlays.tsx +0 -399
- package/src/components/chat/ChatRunner.tsx +0 -517
- package/src/components/chat/hooks/useChat.ts +0 -631
- package/src/components/chat/hooks/useChatInput.ts +0 -79
- package/src/components/chat/hooks/useCommandHandlers.ts +0 -327
- package/src/components/provider/ProviderPicker.tsx +0 -76
- package/src/components/provider/RemoveProviderStep.tsx +0 -82
- package/src/components/repo/DiffViewer.tsx +0 -175
- package/src/components/repo/FileReviewer.tsx +0 -70
- package/src/components/repo/FileViewer.tsx +0 -60
- package/src/components/repo/IssueFixer.tsx +0 -666
- package/src/components/repo/LensFileMenu.tsx +0 -115
- package/src/components/repo/NoProviderPrompt.tsx +0 -28
- package/src/components/repo/PreviewRunner.tsx +0 -217
- package/src/components/repo/RepoAnalysis.tsx +0 -534
- package/src/components/task/TaskRunner.tsx +0 -396
- package/src/components/timeline/CommitDetail.tsx +0 -272
- package/src/components/timeline/CommitList.tsx +0 -162
- package/src/components/timeline/TimelineChat.tsx +0 -166
- package/src/components/timeline/TimelineRunner.tsx +0 -1285
- package/src/components/watch/RunRunner.tsx +0 -929
- package/src/prompts/fewshot.ts +0 -252
- package/src/prompts/index.ts +0 -2
- package/src/prompts/system.ts +0 -285
- package/src/tools/chart.ts +0 -202
- package/src/tools/convert-image.ts +0 -312
- package/src/tools/files.ts +0 -253
- package/src/tools/git.ts +0 -603
- package/src/tools/index.ts +0 -17
- package/src/tools/pdf.ts +0 -164
- package/src/tools/shell.ts +0 -96
- package/src/tools/view-image.ts +0 -335
- package/src/tools/web.ts +0 -212
- package/src/types/chat.ts +0 -86
- package/src/types/config.ts +0 -20
- package/src/types/repo.ts +0 -54
- package/src/utils/addons/loadAddons.ts +0 -34
- package/src/utils/ai.ts +0 -321
- package/src/utils/chat.ts +0 -326
- package/src/utils/chatHistory.ts +0 -121
- package/src/utils/config.ts +0 -61
- package/src/utils/files.ts +0 -105
- package/src/utils/intentClassifier.ts +0 -58
- package/src/utils/lensfile.ts +0 -142
- package/src/utils/llm.ts +0 -81
- package/src/utils/memory.ts +0 -209
- package/src/utils/preview.ts +0 -119
- package/src/utils/stats.ts +0 -174
- package/src/utils/tools/builtins.ts +0 -377
- package/src/utils/tools/registry.ts +0 -105
package/src/tools/chart.ts
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
import type { Tool } from "@ridit/lens-sdk";
|
|
2
|
-
|
|
3
|
-
type ChartType = "bar" | "line" | "sparkline";
|
|
4
|
-
|
|
5
|
-
interface ChartInput {
|
|
6
|
-
type: ChartType;
|
|
7
|
-
title?: string;
|
|
8
|
-
labels?: string[];
|
|
9
|
-
values: number[];
|
|
10
|
-
height?: number;
|
|
11
|
-
fill?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function parseChartInput(body: string): ChartInput | null {
|
|
15
|
-
const trimmed = body.trim();
|
|
16
|
-
if (!trimmed) return null;
|
|
17
|
-
try {
|
|
18
|
-
const parsed = JSON.parse(trimmed) as Partial<ChartInput> & {
|
|
19
|
-
data?: number[];
|
|
20
|
-
series?: number[];
|
|
21
|
-
};
|
|
22
|
-
const values = parsed.values ?? parsed.data ?? parsed.series ?? [];
|
|
23
|
-
if (!Array.isArray(values) || values.length === 0) return null;
|
|
24
|
-
return {
|
|
25
|
-
type: parsed.type ?? "bar",
|
|
26
|
-
title: parsed.title,
|
|
27
|
-
labels: parsed.labels,
|
|
28
|
-
values: values.map(Number),
|
|
29
|
-
height: parsed.height ?? 10,
|
|
30
|
-
fill: parsed.fill ?? "█",
|
|
31
|
-
};
|
|
32
|
-
} catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const C = {
|
|
38
|
-
reset: "\x1b[0m",
|
|
39
|
-
dim: "\x1b[2m",
|
|
40
|
-
bold: "\x1b[1m",
|
|
41
|
-
orange: "\x1b[38;2;218;119;88m",
|
|
42
|
-
cyan: "\x1b[36m",
|
|
43
|
-
white: "\x1b[37m",
|
|
44
|
-
gray: "\x1b[90m",
|
|
45
|
-
green: "\x1b[32m",
|
|
46
|
-
yellow: "\x1b[33m",
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const PALETTE = [C.orange, C.cyan, C.green, C.yellow];
|
|
50
|
-
function color(i: number) {
|
|
51
|
-
return PALETTE[i % PALETTE.length]!;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function renderBar(input: ChartInput): string {
|
|
55
|
-
const { values, labels, title, fill = "█" } = input;
|
|
56
|
-
const max = Math.max(...values, 1);
|
|
57
|
-
const termW = process.stdout.columns ?? 80;
|
|
58
|
-
const maxLabelLen = labels
|
|
59
|
-
? Math.max(...labels.map((l) => l.length), 0)
|
|
60
|
-
: String(values.length).length + 1;
|
|
61
|
-
const barMaxW = Math.max(20, termW - maxLabelLen - 12);
|
|
62
|
-
|
|
63
|
-
const lines: string[] = [];
|
|
64
|
-
if (title) lines.push(`${C.bold}${C.white}${title}${C.reset}\n`);
|
|
65
|
-
|
|
66
|
-
values.forEach((v, i) => {
|
|
67
|
-
const label = labels?.[i] ?? String(i + 1);
|
|
68
|
-
const barLen = Math.round((v / max) * barMaxW);
|
|
69
|
-
const bar = fill.repeat(barLen);
|
|
70
|
-
const valueStr = String(v);
|
|
71
|
-
lines.push(
|
|
72
|
-
`${C.gray}${label.padStart(maxLabelLen)}${C.reset} ` +
|
|
73
|
-
`${color(i)}${bar}${C.reset} ` +
|
|
74
|
-
`${C.dim}${valueStr}${C.reset}`,
|
|
75
|
-
);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
lines.push(
|
|
79
|
-
`${" ".repeat(maxLabelLen + 1)}${C.gray}${"─".repeat(barMaxW)}${C.reset}`,
|
|
80
|
-
);
|
|
81
|
-
lines.push(
|
|
82
|
-
`${" ".repeat(maxLabelLen + 1)}${C.gray}0${" ".repeat(barMaxW - String(max).length)}${max}${C.reset}`,
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
return lines.join("\n");
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const SPARK_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
89
|
-
|
|
90
|
-
function renderSparkline(input: ChartInput): string {
|
|
91
|
-
const { values, title } = input;
|
|
92
|
-
const min = Math.min(...values);
|
|
93
|
-
const max = Math.max(...values, min + 1);
|
|
94
|
-
const range = max - min;
|
|
95
|
-
const spark = values
|
|
96
|
-
.map((v) => {
|
|
97
|
-
const idx = Math.floor(((v - min) / range) * (SPARK_CHARS.length - 1));
|
|
98
|
-
return `${color(0)}${SPARK_CHARS[idx] ?? "▁"}${C.reset}`;
|
|
99
|
-
})
|
|
100
|
-
.join("");
|
|
101
|
-
|
|
102
|
-
const lines: string[] = [];
|
|
103
|
-
if (title) lines.push(`${C.bold}${C.white}${title}${C.reset}`);
|
|
104
|
-
lines.push(spark);
|
|
105
|
-
lines.push(`${C.gray}min ${min} max ${max} n=${values.length}${C.reset}`);
|
|
106
|
-
return lines.join("\n");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function renderLine(input: ChartInput): Promise<string> {
|
|
110
|
-
let asciichart: any;
|
|
111
|
-
try {
|
|
112
|
-
asciichart = await import("asciichart");
|
|
113
|
-
asciichart = asciichart.default ?? asciichart;
|
|
114
|
-
} catch {
|
|
115
|
-
return (
|
|
116
|
-
`${C.yellow}asciichart not installed (npm install asciichart). ` +
|
|
117
|
-
`Falling back to sparkline:${C.reset}\n` +
|
|
118
|
-
renderSparkline(input)
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const termW = process.stdout.columns ?? 80;
|
|
123
|
-
const height = input.height ?? 10;
|
|
124
|
-
|
|
125
|
-
const lines: string[] = [];
|
|
126
|
-
if (input.title) {
|
|
127
|
-
lines.push(`${C.bold}${C.white}${input.title}${C.reset}\n`);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const chart = asciichart.plot(input.values, {
|
|
131
|
-
height,
|
|
132
|
-
width: Math.min(input.values.length, termW - 14),
|
|
133
|
-
colors: [asciichart.cyan],
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
lines.push(chart);
|
|
137
|
-
|
|
138
|
-
if (input.labels && input.labels.length === input.values.length) {
|
|
139
|
-
const step = Math.max(
|
|
140
|
-
1,
|
|
141
|
-
Math.floor(input.labels.length / Math.min(input.labels.length, 10)),
|
|
142
|
-
);
|
|
143
|
-
const labelRow = input.labels
|
|
144
|
-
.filter((_, i) => i % step === 0)
|
|
145
|
-
.map((l) => l.slice(0, 6).padEnd(6))
|
|
146
|
-
.join(" ");
|
|
147
|
-
lines.push(`${C.gray}${" ".repeat(8)}${labelRow}${C.reset}`);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return lines.join("\n");
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async function renderChart(input: ChartInput): Promise<string> {
|
|
154
|
-
switch (input.type) {
|
|
155
|
-
case "bar":
|
|
156
|
-
return renderBar(input);
|
|
157
|
-
case "sparkline":
|
|
158
|
-
return renderSparkline(input);
|
|
159
|
-
case "line":
|
|
160
|
-
return await renderLine(input);
|
|
161
|
-
default:
|
|
162
|
-
return renderBar(input);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export const chartDataTool: Tool<ChartInput> = {
|
|
167
|
-
name: "chart-data",
|
|
168
|
-
description:
|
|
169
|
-
"render a bar, line, or sparkline chart in the terminal from given data",
|
|
170
|
-
safe: true,
|
|
171
|
-
permissionLabel: "chart",
|
|
172
|
-
|
|
173
|
-
systemPromptEntry: (i) =>
|
|
174
|
-
`### ${i}. chart-data — render a chart in the terminal\n` +
|
|
175
|
-
`Types: "bar" (default), "line", "sparkline"\n` +
|
|
176
|
-
`<chart-data>\n` +
|
|
177
|
-
`{"type": "bar", "title": "Commits per month", "labels": ["Jan","Feb","Mar"], "values": [12, 34, 21]}\n` +
|
|
178
|
-
`</chart-data>\n` +
|
|
179
|
-
`<chart-data>\n` +
|
|
180
|
-
`{"type": "line", "title": "Stars over time", "values": [1,3,6,10,15,21,28], "height": 8}\n` +
|
|
181
|
-
`</chart-data>\n` +
|
|
182
|
-
`<chart-data>\n` +
|
|
183
|
-
`{"type": "sparkline", "title": "Daily commits", "values": [2,5,1,8,3,7,4]}\n` +
|
|
184
|
-
`</chart-data>`,
|
|
185
|
-
|
|
186
|
-
parseInput: parseChartInput,
|
|
187
|
-
|
|
188
|
-
summariseInput: ({ type, title }) =>
|
|
189
|
-
title ? `${type} chart — ${title}` : `${type} chart`,
|
|
190
|
-
|
|
191
|
-
execute: async (input, _ctx) => {
|
|
192
|
-
try {
|
|
193
|
-
const rendered = await renderChart(input);
|
|
194
|
-
return { kind: "image" as any, value: rendered };
|
|
195
|
-
} catch (err) {
|
|
196
|
-
return {
|
|
197
|
-
kind: "text",
|
|
198
|
-
value: `Error rendering chart: ${err instanceof Error ? err.message : String(err)}`,
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
},
|
|
202
|
-
};
|
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
// this tool will be used by view-image tool for conversion of unsupported image formats
|
|
2
|
-
|
|
3
|
-
import path from "path";
|
|
4
|
-
import { existsSync, mkdirSync } from "fs";
|
|
5
|
-
import { execSync } from "child_process";
|
|
6
|
-
import type { Tool } from "@ridit/lens-sdk";
|
|
7
|
-
|
|
8
|
-
interface ConvertImageInput {
|
|
9
|
-
input: string | string[];
|
|
10
|
-
|
|
11
|
-
output: string;
|
|
12
|
-
|
|
13
|
-
resize?: string;
|
|
14
|
-
|
|
15
|
-
crop?: string;
|
|
16
|
-
|
|
17
|
-
rotate?: 90 | 180 | 270;
|
|
18
|
-
|
|
19
|
-
flip?: "h" | "v" | "both";
|
|
20
|
-
|
|
21
|
-
quality?: number;
|
|
22
|
-
|
|
23
|
-
grayscale?: boolean;
|
|
24
|
-
|
|
25
|
-
blur?: number;
|
|
26
|
-
|
|
27
|
-
sharpen?: number;
|
|
28
|
-
|
|
29
|
-
strip?: boolean;
|
|
30
|
-
|
|
31
|
-
watermark?: string;
|
|
32
|
-
|
|
33
|
-
frames?: number;
|
|
34
|
-
|
|
35
|
-
gifDelay?: number;
|
|
36
|
-
|
|
37
|
-
gifLoop?: number;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function parseInput(body: string): ConvertImageInput | null {
|
|
41
|
-
const trimmed = body.trim();
|
|
42
|
-
if (!trimmed) return null;
|
|
43
|
-
try {
|
|
44
|
-
const parsed = JSON.parse(trimmed) as ConvertImageInput;
|
|
45
|
-
if (!parsed.input || !parsed.output) return null;
|
|
46
|
-
return parsed;
|
|
47
|
-
} catch {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function ffmpegAvailable(): boolean {
|
|
53
|
-
try {
|
|
54
|
-
execSync("ffmpeg -version", { stdio: "pipe", timeout: 5000 });
|
|
55
|
-
return true;
|
|
56
|
-
} catch {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function resolve(p: string, repoPath: string): string {
|
|
62
|
-
return path.isAbsolute(p) ? p : path.join(repoPath, p);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function ensureDir(filePath: string): void {
|
|
66
|
-
const dir = path.dirname(filePath);
|
|
67
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function buildVfFilters(input: ConvertImageInput): string[] {
|
|
71
|
-
const filters: string[] = [];
|
|
72
|
-
|
|
73
|
-
if (input.resize) {
|
|
74
|
-
const [w, h] = input.resize.split("x");
|
|
75
|
-
const fw = w === "-1" ? "-2" : (w ?? "-2");
|
|
76
|
-
const fh = h === "-1" ? "-2" : (h ?? "-2");
|
|
77
|
-
filters.push(`scale=${fw}:${fh}`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (input.crop) {
|
|
81
|
-
const parts = input.crop.split(/[x:]/);
|
|
82
|
-
const [cw, ch, cx = "0", cy = "0"] = parts;
|
|
83
|
-
filters.push(`crop=${cw}:${ch}:${cx}:${cy}`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (input.rotate) {
|
|
87
|
-
const transposeMap: Record<number, string> = {
|
|
88
|
-
90: "transpose=1",
|
|
89
|
-
180: "transpose=2,transpose=2",
|
|
90
|
-
270: "transpose=2",
|
|
91
|
-
};
|
|
92
|
-
if (transposeMap[input.rotate]) filters.push(transposeMap[input.rotate]!);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (input.flip) {
|
|
96
|
-
if (input.flip === "h" || input.flip === "both") filters.push("hflip");
|
|
97
|
-
if (input.flip === "v" || input.flip === "both") filters.push("vflip");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (input.grayscale) {
|
|
101
|
-
filters.push("hue=s=0");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (input.blur && input.blur > 0) {
|
|
105
|
-
filters.push(`gblur=sigma=${input.blur}`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (input.sharpen && input.sharpen > 0) {
|
|
109
|
-
filters.push(`unsharp=5:5:${input.sharpen}:5:5:0`);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return filters;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function buildArgs(
|
|
116
|
-
input: ConvertImageInput,
|
|
117
|
-
resolvedInput: string,
|
|
118
|
-
resolvedOutput: string,
|
|
119
|
-
repoPath: string,
|
|
120
|
-
): string[] {
|
|
121
|
-
const args: string[] = ["-y"];
|
|
122
|
-
|
|
123
|
-
if (Array.isArray(input.input)) {
|
|
124
|
-
const delay = input.gifDelay ?? 10;
|
|
125
|
-
const loop = input.gifLoop ?? 0;
|
|
126
|
-
|
|
127
|
-
for (const src of input.input) {
|
|
128
|
-
args.push("-i", resolve(src, repoPath));
|
|
129
|
-
}
|
|
130
|
-
args.push(
|
|
131
|
-
"-filter_complex",
|
|
132
|
-
`concat=n=${input.input.length}:v=1:a=0,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
|
|
133
|
-
"-loop",
|
|
134
|
-
String(loop),
|
|
135
|
-
resolvedOutput,
|
|
136
|
-
);
|
|
137
|
-
return args;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (input.frames) {
|
|
141
|
-
args.push("-i", resolvedInput);
|
|
142
|
-
args.push("-vframes", String(input.frames));
|
|
143
|
-
|
|
144
|
-
args.push(resolvedOutput);
|
|
145
|
-
return args;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (input.watermark) {
|
|
149
|
-
const wmPath = resolve(input.watermark, repoPath);
|
|
150
|
-
args.push("-i", resolvedInput, "-i", wmPath);
|
|
151
|
-
args.push(
|
|
152
|
-
"-filter_complex",
|
|
153
|
-
"[1:v]scale=iw/4:-1[wm];[0:v][wm]overlay=W-w-10:H-h-10",
|
|
154
|
-
);
|
|
155
|
-
const vf = buildVfFilters(input);
|
|
156
|
-
if (vf.length) args.push("-vf", vf.join(","));
|
|
157
|
-
} else {
|
|
158
|
-
args.push("-i", resolvedInput);
|
|
159
|
-
const vf = buildVfFilters(input);
|
|
160
|
-
if (vf.length) args.push("-vf", vf.join(","));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const ext = path.extname(resolvedOutput).toLowerCase();
|
|
164
|
-
if (input.quality !== undefined) {
|
|
165
|
-
if (ext === ".jpg" || ext === ".jpeg") {
|
|
166
|
-
const q = Math.round(2 + ((100 - input.quality) / 100) * 29);
|
|
167
|
-
args.push("-q:v", String(q));
|
|
168
|
-
} else if (ext === ".webp") {
|
|
169
|
-
args.push("-quality", String(input.quality));
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (input.strip) {
|
|
174
|
-
args.push("-map_metadata", "-1");
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const imageExts = [
|
|
178
|
-
".png",
|
|
179
|
-
".jpg",
|
|
180
|
-
".jpeg",
|
|
181
|
-
".webp",
|
|
182
|
-
".avif",
|
|
183
|
-
".bmp",
|
|
184
|
-
".tiff",
|
|
185
|
-
".gif",
|
|
186
|
-
];
|
|
187
|
-
if (imageExts.includes(ext) && !input.frames) {
|
|
188
|
-
args.push("-frames:v", "1");
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
args.push(resolvedOutput);
|
|
192
|
-
return args;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function runConvert(input: ConvertImageInput, repoPath: string): string {
|
|
196
|
-
if (!ffmpegAvailable()) {
|
|
197
|
-
return (
|
|
198
|
-
"Error: ffmpeg is not installed or not on PATH.\n" +
|
|
199
|
-
"Install it from https://ffmpeg.org/download.html or:\n" +
|
|
200
|
-
" Windows: winget install ffmpeg\n" +
|
|
201
|
-
" macOS: brew install ffmpeg\n" +
|
|
202
|
-
" Linux: sudo apt install ffmpeg"
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const resolvedOutput = resolve(input.output, repoPath);
|
|
207
|
-
ensureDir(resolvedOutput);
|
|
208
|
-
|
|
209
|
-
const resolvedInput = Array.isArray(input.input)
|
|
210
|
-
? resolve(input.input[0]!, repoPath)
|
|
211
|
-
: resolve(input.input, repoPath);
|
|
212
|
-
|
|
213
|
-
if (!Array.isArray(input.input) && !input.input.startsWith("http")) {
|
|
214
|
-
if (!existsSync(resolvedInput)) {
|
|
215
|
-
return `Error: input file not found — ${resolvedInput}`;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const args = buildArgs(input, resolvedInput, resolvedOutput, repoPath);
|
|
220
|
-
const cmd = `ffmpeg ${args.map((a) => `"${a}"`).join(" ")}`;
|
|
221
|
-
|
|
222
|
-
try {
|
|
223
|
-
const stderr = execSync(cmd, {
|
|
224
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
225
|
-
timeout: 120_000,
|
|
226
|
-
encoding: "utf-8",
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
const lines = (stderr || "")
|
|
230
|
-
.split("\n")
|
|
231
|
-
.filter((l) => l.includes("video:") || l.includes("frame="))
|
|
232
|
-
.slice(-3)
|
|
233
|
-
.join("\n");
|
|
234
|
-
|
|
235
|
-
const inputLabel = Array.isArray(input.input)
|
|
236
|
-
? `${input.input.length} files`
|
|
237
|
-
: path.basename(input.input as string);
|
|
238
|
-
|
|
239
|
-
return (
|
|
240
|
-
`✓ converted ${inputLabel} → ${input.output}\n` +
|
|
241
|
-
(lines ? `\n${lines}` : "")
|
|
242
|
-
).trim();
|
|
243
|
-
} catch (e: any) {
|
|
244
|
-
const errOut = (e.stderr ?? e.stdout ?? e.message ?? String(e))
|
|
245
|
-
.split("\n")
|
|
246
|
-
.filter((l: string) => l.trim() && !l.startsWith("ffmpeg version"))
|
|
247
|
-
.slice(-6)
|
|
248
|
-
.join("\n");
|
|
249
|
-
return `Error: ${errOut}`;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
export const convertImageTool: Tool<ConvertImageInput> = {
|
|
254
|
-
name: "convert-image",
|
|
255
|
-
description:
|
|
256
|
-
"convert, resize, crop, rotate, compress, or reformat images using ffmpeg",
|
|
257
|
-
safe: false,
|
|
258
|
-
permissionLabel: "convert image",
|
|
259
|
-
|
|
260
|
-
systemPromptEntry: (i) =>
|
|
261
|
-
[
|
|
262
|
-
`### ${i}. convert-image — image conversion and manipulation via ffmpeg`,
|
|
263
|
-
`<convert-image>`,
|
|
264
|
-
`{"input": "photo.png", "output": "photo.webp", "quality": 85, "resize": "1280x-1"}`,
|
|
265
|
-
`</convert-image>`,
|
|
266
|
-
`Fields (all optional except input/output):`,
|
|
267
|
-
` input — source path or array of paths (for gif assembly)`,
|
|
268
|
-
` output — destination path; extension sets format (png/jpg/webp/avif/gif/bmp/tiff)`,
|
|
269
|
-
` resize — "WxH" or "Wx-1" (keep aspect) e.g. "800x-1" or "400x300"`,
|
|
270
|
-
` crop — "WxH:X:Y" e.g. "400x300:100:50"`,
|
|
271
|
-
` rotate — 90 | 180 | 270`,
|
|
272
|
-
` flip — "h" | "v" | "both"`,
|
|
273
|
-
` quality — 1–100 (jpg/webp)`,
|
|
274
|
-
` grayscale — true`,
|
|
275
|
-
` blur — gaussian blur radius e.g. 5`,
|
|
276
|
-
` sharpen — unsharp strength e.g. 1.5`,
|
|
277
|
-
` strip — true to remove EXIF metadata`,
|
|
278
|
-
` watermark — path to overlay image (bottom-right, 25% size)`,
|
|
279
|
-
` frames — extract N frames from video/gif (output must contain %04d e.g. frame%04d.png)`,
|
|
280
|
-
` gifDelay — frame delay in centiseconds for gif assembly (default 10)`,
|
|
281
|
-
` gifLoop — gif loop count, 0 = infinite (default 0)`,
|
|
282
|
-
`Examples:`,
|
|
283
|
-
` Convert format: {"input":"a.png","output":"a.jpg","quality":90}`,
|
|
284
|
-
` Resize: {"input":"big.jpg","output":"thumb.jpg","resize":"400x-1"}`,
|
|
285
|
-
` Rotate + grayscale:{"input":"photo.jpg","output":"out.jpg","rotate":90,"grayscale":true}`,
|
|
286
|
-
` Strip EXIF: {"input":"photo.jpg","output":"clean.jpg","strip":true}`,
|
|
287
|
-
` Assemble gif: {"input":["f1.png","f2.png","f3.png"],"output":"anim.gif","gifDelay":15}`,
|
|
288
|
-
` Extract frames: {"input":"clip.gif","output":"frame%04d.png","frames":10}`,
|
|
289
|
-
` Watermark: {"input":"photo.jpg","output":"marked.jpg","watermark":"logo.png"}`,
|
|
290
|
-
].join("\n"),
|
|
291
|
-
|
|
292
|
-
parseInput,
|
|
293
|
-
|
|
294
|
-
summariseInput: ({ input, output }) => {
|
|
295
|
-
const src = Array.isArray(input)
|
|
296
|
-
? `${input.length} files`
|
|
297
|
-
: path.basename(input as string);
|
|
298
|
-
return `${src} → ${path.basename(output)}`;
|
|
299
|
-
},
|
|
300
|
-
|
|
301
|
-
execute: async (input, ctx) => {
|
|
302
|
-
try {
|
|
303
|
-
const result = runConvert(input, ctx.repoPath);
|
|
304
|
-
return { kind: "text", value: result };
|
|
305
|
-
} catch (err) {
|
|
306
|
-
return {
|
|
307
|
-
kind: "text",
|
|
308
|
-
value: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
},
|
|
312
|
-
};
|