@aj-archipelago/cortex 1.4.28 → 1.4.30

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/lib/fileUtils.js CHANGED
@@ -266,7 +266,7 @@ async function getMediaChunks(file, requestId, contextId = null) {
266
266
  requestId,
267
267
  ...(contextId ? { contextId } : {})
268
268
  });
269
- const res = await axios.get(url, { timeout: 30000 });
269
+ const res = await axios.get(url, { timeout: 600000 });
270
270
  return res.data;
271
271
  } else {
272
272
  logger.info(`No API_URL set, returning file as chunk`);
@@ -1444,33 +1444,36 @@ function getDefaultContext(agentContext) {
1444
1444
  * Load merged file collection from agentContext array
1445
1445
  * Merges all contexts in the array for read operations
1446
1446
  * @param {Array} agentContext - Array of context objects { contextId, contextKey, default }
1447
+ * @param {string|null} chatId - Optional chat ID to filter files by (if provided, only includes files with '*' or this chatId in inCollection)
1447
1448
  * @returns {Promise<Array>} Merged file collection
1448
1449
  */
1449
- async function loadMergedFileCollection(agentContext) {
1450
+ async function loadMergedFileCollection(agentContext, chatId = null) {
1450
1451
  if (!agentContext || !Array.isArray(agentContext) || agentContext.length === 0) {
1451
1452
  return [];
1452
1453
  }
1453
1454
 
1454
- // Load first context as primary - use loadFileCollectionAll to get all files (not filtered by inCollection)
1455
+ // Load first context as primary
1456
+ // If chatId is provided, use loadFileCollection to filter by chatId
1457
+ // Otherwise, use loadFileCollectionAll to get all files (we'll filter by inCollection below)
1455
1458
  const primaryCtx = agentContext[0];
1456
- const primaryCollection = await loadFileCollectionAll(primaryCtx.contextId, primaryCtx.contextKey || null);
1459
+ const primaryCollection = chatId
1460
+ ? await loadFileCollection(primaryCtx.contextId, primaryCtx.contextKey || null, true, chatId)
1461
+ : await loadFileCollectionAll(primaryCtx.contextId, primaryCtx.contextKey || null);
1457
1462
 
1458
1463
  // Tag primary files with their source context
1459
- const collection = primaryCollection.map(f => ({ ...f, _contextId: primaryCtx.contextId }));
1460
-
1461
- // If only one context, return early
1462
- if (agentContext.length === 1) {
1463
- return collection;
1464
- }
1464
+ let collection = primaryCollection.map(f => ({ ...f, _contextId: primaryCtx.contextId }));
1465
1465
 
1466
1466
  // Load and merge additional contexts
1467
1467
  for (let i = 1; i < agentContext.length; i++) {
1468
1468
  const ctx = agentContext[i];
1469
1469
  if (!ctx.contextId) continue;
1470
1470
 
1471
- // Load alternate collection - use loadFileCollectionAll to bypass inCollection filtering
1472
- // (we want ALL files from the alt context, not just global ones)
1473
- const altCollection = await loadFileCollectionAll(ctx.contextId, ctx.contextKey || null);
1471
+ // Load alternate collection
1472
+ // If chatId is provided, use loadFileCollection to filter by chatId
1473
+ // Otherwise, use loadFileCollectionAll to get all files
1474
+ const altCollection = chatId
1475
+ ? await loadFileCollection(ctx.contextId, ctx.contextKey || null, true, chatId)
1476
+ : await loadFileCollectionAll(ctx.contextId, ctx.contextKey || null);
1474
1477
 
1475
1478
  // Build set of existing identifiers from current collection
1476
1479
  const existingHashes = new Set(collection.map(f => f.hash).filter(Boolean));
@@ -1488,6 +1491,27 @@ async function loadMergedFileCollection(agentContext) {
1488
1491
  }
1489
1492
  }
1490
1493
 
1494
+ // When chatId is null (includeAllChats=true), filter to only include files with inCollection set
1495
+ // Agent tools should only see files that are actually in the collection (have inCollection set)
1496
+ if (chatId === null) {
1497
+ collection = collection.filter(file => {
1498
+ const inCollection = file.inCollection;
1499
+
1500
+ // Exclude files without inCollection set or with empty inCollection array/string
1501
+ if (inCollection === undefined || inCollection === null || inCollection === false || inCollection === '') {
1502
+ return false;
1503
+ }
1504
+
1505
+ // Exclude empty arrays (file not in any collection)
1506
+ if (Array.isArray(inCollection) && inCollection.length === 0) {
1507
+ return false;
1508
+ }
1509
+
1510
+ // Include files with inCollection set (truthy and non-empty)
1511
+ return true;
1512
+ });
1513
+ }
1514
+
1491
1515
  return collection;
1492
1516
  }
1493
1517
 
@@ -2491,6 +2515,65 @@ function isTextMimeType(mimeType) {
2491
2515
  return false;
2492
2516
  }
2493
2517
 
2518
+ /**
2519
+ * Build a standardized JSON response for file creation tools (image, video, slides).
2520
+ * Provides consistent format with structured file objects and instructional message.
2521
+ *
2522
+ * @param {Array} successfulFiles - Array of successful file objects, each with optional fileEntry and url/hash
2523
+ * @param {Object} options - Configuration options
2524
+ * @param {string} options.mediaType - Type of media: 'image' or 'video' (default: 'image')
2525
+ * @param {string} options.action - Action description for message: 'Image generation', 'Video generation', etc.
2526
+ * @param {Array} options.legacyUrls - Optional array of URLs for backward compatibility (imageUrls field)
2527
+ * @returns {string} JSON string with success, count, message, files, and optional imageUrls
2528
+ */
2529
+ function buildFileCreationResponse(successfulFiles, options = {}) {
2530
+ const {
2531
+ mediaType = 'image',
2532
+ action = 'Generation',
2533
+ legacyUrls = []
2534
+ } = options;
2535
+
2536
+ const files = successfulFiles.map((item) => {
2537
+ if (item.fileEntry) {
2538
+ const fe = item.fileEntry;
2539
+ return {
2540
+ hash: fe.hash || null,
2541
+ displayFilename: fe.displayFilename || null,
2542
+ url: fe.url || item.url,
2543
+ addedDate: fe.addedDate || null,
2544
+ tags: Array.isArray(fe.tags) ? fe.tags : []
2545
+ };
2546
+ } else {
2547
+ return {
2548
+ hash: item.hash || null,
2549
+ displayFilename: null,
2550
+ url: item.url,
2551
+ addedDate: null,
2552
+ tags: []
2553
+ };
2554
+ }
2555
+ });
2556
+
2557
+ const count = files.length;
2558
+ const displayInstruction = mediaType === 'video'
2559
+ ? 'Display videos using markdown link: [video description](url).'
2560
+ : 'Display images using markdown: ![description](url).';
2561
+
2562
+ const response = {
2563
+ success: true,
2564
+ count: count,
2565
+ message: `${action} complete. ${count} ${mediaType}(s) uploaded and added to file collection. ${displayInstruction} Reference files by hash or displayFilename.`,
2566
+ files: files
2567
+ };
2568
+
2569
+ // Add legacyUrls as imageUrls for backward compatibility if provided
2570
+ if (legacyUrls && legacyUrls.length > 0) {
2571
+ response.imageUrls = legacyUrls;
2572
+ }
2573
+
2574
+ return JSON.stringify(response);
2575
+ }
2576
+
2494
2577
  export {
2495
2578
  computeFileHash,
2496
2579
  computeBufferHash,
@@ -2522,6 +2605,7 @@ export {
2522
2605
  getRedisClient,
2523
2606
  checkHashExists,
2524
2607
  ensureShortLivedUrl,
2608
+ buildFileCreationResponse,
2525
2609
  uploadFileToCloud,
2526
2610
  uploadImageToCloud,
2527
2611
  resolveFileHashesToContent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aj-archipelago/cortex",
3
- "version": "1.4.28",
3
+ "version": "1.4.30",
4
4
  "description": "Cortex is a GraphQL API for AI. It provides a simple, extensible interface for using AI services from OpenAI, Azure and others.",
5
5
  "private": false,
6
6
  "repository": {
@@ -83,8 +83,6 @@ export default {
83
83
  try {
84
84
  const { codingTask, userMessage, inputFiles, codingTaskKeywords, contextId } = args;
85
85
 
86
- // pathwayResolver.executePathway should have already extracted contextId from agentContext,
87
- // but validate it's present as a safety check
88
86
  if (!contextId) {
89
87
  throw new Error("contextId is required. It should be provided via agentContext or contextId parameter.");
90
88
  }
@@ -2,7 +2,7 @@
2
2
  // Entity tool that modifies existing files by replacing line ranges or exact string matches
3
3
  import logger from '../../../../lib/logger.js';
4
4
  import { axios } from '../../../../lib/requestExecutor.js';
5
- import { uploadFileToCloud, findFileInCollection, loadMergedFileCollection, getDefaultContext, getMimeTypeFromFilename, deleteFileByHash, isTextMimeType, updateFileMetadata, writeFileDataToRedis, invalidateFileCollectionCache, getActualContentMimeType } from '../../../../lib/fileUtils.js';
5
+ import { uploadFileToCloud, findFileInCollection, loadMergedFileCollection, getMimeTypeFromFilename, deleteFileByHash, isTextMimeType, updateFileMetadata, writeFileDataToRedis, invalidateFileCollectionCache, getActualContentMimeType } from '../../../../lib/fileUtils.js';
6
6
 
7
7
  // Maximum file size for editing (50MB) - prevents memory blowup on huge files
8
8
  const MAX_EDITABLE_FILE_SIZE = 50 * 1024 * 1024;
@@ -147,17 +147,15 @@ export default {
147
147
  executePathway: async ({args, runAllPrompts, resolver}) => {
148
148
  const { file, startLine, endLine, content, oldString, newString, replaceAll = false, agentContext, chatId } = args;
149
149
 
150
- const defaultCtx = getDefaultContext(agentContext);
151
- if (!defaultCtx) {
150
+ const { contextId, contextKey } = args;
151
+ if (!contextId) {
152
152
  const errorResult = {
153
153
  success: false,
154
- error: "agentContext with at least one default context is required"
154
+ error: "contextId is required. It should be provided via agentContext or contextId parameter."
155
155
  };
156
156
  resolver.tool = JSON.stringify({ toolUsed: "EditFile" });
157
157
  return JSON.stringify(errorResult);
158
158
  }
159
- const contextId = defaultCtx.contextId;
160
- const contextKey = defaultCtx.contextKey || null;
161
159
 
162
160
  // Determine which tool was called based on parameters
163
161
  const isSearchReplace = oldString !== undefined && newString !== undefined;
@@ -3,7 +3,7 @@
3
3
  // Uses Redis hash maps (FileStoreMap:ctx:<contextId>) for storage
4
4
  // Supports atomic rename/tag/notes updates via UpdateFileMetadata
5
5
  import logger from '../../../../lib/logger.js';
6
- import { addFileToCollection, loadFileCollection, loadMergedFileCollection, findFileInCollection, deleteFileByHash, updateFileMetadata, invalidateFileCollectionCache, getDefaultContext } from '../../../../lib/fileUtils.js';
6
+ import { addFileToCollection, loadFileCollection, loadFileCollectionAll, loadMergedFileCollection, findFileInCollection, deleteFileByHash, updateFileMetadata, invalidateFileCollectionCache } from '../../../../lib/fileUtils.js';
7
7
 
8
8
  export default {
9
9
  prompt: [],
@@ -82,6 +82,10 @@ export default {
82
82
  type: "number",
83
83
  description: "Optional: Maximum number of results to return (default: 20)"
84
84
  },
85
+ includeAllChats: {
86
+ type: "boolean",
87
+ description: "Optional: Only use true if you need to search across all chats. Default (false) searches only the current chat, which is usually what you want."
88
+ },
85
89
  userMessage: {
86
90
  type: "string",
87
91
  description: "A user-friendly message that describes what you're doing with this tool"
@@ -114,6 +118,10 @@ export default {
114
118
  type: "number",
115
119
  description: "Optional: Maximum number of results to return (default: 50)"
116
120
  },
121
+ includeAllChats: {
122
+ type: "boolean",
123
+ description: "Optional: Only use true if you need to list files from all chats. Default (false) lists only the current chat, which is usually what you want."
124
+ },
117
125
  userMessage: {
118
126
  type: "string",
119
127
  description: "A user-friendly message that describes what you're doing with this tool"
@@ -198,12 +206,10 @@ export default {
198
206
  ],
199
207
 
200
208
  executePathway: async ({args, runAllPrompts, resolver}) => {
201
- const defaultCtx = getDefaultContext(args.agentContext);
202
- if (!defaultCtx) {
203
- throw new Error("agentContext with at least one default context is required");
209
+ const { contextId, contextKey } = args;
210
+ if (!contextId) {
211
+ throw new Error("contextId is required. It should be provided via agentContext or contextId parameter.");
204
212
  }
205
- const contextId = defaultCtx.contextId;
206
- const contextKey = defaultCtx.contextKey || null;
207
213
  const chatId = args.chatId || null;
208
214
 
209
215
  // Determine which function was called based on which parameters are present
@@ -234,7 +240,9 @@ export default {
234
240
  }
235
241
 
236
242
  // Load collection and find the file
237
- const collection = await loadFileCollection(contextId, contextKey, false);
243
+ // Use loadFileCollectionAll to find files regardless of inCollection status
244
+ // This ensures files can be updated even if they're chat-specific
245
+ const collection = await loadFileCollectionAll(contextId, contextKey);
238
246
  const foundFile = findFileInCollection(file, collection);
239
247
 
240
248
  if (!foundFile) {
@@ -356,7 +364,7 @@ export default {
356
364
 
357
365
  } else if (isSearch) {
358
366
  // Search collection
359
- const { query, tags: filterTags = [], limit = 20 } = args;
367
+ const { query, tags: filterTags = [], limit = 20, includeAllChats = false } = args;
360
368
 
361
369
  if (!query || typeof query !== 'string') {
362
370
  throw new Error("query is required and must be a string");
@@ -366,8 +374,15 @@ export default {
366
374
  const safeFilterTags = Array.isArray(filterTags) ? filterTags : [];
367
375
  const queryLower = query.toLowerCase();
368
376
 
377
+ // Normalize query for flexible matching: treat spaces, dashes, underscores as equivalent
378
+ const normalizeForSearch = (str) => str.toLowerCase().replace(/[-_\s]+/g, ' ').trim();
379
+ const queryNormalized = normalizeForSearch(query);
380
+
381
+ // Determine which chatId to use for filtering (null if includeAllChats is true)
382
+ const filterChatId = includeAllChats ? null : chatId;
383
+
369
384
  // Load primary collection for lastAccessed updates (only update files in primary context)
370
- const primaryFiles = await loadFileCollection(contextId, contextKey, false);
385
+ const primaryFiles = await loadFileCollection(contextId, contextKey, false, filterChatId);
371
386
  const now = new Date().toISOString();
372
387
 
373
388
  // Find matching files in primary collection and update lastAccessed directly
@@ -376,9 +391,9 @@ export default {
376
391
 
377
392
  // Fallback to filename if displayFilename is not set (for files uploaded before displayFilename was added)
378
393
  const displayFilename = file.displayFilename || file.filename || '';
379
- const filenameMatch = displayFilename.toLowerCase().includes(queryLower);
380
- const notesMatch = file.notes && file.notes.toLowerCase().includes(queryLower);
381
- const tagMatch = Array.isArray(file.tags) && file.tags.some(tag => tag.toLowerCase().includes(queryLower));
394
+ const filenameMatch = normalizeForSearch(displayFilename).includes(queryNormalized);
395
+ const notesMatch = file.notes && normalizeForSearch(file.notes).includes(queryNormalized);
396
+ const tagMatch = Array.isArray(file.tags) && file.tags.some(tag => normalizeForSearch(tag).includes(queryNormalized));
382
397
  const matchesQuery = filenameMatch || notesMatch || tagMatch;
383
398
 
384
399
  const matchesTags = safeFilterTags.length === 0 ||
@@ -396,20 +411,26 @@ export default {
396
411
  }
397
412
 
398
413
  // Load merged collection for search results (includes all agentContext files)
399
- const updatedFiles = await loadMergedFileCollection(args.agentContext);
414
+ // Filter by chatId if includeAllChats is false and chatId is available
415
+ // loadMergedFileCollection now handles inCollection filtering centrally
416
+ const updatedFiles = await loadMergedFileCollection(args.agentContext, filterChatId);
400
417
 
401
418
  // Filter and sort results (for display only, not modifying)
402
419
  let results = updatedFiles.filter(file => {
420
+ // Filter by query and tags
403
421
  // Fallback to filename if displayFilename is not set
404
422
  const displayFilename = file.displayFilename || file.filename || '';
405
423
  const filename = file.filename || '';
406
424
 
407
425
  // Check both displayFilename and filename for matches
408
- // (displayFilename may be different from filename, so check both)
409
- const filenameMatch = displayFilename.toLowerCase().includes(queryLower) ||
410
- (filename && filename !== displayFilename && filename.toLowerCase().includes(queryLower));
411
- const notesMatch = file.notes && file.notes.toLowerCase().includes(queryLower);
412
- const tagMatch = Array.isArray(file.tags) && file.tags.some(tag => tag.toLowerCase().includes(queryLower));
426
+ // Use normalized matching (treating spaces, dashes, underscores as equivalent)
427
+ // so "News Corp" matches "News-Corp" and "News_Corp"
428
+ const displayFilenameNorm = normalizeForSearch(displayFilename);
429
+ const filenameNorm = normalizeForSearch(filename);
430
+ const filenameMatch = displayFilenameNorm.includes(queryNormalized) ||
431
+ (filename && filename !== displayFilename && filenameNorm.includes(queryNormalized));
432
+ const notesMatch = file.notes && normalizeForSearch(file.notes).includes(queryNormalized);
433
+ const tagMatch = Array.isArray(file.tags) && file.tags.some(tag => normalizeForSearch(tag).includes(queryNormalized));
413
434
 
414
435
  const matchesQuery = filenameMatch || notesMatch || tagMatch;
415
436
 
@@ -426,8 +447,8 @@ export default {
426
447
  // Fallback to filename if displayFilename is not set
427
448
  const aDisplayFilename = a.displayFilename || a.filename || '';
428
449
  const bDisplayFilename = b.displayFilename || b.filename || '';
429
- const aFilenameMatch = aDisplayFilename.toLowerCase().includes(queryLower);
430
- const bFilenameMatch = bDisplayFilename.toLowerCase().includes(queryLower);
450
+ const aFilenameMatch = normalizeForSearch(aDisplayFilename).includes(queryNormalized);
451
+ const bFilenameMatch = normalizeForSearch(bDisplayFilename).includes(queryNormalized);
431
452
  if (aFilenameMatch && !bFilenameMatch) return -1;
432
453
  if (!aFilenameMatch && bFilenameMatch) return 1;
433
454
  return new Date(b.addedDate) - new Date(a.addedDate);
@@ -437,11 +458,28 @@ export default {
437
458
  results = results.slice(0, limit);
438
459
 
439
460
  resolver.tool = JSON.stringify({ toolUsed: "SearchFileCollection" });
461
+
462
+ // Build helpful message when no results found
463
+ let message;
464
+ if (results.length === 0) {
465
+ const suggestions = [];
466
+ if (chatId && !includeAllChats) {
467
+ suggestions.push('try includeAllChats=true to search across all chats');
468
+ }
469
+ suggestions.push('use ListFileCollection to see all available files');
470
+
471
+ message = `No files found matching "${query}". Count: 0. Suggestions: ${suggestions.join('; ')}.`;
472
+ } else {
473
+ message = `Found ${results.length} file(s) matching "${query}". Use the hash or displayFilename to reference files in other tools.`;
474
+ }
475
+
440
476
  return JSON.stringify({
441
477
  success: true,
442
478
  count: results.length,
479
+ message,
443
480
  files: results.map(f => ({
444
481
  id: f.id,
482
+ hash: f.hash || null,
445
483
  displayFilename: f.displayFilename || f.filename || null,
446
484
  url: f.url,
447
485
  gcs: f.gcs || null,
@@ -472,8 +510,9 @@ export default {
472
510
  let filesToProcess = [];
473
511
 
474
512
  // Load collection ONCE to find all files and their data
475
- // Use useCache: false to get fresh data
476
- const collection = await loadFileCollection(contextId, contextKey, false);
513
+ // Do NOT filter by chatId - remove should be able to delete files from any chat
514
+ // Use merged collection to include files from all agentContext contexts
515
+ const collection = await loadMergedFileCollection(args.agentContext, null);
477
516
 
478
517
  // Resolve all files and collect their info in a single pass
479
518
  for (const target of targetFiles) {
@@ -498,7 +537,7 @@ export default {
498
537
  }
499
538
 
500
539
  if (filesToProcess.length === 0 && notFoundFiles.length > 0) {
501
- throw new Error(`No files found matching: ${notFoundFiles.join(', ')}`);
540
+ throw new Error(`No files found matching: ${notFoundFiles.join(', ')}. Try using the file hash, URL, or filename instead of ID. If the file was found in a search, use the hash or filename from the search results.`);
502
541
  }
503
542
 
504
543
  // Import helpers for reference counting
@@ -523,15 +562,23 @@ export default {
523
562
  // No chatId context - fully remove
524
563
  filesToFullyDelete.push(fileInfo);
525
564
  } else {
526
- // Remove this chatId from inCollection
527
- const updatedInCollection = removeChatIdFromInCollection(fileInfo.inCollection, chatId);
565
+ // Check if current chatId is in the file's inCollection
566
+ const currentChatInCollection = Array.isArray(fileInfo.inCollection) && fileInfo.inCollection.includes(chatId);
528
567
 
529
- if (updatedInCollection.length === 0) {
530
- // No more references - fully delete
568
+ if (!currentChatInCollection) {
569
+ // File doesn't belong to current chat - fully remove it (cross-chat removal)
531
570
  filesToFullyDelete.push(fileInfo);
532
571
  } else {
533
- // Still has references from other chats - just update inCollection
534
- filesToUpdate.push({ ...fileInfo, updatedInCollection });
572
+ // Remove this chatId from inCollection
573
+ const updatedInCollection = removeChatIdFromInCollection(fileInfo.inCollection, chatId);
574
+
575
+ if (updatedInCollection.length === 0) {
576
+ // No more references - fully delete
577
+ filesToFullyDelete.push(fileInfo);
578
+ } else {
579
+ // Still has references from other chats - just update inCollection
580
+ filesToUpdate.push({ ...fileInfo, updatedInCollection });
581
+ }
535
582
  }
536
583
  }
537
584
  }
@@ -615,10 +662,15 @@ export default {
615
662
 
616
663
  } else {
617
664
  // List collection (read-only, no locking needed)
618
- const { tags: filterTags = [], sortBy = 'date', limit = 50 } = args;
665
+ const { tags: filterTags = [], sortBy = 'date', limit = 50, includeAllChats = false } = args;
666
+
667
+ // Determine which chatId to use for filtering (null if includeAllChats is true)
668
+ const filterChatId = includeAllChats ? null : chatId;
619
669
 
620
670
  // Use merged collection to include files from all agentContext contexts
621
- const collection = await loadMergedFileCollection(args.agentContext);
671
+ // Filter by chatId if includeAllChats is false and chatId is available
672
+ // loadMergedFileCollection now handles inCollection filtering centrally
673
+ const collection = await loadMergedFileCollection(args.agentContext, filterChatId);
622
674
  let results = collection;
623
675
 
624
676
  // Filter by tags if provided
@@ -646,12 +698,32 @@ export default {
646
698
  results = results.slice(0, limit);
647
699
 
648
700
  resolver.tool = JSON.stringify({ toolUsed: "ListFileCollection" });
701
+
702
+ // Build helpful message
703
+ let message;
704
+ if (results.length === 0) {
705
+ const suggestions = [];
706
+ if (chatId && !includeAllChats) {
707
+ suggestions.push('try includeAllChats=true to see files from all chats');
708
+ }
709
+ if (filterTags.length > 0) {
710
+ suggestions.push('remove tag filters to see more files');
711
+ }
712
+ message = suggestions.length > 0
713
+ ? `No files in collection. Suggestions: ${suggestions.join('; ')}.`
714
+ : 'No files in collection.';
715
+ } else {
716
+ message = (results.length === collection.length) ? `Showing all ${results.length} file(s). These are ALL of the files that you can access. Use the hash or displayFilename to reference files in other tools.` : `Showing ${results.length} of ${collection.length} file(s). Use the hash or displayFilename to reference files in other tools.`;
717
+ }
718
+
649
719
  return JSON.stringify({
650
720
  success: true,
651
721
  count: results.length,
652
722
  totalFiles: collection.length,
723
+ message,
653
724
  files: results.map(f => ({
654
725
  id: f.id,
726
+ hash: f.hash || null,
655
727
  displayFilename: f.displayFilename || f.filename || null,
656
728
  url: f.url,
657
729
  gcs: f.gcs || null,
@@ -1,7 +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
+ import { uploadFileToCloud, addFileToCollection, resolveFileParameter, buildFileCreationResponse } from '../../../../lib/fileUtils.js';
5
5
 
6
6
  export default {
7
7
  prompt: [],
@@ -269,36 +269,13 @@ export default {
269
269
  };
270
270
  });
271
271
 
272
- // Return image info in the same format as availableFiles for the text message
273
- // Format: hash | filename | url | date | tags
274
- const imageList = successfulImages.map((img) => {
275
- if (img.fileEntry) {
276
- // Use the file entry data from addFileToCollection
277
- const fe = img.fileEntry;
278
- const dateStr = fe.addedDate
279
- ? new Date(fe.addedDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
280
- : '';
281
- const tagsStr = Array.isArray(fe.tags) ? fe.tags.join(',') : '';
282
- return `${fe.hash || ''} | ${fe.displayFilename || ''} | ${fe.url || img.url} | ${dateStr} | ${tagsStr}`;
283
- } else {
284
- // Fallback if file collection wasn't available
285
- return `${img.hash || 'unknown'} | | ${img.url} | |`;
286
- }
287
- }).join('\n');
288
-
289
- const count = successfulImages.length;
290
272
  const isModification = args.inputImages && Array.isArray(args.inputImages) && args.inputImages.length > 0;
291
-
292
- // Make the success message very explicit so the agent knows files were created and added to collection
293
- // This format matches availableFiles so the agent can reference them by hash/filename
294
273
  const action = isModification ? 'Image modification' : 'Image generation';
295
- const message = `${action} completed successfully. ${count} image${count > 1 ? 's have' : ' has'} been generated, uploaded to cloud storage, and added to your file collection. The image${count > 1 ? 's are' : ' is'} now available in your file collection:\n\n${imageList}\n\nYou can reference these images by their hash, filename, or URL in future tool calls.`;
296
274
 
297
- // Return JSON object with imageUrls (kept for backward compatibility, but explicit message should prevent looping)
298
- result = JSON.stringify({
299
- success: true,
300
- message: message,
301
- imageUrls: imageUrls
275
+ result = buildFileCreationResponse(successfulImages, {
276
+ mediaType: 'image',
277
+ action: action,
278
+ legacyUrls: imageUrls
302
279
  });
303
280
  }
304
281
  }
@@ -1,7 +1,7 @@
1
1
  // sys_tool_image_gemini.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 { uploadImageToCloud, addFileToCollection, resolveFileParameter } from '../../../../lib/fileUtils.js';
4
+ import { uploadImageToCloud, addFileToCollection, resolveFileParameter, buildFileCreationResponse } from '../../../../lib/fileUtils.js';
5
5
 
6
6
  export default {
7
7
  prompt: [],
@@ -247,34 +247,10 @@ export default {
247
247
  };
248
248
  });
249
249
 
250
- // Return image info in the same format as availableFiles for the text message
251
- // Format: hash | filename | url | date | tags
252
- const imageList = successfulImages.map((img) => {
253
- if (img.fileEntry) {
254
- // Use the file entry data from addFileToCollection
255
- const fe = img.fileEntry;
256
- const dateStr = fe.addedDate
257
- ? new Date(fe.addedDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
258
- : '';
259
- const tagsStr = Array.isArray(fe.tags) ? fe.tags.join(',') : '';
260
- return `${fe.hash || ''} | ${fe.displayFilename || ''} | ${fe.url || img.url} | ${dateStr} | ${tagsStr}`;
261
- } else {
262
- // Fallback if file collection wasn't available
263
- return `${img.hash || 'unknown'} | | ${img.url} | |`;
264
- }
265
- }).join('\n');
266
-
267
- const count = successfulImages.length;
268
-
269
- // Make the success message very explicit so the agent knows files were created and added to collection
270
- // This format matches availableFiles so the agent can reference them by hash/filename
271
- const message = `Image generation completed successfully. ${count} image${count > 1 ? 's have' : ' has'} been generated, uploaded to cloud storage, and added to your file collection. The image${count > 1 ? 's are' : ' is'} now available in your file collection:\n\n${imageList}\n\nYou can reference these images by their hash, filename, or URL in future tool calls.`;
272
-
273
- // Return JSON object with imageUrls (kept for backward compatibility, but explicit message should prevent looping)
274
- return JSON.stringify({
275
- success: true,
276
- message: message,
277
- imageUrls: imageUrls
250
+ return buildFileCreationResponse(successfulImages, {
251
+ mediaType: 'image',
252
+ action: 'Image generation',
253
+ legacyUrls: imageUrls
278
254
  });
279
255
  } else {
280
256
  throw new Error('Image generation failed: Images were generated but could not be uploaded to storage');
@@ -1,7 +1,7 @@
1
1
  // sys_tool_slides_gemini.js
2
2
  // Entity tool that creates slides, infographics, and presentations using Gemini 3 Pro image generation
3
3
  import { callPathway } from '../../../../lib/pathwayTools.js';
4
- import { uploadImageToCloud, addFileToCollection, resolveFileParameter } from '../../../../lib/fileUtils.js';
4
+ import { uploadImageToCloud, addFileToCollection, resolveFileParameter, buildFileCreationResponse } from '../../../../lib/fileUtils.js';
5
5
 
6
6
  export default {
7
7
  prompt: [],
@@ -61,6 +61,7 @@ export default {
61
61
  }],
62
62
  executePathway: async ({args, runAllPrompts, resolver}) => {
63
63
  const pathwayResolver = resolver;
64
+ const chatId = args.chatId || null;
64
65
 
65
66
  try {
66
67
  let model = "gemini-pro-3-image";
@@ -184,7 +185,8 @@ export default {
184
185
  imageHash,
185
186
  null,
186
187
  pathwayResolver,
187
- true // permanent => retention=permanent
188
+ true, // permanent => retention=permanent
189
+ chatId
188
190
  );
189
191
 
190
192
  // Use the file entry data for the return message
@@ -226,35 +228,10 @@ export default {
226
228
  };
227
229
  });
228
230
 
229
- // Return image info in the same format as availableFiles for the text message
230
- // Format: hash | filename | url | date | tags
231
- const imageList = successfulImages.map((img) => {
232
- if (img.fileEntry) {
233
- // Use the file entry data from addFileToCollection
234
- const fe = img.fileEntry;
235
- const dateStr = fe.addedDate
236
- ? new Date(fe.addedDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
237
- : '';
238
- const tagsStr = Array.isArray(fe.tags) ? fe.tags.join(',') : '';
239
- return `${fe.hash || ''} | ${fe.displayFilename || ''} | ${fe.url || img.url} | ${dateStr} | ${tagsStr}`;
240
- } else {
241
- // Fallback if file collection wasn't available
242
- return `${img.hash || 'unknown'} | | ${img.url} | |`;
243
- }
244
- }).join('\n');
245
-
246
- const count = successfulImages.length;
247
-
248
- // Make the success message very explicit so the agent knows files were created and added to collection
249
- // This format matches availableFiles so the agent can reference them by hash/filename
250
- const message = `Slide/infographic generation completed successfully. ${count} image${count > 1 ? 's have' : ' has'} been generated, uploaded to cloud storage, and added to your file collection. The image${count > 1 ? 's are' : ' is'} now available in your file collection:\n\n${imageList}\n\nYou can reference these images by their hash, filename, or URL in future tool calls.`;
251
-
252
- // Return JSON object with imageUrls (kept for backward compatibility, but explicit message should prevent looping)
253
- // This prevents the agent from looping because it can't see the generated images
254
- return JSON.stringify({
255
- success: true,
256
- message: message,
257
- imageUrls: imageUrls
231
+ return buildFileCreationResponse(successfulImages, {
232
+ mediaType: 'image',
233
+ action: 'Slide/infographic generation',
234
+ legacyUrls: imageUrls
258
235
  });
259
236
  } else {
260
237
  throw new Error('Slide generation failed: Content was generated but could not be uploaded to storage');
@@ -58,8 +58,6 @@ export default {
58
58
  });
59
59
  }
60
60
 
61
- // pathwayResolver.executePathway should have already extracted contextId from agentContext,
62
- // but validate it's present as a safety check
63
61
  const { contextId, contextKey } = args;
64
62
  if (!contextId) {
65
63
  return JSON.stringify({
@@ -1,7 +1,7 @@
1
1
  // sys_tool_video_veo.js
2
2
  // Entity tool that generates videos using Google Veo 3.1 Fast 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
+ import { uploadFileToCloud, addFileToCollection, resolveFileParameter, buildFileCreationResponse } from '../../../../lib/fileUtils.js';
5
5
  import { config } from '../../../../config.js';
6
6
  import axios from 'axios';
7
7
 
@@ -345,34 +345,10 @@ export default {
345
345
  };
346
346
  });
347
347
 
348
- // Return video info in the same format as availableFiles for the text message
349
- // Format: hash | filename | url | date | tags
350
- const videoList = successfulVideos.map((vid) => {
351
- if (vid.fileEntry) {
352
- // Use the file entry data from addFileToCollection
353
- const fe = vid.fileEntry;
354
- const dateStr = fe.addedDate
355
- ? new Date(fe.addedDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
356
- : '';
357
- const tagsStr = Array.isArray(fe.tags) ? fe.tags.join(',') : '';
358
- return `${fe.hash || ''} | ${fe.displayFilename || ''} | ${fe.url || vid.url} | ${dateStr} | ${tagsStr}`;
359
- } else {
360
- // Fallback if file collection wasn't available
361
- return `${vid.hash || 'unknown'} | | ${vid.url} | |`;
362
- }
363
- }).join('\n');
364
-
365
- const count = successfulVideos.length;
366
-
367
- // Make the success message very explicit so the agent knows files were created and added to collection
368
- // This format matches availableFiles so the agent can reference them by hash/filename
369
- const message = `Video generation completed successfully. ${count} video${count > 1 ? 's have' : ' has'} been generated, uploaded to cloud storage, and added to your file collection. The video${count > 1 ? 's are' : ' is'} now available in your file collection:\n\n${videoList}\n\nYou can reference these videos by their hash, filename, or URL in future tool calls. Videos can be displayed using markdown image syntax, e.g. ![video](url)`;
370
-
371
- // Return JSON object with imageUrls (kept for backward compatibility, but explicit message should prevent looping)
372
- return JSON.stringify({
373
- success: true,
374
- message: message,
375
- imageUrls: imageUrls
348
+ return buildFileCreationResponse(successfulVideos, {
349
+ mediaType: 'video',
350
+ action: 'Video generation',
351
+ legacyUrls: imageUrls
376
352
  });
377
353
  } else {
378
354
  // All videos failed to upload
@@ -1,7 +1,7 @@
1
1
  // sys_tool_view_image.js
2
2
  // Tool pathway that allows agents to view image files from the file collection
3
3
  import logger from '../../../../lib/logger.js';
4
- import { loadMergedFileCollection, findFileInCollection, ensureShortLivedUrl, getDefaultContext } from '../../../../lib/fileUtils.js';
4
+ import { loadMergedFileCollection, findFileInCollection, ensureShortLivedUrl } from '../../../../lib/fileUtils.js';
5
5
  import { config } from '../../../../config.js';
6
6
 
7
7
  export default {
@@ -74,8 +74,7 @@ export default {
74
74
 
75
75
  // Resolve to short-lived URL if possible
76
76
  const fileHandlerUrl = config.get('whisperMediaApiUrl');
77
- const defaultCtx = getDefaultContext(args.agentContext);
78
- const fileWithShortLivedUrl = await ensureShortLivedUrl(foundFile, fileHandlerUrl, defaultCtx?.contextId || null);
77
+ const fileWithShortLivedUrl = await ensureShortLivedUrl(foundFile, fileHandlerUrl, args.contextId || null);
79
78
 
80
79
  // Add to imageUrls array
81
80
  imageUrls.push({
@@ -57,7 +57,7 @@ export default {
57
57
  },
58
58
 
59
59
  executePathway: async ({args, runAllPrompts, resolver}) => {
60
- const { content, filename, tags = [], notes = '', contextId, contextKey } = args;
60
+ const { content, filename, tags = [], notes = '', contextId, contextKey, chatId } = args;
61
61
 
62
62
  // Validate inputs and return JSON error if invalid
63
63
  if (content === undefined || content === null) {
@@ -176,7 +176,8 @@ export default {
176
176
  uploadResult.hash || null,
177
177
  null, // fileUrl - not needed since we already uploaded
178
178
  resolver,
179
- true // permanent => retention=permanent
179
+ true, // permanent => retention=permanent
180
+ chatId || null
180
181
  );
181
182
  } catch (collectionError) {
182
183
  // Log but don't fail - file collection is optional
@@ -185,6 +186,19 @@ export default {
185
186
  }
186
187
 
187
188
  const fileSize = Buffer.byteLength(content, 'utf8');
189
+
190
+ // Create explicit success message that clearly indicates completion and file collection status
191
+ // This format matches image generation tools to prevent agent loops
192
+ let message;
193
+ if (fileEntry) {
194
+ // File was added to collection - provide explicit completion message with reference info
195
+ const fileRef = fileEntry.hash || fileEntry.id || filename;
196
+ message = `File creation completed successfully. The file "${filename}" (${formatFileSize(fileSize)}) has been written, uploaded to cloud storage, and added to your file collection. The file is now available in your file collection and can be referenced by its hash (${fileEntry.hash || 'N/A'}), filename (${filename}), fileId (${fileEntry.id || 'N/A'}), or URL in future tool calls.`;
197
+ } else {
198
+ // File was uploaded but not added to collection (no contextId provided)
199
+ message = `File "${filename}" written and uploaded successfully (${formatFileSize(fileSize)}). File URL: ${uploadResult.url}`;
200
+ }
201
+
188
202
  const result = {
189
203
  success: true,
190
204
  filename: filename,
@@ -194,7 +208,7 @@ export default {
194
208
  fileId: fileEntry?.id || null,
195
209
  size: fileSize,
196
210
  sizeFormatted: formatFileSize(fileSize),
197
- message: `File "${filename}" written and uploaded successfully (${formatFileSize(fileSize)})`
211
+ message: message
198
212
  };
199
213
 
200
214
  resolver.tool = JSON.stringify({ toolUsed: "WriteFile" });
@@ -2389,3 +2389,324 @@ test('loadMergedFileCollection should dedupe files present in both contexts', as
2389
2389
  }
2390
2390
  }
2391
2391
  });
2392
+
2393
+ test('File collection: SearchFileCollection filters by chatId by default', async t => {
2394
+ const contextId = createTestContext();
2395
+ const chatId1 = 'chat-1';
2396
+ const chatId2 = 'chat-2';
2397
+
2398
+ try {
2399
+ const { addFileToCollection } = await import('../../../../lib/fileUtils.js');
2400
+
2401
+ // Add file to chat-1
2402
+ await addFileToCollection(contextId, null, 'https://example.com/chat1-file.pdf', null, 'chat1-file.pdf', [], '', 'hash-chat1', null, null, false, chatId1);
2403
+
2404
+ // Add file to chat-2
2405
+ await addFileToCollection(contextId, null, 'https://example.com/chat2-file.pdf', null, 'chat2-file.pdf', [], '', 'hash-chat2', null, null, false, chatId2);
2406
+
2407
+ // Add global file (no chatId)
2408
+ await addFileToCollection(contextId, null, 'https://example.com/global-file.pdf', null, 'global-file.pdf', [], '', 'hash-global', null, null, false, null);
2409
+
2410
+ // Search from chat-1 - should only see chat-1 file and global file
2411
+ const result1 = await callPathway('sys_tool_file_collection', {
2412
+ agentContext: [{ contextId, contextKey: null, default: true }],
2413
+ chatId: chatId1,
2414
+ query: 'file',
2415
+ userMessage: 'Search from chat-1'
2416
+ });
2417
+
2418
+ const parsed1 = JSON.parse(result1);
2419
+ t.is(parsed1.success, true);
2420
+ t.is(parsed1.count, 2, 'Should find chat-1 file and global file');
2421
+ const filenames1 = parsed1.files.map(f => f.displayFilename);
2422
+ t.true(filenames1.includes('chat1-file.pdf'), 'Should include chat-1 file');
2423
+ t.true(filenames1.includes('global-file.pdf'), 'Should include global file');
2424
+ t.false(filenames1.includes('chat2-file.pdf'), 'Should not include chat-2 file');
2425
+
2426
+ // Search from chat-2 - should only see chat-2 file and global file
2427
+ const result2 = await callPathway('sys_tool_file_collection', {
2428
+ agentContext: [{ contextId, contextKey: null, default: true }],
2429
+ chatId: chatId2,
2430
+ query: 'file',
2431
+ userMessage: 'Search from chat-2'
2432
+ });
2433
+
2434
+ const parsed2 = JSON.parse(result2);
2435
+ t.is(parsed2.success, true);
2436
+ t.is(parsed2.count, 2, 'Should find chat-2 file and global file');
2437
+ const filenames2 = parsed2.files.map(f => f.displayFilename);
2438
+ t.true(filenames2.includes('chat2-file.pdf'), 'Should include chat-2 file');
2439
+ t.true(filenames2.includes('global-file.pdf'), 'Should include global file');
2440
+ t.false(filenames2.includes('chat1-file.pdf'), 'Should not include chat-1 file');
2441
+
2442
+ // Search without chatId - should see all files (no filtering when chatId not provided)
2443
+ const result3 = await callPathway('sys_tool_file_collection', {
2444
+ agentContext: [{ contextId, contextKey: null, default: true }],
2445
+ query: 'file',
2446
+ userMessage: 'Search without chatId'
2447
+ });
2448
+
2449
+ const parsed3 = JSON.parse(result3);
2450
+ t.is(parsed3.success, true);
2451
+ t.is(parsed3.count, 3, 'Should find all files when chatId not provided');
2452
+ const filenames3 = parsed3.files.map(f => f.displayFilename);
2453
+ t.true(filenames3.includes('chat1-file.pdf'), 'Should include chat-1 file');
2454
+ t.true(filenames3.includes('chat2-file.pdf'), 'Should include chat-2 file');
2455
+ t.true(filenames3.includes('global-file.pdf'), 'Should include global file');
2456
+ } finally {
2457
+ await cleanup(contextId);
2458
+ }
2459
+ });
2460
+
2461
+ test('File collection: SearchFileCollection with includeAllChats=true shows all files', async t => {
2462
+ const contextId = createTestContext();
2463
+ const chatId1 = 'chat-1';
2464
+ const chatId2 = 'chat-2';
2465
+
2466
+ try {
2467
+ const { addFileToCollection } = await import('../../../../lib/fileUtils.js');
2468
+
2469
+ // Add file to chat-1
2470
+ await addFileToCollection(contextId, null, 'https://example.com/chat1-file.pdf', null, 'chat1-file.pdf', [], '', 'hash-chat1', null, null, false, chatId1);
2471
+
2472
+ // Add file to chat-2
2473
+ await addFileToCollection(contextId, null, 'https://example.com/chat2-file.pdf', null, 'chat2-file.pdf', [], '', 'hash-chat2', null, null, false, chatId2);
2474
+
2475
+ // Add global file
2476
+ await addFileToCollection(contextId, null, 'https://example.com/global-file.pdf', null, 'global-file.pdf', [], '', 'hash-global', null, null, false, null);
2477
+
2478
+ // Search from chat-1 with includeAllChats=true - should see all files
2479
+ const result = await callPathway('sys_tool_file_collection', {
2480
+ agentContext: [{ contextId, contextKey: null, default: true }],
2481
+ chatId: chatId1,
2482
+ query: 'file',
2483
+ includeAllChats: true,
2484
+ userMessage: 'Search all chats from chat-1'
2485
+ });
2486
+
2487
+ const parsed = JSON.parse(result);
2488
+ t.is(parsed.success, true);
2489
+ t.is(parsed.count, 3, 'Should find all files from all chats');
2490
+ const filenames = parsed.files.map(f => f.displayFilename);
2491
+ t.true(filenames.includes('chat1-file.pdf'), 'Should include chat-1 file');
2492
+ t.true(filenames.includes('chat2-file.pdf'), 'Should include chat-2 file');
2493
+ t.true(filenames.includes('global-file.pdf'), 'Should include global file');
2494
+ } finally {
2495
+ await cleanup(contextId);
2496
+ }
2497
+ });
2498
+
2499
+ test('File collection: ListFileCollection filters by chatId by default', async t => {
2500
+ const contextId = createTestContext();
2501
+ const chatId1 = 'chat-1';
2502
+ const chatId2 = 'chat-2';
2503
+
2504
+ try {
2505
+ const { addFileToCollection } = await import('../../../../lib/fileUtils.js');
2506
+
2507
+ // Add file to chat-1
2508
+ await addFileToCollection(contextId, null, 'https://example.com/chat1-file.pdf', null, 'chat1-file.pdf', [], '', 'hash-chat1', null, null, false, chatId1);
2509
+
2510
+ // Add file to chat-2
2511
+ await addFileToCollection(contextId, null, 'https://example.com/chat2-file.pdf', null, 'chat2-file.pdf', [], '', 'hash-chat2', null, null, false, chatId2);
2512
+
2513
+ // Add global file
2514
+ await addFileToCollection(contextId, null, 'https://example.com/global-file.pdf', null, 'global-file.pdf', [], '', 'hash-global', null, null, false, null);
2515
+
2516
+ // List from chat-1 - should only see chat-1 file and global file
2517
+ const result1 = await callPathway('sys_tool_file_collection', {
2518
+ agentContext: [{ contextId, contextKey: null, default: true }],
2519
+ chatId: chatId1,
2520
+ userMessage: 'List files from chat-1'
2521
+ });
2522
+
2523
+ const parsed1 = JSON.parse(result1);
2524
+ t.is(parsed1.success, true);
2525
+ t.is(parsed1.count, 2, 'Should find chat-1 file and global file');
2526
+ t.is(parsed1.totalFiles, 2, 'Total should match count');
2527
+ const filenames1 = parsed1.files.map(f => f.displayFilename);
2528
+ t.true(filenames1.includes('chat1-file.pdf'), 'Should include chat-1 file');
2529
+ t.true(filenames1.includes('global-file.pdf'), 'Should include global file');
2530
+ t.false(filenames1.includes('chat2-file.pdf'), 'Should not include chat-2 file');
2531
+
2532
+ // List from chat-2 - should only see chat-2 file and global file
2533
+ const result2 = await callPathway('sys_tool_file_collection', {
2534
+ agentContext: [{ contextId, contextKey: null, default: true }],
2535
+ chatId: chatId2,
2536
+ userMessage: 'List files from chat-2'
2537
+ });
2538
+
2539
+ const parsed2 = JSON.parse(result2);
2540
+ t.is(parsed2.success, true);
2541
+ t.is(parsed2.count, 2, 'Should find chat-2 file and global file');
2542
+ const filenames2 = parsed2.files.map(f => f.displayFilename);
2543
+ t.true(filenames2.includes('chat2-file.pdf'), 'Should include chat-2 file');
2544
+ t.true(filenames2.includes('global-file.pdf'), 'Should include global file');
2545
+ t.false(filenames2.includes('chat1-file.pdf'), 'Should not include chat-1 file');
2546
+ } finally {
2547
+ await cleanup(contextId);
2548
+ }
2549
+ });
2550
+
2551
+ test('File collection: ListFileCollection with includeAllChats=true shows all files', async t => {
2552
+ const contextId = createTestContext();
2553
+ const chatId1 = 'chat-1';
2554
+ const chatId2 = 'chat-2';
2555
+
2556
+ try {
2557
+ const { addFileToCollection } = await import('../../../../lib/fileUtils.js');
2558
+
2559
+ // Add file to chat-1
2560
+ await addFileToCollection(contextId, null, 'https://example.com/chat1-file.pdf', null, 'chat1-file.pdf', [], '', 'hash-chat1', null, null, false, chatId1);
2561
+
2562
+ // Add file to chat-2
2563
+ await addFileToCollection(contextId, null, 'https://example.com/chat2-file.pdf', null, 'chat2-file.pdf', [], '', 'hash-chat2', null, null, false, chatId2);
2564
+
2565
+ // Add global file
2566
+ await addFileToCollection(contextId, null, 'https://example.com/global-file.pdf', null, 'global-file.pdf', [], '', 'hash-global', null, null, false, null);
2567
+
2568
+ // List from chat-1 with includeAllChats=true - should see all files
2569
+ const result = await callPathway('sys_tool_file_collection', {
2570
+ agentContext: [{ contextId, contextKey: null, default: true }],
2571
+ chatId: chatId1,
2572
+ includeAllChats: true,
2573
+ userMessage: 'List all files from chat-1'
2574
+ });
2575
+
2576
+ const parsed = JSON.parse(result);
2577
+ t.is(parsed.success, true);
2578
+ t.is(parsed.count, 3, 'Should find all files from all chats');
2579
+ t.is(parsed.totalFiles, 3, 'Total should match count');
2580
+ const filenames = parsed.files.map(f => f.displayFilename);
2581
+ t.true(filenames.includes('chat1-file.pdf'), 'Should include chat-1 file');
2582
+ t.true(filenames.includes('chat2-file.pdf'), 'Should include chat-2 file');
2583
+ t.true(filenames.includes('global-file.pdf'), 'Should include global file');
2584
+ } finally {
2585
+ await cleanup(contextId);
2586
+ }
2587
+ });
2588
+
2589
+ test('File collection: RemoveFileFromCollection can remove files from any chat', async t => {
2590
+ const contextId = createTestContext();
2591
+ const chatId1 = 'chat-1';
2592
+ const chatId2 = 'chat-2';
2593
+
2594
+ try {
2595
+ const { addFileToCollection } = await import('../../../../lib/fileUtils.js');
2596
+
2597
+ // Add file to chat-1
2598
+ await addFileToCollection(contextId, null, 'https://example.com/chat1-file.pdf', null, 'chat1-file.pdf', [], '', 'hash-chat1', null, null, false, chatId1);
2599
+
2600
+ // Add file to chat-2
2601
+ await addFileToCollection(contextId, null, 'https://example.com/chat2-file.pdf', null, 'chat2-file.pdf', [], '', 'hash-chat2', null, null, false, chatId2);
2602
+
2603
+ // Add global file
2604
+ await addFileToCollection(contextId, null, 'https://example.com/global-file.pdf', null, 'global-file.pdf', [], '', 'hash-global', null, null, false, null);
2605
+
2606
+ // Search from chat-1 to get the file ID for chat-2's file
2607
+ const searchResult = await callPathway('sys_tool_file_collection', {
2608
+ agentContext: [{ contextId, contextKey: null, default: true }],
2609
+ chatId: chatId1,
2610
+ query: 'chat2',
2611
+ includeAllChats: true,
2612
+ userMessage: 'Search all chats to find chat-2 file'
2613
+ });
2614
+ const searchParsed = JSON.parse(searchResult);
2615
+ t.is(searchParsed.success, true);
2616
+ const chat2File = searchParsed.files.find(f => f.displayFilename === 'chat2-file.pdf');
2617
+ t.truthy(chat2File, 'Should find chat-2 file when searching all chats');
2618
+ const chat2FileId = chat2File.id || chat2File.hash || chat2File.url;
2619
+
2620
+ // Remove chat-2's file from chat-1 context (cross-chat removal)
2621
+ const removeResult = await callPathway('sys_tool_file_collection', {
2622
+ agentContext: [{ contextId, contextKey: null, default: true }],
2623
+ chatId: chatId1, // Calling from chat-1
2624
+ fileIds: [chat2FileId],
2625
+ userMessage: 'Remove chat-2 file from chat-1'
2626
+ });
2627
+
2628
+ const removeParsed = JSON.parse(removeResult);
2629
+ t.is(removeParsed.success, true);
2630
+ t.is(removeParsed.removedCount, 1);
2631
+ t.is(removeParsed.removedFiles.length, 1);
2632
+ t.is(removeParsed.removedFiles[0].displayFilename, 'chat2-file.pdf');
2633
+
2634
+ // Verify chat-2 file is gone (search from chat-2)
2635
+ const verifyResult = await callPathway('sys_tool_file_collection', {
2636
+ agentContext: [{ contextId, contextKey: null, default: true }],
2637
+ chatId: chatId2,
2638
+ userMessage: 'List files from chat-2'
2639
+ });
2640
+ const verifyParsed = JSON.parse(verifyResult);
2641
+ t.is(verifyParsed.success, true);
2642
+ t.is(verifyParsed.count, 1, 'Should only have global file left');
2643
+ t.true(verifyParsed.files.some(f => f.displayFilename === 'global-file.pdf'));
2644
+ t.false(verifyParsed.files.some(f => f.displayFilename === 'chat2-file.pdf'));
2645
+
2646
+ // Verify chat-1 file is still there
2647
+ const chat1Result = await callPathway('sys_tool_file_collection', {
2648
+ agentContext: [{ contextId, contextKey: null, default: true }],
2649
+ chatId: chatId1,
2650
+ userMessage: 'List files from chat-1'
2651
+ });
2652
+ const chat1Parsed = JSON.parse(chat1Result);
2653
+ t.is(chat1Parsed.success, true);
2654
+ t.is(chat1Parsed.count, 2, 'Should have chat-1 file and global file');
2655
+ t.true(chat1Parsed.files.some(f => f.displayFilename === 'chat1-file.pdf'));
2656
+ t.true(chat1Parsed.files.some(f => f.displayFilename === 'global-file.pdf'));
2657
+ } finally {
2658
+ await cleanup(contextId);
2659
+ }
2660
+ });
2661
+
2662
+ test('File collection: SearchFileCollection normalizes separators (space/dash/underscore matching)', async t => {
2663
+ const contextId = createTestContext();
2664
+
2665
+ try {
2666
+ const { addFileToCollection } = await import('../../../../lib/fileUtils.js');
2667
+
2668
+ // Add files with different separator conventions
2669
+ await addFileToCollection(contextId, null, 'https://example.com/news-corp-report.pdf', null, 'News-Corp-Report.pdf', [], '', 'hash-dashes', null, null, false, null);
2670
+ await addFileToCollection(contextId, null, 'https://example.com/news_corp_annual.pdf', null, 'News_Corp_Annual.pdf', [], '', 'hash-underscores', null, null, false, null);
2671
+ await addFileToCollection(contextId, null, 'https://example.com/unrelated.pdf', null, 'Unrelated-File.pdf', [], '', 'hash-unrelated', null, null, false, null);
2672
+
2673
+ // Search with space: "News Corp" should match both "News-Corp" and "News_Corp"
2674
+ const result = await callPathway('sys_tool_file_collection', {
2675
+ agentContext: [{ contextId, contextKey: null, default: true }],
2676
+ query: 'News Corp',
2677
+ userMessage: 'Search with spaces'
2678
+ });
2679
+
2680
+ const parsed = JSON.parse(result);
2681
+ t.is(parsed.success, true);
2682
+ t.is(parsed.count, 2, 'Should find both files with different separators');
2683
+ const filenames = parsed.files.map(f => f.displayFilename);
2684
+ t.true(filenames.includes('News-Corp-Report.pdf'), 'Should match dash-separated');
2685
+ t.true(filenames.includes('News_Corp_Annual.pdf'), 'Should match underscore-separated');
2686
+ t.false(filenames.includes('Unrelated-File.pdf'), 'Should not match unrelated file');
2687
+
2688
+ // Search with dash: "News-Corp" should also match both
2689
+ const result2 = await callPathway('sys_tool_file_collection', {
2690
+ agentContext: [{ contextId, contextKey: null, default: true }],
2691
+ query: 'News-Corp',
2692
+ userMessage: 'Search with dashes'
2693
+ });
2694
+
2695
+ const parsed2 = JSON.parse(result2);
2696
+ t.is(parsed2.success, true);
2697
+ t.is(parsed2.count, 2, 'Dash search should also find both');
2698
+
2699
+ // Search with underscore: "News_Corp" should also match both
2700
+ const result3 = await callPathway('sys_tool_file_collection', {
2701
+ agentContext: [{ contextId, contextKey: null, default: true }],
2702
+ query: 'News_Corp',
2703
+ userMessage: 'Search with underscores'
2704
+ });
2705
+
2706
+ const parsed3 = JSON.parse(result3);
2707
+ t.is(parsed3.success, true);
2708
+ t.is(parsed3.count, 2, 'Underscore search should also find both');
2709
+ } finally {
2710
+ await cleanup(contextId);
2711
+ }
2712
+ });
@@ -73,7 +73,7 @@ test('WriteFile: Write and upload text file', async t => {
73
73
  t.is(parsed.filename, filename);
74
74
  t.truthy(parsed.url);
75
75
  t.is(parsed.size, Buffer.byteLength(content, 'utf8'));
76
- t.true(parsed.message.includes('written and uploaded successfully'));
76
+ t.true(parsed.message.includes('written') && parsed.message.includes('uploaded'));
77
77
  } finally {
78
78
  await cleanup(contextId);
79
79
  }
@@ -70,7 +70,7 @@ test('WriteFile: Write and upload text file', async t => {
70
70
  t.is(parsed.filename, filename);
71
71
  t.truthy(parsed.url);
72
72
  t.is(parsed.size, Buffer.byteLength(content, 'utf8'));
73
- t.true(parsed.message.includes('written and uploaded successfully'));
73
+ t.true(parsed.message.includes('written') && parsed.message.includes('uploaded'));
74
74
 
75
75
  // Verify it was added to file collection
76
76
  const collection = await loadFileCollection(contextId, null, false);