@aj-archipelago/cortex 1.4.6 → 1.4.7

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.
Files changed (38) hide show
  1. package/helper-apps/cortex-file-handler/package-lock.json +2 -2
  2. package/helper-apps/cortex-file-handler/package.json +1 -1
  3. package/helper-apps/cortex-file-handler/src/index.js +27 -4
  4. package/helper-apps/cortex-file-handler/src/services/storage/AzureStorageProvider.js +74 -10
  5. package/helper-apps/cortex-file-handler/src/services/storage/StorageService.js +23 -2
  6. package/helper-apps/cortex-file-handler/src/start.js +2 -0
  7. package/helper-apps/cortex-file-handler/tests/deleteOperations.test.js +287 -0
  8. package/helper-apps/cortex-file-handler/tests/start.test.js +1 -1
  9. package/lib/entityConstants.js +1 -1
  10. package/lib/fileUtils.js +1481 -0
  11. package/lib/pathwayTools.js +7 -1
  12. package/lib/util.js +2 -313
  13. package/package.json +4 -3
  14. package/pathways/image_qwen.js +1 -1
  15. package/pathways/system/entity/memory/sys_read_memory.js +17 -3
  16. package/pathways/system/entity/memory/sys_save_memory.js +22 -6
  17. package/pathways/system/entity/sys_entity_agent.js +21 -4
  18. package/pathways/system/entity/tools/sys_tool_analyzefile.js +171 -0
  19. package/pathways/system/entity/tools/sys_tool_codingagent.js +38 -4
  20. package/pathways/system/entity/tools/sys_tool_editfile.js +403 -0
  21. package/pathways/system/entity/tools/sys_tool_file_collection.js +433 -0
  22. package/pathways/system/entity/tools/sys_tool_image.js +172 -10
  23. package/pathways/system/entity/tools/sys_tool_image_gemini.js +123 -10
  24. package/pathways/system/entity/tools/sys_tool_readfile.js +217 -124
  25. package/pathways/system/entity/tools/sys_tool_validate_url.js +137 -0
  26. package/pathways/system/entity/tools/sys_tool_writefile.js +211 -0
  27. package/pathways/system/workspaces/run_workspace_prompt.js +4 -3
  28. package/pathways/transcribe_gemini.js +2 -1
  29. package/server/executeWorkspace.js +1 -1
  30. package/server/plugins/neuralSpacePlugin.js +2 -6
  31. package/server/plugins/openAiWhisperPlugin.js +2 -1
  32. package/server/plugins/replicateApiPlugin.js +4 -14
  33. package/server/typeDef.js +10 -1
  34. package/tests/integration/features/tools/fileCollection.test.js +858 -0
  35. package/tests/integration/features/tools/fileOperations.test.js +851 -0
  36. package/tests/integration/features/tools/writefile.test.js +350 -0
  37. package/tests/unit/core/fileCollection.test.js +259 -0
  38. package/tests/unit/core/util.test.js +320 -1
@@ -0,0 +1,433 @@
1
+ // sys_tool_file_collection.js
2
+ // Tool pathway that manages user file collections (add, search, list files)
3
+ // Uses memory system endpoints (memoryFiles section) for storage
4
+ import logger from '../../../../lib/logger.js';
5
+ import { addFileToCollection, loadFileCollection, saveFileCollection, findFileInCollection, deleteFileByHash, modifyFileCollectionWithLock } from '../../../../lib/fileUtils.js';
6
+
7
+ export default {
8
+ prompt: [],
9
+ timeout: 30,
10
+ toolDefinition: [
11
+ {
12
+ type: "function",
13
+ icon: "📁",
14
+ function: {
15
+ name: "AddFileToCollection",
16
+ description: "Add a file to your persistent file collection. This tool can upload a file from a URL to cloud storage (checking for duplicates by hash) and then store it in your collection with metadata. You can also add files that are already in cloud storage by providing the cloud URL directly.",
17
+ parameters: {
18
+ type: "object",
19
+ properties: {
20
+ fileUrl: {
21
+ type: "string",
22
+ description: "Optional: The URL of a file to upload to cloud storage (e.g., https://example.com/file.pdf). If provided, the file will be uploaded and then added to the collection. If not provided, you must provide the 'url' parameter for an already-uploaded file."
23
+ },
24
+ url: {
25
+ type: "string",
26
+ description: "Optional: The cloud storage URL of an already-uploaded file (Azure URL). Use this if the file is already in cloud storage. If 'fileUrl' is provided, this will be ignored."
27
+ },
28
+ gcs: {
29
+ type: "string",
30
+ description: "Optional: The Google Cloud Storage URL of the file (GCS URL). Only needed if the file is already in cloud storage and you're providing 'url'."
31
+ },
32
+ filename: {
33
+ type: "string",
34
+ description: "The filename or title for this file"
35
+ },
36
+ tags: {
37
+ type: "array",
38
+ items: { type: "string" },
39
+ description: "Optional: Array of tags to help organize and search for this file (e.g., ['pdf', 'report', '2024'])"
40
+ },
41
+ notes: {
42
+ type: "string",
43
+ description: "Optional: Notes or description about this file to help you remember what it contains"
44
+ },
45
+ hash: {
46
+ type: "string",
47
+ description: "Optional: File hash for deduplication and identification (usually computed automatically during upload)"
48
+ },
49
+ userMessage: {
50
+ type: "string",
51
+ description: "A user-friendly message that describes what you're doing with this tool"
52
+ }
53
+ },
54
+ required: ["filename", "userMessage"]
55
+ }
56
+ }
57
+ },
58
+ {
59
+ type: "function",
60
+ icon: "🔍",
61
+ function: {
62
+ name: "SearchFileCollection",
63
+ description: "Search your file collection to find files by filename, tags, notes, or date. Returns matching files with their cloud URLs and metadata.",
64
+ parameters: {
65
+ type: "object",
66
+ properties: {
67
+ query: {
68
+ type: "string",
69
+ description: "Search query - can search by filename, tags, or notes content"
70
+ },
71
+ tags: {
72
+ type: "array",
73
+ items: { type: "string" },
74
+ description: "Optional: Filter results by specific tags (all tags must match)"
75
+ },
76
+ limit: {
77
+ type: "number",
78
+ description: "Optional: Maximum number of results to return (default: 20)"
79
+ },
80
+ userMessage: {
81
+ type: "string",
82
+ description: "A user-friendly message that describes what you're doing with this tool"
83
+ }
84
+ },
85
+ required: ["query", "userMessage"]
86
+ }
87
+ }
88
+ },
89
+ {
90
+ type: "function",
91
+ icon: "📋",
92
+ function: {
93
+ name: "ListFileCollection",
94
+ description: "List all files in your collection, optionally filtered by tags or sorted by date. Useful for getting an overview of your stored files.",
95
+ parameters: {
96
+ type: "object",
97
+ properties: {
98
+ tags: {
99
+ type: "array",
100
+ items: { type: "string" },
101
+ description: "Optional: Filter results by specific tags (all tags must match)"
102
+ },
103
+ sortBy: {
104
+ type: "string",
105
+ enum: ["date", "filename"],
106
+ description: "Optional: Sort results by date (newest first) or filename (alphabetical). Default: date"
107
+ },
108
+ limit: {
109
+ type: "number",
110
+ description: "Optional: Maximum number of results to return (default: 50)"
111
+ },
112
+ userMessage: {
113
+ type: "string",
114
+ description: "A user-friendly message that describes what you're doing with this tool"
115
+ }
116
+ },
117
+ required: ["userMessage"]
118
+ }
119
+ }
120
+ },
121
+ {
122
+ type: "function",
123
+ icon: "🗑️",
124
+ function: {
125
+ name: "RemoveFileFromCollection",
126
+ description: "Remove one or more files from your collection and delete them from cloud storage. Use a file ID to remove a specific file, or use '*' to remove all files. The file will be deleted from cloud storage (if a hash is available) and removed from your collection.",
127
+ parameters: {
128
+ type: "object",
129
+ properties: {
130
+ fileId: {
131
+ type: "string",
132
+ description: "The file to remove (from ListFileCollection or SearchFileCollection): can be the hash, the filename, the URL, or the GCS URL, or '*' to remove all files."
133
+ },
134
+ userMessage: {
135
+ type: "string",
136
+ description: "A user-friendly message that describes what you're doing with this tool"
137
+ }
138
+ },
139
+ required: ["fileId", "userMessage"]
140
+ }
141
+ }
142
+ }
143
+ ],
144
+
145
+ executePathway: async ({args, runAllPrompts, resolver}) => {
146
+ const { contextId, contextKey } = args;
147
+
148
+ // Determine which function was called based on which parameters are present
149
+ const isAdd = args.fileUrl !== undefined || args.url !== undefined;
150
+ const isSearch = args.query !== undefined;
151
+ const isRemove = args.fileId !== undefined;
152
+
153
+ try {
154
+ if (!contextId) {
155
+ throw new Error("contextId is required for file collection operations");
156
+ }
157
+
158
+ if (isAdd) {
159
+ // Add file to collection
160
+ const { fileUrl, url, gcs, filename, tags = [], notes = '', hash = null } = args;
161
+
162
+ if (!filename) {
163
+ throw new Error("filename is required");
164
+ }
165
+
166
+ if (!fileUrl && !url) {
167
+ throw new Error("Either fileUrl (to upload) or url (already uploaded) is required");
168
+ }
169
+
170
+ // Use the centralized utility function (it will handle upload if fileUrl is provided)
171
+ const fileEntry = await addFileToCollection(
172
+ contextId,
173
+ contextKey,
174
+ url,
175
+ gcs,
176
+ filename,
177
+ tags,
178
+ notes,
179
+ hash,
180
+ fileUrl,
181
+ resolver
182
+ );
183
+
184
+ resolver.tool = JSON.stringify({ toolUsed: "AddFileToCollection" });
185
+ return JSON.stringify({
186
+ success: true,
187
+ fileId: fileEntry.id,
188
+ message: `File "${filename}" added to collection`
189
+ });
190
+
191
+ } else if (isSearch) {
192
+ // Search collection
193
+ const { query, tags: filterTags = [], limit = 20 } = args;
194
+
195
+ if (!query || typeof query !== 'string') {
196
+ throw new Error("query is required and must be a string");
197
+ }
198
+
199
+ const queryLower = query.toLowerCase();
200
+
201
+ // Use optimistic locking to update lastAccessed
202
+ await modifyFileCollectionWithLock(contextId, contextKey, (collection) => {
203
+ // Find matching files and update their lastAccessed
204
+ const fileIds = new Set();
205
+ collection.forEach(file => {
206
+ // Search in filename, tags, and notes
207
+ const filenameMatch = file.filename.toLowerCase().includes(queryLower);
208
+ const notesMatch = file.notes && file.notes.toLowerCase().includes(queryLower);
209
+ const tagMatch = file.tags.some(tag => tag.toLowerCase().includes(queryLower));
210
+
211
+ const matchesQuery = filenameMatch || notesMatch || tagMatch;
212
+
213
+ // Filter by tags if provided
214
+ const matchesTags = filterTags.length === 0 ||
215
+ filterTags.every(filterTag =>
216
+ file.tags.some(tag => tag.toLowerCase() === filterTag.toLowerCase())
217
+ );
218
+
219
+ if (matchesQuery && matchesTags) {
220
+ fileIds.add(file.id);
221
+ }
222
+ });
223
+
224
+ // Update lastAccessed for found files
225
+ collection.forEach(file => {
226
+ if (fileIds.has(file.id)) {
227
+ file.lastAccessed = new Date().toISOString();
228
+ }
229
+ });
230
+
231
+ return collection;
232
+ });
233
+
234
+ // Reload collection to get results (after update)
235
+ const collection = await loadFileCollection(contextId, contextKey, false);
236
+
237
+ // Filter and sort results (for display only, not modifying)
238
+ let results = collection.filter(file => {
239
+ const filenameMatch = file.filename.toLowerCase().includes(queryLower);
240
+ const notesMatch = file.notes && file.notes.toLowerCase().includes(queryLower);
241
+ const tagMatch = file.tags.some(tag => tag.toLowerCase().includes(queryLower));
242
+
243
+ const matchesQuery = filenameMatch || notesMatch || tagMatch;
244
+
245
+ const matchesTags = filterTags.length === 0 ||
246
+ filterTags.every(filterTag =>
247
+ file.tags.some(tag => tag.toLowerCase() === filterTag.toLowerCase())
248
+ );
249
+
250
+ return matchesQuery && matchesTags;
251
+ });
252
+
253
+ // Sort by relevance (filename matches first, then by date)
254
+ results.sort((a, b) => {
255
+ const aFilenameMatch = a.filename.toLowerCase().includes(queryLower);
256
+ const bFilenameMatch = b.filename.toLowerCase().includes(queryLower);
257
+ if (aFilenameMatch && !bFilenameMatch) return -1;
258
+ if (!aFilenameMatch && bFilenameMatch) return 1;
259
+ return new Date(b.addedDate) - new Date(a.addedDate);
260
+ });
261
+
262
+ // Limit results
263
+ results = results.slice(0, limit);
264
+
265
+ resolver.tool = JSON.stringify({ toolUsed: "SearchFileCollection" });
266
+ return JSON.stringify({
267
+ success: true,
268
+ count: results.length,
269
+ files: results.map(f => ({
270
+ id: f.id,
271
+ filename: f.filename,
272
+ url: f.url,
273
+ gcs: f.gcs || null,
274
+ tags: f.tags,
275
+ notes: f.notes,
276
+ addedDate: f.addedDate
277
+ }))
278
+ });
279
+
280
+ } else if (isRemove) {
281
+ // Remove file(s) from collection and delete from cloud storage
282
+ const { fileId } = args;
283
+
284
+ if (!fileId || typeof fileId !== 'string') {
285
+ throw new Error("fileId is required and must be a string");
286
+ }
287
+
288
+ let removedCount = 0;
289
+ let removedFiles = [];
290
+ let deletedFromCloud = 0;
291
+ let deletionErrors = [];
292
+ let filesToRemove = [];
293
+
294
+ // First, identify files to remove (before locking)
295
+ if (fileId === '*') {
296
+ // Load collection to get all files
297
+ const collection = await loadFileCollection(contextId, contextKey, false);
298
+ filesToRemove = collection.map(f => ({
299
+ id: f.id,
300
+ filename: f.filename,
301
+ hash: f.hash || null
302
+ }));
303
+ } else {
304
+ // Load collection and find specific file
305
+ const collection = await loadFileCollection(contextId, contextKey, false);
306
+ const foundFile = findFileInCollection(fileId, collection);
307
+
308
+ if (!foundFile) {
309
+ throw new Error(`File with ID, filename, URL, or hash "${fileId}" not found in collection`);
310
+ }
311
+
312
+ filesToRemove = [{
313
+ id: foundFile.id,
314
+ filename: foundFile.filename,
315
+ hash: foundFile.hash || null
316
+ }];
317
+ }
318
+
319
+ // Delete files from cloud storage (outside lock - idempotent operation)
320
+ for (const fileInfo of filesToRemove) {
321
+ if (fileInfo.hash) {
322
+ try {
323
+ logger.info(`Deleting file from cloud storage: ${fileInfo.filename} (hash: ${fileInfo.hash})`);
324
+ const deleted = await deleteFileByHash(fileInfo.hash, resolver);
325
+ if (deleted) {
326
+ deletedFromCloud++;
327
+ }
328
+ } catch (error) {
329
+ const errorMsg = error?.message || String(error);
330
+ logger.warn(`Failed to delete file ${fileInfo.filename} (hash: ${fileInfo.hash}) from cloud storage: ${errorMsg}`);
331
+ deletionErrors.push({ filename: fileInfo.filename, error: errorMsg });
332
+ }
333
+ }
334
+ }
335
+
336
+ // Use optimistic locking to remove files from collection
337
+ const fileIdsToRemove = new Set(filesToRemove.map(f => f.id));
338
+ const finalCollection = await modifyFileCollectionWithLock(contextId, contextKey, (collection) => {
339
+ // Remove files by ID
340
+ return collection.filter(file => !fileIdsToRemove.has(file.id));
341
+ });
342
+
343
+ removedCount = filesToRemove.length;
344
+ removedFiles = filesToRemove;
345
+
346
+ // Build result message
347
+ let message;
348
+ if (fileId === '*') {
349
+ message = `All ${removedCount} file(s) removed from collection`;
350
+ if (deletedFromCloud > 0) {
351
+ message += ` (${deletedFromCloud} deleted from cloud storage)`;
352
+ }
353
+ if (deletionErrors.length > 0) {
354
+ message += `. ${deletionErrors.length} deletion error(s) occurred`;
355
+ }
356
+ } else {
357
+ message = `File "${removedFiles[0]?.filename || fileId}" removed from collection`;
358
+ if (deletedFromCloud > 0) {
359
+ message += ` and deleted from cloud storage`;
360
+ } else if (removedFiles[0]?.hash) {
361
+ message += ` (cloud storage deletion failed or file not found)`;
362
+ }
363
+ }
364
+
365
+ resolver.tool = JSON.stringify({ toolUsed: "RemoveFileFromCollection" });
366
+ return JSON.stringify({
367
+ success: true,
368
+ removedCount: removedCount,
369
+ deletedFromCloud: deletedFromCloud,
370
+ remainingFiles: finalCollection.length,
371
+ message: message,
372
+ removedFiles: removedFiles,
373
+ deletionErrors: deletionErrors.length > 0 ? deletionErrors : undefined
374
+ });
375
+
376
+ } else {
377
+ // List collection (read-only, no locking needed)
378
+ const { tags: filterTags = [], sortBy = 'date', limit = 50 } = args;
379
+
380
+ const collection = await loadFileCollection(contextId, contextKey, true);
381
+ let results = collection;
382
+
383
+ // Filter by tags if provided
384
+ if (filterTags.length > 0) {
385
+ results = results.filter(file =>
386
+ filterTags.every(filterTag =>
387
+ file.tags.some(tag => tag.toLowerCase() === filterTag.toLowerCase())
388
+ )
389
+ );
390
+ }
391
+
392
+ // Sort results
393
+ if (sortBy === 'date') {
394
+ results.sort((a, b) => new Date(b.addedDate) - new Date(a.addedDate));
395
+ } else if (sortBy === 'filename') {
396
+ results.sort((a, b) => a.filename.localeCompare(b.filename));
397
+ }
398
+
399
+ // Limit results
400
+ results = results.slice(0, limit);
401
+
402
+ resolver.tool = JSON.stringify({ toolUsed: "ListFileCollection" });
403
+ return JSON.stringify({
404
+ success: true,
405
+ count: results.length,
406
+ totalFiles: collection.length,
407
+ files: results.map(f => ({
408
+ id: f.id,
409
+ filename: f.filename,
410
+ url: f.url,
411
+ gcs: f.gcs || null,
412
+ tags: f.tags,
413
+ notes: f.notes,
414
+ addedDate: f.addedDate,
415
+ lastAccessed: f.lastAccessed
416
+ }))
417
+ });
418
+ }
419
+
420
+ } catch (e) {
421
+ logger.error(`Error in file collection operation: ${e.message}`);
422
+
423
+ const errorResult = {
424
+ success: false,
425
+ error: e.message || "Unknown error occurred"
426
+ };
427
+
428
+ resolver.tool = JSON.stringify({ toolUsed: "FileCollection" });
429
+ return JSON.stringify(errorResult);
430
+ }
431
+ }
432
+ };
433
+
@@ -1,6 +1,7 @@
1
1
  // sys_tool_image.js
2
2
  // Entity tool that creates and modifies images for the entity to show to the user
3
3
  import { callPathway } from '../../../../lib/pathwayTools.js';
4
+ import { uploadFileToCloud, addFileToCollection, resolveFileParameter } from '../../../../lib/fileUtils.js';
4
5
 
5
6
  export default {
6
7
  prompt: [],
@@ -24,6 +25,17 @@ export default {
24
25
  type: "string",
25
26
  description: "A very detailed prompt describing the image you want to create. You should be very specific - explaining subject matter, style, and details about the image including things like camera angle, lens types, lighting, photographic techniques, etc. Any details you can provide to the image creation engine will help it create the most accurate and useful images. The more detailed and descriptive the prompt, the better the result."
26
27
  },
28
+ filenamePrefix: {
29
+ type: "string",
30
+ description: "Optional: A descriptive prefix to use for the generated image filename (e.g., 'portrait', 'landscape', 'logo'). If not provided, defaults to 'generated-image'."
31
+ },
32
+ tags: {
33
+ type: "array",
34
+ items: {
35
+ type: "string"
36
+ },
37
+ description: "Optional: Array of tags to categorize the image (e.g., ['portrait', 'art', 'photography']). Will be merged with default tags ['image', 'generated']."
38
+ },
27
39
  userMessage: {
28
40
  type: "string",
29
41
  description: "A user-friendly message that describes what you're doing with this tool"
@@ -44,20 +56,31 @@ export default {
44
56
  properties: {
45
57
  inputImage: {
46
58
  type: "string",
47
- description: "The first image URL copied exactly from an image_url field in your chat context."
59
+ description: "An image from your available files (from Available Files section or ListFileCollection or SearchFileCollection) to use as a reference for the image modification."
48
60
  },
49
61
  inputImage2: {
50
62
  type: "string",
51
- description: "The second input image URL copied exactly from an image_url field in your chat context if there is one."
63
+ description: "A second image from your available files (from Available Files section or ListFileCollection or SearchFileCollection) to use as a reference for the image modification if there is one."
52
64
  },
53
65
  inputImage3: {
54
66
  type: "string",
55
- description: "The third input image URL copied exactly from an image_url field in your chat context if there is one."
67
+ description: "A third image from your available files (from Available Files section or ListFileCollection or SearchFileCollection) to use as a reference for the image modification if there is one."
56
68
  },
57
69
  detailedInstructions: {
58
70
  type: "string",
59
71
  description: "A very detailed prompt describing how you want to modify the image. Be specific about the changes you want to make, including style changes, artistic effects, or specific modifications. The more detailed and descriptive the prompt, the better the result."
60
72
  },
73
+ filenamePrefix: {
74
+ type: "string",
75
+ description: "Optional: A prefix to use for the modified image filename (e.g., 'edited', 'stylized', 'enhanced'). If not provided, defaults to 'modified-image'."
76
+ },
77
+ tags: {
78
+ type: "array",
79
+ items: {
80
+ type: "string"
81
+ },
82
+ description: "Optional: Array of tags to categorize the image (e.g., ['edited', 'art', 'stylized']). Will be merged with default tags ['image', 'modified']."
83
+ },
61
84
  userMessage: {
62
85
  type: "string",
63
86
  description: "A user-friendly message that describes what you're doing with this tool"
@@ -82,6 +105,45 @@ export default {
82
105
 
83
106
  pathwayResolver.tool = JSON.stringify({ toolUsed: "image" });
84
107
 
108
+ // Resolve all input images to URLs using the common utility
109
+ // Fail early if any provided image parameter cannot be resolved
110
+ if (args.inputImage) {
111
+ if (!args.contextId) {
112
+ throw new Error("contextId is required when using the 'inputImage' parameter. Use ListFileCollection or SearchFileCollection to find available files.");
113
+ }
114
+ const resolved = await resolveFileParameter(args.inputImage, args.contextId, args.contextKey);
115
+ if (!resolved) {
116
+ throw new Error(`File not found: "${args.inputImage}". Use ListFileCollection or SearchFileCollection to find available files.`);
117
+ }
118
+ args.inputImage = resolved;
119
+ }
120
+
121
+ if (args.inputImage2) {
122
+ if (!args.contextId) {
123
+ throw new Error("contextId is required when using the 'inputImage2' parameter. Use ListFileCollection or SearchFileCollection to find available files.");
124
+ }
125
+ const resolved = await resolveFileParameter(args.inputImage2, args.contextId, args.contextKey);
126
+ if (!resolved) {
127
+ throw new Error(`File not found: "${args.inputImage2}". Use ListFileCollection or SearchFileCollection to find available files.`);
128
+ }
129
+ args.inputImage2 = resolved;
130
+ }
131
+
132
+ if (args.inputImage3) {
133
+ if (!args.contextId) {
134
+ throw new Error("contextId is required when using the 'inputImage3' parameter. Use ListFileCollection or SearchFileCollection to find available files.");
135
+ }
136
+ const resolved = await resolveFileParameter(args.inputImage3, args.contextId, args.contextKey);
137
+ if (!resolved) {
138
+ throw new Error(`File not found: "${args.inputImage3}". Use ListFileCollection or SearchFileCollection to find available files.`);
139
+ }
140
+ args.inputImage3 = resolved;
141
+ }
142
+
143
+ const resolvedInputImage = args.inputImage;
144
+ const resolvedInputImage2 = args.inputImage2;
145
+ const resolvedInputImage3 = args.inputImage3;
146
+
85
147
  // Build parameters object, only including image parameters if they have non-empty values
86
148
  const params = {
87
149
  ...args,
@@ -90,19 +152,119 @@ export default {
90
152
  stream: false,
91
153
  };
92
154
 
93
- if (args.inputImage && args.inputImage.trim()) {
94
- params.input_image = args.inputImage;
155
+ if (resolvedInputImage) {
156
+ params.input_image = resolvedInputImage;
95
157
  }
96
- if (args.inputImage2 && args.inputImage2.trim()) {
97
- params.input_image_2 = args.inputImage2;
158
+ if (resolvedInputImage2) {
159
+ params.input_image_2 = resolvedInputImage2;
98
160
  }
99
- if (args.inputImage3 && args.inputImage3.trim()) {
100
- params.input_image_3 = args.inputImage3;
161
+ if (resolvedInputImage3) {
162
+ params.input_image_3 = resolvedInputImage3;
101
163
  }
102
164
 
103
165
  // Call appropriate pathway based on model
104
166
  const pathwayName = model.includes('seedream') ? 'image_seedream4' : 'image_qwen';
105
- return await callPathway(pathwayName, params);
167
+ let result = await callPathway(pathwayName, params, pathwayResolver);
168
+
169
+ // Process artifacts from Replicate (which come as URLs, not base64 data)
170
+ if (pathwayResolver.pathwayResultData) {
171
+ if (pathwayResolver.pathwayResultData.artifacts && Array.isArray(pathwayResolver.pathwayResultData.artifacts)) {
172
+ const uploadedImages = [];
173
+
174
+ // Process each image artifact
175
+ for (const artifact of pathwayResolver.pathwayResultData.artifacts) {
176
+ if (artifact.type === 'image' && artifact.url) {
177
+ try {
178
+ // Replicate artifacts have URLs, not base64 data
179
+ // Download the image and upload it to cloud storage
180
+ const imageUrl = artifact.url;
181
+ const mimeType = artifact.mimeType || 'image/png';
182
+
183
+ // Upload image to cloud storage (downloads from URL, computes hash, uploads)
184
+ const uploadResult = await uploadFileToCloud(
185
+ imageUrl,
186
+ mimeType,
187
+ null, // filename will be generated
188
+ pathwayResolver
189
+ );
190
+
191
+ const uploadedUrl = uploadResult.url || uploadResult;
192
+ const uploadedGcs = uploadResult.gcs || null;
193
+ const uploadedHash = uploadResult.hash || null;
194
+
195
+ uploadedImages.push({
196
+ type: 'image',
197
+ url: uploadedUrl,
198
+ gcs: uploadedGcs,
199
+ hash: uploadedHash,
200
+ mimeType: mimeType
201
+ });
202
+
203
+ // Add uploaded image to file collection if contextId is available
204
+ if (args.contextId && uploadedUrl) {
205
+ try {
206
+ // Generate filename from mimeType (e.g., "image/png" -> "png")
207
+ const extension = mimeType.split('/')[1] || 'png';
208
+ // Use hash for uniqueness if available, otherwise use timestamp and index
209
+ const uniqueId = uploadedHash ? uploadedHash.substring(0, 8) : `${Date.now()}-${uploadedImages.length}`;
210
+
211
+ // Determine filename prefix based on whether this is a modification or generation
212
+ const isModification = args.inputImage || args.inputImage2 || args.inputImage3;
213
+ const defaultPrefix = isModification ? 'modified-image' : 'generated-image';
214
+ const filenamePrefix = args.filenamePrefix || defaultPrefix;
215
+
216
+ // Sanitize the prefix to ensure it's a valid filename component
217
+ const sanitizedPrefix = filenamePrefix.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
218
+ const filename = `${sanitizedPrefix}-${uniqueId}.${extension}`;
219
+
220
+ // Merge provided tags with default tags
221
+ const defaultTags = ['image', isModification ? 'modified' : 'generated'];
222
+ const providedTags = Array.isArray(args.tags) ? args.tags : [];
223
+ const allTags = [...defaultTags, ...providedTags.filter(tag => !defaultTags.includes(tag))];
224
+
225
+ // Use the centralized utility function to add to collection
226
+ await addFileToCollection(
227
+ args.contextId,
228
+ args.contextKey || '',
229
+ uploadedUrl,
230
+ uploadedGcs,
231
+ filename,
232
+ allTags,
233
+ isModification
234
+ ? `Modified image from prompt: ${args.detailedInstructions || 'image modification'}`
235
+ : `Generated image from prompt: ${args.detailedInstructions || 'image generation'}`,
236
+ uploadedHash
237
+ );
238
+ } catch (collectionError) {
239
+ // Log but don't fail - file collection is optional
240
+ pathwayResolver.logWarning(`Failed to add image to file collection: ${collectionError.message}`);
241
+ }
242
+ }
243
+ } catch (uploadError) {
244
+ pathwayResolver.logError(`Failed to upload image from Replicate: ${uploadError.message}`);
245
+ // Keep original URL as fallback
246
+ uploadedImages.push({
247
+ type: 'image',
248
+ url: artifact.url,
249
+ mimeType: artifact.mimeType || 'image/png'
250
+ });
251
+ }
252
+ } else {
253
+ // Keep non-image artifacts as-is
254
+ uploadedImages.push(artifact);
255
+ }
256
+ }
257
+
258
+ // Return the URLs of the uploaded images as text in the result
259
+ // Replace the result with uploaded cloud URLs (not the original Replicate URLs)
260
+ if (uploadedImages.length > 0) {
261
+ const imageUrls = uploadedImages.map(image => image.url || image).filter(Boolean);
262
+ result = imageUrls.join('\n');
263
+ }
264
+ }
265
+ }
266
+
267
+ return result;
106
268
 
107
269
  } catch (e) {
108
270
  pathwayResolver.logError(e.message ?? e);