@yeaft/webchat-agent 0.1.511 → 0.1.513

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.
@@ -1,31 +1,68 @@
1
1
  /**
2
- * view-image.js — Read and describe an image file.
2
+ * view-image.js — Load a local image file into the LLM's context.
3
3
  *
4
- * Returns image metadata (dimensions, format, size).
5
- * In a full multimodal integration, would pass the image to the LLM.
4
+ * task-333b PR-B: upgraded from metadata-only stub to a real multimodal
5
+ * loader. Returns a tool result that includes:
6
+ * - `image`: a base64-encoded data URI suitable for embedding into an
7
+ * LLM image content block (OpenAI / Anthropic style)
8
+ * - `media_type`: the canonical MIME (image/png, image/jpeg, ...)
9
+ * - `format`, `width`, `height`, `size`, `sizeFormatted`, `path`
10
+ *
11
+ * Safety rules (per PM 乔布斯 PR-B spec + prev-3 product review):
12
+ * - Path safety: no `..`, no absolute paths escaping cwd unless the
13
+ * resolved path lives under ctx.imageAllowlist[] (absolute dirs
14
+ * provided by the host).
15
+ * - Size cap: configurable via ctx.maxImageBytes (default 20 MiB).
16
+ * Larger files are rejected with a self-correcting error message
17
+ * that nudges resize/crop or config.json tuning.
18
+ * - MIME whitelist: png / jpeg / gif / webp / jfif. SVG / BMP / ICO /
19
+ * TIFF are intentionally excluded — they either aren't multimodal-
20
+ * LLM-safe (SVG = embedded script surface) or aren't supported by
21
+ * the mainstream vision endpoints.
22
+ * - HEIC is special-cased: we cannot decode it server-side, but the
23
+ * error nudges the user to convert via `sips -s format jpeg` (mac)
24
+ * instead of a generic "Unsupported format".
6
25
  */
7
26
 
8
27
  import { defineTool } from './types.js';
9
28
  import { stat, readFile } from 'fs/promises';
10
29
  import { existsSync } from 'fs';
11
- import { resolve, extname } from 'path';
30
+ import { resolve, extname, isAbsolute, relative } from 'path';
12
31
 
13
- /** Supported image formats. */
14
- const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg', '.ico']);
32
+ /** Default max image size in bytes (20 MiB). Override via ctx.maxImageBytes. */
33
+ const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
15
34
 
16
35
  /**
17
- * Parse basic image dimensions from headers.
36
+ * Extension canonical MIME type.
37
+ * The keys are the whitelist; anything else is rejected.
38
+ * `.jfif` (common Windows paste extension) maps to image/jpeg.
39
+ */
40
+ const EXT_TO_MIME = Object.freeze({
41
+ '.png': 'image/png',
42
+ '.jpg': 'image/jpeg',
43
+ '.jpeg': 'image/jpeg',
44
+ '.jfif': 'image/jpeg',
45
+ '.gif': 'image/gif',
46
+ '.webp': 'image/webp',
47
+ });
48
+
49
+ const ALLOWED_EXTS = Object.keys(EXT_TO_MIME);
50
+
51
+ /**
52
+ * Parse basic image dimensions from header bytes. Best-effort — returns
53
+ * null if the file is too short or the format isn't one we parse.
54
+ *
55
+ * @param {Buffer} buffer
56
+ * @param {string} ext — lowercase extension including the dot
18
57
  */
19
58
  function parseImageDimensions(buffer, ext) {
20
59
  try {
21
- if (ext === '.png') {
60
+ if (ext === '.png' && buffer.length >= 24) {
22
61
  // PNG: width at offset 16, height at 20 (big-endian 32-bit)
23
- if (buffer.length >= 24) {
24
- return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
25
- }
62
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
26
63
  }
27
- if (ext === '.jpg' || ext === '.jpeg') {
28
- // JPEG: scan for SOF0 marker (0xFF 0xC0)
64
+ if ((ext === '.jpg' || ext === '.jpeg' || ext === '.jfif') && buffer.length > 10) {
65
+ // JPEG: scan for SOF0 (0xFFC0) / SOF2 (0xFFC2) marker
29
66
  for (let i = 0; i < buffer.length - 9; i++) {
30
67
  if (buffer[i] === 0xFF && (buffer[i + 1] === 0xC0 || buffer[i + 1] === 0xC2)) {
31
68
  return {
@@ -35,30 +72,117 @@ function parseImageDimensions(buffer, ext) {
35
72
  }
36
73
  }
37
74
  }
38
- if (ext === '.gif') {
75
+ if (ext === '.gif' && buffer.length >= 10) {
39
76
  // GIF: width at offset 6, height at 8 (little-endian 16-bit)
40
- if (buffer.length >= 10) {
41
- return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
77
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
78
+ }
79
+ if (ext === '.webp' && buffer.length >= 30) {
80
+ // WEBP: RIFF...WEBP...VP8(L|X| ). Three common sub-chunks.
81
+ if (buffer.slice(0, 4).toString('ascii') === 'RIFF' &&
82
+ buffer.slice(8, 12).toString('ascii') === 'WEBP') {
83
+ const fourcc = buffer.slice(12, 16).toString('ascii');
84
+ if (fourcc === 'VP8 ' && buffer.length >= 30) {
85
+ // Lossy: width/height at 26/28 as 14-bit LE (mask 0x3FFF)
86
+ return {
87
+ width: buffer.readUInt16LE(26) & 0x3FFF,
88
+ height: buffer.readUInt16LE(28) & 0x3FFF,
89
+ };
90
+ }
91
+ if (fourcc === 'VP8L' && buffer.length >= 25) {
92
+ // Lossless: packed 14+14 bits at offset 21
93
+ const b0 = buffer[21], b1 = buffer[22], b2 = buffer[23], b3 = buffer[24];
94
+ const width = 1 + (((b1 & 0x3F) << 8) | b0);
95
+ const height = 1 + (((b3 & 0x0F) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6));
96
+ return { width, height };
97
+ }
98
+ if (fourcc === 'VP8X' && buffer.length >= 30) {
99
+ // Extended: 24-bit LE widths/heights at 24/27, stored as (n-1)
100
+ const width = 1 + (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16));
101
+ const height = 1 + (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16));
102
+ return { width, height };
103
+ }
42
104
  }
43
105
  }
44
106
  } catch {
45
- // Dimension parsing is best-effort
107
+ // Dimension parsing is best-effort only.
46
108
  }
47
109
  return null;
48
110
  }
49
111
 
112
+ /**
113
+ * Check whether `absPath` is allowed given a project `cwd` and an optional
114
+ * allowlist of absolute directories. Returns `null` on success, or an object
115
+ * `{ kind, message }` describing the failure. The `kind` field lets callers
116
+ * tailor the error text (see prev-3 P2: distinguish "absolute path outside
117
+ * project" from "relative path containing ..").
118
+ */
119
+ function checkPathAllowed(absPath, cwd, allowlist) {
120
+ // Reject if the resolved path lives inside the project (good).
121
+ const relToCwd = relative(cwd, absPath);
122
+ const insideCwd = relToCwd && !relToCwd.startsWith('..') && !isAbsolute(relToCwd);
123
+ if (insideCwd) return null;
124
+
125
+ // Otherwise must match an allowlist entry.
126
+ if (Array.isArray(allowlist) && allowlist.length > 0) {
127
+ for (const dir of allowlist) {
128
+ if (typeof dir !== 'string' || !isAbsolute(dir)) continue;
129
+ const rel = relative(dir, absPath);
130
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return null;
131
+ }
132
+ }
133
+
134
+ return {
135
+ kind: 'path_outside',
136
+ message:
137
+ 'Path is outside the project directory and not on the image allowlist. ' +
138
+ 'Either move the file into the project, or ask the user to add its parent ' +
139
+ 'directory to ctx.imageAllowlist (set via ~/.yeaft/config.json imageAllowlist[]).',
140
+ };
141
+ }
142
+
143
+ function formatBytes(n) {
144
+ if (n < 1024) return `${n}B`;
145
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
146
+ return `${(n / 1024 / 1024).toFixed(1)}MB`;
147
+ }
148
+
50
149
  export default defineTool({
51
150
  name: 'ViewImage',
52
- description: `View an image file and get its metadata.
151
+ description: `Load a local image file and attach it to the conversation so the LLM can see it.
152
+
153
+ Returns a base64 data URI (\`image\` field) plus metadata (format, dimensions,
154
+ size). The caller/bridge is responsible for turning the data URI into the
155
+ provider-specific image content block.
156
+
157
+ When to call:
158
+ - User references a local image path (screenshot, design, log/chart) and
159
+ asks you to read, analyse, or describe it.
160
+ - User says "look at this file" / "check the screenshot at ..." / "what's
161
+ in docs/assets/arch.png?".
162
+
163
+ When NOT to call:
164
+ - The image is already attached to the current message (the host has
165
+ already uploaded it — you can see it without this tool).
166
+ - The image is a remote URL (http/https). ViewImage only reads local
167
+ files; use a fetch-style tool for URLs.
168
+ - You only need the file's existence / mtime / size — use Read or a
169
+ filesystem tool instead; ViewImage loads the full bytes into memory.
53
170
 
54
- Returns image format, dimensions, and file size.
55
- Supports PNG, JPEG, GIF, BMP, WebP, SVG, and ICO.`,
171
+ Path examples:
172
+ - Relative (resolved against project cwd): "./screenshots/bug.png",
173
+ "docs/assets/arch.png"
174
+ - Absolute inside an allowlisted dir: "/home/user/Downloads/error.png"
175
+ (only works when the host added that dir to ctx.imageAllowlist)
176
+
177
+ Supported formats: PNG, JPEG (.jpg/.jpeg/.jfif), GIF, WebP.
178
+ Max size: 20 MiB by default (configurable via ctx.maxImageBytes).
179
+ Path must live under the project directory or an explicit host allowlist.`,
56
180
  parameters: {
57
181
  type: 'object',
58
182
  properties: {
59
183
  file_path: {
60
184
  type: 'string',
61
- description: 'Path to the image file',
185
+ description: 'Path to the image file. Relative paths are resolved against the project cwd.',
62
186
  },
63
187
  },
64
188
  required: ['file_path'],
@@ -66,51 +190,126 @@ Supports PNG, JPEG, GIF, BMP, WebP, SVG, and ICO.`,
66
190
  isConcurrencySafe: () => true,
67
191
  isReadOnly: () => true,
68
192
  async execute(input, ctx) {
69
- const { file_path } = input;
70
- if (!file_path) return JSON.stringify({ error: 'file_path is required' });
193
+ const { file_path } = input || {};
194
+ if (!file_path || typeof file_path !== 'string') {
195
+ return JSON.stringify({ error: 'file_path is required and must be a string' });
196
+ }
197
+
198
+ // Reject `..` segments explicitly before path resolution — catches the
199
+ // cases where resolve() might still land inside cwd by accident. This
200
+ // also gives LLMs a self-correcting error ("don't use ../") distinct
201
+ // from the "path outside project" message for absolute paths.
202
+ if (file_path.split(/[/\\]/).some(seg => seg === '..')) {
203
+ return JSON.stringify({
204
+ error:
205
+ 'file_path must not contain `..` segments. Use a path relative to the ' +
206
+ 'project (e.g. "docs/assets/foo.png") or an absolute path under an ' +
207
+ 'allowlisted directory.',
208
+ });
209
+ }
71
210
 
72
211
  const cwd = ctx?.cwd || process.cwd();
212
+ const allowlist = Array.isArray(ctx?.imageAllowlist) ? ctx.imageAllowlist : [];
213
+ // Size cap: ctx.maxImageBytes (host-injected from config.json) wins,
214
+ // falling back to 20 MiB. A non-finite / non-positive override is ignored.
215
+ const maxBytes =
216
+ Number.isFinite(ctx?.maxImageBytes) && ctx.maxImageBytes > 0
217
+ ? Math.floor(ctx.maxImageBytes)
218
+ : DEFAULT_MAX_IMAGE_BYTES;
73
219
  const absPath = resolve(cwd, file_path);
74
220
 
75
- if (!existsSync(absPath)) {
76
- return JSON.stringify({ error: `Image not found: ${absPath}` });
221
+ const pathErr = checkPathAllowed(absPath, cwd, allowlist);
222
+ if (pathErr) {
223
+ // prev-3 P2: split "absolute outside project" from "relative ..".
224
+ // The `..` case is already handled above, so anything reaching here
225
+ // is either an absolute path outside cwd/allowlist or a relative
226
+ // path that resolve() pushed outside cwd (rare). Either way, the
227
+ // host-level fix is the same, so we keep one nudge message.
228
+ const isAbs = isAbsolute(file_path);
229
+ const hint = isAbs
230
+ ? 'Absolute path is outside the project directory. '
231
+ : 'Resolved path is outside the project directory. ';
232
+ return JSON.stringify({
233
+ error: hint + pathErr.message,
234
+ path: absPath,
235
+ });
77
236
  }
78
237
 
79
238
  const ext = extname(absPath).toLowerCase();
80
- if (!IMAGE_EXTS.has(ext)) {
81
- return JSON.stringify({ error: `Not a recognized image format: ${ext}` });
239
+ // HEIC special-case: iPhone screenshots default to HEIC and silently
240
+ // fail today. Give users a concrete one-liner to fix it instead of a
241
+ // generic "Unsupported format".
242
+ if (ext === '.heic' || ext === '.heif') {
243
+ return JSON.stringify({
244
+ error:
245
+ 'HEIC images need to be converted to JPEG first. ' +
246
+ 'Use `sips -s format jpeg <file> --out <file>.jpg` on macOS ' +
247
+ '(or an equivalent tool like ImageMagick on Linux/Windows), then retry.',
248
+ format: ext.slice(1).toUpperCase(),
249
+ supported: ALLOWED_EXTS,
250
+ });
251
+ }
252
+ if (!(ext in EXT_TO_MIME)) {
253
+ return JSON.stringify({
254
+ error: `Unsupported image format: ${ext || '(none)'}`,
255
+ supported: ALLOWED_EXTS,
256
+ });
82
257
  }
83
258
 
259
+ if (!existsSync(absPath)) {
260
+ return JSON.stringify({ error: `Image not found: ${absPath}` });
261
+ }
262
+
263
+ let fileStat;
84
264
  try {
85
- const fileStat = await stat(absPath);
86
- const buffer = await readFile(absPath);
265
+ fileStat = await stat(absPath);
266
+ } catch (err) {
267
+ return JSON.stringify({ error: `Failed to stat image: ${err.message}` });
268
+ }
87
269
 
88
- const dimensions = parseImageDimensions(buffer, ext);
270
+ if (!fileStat.isFile()) {
271
+ return JSON.stringify({ error: 'file_path does not point to a regular file' });
272
+ }
89
273
 
90
- const result = {
91
- path: absPath,
92
- format: ext.slice(1).toUpperCase(),
274
+ if (fileStat.size > maxBytes) {
275
+ return JSON.stringify({
276
+ error:
277
+ `Image exceeds ${formatBytes(maxBytes)} (${formatBytes(fileStat.size)} actual). ` +
278
+ `Reduce image size (resize/crop), or set \`maxImageBytes\` in ` +
279
+ `~/.yeaft/config.json if your LLM supports more.`,
93
280
  size: fileStat.size,
94
- sizeFormatted: fileStat.size < 1024 ? `${fileStat.size}B`
95
- : fileStat.size < 1024 * 1024 ? `${(fileStat.size / 1024).toFixed(1)}KB`
96
- : `${(fileStat.size / 1024 / 1024).toFixed(1)}MB`,
97
- modified: fileStat.mtime.toISOString(),
98
- };
99
-
100
- if (dimensions) {
101
- result.width = dimensions.width;
102
- result.height = dimensions.height;
103
- }
104
-
105
- // For SVG, include a text preview
106
- if (ext === '.svg') {
107
- const svgText = buffer.toString('utf-8');
108
- result.preview = svgText.slice(0, 500);
109
- }
281
+ maxSize: maxBytes,
282
+ });
283
+ }
110
284
 
111
- return JSON.stringify(result, null, 2);
285
+ let buffer;
286
+ try {
287
+ buffer = await readFile(absPath);
112
288
  } catch (err) {
113
289
  return JSON.stringify({ error: `Failed to read image: ${err.message}` });
114
290
  }
291
+
292
+ const mediaType = EXT_TO_MIME[ext];
293
+ const base64 = buffer.toString('base64');
294
+ const dataUri = `data:${mediaType};base64,${base64}`;
295
+ const dimensions = parseImageDimensions(buffer, ext);
296
+
297
+ const result = {
298
+ path: absPath,
299
+ format: ext.slice(1).toUpperCase() === 'JPG' ? 'JPEG' : ext.slice(1).toUpperCase(),
300
+ media_type: mediaType,
301
+ size: fileStat.size,
302
+ sizeFormatted: formatBytes(fileStat.size),
303
+ modified: fileStat.mtime.toISOString(),
304
+ image: dataUri,
305
+ };
306
+ // Normalise .jfif to JPEG in display format too, for consistency.
307
+ if (ext === '.jfif') result.format = 'JPEG';
308
+ if (dimensions) {
309
+ result.width = dimensions.width;
310
+ result.height = dimensions.height;
311
+ }
312
+
313
+ return JSON.stringify(result, null, 2);
115
314
  },
116
315
  });
@@ -1,88 +0,0 @@
1
- /**
2
- * tool-search.js — Search available tools by name or description.
3
- *
4
- * task-311: chat/work mode was removed in task-297; this tool no longer
5
- * accepts or reports a `modes` filter. Results come back as plain
6
- * { name, description } pairs.
7
- */
8
-
9
- import { defineTool } from './types.js';
10
-
11
- export default defineTool({
12
- name: 'ToolSearch',
13
- description: `Search available tools by name or description keyword.
14
-
15
- Use when you're unsure which tool to use for a task.
16
- Returns matching tools with their descriptions.`,
17
- parameters: {
18
- type: 'object',
19
- properties: {
20
- query: {
21
- type: 'string',
22
- description: 'Search keyword to match against tool names and descriptions',
23
- },
24
- },
25
- required: ['query'],
26
- },
27
- isConcurrencySafe: () => true,
28
- isReadOnly: () => true,
29
- async execute(input, _ctx) {
30
- const { query } = input;
31
- if (!query) return JSON.stringify({ error: 'query is required' });
32
-
33
- const lowerQuery = query.toLowerCase();
34
-
35
- // Self-referential catalogue of tools available to the engine.
36
- const toolList = [
37
- { name: 'AskUser', description: 'Ask the user a question' },
38
- { name: 'MemoryRead', description: 'Read from memory system' },
39
- { name: 'MemoryWrite', description: 'Write to memory system' },
40
- { name: 'MemorySearch', description: 'Search memory entries' },
41
- { name: 'WebSearch', description: 'Search the web' },
42
- { name: 'WebFetch', description: 'Fetch web page content' },
43
- { name: 'HistorySearch', description: 'Search conversation history' },
44
- { name: 'Bash', description: 'Execute shell commands' },
45
- { name: 'FileRead', description: 'Read file with line numbers' },
46
- { name: 'FileWrite', description: 'Write/create files' },
47
- { name: 'FileEdit', description: 'Surgical string replacement in files' },
48
- { name: 'Glob', description: 'Find files by pattern' },
49
- { name: 'Grep', description: 'Search file contents' },
50
- { name: 'ListDir', description: 'List directory contents' },
51
- { name: 'ApplyPatch', description: 'Apply unified diff patches' },
52
- { name: 'Agent', description: 'Create sub-agents' },
53
- { name: 'SendMessage', description: 'Send message to sub-agent' },
54
- { name: 'WaitAgent', description: 'Wait for sub-agent result' },
55
- { name: 'CloseAgent', description: 'Close a sub-agent' },
56
- { name: 'ListAgents', description: 'List all sub-agents' },
57
- { name: 'TaskCreate', description: 'Create a task' },
58
- { name: 'TaskUpdate', description: 'Update task status' },
59
- { name: 'TaskList', description: 'List all tasks' },
60
- { name: 'TaskGet', description: 'Get task details' },
61
- { name: 'FollowupTask', description: 'Create follow-up task' },
62
- { name: 'UpdatePlan', description: 'View/update execution plan' },
63
- { name: 'JsRepl', description: 'JavaScript REPL evaluation' },
64
- { name: 'JsReplReset', description: 'Reset REPL state' },
65
- { name: 'NotebookEdit', description: 'Edit Jupyter notebooks' },
66
- { name: 'ImageGeneration', description: 'Generate images from text' },
67
- { name: 'ViewImage', description: 'View image metadata' },
68
- { name: 'RequestPermissions', description: 'Request dangerous operation permissions' },
69
- { name: 'WriteStdin', description: 'Write to running process stdin' },
70
- { name: 'Skill', description: 'Load skills from library' },
71
- { name: 'EnterWorktree', description: 'Create git worktree' },
72
- { name: 'ExitWorktree', description: 'Exit git worktree' },
73
- { name: 'mcp_list_tools', description: 'List MCP server tools' },
74
- { name: 'mcp_call_tool', description: 'Call MCP server tool' },
75
- ];
76
-
77
- const results = toolList.filter(t =>
78
- t.name.toLowerCase().includes(lowerQuery) ||
79
- t.description.toLowerCase().includes(lowerQuery)
80
- );
81
-
82
- return JSON.stringify({
83
- results,
84
- totalResults: results.length,
85
- query,
86
- }, null, 2);
87
- },
88
- });
@@ -1,53 +0,0 @@
1
- /**
2
- * write-stdin.js — Write data to a running process's stdin.
3
- *
4
- * Used in conjunction with Bash for processes that need interactive input.
5
- * Currently returns a guidance message since Bash tool uses 'ignore' for stdin.
6
- */
7
-
8
- import { defineTool } from './types.js';
9
-
10
- export default defineTool({
11
- name: 'WriteStdin',
12
- description: `Write data to a running process's standard input.
13
-
14
- This tool is intended for sending input to interactive processes.
15
- Since the Bash tool runs commands non-interactively, this is primarily
16
- useful with the terminal system or long-running processes.
17
-
18
- Note: For most use cases, pipe input via Bash: echo "input" | command`,
19
- parameters: {
20
- type: 'object',
21
- properties: {
22
- process_id: {
23
- type: 'string',
24
- description: 'Process identifier or terminal ID',
25
- },
26
- data: {
27
- type: 'string',
28
- description: 'Data to write to stdin',
29
- },
30
- newline: {
31
- type: 'boolean',
32
- description: 'Append newline after data (default: true)',
33
- },
34
- },
35
- required: ['data'],
36
- },
37
- isConcurrencySafe: () => false,
38
- isReadOnly: () => false,
39
- async execute(input, ctx) {
40
- const { process_id, data, newline = true } = input;
41
- if (!data && data !== '') return JSON.stringify({ error: 'data is required' });
42
-
43
- // The Bash tool uses 'ignore' for stdin, so direct stdin writing
44
- // is only possible through the terminal system.
45
- // For most interactive needs, recommend using pipe syntax.
46
- return JSON.stringify({
47
- hint: 'The Bash tool does not support interactive stdin. Use pipe syntax instead:',
48
- example: `echo "${data}" | your_command`,
49
- alternativeBash: `printf '%s\\n' '${data.replace(/'/g, "'\\''")}' | your_command`,
50
- message: 'For interactive processes, use the terminal system (not the AI tool system).',
51
- });
52
- },
53
- });