diff options
Diffstat (limited to 'packages/server/src/utils/memoryDiscovery.ts')
| -rw-r--r-- | packages/server/src/utils/memoryDiscovery.ts | 351 |
1 files changed, 351 insertions, 0 deletions
diff --git a/packages/server/src/utils/memoryDiscovery.ts b/packages/server/src/utils/memoryDiscovery.ts new file mode 100644 index 00000000..362134d8 --- /dev/null +++ b/packages/server/src/utils/memoryDiscovery.ts @@ -0,0 +1,351 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs/promises'; +import * as fsSync from 'fs'; +import * as path from 'path'; +import { homedir } from 'os'; +import { GEMINI_CONFIG_DIR, GEMINI_MD_FILENAME } from '../tools/memoryTool.js'; + +// Simple console logger, similar to the one previously in CLI's config.ts +// TODO: Integrate with a more robust server-side logger if available/appropriate. +const logger = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + debug: (...args: any[]) => + console.debug('[DEBUG] [MemoryDiscovery]', ...args), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + warn: (...args: any[]) => console.warn('[WARN] [MemoryDiscovery]', ...args), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: (...args: any[]) => + console.error('[ERROR] [MemoryDiscovery]', ...args), +}; + +// TODO(adh): Refactor to use a shared ignore list with other tools like glob and read-many-files. +const DEFAULT_IGNORE_DIRECTORIES = [ + 'node_modules', + '.git', + 'dist', + 'build', + 'out', + 'coverage', + '.vscode', + '.idea', + '.DS_Store', +]; + +const MAX_DIRECTORIES_TO_SCAN_FOR_MEMORY = 200; + +interface GeminiFileContent { + filePath: string; + content: string | null; +} + +async function findProjectRoot(startDir: string): Promise<string | null> { + let currentDir = path.resolve(startDir); + while (true) { + const gitPath = path.join(currentDir, '.git'); + try { + const stats = await fs.stat(gitPath); + if (stats.isDirectory()) { + return currentDir; + } + } catch (error: unknown) { + if (typeof error === 'object' && error !== null && 'code' in error) { + const fsError = error as { code: string; message: string }; + if (fsError.code !== 'ENOENT') { + logger.warn( + `Error checking for .git directory at ${gitPath}: ${fsError.message}`, + ); + } + } else { + logger.warn( + `Non-standard error checking for .git directory at ${gitPath}: ${String(error)}`, + ); + } + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +async function collectDownwardGeminiFiles( + directory: string, + debugMode: boolean, + ignoreDirs: string[], + scannedDirCount: { count: number }, + maxScanDirs: number, +): Promise<string[]> { + if (scannedDirCount.count >= maxScanDirs) { + if (debugMode) + logger.debug( + `Max directory scan limit (${maxScanDirs}) reached. Stopping downward scan at: ${directory}`, + ); + return []; + } + scannedDirCount.count++; + + if (debugMode) + logger.debug( + `Scanning downward for ${GEMINI_MD_FILENAME} files in: ${directory} (scanned: ${scannedDirCount.count}/${maxScanDirs})`, + ); + const collectedPaths: string[] = []; + try { + const entries = await fs.readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (ignoreDirs.includes(entry.name)) { + if (debugMode) + logger.debug(`Skipping ignored directory: ${fullPath}`); + continue; + } + const subDirPaths = await collectDownwardGeminiFiles( + fullPath, + debugMode, + ignoreDirs, + scannedDirCount, + maxScanDirs, + ); + collectedPaths.push(...subDirPaths); + } else if (entry.isFile() && entry.name === GEMINI_MD_FILENAME) { + try { + await fs.access(fullPath, fsSync.constants.R_OK); + collectedPaths.push(fullPath); + if (debugMode) + logger.debug( + `Found readable downward ${GEMINI_MD_FILENAME}: ${fullPath}`, + ); + } catch { + if (debugMode) + logger.debug( + `Downward ${GEMINI_MD_FILENAME} not readable, skipping: ${fullPath}`, + ); + } + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Error scanning directory ${directory}: ${message}`); + if (debugMode) logger.debug(`Failed to scan directory: ${directory}`); + } + return collectedPaths; +} + +async function getGeminiMdFilePathsInternal( + currentWorkingDirectory: string, + userHomePath: string, // Keep userHomePath as a parameter for clarity + debugMode: boolean, +): Promise<string[]> { + const resolvedCwd = path.resolve(currentWorkingDirectory); + const resolvedHome = path.resolve(userHomePath); + const globalMemoryPath = path.join( + resolvedHome, + GEMINI_CONFIG_DIR, + GEMINI_MD_FILENAME, + ); + const paths: string[] = []; + + if (debugMode) + logger.debug( + `Searching for ${GEMINI_MD_FILENAME} starting from CWD: ${resolvedCwd}`, + ); + if (debugMode) logger.debug(`User home directory: ${resolvedHome}`); + + try { + await fs.access(globalMemoryPath, fsSync.constants.R_OK); + paths.push(globalMemoryPath); + if (debugMode) + logger.debug( + `Found readable global ${GEMINI_MD_FILENAME}: ${globalMemoryPath}`, + ); + } catch { + if (debugMode) + logger.debug( + `Global ${GEMINI_MD_FILENAME} not found or not readable: ${globalMemoryPath}`, + ); + } + + const projectRoot = await findProjectRoot(resolvedCwd); + if (debugMode) + logger.debug(`Determined project root: ${projectRoot ?? 'None'}`); + + const upwardPaths: string[] = []; + let currentDir = resolvedCwd; + // Determine the directory that signifies the top of the project or user-specific space. + const ultimateStopDir = projectRoot + ? path.dirname(projectRoot) + : path.dirname(resolvedHome); + + while (currentDir && currentDir !== path.dirname(currentDir)) { + // Loop until filesystem root or currentDir is empty + if (debugMode) { + logger.debug( + `Checking for ${GEMINI_MD_FILENAME} in (upward scan): ${currentDir}`, + ); + } + + // Skip the global .gemini directory itself during upward scan from CWD, + // as global is handled separately and explicitly first. + if (currentDir === path.join(resolvedHome, GEMINI_CONFIG_DIR)) { + if (debugMode) { + logger.debug( + `Upward scan reached global config dir path, stopping upward search here: ${currentDir}`, + ); + } + break; + } + + const potentialPath = path.join(currentDir, GEMINI_MD_FILENAME); + try { + await fs.access(potentialPath, fsSync.constants.R_OK); + // Add to upwardPaths only if it's not the already added globalMemoryPath + if (potentialPath !== globalMemoryPath) { + upwardPaths.unshift(potentialPath); + if (debugMode) { + logger.debug( + `Found readable upward ${GEMINI_MD_FILENAME}: ${potentialPath}`, + ); + } + } + } catch { + if (debugMode) { + logger.debug( + `Upward ${GEMINI_MD_FILENAME} not found or not readable in: ${currentDir}`, + ); + } + } + + // Stop condition: if currentDir is the ultimateStopDir, break after this iteration. + if (currentDir === ultimateStopDir) { + if (debugMode) + logger.debug( + `Reached ultimate stop directory for upward scan: ${currentDir}`, + ); + break; + } + + currentDir = path.dirname(currentDir); + } + paths.push(...upwardPaths); + + if (debugMode) + logger.debug(`Starting downward scan from CWD: ${resolvedCwd}`); + const scannedDirCount = { count: 0 }; + const downwardPaths = await collectDownwardGeminiFiles( + resolvedCwd, + debugMode, + DEFAULT_IGNORE_DIRECTORIES, + scannedDirCount, + MAX_DIRECTORIES_TO_SCAN_FOR_MEMORY, + ); + downwardPaths.sort(); // Sort for consistent ordering, though hierarchy might be more complex + if (debugMode && downwardPaths.length > 0) + logger.debug( + `Found downward ${GEMINI_MD_FILENAME} files (sorted): ${JSON.stringify(downwardPaths)}`, + ); + // Add downward paths only if they haven't been included already (e.g. from upward scan) + for (const dPath of downwardPaths) { + if (!paths.includes(dPath)) { + paths.push(dPath); + } + } + + if (debugMode) + logger.debug( + `Final ordered ${GEMINI_MD_FILENAME} paths to read: ${JSON.stringify(paths)}`, + ); + return paths; +} + +async function readGeminiMdFiles( + filePaths: string[], + debugMode: boolean, +): Promise<GeminiFileContent[]> { + const results: GeminiFileContent[] = []; + for (const filePath of filePaths) { + try { + const content = await fs.readFile(filePath, 'utf-8'); + results.push({ filePath, content }); + if (debugMode) + logger.debug( + `Successfully read: ${filePath} (Length: ${content.length})`, + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.warn( + `Warning: Could not read ${GEMINI_MD_FILENAME} file at ${filePath}. Error: ${message}`, + ); + results.push({ filePath, content: null }); // Still include it with null content + if (debugMode) logger.debug(`Failed to read: ${filePath}`); + } + } + return results; +} + +function concatenateInstructions( + instructionContents: GeminiFileContent[], + // CWD is needed to resolve relative paths for display markers + currentWorkingDirectoryForDisplay: string, +): string { + return instructionContents + .filter((item) => typeof item.content === 'string') + .map((item) => { + const trimmedContent = (item.content as string).trim(); + if (trimmedContent.length === 0) { + return null; + } + const displayPath = path.isAbsolute(item.filePath) + ? path.relative(currentWorkingDirectoryForDisplay, item.filePath) + : item.filePath; + return `--- Context from: ${displayPath} ---\n${trimmedContent}\n--- End of Context from: ${displayPath} ---`; + }) + .filter((block): block is string => block !== null) + .join('\n\n'); +} + +/** + * Loads hierarchical GEMINI.md files and concatenates their content. + * This function is intended for use by the server. + */ +export async function loadServerHierarchicalMemory( + currentWorkingDirectory: string, + debugMode: boolean, +): Promise<{ memoryContent: string; fileCount: number }> { + if (debugMode) + logger.debug( + `Loading server hierarchical memory for CWD: ${currentWorkingDirectory}`, + ); + // For the server, homedir() refers to the server process's home. + // This is consistent with how MemoryTool already finds the global path. + const userHomePath = homedir(); + const filePaths = await getGeminiMdFilePathsInternal( + currentWorkingDirectory, + userHomePath, + debugMode, + ); + if (filePaths.length === 0) { + if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.'); + return { memoryContent: '', fileCount: 0 }; + } + const contentsWithPaths = await readGeminiMdFiles(filePaths, debugMode); + // Pass CWD for relative path display in concatenated content + const combinedInstructions = concatenateInstructions( + contentsWithPaths, + currentWorkingDirectory, + ); + if (debugMode) + logger.debug( + `Combined instructions length: ${combinedInstructions.length}`, + ); + if (debugMode && combinedInstructions.length > 0) + logger.debug( + `Combined instructions (snippet): ${combinedInstructions.substring(0, 500)}...`, + ); + return { memoryContent: combinedInstructions, fileCount: filePaths.length }; +} |
