micro-models-agent 0.13.3 → 0.14.1
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/cli/repl.js +64 -2
- package/dist/core/agent.js +4 -0
- package/dist/i18n/en.json +10 -1
- package/dist/i18n/ru.json +10 -1
- package/dist/llm/image-utils.js +121 -0
- package/dist/llm/provider.js +9 -1
- package/dist/llm/token-counter.js +3 -1
- package/dist/modules/context/manager.js +64 -3
- package/dist/tools/attach-image.js +90 -0
- package/dist/tools/index.js +3 -1
- package/package.json +1 -1
package/dist/cli/repl.js
CHANGED
|
@@ -10,11 +10,13 @@ import { renderTable } from "../ui/table";
|
|
|
10
10
|
import { t } from "../i18n/index";
|
|
11
11
|
import { runSetup } from "./setup";
|
|
12
12
|
import { saveConfig } from "../config/config";
|
|
13
|
+
import { getMessageText } from "../llm/provider";
|
|
13
14
|
const COMMAND_GROUPS = {
|
|
14
15
|
help: "general",
|
|
15
16
|
exit: "general",
|
|
16
17
|
clear: "general",
|
|
17
18
|
run: "general",
|
|
19
|
+
image: "general",
|
|
18
20
|
config: "agent",
|
|
19
21
|
status: "agent",
|
|
20
22
|
reasoning: "agent",
|
|
@@ -141,6 +143,66 @@ export class Repl {
|
|
|
141
143
|
await this.runAgent(prompt);
|
|
142
144
|
},
|
|
143
145
|
});
|
|
146
|
+
this.registerCommand({
|
|
147
|
+
name: "image",
|
|
148
|
+
description: t("repl.image"),
|
|
149
|
+
aliases: ["img"],
|
|
150
|
+
usage: t("repl.image_usage"),
|
|
151
|
+
action: async (args) => {
|
|
152
|
+
const source = args.join(" ");
|
|
153
|
+
if (!source) {
|
|
154
|
+
console.log(t("repl.image_usage"));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const { loadFileAsDataUrl, loadUrlAsDataUrl, readClipboardImage } = await import("../llm/image-utils");
|
|
159
|
+
const { existsSync } = await import("fs");
|
|
160
|
+
const { resolve } = await import("path");
|
|
161
|
+
let dataUrl;
|
|
162
|
+
let label;
|
|
163
|
+
if (source.toLowerCase() === "clipboard") {
|
|
164
|
+
const clipPath = await readClipboardImage();
|
|
165
|
+
if (!clipPath) {
|
|
166
|
+
console.log(pc.yellow(t("image.clipboard_empty")));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const result = loadFileAsDataUrl(clipPath);
|
|
170
|
+
dataUrl = result.dataUrl;
|
|
171
|
+
label = "clipboard";
|
|
172
|
+
}
|
|
173
|
+
else if (source.startsWith("http://") || source.startsWith("https://")) {
|
|
174
|
+
const result = await loadUrlAsDataUrl(source);
|
|
175
|
+
dataUrl = result.dataUrl;
|
|
176
|
+
label = source;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const absPath = resolve(process.cwd(), source);
|
|
180
|
+
if (!existsSync(absPath)) {
|
|
181
|
+
console.log(pc.red(t("image.not_found", { path: source })));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const result = loadFileAsDataUrl(absPath);
|
|
185
|
+
dataUrl = result.dataUrl;
|
|
186
|
+
label = source;
|
|
187
|
+
}
|
|
188
|
+
// Store the image data on the agent's context manager for the next message
|
|
189
|
+
const contextManager = this.agent.contextManager;
|
|
190
|
+
if (!contextManager) {
|
|
191
|
+
console.log(pc.red(t("image.no_context")));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
contextManager.addPendingImage({
|
|
195
|
+
type: "image_url",
|
|
196
|
+
image_url: { url: dataUrl },
|
|
197
|
+
});
|
|
198
|
+
const sizeKb = Math.round((dataUrl.length * 3) / 4 / 1024);
|
|
199
|
+
console.log(pc.green(t("image.attached", { source: label, size: `${sizeKb} KB` })));
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
console.log(pc.red(t("image.error", { message: err.message })));
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
});
|
|
144
206
|
this.registerCommand({
|
|
145
207
|
name: "config",
|
|
146
208
|
description: t("repl.config"),
|
|
@@ -444,12 +506,12 @@ export class Repl {
|
|
|
444
506
|
for (const msg of history) {
|
|
445
507
|
if (msg.role === "user") {
|
|
446
508
|
console.log(pc.cyan(t("session.user_label") + ":"));
|
|
447
|
-
console.log(msg.content);
|
|
509
|
+
console.log(getMessageText(msg.content));
|
|
448
510
|
console.log();
|
|
449
511
|
}
|
|
450
512
|
else if (msg.role === "assistant") {
|
|
451
513
|
console.log(pc.green(t("session.assistant_label") + ":"));
|
|
452
|
-
console.log(msg.content);
|
|
514
|
+
console.log(getMessageText(msg.content));
|
|
453
515
|
console.log();
|
|
454
516
|
}
|
|
455
517
|
}
|
package/dist/core/agent.js
CHANGED
|
@@ -12,6 +12,10 @@ export class Agent {
|
|
|
12
12
|
constructor(deps) {
|
|
13
13
|
this.deps = deps;
|
|
14
14
|
}
|
|
15
|
+
/** Expose context manager for REPL image attachment and other direct access. */
|
|
16
|
+
get contextManager() {
|
|
17
|
+
return this.deps.contextManager;
|
|
18
|
+
}
|
|
15
19
|
setScope() {
|
|
16
20
|
if (this.deps.scope) {
|
|
17
21
|
this.deps.toolExecutor.setScope(this.deps.scope);
|
package/dist/i18n/en.json
CHANGED
|
@@ -431,5 +431,14 @@
|
|
|
431
431
|
"indexer.no_matches": "No matching files for \"{query}\"",
|
|
432
432
|
"tool.friendly.project_map": "Project map",
|
|
433
433
|
"config.decryption_warning": "Warning: Failed to decrypt config: {error}",
|
|
434
|
-
"config.encryption_warning": "Warning: Failed to encrypt config: {error}"
|
|
434
|
+
"config.encryption_warning": "Warning: Failed to encrypt config: {error}",
|
|
435
|
+
"image.source_required": "Image source is required. Provide a file path, URL, or \"clipboard\".",
|
|
436
|
+
"image.clipboard_empty": "Clipboard does not contain an image. Copy an image first (e.g. screenshot with Win+Shift+S).",
|
|
437
|
+
"image.not_found": "Image file not found: {path}",
|
|
438
|
+
"image.no_context": "No context manager available. Image can only be attached during an active session.",
|
|
439
|
+
"image.attached": "Image attached from {source} ({size}). It will be included in the next message.",
|
|
440
|
+
"image.error": "Failed to load image: {message}",
|
|
441
|
+
"tool.friendly.attach_image": "Attach image",
|
|
442
|
+
"repl.image": "Attach image",
|
|
443
|
+
"repl.image_usage": "/image <path|url|clipboard> — attach an image to the next message"
|
|
435
444
|
}
|
package/dist/i18n/ru.json
CHANGED
|
@@ -431,5 +431,14 @@
|
|
|
431
431
|
"indexer.no_matches": "Нет совпадающих файлов для \"{query}\"",
|
|
432
432
|
"tool.friendly.project_map": "Карта проекта",
|
|
433
433
|
"config.decryption_warning": "Предупреждение: не удалось расшифровать конфигурацию: {error}",
|
|
434
|
-
"config.encryption_warning": "Предупреждение: не удалось зашифровать конфигурацию: {error}"
|
|
434
|
+
"config.encryption_warning": "Предупреждение: не удалось зашифровать конфигурацию: {error}",
|
|
435
|
+
"image.source_required": "Укажите источник изображения: путь к файлу, URL или \"clipboard\".",
|
|
436
|
+
"image.clipboard_empty": "Буфер обмена не содержит изображение. Скопируйте изображение (например, скриншот через Win+Shift+S).",
|
|
437
|
+
"image.not_found": "Файл изображения не найден: {path}",
|
|
438
|
+
"image.no_context": "Менеджер контекста недоступен. Изображение можно прикрепить только во время активной сессии.",
|
|
439
|
+
"image.attached": "Изображение прикреплено из {source} ({size}). Оно будет включено в следующее сообщение.",
|
|
440
|
+
"image.error": "Не удалось загрузить изображение: {message}",
|
|
441
|
+
"tool.friendly.attach_image": "Прикрепить изображение",
|
|
442
|
+
"repl.image": "Прикрепить изображение",
|
|
443
|
+
"repl.image_usage": "/image <путь|URL|clipboard> — прикрепить изображение к следующему сообщению"
|
|
435
444
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { extname } from "path";
|
|
3
|
+
const MIME_MAP = {
|
|
4
|
+
".png": "image/png",
|
|
5
|
+
".jpg": "image/jpeg",
|
|
6
|
+
".jpeg": "image/jpeg",
|
|
7
|
+
".gif": "image/gif",
|
|
8
|
+
".webp": "image/webp",
|
|
9
|
+
".bmp": "image/bmp",
|
|
10
|
+
".svg": "image/svg+xml",
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Detect MIME type from file extension.
|
|
14
|
+
*/
|
|
15
|
+
export function detectMime(filePath) {
|
|
16
|
+
const ext = extname(filePath).toLowerCase();
|
|
17
|
+
return MIME_MAP[ext] ?? "image/png";
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Load an image from a file path and return as base64 data URL.
|
|
21
|
+
* Optionally resize if the raw data exceeds maxBytes.
|
|
22
|
+
*/
|
|
23
|
+
export function loadFileAsDataUrl(filePath, maxBytes) {
|
|
24
|
+
const mime = detectMime(filePath);
|
|
25
|
+
let buf = readFileSync(filePath);
|
|
26
|
+
let resized = false;
|
|
27
|
+
if (maxBytes && buf.length > maxBytes) {
|
|
28
|
+
buf = resizePngBuffer(buf, maxBytes);
|
|
29
|
+
resized = true;
|
|
30
|
+
}
|
|
31
|
+
const b64 = buf.toString("base64");
|
|
32
|
+
return { dataUrl: `data:${mime};base64,${b64}`, mime, resized };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Load an image from a URL, return as base64 data URL.
|
|
36
|
+
*/
|
|
37
|
+
export async function loadUrlAsDataUrl(url) {
|
|
38
|
+
const resp = await fetch(url);
|
|
39
|
+
if (!resp.ok) {
|
|
40
|
+
throw new Error(`Failed to fetch image: ${resp.status} ${resp.statusText}`);
|
|
41
|
+
}
|
|
42
|
+
const contentType = resp.headers.get("content-type") ?? "image/png";
|
|
43
|
+
const mime = contentType.split(";")[0].trim();
|
|
44
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
45
|
+
const b64 = buf.toString("base64");
|
|
46
|
+
return { dataUrl: `data:${mime};base64,${b64}`, mime };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read image from system clipboard (platform-specific).
|
|
50
|
+
* Returns null if clipboard has no image.
|
|
51
|
+
*/
|
|
52
|
+
export async function readClipboardImage() {
|
|
53
|
+
const { platform } = await import("os");
|
|
54
|
+
if (platform() === "win32") {
|
|
55
|
+
return readClipboardWindows();
|
|
56
|
+
}
|
|
57
|
+
if (platform() === "darwin") {
|
|
58
|
+
return readClipboardMacos();
|
|
59
|
+
}
|
|
60
|
+
return readClipboardLinux();
|
|
61
|
+
}
|
|
62
|
+
async function readClipboardWindows() {
|
|
63
|
+
try {
|
|
64
|
+
const { execSync } = await import("child_process");
|
|
65
|
+
const tmpPath = `${process.env.TEMP || process.env.TMP || "/tmp"}\\mma-clip-${Date.now()}.png`;
|
|
66
|
+
const ps = [
|
|
67
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
68
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
|
|
69
|
+
"if ($img -ne $null) {",
|
|
70
|
+
` $img.Save("${tmpPath}")`,
|
|
71
|
+
' Write-Output "OK"',
|
|
72
|
+
"} else {",
|
|
73
|
+
' Write-Output "EMPTY"',
|
|
74
|
+
"}",
|
|
75
|
+
].join("; ");
|
|
76
|
+
const out = execSync(`powershell -NoProfile -Command "${ps}"`, {
|
|
77
|
+
timeout: 5000,
|
|
78
|
+
encoding: "utf-8",
|
|
79
|
+
});
|
|
80
|
+
if (out.includes("OK")) {
|
|
81
|
+
return tmpPath;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function readClipboardMacos() {
|
|
90
|
+
try {
|
|
91
|
+
const { execSync } = await import("child_process");
|
|
92
|
+
const tmpPath = `/tmp/mma-clip-${Date.now()}.png`;
|
|
93
|
+
execSync(`pngpaste "${tmpPath}"`, { timeout: 5000 });
|
|
94
|
+
return tmpPath;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function readClipboardLinux() {
|
|
101
|
+
try {
|
|
102
|
+
const { execSync } = await import("child_process");
|
|
103
|
+
const tmpPath = `/tmp/mma-clip-${Date.now()}.png`;
|
|
104
|
+
execSync(`xclip -selection clipboard -t image/png -o > "${tmpPath}" 2>/dev/null`, { timeout: 5000 });
|
|
105
|
+
return tmpPath;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Rough PNG resize by reducing dimensions.
|
|
113
|
+
* This is a naive approach — for production, use sharp or jimp.
|
|
114
|
+
* For now, we just warn if image is too large and return as-is.
|
|
115
|
+
*/
|
|
116
|
+
function resizePngBuffer(buf, _maxBytes) {
|
|
117
|
+
// Without a native image library, we can't reliably resize.
|
|
118
|
+
// Return as-is and let the model handle it (or fail gracefully).
|
|
119
|
+
// TODO: integrate sharp for proper resize
|
|
120
|
+
return buf;
|
|
121
|
+
}
|
package/dist/llm/provider.js
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
1
|
// src/llm/provider.ts
|
|
2
|
-
|
|
2
|
+
/** Helper: extract plain text from message content (for token counting, logging, etc.) */
|
|
3
|
+
export function getMessageText(content) {
|
|
4
|
+
if (typeof content === "string")
|
|
5
|
+
return content;
|
|
6
|
+
return content
|
|
7
|
+
.filter((p) => p.type === "text")
|
|
8
|
+
.map((p) => p.text)
|
|
9
|
+
.join("");
|
|
10
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/llm/token-counter.ts
|
|
2
2
|
import { encodingForModel, getEncoding } from "js-tiktoken";
|
|
3
|
+
import { getMessageText } from "./provider";
|
|
3
4
|
export class TokenCounter {
|
|
4
5
|
encoder;
|
|
5
6
|
constructor(model = "gpt-4o") {
|
|
@@ -21,7 +22,8 @@ export class TokenCounter {
|
|
|
21
22
|
for (const msg of messages) {
|
|
22
23
|
// ~4 tokens per message overhead (role, boundaries)
|
|
23
24
|
total += 4;
|
|
24
|
-
|
|
25
|
+
const text = getMessageText(msg.content);
|
|
26
|
+
total += this.count(text);
|
|
25
27
|
if (msg.role === "tool")
|
|
26
28
|
total += 2; // tool_call_id
|
|
27
29
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getMessageText } from "../../llm/provider";
|
|
1
2
|
const COMPACTION_INTERVAL = 15;
|
|
2
3
|
const KEEP_LAST_N = 6;
|
|
3
4
|
export class ContextManager {
|
|
@@ -11,6 +12,7 @@ export class ContextManager {
|
|
|
11
12
|
decisionFacts = [];
|
|
12
13
|
errorFacts = [];
|
|
13
14
|
tokenCounter;
|
|
15
|
+
pendingImageParts = [];
|
|
14
16
|
onCompact = null;
|
|
15
17
|
constructor(contextWindow, contextBudget, tokenCounter) {
|
|
16
18
|
this.contextWindow = contextWindow;
|
|
@@ -36,15 +38,64 @@ export class ContextManager {
|
|
|
36
38
|
return { ...this.budget };
|
|
37
39
|
}
|
|
38
40
|
addMessage(msg) {
|
|
41
|
+
// Auto-attach pending images to the next user message
|
|
42
|
+
if (msg.role === "user" && this.pendingImageParts.length > 0) {
|
|
43
|
+
const textPart = {
|
|
44
|
+
type: "text",
|
|
45
|
+
text: typeof msg.content === "string" ? msg.content : getMessageText(msg.content),
|
|
46
|
+
};
|
|
47
|
+
msg = {
|
|
48
|
+
...msg,
|
|
49
|
+
content: [textPart, ...this.pendingImageParts],
|
|
50
|
+
};
|
|
51
|
+
this.pendingImageParts = [];
|
|
52
|
+
}
|
|
39
53
|
this.messages.push(msg);
|
|
40
54
|
this.iterationsSinceCompaction++;
|
|
41
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Queue an image part to be attached to the next user message.
|
|
58
|
+
*/
|
|
59
|
+
addPendingImage(part) {
|
|
60
|
+
this.pendingImageParts.push(part);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Check if there are pending images waiting to be attached.
|
|
64
|
+
*/
|
|
65
|
+
hasPendingImages() {
|
|
66
|
+
return this.pendingImageParts.length > 0;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Get pending image parts without clearing them.
|
|
70
|
+
*/
|
|
71
|
+
getPendingImages() {
|
|
72
|
+
return [...this.pendingImageParts];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Clear pending images (e.g., if user sends a text-only message).
|
|
76
|
+
*/
|
|
77
|
+
clearPendingImages() {
|
|
78
|
+
this.pendingImageParts = [];
|
|
79
|
+
}
|
|
42
80
|
getMessageCount() {
|
|
43
81
|
return this.messages.length;
|
|
44
82
|
}
|
|
45
83
|
estimateMessageTokens(m) {
|
|
84
|
+
const text = getMessageText(m.content);
|
|
46
85
|
if (this.tokenCounter) {
|
|
47
|
-
let t = this.tokenCounter.count(
|
|
86
|
+
let t = this.tokenCounter.count(text);
|
|
87
|
+
// Image tokens: base64 ~130 tokens per 512x512 tile; rough estimate
|
|
88
|
+
if (Array.isArray(m.content)) {
|
|
89
|
+
for (const part of m.content) {
|
|
90
|
+
if (part.type === "image_url" && part.image_url?.url) {
|
|
91
|
+
const b64Len = part.image_url.url.includes(",")
|
|
92
|
+
? part.image_url.url.split(",")[1]?.length ?? 0
|
|
93
|
+
: part.image_url.url.length;
|
|
94
|
+
// ~130 tokens per 512 bytes of base64
|
|
95
|
+
t += Math.ceil(b64Len / 512) * 130;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
48
99
|
if (m.tool_calls) {
|
|
49
100
|
for (const tc of m.tool_calls) {
|
|
50
101
|
t += this.tokenCounter.count(tc.id);
|
|
@@ -55,7 +106,17 @@ export class ContextManager {
|
|
|
55
106
|
}
|
|
56
107
|
return t;
|
|
57
108
|
}
|
|
58
|
-
let t = Math.ceil(
|
|
109
|
+
let t = Math.ceil(text.length / 2);
|
|
110
|
+
if (Array.isArray(m.content)) {
|
|
111
|
+
for (const part of m.content) {
|
|
112
|
+
if (part.type === "image_url" && part.image_url?.url) {
|
|
113
|
+
const b64Len = part.image_url.url.includes(",")
|
|
114
|
+
? part.image_url.url.split(",")[1]?.length ?? 0
|
|
115
|
+
: part.image_url.url.length;
|
|
116
|
+
t += Math.ceil(b64Len / 512) * 130;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
59
120
|
if (m.tool_calls) {
|
|
60
121
|
for (const tc of m.tool_calls) {
|
|
61
122
|
t += Math.ceil(tc.id.length / 2);
|
|
@@ -113,7 +174,7 @@ export class ContextManager {
|
|
|
113
174
|
/file (?:created|updated|written|deleted|moved):? ([\w./\\-]+\.[a-z]+)/gi,
|
|
114
175
|
];
|
|
115
176
|
for (const msg of turns) {
|
|
116
|
-
const content = msg.content;
|
|
177
|
+
const content = getMessageText(msg.content);
|
|
117
178
|
if (msg.role === "tool") {
|
|
118
179
|
for (const pattern of filePatterns) {
|
|
119
180
|
for (const match of content.matchAll(pattern)) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { t } from "../i18n/index";
|
|
4
|
+
import { loadFileAsDataUrl, loadUrlAsDataUrl, readClipboardImage } from "../llm/image-utils";
|
|
5
|
+
import { logSecurityBlock } from "../modules/security/audit-log";
|
|
6
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
7
|
+
export const attachImageTool = {
|
|
8
|
+
name: "attach_image",
|
|
9
|
+
description: "Attach an image to the conversation from a file path, URL, or clipboard. The image will be included in the next message sent to the model. Supports PNG, JPEG, GIF, WebP.",
|
|
10
|
+
tags: ["vision", "image"],
|
|
11
|
+
parameters: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
source: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: 'Image source: file path (e.g. "./screenshot.png"), URL (e.g. "https://example.com/img.png"), or "clipboard" to read from system clipboard.',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
required: ["source"],
|
|
20
|
+
},
|
|
21
|
+
handler: async (ctx, args) => {
|
|
22
|
+
const source = String(args.source ?? "").trim();
|
|
23
|
+
if (!source) {
|
|
24
|
+
return { success: false, output: t("image.source_required") };
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
let dataUrl;
|
|
28
|
+
if (source.toLowerCase() === "clipboard") {
|
|
29
|
+
const clipPath = await readClipboardImage();
|
|
30
|
+
if (!clipPath) {
|
|
31
|
+
return {
|
|
32
|
+
success: false,
|
|
33
|
+
output: t("image.clipboard_empty"),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const result = loadFileAsDataUrl(clipPath, MAX_IMAGE_BYTES);
|
|
37
|
+
dataUrl = result.dataUrl;
|
|
38
|
+
}
|
|
39
|
+
else if (source.startsWith("http://") || source.startsWith("https://")) {
|
|
40
|
+
const result = await loadUrlAsDataUrl(source);
|
|
41
|
+
dataUrl = result.dataUrl;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const absPath = resolve(ctx.baseDir, source);
|
|
45
|
+
if (!existsSync(absPath)) {
|
|
46
|
+
return {
|
|
47
|
+
success: false,
|
|
48
|
+
output: t("image.not_found", { path: source }),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Security: check path scope
|
|
52
|
+
if (ctx.scope) {
|
|
53
|
+
const { isPathInScope } = await import("../modules/security/path-validator");
|
|
54
|
+
const validation = isPathInScope(ctx.baseDir, absPath, ctx.scope);
|
|
55
|
+
if (!validation.allowed) {
|
|
56
|
+
logSecurityBlock(ctx.sessionId, "file_read", validation.reason ?? "attach_image out of scope", absPath);
|
|
57
|
+
return {
|
|
58
|
+
success: false,
|
|
59
|
+
output: t("file.path_not_allowed", { path: source }),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const result = loadFileAsDataUrl(absPath, MAX_IMAGE_BYTES);
|
|
64
|
+
dataUrl = result.dataUrl;
|
|
65
|
+
}
|
|
66
|
+
// Add the image as a pending content part on the context manager
|
|
67
|
+
if (!ctx.contextManager) {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
output: t("image.no_context"),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
ctx.contextManager.addPendingImage({
|
|
74
|
+
type: "image_url",
|
|
75
|
+
image_url: { url: dataUrl },
|
|
76
|
+
});
|
|
77
|
+
const sizeKb = Math.round((dataUrl.length * 3) / 4 / 1024);
|
|
78
|
+
return {
|
|
79
|
+
success: true,
|
|
80
|
+
output: t("image.attached", { source, size: `${sizeKb} KB` }),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return {
|
|
85
|
+
success: false,
|
|
86
|
+
output: t("image.error", { message: err.message }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
package/dist/tools/index.js
CHANGED
|
@@ -25,9 +25,10 @@ import { pipelineRunTool } from './pipeline-run';
|
|
|
25
25
|
import { mcpCallTool } from './mcp-call';
|
|
26
26
|
import { searchHistoryTool } from './search-history';
|
|
27
27
|
import { createBrowserTool } from './browser';
|
|
28
|
+
import { attachImageTool } from './attach-image';
|
|
28
29
|
export { ToolRegistry, ToolExecutor };
|
|
29
30
|
export { filterToolsByTags } from './filter-tools';
|
|
30
|
-
export { readFileTool, writeFileTool, editFileTool, globTool, grepTool, listDirTool, createDirTool, deleteFileTool, moveFileTool, fileInfoTool, bashTool, subagentTool, processListTool, processLogTool, processKillTool, webSearchTool, webFetchTool, webBrowseTool, questionTool, approveTool, createLoadSkillTool, pipelineRunTool, mcpCallTool, searchHistoryTool, createBrowserTool, };
|
|
31
|
+
export { readFileTool, writeFileTool, editFileTool, globTool, grepTool, listDirTool, createDirTool, deleteFileTool, moveFileTool, fileInfoTool, bashTool, subagentTool, processListTool, processLogTool, processKillTool, webSearchTool, webFetchTool, webBrowseTool, questionTool, approveTool, createLoadSkillTool, pipelineRunTool, mcpCallTool, searchHistoryTool, createBrowserTool, attachImageTool, };
|
|
31
32
|
export function registerAllTools(registry, skillsModule) {
|
|
32
33
|
const tools = [
|
|
33
34
|
readFileTool, writeFileTool, editFileTool,
|
|
@@ -38,6 +39,7 @@ export function registerAllTools(registry, skillsModule) {
|
|
|
38
39
|
webSearchTool, webFetchTool, webBrowseTool,
|
|
39
40
|
questionTool, approveTool,
|
|
40
41
|
pipelineRunTool, mcpCallTool, searchHistoryTool,
|
|
42
|
+
attachImageTool,
|
|
41
43
|
];
|
|
42
44
|
if (skillsModule) {
|
|
43
45
|
tools.push(createLoadSkillTool(skillsModule));
|