server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextUtils.ts (130 lines of code) (raw):
import { getUserHomeDir } from '@aws/lsp-core/out/util/path'
import * as path from 'path'
import { sanitizeFilename } from '@aws/lsp-core/out/util/text'
import { RelevantTextDocumentAddition } from './agenticChatTriggerContext'
import { FileDetails, FileList } from '@aws/language-server-runtimes/server-interface'
export interface ContextInfo {
contextCount: {
fileContextCount: number
folderContextCount: number
promptContextCount: number
ruleContextCount: number
codeContextCount: number
}
contextLength: {
fileContextLength: number
ruleContextLength: number
promptContextLength: number
codeContextLength: number
}
}
export const initialContextInfo: ContextInfo = {
contextCount: {
fileContextCount: 0,
folderContextCount: 0,
promptContextCount: 0,
ruleContextCount: 0,
codeContextCount: 0,
},
contextLength: {
fileContextLength: 0,
ruleContextLength: 0,
promptContextLength: 0,
codeContextLength: 0,
},
}
export const promptFileExtension = '.md'
export const additionalContentInnerContextLimit = 8192
export const additionalContentNameLimit = 1024
export const getUserPromptsDirectory = (): string => {
return path.join(getUserHomeDir(), '.aws', 'amazonq', 'prompts')
}
/**
* Creates a secure file path for a new prompt file.
*
* @param promptName - The user-provided name for the prompt
* @returns A sanitized file path within the user prompts directory
*/
export const getNewPromptFilePath = (promptName: string): string => {
const userPromptsDirectory = getUserPromptsDirectory()
const trimmedName = promptName?.trim() || ''
const truncatedName = trimmedName.slice(0, 100)
const safePromptName = truncatedName ? sanitizeFilename(path.basename(truncatedName)) : 'default'
const finalPath = path.join(userPromptsDirectory, `${safePromptName}${promptFileExtension}`)
return finalPath
}
/**
* Merges a RelevantTextDocumentAddition array into a FileList, which is used to display list of context files.
* This function combines document fragments from the same file, merging overlapping
* or consecutive line ranges to create a more compact representation.
*
* @param documents - Array of RelevantTextDocumentAddition objects containing file paths and line ranges
* @returns A FileList object with merged file paths and consolidated line ranges
*
* Ported from https://github.com/aws/aws-toolkit-vscode/blob/master/packages/core/src/codewhispererChat/controllers/chat/controller.ts#L1239
*/
export function mergeRelevantTextDocuments(documents: RelevantTextDocumentAddition[]): FileList {
if (documents.length === 0) {
return { filePaths: [], details: {} }
}
const details: Record<string, FileDetails> = {}
Object.entries(
documents.reduce<Record<string, { first: number; second: number }[]>>((acc, doc) => {
if (!doc.relativeFilePath || doc.startLine === undefined || doc.endLine === undefined) {
return acc // Skip invalid documents
}
if (!acc[doc.relativeFilePath]) {
acc[doc.relativeFilePath] = []
}
acc[doc.relativeFilePath].push({ first: doc.startLine, second: doc.endLine })
return acc
}, {})
).forEach(([relativeFilePath, ranges]) => {
// Sort by startLine
const sortedRanges = ranges.sort((a, b) => a.first - b.first)
const mergedRanges: { first: number; second: number }[] = []
for (const { first, second } of sortedRanges) {
if (mergedRanges.length === 0 || mergedRanges[mergedRanges.length - 1].second < first - 1) {
// If no overlap, add new range
mergedRanges.push({ first, second })
} else {
// Merge overlapping or consecutive ranges
mergedRanges[mergedRanges.length - 1].second = Math.max(
mergedRanges[mergedRanges.length - 1].second,
second
)
}
}
const fullPath = documents.find(doc => doc.relativeFilePath === relativeFilePath)?.path
details[relativeFilePath] = {
fullPath: fullPath,
description: fullPath,
lineRanges: mergedRanges,
}
})
return {
filePaths: Object.keys(details),
details: details,
}
}
/**
* Merges two FileList objects into a single FileList
* @param fileList1 The first FileList
* @param fileList2 The second FileList
* @returns A merged FileList
*/
export function mergeFileLists(fileList1: FileList, fileList2: FileList): FileList {
// Handle empty lists
if (!fileList1.filePaths?.length) {
return fileList2
}
if (!fileList2.filePaths?.length) {
return fileList1
}
// Initialize the result
const mergedFilePaths: string[] = []
const mergedDetails: Record<string, FileDetails> = {}
// Process all files from fileList1
fileList1.filePaths?.forEach(filePath => {
mergedFilePaths.push(filePath)
mergedDetails[filePath] = { ...fileList1.details?.[filePath] }
})
// Process all files from fileList2
fileList2.filePaths?.forEach(filePath => {
// If the file already exists in the merged result, merge the line ranges
if (mergedDetails[filePath]) {
const existingRanges = mergedDetails[filePath].lineRanges || []
const newRanges = fileList2.details?.[filePath].lineRanges || []
// Combine and sort all ranges
const combinedRanges = [...existingRanges, ...newRanges].sort((a, b) => a.first - b.first)
// Merge overlapping ranges
const mergedRanges: Array<{ first: number; second: number }> = []
for (const range of combinedRanges) {
if (mergedRanges.length === 0 || mergedRanges[mergedRanges.length - 1].second < range.first - 1) {
// No overlap, add new range
mergedRanges.push({ ...range })
} else {
// Merge overlapping or consecutive ranges
mergedRanges[mergedRanges.length - 1].second = Math.max(
mergedRanges[mergedRanges.length - 1].second,
range.second
)
}
}
mergedDetails[filePath].lineRanges = mergedRanges
} else {
// If the file doesn't exist in the merged result, add it
mergedFilePaths.push(filePath)
mergedDetails[filePath] = { ...fileList2.details?.[filePath] }
}
})
return {
filePaths: mergedFilePaths,
details: mergedDetails,
}
}