micro-models-agent 0.13.2 → 0.14.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/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
  }
@@ -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);
@@ -137,6 +141,7 @@ export class Agent {
137
141
  const toolCalls = [];
138
142
  let sawToolCall = false;
139
143
  let emittedReasoning = false;
144
+ const textChunks = [];
140
145
  this.emitPhase(iteration, "thinking", onPhase);
141
146
  try {
142
147
  for await (const chunk of llmProvider.chat(history, allTools)) {
@@ -145,8 +150,7 @@ export class Agent {
145
150
  onMeta?.("\n\n");
146
151
  }
147
152
  textContent += chunk.content;
148
- const textOut = pluginManager.runOnText({ iteration, logger }, chunk.content);
149
- onChunk?.(textOut);
153
+ textChunks.push(chunk.content);
150
154
  }
151
155
  if (chunk.type === "reasoning" && chunk.content) {
152
156
  reasoningContent += chunk.content;
@@ -189,6 +193,15 @@ export class Agent {
189
193
  finally {
190
194
  this.emitPhase(iteration, "done", onPhase);
191
195
  }
196
+ // Display buffered text only if no tool call in this response.
197
+ // When a tool call is present, text is just the model describing
198
+ // its tool call (e.g. raw JSON args) — suppress it.
199
+ if (!sawToolCall && textChunks.length > 0) {
200
+ for (const chunk of textChunks) {
201
+ const textOut = pluginManager.runOnText({ iteration, logger }, chunk);
202
+ onChunk?.(textOut);
203
+ }
204
+ }
192
205
  let llmResponse = null;
193
206
  if (sawToolCall) {
194
207
  llmResponse = { type: "tool_call", calls: toolCalls };
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
+ }
@@ -1,2 +1,10 @@
1
1
  // src/llm/provider.ts
2
- export {};
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
- total += this.count(msg.content);
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(m.content);
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(m.content.length / 2);
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
+ };
@@ -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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "micro-models-agent",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "Micro Models Agent (MMA) — LLM agent harness for small models (Qwen3.5-9B, 32K-64K context)",
5
5
  "type": "module",
6
6
  "bin": {