@aj-archipelago/cortex 1.4.28 → 1.4.29

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
@@ -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.29",
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, 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
@@ -356,7 +362,7 @@ export default {
356
362
 
357
363
  } else if (isSearch) {
358
364
  // Search collection
359
- const { query, tags: filterTags = [], limit = 20 } = args;
365
+ const { query, tags: filterTags = [], limit = 20, includeAllChats = false } = args;
360
366
 
361
367
  if (!query || typeof query !== 'string') {
362
368
  throw new Error("query is required and must be a string");
@@ -366,8 +372,15 @@ export default {
366
372
  const safeFilterTags = Array.isArray(filterTags) ? filterTags : [];
367
373
  const queryLower = query.toLowerCase();
368
374
 
375
+ // Normalize query for flexible matching: treat spaces, dashes, underscores as equivalent
376
+ const normalizeForSearch = (str) => str.toLowerCase().replace(/[-_\s]+/g, ' ').trim();
377
+ const queryNormalized = normalizeForSearch(query);
378
+
379
+ // Determine which chatId to use for filtering (null if includeAllChats is true)
380
+ const filterChatId = includeAllChats ? null : chatId;
381
+
369
382
  // Load primary collection for lastAccessed updates (only update files in primary context)
370
- const primaryFiles = await loadFileCollection(contextId, contextKey, false);
383
+ const primaryFiles = await loadFileCollection(contextId, contextKey, false, filterChatId);
371
384
  const now = new Date().toISOString();
372
385
 
373
386
  // Find matching files in primary collection and update lastAccessed directly
@@ -376,9 +389,9 @@ export default {
376
389
 
377
390
  // Fallback to filename if displayFilename is not set (for files uploaded before displayFilename was added)
378
391
  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));
392
+ const filenameMatch = normalizeForSearch(displayFilename).includes(queryNormalized);
393
+ const notesMatch = file.notes && normalizeForSearch(file.notes).includes(queryNormalized);
394
+ const tagMatch = Array.isArray(file.tags) && file.tags.some(tag => normalizeForSearch(tag).includes(queryNormalized));
382
395
  const matchesQuery = filenameMatch || notesMatch || tagMatch;
383
396
 
384
397
  const matchesTags = safeFilterTags.length === 0 ||
@@ -396,20 +409,26 @@ export default {
396
409
  }
397
410
 
398
411
  // Load merged collection for search results (includes all agentContext files)
399
- const updatedFiles = await loadMergedFileCollection(args.agentContext);
412
+ // Filter by chatId if includeAllChats is false and chatId is available
413
+ // loadMergedFileCollection now handles inCollection filtering centrally
414
+ const updatedFiles = await loadMergedFileCollection(args.agentContext, filterChatId);
400
415
 
401
416
  // Filter and sort results (for display only, not modifying)
402
417
  let results = updatedFiles.filter(file => {
418
+ // Filter by query and tags
403
419
  // Fallback to filename if displayFilename is not set
404
420
  const displayFilename = file.displayFilename || file.filename || '';
405
421
  const filename = file.filename || '';
406
422
 
407
423
  // 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));
424
+ // Use normalized matching (treating spaces, dashes, underscores as equivalent)
425
+ // so "News Corp" matches "News-Corp" and "News_Corp"
426
+ const displayFilenameNorm = normalizeForSearch(displayFilename);
427
+ const filenameNorm = normalizeForSearch(filename);
428
+ const filenameMatch = displayFilenameNorm.includes(queryNormalized) ||
429
+ (filename && filename !== displayFilename && filenameNorm.includes(queryNormalized));
430
+ const notesMatch = file.notes && normalizeForSearch(file.notes).includes(queryNormalized);
431
+ const tagMatch = Array.isArray(file.tags) && file.tags.some(tag => normalizeForSearch(tag).includes(queryNormalized));
413
432
 
414
433
  const matchesQuery = filenameMatch || notesMatch || tagMatch;
415
434
 
@@ -426,8 +445,8 @@ export default {
426
445
  // Fallback to filename if displayFilename is not set
427
446
  const aDisplayFilename = a.displayFilename || a.filename || '';
428
447
  const bDisplayFilename = b.displayFilename || b.filename || '';
429
- const aFilenameMatch = aDisplayFilename.toLowerCase().includes(queryLower);
430
- const bFilenameMatch = bDisplayFilename.toLowerCase().includes(queryLower);
448
+ const aFilenameMatch = normalizeForSearch(aDisplayFilename).includes(queryNormalized);
449
+ const bFilenameMatch = normalizeForSearch(bDisplayFilename).includes(queryNormalized);
431
450
  if (aFilenameMatch && !bFilenameMatch) return -1;
432
451
  if (!aFilenameMatch && bFilenameMatch) return 1;
433
452
  return new Date(b.addedDate) - new Date(a.addedDate);
@@ -437,11 +456,28 @@ export default {
437
456
  results = results.slice(0, limit);
438
457
 
439
458
  resolver.tool = JSON.stringify({ toolUsed: "SearchFileCollection" });
459
+
460
+ // Build helpful message when no results found
461
+ let message;
462
+ if (results.length === 0) {
463
+ const suggestions = [];
464
+ if (chatId && !includeAllChats) {
465
+ suggestions.push('try includeAllChats=true to search across all chats');
466
+ }
467
+ suggestions.push('use ListFileCollection to see all available files');
468
+
469
+ message = `No files found matching "${query}". Count: 0. Suggestions: ${suggestions.join('; ')}.`;
470
+ } else {
471
+ message = `Found ${results.length} file(s) matching "${query}". Use the hash or displayFilename to reference files in other tools.`;
472
+ }
473
+
440
474
  return JSON.stringify({
441
475
  success: true,
442
476
  count: results.length,
477
+ message,
443
478
  files: results.map(f => ({
444
479
  id: f.id,
480
+ hash: f.hash || null,
445
481
  displayFilename: f.displayFilename || f.filename || null,
446
482
  url: f.url,
447
483
  gcs: f.gcs || null,
@@ -472,8 +508,9 @@ export default {
472
508
  let filesToProcess = [];
473
509
 
474
510
  // 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);
511
+ // Do NOT filter by chatId - remove should be able to delete files from any chat
512
+ // Use merged collection to include files from all agentContext contexts
513
+ const collection = await loadMergedFileCollection(args.agentContext, null);
477
514
 
478
515
  // Resolve all files and collect their info in a single pass
479
516
  for (const target of targetFiles) {
@@ -498,7 +535,7 @@ export default {
498
535
  }
499
536
 
500
537
  if (filesToProcess.length === 0 && notFoundFiles.length > 0) {
501
- throw new Error(`No files found matching: ${notFoundFiles.join(', ')}`);
538
+ 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
539
  }
503
540
 
504
541
  // Import helpers for reference counting
@@ -523,15 +560,23 @@ export default {
523
560
  // No chatId context - fully remove
524
561
  filesToFullyDelete.push(fileInfo);
525
562
  } else {
526
- // Remove this chatId from inCollection
527
- const updatedInCollection = removeChatIdFromInCollection(fileInfo.inCollection, chatId);
563
+ // Check if current chatId is in the file's inCollection
564
+ const currentChatInCollection = Array.isArray(fileInfo.inCollection) && fileInfo.inCollection.includes(chatId);
528
565
 
529
- if (updatedInCollection.length === 0) {
530
- // No more references - fully delete
566
+ if (!currentChatInCollection) {
567
+ // File doesn't belong to current chat - fully remove it (cross-chat removal)
531
568
  filesToFullyDelete.push(fileInfo);
532
569
  } else {
533
- // Still has references from other chats - just update inCollection
534
- filesToUpdate.push({ ...fileInfo, updatedInCollection });
570
+ // Remove this chatId from inCollection
571
+ const updatedInCollection = removeChatIdFromInCollection(fileInfo.inCollection, chatId);
572
+
573
+ if (updatedInCollection.length === 0) {
574
+ // No more references - fully delete
575
+ filesToFullyDelete.push(fileInfo);
576
+ } else {
577
+ // Still has references from other chats - just update inCollection
578
+ filesToUpdate.push({ ...fileInfo, updatedInCollection });
579
+ }
535
580
  }
536
581
  }
537
582
  }
@@ -615,10 +660,15 @@ export default {
615
660
 
616
661
  } else {
617
662
  // List collection (read-only, no locking needed)
618
- const { tags: filterTags = [], sortBy = 'date', limit = 50 } = args;
663
+ const { tags: filterTags = [], sortBy = 'date', limit = 50, includeAllChats = false } = args;
664
+
665
+ // Determine which chatId to use for filtering (null if includeAllChats is true)
666
+ const filterChatId = includeAllChats ? null : chatId;
619
667
 
620
668
  // Use merged collection to include files from all agentContext contexts
621
- const collection = await loadMergedFileCollection(args.agentContext);
669
+ // Filter by chatId if includeAllChats is false and chatId is available
670
+ // loadMergedFileCollection now handles inCollection filtering centrally
671
+ const collection = await loadMergedFileCollection(args.agentContext, filterChatId);
622
672
  let results = collection;
623
673
 
624
674
  // Filter by tags if provided
@@ -646,12 +696,32 @@ export default {
646
696
  results = results.slice(0, limit);
647
697
 
648
698
  resolver.tool = JSON.stringify({ toolUsed: "ListFileCollection" });
699
+
700
+ // Build helpful message
701
+ let message;
702
+ if (results.length === 0) {
703
+ const suggestions = [];
704
+ if (chatId && !includeAllChats) {
705
+ suggestions.push('try includeAllChats=true to see files from all chats');
706
+ }
707
+ if (filterTags.length > 0) {
708
+ suggestions.push('remove tag filters to see more files');
709
+ }
710
+ message = suggestions.length > 0
711
+ ? `No files in collection. Suggestions: ${suggestions.join('; ')}.`
712
+ : 'No files in collection.';
713
+ } else {
714
+ 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.`;
715
+ }
716
+
649
717
  return JSON.stringify({
650
718
  success: true,
651
719
  count: results.length,
652
720
  totalFiles: collection.length,
721
+ message,
653
722
  files: results.map(f => ({
654
723
  id: f.id,
724
+ hash: f.hash || null,
655
725
  displayFilename: f.displayFilename || f.filename || null,
656
726
  url: f.url,
657
727
  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);