@aj-archipelago/cortex 1.4.30 → 1.4.32

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
@@ -507,17 +507,24 @@ function extractFilesFromChatHistory(chatHistory) {
507
507
  */
508
508
  function isFileInCollection(inCollection, chatId = null) {
509
509
  // If not set, file is not in collection
510
+ // Treat empty array [] the same as undefined (not in collection)
511
+ // Note: false is not a valid value (normalizes to undefined), but handle it defensively
510
512
  if (inCollection === undefined || inCollection === null || inCollection === false) {
511
513
  return false;
512
514
  }
513
515
 
516
+ // Empty array means not in collection (same as undefined)
517
+ if (Array.isArray(inCollection) && inCollection.length === 0) {
518
+ return false;
519
+ }
520
+
514
521
  // Backward compatibility: boolean true means global
515
522
  if (inCollection === true) {
516
523
  return true;
517
524
  }
518
525
 
519
526
  // Array format: check if it includes '*' (global) or the specific chatId
520
- if (Array.isArray(inCollection)) {
527
+ if (Array.isArray(inCollection) && inCollection.length > 0) {
521
528
  // If no chatId specified, only include global files
522
529
  if (chatId === null) {
523
530
  return inCollection.includes('*');
@@ -699,125 +706,180 @@ function parseRawFileData(allFiles, contextKey = null) {
699
706
  }
700
707
 
701
708
  /**
702
- * Filter and format file collection based on inCollection and chatId
703
- * @param {Array} rawFiles - Array of parsed file data objects
704
- * @param {string|null} chatId - Optional chat ID to filter by
705
- * @returns {Array} Filtered and sorted file collection (includes inCollection for reference counting)
709
+ * Sort files by lastAccessed (most recent first)
710
+ * @param {Array} files - Array of file objects
711
+ * @returns {Array} Sorted array
706
712
  */
707
- function filterAndFormatFileCollection(rawFiles, chatId = null) {
708
- // Filter by inCollection and optional chatId
709
- const filtered = rawFiles.filter(file => isFileInCollection(file.inCollection, chatId));
710
-
711
- // Keep inCollection in output (needed for reference counting display)
712
- // Sort by lastAccessed (most recent first)
713
- filtered.sort((a, b) => {
713
+ function sortFilesByLastAccessed(files) {
714
+ return files.sort((a, b) => {
714
715
  const aDate = new Date(a.lastAccessed || a.addedDate || 0);
715
716
  const bDate = new Date(b.lastAccessed || b.addedDate || 0);
716
717
  return bDate - aDate;
717
718
  });
718
-
719
- return filtered;
720
719
  }
721
720
 
722
- async function loadFileCollection(contextId, contextKey = null, useCache = true, chatId = null) {
723
- if (!contextId) {
721
+ /**
722
+ * Load file collection from one or more contexts (unified function)
723
+ *
724
+ * @param {Array|Object|string} agentContext - Context(s) to load from:
725
+ * - Array of {contextId, contextKey, default} objects (compound context)
726
+ * - Single {contextId, contextKey, default} object
727
+ * - String contextId (contextKey will be null)
728
+ * @param {Object} options - Load options
729
+ * @param {Array<string>|string|null} options.chatIds - Chat IDs to filter by:
730
+ * - If null/undefined/empty array: return ALL files regardless of inCollection
731
+ * - If provided: filter to files where inCollection includes '*' or any of these chatIds
732
+ * @param {boolean} options.useCache - Whether to use cache (default: true)
733
+ * @returns {Promise<Array>} File collection (deduplicated across contexts, sorted by lastAccessed)
734
+ */
735
+ async function loadFileCollection(agentContext, options = {}) {
736
+ // Normalize agentContext to array format
737
+ let contexts = [];
738
+ if (typeof agentContext === 'string') {
739
+ // Single contextId string
740
+ contexts = [{ contextId: agentContext, contextKey: null, default: true }];
741
+ } else if (Array.isArray(agentContext)) {
742
+ contexts = agentContext.filter(ctx => ctx && ctx.contextId);
743
+ } else if (agentContext && typeof agentContext === 'object' && agentContext.contextId) {
744
+ // Single context object
745
+ contexts = [agentContext];
746
+ }
747
+
748
+ if (contexts.length === 0) {
724
749
  return [];
725
750
  }
726
-
727
- const cacheKey = getCollectionCacheKey(contextId, contextKey);
728
-
729
- // Check cache first - cache stores raw parsed file data, so we can filter by chatId from cache
730
- if (useCache && fileCollectionCache.has(cacheKey)) {
731
- const cached = fileCollectionCache.get(cacheKey);
732
- if (Date.now() - cached.timestamp < CACHE_TTL) {
733
- // Apply filtering to cached raw data
734
- return filterAndFormatFileCollection(cached.rawFiles, chatId);
735
- }
751
+
752
+ // Normalize options
753
+ const useCache = options.useCache !== false; // default true
754
+ let chatIds = options.chatIds;
755
+
756
+ // Normalize chatIds to array or null
757
+ if (chatIds === undefined || chatIds === null) {
758
+ chatIds = null; // No filtering
759
+ } else if (typeof chatIds === 'string') {
760
+ chatIds = chatIds.trim() ? [chatIds] : null;
761
+ } else if (Array.isArray(chatIds)) {
762
+ chatIds = chatIds.filter(id => id && typeof id === 'string' && id.trim());
763
+ if (chatIds.length === 0) chatIds = null;
764
+ } else {
765
+ chatIds = null;
736
766
  }
737
-
738
- // Load from context-scoped Redis hash map (FileStoreMap:ctx:<contextId>)
739
- let rawFiles = [];
740
767
 
741
- try {
742
- const redisClient = await getRedisClient();
768
+ // Load files from all contexts
769
+ let allFiles = [];
770
+
771
+ for (const ctx of contexts) {
772
+ const contextId = ctx.contextId;
773
+ const contextKey = ctx.contextKey || null;
774
+ const cacheKey = getCollectionCacheKey(contextId, contextKey);
743
775
 
744
- if (redisClient) {
745
- const contextMapKey = `FileStoreMap:ctx:${contextId}`;
746
- const allFiles = await redisClient.hgetall(contextMapKey);
747
-
748
- // Parse raw file data (preserves inCollection metadata for filtering)
749
- // Pass contextKey for decryption
750
- rawFiles = parseRawFileData(allFiles, contextKey);
776
+ let rawFiles = [];
777
+
778
+ // Check cache first
779
+ if (useCache && fileCollectionCache.has(cacheKey)) {
780
+ const cached = fileCollectionCache.get(cacheKey);
781
+ if (Date.now() - cached.timestamp < CACHE_TTL) {
782
+ rawFiles = cached.rawFiles;
783
+ }
751
784
  }
752
- } catch (e) {
753
- // Collection doesn't exist yet or error reading, start with empty array
754
- rawFiles = [];
755
- }
756
-
757
- // Update cache with raw file data (supports any filtering on retrieval)
758
- if (useCache) {
759
- fileCollectionCache.set(cacheKey, {
760
- rawFiles: rawFiles,
761
- timestamp: Date.now()
762
- });
763
- }
764
-
765
- // Filter and format for return
766
- return filterAndFormatFileCollection(rawFiles, chatId);
767
- }
768
-
769
- /**
770
- * Load ALL files from a context's file collection, bypassing inCollection filtering.
771
- * Used when merging alt contexts where we want all files regardless of chat scope.
772
- * @param {string} contextId - Context ID
773
- * @param {string|null} contextKey - Optional encryption key
774
- * @returns {Promise<Array>} All files in the collection
775
- */
776
- async function loadFileCollectionAll(contextId, contextKey = null) {
777
- if (!contextId) {
778
- return [];
779
- }
780
-
781
- try {
782
- const redisClient = await getRedisClient();
783
785
 
784
- if (redisClient) {
785
- const contextMapKey = `FileStoreMap:ctx:${contextId}`;
786
- const allFiles = await redisClient.hgetall(contextMapKey);
787
-
788
- // Parse raw file data
789
- const rawFiles = parseRawFileData(allFiles, contextKey);
790
-
791
- // Return all files without inCollection filtering (keep inCollection for reference counting)
792
- // Sort by lastAccessed (most recent first)
793
- rawFiles.sort((a, b) => {
794
- const aDate = new Date(a.lastAccessed || a.addedDate || 0);
795
- const bDate = new Date(b.lastAccessed || b.addedDate || 0);
796
- return bDate - aDate;
797
- });
798
-
799
- return rawFiles;
786
+ // Load from Redis if not cached
787
+ if (rawFiles.length === 0) {
788
+ try {
789
+ const redisClient = await getRedisClient();
790
+ if (redisClient) {
791
+ const contextMapKey = `FileStoreMap:ctx:${contextId}`;
792
+ const filesData = await redisClient.hgetall(contextMapKey);
793
+ rawFiles = parseRawFileData(filesData, contextKey);
794
+
795
+ // Update cache
796
+ if (useCache) {
797
+ fileCollectionCache.set(cacheKey, {
798
+ rawFiles: rawFiles,
799
+ timestamp: Date.now()
800
+ });
801
+ }
802
+ }
803
+ } catch (e) {
804
+ // Collection doesn't exist yet or error reading
805
+ rawFiles = [];
806
+ }
800
807
  }
801
- } catch (e) {
802
- // Collection doesn't exist yet or error reading
808
+
809
+ // Tag files with their source context
810
+ allFiles.push(...rawFiles.map(f => ({ ...f, _contextId: contextId })));
811
+ }
812
+
813
+ // Deduplicate by hash/url/gcs (keep first occurrence - primary context wins)
814
+ const seenHashes = new Set();
815
+ const seenUrls = new Set();
816
+ const seenGcs = new Set();
817
+ const deduped = [];
818
+
819
+ for (const file of allFiles) {
820
+ const isDupe = (file.hash && seenHashes.has(file.hash)) ||
821
+ (file.url && seenUrls.has(file.url)) ||
822
+ (file.gcs && seenGcs.has(file.gcs));
823
+ if (!isDupe) {
824
+ if (file.hash) seenHashes.add(file.hash);
825
+ if (file.url) seenUrls.add(file.url);
826
+ if (file.gcs) seenGcs.add(file.gcs);
827
+ deduped.push(file);
828
+ }
829
+ }
830
+
831
+ // Apply filtering
832
+ let filtered;
833
+ if (chatIds !== null) {
834
+ // Filter to files that are in collection for any of the provided chatIds
835
+ filtered = deduped.filter(file => {
836
+ // Check if file is accessible for any of the chatIds
837
+ for (const chatId of chatIds) {
838
+ if (isFileInCollection(file.inCollection, chatId)) {
839
+ return true;
840
+ }
841
+ }
842
+ return false;
843
+ });
844
+ } else {
845
+ // If chatIds is null, return ALL files
846
+ // Valid inCollection is an array with strings (or boolean true for backward compat)
847
+ // Files with inCollection === undefined/null/[] are included (Labeeb uploads, not in collection - treat the same)
848
+ // Note: false is not a valid value (normalizes to undefined), so we shouldn't see it in data
849
+ filtered = deduped.filter(file => {
850
+ const ic = file.inCollection;
851
+ // Include undefined/null/empty array (Labeeb uploads, not in collection - treat the same)
852
+ if (ic === undefined || ic === null || (Array.isArray(ic) && ic.length === 0)) return true;
853
+ // Include boolean true (backward compat - global)
854
+ if (ic === true) return true;
855
+ // Include valid arrays (non-empty arrays with strings)
856
+ if (Array.isArray(ic) && ic.length > 0) return true;
857
+ // Exclude everything else (invalid formats - defensive programming)
858
+ return false;
859
+ });
803
860
  }
804
861
 
805
- return [];
862
+ return sortFilesByLastAccessed(filtered);
806
863
  }
807
864
 
808
865
  /**
809
866
  * Normalize inCollection value to array format
810
867
  * @param {boolean|Array<string>|undefined} inCollection - inCollection value to normalize
811
- * @returns {Array<string>|undefined} Normalized array or undefined if false/null
868
+ * @returns {Array<string>|undefined} Normalized array, or undefined if false/null/undefined/empty array
812
869
  */
813
870
  function normalizeInCollection(inCollection) {
814
- // If explicitly false or null, return undefined (file not in collection)
815
- if (inCollection === false || inCollection === null) {
871
+ // If explicitly false, return undefined (not in collection, same as undefined)
872
+ if (inCollection === false) {
816
873
  return undefined;
817
874
  }
818
875
 
819
- // If undefined, return undefined (preserve existing state)
820
- if (inCollection === undefined) {
876
+ // If null or undefined, return undefined (file not in collection)
877
+ if (inCollection === null || inCollection === undefined) {
878
+ return undefined;
879
+ }
880
+
881
+ // If empty array, return undefined (not in collection, same as undefined)
882
+ if (Array.isArray(inCollection) && inCollection.length === 0) {
821
883
  return undefined;
822
884
  }
823
885
 
@@ -947,6 +1009,7 @@ async function updateFileMetadata(contextId, hash, metadata, contextKey = null,
947
1009
  const fileData = {
948
1010
  ...existingData, // Preserve all CFH data (url, gcs, hash, filename, etc.)
949
1011
  // Handle inCollection: normalize if provided, otherwise preserve existing or default based on chatId
1012
+ // normalizeInCollection converts false/null/undefined/[] to undefined (no collection membership)
950
1013
  inCollection: metadata.inCollection !== undefined
951
1014
  ? normalizeInCollection(metadata.inCollection)
952
1015
  : (existingData.inCollection !== undefined
@@ -964,6 +1027,7 @@ async function updateFileMetadata(contextId, hash, metadata, contextKey = null,
964
1027
  };
965
1028
 
966
1029
  // Remove inCollection if it's undefined (file not in collection)
1030
+ // Empty arrays [] are normalized to undefined, so they get deleted too
967
1031
  if (fileData.inCollection === undefined) {
968
1032
  delete fileData.inCollection;
969
1033
  }
@@ -1377,7 +1441,7 @@ async function getAvailableFilesFromCollection(contextId, contextKey = null) {
1377
1441
  return 'No files available.';
1378
1442
  }
1379
1443
 
1380
- const collection = await loadFileCollection(contextId, contextKey, true);
1444
+ const collection = await loadFileCollection({ contextId, contextKey, default: true });
1381
1445
  return formatFilesForTemplate(collection);
1382
1446
  }
1383
1447
 
@@ -1440,81 +1504,6 @@ function getDefaultContext(agentContext) {
1440
1504
  return agentContext.find(ctx => ctx.default === true) || agentContext[0] || null;
1441
1505
  }
1442
1506
 
1443
- /**
1444
- * Load merged file collection from agentContext array
1445
- * Merges all contexts in the array for read operations
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)
1448
- * @returns {Promise<Array>} Merged file collection
1449
- */
1450
- async function loadMergedFileCollection(agentContext, chatId = null) {
1451
- if (!agentContext || !Array.isArray(agentContext) || agentContext.length === 0) {
1452
- return [];
1453
- }
1454
-
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)
1458
- const primaryCtx = agentContext[0];
1459
- const primaryCollection = chatId
1460
- ? await loadFileCollection(primaryCtx.contextId, primaryCtx.contextKey || null, true, chatId)
1461
- : await loadFileCollectionAll(primaryCtx.contextId, primaryCtx.contextKey || null);
1462
-
1463
- // Tag primary files with their source context
1464
- let collection = primaryCollection.map(f => ({ ...f, _contextId: primaryCtx.contextId }));
1465
-
1466
- // Load and merge additional contexts
1467
- for (let i = 1; i < agentContext.length; i++) {
1468
- const ctx = agentContext[i];
1469
- if (!ctx.contextId) continue;
1470
-
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);
1477
-
1478
- // Build set of existing identifiers from current collection
1479
- const existingHashes = new Set(collection.map(f => f.hash).filter(Boolean));
1480
- const existingUrls = new Set(collection.map(f => f.url).filter(Boolean));
1481
- const existingGcs = new Set(collection.map(f => f.gcs).filter(Boolean));
1482
-
1483
- // Add files from alt collection that aren't already in collection, tagged with alt context
1484
- for (const file of altCollection) {
1485
- const isDupe = (file.hash && existingHashes.has(file.hash)) ||
1486
- (file.url && existingUrls.has(file.url)) ||
1487
- (file.gcs && existingGcs.has(file.gcs));
1488
- if (!isDupe) {
1489
- collection.push({ ...file, _contextId: ctx.contextId });
1490
- }
1491
- }
1492
- }
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
-
1515
- return collection;
1516
- }
1517
-
1518
1507
  /**
1519
1508
  * Get available files from file collection (no syncing from chat history)
1520
1509
  * @param {Array} chatHistory - Unused, kept for API compatibility
@@ -1525,7 +1514,8 @@ async function getAvailableFiles(chatHistory, agentContext) {
1525
1514
  if (!agentContext || !Array.isArray(agentContext) || agentContext.length === 0) {
1526
1515
  return 'No files available.';
1527
1516
  }
1528
- const collection = await loadMergedFileCollection(agentContext);
1517
+ // Load all files from all contexts (no chatIds = no filtering)
1518
+ const collection = await loadFileCollection(agentContext);
1529
1519
  // Strip internal _contextId before formatting
1530
1520
  const cleanCollection = collection.map(({ _contextId, ...file }) => file);
1531
1521
  return formatFilesForTemplate(cleanCollection);
@@ -1551,16 +1541,32 @@ async function syncAndStripFilesFromChatHistory(chatHistory, agentContext, chatI
1551
1541
  return { chatHistory, availableFiles: 'No files available.' };
1552
1542
  }
1553
1543
 
1554
- // Load merged collection once
1555
- const collection = await loadMergedFileCollection(agentContext);
1544
+ // Load ALL files from all contexts (no chatIds = no filtering)
1545
+ // This finds files even if they don't have inCollection set (Labeeb uploads)
1546
+ const allFiles = await loadFileCollection(agentContext);
1547
+
1548
+ // For availableFiles output, filter to only files with inCollection set
1549
+ const collection = chatId
1550
+ ? await loadFileCollection(agentContext, { chatIds: [chatId] })
1551
+ : allFiles.filter(file => {
1552
+ const inCollection = file.inCollection;
1553
+ if (inCollection === undefined || inCollection === null || inCollection === false || inCollection === '') {
1554
+ return false;
1555
+ }
1556
+ if (Array.isArray(inCollection) && inCollection.length === 0) {
1557
+ return false;
1558
+ }
1559
+ return true;
1560
+ });
1556
1561
 
1557
1562
  // Build lookup map from contextId to contextKey for updates
1558
1563
  const contextKeyMap = new Map(agentContext.map(ctx => [ctx.contextId, ctx.contextKey || null]));
1559
1564
 
1560
- // Build lookup maps for fast matching and context lookup (need Maps, not Sets, to get full file object)
1561
- const collectionByHash = new Map(collection.filter(f => f.hash).map(f => [f.hash, f]));
1562
- const collectionByUrl = new Map(collection.filter(f => f.url).map(f => [f.url, f]));
1563
- const collectionByGcs = new Map(collection.filter(f => f.gcs).map(f => [f.gcs, f]));
1565
+ // Build lookup maps for fast matching and context lookup (use ALL files, not just filtered)
1566
+ // This allows us to find files that exist in Redis but don't have inCollection set yet
1567
+ const collectionByHash = new Map(allFiles.filter(f => f.hash).map(f => [f.hash, f]));
1568
+ const collectionByUrl = new Map(allFiles.filter(f => f.url).map(f => [f.url, f]));
1569
+ const collectionByGcs = new Map(allFiles.filter(f => f.gcs).map(f => [f.gcs, f]));
1564
1570
 
1565
1571
  // Helper to get file from collection (by hash, URL, or GCS) to find _contextId
1566
1572
  const getFileFromCollection = (contentObj) => {
@@ -1600,11 +1606,14 @@ async function syncAndStripFilesFromChatHistory(chatHistory, agentContext, chatI
1600
1606
  const now = new Date().toISOString();
1601
1607
  // Update lastAccessed and add chatId to inCollection (reference counting)
1602
1608
  // If this file is being used in a new chat, add that chat to the list
1603
- const updatedInCollection = addChatIdToInCollection(file.inCollection, chatId);
1609
+ // If file doesn't have inCollection set yet (e.g., uploaded by Labeeb), initialize it
1610
+ const updatedInCollection = file.inCollection
1611
+ ? addChatIdToInCollection(file.inCollection, chatId)
1612
+ : getInCollectionValue(chatId);
1604
1613
  updateFileMetadata(file._contextId, hash, {
1605
1614
  lastAccessed: now,
1606
1615
  inCollection: updatedInCollection
1607
- }, fileContextKey).catch((err) => {
1616
+ }, fileContextKey, chatId).catch((err) => {
1608
1617
  logger.warn(`Failed to update metadata for stripped file (hash=${hash}): ${err?.message || err}`);
1609
1618
  });
1610
1619
  };
@@ -1626,8 +1635,8 @@ async function syncAndStripFilesFromChatHistory(chatHistory, agentContext, chatI
1626
1635
  const filename = extractFilenameFromFileContent(contentObj);
1627
1636
  return { type: 'text', text: `[File: ${filename} - available via file tools]` };
1628
1637
  }
1629
- // Not in collection - leave as-is
1630
- return item;
1638
+ // Not in collection - leave as-is (use parsed object if it was parsed from string)
1639
+ return contentObj;
1631
1640
  }
1632
1641
  return item;
1633
1642
  });
@@ -1817,9 +1826,8 @@ export async function resolveFileParameter(fileParam, agentContext, options = {}
1817
1826
  }
1818
1827
 
1819
1828
  try {
1820
- // Load merged file collection (always use merged to get all files, not just global ones)
1821
- // Note: useCache option is ignored for merged collections (they always load fresh)
1822
- const collection = await loadMergedFileCollection(agentContext);
1829
+ // Load all files from all contexts (no chatIds = no filtering)
1830
+ const collection = await loadFileCollection(agentContext);
1823
1831
 
1824
1832
  const foundFile = findFileInCollection(trimmed, collection);
1825
1833
 
@@ -1875,8 +1883,9 @@ async function generateFileMessageContent(fileParam, agentContext) {
1875
1883
  return null;
1876
1884
  }
1877
1885
 
1878
- // Load merged file collection
1879
- const collection = await loadMergedFileCollection(agentContext);
1886
+ // Load all files from all contexts (no chatIds = no filtering)
1887
+ // Use useCache: false to ensure we get fresh data (important for recently added files)
1888
+ const collection = await loadFileCollection(agentContext, { useCache: false });
1880
1889
 
1881
1890
  // Find the file using shared matching logic
1882
1891
  const foundFile = findFileInCollection(fileParam, collection);
@@ -2588,7 +2597,6 @@ export {
2588
2597
  extractFilesFromChatHistory,
2589
2598
  getAvailableFilesFromCollection,
2590
2599
  getDefaultContext,
2591
- loadMergedFileCollection,
2592
2600
  formatFilesForTemplate,
2593
2601
  getAvailableFiles,
2594
2602
  syncAndStripFilesFromChatHistory,
@@ -2598,7 +2606,6 @@ export {
2598
2606
  injectFileIntoChatHistory,
2599
2607
  addFileToCollection,
2600
2608
  loadFileCollection,
2601
- loadFileCollectionAll,
2602
2609
  saveFileCollection,
2603
2610
  updateFileMetadata,
2604
2611
  getCollectionCacheKey,
@@ -349,6 +349,7 @@ class PathwayManager {
349
349
  const promptName = typeof promptItem === 'string' ? defaultName : (promptItem.name || defaultName);
350
350
  const promptFiles = typeof promptItem === 'string' ? [] : (promptItem.files || []);
351
351
  const cortexPathwayName = typeof promptItem === 'string' ? null : (promptItem.cortexPathwayName || null);
352
+ const researchMode = typeof promptItem === 'string' ? undefined : (promptItem.researchMode !== undefined ? promptItem.researchMode : undefined);
352
353
 
353
354
  const messages = [];
354
355
 
@@ -383,6 +384,11 @@ class PathwayManager {
383
384
  prompt.cortexPathwayName = cortexPathwayName;
384
385
  }
385
386
 
387
+ // Preserve researchMode if present
388
+ if (researchMode !== undefined) {
389
+ prompt.researchMode = researchMode;
390
+ }
391
+
386
392
  return prompt;
387
393
  }
388
394
 
@@ -460,6 +466,7 @@ class PathwayManager {
460
466
  prompt: String!
461
467
  files: [String!]
462
468
  cortexPathwayName: String
469
+ researchMode: Boolean
463
470
  }
464
471
 
465
472
  input PathwayInput {
@@ -6,6 +6,7 @@ import { getSemanticChunks } from "../server/chunker.js";
6
6
  import logger from '../lib/logger.js';
7
7
  import { requestState } from '../server/requestState.js';
8
8
  import { processPathwayParameters } from '../server/typeDef.js';
9
+ import { waitForClientToolResult } from '../server/clientToolCallbacks.js';
9
10
 
10
11
  // callPathway - call a pathway from another pathway
11
12
  const callPathway = async (pathwayName, inArgs, pathwayResolver) => {
@@ -91,6 +92,76 @@ const callTool = async (toolName, args, toolDefinitions, pathwayResolver) => {
91
92
  logger.debug(`callTool: Starting execution of ${toolName} ${JSON.stringify(logArgs)}`);
92
93
 
93
94
  try {
95
+ // Check if this is a client-side tool
96
+ if (toolDef.clientSide === true || toolDef.definition?.clientSide === true) {
97
+ logger.info(`Tool ${toolName} is a client-side tool - waiting for client execution`);
98
+
99
+ const toolCallbackId = `${toolName}_${Date.now()}_${Math.random().toString(36).substring(7)}`;
100
+
101
+ // Explicitly publish the marker to the stream so the client receives it
102
+ if (pathwayResolver) {
103
+ const requestId = pathwayResolver.rootRequestId || pathwayResolver.requestId;
104
+
105
+ const toolCallbackData = {
106
+ toolUsed: [toolName],
107
+ clientSideTool: true,
108
+ toolCallbackName: toolName,
109
+ toolCallbackId: toolCallbackId,
110
+ toolCallbackMessage: args.userMessage || `Executing ${toolName}...`,
111
+ chatId: args.chatId || "",
112
+ requestId: requestId, // Include requestId so client can submit tool results
113
+ toolArgs: args
114
+ };
115
+
116
+ try {
117
+ logger.info(`Publishing client-side tool marker to requestId: ${requestId}, toolCallbackId: ${toolCallbackId}`);
118
+ await publishRequestProgress({
119
+ requestId,
120
+ progress: 0.5,
121
+ data: JSON.stringify(""),
122
+ info: JSON.stringify(toolCallbackData)
123
+ });
124
+ } catch (error) {
125
+ logger.error(`Error publishing client-side tool marker: ${error.message}`);
126
+ throw error;
127
+ }
128
+
129
+ // Wait for the client to execute the tool and send back the result
130
+ logger.info(`Waiting for client tool result: ${toolCallbackId}`);
131
+ try {
132
+ // Use 5 minute timeout to accommodate longer operations like CreateApplet
133
+ const clientResult = await waitForClientToolResult(toolCallbackId, requestId, 300000);
134
+ logger.info(`Received client tool result for ${toolCallbackId}: ${JSON.stringify(clientResult).substring(0, 200)}`);
135
+
136
+ // If the client reported an error, throw it
137
+ if (!clientResult.success) {
138
+ throw new Error(clientResult.error || 'Client tool execution failed');
139
+ }
140
+
141
+ // Return the client's result
142
+ toolResult = typeof clientResult.data === 'string'
143
+ ? clientResult.data
144
+ : JSON.stringify(clientResult.data);
145
+
146
+ // Update resolver with tool result
147
+ pathwayResolver.tool = JSON.stringify({
148
+ ...toolCallbackData,
149
+ result: clientResult.data
150
+ });
151
+
152
+ return {
153
+ result: toolResult,
154
+ images: []
155
+ };
156
+ } catch (error) {
157
+ logger.error(`Error waiting for client tool result: ${error.message}`);
158
+ throw new Error(`Client tool execution failed: ${error.message}`);
159
+ }
160
+ } else {
161
+ throw new Error('PathwayResolver is required for client-side tools');
162
+ }
163
+ }
164
+
94
165
  const pathwayName = toolDef.pathwayName;
95
166
  // Merge hard-coded pathway parameters with runtime args
96
167
  const mergedArgs = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aj-archipelago/cortex",
3
- "version": "1.4.30",
3
+ "version": "1.4.32",
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": {
@@ -3,7 +3,7 @@
3
3
  // File collections are stored in Redis hash maps (FileStoreMap:ctx:<contextId>
4
4
  // Returns file collection as JSON array string for backward compatibility with Labeeb
5
5
 
6
- import { loadMergedFileCollection } from '../../../../lib/fileUtils.js';
6
+ import { loadFileCollection } from '../../../../lib/fileUtils.js';
7
7
 
8
8
  export default {
9
9
  inputParameters: {
@@ -33,8 +33,8 @@ export default {
33
33
  }
34
34
 
35
35
  try {
36
- // Load file collection from Redis hash maps (merged from all agentContext contexts)
37
- const collection = await loadMergedFileCollection(agentContext);
36
+ // Load file collection from Redis hash maps (from all agentContext contexts)
37
+ const collection = await loadFileCollection(agentContext);
38
38
 
39
39
  // Return as JSON array string for backward compatibility with Labeeb
40
40
  // Labeeb expects either: [] or { version: "...", files: [...] }