@j0hanz/code-assistant
Advanced tools
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| import { createErrorToolResponse } from './tools.js'; | ||
| export declare function getMaxFileChars(): number; | ||
| export declare function resetMaxFileCharsCacheForTesting(): void; | ||
| export declare function validateFileBudget(content: string): ReturnType<typeof createErrorToolResponse> | undefined; | ||
| export declare const SOURCE_RESOURCE_URI = "internal://file/current"; | ||
| export declare const fileStaleWarningMs: import("./config.js").CachedEnvInt; | ||
| export interface FileSlot { | ||
| filePath: string; | ||
| content: string; | ||
| language: string; | ||
| lineCount: number; | ||
| sizeChars: number; | ||
| cachedAt: number; | ||
| } | ||
| /** Binds file resource notifications to the currently active server instance. */ | ||
| export declare function initFileStore(server: McpServer): void; | ||
| export declare function storeFile(slot: FileSlot): void; | ||
| export declare function getFile(): FileSlot | undefined; | ||
| export declare function hasFile(): boolean; | ||
| /** Test-only: directly set or clear the file slot without emitting resource-updated. */ | ||
| export declare function setFileForTesting(slot: FileSlot | undefined): void; | ||
| export declare function createNoFileError(): ReturnType<typeof createErrorToolResponse>; |
| import { performance } from 'node:perf_hooks'; | ||
| import { createCachedEnvInt } from './config.js'; | ||
| import { formatUsNumber } from './format.js'; | ||
| import { createErrorToolResponse } from './tools.js'; | ||
| // --- File Budget --- | ||
| const DEFAULT_MAX_FILE_CHARS = 120_000; | ||
| const MAX_FILE_CHARS_ENV_VAR = 'MAX_FILE_CHARS'; | ||
| const fileCharsConfig = createCachedEnvInt(MAX_FILE_CHARS_ENV_VAR, DEFAULT_MAX_FILE_CHARS); | ||
| export function getMaxFileChars() { | ||
| return fileCharsConfig.get(); | ||
| } | ||
| export function resetMaxFileCharsCacheForTesting() { | ||
| fileCharsConfig.reset(); | ||
| } | ||
| const BUDGET_ERROR_META = { retryable: false, kind: 'budget' }; | ||
| export function validateFileBudget(content) { | ||
| const providedChars = content.length; | ||
| const maxChars = getMaxFileChars(); | ||
| if (providedChars <= maxChars) { | ||
| return undefined; | ||
| } | ||
| return createErrorToolResponse('E_INPUT_TOO_LARGE', `File exceeds max allowed size (${formatUsNumber(providedChars)} chars > ${formatUsNumber(maxChars)} chars)`, { providedChars, maxChars }, BUDGET_ERROR_META); | ||
| } | ||
| // --- File Store --- | ||
| export const SOURCE_RESOURCE_URI = 'internal://file/current'; | ||
| const fileCacheTtlMs = createCachedEnvInt('FILE_CACHE_TTL_MS', 60 * 60 * 1_000 // 1 hour default | ||
| ); | ||
| export const fileStaleWarningMs = createCachedEnvInt('FILE_STALE_WARNING_MS', 5 * 60 * 1_000 // 5 minutes default | ||
| ); | ||
| let currentSlot; | ||
| let sendResourceUpdated; | ||
| function notifyFileUpdated() { | ||
| void sendResourceUpdated?.({ uri: SOURCE_RESOURCE_URI }).catch(() => { | ||
| // Ignore errors sending resource-updated | ||
| }); | ||
| } | ||
| /** Binds file resource notifications to the currently active server instance. */ | ||
| export function initFileStore(server) { | ||
| sendResourceUpdated = (params) => server.server.sendResourceUpdated(params); | ||
| } | ||
| export function storeFile(slot) { | ||
| currentSlot = slot; | ||
| notifyFileUpdated(); | ||
| } | ||
| export function getFile() { | ||
| if (!currentSlot) { | ||
| return undefined; | ||
| } | ||
| const age = performance.now() - currentSlot.cachedAt; | ||
| if (age > fileCacheTtlMs.get()) { | ||
| currentSlot = undefined; | ||
| notifyFileUpdated(); | ||
| return undefined; | ||
| } | ||
| return currentSlot; | ||
| } | ||
| export function hasFile() { | ||
| return getFile() !== undefined; | ||
| } | ||
| /** Test-only: directly set or clear the file slot without emitting resource-updated. */ | ||
| export function setFileForTesting(slot) { | ||
| currentSlot = slot; | ||
| } | ||
| export function createNoFileError() { | ||
| return createErrorToolResponse('E_NO_FILE', 'No file cached. You must call the load_file tool before using any file analysis tool. Run load_file with the absolute path to the file, then retry this tool.', undefined, { retryable: false, kind: 'validation' }); | ||
| } |
| export interface DiffCacheSlot { | ||
| cacheName: string; | ||
| model: string; | ||
| createdAt: number; | ||
| } | ||
| export declare function isDiffCacheEnabled(): boolean; | ||
| export declare function shouldCacheDiff(diffLength: number): boolean; | ||
| /** | ||
| * Create a Gemini context cache containing the given diff text. | ||
| * Returns the cache slot on success, or undefined on failure (logged). | ||
| */ | ||
| export declare function createDiffCache(diff: string, model?: string): Promise<DiffCacheSlot | undefined>; | ||
| /** | ||
| * Returns the current diff cache slot if the model matches. | ||
| * Falls back to undefined when: | ||
| * - No cache exists | ||
| * - The cache was created for a different model | ||
| */ | ||
| export declare function getCurrentDiffCache(model?: string): DiffCacheSlot | undefined; | ||
| /** | ||
| * Delete the current diff cache from the Gemini API and clear local state. | ||
| * Best-effort: errors are logged but not thrown. | ||
| */ | ||
| export declare function deleteDiffCache(): Promise<void>; | ||
| /** Clear local cache state without calling the API. */ | ||
| export declare function clearDiffCacheLocal(): void; | ||
| /** Expose for testing. */ | ||
| export declare function setDiffCacheForTesting(slot: DiffCacheSlot | undefined): void; |
| import { debuglog } from 'node:util'; | ||
| import { getClient } from './client.js'; | ||
| import { getDefaultModel } from './config.js'; | ||
| // --------------------------------------------------------------------------- | ||
| // Environment configuration | ||
| // --------------------------------------------------------------------------- | ||
| const GEMINI_DIFF_CACHE_ENABLED_ENV = 'GEMINI_DIFF_CACHE_ENABLED'; | ||
| const GEMINI_DIFF_CACHE_TTL_S_ENV = 'GEMINI_DIFF_CACHE_TTL_S'; | ||
| const DEFAULT_CACHE_TTL_S = 3600; // 1 hour | ||
| const MIN_DIFF_CHARS_FOR_CACHING = 30_000; | ||
| const debug = debuglog('gemini:cache'); | ||
| let currentCache; | ||
| // --------------------------------------------------------------------------- | ||
| // Helpers | ||
| // --------------------------------------------------------------------------- | ||
| export function isDiffCacheEnabled() { | ||
| const value = process.env[GEMINI_DIFF_CACHE_ENABLED_ENV]; | ||
| return value === '1' || value === 'true'; | ||
| } | ||
| function getCacheTtlSeconds() { | ||
| const raw = process.env[GEMINI_DIFF_CACHE_TTL_S_ENV]; | ||
| if (!raw) | ||
| return DEFAULT_CACHE_TTL_S; | ||
| const parsed = Number.parseInt(raw, 10); | ||
| return Number.isSafeInteger(parsed) && parsed > 0 | ||
| ? parsed | ||
| : DEFAULT_CACHE_TTL_S; | ||
| } | ||
| export function shouldCacheDiff(diffLength) { | ||
| return isDiffCacheEnabled() && diffLength >= MIN_DIFF_CHARS_FOR_CACHING; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Cache lifecycle | ||
| // --------------------------------------------------------------------------- | ||
| /** | ||
| * Create a Gemini context cache containing the given diff text. | ||
| * Returns the cache slot on success, or undefined on failure (logged). | ||
| */ | ||
| export async function createDiffCache(diff, model) { | ||
| const effectiveModel = model ?? getDefaultModel(); | ||
| const ttl = `${String(getCacheTtlSeconds())}s`; | ||
| try { | ||
| const cache = await getClient().caches.create({ | ||
| model: effectiveModel, | ||
| config: { | ||
| contents: [{ role: 'user', parts: [{ text: diff }] }], | ||
| displayName: 'code-assistant-diff', | ||
| ttl, | ||
| }, | ||
| }); | ||
| if (!cache.name) { | ||
| debug('Cache created but no name returned'); | ||
| return undefined; | ||
| } | ||
| const slot = { | ||
| cacheName: cache.name, | ||
| model: effectiveModel, | ||
| createdAt: Date.now(), | ||
| }; | ||
| currentCache = slot; | ||
| debug('Diff cache created: %s (model=%s)', cache.name, effectiveModel); | ||
| return slot; | ||
| } | ||
| catch (error) { | ||
| debug('Failed to create diff cache: %O', error); | ||
| return undefined; | ||
| } | ||
| } | ||
| /** | ||
| * Returns the current diff cache slot if the model matches. | ||
| * Falls back to undefined when: | ||
| * - No cache exists | ||
| * - The cache was created for a different model | ||
| */ | ||
| export function getCurrentDiffCache(model) { | ||
| if (!currentCache) | ||
| return undefined; | ||
| const effectiveModel = model ?? getDefaultModel(); | ||
| if (currentCache.model !== effectiveModel) { | ||
| debug('Cache model mismatch: cached=%s, requested=%s', currentCache.model, effectiveModel); | ||
| return undefined; | ||
| } | ||
| return currentCache; | ||
| } | ||
| /** | ||
| * Delete the current diff cache from the Gemini API and clear local state. | ||
| * Best-effort: errors are logged but not thrown. | ||
| */ | ||
| export async function deleteDiffCache() { | ||
| const slot = currentCache; | ||
| currentCache = undefined; | ||
| if (!slot) | ||
| return; | ||
| try { | ||
| await getClient().caches.delete({ name: slot.cacheName }); | ||
| debug('Diff cache deleted: %s', slot.cacheName); | ||
| } | ||
| catch (error) { | ||
| debug('Failed to delete diff cache: %O', error); | ||
| } | ||
| } | ||
| /** Clear local cache state without calling the API. */ | ||
| export function clearDiffCacheLocal() { | ||
| currentCache = undefined; | ||
| } | ||
| /** Expose for testing. */ | ||
| export function setDiffCacheForTesting(slot) { | ||
| currentCache = slot; | ||
| } |
| import { AsyncLocalStorage } from 'node:async_hooks'; | ||
| import { EventEmitter } from 'node:events'; | ||
| import { GoogleGenAI } from '@google/genai'; | ||
| import type { GeminiOnLog } from './types.js'; | ||
| export declare function getClient(): GoogleGenAI; | ||
| export declare function setClientForTesting(client: GoogleGenAI): void; | ||
| interface GeminiRequestContext { | ||
| requestId: string; | ||
| model: string; | ||
| } | ||
| export type GeminiLogLevel = 'info' | 'warning' | 'error'; | ||
| interface GeminiLogPayload { | ||
| event: string; | ||
| details: Record<string, unknown>; | ||
| } | ||
| export type { GeminiLogPayload }; | ||
| export declare const geminiContext: AsyncLocalStorage<GeminiRequestContext>; | ||
| export declare function getCurrentRequestId(): string; | ||
| export declare function nextRequestId(): string; | ||
| export declare const geminiEvents: EventEmitter<[never]>; | ||
| export declare function safeCallOnLog(onLog: GeminiOnLog, level: string, data: Record<string, unknown>): Promise<void>; | ||
| export declare function emitGeminiLog(onLog: GeminiOnLog, level: GeminiLogLevel, payload: GeminiLogPayload): Promise<void>; |
| import { AsyncLocalStorage } from 'node:async_hooks'; | ||
| import { randomUUID } from 'node:crypto'; | ||
| import { EventEmitter } from 'node:events'; | ||
| import { debuglog } from 'node:util'; | ||
| import { GoogleGenAI } from '@google/genai'; | ||
| import { UNKNOWN_REQUEST_CONTEXT_VALUE } from './config.js'; | ||
| // --------------------------------------------------------------------------- | ||
| // API key & client singleton | ||
| // --------------------------------------------------------------------------- | ||
| const GEMINI_API_KEY_ENV_VAR = 'GEMINI_API_KEY'; | ||
| const GOOGLE_API_KEY_ENV_VAR = 'GOOGLE_API_KEY'; | ||
| let cachedClient; | ||
| function getApiKey() { | ||
| const apiKey = process.env[GEMINI_API_KEY_ENV_VAR] ?? process.env[GOOGLE_API_KEY_ENV_VAR]; | ||
| if (!apiKey) { | ||
| throw new Error(`Missing ${GEMINI_API_KEY_ENV_VAR} or ${GOOGLE_API_KEY_ENV_VAR}.`); | ||
| } | ||
| return apiKey; | ||
| } | ||
| export function getClient() { | ||
| cachedClient ??= new GoogleGenAI({ | ||
| apiKey: getApiKey(), | ||
| apiVersion: 'v1beta', | ||
| }); | ||
| return cachedClient; | ||
| } | ||
| export function setClientForTesting(client) { | ||
| cachedClient = client; | ||
| } | ||
| export const geminiContext = new AsyncLocalStorage({ | ||
| name: 'gemini_request', | ||
| defaultValue: { | ||
| requestId: UNKNOWN_REQUEST_CONTEXT_VALUE, | ||
| model: UNKNOWN_REQUEST_CONTEXT_VALUE, | ||
| }, | ||
| }); | ||
| const UNKNOWN_CONTEXT = { | ||
| requestId: UNKNOWN_REQUEST_CONTEXT_VALUE, | ||
| model: UNKNOWN_REQUEST_CONTEXT_VALUE, | ||
| }; | ||
| export function getCurrentRequestId() { | ||
| const context = geminiContext.getStore(); | ||
| return context?.requestId ?? UNKNOWN_REQUEST_CONTEXT_VALUE; | ||
| } | ||
| export function nextRequestId() { | ||
| return randomUUID(); | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Event emitter & debug logging | ||
| // --------------------------------------------------------------------------- | ||
| export const geminiEvents = new EventEmitter(); | ||
| const debug = debuglog('gemini'); | ||
| geminiEvents.on('log', (payload) => { | ||
| if (debug.enabled) { | ||
| debug('%j', payload); | ||
| } | ||
| }); | ||
| function logEvent(event, details) { | ||
| const context = geminiContext.getStore() ?? UNKNOWN_CONTEXT; | ||
| geminiEvents.emit('log', { | ||
| event, | ||
| requestId: context.requestId, | ||
| model: context.model, | ||
| ...details, | ||
| }); | ||
| } | ||
| export async function safeCallOnLog(onLog, level, data) { | ||
| try { | ||
| await onLog?.(level, data); | ||
| } | ||
| catch { | ||
| // Log callbacks are best-effort; never fail the tool call. | ||
| } | ||
| } | ||
| export async function emitGeminiLog(onLog, level, payload) { | ||
| logEvent(payload.event, payload.details); | ||
| await safeCallOnLog(onLog, level, { | ||
| event: payload.event, | ||
| ...payload.details, | ||
| }); | ||
| } |
| import { HarmBlockThreshold, HarmCategory, ThinkingLevel } from '@google/genai'; | ||
| export declare const DEFAULT_MODEL = "gemini-3-flash-preview"; | ||
| export declare const MODEL_FALLBACK_TARGET = "gemini-2.5-flash"; | ||
| export declare function getDefaultModel(): string; | ||
| /** Test-only: reset cached model so env changes take effect. */ | ||
| export declare function resetDefaultModelForTesting(): void; | ||
| export declare const DEFAULT_MAX_RETRIES = 3; | ||
| export declare const DEFAULT_TIMEOUT_MS = 90000; | ||
| export declare const CANCELLED_REQUEST_MESSAGE = "Gemini request was cancelled."; | ||
| declare const UNKNOWN_REQUEST_CONTEXT_VALUE_STR = "unknown"; | ||
| export { UNKNOWN_REQUEST_CONTEXT_VALUE_STR as UNKNOWN_REQUEST_CONTEXT_VALUE }; | ||
| export declare function getSafetyThreshold(): HarmBlockThreshold; | ||
| export declare function getSafetySettings(threshold: HarmBlockThreshold): { | ||
| category: HarmCategory; | ||
| threshold: HarmBlockThreshold; | ||
| }[]; | ||
| export declare function getThinkingConfig(thinkingLevel: 'minimal' | 'low' | 'medium' | 'high' | undefined, includeThoughts: boolean): { | ||
| thinkingLevel?: ThinkingLevel; | ||
| includeThoughts?: true; | ||
| } | undefined; | ||
| export declare function getDefaultIncludeThoughts(): boolean; | ||
| export declare function getDefaultBatchMode(): 'off' | 'inline'; | ||
| export declare const maxConcurrentCallsConfig: import("../config.js").CachedEnvInt; | ||
| export declare const maxConcurrentBatchCallsConfig: import("../config.js").CachedEnvInt; | ||
| export declare const concurrencyWaitMsConfig: import("../config.js").CachedEnvInt; | ||
| export declare const batchPollIntervalMsConfig: import("../config.js").CachedEnvInt; | ||
| export declare const batchTimeoutMsConfig: import("../config.js").CachedEnvInt; |
| import { HarmBlockThreshold, HarmCategory, ThinkingLevel } from '@google/genai'; | ||
| import { createCachedEnvInt } from '../config.js'; | ||
| // --------------------------------------------------------------------------- | ||
| // Model defaults | ||
| // --------------------------------------------------------------------------- | ||
| // Lazy-cached: first call happens after parseCommandLineArgs() sets GEMINI_MODEL. | ||
| let _defaultModel; | ||
| export const DEFAULT_MODEL = 'gemini-3-flash-preview'; | ||
| export const MODEL_FALLBACK_TARGET = 'gemini-2.5-flash'; | ||
| const GEMINI_MODEL_ENV_VAR = 'GEMINI_MODEL'; | ||
| export function getDefaultModel() { | ||
| _defaultModel ??= process.env[GEMINI_MODEL_ENV_VAR] ?? DEFAULT_MODEL; | ||
| return _defaultModel; | ||
| } | ||
| /** Test-only: reset cached model so env changes take effect. */ | ||
| export function resetDefaultModelForTesting() { | ||
| _defaultModel = undefined; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Execution defaults | ||
| // --------------------------------------------------------------------------- | ||
| export const DEFAULT_MAX_RETRIES = 3; | ||
| export const DEFAULT_TIMEOUT_MS = 90_000; | ||
| export const CANCELLED_REQUEST_MESSAGE = 'Gemini request was cancelled.'; | ||
| const UNKNOWN_REQUEST_CONTEXT_VALUE_STR = 'unknown'; | ||
| export { UNKNOWN_REQUEST_CONTEXT_VALUE_STR as UNKNOWN_REQUEST_CONTEXT_VALUE }; | ||
| // --------------------------------------------------------------------------- | ||
| // Safety settings | ||
| // --------------------------------------------------------------------------- | ||
| const GEMINI_HARM_BLOCK_THRESHOLD_ENV_VAR = 'GEMINI_HARM_BLOCK_THRESHOLD'; | ||
| const DEFAULT_SAFETY_THRESHOLD = HarmBlockThreshold.BLOCK_NONE; | ||
| const SAFETY_CATEGORIES = [ | ||
| HarmCategory.HARM_CATEGORY_HATE_SPEECH, | ||
| HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, | ||
| HarmCategory.HARM_CATEGORY_HARASSMENT, | ||
| HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, | ||
| ]; | ||
| const SAFETY_THRESHOLD_BY_NAME = { | ||
| BLOCK_NONE: HarmBlockThreshold.BLOCK_NONE, | ||
| BLOCK_ONLY_HIGH: HarmBlockThreshold.BLOCK_ONLY_HIGH, | ||
| BLOCK_MEDIUM_AND_ABOVE: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, | ||
| BLOCK_LOW_AND_ABOVE: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE, | ||
| }; | ||
| let cachedSafetyThresholdEnv; | ||
| let cachedSafetyThreshold = DEFAULT_SAFETY_THRESHOLD; | ||
| const safetySettingsCache = new Map(); | ||
| function parseSafetyThreshold(threshold) { | ||
| const normalizedThreshold = threshold.trim().toUpperCase(); | ||
| if (!(normalizedThreshold in SAFETY_THRESHOLD_BY_NAME)) { | ||
| return undefined; | ||
| } | ||
| return SAFETY_THRESHOLD_BY_NAME[normalizedThreshold]; | ||
| } | ||
| export function getSafetyThreshold() { | ||
| const threshold = process.env[GEMINI_HARM_BLOCK_THRESHOLD_ENV_VAR]; | ||
| if (threshold === cachedSafetyThresholdEnv) { | ||
| return cachedSafetyThreshold; | ||
| } | ||
| cachedSafetyThresholdEnv = threshold; | ||
| if (!threshold) { | ||
| cachedSafetyThreshold = DEFAULT_SAFETY_THRESHOLD; | ||
| return cachedSafetyThreshold; | ||
| } | ||
| const parsedThreshold = parseSafetyThreshold(threshold); | ||
| if (parsedThreshold) { | ||
| cachedSafetyThreshold = parsedThreshold; | ||
| return cachedSafetyThreshold; | ||
| } | ||
| cachedSafetyThreshold = DEFAULT_SAFETY_THRESHOLD; | ||
| return cachedSafetyThreshold; | ||
| } | ||
| export function getSafetySettings(threshold) { | ||
| const cached = safetySettingsCache.get(threshold); | ||
| if (cached) { | ||
| return cached; | ||
| } | ||
| const settings = SAFETY_CATEGORIES.map((category) => ({ | ||
| category, | ||
| threshold, | ||
| })); | ||
| safetySettingsCache.set(threshold, settings); | ||
| return settings; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Thinking config | ||
| // --------------------------------------------------------------------------- | ||
| const THINKING_LEVEL_MAP = { | ||
| minimal: ThinkingLevel.MINIMAL, | ||
| low: ThinkingLevel.LOW, | ||
| medium: ThinkingLevel.MEDIUM, | ||
| high: ThinkingLevel.HIGH, | ||
| }; | ||
| export function getThinkingConfig(thinkingLevel, includeThoughts) { | ||
| if (!thinkingLevel && !includeThoughts) { | ||
| return undefined; | ||
| } | ||
| return { | ||
| ...(thinkingLevel | ||
| ? { thinkingLevel: THINKING_LEVEL_MAP[thinkingLevel] } | ||
| : {}), | ||
| ...(includeThoughts ? { includeThoughts: true } : {}), | ||
| }; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Boolean / env helpers | ||
| // --------------------------------------------------------------------------- | ||
| const GEMINI_INCLUDE_THOUGHTS_ENV_VAR = 'GEMINI_INCLUDE_THOUGHTS'; | ||
| const GEMINI_BATCH_MODE_ENV_VAR = 'GEMINI_BATCH_MODE'; | ||
| const DEFAULT_INCLUDE_THOUGHTS = false; | ||
| const DEFAULT_BATCH_MODE = 'off'; | ||
| const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); | ||
| const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']); | ||
| let cachedIncludeThoughtsEnv; | ||
| let cachedIncludeThoughts = DEFAULT_INCLUDE_THOUGHTS; | ||
| function parseBooleanEnv(value) { | ||
| const normalized = value.trim().toLowerCase(); | ||
| if (normalized.length === 0) { | ||
| return undefined; | ||
| } | ||
| if (TRUE_ENV_VALUES.has(normalized)) { | ||
| return true; | ||
| } | ||
| if (FALSE_ENV_VALUES.has(normalized)) { | ||
| return false; | ||
| } | ||
| return undefined; | ||
| } | ||
| export function getDefaultIncludeThoughts() { | ||
| const value = process.env[GEMINI_INCLUDE_THOUGHTS_ENV_VAR]; | ||
| if (value === cachedIncludeThoughtsEnv) { | ||
| return cachedIncludeThoughts; | ||
| } | ||
| cachedIncludeThoughtsEnv = value; | ||
| if (!value) { | ||
| cachedIncludeThoughts = DEFAULT_INCLUDE_THOUGHTS; | ||
| return cachedIncludeThoughts; | ||
| } | ||
| cachedIncludeThoughts = parseBooleanEnv(value) ?? DEFAULT_INCLUDE_THOUGHTS; | ||
| return cachedIncludeThoughts; | ||
| } | ||
| export function getDefaultBatchMode() { | ||
| const value = process.env[GEMINI_BATCH_MODE_ENV_VAR]?.trim().toLowerCase(); | ||
| if (value === 'inline') { | ||
| return 'inline'; | ||
| } | ||
| return DEFAULT_BATCH_MODE; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Concurrency env configs | ||
| // --------------------------------------------------------------------------- | ||
| export const maxConcurrentCallsConfig = createCachedEnvInt('MAX_CONCURRENT_CALLS', 10); | ||
| export const maxConcurrentBatchCallsConfig = createCachedEnvInt('MAX_CONCURRENT_BATCH_CALLS', 2); | ||
| export const concurrencyWaitMsConfig = createCachedEnvInt('MAX_CONCURRENT_CALLS_WAIT_MS', 2_000); | ||
| export const batchPollIntervalMsConfig = createCachedEnvInt('GEMINI_BATCH_POLL_INTERVAL_MS', 2_000); | ||
| export const batchTimeoutMsConfig = createCachedEnvInt('GEMINI_BATCH_TIMEOUT_MS', 120_000); |
| import type { CodeExecutionResponse, GeminiStructuredRequest } from './types.js'; | ||
| export declare function getGeminiQueueSnapshot(): { | ||
| activeWaiters: number; | ||
| activeCalls: number; | ||
| activeBatchWaiters: number; | ||
| activeBatchCalls: number; | ||
| }; | ||
| export declare function generateWithCodeExecution(request: GeminiStructuredRequest): Promise<CodeExecutionResponse>; | ||
| export declare function generateGroundedContent(request: GeminiStructuredRequest): Promise<{ | ||
| text: string; | ||
| groundingMetadata: unknown; | ||
| }>; | ||
| export interface FileSearchResponse { | ||
| text: string; | ||
| parts: unknown[]; | ||
| } | ||
| export declare function generateWithFileSearch(request: GeminiStructuredRequest & { | ||
| fileSearchStoreNames: readonly string[]; | ||
| }): Promise<FileSearchResponse>; | ||
| export declare function generateStructuredJson(request: GeminiStructuredRequest): Promise<unknown>; |
| import { performance } from 'node:perf_hooks'; | ||
| import { setTimeout as sleep } from 'node:timers/promises'; | ||
| import { FinishReason } from '@google/genai'; | ||
| import { ConcurrencyLimiter } from '../concurrency.js'; | ||
| import { DEFAULT_MAX_OUTPUT_TOKENS } from '../config.js'; | ||
| import { getErrorMessage, toRecord } from '../errors.js'; | ||
| import { formatUsNumber } from '../format.js'; | ||
| import { emitGeminiLog, geminiContext, getClient, nextRequestId, safeCallOnLog, } from './client.js'; | ||
| import { batchPollIntervalMsConfig, batchTimeoutMsConfig, CANCELLED_REQUEST_MESSAGE, concurrencyWaitMsConfig, DEFAULT_MAX_RETRIES, DEFAULT_MODEL, DEFAULT_TIMEOUT_MS, getDefaultBatchMode, getDefaultIncludeThoughts, getDefaultModel, getSafetySettings, getSafetyThreshold, getThinkingConfig, maxConcurrentBatchCallsConfig, maxConcurrentCallsConfig, MODEL_FALLBACK_TARGET, } from './config.js'; | ||
| import { canRetryAttempt, getNumericErrorCode, getRetryDelayMs, toUpperStringCode, } from './retry.js'; | ||
| // --------------------------------------------------------------------------- | ||
| // Constants | ||
| // --------------------------------------------------------------------------- | ||
| const SLEEP_UNREF_OPTIONS = { ref: false }; | ||
| const JSON_CODE_BLOCK_PATTERN = /```(?:json)?\n?([\s\S]*?)(?=\n?```)/u; | ||
| // --------------------------------------------------------------------------- | ||
| // Concurrency limiters | ||
| // --------------------------------------------------------------------------- | ||
| function formatConcurrencyLimitErrorMessage(limit, waitLimitMs) { | ||
| return `Too many concurrent Gemini calls (limit: ${formatUsNumber(limit)}; waited ${formatUsNumber(waitLimitMs)}ms).`; | ||
| } | ||
| const callLimiter = new ConcurrencyLimiter(() => maxConcurrentCallsConfig.get(), () => concurrencyWaitMsConfig.get(), (limit, ms) => formatConcurrencyLimitErrorMessage(limit, ms), () => CANCELLED_REQUEST_MESSAGE); | ||
| const batchCallLimiter = new ConcurrencyLimiter(() => maxConcurrentBatchCallsConfig.get(), () => concurrencyWaitMsConfig.get(), (limit, ms) => formatConcurrencyLimitErrorMessage(limit, ms), () => CANCELLED_REQUEST_MESSAGE); | ||
| // --------------------------------------------------------------------------- | ||
| // Generation config helpers | ||
| // --------------------------------------------------------------------------- | ||
| function applyResponseKeyOrdering(responseSchema, responseKeyOrdering) { | ||
| if (!responseKeyOrdering || responseKeyOrdering.length === 0) { | ||
| return responseSchema; | ||
| } | ||
| return { | ||
| ...responseSchema, | ||
| propertyOrdering: [...responseKeyOrdering], | ||
| }; | ||
| } | ||
| function getPromptWithFunctionCallingContext(request) { | ||
| return request.prompt; | ||
| } | ||
| function buildGenerationConfig(request, abortSignal) { | ||
| const includeThoughts = request.includeThoughts ?? getDefaultIncludeThoughts(); | ||
| const thinkingConfig = getThinkingConfig(request.thinkingLevel, includeThoughts); | ||
| const config = { | ||
| temperature: request.temperature ?? 1.0, | ||
| maxOutputTokens: request.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS, | ||
| safetySettings: getSafetySettings(getSafetyThreshold()), | ||
| ...(abortSignal ? { abortSignal } : {}), | ||
| }; | ||
| const tools = []; | ||
| if (request.useGrounding) { | ||
| tools.push({ googleSearch: {} }); | ||
| } | ||
| if (request.useCodeExecution) { | ||
| tools.push({ codeExecution: {} }); | ||
| } | ||
| if (request.fileSearchStoreNames && request.fileSearchStoreNames.length > 0) { | ||
| tools.push({ | ||
| fileSearch: { | ||
| fileSearchStoreNames: [...request.fileSearchStoreNames], | ||
| }, | ||
| }); | ||
| } | ||
| if (tools.length > 0) { | ||
| config.tools = tools; | ||
| } | ||
| else { | ||
| config.responseMimeType = 'application/json'; | ||
| config.responseSchema = applyResponseKeyOrdering(request.responseSchema, request.responseKeyOrdering); | ||
| } | ||
| if (request.systemInstruction) { | ||
| config.systemInstruction = request.systemInstruction; | ||
| } | ||
| if (thinkingConfig) { | ||
| config.thinkingConfig = thinkingConfig; | ||
| } | ||
| if (request.cachedContent) { | ||
| config.cachedContent = request.cachedContent; | ||
| } | ||
| return config; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Signal / sleep helpers | ||
| // --------------------------------------------------------------------------- | ||
| function combineSignals(signal, requestSignal) { | ||
| return requestSignal ? AbortSignal.any([signal, requestSignal]) : signal; | ||
| } | ||
| function throwIfRequestCancelled(requestSignal) { | ||
| if (requestSignal?.aborted) { | ||
| throw new Error(CANCELLED_REQUEST_MESSAGE); | ||
| } | ||
| } | ||
| function getSleepOptions(signal) { | ||
| return signal ? { ...SLEEP_UNREF_OPTIONS, signal } : SLEEP_UNREF_OPTIONS; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Response parsing | ||
| // --------------------------------------------------------------------------- | ||
| function parseStructuredResponse(responseText) { | ||
| if (!responseText) { | ||
| throw new Error('Gemini returned an empty response body.'); | ||
| } | ||
| try { | ||
| return JSON.parse(responseText); | ||
| } | ||
| catch { | ||
| // fast-path failed; try extracting from markdown block | ||
| } | ||
| const jsonMatch = JSON_CODE_BLOCK_PATTERN.exec(responseText); | ||
| const jsonText = jsonMatch?.[1] ?? responseText; | ||
| try { | ||
| return JSON.parse(jsonText); | ||
| } | ||
| catch (error) { | ||
| throw new Error(`Model produced invalid JSON: ${getErrorMessage(error)}`, { | ||
| cause: error, | ||
| }); | ||
| } | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Error message formatters | ||
| // --------------------------------------------------------------------------- | ||
| function formatTimeoutErrorMessage(timeoutMs) { | ||
| return `Gemini request timed out after ${formatUsNumber(timeoutMs)}ms.`; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Core generation call with timeout | ||
| // --------------------------------------------------------------------------- | ||
| async function generateContentWithTimeout(request, model, timeoutMs) { | ||
| const controller = new AbortController(); | ||
| const timeout = setTimeout(() => { | ||
| controller.abort(); | ||
| }, timeoutMs); | ||
| timeout.unref(); | ||
| const signal = combineSignals(controller.signal, request.signal); | ||
| try { | ||
| return await getClient().models.generateContent({ | ||
| model, | ||
| contents: getPromptWithFunctionCallingContext(request), | ||
| config: buildGenerationConfig(request, signal), | ||
| }); | ||
| } | ||
| catch (error) { | ||
| throwIfRequestCancelled(request.signal); | ||
| if (controller.signal.aborted) { | ||
| throw new Error(formatTimeoutErrorMessage(timeoutMs), { cause: error }); | ||
| } | ||
| throw error; | ||
| } | ||
| finally { | ||
| clearTimeout(timeout); | ||
| } | ||
| } | ||
| function isThoughtPart(part) { | ||
| return (typeof part === 'object' && | ||
| part !== null && | ||
| part.thought === true && | ||
| typeof part.text === 'string'); | ||
| } | ||
| function isTextOnlyPart(part) { | ||
| return (typeof part === 'object' && | ||
| part !== null && | ||
| 'text' in part && | ||
| typeof part.text === 'string' && | ||
| !part.thought); | ||
| } | ||
| function extractThoughtsFromParts(parts) { | ||
| if (!Array.isArray(parts)) { | ||
| return undefined; | ||
| } | ||
| const thoughtParts = parts.filter(isThoughtPart); | ||
| if (thoughtParts.length === 0) { | ||
| return undefined; | ||
| } | ||
| return thoughtParts.map((part) => part.text).join('\n\n'); | ||
| } | ||
| function isExecutableCodePart(part) { | ||
| return (typeof part === 'object' && | ||
| part !== null && | ||
| 'executableCode' in part && | ||
| typeof part.executableCode === 'object'); | ||
| } | ||
| function isCodeExecutionResultPart(part) { | ||
| return (typeof part === 'object' && | ||
| part !== null && | ||
| 'codeExecutionResult' in part && | ||
| typeof part.codeExecutionResult === 'object'); | ||
| } | ||
| function extractCodeExecutionResponse(response) { | ||
| const parts = response.candidates?.[0]?.content?.parts; | ||
| const textSegments = []; | ||
| const codeBlocks = []; | ||
| const executionResults = []; | ||
| if (Array.isArray(parts)) { | ||
| for (const part of parts) { | ||
| if (isExecutableCodePart(part)) { | ||
| codeBlocks.push({ | ||
| code: part.executableCode.code ?? '', | ||
| language: part.executableCode.language ?? 'python', | ||
| }); | ||
| } | ||
| else if (isCodeExecutionResultPart(part)) { | ||
| executionResults.push({ | ||
| outcome: part.codeExecutionResult.outcome ?? 'OUTCOME_UNSPECIFIED', | ||
| output: part.codeExecutionResult.output ?? '', | ||
| }); | ||
| } | ||
| else if (isTextOnlyPart(part)) { | ||
| textSegments.push(part.text); | ||
| } | ||
| } | ||
| } | ||
| return { | ||
| text: textSegments.join('\n\n') || (response.text ?? ''), | ||
| codeBlocks, | ||
| executionResults, | ||
| }; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Single-attempt execution | ||
| // --------------------------------------------------------------------------- | ||
| async function executeAttempt(request, model, timeoutMs, attempt, onLog) { | ||
| const startedAt = performance.now(); | ||
| const response = await generateContentWithTimeout(request, model, timeoutMs); | ||
| const latencyMs = Math.round(performance.now() - startedAt); | ||
| const finishReason = response.candidates?.[0]?.finishReason; | ||
| const thoughts = extractThoughtsFromParts(response.candidates?.[0]?.content?.parts); | ||
| await emitGeminiLog(onLog, 'info', { | ||
| event: 'gemini_call', | ||
| details: { | ||
| attempt, | ||
| latencyMs, | ||
| finishReason: finishReason ?? null, | ||
| usageMetadata: response.usageMetadata ?? null, | ||
| ...(thoughts ? { thoughts } : {}), | ||
| }, | ||
| }); | ||
| if (finishReason === FinishReason.MAX_TOKENS) { | ||
| const limit = request.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS; | ||
| throw new Error(`Response truncated: model output exceeds limit (maxOutputTokens=${formatUsNumber(limit)}). Increase maxOutputTokens or reduce prompt complexity.`); | ||
| } | ||
| if (request.useCodeExecution) { | ||
| return extractCodeExecutionResponse(response); | ||
| } | ||
| if (request.useGrounding) { | ||
| return { | ||
| text: response.text, | ||
| groundingMetadata: response.candidates?.[0]?.groundingMetadata, | ||
| }; | ||
| } | ||
| if (request.fileSearchStoreNames && request.fileSearchStoreNames.length > 0) { | ||
| const parts = (response.candidates?.[0]?.content?.parts ?? []); | ||
| return { | ||
| text: response.text ?? '', | ||
| parts, | ||
| }; | ||
| } | ||
| return parseStructuredResponse(response.text); | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Retry orchestration | ||
| // --------------------------------------------------------------------------- | ||
| async function waitBeforeRetry(attempt, error, onLog, requestSignal) { | ||
| const delayMs = getRetryDelayMs(attempt); | ||
| const reason = getErrorMessage(error); | ||
| await emitGeminiLog(onLog, 'warning', { | ||
| event: 'gemini_retry', | ||
| details: { | ||
| attempt, | ||
| delayMs, | ||
| reason, | ||
| }, | ||
| }); | ||
| throwIfRequestCancelled(requestSignal); | ||
| try { | ||
| await sleep(delayMs, undefined, getSleepOptions(requestSignal)); | ||
| } | ||
| catch (sleepError) { | ||
| throwIfRequestCancelled(requestSignal); | ||
| throw sleepError; | ||
| } | ||
| } | ||
| async function throwGeminiFailure(attemptsMade, lastError, onLog) { | ||
| const message = getErrorMessage(lastError); | ||
| await emitGeminiLog(onLog, 'error', { | ||
| event: 'gemini_failure', | ||
| details: { | ||
| error: message, | ||
| attempts: attemptsMade, | ||
| }, | ||
| }); | ||
| throw new Error(`Gemini request failed after ${attemptsMade} attempts: ${message}`, { cause: lastError }); | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Model fallback | ||
| // --------------------------------------------------------------------------- | ||
| function shouldUseModelFallback(error, model) { | ||
| return getNumericErrorCode(error) === 404 && model === DEFAULT_MODEL; | ||
| } | ||
| function omitThinkingLevel(request) { | ||
| const copy = { ...request }; | ||
| Reflect.deleteProperty(copy, 'thinkingLevel'); | ||
| return copy; | ||
| } | ||
| async function applyModelFallback(request, onLog, reason) { | ||
| await emitGeminiLog(onLog, 'warning', { | ||
| event: 'gemini_model_fallback', | ||
| details: { | ||
| from: DEFAULT_MODEL, | ||
| to: MODEL_FALLBACK_TARGET, | ||
| reason, | ||
| }, | ||
| }); | ||
| return { | ||
| model: MODEL_FALLBACK_TARGET, | ||
| request: omitThinkingLevel(request), | ||
| }; | ||
| } | ||
| async function tryApplyModelFallback(error, model, request, onLog, reason) { | ||
| if (!shouldUseModelFallback(error, model)) { | ||
| return undefined; | ||
| } | ||
| return applyModelFallback(request, onLog, reason); | ||
| } | ||
| function countAttemptsMade(attempt) { | ||
| return attempt + 1; | ||
| } | ||
| async function runWithRetries(request, model, timeoutMs, maxRetries, onLog) { | ||
| let lastError; | ||
| let currentModel = model; | ||
| let effectiveRequest = request; | ||
| for (let attempt = 0; attempt <= maxRetries; attempt += 1) { | ||
| try { | ||
| return await executeAttempt(effectiveRequest, currentModel, timeoutMs, attempt, onLog); | ||
| } | ||
| catch (error) { | ||
| lastError = error; | ||
| const fallback = await tryApplyModelFallback(error, currentModel, request, onLog, 'Model not found (404)'); | ||
| if (fallback) { | ||
| currentModel = fallback.model; | ||
| effectiveRequest = fallback.request; | ||
| continue; | ||
| } | ||
| if (!canRetryAttempt(attempt, maxRetries, error)) { | ||
| return throwGeminiFailure(countAttemptsMade(attempt), lastError, onLog); | ||
| } | ||
| await waitBeforeRetry(attempt, error, onLog, request.signal); | ||
| } | ||
| } | ||
| return throwGeminiFailure(maxRetries + 1, lastError, onLog); | ||
| } | ||
| function isInlineBatchMode(mode) { | ||
| return mode === 'inline'; | ||
| } | ||
| async function acquireQueueSlot(mode, requestSignal) { | ||
| const queueWaitStartedAt = performance.now(); | ||
| if (isInlineBatchMode(mode)) { | ||
| await batchCallLimiter.acquire(requestSignal); | ||
| } | ||
| else { | ||
| await callLimiter.acquire(requestSignal); | ||
| } | ||
| return { | ||
| queueWaitMs: Math.round(performance.now() - queueWaitStartedAt), | ||
| waitingCalls: isInlineBatchMode(mode) | ||
| ? batchCallLimiter.pendingCount | ||
| : callLimiter.pendingCount, | ||
| }; | ||
| } | ||
| function releaseQueueSlot(mode) { | ||
| if (isInlineBatchMode(mode)) { | ||
| batchCallLimiter.release(); | ||
| return; | ||
| } | ||
| callLimiter.release(); | ||
| } | ||
| const BatchHelper = { | ||
| getState(payload) { | ||
| const record = toRecord(payload); | ||
| if (!record) | ||
| return undefined; | ||
| const directState = toUpperStringCode(record.state); | ||
| if (directState) | ||
| return directState; | ||
| const metadata = toRecord(record.metadata); | ||
| return metadata ? toUpperStringCode(metadata.state) : undefined; | ||
| }, | ||
| getResponseText(payload) { | ||
| const record = toRecord(payload); | ||
| if (!record) | ||
| return undefined; | ||
| // Try inlineResponse.text | ||
| const inline = toRecord(record.inlineResponse); | ||
| if (typeof inline?.text === 'string') | ||
| return inline.text; | ||
| const response = toRecord(record.response); | ||
| if (!response) | ||
| return undefined; | ||
| // Try response.text | ||
| if (typeof response.text === 'string') | ||
| return response.text; | ||
| // Try response.inlineResponses[0].text | ||
| if (Array.isArray(response.inlineResponses) && | ||
| response.inlineResponses.length > 0) { | ||
| const first = toRecord(response.inlineResponses[0]); | ||
| if (typeof first?.text === 'string') | ||
| return first.text; | ||
| } | ||
| return undefined; | ||
| }, | ||
| getErrorDetail(payload) { | ||
| const record = toRecord(payload); | ||
| if (!record) | ||
| return undefined; | ||
| // Try error.message | ||
| const directError = toRecord(record.error); | ||
| if (typeof directError?.message === 'string') | ||
| return directError.message; | ||
| // Try metadata.error.message | ||
| const metadata = toRecord(record.metadata); | ||
| const metaError = toRecord(metadata?.error); | ||
| if (typeof metaError?.message === 'string') | ||
| return metaError.message; | ||
| // Try response.error.message | ||
| const response = toRecord(record.response); | ||
| const respError = toRecord(response?.error); | ||
| return typeof respError?.message === 'string' | ||
| ? respError.message | ||
| : undefined; | ||
| }, | ||
| getSuccessResponseText(polled) { | ||
| const text = this.getResponseText(polled); | ||
| if (text) | ||
| return text; | ||
| const err = this.getErrorDetail(polled); | ||
| throw new Error(err | ||
| ? `Gemini batch request succeeded but returned no response text: ${err}` | ||
| : 'Gemini batch request succeeded but returned no response text.'); | ||
| }, | ||
| handleTerminalState(state, payload) { | ||
| if (state === 'JOB_STATE_FAILED' || state === 'JOB_STATE_CANCELLED') { | ||
| const err = this.getErrorDetail(payload); | ||
| throw new Error(err | ||
| ? `Gemini batch request ended with state ${state}: ${err}` | ||
| : `Gemini batch request ended with state ${state}.`); | ||
| } | ||
| }, | ||
| }; | ||
| async function pollBatchStatusWithRetries(batches, batchName, onLog, requestSignal) { | ||
| const maxPollRetries = 2; | ||
| for (let attempt = 0; attempt <= maxPollRetries; attempt += 1) { | ||
| try { | ||
| return await batches.get({ name: batchName }); | ||
| } | ||
| catch (error) { | ||
| if (!canRetryAttempt(attempt, maxPollRetries, error)) { | ||
| throw error; | ||
| } | ||
| await waitBeforeRetry(attempt, error, onLog, requestSignal); | ||
| } | ||
| } | ||
| throw new Error('Batch polling retries exhausted unexpectedly.'); | ||
| } | ||
| async function cancelBatchIfNeeded(request, batches, batchName, onLog, completed, timedOut) { | ||
| const aborted = request.signal?.aborted === true; | ||
| const shouldCancel = !completed && (aborted || timedOut); | ||
| if (!shouldCancel || !batchName || !batches.cancel) { | ||
| return; | ||
| } | ||
| const reason = timedOut ? 'timeout' : 'aborted'; | ||
| try { | ||
| await batches.cancel({ name: batchName }); | ||
| await emitGeminiLog(onLog, 'info', { | ||
| event: 'gemini_batch_cancelled', | ||
| details: { batchName, reason }, | ||
| }); | ||
| } | ||
| catch (error) { | ||
| await emitGeminiLog(onLog, 'warning', { | ||
| event: 'gemini_batch_cancel_failed', | ||
| details: { | ||
| batchName, | ||
| reason, | ||
| error: getErrorMessage(error), | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
| async function createBatchJobWithFallback(request, batches, model, onLog) { | ||
| let currentModel = model; | ||
| let effectiveRequest = request; | ||
| const createSignal = request.signal; | ||
| for (let attempt = 0; attempt <= 1; attempt += 1) { | ||
| try { | ||
| const createPayload = { | ||
| model: currentModel, | ||
| src: [ | ||
| { | ||
| contents: [ | ||
| { role: 'user', parts: [{ text: effectiveRequest.prompt }] }, | ||
| ], | ||
| config: buildGenerationConfig(effectiveRequest, createSignal), | ||
| }, | ||
| ], | ||
| }; | ||
| return await batches.create(createPayload); | ||
| } | ||
| catch (error) { | ||
| if (attempt === 0 && shouldUseModelFallback(error, currentModel)) { | ||
| const fallback = await applyModelFallback(request, onLog, 'Model not found (404) during batch create'); | ||
| currentModel = fallback.model; | ||
| effectiveRequest = fallback.request; | ||
| continue; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| throw new Error('Unexpected state: batch creation loop exited without returning or throwing.'); | ||
| } | ||
| async function pollBatchForCompletion(batches, batchName, onLog, requestSignal) { | ||
| const pollIntervalMs = batchPollIntervalMsConfig.get(); | ||
| const timeoutMs = batchTimeoutMsConfig.get(); | ||
| const pollStart = performance.now(); | ||
| // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | ||
| while (true) { | ||
| throwIfRequestCancelled(requestSignal); | ||
| const elapsedMs = Math.round(performance.now() - pollStart); | ||
| if (elapsedMs > timeoutMs) { | ||
| throw new Error(`Gemini batch request timed out after ${formatUsNumber(timeoutMs)}ms.`); | ||
| } | ||
| const polled = await pollBatchStatusWithRetries(batches, batchName, onLog, requestSignal); | ||
| const state = BatchHelper.getState(polled); | ||
| if (state === 'JOB_STATE_SUCCEEDED') { | ||
| const responseText = BatchHelper.getSuccessResponseText(polled); | ||
| return parseStructuredResponse(responseText); | ||
| } | ||
| BatchHelper.handleTerminalState(state, polled); | ||
| await sleep(pollIntervalMs, undefined, getSleepOptions(requestSignal)); | ||
| } | ||
| } | ||
| async function runInlineBatchWithPolling(request, model, onLog) { | ||
| // SDK batch API is not yet typed in @google/genai; cast verified by !batches guard below. | ||
| const client = getClient(); | ||
| const { batches } = client; | ||
| if (!batches) { | ||
| throw new Error('Batch mode requires SDK batch support, but batches API is unavailable.'); | ||
| } | ||
| let batchName; | ||
| let completed = false; | ||
| let timedOut = false; | ||
| try { | ||
| const createdJob = await createBatchJobWithFallback(request, batches, model, onLog); | ||
| const createdRecord = toRecord(createdJob); | ||
| batchName = | ||
| typeof createdRecord?.name === 'string' ? createdRecord.name : undefined; | ||
| if (!batchName) | ||
| throw new Error('Batch mode failed to return a job name.'); | ||
| await emitGeminiLog(onLog, 'info', { | ||
| event: 'gemini_batch_created', | ||
| details: { batchName }, | ||
| }); | ||
| const result = await pollBatchForCompletion(batches, batchName, onLog, request.signal); | ||
| completed = true; | ||
| return result; | ||
| } | ||
| catch (error) { | ||
| if (getErrorMessage(error).includes('timed out')) { | ||
| timedOut = true; | ||
| } | ||
| throw error; | ||
| } | ||
| finally { | ||
| await cancelBatchIfNeeded(request, batches, batchName, onLog, completed, timedOut); | ||
| } | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Public API | ||
| // --------------------------------------------------------------------------- | ||
| export function getGeminiQueueSnapshot() { | ||
| return { | ||
| activeWaiters: callLimiter.pendingCount, | ||
| activeCalls: callLimiter.active, | ||
| activeBatchWaiters: batchCallLimiter.pendingCount, | ||
| activeBatchCalls: batchCallLimiter.active, | ||
| }; | ||
| } | ||
| export async function generateWithCodeExecution(request) { | ||
| return (await generateStructuredJson({ | ||
| ...request, | ||
| useCodeExecution: true, | ||
| responseSchema: request.responseSchema, | ||
| })); | ||
| } | ||
| export async function generateGroundedContent(request) { | ||
| return (await generateStructuredJson({ | ||
| ...request, | ||
| useGrounding: true, | ||
| // Provide a dummy schema if one is required by types, though it won't be used due to useGrounding check | ||
| responseSchema: request.responseSchema, | ||
| })); | ||
| } | ||
| export async function generateWithFileSearch(request) { | ||
| return (await generateStructuredJson(request)); | ||
| } | ||
| export async function generateStructuredJson(request) { | ||
| const model = request.model ?? getDefaultModel(); | ||
| const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| const maxRetries = request.maxRetries ?? DEFAULT_MAX_RETRIES; | ||
| const batchMode = request.batchMode ?? getDefaultBatchMode(); | ||
| const { onLog } = request; | ||
| const { queueWaitMs, waitingCalls } = await acquireQueueSlot(batchMode, request.signal); | ||
| await safeCallOnLog(onLog, 'info', { | ||
| event: 'gemini_queue_acquired', | ||
| queueWaitMs, | ||
| waitingCalls, | ||
| activeCalls: callLimiter.active, | ||
| activeBatchCalls: batchCallLimiter.active, | ||
| mode: batchMode, | ||
| }); | ||
| try { | ||
| return await geminiContext.run({ requestId: nextRequestId(), model }, () => { | ||
| if (isInlineBatchMode(batchMode)) { | ||
| return runInlineBatchWithPolling(request, model, onLog); | ||
| } | ||
| return runWithRetries(request, model, timeoutMs, maxRetries, onLog); | ||
| }); | ||
| } | ||
| finally { | ||
| releaseQueueSlot(batchMode); | ||
| } | ||
| } |
| export type { CodeExecutionBlock, CodeExecutionResponse, CodeExecutionResultBlock, GeminiLogHandler, GeminiOnLog, GeminiRequestExecutionOptions, GeminiStructuredRequest, GeminiStructuredRequestOptions, GeminiThinkingLevel, JsonObject, } from './types.js'; | ||
| export { stripJsonSchemaConstraints } from './schema.js'; | ||
| export { canRetryAttempt, getNumericErrorCode, getRetryDelayMs, RETRYABLE_NUMERIC_CODES, RETRYABLE_TRANSIENT_CODES, shouldRetry, toUpperStringCode, } from './retry.js'; | ||
| export { geminiEvents, getCurrentRequestId, setClientForTesting, } from './client.js'; | ||
| export { clearDiffCacheLocal, createDiffCache, type DiffCacheSlot, deleteDiffCache, getCurrentDiffCache, isDiffCacheEnabled, setDiffCacheForTesting, shouldCacheDiff, } from './cache.js'; | ||
| export { clearSearchStoreLocal, createSearchStore, type SearchStoreSlot, deleteSearchStore, getCurrentSearchStore, getSearchStoreInfo, setCurrentSearchStore, setSearchStoreForTesting, uploadToSearchStore, } from './search-store.js'; | ||
| export type { FileSearchResponse } from './generate.js'; | ||
| export { generateGroundedContent, generateStructuredJson, generateWithCodeExecution, generateWithFileSearch, getGeminiQueueSnapshot, } from './generate.js'; |
| // Public API re-exports from the gemini module. | ||
| // Consumers should import from './gemini/index.js' (or './gemini.js' via the barrel). | ||
| // Schema stripping | ||
| export { stripJsonSchemaConstraints } from './schema.js'; | ||
| // Retry utilities | ||
| export { canRetryAttempt, getNumericErrorCode, getRetryDelayMs, RETRYABLE_NUMERIC_CODES, RETRYABLE_TRANSIENT_CODES, shouldRetry, toUpperStringCode, } from './retry.js'; | ||
| // Client / context / events | ||
| export { geminiEvents, getCurrentRequestId, setClientForTesting, } from './client.js'; | ||
| // Context caching | ||
| export { clearDiffCacheLocal, createDiffCache, deleteDiffCache, getCurrentDiffCache, isDiffCacheEnabled, setDiffCacheForTesting, shouldCacheDiff, } from './cache.js'; | ||
| // File Search Stores (RAG) | ||
| export { clearSearchStoreLocal, createSearchStore, deleteSearchStore, getCurrentSearchStore, getSearchStoreInfo, setCurrentSearchStore, setSearchStoreForTesting, uploadToSearchStore, } from './search-store.js'; | ||
| export { generateGroundedContent, generateStructuredJson, generateWithCodeExecution, generateWithFileSearch, getGeminiQueueSnapshot, } from './generate.js'; |
| export declare const RETRYABLE_NUMERIC_CODES: Set<number>; | ||
| export declare const RETRYABLE_TRANSIENT_CODES: Set<string>; | ||
| export declare function toUpperStringCode(candidate: unknown): string | undefined; | ||
| export declare function getNumericErrorCode(error: unknown): number | undefined; | ||
| export declare function shouldRetry(error: unknown): boolean; | ||
| export declare function getRetryDelayMs(attempt: number): number; | ||
| export declare function canRetryAttempt(attempt: number, maxRetries: number, error: unknown): boolean; |
| import { randomInt } from 'node:crypto'; | ||
| import { getErrorMessage, RETRYABLE_UPSTREAM_ERROR_PATTERN, toRecord, } from '../errors.js'; | ||
| const DIGITS_ONLY_PATTERN = /^\d+$/; | ||
| const RETRY_DELAY_BASE_MS = 300; | ||
| const RETRY_DELAY_MAX_MS = 5_000; | ||
| const RETRY_JITTER_RATIO = 0.2; | ||
| export const RETRYABLE_NUMERIC_CODES = new Set([429, 500, 502, 503, 504]); | ||
| export const RETRYABLE_TRANSIENT_CODES = new Set([ | ||
| 'RESOURCE_EXHAUSTED', | ||
| 'UNAVAILABLE', | ||
| 'DEADLINE_EXCEEDED', | ||
| 'INTERNAL', | ||
| 'ABORTED', | ||
| ]); | ||
| function getNestedError(error) { | ||
| const record = toRecord(error); | ||
| if (!record) { | ||
| return undefined; | ||
| } | ||
| const nested = record.error; | ||
| const nestedRecord = toRecord(nested); | ||
| if (!nestedRecord) { | ||
| return record; | ||
| } | ||
| return nestedRecord; | ||
| } | ||
| function toNumericCode(candidate) { | ||
| if (typeof candidate === 'number' && Number.isFinite(candidate)) { | ||
| return candidate; | ||
| } | ||
| if (typeof candidate === 'string' && DIGITS_ONLY_PATTERN.test(candidate)) { | ||
| return Number.parseInt(candidate, 10); | ||
| } | ||
| return undefined; | ||
| } | ||
| export function toUpperStringCode(candidate) { | ||
| if (typeof candidate !== 'string') { | ||
| return undefined; | ||
| } | ||
| const normalized = candidate.trim().toUpperCase(); | ||
| return normalized.length > 0 ? normalized : undefined; | ||
| } | ||
| function findFirstNumericCode(record, keys) { | ||
| for (const key of keys) { | ||
| const numericCode = toNumericCode(record[key]); | ||
| if (numericCode !== undefined) { | ||
| return numericCode; | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
| function findFirstStringCode(record, keys) { | ||
| for (const key of keys) { | ||
| const stringCode = toUpperStringCode(record[key]); | ||
| if (stringCode !== undefined) { | ||
| return stringCode; | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
| const NUMERIC_ERROR_KEYS = ['status', 'statusCode', 'code']; | ||
| export function getNumericErrorCode(error) { | ||
| const record = getNestedError(error); | ||
| if (!record) { | ||
| return undefined; | ||
| } | ||
| return findFirstNumericCode(record, NUMERIC_ERROR_KEYS); | ||
| } | ||
| const TRANSIENT_ERROR_KEYS = ['code', 'status', 'statusText']; | ||
| function getTransientErrorCode(error) { | ||
| const record = getNestedError(error); | ||
| if (!record) { | ||
| return undefined; | ||
| } | ||
| return findFirstStringCode(record, TRANSIENT_ERROR_KEYS); | ||
| } | ||
| export function shouldRetry(error) { | ||
| const numericCode = getNumericErrorCode(error); | ||
| if (numericCode !== undefined && RETRYABLE_NUMERIC_CODES.has(numericCode)) { | ||
| return true; | ||
| } | ||
| const transientCode = getTransientErrorCode(error); | ||
| if (transientCode !== undefined && | ||
| RETRYABLE_TRANSIENT_CODES.has(transientCode)) { | ||
| return true; | ||
| } | ||
| const message = getErrorMessage(error); | ||
| return RETRYABLE_UPSTREAM_ERROR_PATTERN.test(message); | ||
| } | ||
| export function getRetryDelayMs(attempt) { | ||
| const exponentialDelay = RETRY_DELAY_BASE_MS * 2 ** attempt; | ||
| const boundedDelay = Math.min(RETRY_DELAY_MAX_MS, exponentialDelay); | ||
| const jitterWindow = Math.max(1, Math.floor(boundedDelay * RETRY_JITTER_RATIO)); | ||
| const jitter = randomInt(0, jitterWindow); | ||
| return Math.min(RETRY_DELAY_MAX_MS, boundedDelay + jitter); | ||
| } | ||
| export function canRetryAttempt(attempt, maxRetries, error) { | ||
| return attempt < maxRetries && shouldRetry(error); | ||
| } |
| type JsonRecord = Record<string, unknown>; | ||
| export declare function stripJsonSchemaConstraints(schema: JsonRecord): JsonRecord; | ||
| export {}; |
| const CONSTRAINT_KEY_VALUES = [ | ||
| 'minLength', | ||
| 'maxLength', | ||
| 'minimum', | ||
| 'maximum', | ||
| 'exclusiveMinimum', | ||
| 'exclusiveMaximum', | ||
| 'minItems', | ||
| 'maxItems', | ||
| 'multipleOf', | ||
| 'pattern', | ||
| 'format', | ||
| ]; | ||
| const CONSTRAINT_KEYS = new Set(CONSTRAINT_KEY_VALUES); | ||
| const INTEGER_JSON_TYPE = 'integer'; | ||
| const NUMBER_JSON_TYPE = 'number'; | ||
| function isJsonRecord(value) { | ||
| return typeof value === 'object' && value !== null && !Array.isArray(value); | ||
| } | ||
| function stripConstraintValue(value) { | ||
| if (Array.isArray(value)) { | ||
| const stripped = new Array(value.length); | ||
| for (let index = 0; index < value.length; index += 1) { | ||
| stripped[index] = stripConstraintValue(value[index]); | ||
| } | ||
| return stripped; | ||
| } | ||
| if (isJsonRecord(value)) { | ||
| return stripJsonSchemaConstraints(value); | ||
| } | ||
| return value; | ||
| } | ||
| export function stripJsonSchemaConstraints(schema) { | ||
| const result = {}; | ||
| for (const [key, value] of Object.entries(schema)) { | ||
| if (CONSTRAINT_KEYS.has(key)) { | ||
| continue; | ||
| } | ||
| if (key === 'type' && value === INTEGER_JSON_TYPE) { | ||
| result[key] = NUMBER_JSON_TYPE; | ||
| continue; | ||
| } | ||
| result[key] = stripConstraintValue(value); | ||
| } | ||
| return result; | ||
| } |
| export interface SearchStoreSlot { | ||
| storeName: string; | ||
| displayName: string; | ||
| documentCount: number; | ||
| createdAt: number; | ||
| } | ||
| /** | ||
| * Create a new Gemini File Search Store. | ||
| * Returns the store name on success, or undefined on failure (logged). | ||
| */ | ||
| export declare function createSearchStore(displayName: string): Promise<string | undefined>; | ||
| /** | ||
| * Upload in-memory file content to a File Search Store. | ||
| * Returns the document name on success, or undefined on failure. | ||
| */ | ||
| export declare function uploadToSearchStore(storeName: string, fileName: string, content: string, mimeType: string): Promise<string | undefined>; | ||
| /** | ||
| * Get store info from the API. Returns undefined on failure. | ||
| */ | ||
| export declare function getSearchStoreInfo(storeName: string): Promise<{ | ||
| name: string; | ||
| activeDocuments: number; | ||
| pendingDocuments: number; | ||
| failedDocuments: number; | ||
| sizeBytes: number; | ||
| } | undefined>; | ||
| /** | ||
| * Delete a File Search Store from the API (with force=true to remove documents). | ||
| * Best-effort: errors are logged but not thrown. | ||
| */ | ||
| export declare function deleteSearchStore(storeName: string): Promise<void>; | ||
| export declare function getCurrentSearchStore(): SearchStoreSlot | undefined; | ||
| export declare function setCurrentSearchStore(slot: SearchStoreSlot): void; | ||
| /** Clear local state without API call. */ | ||
| export declare function clearSearchStoreLocal(): void; | ||
| /** Expose for testing. */ | ||
| export declare function setSearchStoreForTesting(slot: SearchStoreSlot | undefined): void; |
| import { debuglog } from 'node:util'; | ||
| import { getClient } from './client.js'; | ||
| // --------------------------------------------------------------------------- | ||
| // State | ||
| // --------------------------------------------------------------------------- | ||
| const debug = debuglog('gemini:search-store'); | ||
| let currentStore; | ||
| // --------------------------------------------------------------------------- | ||
| // Store lifecycle | ||
| // --------------------------------------------------------------------------- | ||
| /** | ||
| * Create a new Gemini File Search Store. | ||
| * Returns the store name on success, or undefined on failure (logged). | ||
| */ | ||
| export async function createSearchStore(displayName) { | ||
| try { | ||
| const store = await getClient().fileSearchStores.create({ | ||
| config: { displayName }, | ||
| }); | ||
| if (!store.name) { | ||
| debug('Store created but no name returned'); | ||
| return undefined; | ||
| } | ||
| debug('Search store created: %s', store.name); | ||
| return store.name; | ||
| } | ||
| catch (error) { | ||
| debug('Failed to create search store: %O', error); | ||
| return undefined; | ||
| } | ||
| } | ||
| /** | ||
| * Upload in-memory file content to a File Search Store. | ||
| * Returns the document name on success, or undefined on failure. | ||
| */ | ||
| export async function uploadToSearchStore(storeName, fileName, content, mimeType) { | ||
| try { | ||
| const blob = new Blob([content], { type: mimeType }); | ||
| const operation = await getClient().fileSearchStores.uploadToFileSearchStore({ | ||
| fileSearchStoreName: storeName, | ||
| file: blob, | ||
| config: { mimeType, displayName: fileName }, | ||
| }); | ||
| debug('Upload to store %s: operation=%s', storeName, operation.name); | ||
| return operation.response?.documentName; | ||
| } | ||
| catch (error) { | ||
| debug('Failed to upload %s to store %s: %O', fileName, storeName, error); | ||
| return undefined; | ||
| } | ||
| } | ||
| /** | ||
| * Get store info from the API. Returns undefined on failure. | ||
| */ | ||
| export async function getSearchStoreInfo(storeName) { | ||
| try { | ||
| const store = await getClient().fileSearchStores.get({ name: storeName }); | ||
| return { | ||
| name: store.name ?? storeName, | ||
| activeDocuments: Number.parseInt(store.activeDocumentsCount ?? '0', 10) || 0, | ||
| pendingDocuments: Number.parseInt(store.pendingDocumentsCount ?? '0', 10) || 0, | ||
| failedDocuments: Number.parseInt(store.failedDocumentsCount ?? '0', 10) || 0, | ||
| sizeBytes: Number.parseInt(store.sizeBytes ?? '0', 10) || 0, | ||
| }; | ||
| } | ||
| catch (error) { | ||
| debug('Failed to get store info for %s: %O', storeName, error); | ||
| return undefined; | ||
| } | ||
| } | ||
| /** | ||
| * Delete a File Search Store from the API (with force=true to remove documents). | ||
| * Best-effort: errors are logged but not thrown. | ||
| */ | ||
| export async function deleteSearchStore(storeName) { | ||
| try { | ||
| await getClient().fileSearchStores.delete({ | ||
| name: storeName, | ||
| config: { force: true }, | ||
| }); | ||
| debug('Search store deleted: %s', storeName); | ||
| } | ||
| catch (error) { | ||
| debug('Failed to delete search store %s: %O', storeName, error); | ||
| } | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Current store state management | ||
| // --------------------------------------------------------------------------- | ||
| export function getCurrentSearchStore() { | ||
| return currentStore; | ||
| } | ||
| export function setCurrentSearchStore(slot) { | ||
| currentStore = slot; | ||
| } | ||
| /** Clear local state without API call. */ | ||
| export function clearSearchStoreLocal() { | ||
| currentStore = undefined; | ||
| } | ||
| /** Expose for testing. */ | ||
| export function setSearchStoreForTesting(slot) { | ||
| currentStore = slot; | ||
| } |
| export type JsonObject = Record<string, unknown>; | ||
| export type GeminiLogHandler = (level: string, data: unknown) => Promise<void>; | ||
| export type GeminiThinkingLevel = 'minimal' | 'low' | 'medium' | 'high'; | ||
| export interface GeminiRequestExecutionOptions { | ||
| maxRetries?: number; | ||
| timeoutMs?: number; | ||
| temperature?: number; | ||
| maxOutputTokens?: number; | ||
| thinkingLevel?: GeminiThinkingLevel; | ||
| includeThoughts?: boolean; | ||
| signal?: AbortSignal; | ||
| onLog?: GeminiLogHandler; | ||
| responseKeyOrdering?: readonly string[]; | ||
| batchMode?: 'off' | 'inline'; | ||
| useGrounding?: boolean; | ||
| useCodeExecution?: boolean; | ||
| fileSearchStoreNames?: readonly string[]; | ||
| } | ||
| export interface GeminiStructuredRequestOptions extends GeminiRequestExecutionOptions { | ||
| model?: string; | ||
| } | ||
| export interface GeminiStructuredRequest extends GeminiStructuredRequestOptions { | ||
| systemInstruction?: string; | ||
| prompt: string; | ||
| responseSchema: Readonly<JsonObject>; | ||
| cachedContent?: string; | ||
| } | ||
| export type GeminiOnLog = GeminiStructuredRequest['onLog']; | ||
| export interface CodeExecutionBlock { | ||
| code: string; | ||
| language: string; | ||
| } | ||
| export interface CodeExecutionResultBlock { | ||
| outcome: string; | ||
| output: string; | ||
| } | ||
| export interface CodeExecutionResponse { | ||
| text: string; | ||
| codeBlocks: CodeExecutionBlock[]; | ||
| executionResults: CodeExecutionResultBlock[]; | ||
| } |
| export {}; |
| export declare const INSPECTION_FOCUS_AREAS: readonly ["security", "correctness", "performance", "regressions", "tests", "maintainability", "concurrency"]; | ||
| export interface ToolParameterContract { | ||
| name: string; | ||
| type: string; | ||
| required: boolean; | ||
| constraints: string; | ||
| description: string; | ||
| } | ||
| export interface ToolContract { | ||
| name: string; | ||
| purpose: string; | ||
| /** Set to 'none' for synchronous (non-Gemini) tools. */ | ||
| model: string; | ||
| /** Set to 0 for synchronous (non-Gemini) tools. */ | ||
| timeoutMs: number; | ||
| thinkingLevel?: 'minimal' | 'low' | 'medium' | 'high'; | ||
| /** Set to 0 for synchronous (non-Gemini) tools. */ | ||
| maxOutputTokens: number; | ||
| /** | ||
| * Sampling temperature for the Gemini call. | ||
| * Gemini 3 recommends 1.0 for all tasks. | ||
| */ | ||
| temperature?: number; | ||
| /** Enables deterministic JSON guidance and schema key ordering. */ | ||
| deterministicJson?: boolean; | ||
| params: readonly ToolParameterContract[]; | ||
| outputShape: string; | ||
| gotchas: readonly string[]; | ||
| crossToolFlow: readonly string[]; | ||
| constraints?: readonly string[]; | ||
| } | ||
| interface StructuredToolRuntimeOptions { | ||
| thinkingLevel?: NonNullable<ToolContract['thinkingLevel']>; | ||
| temperature?: NonNullable<ToolContract['temperature']>; | ||
| deterministicJson?: NonNullable<ToolContract['deterministicJson']>; | ||
| } | ||
| interface StructuredToolExecutionOptions extends StructuredToolRuntimeOptions { | ||
| timeoutMs: ToolContract['timeoutMs']; | ||
| maxOutputTokens: ToolContract['maxOutputTokens']; | ||
| } | ||
| export declare function buildStructuredToolRuntimeOptions(contract: Pick<ToolContract, 'thinkingLevel' | 'temperature' | 'deterministicJson'>): StructuredToolRuntimeOptions; | ||
| export declare function buildStructuredToolExecutionOptions(contract: Pick<ToolContract, 'timeoutMs' | 'maxOutputTokens' | 'thinkingLevel' | 'temperature' | 'deterministicJson'>): StructuredToolExecutionOptions; | ||
| export declare const TOOL_CONTRACTS: readonly [{ | ||
| readonly name: "generate_diff"; | ||
| readonly purpose: "Generate a diff of current changes and cache it server-side. MUST be called before any other tool. Uses git to capture unstaged or staged changes in the current working directory."; | ||
| readonly model: "none"; | ||
| readonly timeoutMs: 0; | ||
| readonly maxOutputTokens: 0; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{ok, result: {diffRef, stats{files, added, deleted}, generatedAt, mode, message}}"; | ||
| readonly gotchas: readonly ["Must be called first — all other tools return E_NO_DIFF if no diff is cached.", "Noisy files (lock files, dist/, build/, minified assets) are excluded automatically.", "Empty diff (no changes) returns E_NO_CHANGES."]; | ||
| readonly crossToolFlow: readonly ["Caches diff at internal://diff/current — consumed automatically by all review tools."]; | ||
| }, { | ||
| readonly name: "analyze_pr_impact"; | ||
| readonly purpose: "Assess severity, categories, breaking changes, and rollback complexity."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 90000; | ||
| readonly thinkingLevel: "minimal"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{severity, categories[], summary, breakingChanges[], affectedAreas[], rollbackComplexity}"; | ||
| readonly gotchas: readonly ["Requires generate_diff to be called first.", "Flash triage tool optimized for speed."]; | ||
| readonly crossToolFlow: readonly ["severity/categories feed triage and merge-gate decisions."]; | ||
| }, { | ||
| readonly name: "generate_review_summary"; | ||
| readonly purpose: "Produce PR summary, risk rating, and merge recommendation."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 90000; | ||
| readonly thinkingLevel: "minimal"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{summary, overallRisk, keyChanges[], recommendation, stats{filesChanged, linesAdded, linesRemoved}}"; | ||
| readonly gotchas: readonly ["Requires generate_diff to be called first.", "stats are computed locally from the diff."]; | ||
| readonly crossToolFlow: readonly ["Use before deep review to decide whether Pro analysis is needed."]; | ||
| }, { | ||
| readonly name: "generate_test_plan"; | ||
| readonly purpose: "Generate prioritized test cases and coverage guidance."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 90000; | ||
| readonly thinkingLevel: "medium"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{summary, testCases[], coverageSummary}"; | ||
| readonly gotchas: readonly ["Requires generate_diff to be called first.", "maxTestCases caps output after generation."]; | ||
| readonly crossToolFlow: readonly ["Pair with review tools to validate high-risk paths."]; | ||
| }, { | ||
| readonly name: "analyze_time_space_complexity"; | ||
| readonly purpose: "Analyze Big-O complexity and detect degradations in changed code."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 90000; | ||
| readonly thinkingLevel: "medium"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{timeComplexity, spaceComplexity, explanation, potentialBottlenecks[], isDegradation}"; | ||
| readonly gotchas: readonly ["Requires generate_diff to be called first.", "Analyzes only changed code visible in the diff."]; | ||
| readonly crossToolFlow: readonly ["Use for algorithmic/performance-sensitive changes."]; | ||
| }, { | ||
| readonly name: "detect_api_breaking_changes"; | ||
| readonly purpose: "Detect breaking API/interface changes in a diff."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 90000; | ||
| readonly thinkingLevel: "minimal"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{hasBreakingChanges, breakingChanges[]}"; | ||
| readonly gotchas: readonly ["Requires generate_diff to be called first.", "Targets public API contracts over internal refactors."]; | ||
| readonly crossToolFlow: readonly ["Run before merge for API-surface-sensitive changes."]; | ||
| }, { | ||
| readonly name: "load_file"; | ||
| readonly purpose: "Read a single file from disk and cache it server-side. MUST be called before any file analysis tool."; | ||
| readonly model: "none"; | ||
| readonly timeoutMs: 0; | ||
| readonly maxOutputTokens: 0; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{ok, result: {fileRef, filePath, language, lineCount, sizeChars, cachedAt, message}}"; | ||
| readonly gotchas: readonly ["Single file only — overwrites previous cache.", "Max file size enforced (120K chars default).", "File must be under workspace root."]; | ||
| readonly crossToolFlow: readonly ["Caches file at internal://file/current — consumed by refactor_code and future analysis tools."]; | ||
| }, { | ||
| readonly name: "refactor_code"; | ||
| readonly purpose: "Analyze cached file for naming, complexity, duplication, and grouping improvements."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 120000; | ||
| readonly thinkingLevel: "medium"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{filePath, language, summary, suggestions[{category, target, currentIssue, suggestion, priority}], *IssuesCount}"; | ||
| readonly gotchas: readonly ["Requires load_file first.", "Analyzes one file — does not suggest cross-file moves."]; | ||
| readonly crossToolFlow: readonly ["Use after load_file. Provides refactoring roadmap for the cached file."]; | ||
| }, { | ||
| readonly name: "ask_about_code"; | ||
| readonly purpose: "Answer natural-language questions about a cached file."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 120000; | ||
| readonly thinkingLevel: "medium"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{answer, codeReferences[{target, explanation}], confidence, filePath, language}"; | ||
| readonly gotchas: readonly ["Requires load_file first.", "Answers based solely on the cached file content."]; | ||
| readonly crossToolFlow: readonly ["Use after load_file. Complements refactor_code for understanding code."]; | ||
| }, { | ||
| readonly name: "verify_logic"; | ||
| readonly purpose: "Verify algorithms and logic in cached file using Gemini code execution sandbox."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 120000; | ||
| readonly thinkingLevel: "medium"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: false; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{answer, verified, codeBlocks[{code, language}], executionResults[{outcome, output}], filePath, language}"; | ||
| readonly gotchas: readonly ["Requires load_file first.", "Code execution runs Python only (server-side sandbox)."]; | ||
| readonly crossToolFlow: readonly ["Use after load_file. Complements ask_about_code for verification tasks."]; | ||
| }, { | ||
| readonly name: "web_search"; | ||
| readonly purpose: "Perform a Google Search with Grounding to get up-to-date information."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 90000; | ||
| readonly maxOutputTokens: 0; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{ok, result: {text, groundingMetadata}}"; | ||
| readonly gotchas: readonly ["Uses Gemini grounding — results depend on Google Search availability.", "No diff or file prerequisite."]; | ||
| readonly crossToolFlow: readonly ["Standalone tool for fetching up-to-date information from the web."]; | ||
| }, { | ||
| readonly name: "index_repository"; | ||
| readonly purpose: "Walk a local repository, upload source files to a Gemini File Search Store for RAG queries."; | ||
| readonly model: "none"; | ||
| readonly timeoutMs: 0; | ||
| readonly maxOutputTokens: 0; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{ok, result: {storeName, displayName, filesUploaded, filesSkipped, message}}"; | ||
| readonly gotchas: readonly ["Must be called before query_repository.", "Max 500 files, 1 MB per file.", "Re-indexing replaces the previous store."]; | ||
| readonly crossToolFlow: readonly ["Creates a search store consumed by query_repository."]; | ||
| }, { | ||
| readonly name: "query_repository"; | ||
| readonly purpose: "Query the indexed repository search store using natural language."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 120000; | ||
| readonly thinkingLevel: "medium"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{ok, result: {answer, references[]}}"; | ||
| readonly gotchas: readonly ["Requires index_repository first.", "Quality depends on indexed file coverage."]; | ||
| readonly crossToolFlow: readonly ["Use after index_repository for targeted codebase questions."]; | ||
| }]; | ||
| export declare function getToolContracts(): readonly ToolContract[]; | ||
| export declare function getToolContract(toolName: string): ToolContract | undefined; | ||
| export declare function requireToolContract(toolName: string): ToolContract; | ||
| export declare function getToolContractNames(): string[]; | ||
| export {}; |
| import { ANALYSIS_TEMPERATURE, CREATIVE_TEMPERATURE, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_TIMEOUT_EXTENDED_MS, FLASH_MODEL, FLASH_THINKING_LEVEL, FLASH_TRIAGE_THINKING_LEVEL, TRIAGE_TEMPERATURE, } from './config.js'; | ||
| const DEFAULT_TIMEOUT_FLASH_MS = 90_000; | ||
| export const INSPECTION_FOCUS_AREAS = [ | ||
| 'security', | ||
| 'correctness', | ||
| 'performance', | ||
| 'regressions', | ||
| 'tests', | ||
| 'maintainability', | ||
| 'concurrency', | ||
| ]; | ||
| export function buildStructuredToolRuntimeOptions(contract) { | ||
| return { | ||
| ...(contract.thinkingLevel !== undefined | ||
| ? { thinkingLevel: contract.thinkingLevel } | ||
| : {}), | ||
| ...(contract.temperature !== undefined | ||
| ? { temperature: contract.temperature } | ||
| : {}), | ||
| ...(contract.deterministicJson !== undefined | ||
| ? { deterministicJson: contract.deterministicJson } | ||
| : {}), | ||
| }; | ||
| } | ||
| export function buildStructuredToolExecutionOptions(contract) { | ||
| return { | ||
| timeoutMs: contract.timeoutMs, | ||
| maxOutputTokens: contract.maxOutputTokens, | ||
| ...buildStructuredToolRuntimeOptions(contract), | ||
| }; | ||
| } | ||
| function createParam(name, type, required, constraints, description) { | ||
| return { name, type, required, constraints, description }; | ||
| } | ||
| function cloneParams(...params) { | ||
| return params.map((param) => ({ ...param })); | ||
| } | ||
| const MODE_PARAM = createParam('mode', 'string', true, "'unstaged' | 'staged'", "'unstaged': working tree changes not yet staged. 'staged': changes added to the index (git add)."); | ||
| const REPOSITORY_PARAM = createParam('repository', 'string', true, '1-200 chars', 'Repository identifier (org/repo).'); | ||
| const LANGUAGE_PARAM = createParam('language', 'string', false, '2-32 chars', 'Primary language hint.'); | ||
| const TEST_FRAMEWORK_PARAM = createParam('testFramework', 'string', false, '1-50 chars', 'Framework hint (jest, vitest, pytest, node:test).'); | ||
| const MAX_TEST_CASES_PARAM = createParam('maxTestCases', 'number', false, '1-30', 'Post-generation cap applied to test cases.'); | ||
| const FILE_PATH_PARAM = createParam('filePath', 'string', true, '1-500 chars', 'Absolute path to the file to analyze.'); | ||
| const QUESTION_PARAM = createParam('question', 'string', true, '1-2000 chars', 'Question about the loaded file.'); | ||
| const QUERY_PARAM = createParam('query', 'string', true, '1-1000 chars', 'Search query.'); | ||
| const QUERY_REPO_PARAM = createParam('query', 'string', true, '1-2000 chars', 'Natural-language question about the repository codebase.'); | ||
| const ROOT_PATH_PARAM = createParam('rootPath', 'string', true, '1-500 chars', 'Absolute path to the repository root directory.'); | ||
| const DISPLAY_NAME_PARAM = createParam('displayName', 'string', false, '1-100 chars', 'Display name for the search store. Default: directory name.'); | ||
| export const TOOL_CONTRACTS = [ | ||
| { | ||
| name: 'generate_diff', | ||
| purpose: 'Generate a diff of current changes and cache it server-side. MUST be called before any other tool. Uses git to capture unstaged or staged changes in the current working directory.', | ||
| model: 'none', | ||
| timeoutMs: 0, | ||
| maxOutputTokens: 0, | ||
| params: cloneParams(MODE_PARAM), | ||
| outputShape: '{ok, result: {diffRef, stats{files, added, deleted}, generatedAt, mode, message}}', | ||
| gotchas: [ | ||
| 'Must be called first — all other tools return E_NO_DIFF if no diff is cached.', | ||
| 'Noisy files (lock files, dist/, build/, minified assets) are excluded automatically.', | ||
| 'Empty diff (no changes) returns E_NO_CHANGES.', | ||
| ], | ||
| crossToolFlow: [ | ||
| 'Caches diff at internal://diff/current — consumed automatically by all review tools.', | ||
| ], | ||
| }, | ||
| { | ||
| name: 'analyze_pr_impact', | ||
| purpose: 'Assess severity, categories, breaking changes, and rollback complexity.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_FLASH_MS, | ||
| thinkingLevel: FLASH_TRIAGE_THINKING_LEVEL, | ||
| maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS, | ||
| temperature: TRIAGE_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(REPOSITORY_PARAM, LANGUAGE_PARAM), | ||
| outputShape: '{severity, categories[], summary, breakingChanges[], affectedAreas[], rollbackComplexity}', | ||
| gotchas: [ | ||
| 'Requires generate_diff to be called first.', | ||
| 'Flash triage tool optimized for speed.', | ||
| ], | ||
| crossToolFlow: [ | ||
| 'severity/categories feed triage and merge-gate decisions.', | ||
| ], | ||
| }, | ||
| { | ||
| name: 'generate_review_summary', | ||
| purpose: 'Produce PR summary, risk rating, and merge recommendation.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_FLASH_MS, | ||
| thinkingLevel: FLASH_TRIAGE_THINKING_LEVEL, | ||
| maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS, | ||
| temperature: TRIAGE_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(REPOSITORY_PARAM, LANGUAGE_PARAM), | ||
| outputShape: '{summary, overallRisk, keyChanges[], recommendation, stats{filesChanged, linesAdded, linesRemoved}}', | ||
| gotchas: [ | ||
| 'Requires generate_diff to be called first.', | ||
| 'stats are computed locally from the diff.', | ||
| ], | ||
| crossToolFlow: [ | ||
| 'Use before deep review to decide whether Pro analysis is needed.', | ||
| ], | ||
| }, | ||
| { | ||
| name: 'generate_test_plan', | ||
| purpose: 'Generate prioritized test cases and coverage guidance.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_FLASH_MS, | ||
| thinkingLevel: FLASH_THINKING_LEVEL, | ||
| maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS, | ||
| temperature: CREATIVE_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(REPOSITORY_PARAM, LANGUAGE_PARAM, TEST_FRAMEWORK_PARAM, MAX_TEST_CASES_PARAM), | ||
| outputShape: '{summary, testCases[], coverageSummary}', | ||
| gotchas: [ | ||
| 'Requires generate_diff to be called first.', | ||
| 'maxTestCases caps output after generation.', | ||
| ], | ||
| crossToolFlow: ['Pair with review tools to validate high-risk paths.'], | ||
| }, | ||
| { | ||
| name: 'analyze_time_space_complexity', | ||
| purpose: 'Analyze Big-O complexity and detect degradations in changed code.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_FLASH_MS, | ||
| thinkingLevel: FLASH_THINKING_LEVEL, | ||
| maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS, | ||
| temperature: ANALYSIS_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(LANGUAGE_PARAM), | ||
| outputShape: '{timeComplexity, spaceComplexity, explanation, potentialBottlenecks[], isDegradation}', | ||
| gotchas: [ | ||
| 'Requires generate_diff to be called first.', | ||
| 'Analyzes only changed code visible in the diff.', | ||
| ], | ||
| crossToolFlow: ['Use for algorithmic/performance-sensitive changes.'], | ||
| }, | ||
| { | ||
| name: 'detect_api_breaking_changes', | ||
| purpose: 'Detect breaking API/interface changes in a diff.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_FLASH_MS, | ||
| thinkingLevel: FLASH_TRIAGE_THINKING_LEVEL, | ||
| maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS, | ||
| temperature: TRIAGE_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(LANGUAGE_PARAM), | ||
| outputShape: '{hasBreakingChanges, breakingChanges[]}', | ||
| gotchas: [ | ||
| 'Requires generate_diff to be called first.', | ||
| 'Targets public API contracts over internal refactors.', | ||
| ], | ||
| crossToolFlow: ['Run before merge for API-surface-sensitive changes.'], | ||
| }, | ||
| { | ||
| name: 'load_file', | ||
| purpose: 'Read a single file from disk and cache it server-side. MUST be called before any file analysis tool.', | ||
| model: 'none', | ||
| timeoutMs: 0, | ||
| maxOutputTokens: 0, | ||
| params: cloneParams(FILE_PATH_PARAM), | ||
| outputShape: '{ok, result: {fileRef, filePath, language, lineCount, sizeChars, cachedAt, message}}', | ||
| gotchas: [ | ||
| 'Single file only — overwrites previous cache.', | ||
| 'Max file size enforced (120K chars default).', | ||
| 'File must be under workspace root.', | ||
| ], | ||
| crossToolFlow: [ | ||
| 'Caches file at internal://file/current — consumed by refactor_code and future analysis tools.', | ||
| ], | ||
| }, | ||
| { | ||
| name: 'refactor_code', | ||
| purpose: 'Analyze cached file for naming, complexity, duplication, and grouping improvements.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_EXTENDED_MS, | ||
| thinkingLevel: FLASH_THINKING_LEVEL, | ||
| maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS, | ||
| temperature: ANALYSIS_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(LANGUAGE_PARAM), | ||
| outputShape: '{filePath, language, summary, suggestions[{category, target, currentIssue, suggestion, priority}], *IssuesCount}', | ||
| gotchas: [ | ||
| 'Requires load_file first.', | ||
| 'Analyzes one file — does not suggest cross-file moves.', | ||
| ], | ||
| crossToolFlow: [ | ||
| 'Use after load_file. Provides refactoring roadmap for the cached file.', | ||
| ], | ||
| }, | ||
| { | ||
| name: 'ask_about_code', | ||
| purpose: 'Answer natural-language questions about a cached file.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_EXTENDED_MS, | ||
| thinkingLevel: FLASH_THINKING_LEVEL, | ||
| maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS, | ||
| temperature: ANALYSIS_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(QUESTION_PARAM, LANGUAGE_PARAM), | ||
| outputShape: '{answer, codeReferences[{target, explanation}], confidence, filePath, language}', | ||
| gotchas: [ | ||
| 'Requires load_file first.', | ||
| 'Answers based solely on the cached file content.', | ||
| ], | ||
| crossToolFlow: [ | ||
| 'Use after load_file. Complements refactor_code for understanding code.', | ||
| ], | ||
| }, | ||
| { | ||
| name: 'verify_logic', | ||
| purpose: 'Verify algorithms and logic in cached file using Gemini code execution sandbox.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_EXTENDED_MS, | ||
| thinkingLevel: FLASH_THINKING_LEVEL, | ||
| maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS, | ||
| temperature: ANALYSIS_TEMPERATURE, | ||
| deterministicJson: false, | ||
| params: cloneParams(QUESTION_PARAM, LANGUAGE_PARAM), | ||
| outputShape: '{answer, verified, codeBlocks[{code, language}], executionResults[{outcome, output}], filePath, language}', | ||
| gotchas: [ | ||
| 'Requires load_file first.', | ||
| 'Code execution runs Python only (server-side sandbox).', | ||
| ], | ||
| crossToolFlow: [ | ||
| 'Use after load_file. Complements ask_about_code for verification tasks.', | ||
| ], | ||
| }, | ||
| { | ||
| name: 'web_search', | ||
| purpose: 'Perform a Google Search with Grounding to get up-to-date information.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_FLASH_MS, | ||
| maxOutputTokens: 0, | ||
| params: cloneParams(QUERY_PARAM), | ||
| outputShape: '{ok, result: {text, groundingMetadata}}', | ||
| gotchas: [ | ||
| 'Uses Gemini grounding — results depend on Google Search availability.', | ||
| 'No diff or file prerequisite.', | ||
| ], | ||
| crossToolFlow: [ | ||
| 'Standalone tool for fetching up-to-date information from the web.', | ||
| ], | ||
| }, | ||
| { | ||
| name: 'index_repository', | ||
| purpose: 'Walk a local repository, upload source files to a Gemini File Search Store for RAG queries.', | ||
| model: 'none', | ||
| timeoutMs: 0, | ||
| maxOutputTokens: 0, | ||
| params: cloneParams(ROOT_PATH_PARAM, DISPLAY_NAME_PARAM), | ||
| outputShape: '{ok, result: {storeName, displayName, filesUploaded, filesSkipped, message}}', | ||
| gotchas: [ | ||
| 'Must be called before query_repository.', | ||
| 'Max 500 files, 1 MB per file.', | ||
| 'Re-indexing replaces the previous store.', | ||
| ], | ||
| crossToolFlow: ['Creates a search store consumed by query_repository.'], | ||
| }, | ||
| { | ||
| name: 'query_repository', | ||
| purpose: 'Query the indexed repository search store using natural language.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_EXTENDED_MS, | ||
| thinkingLevel: FLASH_THINKING_LEVEL, | ||
| maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS, | ||
| temperature: ANALYSIS_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(QUERY_REPO_PARAM, LANGUAGE_PARAM), | ||
| outputShape: '{ok, result: {answer, references[]}}', | ||
| gotchas: [ | ||
| 'Requires index_repository first.', | ||
| 'Quality depends on indexed file coverage.', | ||
| ], | ||
| crossToolFlow: [ | ||
| 'Use after index_repository for targeted codebase questions.', | ||
| ], | ||
| }, | ||
| ]; | ||
| const TOOL_CONTRACTS_BY_NAME = new Map(TOOL_CONTRACTS.map((contract) => [contract.name, contract])); | ||
| export function getToolContracts() { | ||
| return TOOL_CONTRACTS; | ||
| } | ||
| export function getToolContract(toolName) { | ||
| return TOOL_CONTRACTS_BY_NAME.get(toolName); | ||
| } | ||
| export function requireToolContract(toolName) { | ||
| const contract = getToolContract(toolName); | ||
| if (contract) { | ||
| return contract; | ||
| } | ||
| throw new Error(`Unknown tool contract: ${toolName}`); | ||
| } | ||
| export function getToolContractNames() { | ||
| return TOOL_CONTRACTS.map((contract) => contract.name); | ||
| } |
| export type ErrorKind = 'validation' | 'budget' | 'upstream' | 'timeout' | 'cancelled' | 'internal' | 'busy'; | ||
| export interface ErrorMeta { | ||
| retryable?: boolean; | ||
| kind?: ErrorKind; | ||
| } | ||
| interface ToolError { | ||
| code: string; | ||
| message: string; | ||
| retryable?: boolean; | ||
| kind?: ErrorKind; | ||
| } | ||
| interface ToolTextContent { | ||
| type: 'text'; | ||
| text: string; | ||
| } | ||
| interface ToolEmbeddedResource { | ||
| type: 'resource'; | ||
| resource: { | ||
| uri: string; | ||
| mimeType: string; | ||
| text: string; | ||
| }; | ||
| } | ||
| export type ToolContentBlock = ToolTextContent | ToolEmbeddedResource; | ||
| interface ToolStructuredContent { | ||
| [key: string]: unknown; | ||
| ok: boolean; | ||
| result?: unknown; | ||
| error?: ToolError; | ||
| } | ||
| interface ToolResponse<TStructuredContent extends ToolStructuredContent> { | ||
| [key: string]: unknown; | ||
| content: ToolContentBlock[]; | ||
| structuredContent: TStructuredContent; | ||
| } | ||
| interface ErrorToolResponse { | ||
| [key: string]: unknown; | ||
| content: ToolContentBlock[]; | ||
| isError: true; | ||
| } | ||
| export declare function createToolResponse<TStructuredContent extends ToolStructuredContent>(structured: TStructuredContent, textContent?: string): ToolResponse<TStructuredContent>; | ||
| export declare function createErrorToolResponse(code: string, message: string, result?: unknown, meta?: ErrorMeta): ErrorToolResponse; | ||
| export {}; |
| function appendErrorMeta(error, meta) { | ||
| if (meta?.retryable !== undefined) { | ||
| error.retryable = meta.retryable; | ||
| } | ||
| if (meta?.kind !== undefined) { | ||
| error.kind = meta.kind; | ||
| } | ||
| } | ||
| function createToolError(code, message, meta) { | ||
| const error = { code, message }; | ||
| appendErrorMeta(error, meta); | ||
| return error; | ||
| } | ||
| function toContentBlocks(structured, textContent) { | ||
| const text = textContent ?? JSON.stringify(structured); | ||
| const blocks = [{ type: 'text', text }]; | ||
| if (textContent) { | ||
| blocks.push({ | ||
| type: 'resource', | ||
| resource: { | ||
| uri: 'internal://preview/result.md', | ||
| mimeType: 'text/markdown', | ||
| text: textContent, | ||
| }, | ||
| }); | ||
| } | ||
| return blocks; | ||
| } | ||
| function createErrorStructuredContent(code, message, result, meta) { | ||
| const error = createToolError(code, message, meta); | ||
| if (result === undefined) { | ||
| return { ok: false, error }; | ||
| } | ||
| return { ok: false, error, result }; | ||
| } | ||
| export function createToolResponse(structured, textContent) { | ||
| return { | ||
| content: toContentBlocks(structured, textContent), | ||
| structuredContent: structured, | ||
| }; | ||
| } | ||
| export function createErrorToolResponse(code, message, result, meta) { | ||
| const structured = createErrorStructuredContent(code, message, result, meta); | ||
| return { | ||
| content: toContentBlocks(structured), | ||
| isError: true, | ||
| }; | ||
| } |
| import { z } from 'zod'; | ||
| export declare function createBoundedString(min: number, max: number, description: string): z.ZodString; | ||
| export declare function createOptionalBoundedString(min: number, max: number, description: string): z.ZodOptional<z.ZodString>; | ||
| export declare function createBoundedStringArray(itemMin: number, itemMax: number, minItems: number, maxItems: number, description: string): z.ZodArray<z.ZodString>; |
| import { z } from 'zod'; | ||
| export function createBoundedString(min, max, description) { | ||
| return z.string().min(min).max(max).describe(description); | ||
| } | ||
| export function createOptionalBoundedString(min, max, description) { | ||
| return createBoundedString(min, max, description).optional(); | ||
| } | ||
| export function createBoundedStringArray(itemMin, itemMax, minItems, maxItems, description) { | ||
| return z | ||
| .array(z.string().min(itemMin).max(itemMax)) | ||
| .min(minItems) | ||
| .max(maxItems) | ||
| .describe(description); | ||
| } |
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| export declare function registerAskTool(server: McpServer): void; |
| import { getFileContextSnapshot } from '../lib/tools.js'; | ||
| import { buildStructuredToolExecutionOptions, registerStructuredToolTask, requireToolContract, } from '../lib/tools.js'; | ||
| import { AskInputSchema } from '../schemas/inputs.js'; | ||
| import { AskGeminiResultSchema, AskResultSchema } from '../schemas/outputs.js'; | ||
| const SYSTEM_INSTRUCTION = ` | ||
| <role> | ||
| Code Explanation Assistant. | ||
| </role> | ||
| <task> | ||
| Answer the user's question about the provided source file accurately and concisely. | ||
| </task> | ||
| <constraints> | ||
| - Answer based solely on the provided file content. | ||
| - Reference specific functions, classes, variables, or line ranges when relevant. | ||
| - If the question cannot be answered from the file alone, state that clearly. | ||
| - Do not speculate about code outside the provided file. | ||
| - Return strict JSON only. | ||
| </constraints> | ||
| `; | ||
| const TOOL_CONTRACT = requireToolContract('ask_about_code'); | ||
| export function registerAskTool(server) { | ||
| registerStructuredToolTask(server, { | ||
| name: 'ask_about_code', | ||
| title: 'Ask About Code', | ||
| description: 'Answer questions about a cached file. Prerequisite: load_file. Auto-infer language.', | ||
| inputSchema: AskInputSchema, | ||
| fullInputSchema: AskInputSchema, | ||
| resultSchema: AskResultSchema, | ||
| geminiSchema: AskGeminiResultSchema, | ||
| errorCode: 'E_ASK_CODE', | ||
| ...buildStructuredToolExecutionOptions(TOOL_CONTRACT), | ||
| requiresFile: true, | ||
| progressContext: (input) => input.question.slice(0, 60), | ||
| formatOutcome: (result) => `confidence: ${result.confidence}`, | ||
| formatOutput: (result) => { | ||
| const lines = [result.answer]; | ||
| if (result.codeReferences.length > 0) { | ||
| lines.push('', '### References', ''); | ||
| for (const ref of result.codeReferences) { | ||
| lines.push(`- **${ref.target}**: ${ref.explanation}`); | ||
| } | ||
| } | ||
| lines.push('', `*Confidence: ${result.confidence}*`); | ||
| return lines.join('\n'); | ||
| }, | ||
| buildPrompt: (input, ctx) => { | ||
| const file = getFileContextSnapshot(ctx); | ||
| const language = input.language ?? file.language; | ||
| return { | ||
| systemInstruction: SYSTEM_INSTRUCTION, | ||
| prompt: `Language: ${language}\nFile: ${file.filePath}\n\nSource code:\n${file.content}\n\nQuestion: ${input.question}`, | ||
| }; | ||
| }, | ||
| transformResult: (_input, result, ctx) => { | ||
| const file = getFileContextSnapshot(ctx); | ||
| return { | ||
| ...result, | ||
| filePath: file.filePath, | ||
| language: file.language, | ||
| }; | ||
| }, | ||
| }); | ||
| } |
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| export declare function registerIndexRepositoryTool(server: McpServer): void; |
| import { lstat, readdir, readFile, stat } from 'node:fs/promises'; | ||
| import path from 'node:path'; | ||
| import { createSearchStore, deleteSearchStore, getCurrentSearchStore, setCurrentSearchStore, uploadToSearchStore, } from '../lib/gemini/index.js'; | ||
| import { createErrorToolResponse, createToolResponse } from '../lib/tools.js'; | ||
| import { wrapToolHandler } from '../lib/tools.js'; | ||
| import { IndexRepositoryInputSchema } from '../schemas/inputs.js'; | ||
| import { DefaultOutputSchema } from '../schemas/outputs.js'; | ||
| // --------------------------------------------------------------------------- | ||
| // Constants | ||
| // --------------------------------------------------------------------------- | ||
| const MAX_FILE_BYTES = 1_048_576; // 1 MB | ||
| const MAX_FILES = 500; | ||
| const ALLOWED_EXTENSIONS = new Set([ | ||
| '.ts', | ||
| '.tsx', | ||
| '.js', | ||
| '.jsx', | ||
| '.mjs', | ||
| '.cjs', | ||
| '.py', | ||
| '.rb', | ||
| '.go', | ||
| '.rs', | ||
| '.java', | ||
| '.kt', | ||
| '.cs', | ||
| '.c', | ||
| '.cpp', | ||
| '.h', | ||
| '.hpp', | ||
| '.swift', | ||
| '.php', | ||
| '.sh', | ||
| '.bash', | ||
| '.json', | ||
| '.yaml', | ||
| '.yml', | ||
| '.toml', | ||
| '.xml', | ||
| '.html', | ||
| '.css', | ||
| '.scss', | ||
| '.sql', | ||
| '.md', | ||
| '.lua', | ||
| '.r', | ||
| '.dart', | ||
| '.ex', | ||
| '.exs', | ||
| '.erl', | ||
| '.zig', | ||
| '.vue', | ||
| '.svelte', | ||
| '.txt', | ||
| '.cfg', | ||
| '.ini', | ||
| '.env.example', | ||
| '.dockerfile', | ||
| '.tf', | ||
| '.graphql', | ||
| '.proto', | ||
| ]); | ||
| const DENIED_SEGMENTS = new Set([ | ||
| '.env', | ||
| '.git', | ||
| 'node_modules', | ||
| 'dist', | ||
| 'build', | ||
| '.next', | ||
| '__pycache__', | ||
| '.venv', | ||
| 'vendor', | ||
| 'coverage', | ||
| '.cache', | ||
| ]); | ||
| const EXTENSION_MIME_MAP = new Map([ | ||
| ['.json', 'application/json'], | ||
| ['.xml', 'application/xml'], | ||
| ['.html', 'text/html'], | ||
| ['.css', 'text/css'], | ||
| ['.md', 'text/markdown'], | ||
| ['.yaml', 'text/yaml'], | ||
| ['.yml', 'text/yaml'], | ||
| ]); | ||
| const VALIDATION_META = { retryable: false, kind: 'validation' }; | ||
| // --------------------------------------------------------------------------- | ||
| // Helpers | ||
| // --------------------------------------------------------------------------- | ||
| function getMimeType(filePath) { | ||
| const ext = path.extname(filePath).toLowerCase(); | ||
| return EXTENSION_MIME_MAP.get(ext) ?? 'text/plain'; | ||
| } | ||
| function isDeniedSegment(segment) { | ||
| return DENIED_SEGMENTS.has(segment); | ||
| } | ||
| function isAllowedExtension(filePath) { | ||
| const ext = path.extname(filePath).toLowerCase(); | ||
| return ALLOWED_EXTENSIONS.has(ext); | ||
| } | ||
| /** | ||
| * Recursively walk a directory, collecting files that pass the extension | ||
| * whitelist and denied-path filter. Stops after MAX_FILES. | ||
| */ | ||
| async function collectFiles(rootPath, currentPath, result) { | ||
| if (result.length >= MAX_FILES) | ||
| return; | ||
| let entries; | ||
| try { | ||
| entries = await readdir(currentPath, { withFileTypes: true }); | ||
| } | ||
| catch { | ||
| return; // Skip unreadable directories | ||
| } | ||
| for (const entry of entries) { | ||
| if (result.length >= MAX_FILES) | ||
| return; | ||
| if (isDeniedSegment(entry.name)) | ||
| continue; | ||
| const fullPath = path.join(currentPath, entry.name); | ||
| if (entry.isDirectory()) { | ||
| // Skip symlinked directories to prevent symlink traversal attacks | ||
| try { | ||
| const entryStat = await lstat(fullPath); | ||
| if (entryStat.isSymbolicLink()) | ||
| continue; | ||
| } | ||
| catch { | ||
| continue; | ||
| } | ||
| await collectFiles(rootPath, fullPath, result); | ||
| } | ||
| else if (entry.isFile() && isAllowedExtension(entry.name)) { | ||
| // Skip symlinked files | ||
| try { | ||
| const entryStat = await lstat(fullPath); | ||
| if (entryStat.isSymbolicLink()) | ||
| continue; | ||
| } | ||
| catch { | ||
| continue; | ||
| } | ||
| const relativePath = path.relative(rootPath, fullPath); | ||
| result.push({ absolutePath: fullPath, relativePath }); | ||
| } | ||
| } | ||
| } | ||
| function validateRootPath(rootPath) { | ||
| const resolved = path.resolve(rootPath); | ||
| // Basic safety: reject paths that look like system roots | ||
| if (resolved === path.parse(resolved).root) { | ||
| return 'Cannot index a filesystem root directory.'; | ||
| } | ||
| return undefined; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Tool registration | ||
| // --------------------------------------------------------------------------- | ||
| export function registerIndexRepositoryTool(server) { | ||
| server.registerTool('index_repository', { | ||
| title: 'Index Repository', | ||
| description: 'Walk a local repository, upload source files to a Gemini File Search Store for RAG queries. Call before query_repository.', | ||
| inputSchema: IndexRepositoryInputSchema, | ||
| outputSchema: DefaultOutputSchema, | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| idempotentHint: false, | ||
| openWorldHint: true, | ||
| destructiveHint: false, | ||
| }, | ||
| }, wrapToolHandler({ | ||
| toolName: 'index_repository', | ||
| progressContext: (input) => input.displayName ?? path.basename(input.rootPath), | ||
| }, async (input) => { | ||
| const parsed = IndexRepositoryInputSchema.parse(input); | ||
| const rootPath = path.resolve(parsed.rootPath); | ||
| const pathError = validateRootPath(rootPath); | ||
| if (pathError) { | ||
| return createErrorToolResponse('E_INDEX_REPO', pathError, undefined, VALIDATION_META); | ||
| } | ||
| // Verify root exists | ||
| let rootStat; | ||
| try { | ||
| rootStat = await stat(rootPath); | ||
| } | ||
| catch { | ||
| return createErrorToolResponse('E_INDEX_REPO', `Directory not found: ${rootPath}`, undefined, VALIDATION_META); | ||
| } | ||
| if (!rootStat.isDirectory()) { | ||
| return createErrorToolResponse('E_INDEX_REPO', 'Path is not a directory.', undefined, VALIDATION_META); | ||
| } | ||
| // Collect files | ||
| const files = []; | ||
| await collectFiles(rootPath, rootPath, files); | ||
| if (files.length === 0) { | ||
| return createErrorToolResponse('E_INDEX_REPO', 'No indexable source files found in directory.', undefined, VALIDATION_META); | ||
| } | ||
| // Capture previous store for cleanup after swap | ||
| const previousStore = getCurrentSearchStore(); | ||
| // Create new store | ||
| const displayName = parsed.displayName ?? path.basename(rootPath); | ||
| const storeName = await createSearchStore(displayName); | ||
| if (!storeName) { | ||
| return createErrorToolResponse('E_INDEX_REPO', 'Failed to create File Search Store. Check GEMINI_API_KEY.', undefined, { retryable: true, kind: 'upstream' }); | ||
| } | ||
| // Upload files | ||
| let uploaded = 0; | ||
| let skipped = 0; | ||
| for (const file of files) { | ||
| try { | ||
| const fileStat = await stat(file.absolutePath); | ||
| if (fileStat.size > MAX_FILE_BYTES) { | ||
| skipped += 1; | ||
| continue; | ||
| } | ||
| const content = await readFile(file.absolutePath, 'utf8'); | ||
| const mimeType = getMimeType(file.absolutePath); | ||
| const docName = await uploadToSearchStore(storeName, file.relativePath, content, mimeType); | ||
| if (docName) { | ||
| uploaded += 1; | ||
| } | ||
| else { | ||
| skipped += 1; | ||
| } | ||
| } | ||
| catch { | ||
| skipped += 1; | ||
| } | ||
| } | ||
| // Swap reference: new store becomes active before old is deleted | ||
| setCurrentSearchStore({ | ||
| storeName, | ||
| displayName, | ||
| documentCount: uploaded, | ||
| createdAt: Date.now(), | ||
| }); | ||
| // Clean up previous store (fire-and-forget, after swap) | ||
| if (previousStore) { | ||
| void deleteSearchStore(previousStore.storeName).catch(() => { | ||
| // Best-effort cleanup; errors are logged inside deleteSearchStore | ||
| }); | ||
| } | ||
| const message = `Indexed ${String(uploaded)} files into ${storeName} (${String(skipped)} skipped).`; | ||
| return createToolResponse({ | ||
| ok: true, | ||
| result: { | ||
| storeName, | ||
| displayName, | ||
| filesUploaded: uploaded, | ||
| filesSkipped: skipped, | ||
| message, | ||
| }, | ||
| }, message); | ||
| })); | ||
| } |
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| export declare function registerLoadFileTool(server: McpServer): void; |
| import { readFile, stat } from 'node:fs/promises'; | ||
| import path from 'node:path'; | ||
| import { performance } from 'node:perf_hooks'; | ||
| import { z } from 'zod'; | ||
| import { SOURCE_RESOURCE_URI, storeFile, validateFileBudget, } from '../lib/file-store.js'; | ||
| import { wrapToolHandler } from '../lib/tools.js'; | ||
| import { createErrorToolResponse, createToolResponse } from '../lib/tools.js'; | ||
| import { LoadFileInputSchema } from '../schemas/inputs.js'; | ||
| import { DefaultOutputSchema } from '../schemas/outputs.js'; | ||
| const VALIDATION_META = { retryable: false, kind: 'validation' }; | ||
| const EXTENSION_LANGUAGE_MAP = new Map([ | ||
| ['.ts', 'TypeScript'], | ||
| ['.tsx', 'TypeScript'], | ||
| ['.js', 'JavaScript'], | ||
| ['.jsx', 'JavaScript'], | ||
| ['.mjs', 'JavaScript'], | ||
| ['.cjs', 'JavaScript'], | ||
| ['.py', 'Python'], | ||
| ['.rb', 'Ruby'], | ||
| ['.go', 'Go'], | ||
| ['.rs', 'Rust'], | ||
| ['.java', 'Java'], | ||
| ['.kt', 'Kotlin'], | ||
| ['.cs', 'C#'], | ||
| ['.c', 'C'], | ||
| ['.cpp', 'C++'], | ||
| ['.h', 'C'], | ||
| ['.hpp', 'C++'], | ||
| ['.swift', 'Swift'], | ||
| ['.php', 'PHP'], | ||
| ['.sh', 'Shell'], | ||
| ['.bash', 'Shell'], | ||
| ['.zsh', 'Shell'], | ||
| ['.json', 'JSON'], | ||
| ['.yaml', 'YAML'], | ||
| ['.yml', 'YAML'], | ||
| ['.toml', 'TOML'], | ||
| ['.xml', 'XML'], | ||
| ['.html', 'HTML'], | ||
| ['.css', 'CSS'], | ||
| ['.scss', 'SCSS'], | ||
| ['.sql', 'SQL'], | ||
| ['.md', 'Markdown'], | ||
| ['.lua', 'Lua'], | ||
| ['.r', 'R'], | ||
| ['.dart', 'Dart'], | ||
| ['.ex', 'Elixir'], | ||
| ['.exs', 'Elixir'], | ||
| ['.erl', 'Erlang'], | ||
| ['.zig', 'Zig'], | ||
| ['.vue', 'Vue'], | ||
| ['.svelte', 'Svelte'], | ||
| ]); | ||
| function detectLanguage(filePath) { | ||
| const ext = path.extname(filePath).toLowerCase(); | ||
| return EXTENSION_LANGUAGE_MAP.get(ext) ?? 'Unknown'; | ||
| } | ||
| const DENIED_SEGMENTS = new Set(['.env', '.git', 'node_modules']); | ||
| function isDeniedPath(resolved) { | ||
| return resolved | ||
| .split(path.sep) | ||
| .some((segment) => DENIED_SEGMENTS.has(segment)); | ||
| } | ||
| function validateFilePath(filePath, workspaceRoot) { | ||
| const resolved = path.resolve(filePath); | ||
| const resolvedRoot = path.resolve(workspaceRoot); | ||
| const relative = path.relative(resolvedRoot, resolved); | ||
| if (relative.startsWith('..') || path.isAbsolute(relative)) { | ||
| return 'File path must be within the workspace root directory.'; | ||
| } | ||
| if (isDeniedPath(resolved)) { | ||
| return 'Access to this file path is denied (.env, .git/, node_modules/).'; | ||
| } | ||
| return undefined; | ||
| } | ||
| export function registerLoadFileTool(server) { | ||
| server.registerTool('load_file', { | ||
| title: 'Load File', | ||
| description: 'Read a single file from disk and cache it for file analysis tools. You MUST call this tool before calling any file analysis tool (e.g. refactor_code). Pass the absolute file path. The file must be within the workspace root.', | ||
| inputSchema: z.strictObject({ | ||
| filePath: z | ||
| .string() | ||
| .min(1) | ||
| .max(500) | ||
| .describe('Absolute path to the file to analyze.'), | ||
| }), | ||
| outputSchema: DefaultOutputSchema, | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: false, | ||
| destructiveHint: false, | ||
| }, | ||
| }, wrapToolHandler({ | ||
| toolName: 'load_file', | ||
| progressContext: (input) => path.basename(input.filePath), | ||
| }, async (input) => { | ||
| const parsed = LoadFileInputSchema.parse(input); | ||
| const { filePath } = parsed; | ||
| const pathError = validateFilePath(filePath, process.cwd()); | ||
| if (pathError) { | ||
| return createErrorToolResponse('E_LOAD_FILE', pathError, undefined, VALIDATION_META); | ||
| } | ||
| const resolved = path.resolve(filePath); | ||
| let fileStat; | ||
| try { | ||
| fileStat = await stat(resolved); | ||
| } | ||
| catch { | ||
| return createErrorToolResponse('E_LOAD_FILE', `File not found or not accessible: ${resolved}`, undefined, VALIDATION_META); | ||
| } | ||
| if (!fileStat.isFile()) { | ||
| return createErrorToolResponse('E_LOAD_FILE', 'Path is not a regular file.', undefined, VALIDATION_META); | ||
| } | ||
| let content; | ||
| try { | ||
| content = await readFile(resolved, 'utf8'); | ||
| } | ||
| catch { | ||
| return createErrorToolResponse('E_LOAD_FILE', `Failed to read file: ${resolved}`, undefined, { retryable: false, kind: 'internal' }); | ||
| } | ||
| const budgetError = validateFileBudget(content); | ||
| if (budgetError) { | ||
| return budgetError; | ||
| } | ||
| const language = detectLanguage(resolved); | ||
| const lineCount = content.split('\n').length; | ||
| const sizeChars = content.length; | ||
| const cachedAt = performance.now(); | ||
| storeFile({ | ||
| filePath: resolved, | ||
| content, | ||
| language, | ||
| lineCount, | ||
| sizeChars, | ||
| cachedAt, | ||
| }); | ||
| const summary = `File cached: ${path.basename(resolved)} (${language}, ${lineCount} lines, ${sizeChars} chars)`; | ||
| return createToolResponse({ | ||
| ok: true, | ||
| result: { | ||
| fileRef: SOURCE_RESOURCE_URI, | ||
| filePath: resolved, | ||
| language, | ||
| lineCount, | ||
| sizeChars, | ||
| cachedAt, | ||
| message: summary, | ||
| }, | ||
| }, summary); | ||
| })); | ||
| } |
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| export declare function registerQueryRepositoryTool(server: McpServer): void; |
| import { generateWithFileSearch, getCurrentSearchStore, } from '../lib/gemini/index.js'; | ||
| import { buildStructuredToolExecutionOptions, createErrorToolResponse, registerStructuredToolTask, requireToolContract, } from '../lib/tools.js'; | ||
| import { QueryRepositoryInputSchema } from '../schemas/inputs.js'; | ||
| import { QueryRepositoryResultSchema } from '../schemas/outputs.js'; | ||
| // --------------------------------------------------------------------------- | ||
| // Helpers | ||
| // --------------------------------------------------------------------------- | ||
| function extractSources(parts) { | ||
| const sources = []; | ||
| for (const part of parts) { | ||
| if (typeof part === 'object' && | ||
| part !== null && | ||
| 'type' in part && | ||
| part.type === 'file_search_result') { | ||
| const fsr = part; | ||
| if (Array.isArray(fsr.result)) { | ||
| for (const r of fsr.result) { | ||
| sources.push({ | ||
| fileSearchStore: r.file_search_store, | ||
| title: r.title, | ||
| text: r.text?.slice(0, 2000), | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return sources.slice(0, 20); | ||
| } | ||
| function extractTextFromParts(parts) { | ||
| const textSegments = []; | ||
| for (const part of parts) { | ||
| if (typeof part === 'object' && | ||
| part !== null && | ||
| 'text' in part && | ||
| typeof part.text === 'string' && | ||
| !part.thought) { | ||
| textSegments.push(part.text); | ||
| } | ||
| } | ||
| return textSegments.join('\n\n'); | ||
| } | ||
| const VALIDATION_META = { retryable: false, kind: 'validation' }; | ||
| const SYSTEM_INSTRUCTION = `You are a code analysis assistant. Answer questions about the repository using the retrieved source file contents. Be precise, cite file names when possible, and stay factual.`; | ||
| const TOOL_CONTRACT = requireToolContract('query_repository'); | ||
| // --------------------------------------------------------------------------- | ||
| // Tool registration | ||
| // --------------------------------------------------------------------------- | ||
| export function registerQueryRepositoryTool(server) { | ||
| registerStructuredToolTask(server, { | ||
| name: 'query_repository', | ||
| title: 'Query Repository', | ||
| description: 'Ask a natural-language question about the indexed repository. Prerequisite: index_repository. Uses Gemini File Search for RAG.', | ||
| inputSchema: QueryRepositoryInputSchema, | ||
| fullInputSchema: QueryRepositoryInputSchema, | ||
| resultSchema: QueryRepositoryResultSchema, | ||
| errorCode: 'E_QUERY_REPO', | ||
| ...buildStructuredToolExecutionOptions(TOOL_CONTRACT), | ||
| annotations: { | ||
| readOnlyHint: true, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| destructiveHint: false, | ||
| }, | ||
| progressContext: (input) => input.query.slice(0, 60), | ||
| formatOutput: (result) => { | ||
| const lines = [result.answer]; | ||
| if (result.sources.length > 0) { | ||
| lines.push('', '### Sources', ''); | ||
| for (const s of result.sources) { | ||
| const label = s.title ?? s.fileSearchStore ?? 'source'; | ||
| lines.push(`- ${label}`); | ||
| } | ||
| } | ||
| return lines.join('\n'); | ||
| }, | ||
| validateInput: () => { | ||
| const store = getCurrentSearchStore(); | ||
| if (!store) { | ||
| return Promise.resolve(createErrorToolResponse('E_QUERY_REPO', 'No repository indexed. Call index_repository first.', undefined, VALIDATION_META)); | ||
| } | ||
| return Promise.resolve(undefined); | ||
| }, | ||
| buildPrompt: (input) => ({ | ||
| systemInstruction: SYSTEM_INSTRUCTION, | ||
| prompt: input.query, | ||
| }), | ||
| customGenerate: async (promptParts, _ctx, opts) => { | ||
| const store = getCurrentSearchStore(); | ||
| if (!store) { | ||
| throw new Error('No repository indexed'); | ||
| } | ||
| const response = await generateWithFileSearch({ | ||
| systemInstruction: promptParts.systemInstruction, | ||
| prompt: promptParts.prompt, | ||
| responseSchema: {}, | ||
| fileSearchStoreNames: [store.storeName], | ||
| ...(opts.signal ? { signal: opts.signal } : {}), | ||
| onLog: opts.onLog, | ||
| }); | ||
| const textFromParts = extractTextFromParts(response.parts); | ||
| const answer = textFromParts || response.text || 'No answer generated.'; | ||
| const sources = extractSources(response.parts); | ||
| return { answer, sources }; | ||
| }, | ||
| }); | ||
| } |
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| export declare function registerRefactorCodeTool(server: McpServer): void; |
| import { getFileContextSnapshot } from '../lib/tools.js'; | ||
| import { buildStructuredToolExecutionOptions, registerStructuredToolTask, requireToolContract, } from '../lib/tools.js'; | ||
| import { RefactorCodeInputSchema } from '../schemas/inputs.js'; | ||
| import { RefactorCodeGeminiResultSchema, RefactorCodeResultSchema, } from '../schemas/outputs.js'; | ||
| const SYSTEM_INSTRUCTION = ` | ||
| <role> | ||
| Code Refactoring Analyst. | ||
| </role> | ||
| <task> | ||
| Analyze one source file and return refactoring suggestions in exactly four categories: | ||
| 1. naming | ||
| 2. complexity | ||
| 3. duplication | ||
| 4. grouping | ||
| </task> | ||
| <constraints> | ||
| - Analyze only the provided file content. | ||
| - Do not suggest creating files or moving code across files. | ||
| - Every suggestion must reference a concrete target (name or location) from this file. | ||
| - Prefer high-impact structural improvements over minor style edits. | ||
| - Grouping: only report when related items are split by 50+ lines of unrelated code, or when one logical group is clearly fragmented. | ||
| - If no valid issues exist, return an empty suggestions array and a brief summary. | ||
| </constraints> | ||
| <output> | ||
| - Return strict JSON only. | ||
| - Do not add markdown, prose outside JSON, or extra keys. | ||
| - Suggestions must stay within the four allowed categories. | ||
| </output> | ||
| `; | ||
| const TOOL_CONTRACT = requireToolContract('refactor_code'); | ||
| function countByCategory(suggestions, category) { | ||
| return suggestions.filter((s) => s.category === category).length; | ||
| } | ||
| export function registerRefactorCodeTool(server) { | ||
| registerStructuredToolTask(server, { | ||
| name: 'refactor_code', | ||
| title: 'Refactor Code', | ||
| description: 'Analyze cached file for naming, complexity, duplication, and grouping improvements. Prerequisite: load_file. Auto-infer language.', | ||
| inputSchema: RefactorCodeInputSchema, | ||
| fullInputSchema: RefactorCodeInputSchema, | ||
| resultSchema: RefactorCodeResultSchema, | ||
| geminiSchema: RefactorCodeGeminiResultSchema, | ||
| errorCode: 'E_REFACTOR_CODE', | ||
| ...buildStructuredToolExecutionOptions(TOOL_CONTRACT), | ||
| requiresFile: true, | ||
| progressContext: (input) => input.language ?? 'auto-detect', | ||
| formatOutcome: (result) => { | ||
| const total = result.namingIssuesCount + | ||
| result.complexityIssuesCount + | ||
| result.duplicationIssuesCount + | ||
| result.groupingIssuesCount; | ||
| return `${total} suggestion${total === 1 ? '' : 's'}`; | ||
| }, | ||
| formatOutput: (result) => { | ||
| const lines = [result.summary]; | ||
| if (result.suggestions.length > 0) { | ||
| lines.push('', '### Suggestions', ''); | ||
| for (const s of result.suggestions) { | ||
| lines.push(`- **[${s.category}]** \`${s.target}\` (${s.priority}) `); | ||
| lines.push(` ${s.currentIssue} — ${s.suggestion}`); | ||
| } | ||
| } | ||
| return lines.join('\n'); | ||
| }, | ||
| buildPrompt: (input, ctx) => { | ||
| const file = getFileContextSnapshot(ctx); | ||
| const language = input.language ?? file.language; | ||
| return { | ||
| systemInstruction: SYSTEM_INSTRUCTION, | ||
| prompt: `Language: ${language}\nFile: ${file.filePath}\n\nSource code:\n${file.content}\n\nAnalyze this file and provide refactoring suggestions across all four categories (naming, complexity, duplication, grouping).`, | ||
| }; | ||
| }, | ||
| transformResult: (_input, result, ctx) => { | ||
| const file = getFileContextSnapshot(ctx); | ||
| return { | ||
| ...result, | ||
| filePath: file.filePath, | ||
| language: file.language, | ||
| namingIssuesCount: countByCategory(result.suggestions, 'naming'), | ||
| complexityIssuesCount: countByCategory(result.suggestions, 'complexity'), | ||
| duplicationIssuesCount: countByCategory(result.suggestions, 'duplication'), | ||
| groupingIssuesCount: countByCategory(result.suggestions, 'grouping'), | ||
| }; | ||
| }, | ||
| }); | ||
| } |
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| export declare function registerVerifyLogicTool(server: McpServer): void; |
| import { generateWithCodeExecution, } from '../lib/gemini/index.js'; | ||
| import { buildStructuredToolExecutionOptions, getFileContextSnapshot, registerStructuredToolTask, requireToolContract, } from '../lib/tools.js'; | ||
| import { VerifyLogicInputSchema } from '../schemas/inputs.js'; | ||
| import { VerifyLogicGeminiResultSchema, VerifyLogicResultSchema, } from '../schemas/outputs.js'; | ||
| const OUTCOME_OK = 'OUTCOME_OK'; | ||
| const SYSTEM_INSTRUCTION = ` | ||
| <role> | ||
| Code Verification Assistant. | ||
| </role> | ||
| <task> | ||
| Write Python code to verify the algorithm or logic described in the user's question. | ||
| Execute the code and report the results. | ||
| </task> | ||
| <constraints> | ||
| - Base verification solely on the provided source file content. | ||
| - Write clear, self-contained Python test code with assertions. | ||
| - If the source language is not Python, translate the relevant logic into Python for verification. | ||
| - Print results and use assertions to confirm correctness. | ||
| - If verification is not possible from the file alone, state that clearly in your response text. | ||
| </constraints> | ||
| `; | ||
| function deriveVerified(result) { | ||
| if (result.executionResults.length === 0) { | ||
| return false; | ||
| } | ||
| return result.executionResults.every((r) => r.outcome === OUTCOME_OK); | ||
| } | ||
| const TOOL_CONTRACT = requireToolContract('verify_logic'); | ||
| export function registerVerifyLogicTool(server) { | ||
| registerStructuredToolTask(server, { | ||
| name: 'verify_logic', | ||
| title: 'Verify Logic', | ||
| description: 'Verify algorithms and logic in a cached file using Gemini code execution sandbox. Prerequisite: load_file. Auto-infer language.', | ||
| inputSchema: VerifyLogicInputSchema, | ||
| fullInputSchema: VerifyLogicInputSchema, | ||
| resultSchema: VerifyLogicResultSchema, | ||
| geminiSchema: VerifyLogicGeminiResultSchema, | ||
| errorCode: 'E_VERIFY_LOGIC', | ||
| ...buildStructuredToolExecutionOptions(TOOL_CONTRACT), | ||
| requiresFile: true, | ||
| annotations: { | ||
| readOnlyHint: true, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| destructiveHint: false, | ||
| }, | ||
| progressContext: (input) => input.question.slice(0, 60), | ||
| formatOutcome: (result) => `verified: ${String(result.verified)} | ${result.codeBlocks.length} block(s)`, | ||
| formatOutput: (result) => { | ||
| const status = result.verified ? 'Verified' : 'Failed'; | ||
| const lines = [`**Status:** ${status}`, '', result.answer]; | ||
| if (result.codeBlocks.length > 0) { | ||
| lines.push('', '### Code', ''); | ||
| for (const cb of result.codeBlocks) { | ||
| lines.push(`\`\`\`${cb.language}`, cb.code, '```', ''); | ||
| } | ||
| } | ||
| if (result.executionResults.length > 0) { | ||
| lines.push('### Execution Results', ''); | ||
| for (const er of result.executionResults) { | ||
| lines.push(`- **${er.outcome}**${er.output ? `: ${er.output}` : ''}`); | ||
| } | ||
| } | ||
| return lines.join('\n'); | ||
| }, | ||
| buildPrompt: (input, ctx) => { | ||
| const { filePath, content, language: fileLanguage, } = getFileContextSnapshot(ctx); | ||
| const language = input.language ?? fileLanguage; | ||
| return { | ||
| systemInstruction: SYSTEM_INSTRUCTION, | ||
| prompt: `Language: ${language}\nFile: ${filePath}\n\nSource code:\n${content}\n\nVerification request: ${input.question}`, | ||
| }; | ||
| }, | ||
| transformResult: (input, result, ctx) => { | ||
| const { filePath, language: fileLanguage } = getFileContextSnapshot(ctx); | ||
| return { | ||
| ...result, | ||
| filePath, | ||
| language: input.language ?? fileLanguage, | ||
| }; | ||
| }, | ||
| customGenerate: async (promptParts, _ctx, opts) => { | ||
| const response = await generateWithCodeExecution({ | ||
| systemInstruction: promptParts.systemInstruction, | ||
| prompt: promptParts.prompt, | ||
| responseSchema: {}, | ||
| ...(opts.signal ? { signal: opts.signal } : {}), | ||
| onLog: opts.onLog, | ||
| }); | ||
| const verified = deriveVerified(response); | ||
| return VerifyLogicGeminiResultSchema.parse({ | ||
| answer: response.text || 'No analysis text returned.', | ||
| verified, | ||
| codeBlocks: response.codeBlocks, | ||
| executionResults: response.executionResults, | ||
| }); | ||
| }, | ||
| }); | ||
| } |
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| export declare function registerWebSearchTool(server: McpServer): void; |
| import { generateGroundedContent } from '../lib/gemini/index.js'; | ||
| import { buildStructuredToolExecutionOptions, registerStructuredToolTask, requireToolContract, } from '../lib/tools.js'; | ||
| import { WebSearchInputSchema } from '../schemas/inputs.js'; | ||
| import { WebSearchResultSchema } from '../schemas/outputs.js'; | ||
| function formatGroundedResponse(text, metadata) { | ||
| if (!metadata?.groundingSupports || !metadata.groundingChunks) { | ||
| return text; | ||
| } | ||
| const supports = metadata.groundingSupports; | ||
| const chunks = metadata.groundingChunks; | ||
| let formattedText = text; | ||
| // Sort supports by end_index in descending order to avoid shifting issues when inserting. | ||
| const sortedSupports = [...supports].sort((a, b) => (b.segment?.endIndex ?? 0) - (a.segment?.endIndex ?? 0)); | ||
| for (const support of sortedSupports) { | ||
| const endIndex = support.segment?.endIndex; | ||
| if (endIndex === undefined || !support.groundingChunkIndices?.length) { | ||
| continue; | ||
| } | ||
| const citationLinks = support.groundingChunkIndices | ||
| .map((i) => { | ||
| const chunk = chunks[i]; | ||
| const uri = chunk?.web?.uri; | ||
| const title = chunk?.web?.title ?? 'Source'; | ||
| if (uri) { | ||
| return `[${title}](${uri})`; | ||
| } | ||
| return null; | ||
| }) | ||
| .filter(Boolean); | ||
| if (citationLinks.length > 0) { | ||
| const citationString = ` ${citationLinks.join(' ')}`; | ||
| formattedText = | ||
| formattedText.slice(0, endIndex) + | ||
| citationString + | ||
| formattedText.slice(endIndex); | ||
| } | ||
| } | ||
| return formattedText; | ||
| } | ||
| const TOOL_CONTRACT = requireToolContract('web_search'); | ||
| export function registerWebSearchTool(server) { | ||
| registerStructuredToolTask(server, { | ||
| name: 'web_search', | ||
| title: 'Web Search', | ||
| description: 'Perform a Google Search with Grounding to get up-to-date information.', | ||
| inputSchema: WebSearchInputSchema, | ||
| fullInputSchema: WebSearchInputSchema, | ||
| resultSchema: WebSearchResultSchema, | ||
| errorCode: 'E_WEB_SEARCH', | ||
| ...buildStructuredToolExecutionOptions(TOOL_CONTRACT), | ||
| annotations: { | ||
| readOnlyHint: true, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| destructiveHint: false, | ||
| }, | ||
| progressContext: (input) => input.query.slice(0, 60), | ||
| formatOutput: (result) => result.text.slice(0, 200), | ||
| buildPrompt: (input) => ({ | ||
| systemInstruction: 'You are a technical research assistant. Return factual, concise answers grounded in search results. Cite sources when available.', | ||
| prompt: input.query, | ||
| }), | ||
| customGenerate: async (_promptParts, _ctx, opts) => { | ||
| const result = await generateGroundedContent({ | ||
| prompt: _promptParts.prompt, | ||
| responseSchema: {}, | ||
| ...(opts.signal ? { signal: opts.signal } : {}), | ||
| onLog: opts.onLog, | ||
| }); | ||
| const { text } = result; | ||
| const metadata = result.groundingMetadata; | ||
| const formatted = formatGroundedResponse(text, metadata); | ||
| return WebSearchResultSchema.parse({ | ||
| text: formatted, | ||
| groundingMetadata: metadata ?? {}, | ||
| }); | ||
| }, | ||
| }); | ||
| } |
@@ -70,9 +70,9 @@ export class ConcurrencyLimiter { | ||
| this.activeCount--; | ||
| const next = this.waiters.values().next().value; | ||
| if (next) { | ||
| this.waiters.delete(next); | ||
| next(); | ||
| } | ||
| } | ||
| const next = this.waiters.values().next().value; | ||
| if (next) { | ||
| this.waiters.delete(next); | ||
| next(); | ||
| } | ||
| } | ||
| } |
+2
-12
@@ -24,14 +24,4 @@ export interface CachedEnvInt { | ||
| export declare const FLASH_HIGH_THINKING_LEVEL: "high"; | ||
| /** Output cap for Flash API breaking-change detection. */ | ||
| export declare const FLASH_API_BREAKING_MAX_OUTPUT_TOKENS = 65536; | ||
| /** Output cap for Flash complexity analysis. */ | ||
| export declare const FLASH_COMPLEXITY_MAX_OUTPUT_TOKENS = 65536; | ||
| /** Output cap for Flash test-plan generation. */ | ||
| export declare const FLASH_TEST_PLAN_MAX_OUTPUT_TOKENS = 65536; | ||
| /** Output cap for Flash triage tools. */ | ||
| export declare const FLASH_TRIAGE_MAX_OUTPUT_TOKENS = 65536; | ||
| /** Output cap for Flash patch generation. */ | ||
| export declare const FLASH_PATCH_MAX_OUTPUT_TOKENS = 65536; | ||
| /** Output cap for Flash deep review findings. */ | ||
| export declare const FLASH_REVIEW_MAX_OUTPUT_TOKENS = 65536; | ||
| /** Shared output token cap used by all tool categories. */ | ||
| export declare const DEFAULT_MAX_OUTPUT_TOKENS = 65536; | ||
| /** Temperature for analytical tools. */ | ||
@@ -38,0 +28,0 @@ export declare const ANALYSIS_TEMPERATURE: 1; |
+12
-19
@@ -1,2 +0,2 @@ | ||
| function parsePositiveInteger(value) { | ||
| function parseNonNegativeInteger(value) { | ||
| const normalized = value.trim(); | ||
@@ -7,7 +7,7 @@ if (normalized.length === 0) { | ||
| const parsed = Number.parseInt(normalized, 10); | ||
| return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; | ||
| return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; | ||
| } | ||
| function resolveEnvInt(envVar, defaultValue) { | ||
| const envValue = process.env[envVar] ?? ''; | ||
| return parsePositiveInteger(envValue) ?? defaultValue; | ||
| return parseNonNegativeInteger(envValue) ?? defaultValue; | ||
| } | ||
@@ -60,22 +60,15 @@ /** Creates a cached integer value from an environment variable, with a default fallback. */ | ||
| const DEFAULT_OUTPUT_CAP = 65_536; | ||
| /** Output cap for Flash API breaking-change detection. */ | ||
| export const FLASH_API_BREAKING_MAX_OUTPUT_TOKENS = DEFAULT_OUTPUT_CAP; | ||
| /** Output cap for Flash complexity analysis. */ | ||
| export const FLASH_COMPLEXITY_MAX_OUTPUT_TOKENS = DEFAULT_OUTPUT_CAP; | ||
| /** Output cap for Flash test-plan generation. */ | ||
| export const FLASH_TEST_PLAN_MAX_OUTPUT_TOKENS = DEFAULT_OUTPUT_CAP; | ||
| /** Output cap for Flash triage tools. */ | ||
| export const FLASH_TRIAGE_MAX_OUTPUT_TOKENS = DEFAULT_OUTPUT_CAP; | ||
| /** Output cap for Flash patch generation. */ | ||
| export const FLASH_PATCH_MAX_OUTPUT_TOKENS = DEFAULT_OUTPUT_CAP; | ||
| /** Output cap for Flash deep review findings. */ | ||
| export const FLASH_REVIEW_MAX_OUTPUT_TOKENS = DEFAULT_OUTPUT_CAP; | ||
| /** Shared output token cap used by all tool categories. */ | ||
| export const DEFAULT_MAX_OUTPUT_TOKENS = DEFAULT_OUTPUT_CAP; | ||
| // --------------------------------------------------------------------------- | ||
| // Temperatures | ||
| // --------------------------------------------------------------------------- | ||
| // Gemini 3 recommends temperature 1.0 for all tasks. | ||
| // Separate constants are retained so per-category tuning is possible | ||
| // if future models or workloads warrant different values. | ||
| const TOOL_TEMPERATURE = { | ||
| analysis: 1.0, // Gemini 3 recommends 1.0 for all tasks | ||
| creative: 1.0, // Gemini 3 recommends 1.0 for all tasks | ||
| patch: 1.0, // Gemini 3 recommends 1.0 for all tasks | ||
| triage: 1.0, // Gemini 3 recommends 1.0 for all tasks | ||
| analysis: 1.0, | ||
| creative: 1.0, | ||
| patch: 1.0, | ||
| triage: 1.0, | ||
| }; | ||
@@ -82,0 +75,0 @@ /** Temperature for analytical tools. */ |
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| import type { File as ParsedFile } from 'parse-diff'; | ||
| import { type DiffCacheSlot } from './gemini/cache.js'; | ||
| import { createErrorToolResponse } from './tools.js'; | ||
@@ -33,3 +34,3 @@ export type { ParsedFile }; | ||
| export declare function formatFileSummary(files: ParsedFile[]): string; | ||
| export declare const DIFF_RESOURCE_URI = "diff://current"; | ||
| export declare const DIFF_RESOURCE_URI = "internal://diff/current"; | ||
| export declare const diffStaleWarningMs: import("./config.js").CachedEnvInt; | ||
@@ -41,2 +42,4 @@ export interface DiffSlot { | ||
| generatedAt: string; | ||
| /** Numeric epoch ms cached at creation to avoid repeated Date parsing. */ | ||
| generatedAtMs: number; | ||
| mode: string; | ||
@@ -49,4 +52,6 @@ } | ||
| export declare function hasDiff(key?: string): boolean; | ||
| /** Returns the current Gemini context cache slot for the diff, if available and model-compatible. */ | ||
| export declare function getDiffCacheSlot(model?: string): DiffCacheSlot | undefined; | ||
| /** Test-only: directly set or clear the diff slot without emitting resource-updated. */ | ||
| export declare function setDiffForTesting(data: DiffSlot | undefined, key?: string): void; | ||
| export declare function createNoDiffError(): ReturnType<typeof createErrorToolResponse>; |
+16
-2
| import parseDiff from 'parse-diff'; | ||
| import { createCachedEnvInt } from './config.js'; | ||
| import { formatUsNumber } from './format.js'; | ||
| import { clearDiffCacheLocal, createDiffCache, getCurrentDiffCache, shouldCacheDiff, } from './gemini/cache.js'; | ||
| import { createErrorToolResponse } from './tools.js'; | ||
@@ -191,3 +192,3 @@ // --- Diff Budget --- | ||
| } | ||
| export const DIFF_RESOURCE_URI = 'diff://current'; | ||
| export const DIFF_RESOURCE_URI = 'internal://diff/current'; | ||
| const diffCacheTtlMs = createCachedEnvInt('DIFF_CACHE_TTL_MS', 60 * 60 * 1_000 // 1 hour default | ||
@@ -219,2 +220,11 @@ ); | ||
| notifyDiffUpdated(); | ||
| // Fire-and-forget: create Gemini context cache for large diffs. | ||
| if (shouldCacheDiff(data.diff.length)) { | ||
| void createDiffCache(data.diff).catch(() => { | ||
| // Cache creation is best-effort; failures are logged internally. | ||
| }); | ||
| } | ||
| else { | ||
| clearDiffCacheLocal(); | ||
| } | ||
| } | ||
@@ -226,3 +236,3 @@ export function getDiff(key = process.cwd()) { | ||
| } | ||
| const age = Date.now() - new Date(slot.generatedAt).getTime(); | ||
| const age = Date.now() - slot.generatedAtMs; | ||
| if (age > diffCacheTtlMs.get()) { | ||
@@ -238,2 +248,6 @@ diffSlots.delete(key); | ||
| } | ||
| /** Returns the current Gemini context cache slot for the diff, if available and model-compatible. */ | ||
| export function getDiffCacheSlot(model) { | ||
| return getCurrentDiffCache(model); | ||
| } | ||
| /** Test-only: directly set or clear the diff slot without emitting resource-updated. */ | ||
@@ -240,0 +254,0 @@ export function setDiffForTesting(data, key = process.cwd()) { |
| import type { ErrorMeta } from './tools.js'; | ||
| /** Remove API keys from a string before it reaches clients. */ | ||
| export declare function sanitizeErrorMessage(message: string): string; | ||
| /** Matches transient upstream provider failures that are typically safe to retry. */ | ||
@@ -3,0 +5,0 @@ export declare const RETRYABLE_UPSTREAM_ERROR_PATTERN: RegExp; |
+11
-4
| import { inspect } from 'node:util'; | ||
| import { z } from 'zod'; | ||
| // --- API key sanitization --- | ||
| /** Matches Google AI API keys (AIzaSy...) to prevent accidental leakage in error messages. */ | ||
| const API_KEY_PATTERN = /AIza[0-9A-Za-z_-]{35}/g; | ||
| /** Remove API keys from a string before it reaches clients. */ | ||
| export function sanitizeErrorMessage(message) { | ||
| return message.replace(API_KEY_PATTERN, '[REDACTED]'); | ||
| } | ||
| /** Matches transient upstream provider failures that are typically safe to retry. */ | ||
| export const RETRYABLE_UPSTREAM_ERROR_PATTERN = /(429|500|502|503|504|rate.?limit|quota|overload|unavailable|gateway|timeout|timed.out|connection|reset|econn|enotfound|temporary|transient)/i; | ||
| export const RETRYABLE_UPSTREAM_ERROR_PATTERN = /(\b429\b|\b500\b|\b502\b|\b503\b|\b504\b|rate.?limit|quota|overload|\bunavailable\b|\bgateway\b|\btimeout\b|timed.out|\bconnection\b|conn(ection)?\s*reset|\beconn\w*|\benotfound\b|\btemporary\b|\btransient\b)/i; | ||
| function isObjectRecord(value) { | ||
@@ -24,8 +31,8 @@ return typeof value === 'object' && value !== null; | ||
| if (message !== undefined) { | ||
| return message; | ||
| return sanitizeErrorMessage(message); | ||
| } | ||
| if (typeof error === 'string') { | ||
| return error; | ||
| return sanitizeErrorMessage(error); | ||
| } | ||
| return inspect(error, { depth: 3, breakLength: 120 }); | ||
| return sanitizeErrorMessage(inspect(error, { depth: 3, breakLength: 120 })); | ||
| } | ||
@@ -32,0 +39,0 @@ const CANCELLED_ERROR_PATTERN = /cancelled|canceled/i; |
@@ -0,1 +1,2 @@ | ||
| import type { ToolContentBlock } from './tool-response.js'; | ||
| import type { createErrorToolResponse } from './tools.js'; | ||
@@ -27,2 +28,3 @@ export declare const STEP_STARTING = 0; | ||
| }; | ||
| signal?: AbortSignal; | ||
| sendNotification: (notification: { | ||
@@ -47,7 +49,5 @@ method: 'notifications/progress'; | ||
| isError?: boolean; | ||
| content: { | ||
| type: string; | ||
| text: string; | ||
| }[]; | ||
| content: ToolContentBlock[]; | ||
| }) => Promise<void>; | ||
| reportCancellation?: (message: string) => Promise<void>; | ||
| } | ||
@@ -64,7 +64,5 @@ export declare class RunReporter { | ||
| isError?: boolean; | ||
| content: { | ||
| type: string; | ||
| text: string; | ||
| }[]; | ||
| content: ToolContentBlock[]; | ||
| }, onLog: (level: string, data: unknown) => Promise<void>): Promise<void>; | ||
| reportCancellation(message: string): Promise<void>; | ||
| reportStep(step: number, message: string): Promise<void>; | ||
@@ -71,0 +69,0 @@ reportCompletion(outcome: string): Promise<void>; |
+13
-1
@@ -116,3 +116,4 @@ import { getErrorMessage } from './errors.js'; | ||
| export function extractValidationMessage(validationError) { | ||
| const text = validationError.content.at(0)?.text; | ||
| const first = validationError.content.at(0); | ||
| const text = first && 'text' in first ? first.text : undefined; | ||
| if (!text) | ||
@@ -192,2 +193,13 @@ return INPUT_VALIDATION_FAILED; | ||
| } | ||
| async reportCancellation(message) { | ||
| if (!this.statusReporter.reportCancellation) { | ||
| return; | ||
| } | ||
| try { | ||
| await this.statusReporter.reportCancellation(message); | ||
| } | ||
| catch { | ||
| // Best-effort: cancellation status update must not throw | ||
| } | ||
| } | ||
| async reportStep(step, message) { | ||
@@ -194,0 +206,0 @@ await reportProgressStepUpdate(this.reportProgress, this.toolName, this.progressContext, step, message); |
+28
-156
@@ -7,158 +7,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| import { type DiffStats, type ParsedFile } from './diff.js'; | ||
| import { type FileSlot } from './file-store.js'; | ||
| import { type ProgressExtra, type ProgressPayload, type TaskStatusReporter } from './progress.js'; | ||
| export type ErrorKind = 'validation' | 'budget' | 'upstream' | 'timeout' | 'cancelled' | 'internal' | 'busy'; | ||
| export interface ErrorMeta { | ||
| retryable?: boolean; | ||
| kind?: ErrorKind; | ||
| } | ||
| interface ToolError { | ||
| code: string; | ||
| message: string; | ||
| retryable?: boolean; | ||
| kind?: ErrorKind; | ||
| } | ||
| interface ToolTextContent { | ||
| type: 'text'; | ||
| text: string; | ||
| } | ||
| interface ToolStructuredContent { | ||
| [key: string]: unknown; | ||
| ok: boolean; | ||
| result?: unknown; | ||
| error?: ToolError; | ||
| } | ||
| interface ToolResponse<TStructuredContent extends ToolStructuredContent> { | ||
| [key: string]: unknown; | ||
| content: ToolTextContent[]; | ||
| structuredContent: TStructuredContent; | ||
| } | ||
| interface ErrorToolResponse { | ||
| [key: string]: unknown; | ||
| content: ToolTextContent[]; | ||
| isError: true; | ||
| } | ||
| export declare function createToolResponse<TStructuredContent extends ToolStructuredContent>(structured: TStructuredContent, textContent?: string): ToolResponse<TStructuredContent>; | ||
| export declare function createErrorToolResponse(code: string, message: string, result?: unknown, meta?: ErrorMeta): ErrorToolResponse; | ||
| export declare const INSPECTION_FOCUS_AREAS: readonly ["security", "correctness", "performance", "regressions", "tests", "maintainability", "concurrency"]; | ||
| export interface ToolParameterContract { | ||
| name: string; | ||
| type: string; | ||
| required: boolean; | ||
| constraints: string; | ||
| description: string; | ||
| } | ||
| export interface ToolContract { | ||
| name: string; | ||
| purpose: string; | ||
| /** Set to 'none' for synchronous (non-Gemini) tools. */ | ||
| model: string; | ||
| /** Set to 0 for synchronous (non-Gemini) tools. */ | ||
| timeoutMs: number; | ||
| thinkingLevel?: 'minimal' | 'low' | 'medium' | 'high'; | ||
| /** Set to 0 for synchronous (non-Gemini) tools. */ | ||
| maxOutputTokens: number; | ||
| /** | ||
| * Sampling temperature for the Gemini call. | ||
| * Gemini 3 recommends 1.0 for all tasks. | ||
| */ | ||
| temperature?: number; | ||
| /** Enables deterministic JSON guidance and schema key ordering. */ | ||
| deterministicJson?: boolean; | ||
| params: readonly ToolParameterContract[]; | ||
| outputShape: string; | ||
| gotchas: readonly string[]; | ||
| crossToolFlow: readonly string[]; | ||
| constraints?: readonly string[]; | ||
| } | ||
| interface StructuredToolRuntimeOptions { | ||
| thinkingLevel?: NonNullable<ToolContract['thinkingLevel']>; | ||
| temperature?: NonNullable<ToolContract['temperature']>; | ||
| deterministicJson?: NonNullable<ToolContract['deterministicJson']>; | ||
| } | ||
| interface StructuredToolExecutionOptions extends StructuredToolRuntimeOptions { | ||
| timeoutMs: ToolContract['timeoutMs']; | ||
| maxOutputTokens: ToolContract['maxOutputTokens']; | ||
| } | ||
| export declare function buildStructuredToolRuntimeOptions(contract: Pick<ToolContract, 'thinkingLevel' | 'temperature' | 'deterministicJson'>): StructuredToolRuntimeOptions; | ||
| export declare function buildStructuredToolExecutionOptions(contract: Pick<ToolContract, 'timeoutMs' | 'maxOutputTokens' | 'thinkingLevel' | 'temperature' | 'deterministicJson'>): StructuredToolExecutionOptions; | ||
| export declare const TOOL_CONTRACTS: readonly [{ | ||
| readonly name: "generate_diff"; | ||
| readonly purpose: "Generate a diff of current changes and cache it server-side. MUST be called before any other tool. Uses git to capture unstaged or staged changes in the current working directory."; | ||
| readonly model: "none"; | ||
| readonly timeoutMs: 0; | ||
| readonly maxOutputTokens: 0; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{ok, result: {diffRef, stats{files, added, deleted}, generatedAt, mode, message}}"; | ||
| readonly gotchas: readonly ["Must be called first — all other tools return E_NO_DIFF if no diff is cached.", "Noisy files (lock files, dist/, build/, minified assets) are excluded automatically.", "Empty diff (no changes) returns E_NO_CHANGES."]; | ||
| readonly crossToolFlow: readonly ["Caches diff at diff://current — consumed automatically by all review tools."]; | ||
| }, { | ||
| readonly name: "analyze_pr_impact"; | ||
| readonly purpose: "Assess severity, categories, breaking changes, and rollback complexity."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 90000; | ||
| readonly thinkingLevel: "minimal"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{severity, categories[], summary, breakingChanges[], affectedAreas[], rollbackComplexity}"; | ||
| readonly gotchas: readonly ["Requires generate_diff to be called first.", "Flash triage tool optimized for speed."]; | ||
| readonly crossToolFlow: readonly ["severity/categories feed triage and merge-gate decisions."]; | ||
| }, { | ||
| readonly name: "generate_review_summary"; | ||
| readonly purpose: "Produce PR summary, risk rating, and merge recommendation."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 90000; | ||
| readonly thinkingLevel: "minimal"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{summary, overallRisk, keyChanges[], recommendation, stats{filesChanged, linesAdded, linesRemoved}}"; | ||
| readonly gotchas: readonly ["Requires generate_diff to be called first.", "stats are computed locally from the diff."]; | ||
| readonly crossToolFlow: readonly ["Use before deep review to decide whether Pro analysis is needed."]; | ||
| }, { | ||
| readonly name: "generate_test_plan"; | ||
| readonly purpose: "Generate prioritized test cases and coverage guidance."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 90000; | ||
| readonly thinkingLevel: "medium"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{summary, testCases[], coverageSummary}"; | ||
| readonly gotchas: readonly ["Requires generate_diff to be called first.", "maxTestCases caps output after generation."]; | ||
| readonly crossToolFlow: readonly ["Pair with review tools to validate high-risk paths."]; | ||
| }, { | ||
| readonly name: "analyze_time_space_complexity"; | ||
| readonly purpose: "Analyze Big-O complexity and detect degradations in changed code."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 90000; | ||
| readonly thinkingLevel: "medium"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{timeComplexity, spaceComplexity, explanation, potentialBottlenecks[], isDegradation}"; | ||
| readonly gotchas: readonly ["Requires generate_diff to be called first.", "Analyzes only changed code visible in the diff."]; | ||
| readonly crossToolFlow: readonly ["Use for algorithmic/performance-sensitive changes."]; | ||
| }, { | ||
| readonly name: "detect_api_breaking_changes"; | ||
| readonly purpose: "Detect breaking API/interface changes in a diff."; | ||
| readonly model: "gemini-3-flash-preview"; | ||
| readonly timeoutMs: 90000; | ||
| readonly thinkingLevel: "minimal"; | ||
| readonly maxOutputTokens: 65536; | ||
| readonly temperature: 1; | ||
| readonly deterministicJson: true; | ||
| readonly params: ToolParameterContract[]; | ||
| readonly outputShape: "{hasBreakingChanges, breakingChanges[]}"; | ||
| readonly gotchas: readonly ["Requires generate_diff to be called first.", "Targets public API contracts over internal refactors."]; | ||
| readonly crossToolFlow: readonly ["Run before merge for API-surface-sensitive changes."]; | ||
| }]; | ||
| export declare function getToolContracts(): readonly ToolContract[]; | ||
| export declare function getToolContract(toolName: string): ToolContract | undefined; | ||
| export declare function requireToolContract(toolName: string): ToolContract; | ||
| export declare function getToolContractNames(): string[]; | ||
| import { createErrorToolResponse } from './tool-response.js'; | ||
| export * from './tool-response.js'; | ||
| export * from './tool-contracts.js'; | ||
| export interface PromptParts { | ||
@@ -177,2 +26,5 @@ systemInstruction: string; | ||
| readonly diffSlot: DiffSlot | undefined; | ||
| readonly fileSlot: FileSlot | undefined; | ||
| /** Snapshotted Gemini context cache name for diff-dependent tools. */ | ||
| readonly diffCacheSlotName: string | undefined; | ||
| } | ||
@@ -208,2 +60,4 @@ export interface ToolAnnotations { | ||
| requiresDiff?: boolean; | ||
| /** Optional flag to enforce file presence and budget check before tool execution. */ | ||
| requiresFile?: boolean; | ||
| /** Optional override for schema validation retries. Defaults to GEMINI_SCHEMA_RETRIES env var. */ | ||
@@ -238,2 +92,10 @@ schemaRetries?: number; | ||
| buildPrompt: (input: TInput, ctx: ToolExecutionContext) => PromptParts; | ||
| /** | ||
| * Optional custom generation function. When provided, replaces the standard | ||
| * generateStructuredJson + resultSchema.parse pipeline. Must return a parsed TResult. | ||
| */ | ||
| customGenerate?: (promptParts: PromptParts, ctx: ToolExecutionContext, opts: { | ||
| onLog: (level: string, data: unknown) => Promise<void>; | ||
| signal?: AbortSignal; | ||
| }) => Promise<TResult>; | ||
| } | ||
@@ -253,2 +115,3 @@ export declare function summarizeSchemaValidationErrorForRetry(errorMessage: string): string; | ||
| private reporter; | ||
| private executionCtx; | ||
| constructor(config: StructuredToolTaskConfig<TInput, TResult, TFinal>, dependencies: { | ||
@@ -259,2 +122,3 @@ onLog: (level: string, data: unknown) => Promise<void>; | ||
| }, signal?: AbortSignal | undefined); | ||
| private throwIfAborted; | ||
| private handleInternalLog; | ||
@@ -273,2 +137,3 @@ setResponseSchemaOverride(responseSchema: Record<string, unknown>): void; | ||
| } | ||
| export declare function createGeminiLogger(server: McpServer): (level: string, data: unknown) => Promise<void>; | ||
| export declare function registerStructuredToolTask<TInput extends object, TResult extends object = Record<string, unknown>, TFinal extends TResult = TResult>(server: McpServer, config: StructuredToolTaskConfig<TInput, TResult, TFinal>): void; | ||
@@ -281,2 +146,9 @@ export interface DiffContextSnapshot { | ||
| export declare function getDiffContextSnapshot(ctx: ToolExecutionContext): DiffContextSnapshot; | ||
| export {}; | ||
| export interface FileContextSnapshot { | ||
| filePath: string; | ||
| content: string; | ||
| language: string; | ||
| lineCount: number; | ||
| sizeChars: number; | ||
| } | ||
| export declare function getFileContextSnapshot(ctx: ToolExecutionContext): FileContextSnapshot; |
+116
-228
| import { z } from 'zod'; | ||
| import { DefaultOutputSchema } from '../schemas/outputs.js'; | ||
| import { ANALYSIS_TEMPERATURE, CREATIVE_TEMPERATURE, FLASH_API_BREAKING_MAX_OUTPUT_TOKENS, FLASH_COMPLEXITY_MAX_OUTPUT_TOKENS, FLASH_MODEL, FLASH_TEST_PLAN_MAX_OUTPUT_TOKENS, FLASH_THINKING_LEVEL, FLASH_TRIAGE_MAX_OUTPUT_TOKENS, FLASH_TRIAGE_THINKING_LEVEL, TRIAGE_TEMPERATURE, } from './config.js'; | ||
| import { createCachedEnvInt } from './config.js'; | ||
| import { createNoDiffError, diffStaleWarningMs, getDiff, } from './diff.js'; | ||
| import { createNoDiffError, diffStaleWarningMs, getDiff, getDiffCacheSlot, } from './diff.js'; | ||
| import { validateDiffBudget } from './diff.js'; | ||
@@ -10,208 +9,8 @@ import { EMPTY_DIFF_STATS } from './diff.js'; | ||
| import { getErrorMessage } from './errors.js'; | ||
| import { stripJsonSchemaConstraints } from './gemini.js'; | ||
| import { generateStructuredJson } from './gemini.js'; | ||
| import { createNoFileError, getFile, validateFileBudget, } from './file-store.js'; | ||
| import { generateStructuredJson, stripJsonSchemaConstraints, } from './gemini/index.js'; | ||
| import { createFailureStatusMessage, DEFAULT_PROGRESS_CONTEXT, extractValidationMessage, getOrCreateProgressReporter, normalizeProgressContext, RunReporter, sendSingleStepProgress, STEP_BUILDING_PROMPT, STEP_CALLING_MODEL, STEP_FINALIZING, STEP_STARTING, STEP_VALIDATING, STEP_VALIDATING_RESPONSE, } from './progress.js'; | ||
| function appendErrorMeta(error, meta) { | ||
| if (meta?.retryable !== undefined) { | ||
| error.retryable = meta.retryable; | ||
| } | ||
| if (meta?.kind !== undefined) { | ||
| error.kind = meta.kind; | ||
| } | ||
| } | ||
| function createToolError(code, message, meta) { | ||
| const error = { code, message }; | ||
| appendErrorMeta(error, meta); | ||
| return error; | ||
| } | ||
| function toTextContent(structured, textContent) { | ||
| const text = textContent ?? JSON.stringify(structured); | ||
| return [{ type: 'text', text }]; | ||
| } | ||
| function createErrorStructuredContent(code, message, result, meta) { | ||
| const error = createToolError(code, message, meta); | ||
| if (result === undefined) { | ||
| return { ok: false, error }; | ||
| } | ||
| return { ok: false, error, result }; | ||
| } | ||
| export function createToolResponse(structured, textContent) { | ||
| return { | ||
| content: toTextContent(structured, textContent), | ||
| structuredContent: structured, | ||
| }; | ||
| } | ||
| export function createErrorToolResponse(code, message, result, meta) { | ||
| const structured = createErrorStructuredContent(code, message, result, meta); | ||
| return { | ||
| content: toTextContent(structured), | ||
| isError: true, | ||
| }; | ||
| } | ||
| const DEFAULT_TIMEOUT_FLASH_MS = 90_000; | ||
| export const INSPECTION_FOCUS_AREAS = [ | ||
| 'security', | ||
| 'correctness', | ||
| 'performance', | ||
| 'regressions', | ||
| 'tests', | ||
| 'maintainability', | ||
| 'concurrency', | ||
| ]; | ||
| export function buildStructuredToolRuntimeOptions(contract) { | ||
| return { | ||
| ...(contract.thinkingLevel !== undefined | ||
| ? { thinkingLevel: contract.thinkingLevel } | ||
| : {}), | ||
| ...(contract.temperature !== undefined | ||
| ? { temperature: contract.temperature } | ||
| : {}), | ||
| ...(contract.deterministicJson !== undefined | ||
| ? { deterministicJson: contract.deterministicJson } | ||
| : {}), | ||
| }; | ||
| } | ||
| export function buildStructuredToolExecutionOptions(contract) { | ||
| return { | ||
| timeoutMs: contract.timeoutMs, | ||
| maxOutputTokens: contract.maxOutputTokens, | ||
| ...buildStructuredToolRuntimeOptions(contract), | ||
| }; | ||
| } | ||
| function createParam(name, type, required, constraints, description) { | ||
| return { name, type, required, constraints, description }; | ||
| } | ||
| function cloneParams(...params) { | ||
| return params.map((param) => ({ ...param })); | ||
| } | ||
| const MODE_PARAM = createParam('mode', 'string', true, "'unstaged' | 'staged'", "'unstaged': working tree changes not yet staged. 'staged': changes added to the index (git add)."); | ||
| const REPOSITORY_PARAM = createParam('repository', 'string', true, '1-200 chars', 'Repository identifier (org/repo).'); | ||
| const LANGUAGE_PARAM = createParam('language', 'string', false, '2-32 chars', 'Primary language hint.'); | ||
| const TEST_FRAMEWORK_PARAM = createParam('testFramework', 'string', false, '1-50 chars', 'Framework hint (jest, vitest, pytest, node:test).'); | ||
| const MAX_TEST_CASES_PARAM = createParam('maxTestCases', 'number', false, '1-30', 'Post-generation cap applied to test cases.'); | ||
| export const TOOL_CONTRACTS = [ | ||
| { | ||
| name: 'generate_diff', | ||
| purpose: 'Generate a diff of current changes and cache it server-side. MUST be called before any other tool. Uses git to capture unstaged or staged changes in the current working directory.', | ||
| model: 'none', | ||
| timeoutMs: 0, | ||
| maxOutputTokens: 0, | ||
| params: cloneParams(MODE_PARAM), | ||
| outputShape: '{ok, result: {diffRef, stats{files, added, deleted}, generatedAt, mode, message}}', | ||
| gotchas: [ | ||
| 'Must be called first — all other tools return E_NO_DIFF if no diff is cached.', | ||
| 'Noisy files (lock files, dist/, build/, minified assets) are excluded automatically.', | ||
| 'Empty diff (no changes) returns E_NO_CHANGES.', | ||
| ], | ||
| crossToolFlow: [ | ||
| 'Caches diff at diff://current — consumed automatically by all review tools.', | ||
| ], | ||
| }, | ||
| { | ||
| name: 'analyze_pr_impact', | ||
| purpose: 'Assess severity, categories, breaking changes, and rollback complexity.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_FLASH_MS, | ||
| thinkingLevel: FLASH_TRIAGE_THINKING_LEVEL, | ||
| maxOutputTokens: FLASH_TRIAGE_MAX_OUTPUT_TOKENS, | ||
| temperature: TRIAGE_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(REPOSITORY_PARAM, LANGUAGE_PARAM), | ||
| outputShape: '{severity, categories[], summary, breakingChanges[], affectedAreas[], rollbackComplexity}', | ||
| gotchas: [ | ||
| 'Requires generate_diff to be called first.', | ||
| 'Flash triage tool optimized for speed.', | ||
| ], | ||
| crossToolFlow: [ | ||
| 'severity/categories feed triage and merge-gate decisions.', | ||
| ], | ||
| }, | ||
| { | ||
| name: 'generate_review_summary', | ||
| purpose: 'Produce PR summary, risk rating, and merge recommendation.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_FLASH_MS, | ||
| thinkingLevel: FLASH_TRIAGE_THINKING_LEVEL, | ||
| maxOutputTokens: FLASH_TRIAGE_MAX_OUTPUT_TOKENS, | ||
| temperature: TRIAGE_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(REPOSITORY_PARAM, LANGUAGE_PARAM), | ||
| outputShape: '{summary, overallRisk, keyChanges[], recommendation, stats{filesChanged, linesAdded, linesRemoved}}', | ||
| gotchas: [ | ||
| 'Requires generate_diff to be called first.', | ||
| 'stats are computed locally from the diff.', | ||
| ], | ||
| crossToolFlow: [ | ||
| 'Use before deep review to decide whether Pro analysis is needed.', | ||
| ], | ||
| }, | ||
| { | ||
| name: 'generate_test_plan', | ||
| purpose: 'Generate prioritized test cases and coverage guidance.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_FLASH_MS, | ||
| thinkingLevel: FLASH_THINKING_LEVEL, | ||
| maxOutputTokens: FLASH_TEST_PLAN_MAX_OUTPUT_TOKENS, | ||
| temperature: CREATIVE_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(REPOSITORY_PARAM, LANGUAGE_PARAM, TEST_FRAMEWORK_PARAM, MAX_TEST_CASES_PARAM), | ||
| outputShape: '{summary, testCases[], coverageSummary}', | ||
| gotchas: [ | ||
| 'Requires generate_diff to be called first.', | ||
| 'maxTestCases caps output after generation.', | ||
| ], | ||
| crossToolFlow: ['Pair with review tools to validate high-risk paths.'], | ||
| }, | ||
| { | ||
| name: 'analyze_time_space_complexity', | ||
| purpose: 'Analyze Big-O complexity and detect degradations in changed code.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_FLASH_MS, | ||
| thinkingLevel: FLASH_THINKING_LEVEL, | ||
| maxOutputTokens: FLASH_COMPLEXITY_MAX_OUTPUT_TOKENS, | ||
| temperature: ANALYSIS_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(LANGUAGE_PARAM), | ||
| outputShape: '{timeComplexity, spaceComplexity, explanation, potentialBottlenecks[], isDegradation}', | ||
| gotchas: [ | ||
| 'Requires generate_diff to be called first.', | ||
| 'Analyzes only changed code visible in the diff.', | ||
| ], | ||
| crossToolFlow: ['Use for algorithmic/performance-sensitive changes.'], | ||
| }, | ||
| { | ||
| name: 'detect_api_breaking_changes', | ||
| purpose: 'Detect breaking API/interface changes in a diff.', | ||
| model: FLASH_MODEL, | ||
| timeoutMs: DEFAULT_TIMEOUT_FLASH_MS, | ||
| thinkingLevel: FLASH_TRIAGE_THINKING_LEVEL, | ||
| maxOutputTokens: FLASH_API_BREAKING_MAX_OUTPUT_TOKENS, | ||
| temperature: TRIAGE_TEMPERATURE, | ||
| deterministicJson: true, | ||
| params: cloneParams(LANGUAGE_PARAM), | ||
| outputShape: '{hasBreakingChanges, breakingChanges[]}', | ||
| gotchas: [ | ||
| 'Requires generate_diff to be called first.', | ||
| 'Targets public API contracts over internal refactors.', | ||
| ], | ||
| crossToolFlow: ['Run before merge for API-surface-sensitive changes.'], | ||
| }, | ||
| ]; | ||
| const TOOL_CONTRACTS_BY_NAME = new Map(TOOL_CONTRACTS.map((contract) => [contract.name, contract])); | ||
| export function getToolContracts() { | ||
| return TOOL_CONTRACTS; | ||
| } | ||
| export function getToolContract(toolName) { | ||
| return TOOL_CONTRACTS_BY_NAME.get(toolName); | ||
| } | ||
| export function requireToolContract(toolName) { | ||
| const contract = getToolContract(toolName); | ||
| if (contract) { | ||
| return contract; | ||
| } | ||
| throw new Error(`Unknown tool contract: ${toolName}`); | ||
| } | ||
| export function getToolContractNames() { | ||
| return TOOL_CONTRACTS.map((contract) => contract.name); | ||
| } | ||
| import { createErrorToolResponse, createToolResponse, } from './tool-response.js'; | ||
| export * from './tool-response.js'; | ||
| export * from './tool-contracts.js'; | ||
| const DEFAULT_SCHEMA_RETRIES = 1; | ||
@@ -247,3 +46,5 @@ const geminiSchemaRetriesConfig = createCachedEnvInt('GEMINI_SCHEMA_RETRIES', DEFAULT_SCHEMA_RETRIES); | ||
| const sourceSchema = config.geminiSchema ?? config.resultSchema; | ||
| return stripJsonSchemaConstraints(z.toJSONSchema(sourceSchema)); | ||
| return stripJsonSchemaConstraints(z.toJSONSchema(sourceSchema, { | ||
| target: 'draft-2020-12', | ||
| })); | ||
| } | ||
@@ -298,3 +99,3 @@ function getCachedGeminiResponseSchema(config) { | ||
| } | ||
| function createGenerationRequest(config, promptParts, responseSchema, onLog, signal) { | ||
| function createGenerationRequest(config, promptParts, responseSchema, onLog, signal, cachedContent) { | ||
| const request = { | ||
@@ -320,2 +121,3 @@ systemInstruction: promptParts.systemInstruction, | ||
| ...(signal !== undefined ? { signal } : {}), | ||
| ...(cachedContent !== undefined ? { cachedContent } : {}), | ||
| }; | ||
@@ -334,3 +136,3 @@ if (config.deterministicJson) { | ||
| } | ||
| const ageMs = Date.now() - new Date(diffSlot.generatedAt).getTime(); | ||
| const ageMs = Date.now() - diffSlot.generatedAtMs; | ||
| if (ageMs <= diffStaleWarningMs.get()) { | ||
@@ -401,2 +203,11 @@ return textContent; | ||
| } | ||
| if (config.requiresFile) { | ||
| if (!ctx.fileSlot) { | ||
| return createNoFileError(); | ||
| } | ||
| const budgetError = validateFileBudget(ctx.fileSlot.content); | ||
| if (budgetError) { | ||
| return budgetError; | ||
| } | ||
| } | ||
| if (config.validateInput) { | ||
@@ -415,2 +226,3 @@ return await config.validateInput(inputRecord, ctx); | ||
| reporter; | ||
| executionCtx; | ||
| constructor(config, dependencies, signal) { | ||
@@ -432,2 +244,7 @@ this.config = config; | ||
| } | ||
| throwIfAborted() { | ||
| if (this.signal?.aborted) { | ||
| throw new DOMException('Task cancelled', 'AbortError'); | ||
| } | ||
| } | ||
| async handleInternalLog(data) { | ||
@@ -466,3 +283,5 @@ const record = asObjectRecord(data); | ||
| async executeModelCallAttempt(systemInstruction, prompt, attempt) { | ||
| const raw = await generateStructuredJson(createGenerationRequest(this.config, { systemInstruction, prompt }, this.responseSchema, this.onLog, this.signal)); | ||
| // Use snapshotted cache name from context instead of reading global state. | ||
| const cachedContent = this.executionCtx?.diffCacheSlotName; | ||
| const raw = await generateStructuredJson(createGenerationRequest(this.config, { systemInstruction, prompt }, this.responseSchema, this.onLog, this.signal, cachedContent)); | ||
| if (attempt === 0) { | ||
@@ -501,4 +320,9 @@ await this.reporter.reportStep(STEP_VALIDATING_RESPONSE, 'Verifying output structure...'); | ||
| createExecutionContext() { | ||
| const diffSlot = this.hasSnapshot ? this.diffSlotSnapshot : getDiff(); | ||
| return { | ||
| diffSlot: this.hasSnapshot ? this.diffSlotSnapshot : getDiff(), | ||
| diffSlot, | ||
| fileSlot: getFile(), | ||
| diffCacheSlotName: this.config.requiresDiff | ||
| ? getDiffCacheSlot()?.cacheName | ||
| : undefined, | ||
| }; | ||
@@ -534,3 +358,8 @@ } | ||
| const errorResponse = createErrorToolResponse(this.config.errorCode, errorMessage, undefined, errorMeta); | ||
| await this.reporter.storeResultSafely('failed', errorResponse, this.onLog); | ||
| if (outcome === 'cancelled') { | ||
| await this.reporter.reportCancellation(errorMessage); | ||
| } | ||
| else { | ||
| await this.reporter.storeResultSafely('failed', errorResponse, this.onLog); | ||
| } | ||
| await this.reporter.reportCompletion(outcome); | ||
@@ -546,2 +375,4 @@ return errorResponse; | ||
| const ctx = this.createExecutionContext(); | ||
| this.executionCtx = ctx; | ||
| this.throwIfAborted(); | ||
| await this.reporter.reportStep(STEP_VALIDATING, 'Validating request parameters...'); | ||
@@ -552,7 +383,19 @@ const validationError = await this.executeValidation(inputRecord, ctx); | ||
| } | ||
| this.throwIfAborted(); | ||
| await this.reporter.reportStep(STEP_BUILDING_PROMPT, 'Constructing analysis context...'); | ||
| const promptParts = this.config.buildPrompt(inputRecord, ctx); | ||
| const { prompt, systemInstruction } = promptParts; | ||
| this.throwIfAborted(); | ||
| await this.reporter.reportStep(STEP_CALLING_MODEL, 'Querying Gemini model...'); | ||
| const parsed = await this.executeModelCall(systemInstruction, prompt); | ||
| let parsed; | ||
| if (this.config.customGenerate) { | ||
| parsed = await this.config.customGenerate(promptParts, ctx, { | ||
| onLog: this.onLog, | ||
| ...(this.signal ? { signal: this.signal } : {}), | ||
| }); | ||
| } | ||
| else { | ||
| parsed = await this.executeModelCall(systemInstruction, prompt); | ||
| } | ||
| this.throwIfAborted(); | ||
| await this.reporter.reportStep(STEP_FINALIZING, 'Processing results...'); | ||
@@ -568,3 +411,11 @@ const finalResult = this.applyResultTransform(inputRecord, parsed, ctx); | ||
| } | ||
| function createGeminiLogger(server) { | ||
| /** Runtime check: SDK's InMemoryTaskStore exposes updateTaskStatus. */ | ||
| function hasTaskStatusUpdate(store) { | ||
| return 'updateTaskStatus' in store; | ||
| } | ||
| // Adapter to convert MCP task handler extra into progress reporter extra, which currently only differs by the presence of updateTaskStatus. | ||
| function toProgressExtra(extra) { | ||
| return extra; | ||
| } | ||
| export function createGeminiLogger(server) { | ||
| return async (level, data) => { | ||
@@ -583,6 +434,8 @@ try { | ||
| } | ||
| function createTaskStatusReporter(taskId, extra, extendedStore) { | ||
| function createTaskStatusReporter(taskId, extra, store) { | ||
| return { | ||
| updateStatus: async (message) => { | ||
| await extendedStore.updateTaskStatus(taskId, 'working', message); | ||
| if (hasTaskStatusUpdate(store)) { | ||
| await store.updateTaskStatus(taskId, 'working', message); | ||
| } | ||
| }, | ||
@@ -592,6 +445,13 @@ storeResult: async (status, result) => { | ||
| }, | ||
| reportCancellation: async (message) => { | ||
| if (hasTaskStatusUpdate(store)) { | ||
| await store.updateTaskStatus(taskId, 'cancelled', message); | ||
| } | ||
| }, | ||
| }; | ||
| } | ||
| function runToolTaskInBackground(runner, input, taskId, extendedStore, signal) { | ||
| function runToolTaskInBackground(runner, input, taskId, store, signal) { | ||
| runner.run(input).catch(async (error) => { | ||
| // Safety net: run() swallows errors via handleRunFailure, so this only fires | ||
| // if handleRunFailure itself throws (e.g. reporter/store internal crash). | ||
| const isAbort = error != null && | ||
@@ -603,3 +463,5 @@ typeof error === 'object' && | ||
| try { | ||
| await extendedStore.updateTaskStatus(taskId, isCancelled ? 'cancelled' : 'failed', getErrorMessage(error)); | ||
| if (hasTaskStatusUpdate(store)) { | ||
| await store.updateTaskStatus(taskId, isCancelled ? 'cancelled' : 'failed', getErrorMessage(error)); | ||
| } | ||
| } | ||
@@ -612,6 +474,8 @@ catch { | ||
| export function registerStructuredToolTask(server, config) { | ||
| const responseSchema = createGeminiResponseSchema({ | ||
| geminiSchema: config.geminiSchema, | ||
| resultSchema: config.resultSchema, | ||
| }); | ||
| const responseSchema = config.customGenerate | ||
| ? {} | ||
| : createGeminiResponseSchema({ | ||
| geminiSchema: config.geminiSchema, | ||
| resultSchema: config.resultSchema, | ||
| }); | ||
| responseSchemaCache.set(config, responseSchema); | ||
@@ -632,10 +496,14 @@ server.experimental.tasks.registerToolTask(config.name, { | ||
| }); | ||
| const extendedStore = extra.taskStore; | ||
| const runner = new ToolExecutionRunner(config, { | ||
| onLog: createGeminiLogger(server), | ||
| reportProgress: getOrCreateProgressReporter(extra), | ||
| statusReporter: createTaskStatusReporter(task.taskId, extra, extendedStore), | ||
| reportProgress: getOrCreateProgressReporter(toProgressExtra(extra)), | ||
| statusReporter: createTaskStatusReporter(task.taskId, extra, extra.taskStore), | ||
| }, extra.signal); | ||
| runToolTaskInBackground(runner, input, task.taskId, extendedStore, extra.signal); | ||
| return { task }; | ||
| runToolTaskInBackground(runner, input, task.taskId, extra.taskStore, extra.signal); | ||
| return { | ||
| task, | ||
| _meta: { | ||
| 'io.modelcontextprotocol/model-immediate-response': `${config.title} is running in the background.`, | ||
| }, | ||
| }; | ||
| }, | ||
@@ -666,1 +534,21 @@ getTask: async (input, extra) => { | ||
| } | ||
| const EMPTY_FILE_SNAPSHOT = { | ||
| filePath: '', | ||
| content: '', | ||
| language: '', | ||
| lineCount: 0, | ||
| sizeChars: 0, | ||
| }; | ||
| export function getFileContextSnapshot(ctx) { | ||
| const slot = ctx.fileSlot; | ||
| if (!slot) { | ||
| return EMPTY_FILE_SNAPSHOT; | ||
| } | ||
| return { | ||
| filePath: slot.filePath, | ||
| content: slot.content, | ||
| language: slot.language, | ||
| lineCount: slot.lineCount, | ||
| sizeChars: slot.sizeChars, | ||
| }; | ||
| } |
@@ -12,2 +12,5 @@ import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| export declare const DIFF_RESOURCE_DESCRIPTION = "The most recently generated diff, cached by generate_diff. Read by all review tools automatically."; | ||
| export declare const FILE_RESOURCE_DESCRIPTION = "The most recently loaded source file, cached by load_file. Read by file analysis tools automatically."; | ||
| export declare const REPOSITORY_RESOURCE_URI = "repository://current"; | ||
| export declare const REPOSITORY_RESOURCE_DESCRIPTION = "Current indexed repository search store status. Populated by index_repository."; | ||
| export declare function registerAllResources(server: McpServer, instructions: string): void; |
| import { ResourceTemplate, } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| import { DIFF_RESOURCE_URI, getDiff } from '../lib/diff.js'; | ||
| import { getFile, SOURCE_RESOURCE_URI } from '../lib/file-store.js'; | ||
| import { getCurrentSearchStore } from '../lib/gemini/index.js'; | ||
| import { buildServerConfig } from './server-config.js'; | ||
@@ -108,2 +110,46 @@ import { buildToolCatalog } from './tool-catalog.js'; | ||
| } | ||
| export const FILE_RESOURCE_DESCRIPTION = 'The most recently loaded source file, cached by load_file. Read by file analysis tools automatically.'; | ||
| function formatFileResourceText() { | ||
| const slot = getFile(); | ||
| if (!slot) { | ||
| return '# No file cached. Call load_file first.'; | ||
| } | ||
| return `# File — ${slot.filePath} — ${slot.cachedAt}\n# ${slot.lineCount} lines, ${slot.sizeChars} chars\n\n${slot.content}`; | ||
| } | ||
| function registerFileResource(server) { | ||
| server.registerResource('file-current', SOURCE_RESOURCE_URI, { | ||
| title: 'Current File', | ||
| description: FILE_RESOURCE_DESCRIPTION, | ||
| mimeType: 'text/plain', | ||
| annotations: createResourceAnnotations(1.0), | ||
| }, (uri) => ({ | ||
| contents: [ | ||
| { | ||
| uri: uri.href, | ||
| mimeType: 'text/plain', | ||
| text: formatFileResourceText(), | ||
| }, | ||
| ], | ||
| })); | ||
| } | ||
| export const REPOSITORY_RESOURCE_URI = 'repository://current'; | ||
| export const REPOSITORY_RESOURCE_DESCRIPTION = 'Current indexed repository search store status. Populated by index_repository.'; | ||
| function formatRepositoryResourceText() { | ||
| const slot = getCurrentSearchStore(); | ||
| if (!slot) { | ||
| return '# No repository indexed. Call index_repository first.'; | ||
| } | ||
| const ageMinutes = Math.round((Date.now() - slot.createdAt) / 60_000); | ||
| return `# Repository Store — ${slot.displayName}\n- Store: ${slot.storeName}\n- Documents: ${String(slot.documentCount)}\n- Age: ${String(ageMinutes)} min`; | ||
| } | ||
| function registerRepositoryResource(server) { | ||
| server.registerResource('repository-current', REPOSITORY_RESOURCE_URI, { | ||
| title: 'Current Repository Store', | ||
| description: REPOSITORY_RESOURCE_DESCRIPTION, | ||
| mimeType: RESOURCE_MIME_TYPE, | ||
| annotations: createResourceAnnotations(0.5), | ||
| }, (uri) => ({ | ||
| contents: [createMarkdownContent(uri, formatRepositoryResourceText())], | ||
| })); | ||
| } | ||
| export function registerAllResources(server, instructions) { | ||
@@ -116,2 +162,4 @@ for (const def of STATIC_RESOURCES) { | ||
| registerDiffResource(server); | ||
| registerFileResource(server); | ||
| registerRepositoryResource(server); | ||
| } |
@@ -10,3 +10,3 @@ import { toBulletedList, toInlineCode } from '../lib/format.js'; | ||
| `${toInlineCode('internal://tool-info/{toolName}')}: Per-tool contract details.`, | ||
| `${toInlineCode('diff://current')}: ${DIFF_RESOURCE_DESCRIPTION}`, | ||
| `${toInlineCode('internal://diff/current')}: ${DIFF_RESOURCE_DESCRIPTION}`, | ||
| ]; | ||
@@ -70,4 +70,4 @@ function formatParameterLine(parameter) { | ||
| - Schema repair: on validation failure, retries with error feedback (configurable via \`GEMINI_SCHEMA_RETRIES\`). | ||
| - Task terminal states: \`completed\` and \`failed\`; cancellations are surfaced as \`failed\` with \`error.kind=cancelled\`. | ||
| - Task terminal states: \`completed\`, \`failed\`, and \`cancelled\`. | ||
| `; | ||
| } |
@@ -14,3 +14,3 @@ import { z } from 'zod'; | ||
| testFramework: z.ZodOptional<z.ZodString>; | ||
| maxTestCases: z.ZodOptional<z.ZodNumber>; | ||
| maxTestCases: z.ZodOptional<z.ZodInt>; | ||
| }, z.core.$strict>; | ||
@@ -23,1 +23,38 @@ export declare const AnalyzeComplexityInputSchema: z.ZodObject<{ | ||
| }, z.core.$strict>; | ||
| export declare const WebSearchInputSchema: z.ZodObject<{ | ||
| query: z.ZodString; | ||
| }, z.core.$strict>; | ||
| export declare const LoadFileInputSchema: z.ZodObject<{ | ||
| filePath: z.ZodString; | ||
| }, z.core.$strict>; | ||
| export declare const RefactorCodeInputSchema: z.ZodObject<{ | ||
| language: z.ZodOptional<z.ZodString>; | ||
| }, z.core.$strict>; | ||
| export declare const AskInputSchema: z.ZodObject<{ | ||
| question: z.ZodString; | ||
| language: z.ZodOptional<z.ZodString>; | ||
| }, z.core.$strict>; | ||
| export declare const VerifyLogicInputSchema: z.ZodObject<{ | ||
| question: z.ZodString; | ||
| language: z.ZodOptional<z.ZodString>; | ||
| }, z.core.$strict>; | ||
| export declare const IndexRepositoryInputSchema: z.ZodObject<{ | ||
| rootPath: z.ZodString; | ||
| displayName: z.ZodOptional<z.ZodString>; | ||
| }, z.core.$strict>; | ||
| export declare const QueryRepositoryInputSchema: z.ZodObject<{ | ||
| query: z.ZodString; | ||
| language: z.ZodOptional<z.ZodString>; | ||
| }, z.core.$strict>; | ||
| export type AnalyzePrImpactInput = z.infer<typeof AnalyzePrImpactInputSchema>; | ||
| export type GenerateReviewSummaryInput = z.infer<typeof GenerateReviewSummaryInputSchema>; | ||
| export type GenerateTestPlanInput = z.infer<typeof GenerateTestPlanInputSchema>; | ||
| export type AnalyzeComplexityInput = z.infer<typeof AnalyzeComplexityInputSchema>; | ||
| export type DetectApiBreakingInput = z.infer<typeof DetectApiBreakingInputSchema>; | ||
| export type WebSearchInput = z.infer<typeof WebSearchInputSchema>; | ||
| export type LoadFileInput = z.infer<typeof LoadFileInputSchema>; | ||
| export type RefactorCodeInput = z.infer<typeof RefactorCodeInputSchema>; | ||
| export type AskInput = z.infer<typeof AskInputSchema>; | ||
| export type VerifyLogicInput = z.infer<typeof VerifyLogicInputSchema>; | ||
| export type IndexRepositoryInput = z.infer<typeof IndexRepositoryInputSchema>; | ||
| export type QueryRepositoryInput = z.infer<typeof QueryRepositoryInputSchema>; |
| import { z } from 'zod'; | ||
| import { createBoundedString, createOptionalBoundedString } from './helpers.js'; | ||
| const INPUT_LIMITS = { | ||
@@ -8,8 +9,2 @@ repository: { min: 1, max: 200 }, | ||
| }; | ||
| function createBoundedString(min, max, description) { | ||
| return z.string().min(min).max(max).describe(description); | ||
| } | ||
| function createOptionalBoundedString(min, max, description) { | ||
| return createBoundedString(min, max, description).optional(); | ||
| } | ||
| const LANGUAGE_DESCRIPTION = 'Primary language (e.g. TypeScript). Auto-infer from files.'; | ||
@@ -24,3 +19,3 @@ const REPOSITORY_DESCRIPTION = 'Repo ID (owner/repo). Auto-infer from git/dir.'; | ||
| function createOptionalBoundedInteger(min, max, description) { | ||
| return z.number().int().min(min).max(max).optional().describe(description); | ||
| return z.int().min(min).max(max).optional().describe(description); | ||
| } | ||
@@ -49,1 +44,34 @@ const RepositorySchema = createRepositorySchema(); | ||
| }); | ||
| export const WebSearchInputSchema = z.strictObject({ | ||
| query: z.string().min(1).max(1000).describe('Search query'), | ||
| }); | ||
| export const LoadFileInputSchema = z.strictObject({ | ||
| filePath: z | ||
| .string() | ||
| .min(1) | ||
| .max(500) | ||
| .describe('Absolute path to the file to analyze.'), | ||
| }); | ||
| export const RefactorCodeInputSchema = z.strictObject({ | ||
| language: LanguageSchema, | ||
| }); | ||
| export const AskInputSchema = z.strictObject({ | ||
| question: createBoundedString(1, 2000, 'Question about the loaded file.'), | ||
| language: LanguageSchema, | ||
| }); | ||
| export const VerifyLogicInputSchema = z.strictObject({ | ||
| question: createBoundedString(1, 2000, 'What to verify in the loaded file (e.g. algorithm correctness, edge cases).'), | ||
| language: LanguageSchema, | ||
| }); | ||
| export const IndexRepositoryInputSchema = z.strictObject({ | ||
| rootPath: z | ||
| .string() | ||
| .min(1) | ||
| .max(500) | ||
| .describe('Absolute path to the repository root directory.'), | ||
| displayName: createOptionalBoundedString(1, 100, 'Display name for the search store. Default: directory name.'), | ||
| }); | ||
| export const QueryRepositoryInputSchema = z.strictObject({ | ||
| query: createBoundedString(1, 2000, 'Natural-language question about the repository codebase.'), | ||
| language: LanguageSchema, | ||
| }); |
@@ -59,5 +59,5 @@ import { z } from 'zod'; | ||
| stats: z.ZodObject<{ | ||
| filesChanged: z.ZodNumber; | ||
| linesAdded: z.ZodNumber; | ||
| linesRemoved: z.ZodNumber; | ||
| filesChanged: z.ZodInt; | ||
| linesAdded: z.ZodInt; | ||
| linesRemoved: z.ZodInt; | ||
| }, z.core.$strict>; | ||
@@ -123,1 +123,170 @@ }, z.core.$strict>; | ||
| }, z.core.$strict>; | ||
| export declare const RefactorSuggestionSchema: z.ZodObject<{ | ||
| category: z.ZodEnum<{ | ||
| naming: "naming"; | ||
| complexity: "complexity"; | ||
| duplication: "duplication"; | ||
| grouping: "grouping"; | ||
| }>; | ||
| target: z.ZodString; | ||
| currentIssue: z.ZodString; | ||
| suggestion: z.ZodString; | ||
| priority: z.ZodEnum<{ | ||
| low: "low"; | ||
| medium: "medium"; | ||
| high: "high"; | ||
| }>; | ||
| }, z.core.$strict>; | ||
| export declare const RefactorCodeGeminiResultSchema: z.ZodObject<{ | ||
| summary: z.ZodString; | ||
| suggestions: z.ZodArray<z.ZodObject<{ | ||
| category: z.ZodEnum<{ | ||
| naming: "naming"; | ||
| complexity: "complexity"; | ||
| duplication: "duplication"; | ||
| grouping: "grouping"; | ||
| }>; | ||
| target: z.ZodString; | ||
| currentIssue: z.ZodString; | ||
| suggestion: z.ZodString; | ||
| priority: z.ZodEnum<{ | ||
| low: "low"; | ||
| medium: "medium"; | ||
| high: "high"; | ||
| }>; | ||
| }, z.core.$strict>>; | ||
| }, z.core.$strict>; | ||
| export declare const RefactorCodeResultSchema: z.ZodObject<{ | ||
| summary: z.ZodString; | ||
| suggestions: z.ZodArray<z.ZodObject<{ | ||
| category: z.ZodEnum<{ | ||
| naming: "naming"; | ||
| complexity: "complexity"; | ||
| duplication: "duplication"; | ||
| grouping: "grouping"; | ||
| }>; | ||
| target: z.ZodString; | ||
| currentIssue: z.ZodString; | ||
| suggestion: z.ZodString; | ||
| priority: z.ZodEnum<{ | ||
| low: "low"; | ||
| medium: "medium"; | ||
| high: "high"; | ||
| }>; | ||
| }, z.core.$strict>>; | ||
| filePath: z.ZodString; | ||
| language: z.ZodString; | ||
| namingIssuesCount: z.ZodInt; | ||
| complexityIssuesCount: z.ZodInt; | ||
| duplicationIssuesCount: z.ZodInt; | ||
| groupingIssuesCount: z.ZodInt; | ||
| }, z.core.$strict>; | ||
| export type DefaultOutput = z.infer<typeof DefaultOutputSchema>; | ||
| export type PrImpactResult = z.infer<typeof PrImpactResultSchema>; | ||
| export type ReviewSummaryResult = z.infer<typeof ReviewSummaryResultSchema>; | ||
| export type TestCase = z.infer<typeof TestCaseSchema>; | ||
| export type TestPlanResult = z.infer<typeof TestPlanResultSchema>; | ||
| export type AnalyzeComplexityResult = z.infer<typeof AnalyzeComplexityResultSchema>; | ||
| export type DetectApiBreakingResult = z.infer<typeof DetectApiBreakingResultSchema>; | ||
| export type RefactorSuggestion = z.infer<typeof RefactorSuggestionSchema>; | ||
| export type RefactorCodeGeminiResult = z.infer<typeof RefactorCodeGeminiResultSchema>; | ||
| export type RefactorCodeResult = z.infer<typeof RefactorCodeResultSchema>; | ||
| export declare const AskCodeReferenceSchema: z.ZodObject<{ | ||
| target: z.ZodString; | ||
| explanation: z.ZodString; | ||
| }, z.core.$strict>; | ||
| export declare const AskGeminiResultSchema: z.ZodObject<{ | ||
| answer: z.ZodString; | ||
| codeReferences: z.ZodArray<z.ZodObject<{ | ||
| target: z.ZodString; | ||
| explanation: z.ZodString; | ||
| }, z.core.$strict>>; | ||
| confidence: z.ZodEnum<{ | ||
| low: "low"; | ||
| medium: "medium"; | ||
| high: "high"; | ||
| }>; | ||
| }, z.core.$strict>; | ||
| export declare const AskResultSchema: z.ZodObject<{ | ||
| answer: z.ZodString; | ||
| codeReferences: z.ZodArray<z.ZodObject<{ | ||
| target: z.ZodString; | ||
| explanation: z.ZodString; | ||
| }, z.core.$strict>>; | ||
| confidence: z.ZodEnum<{ | ||
| low: "low"; | ||
| medium: "medium"; | ||
| high: "high"; | ||
| }>; | ||
| filePath: z.ZodString; | ||
| language: z.ZodString; | ||
| }, z.core.$strict>; | ||
| export type AskCodeReference = z.infer<typeof AskCodeReferenceSchema>; | ||
| export type AskGeminiResult = z.infer<typeof AskGeminiResultSchema>; | ||
| export type AskResult = z.infer<typeof AskResultSchema>; | ||
| export declare const VerifyLogicGeminiResultSchema: z.ZodObject<{ | ||
| answer: z.ZodString; | ||
| verified: z.ZodBoolean; | ||
| codeBlocks: z.ZodArray<z.ZodObject<{ | ||
| code: z.ZodString; | ||
| language: z.ZodString; | ||
| }, z.core.$strict>>; | ||
| executionResults: z.ZodArray<z.ZodObject<{ | ||
| outcome: z.ZodEnum<{ | ||
| OUTCOME_OK: "OUTCOME_OK"; | ||
| OUTCOME_FAILED: "OUTCOME_FAILED"; | ||
| OUTCOME_DEADLINE_EXCEEDED: "OUTCOME_DEADLINE_EXCEEDED"; | ||
| OUTCOME_UNSPECIFIED: "OUTCOME_UNSPECIFIED"; | ||
| }>; | ||
| output: z.ZodString; | ||
| }, z.core.$strict>>; | ||
| }, z.core.$strict>; | ||
| export declare const VerifyLogicResultSchema: z.ZodObject<{ | ||
| answer: z.ZodString; | ||
| verified: z.ZodBoolean; | ||
| codeBlocks: z.ZodArray<z.ZodObject<{ | ||
| code: z.ZodString; | ||
| language: z.ZodString; | ||
| }, z.core.$strict>>; | ||
| executionResults: z.ZodArray<z.ZodObject<{ | ||
| outcome: z.ZodEnum<{ | ||
| OUTCOME_OK: "OUTCOME_OK"; | ||
| OUTCOME_FAILED: "OUTCOME_FAILED"; | ||
| OUTCOME_DEADLINE_EXCEEDED: "OUTCOME_DEADLINE_EXCEEDED"; | ||
| OUTCOME_UNSPECIFIED: "OUTCOME_UNSPECIFIED"; | ||
| }>; | ||
| output: z.ZodString; | ||
| }, z.core.$strict>>; | ||
| filePath: z.ZodString; | ||
| language: z.ZodString; | ||
| }, z.core.$strict>; | ||
| export type VerifyLogicGeminiResult = z.infer<typeof VerifyLogicGeminiResultSchema>; | ||
| export type VerifyLogicResult = z.infer<typeof VerifyLogicResultSchema>; | ||
| export declare const IndexRepositoryResultSchema: z.ZodObject<{ | ||
| storeName: z.ZodString; | ||
| displayName: z.ZodString; | ||
| filesUploaded: z.ZodInt; | ||
| filesSkipped: z.ZodInt; | ||
| message: z.ZodString; | ||
| }, z.core.$strict>; | ||
| export type IndexRepositoryResult = z.infer<typeof IndexRepositoryResultSchema>; | ||
| export declare const QueryRepositorySourceSchema: z.ZodObject<{ | ||
| fileSearchStore: z.ZodOptional<z.ZodString>; | ||
| title: z.ZodOptional<z.ZodString>; | ||
| text: z.ZodOptional<z.ZodString>; | ||
| }, z.core.$strict>; | ||
| export declare const QueryRepositoryResultSchema: z.ZodObject<{ | ||
| answer: z.ZodString; | ||
| sources: z.ZodArray<z.ZodObject<{ | ||
| fileSearchStore: z.ZodOptional<z.ZodString>; | ||
| title: z.ZodOptional<z.ZodString>; | ||
| text: z.ZodOptional<z.ZodString>; | ||
| }, z.core.$strict>>; | ||
| }, z.core.$strict>; | ||
| export type QueryRepositorySource = z.infer<typeof QueryRepositorySourceSchema>; | ||
| export type QueryRepositoryResult = z.infer<typeof QueryRepositoryResultSchema>; | ||
| export declare const WebSearchResultSchema: z.ZodObject<{ | ||
| text: z.ZodString; | ||
| groundingMetadata: z.ZodUnknown; | ||
| }, z.core.$strict>; | ||
| export type WebSearchResult = z.infer<typeof WebSearchResultSchema>; |
+132
-13
| import { z } from 'zod'; | ||
| import { createBoundedString, createBoundedStringArray } from './helpers.js'; | ||
| const OUTPUT_LIMITS = { | ||
@@ -34,12 +35,2 @@ reviewDiffResult: { | ||
| ]; | ||
| function createBoundedString(min, max, description) { | ||
| return z.string().min(min).max(max).describe(description); | ||
| } | ||
| function createBoundedStringArray(itemMin, itemMax, minItems, maxItems, description) { | ||
| return z | ||
| .array(z.string().min(itemMin).max(itemMax)) | ||
| .min(minItems) | ||
| .max(maxItems) | ||
| .describe(description); | ||
| } | ||
| function createReviewSummarySchema(description) { | ||
@@ -106,5 +97,5 @@ return z | ||
| .strictObject({ | ||
| filesChanged: z.number().int().min(0).describe('Files changed.'), | ||
| linesAdded: z.number().int().min(0).describe('Lines added.'), | ||
| linesRemoved: z.number().int().min(0).describe('Lines removed.'), | ||
| filesChanged: z.int().min(0).describe('Files changed.'), | ||
| linesAdded: z.int().min(0).describe('Lines added.'), | ||
| linesRemoved: z.int().min(0).describe('Lines removed.'), | ||
| }) | ||
@@ -165,1 +156,129 @@ .describe('Change statistics (computed from diff before Gemini call).'), | ||
| }); | ||
| const REFACTOR_CATEGORIES = [ | ||
| 'naming', | ||
| 'complexity', | ||
| 'duplication', | ||
| 'grouping', | ||
| ]; | ||
| const REFACTOR_PRIORITIES = ['high', 'medium', 'low']; | ||
| export const RefactorSuggestionSchema = z.strictObject({ | ||
| category: z.enum(REFACTOR_CATEGORIES).describe('Refactoring category.'), | ||
| target: createBoundedString(1, 300, 'Function/variable/block name or location.'), | ||
| currentIssue: createBoundedString(1, 500, 'What is wrong.'), | ||
| suggestion: createBoundedString(1, 1000, 'Concrete refactoring suggestion.'), | ||
| priority: z.enum(REFACTOR_PRIORITIES).describe('Suggestion priority.'), | ||
| }); | ||
| export const RefactorCodeGeminiResultSchema = z.strictObject({ | ||
| summary: z | ||
| .string() | ||
| .min(1) | ||
| .max(2000) | ||
| .describe('Refactoring analysis summary.'), | ||
| suggestions: z | ||
| .array(RefactorSuggestionSchema) | ||
| .min(0) | ||
| .max(50) | ||
| .describe('Refactoring suggestions.'), | ||
| }); | ||
| export const RefactorCodeResultSchema = RefactorCodeGeminiResultSchema.extend({ | ||
| filePath: z.string().describe('Analyzed file path.'), | ||
| language: z.string().describe('Detected/provided language.'), | ||
| namingIssuesCount: z.int().min(0).describe('Naming issues count.'), | ||
| complexityIssuesCount: z.int().min(0).describe('Complexity issues count.'), | ||
| duplicationIssuesCount: z.int().min(0).describe('Duplication issues count.'), | ||
| groupingIssuesCount: z.int().min(0).describe('Grouping issues count.'), | ||
| }); | ||
| const CONFIDENCE_LEVELS = ['high', 'medium', 'low']; | ||
| export const AskCodeReferenceSchema = z.strictObject({ | ||
| target: createBoundedString(1, 300, 'Function/class/variable/line referenced.'), | ||
| explanation: createBoundedString(1, 500, 'How this code relates to the answer.'), | ||
| }); | ||
| export const AskGeminiResultSchema = z.strictObject({ | ||
| answer: z.string().min(1).max(10_000).describe('Answer to the question.'), | ||
| codeReferences: z | ||
| .array(AskCodeReferenceSchema) | ||
| .min(0) | ||
| .max(20) | ||
| .describe('Specific code elements referenced in the answer.'), | ||
| confidence: z.enum(CONFIDENCE_LEVELS).describe('Confidence in the answer.'), | ||
| }); | ||
| export const AskResultSchema = AskGeminiResultSchema.extend({ | ||
| filePath: z.string().describe('Analyzed file path.'), | ||
| language: z.string().describe('Detected/provided language.'), | ||
| }); | ||
| const EXECUTION_OUTCOMES = [ | ||
| 'OUTCOME_OK', | ||
| 'OUTCOME_FAILED', | ||
| 'OUTCOME_DEADLINE_EXCEEDED', | ||
| 'OUTCOME_UNSPECIFIED', | ||
| ]; | ||
| export const VerifyLogicGeminiResultSchema = z.strictObject({ | ||
| answer: z | ||
| .string() | ||
| .min(1) | ||
| .max(10_000) | ||
| .describe('Analysis and conclusion from the verification.'), | ||
| verified: z | ||
| .boolean() | ||
| .describe('True if all execution results passed (OUTCOME_OK).'), | ||
| codeBlocks: z | ||
| .array(z.strictObject({ | ||
| code: z.string().describe('Generated verification code.'), | ||
| language: z.string().describe('Programming language (e.g. python).'), | ||
| })) | ||
| .max(10) | ||
| .describe('Code generated and executed during verification.'), | ||
| executionResults: z | ||
| .array(z.strictObject({ | ||
| outcome: z.enum(EXECUTION_OUTCOMES).describe('Execution outcome.'), | ||
| output: z.string().describe('stdout on success, stderr on failure.'), | ||
| })) | ||
| .max(10) | ||
| .describe('Results from server-side code execution.'), | ||
| }); | ||
| export const VerifyLogicResultSchema = VerifyLogicGeminiResultSchema.extend({ | ||
| filePath: z.string().describe('Analyzed file path.'), | ||
| language: z.string().describe('Detected/provided language.'), | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // Repository indexing & query | ||
| // --------------------------------------------------------------------------- | ||
| export const IndexRepositoryResultSchema = z.strictObject({ | ||
| storeName: z.string().describe('File Search Store resource name.'), | ||
| displayName: z.string().describe('Human-readable store name.'), | ||
| filesUploaded: z.int().min(0).describe('Files successfully uploaded.'), | ||
| filesSkipped: z | ||
| .int() | ||
| .min(0) | ||
| .describe('Files skipped (binary, too large, denied).'), | ||
| message: z.string().describe('Summary message.'), | ||
| }); | ||
| export const QueryRepositorySourceSchema = z.strictObject({ | ||
| fileSearchStore: z | ||
| .string() | ||
| .max(200) | ||
| .optional() | ||
| .describe('Search store that provided this result.'), | ||
| title: z.string().max(500).optional().describe('Document title.'), | ||
| text: z.string().max(2000).optional().describe('Relevant excerpt.'), | ||
| }); | ||
| export const QueryRepositoryResultSchema = z.strictObject({ | ||
| answer: z.string().min(1).max(10_000).describe('Answer to the query.'), | ||
| sources: z | ||
| .array(QueryRepositorySourceSchema) | ||
| .max(20) | ||
| .describe('Source documents cited.'), | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // Web search | ||
| // --------------------------------------------------------------------------- | ||
| export const WebSearchResultSchema = z.strictObject({ | ||
| text: z | ||
| .string() | ||
| .min(1) | ||
| .max(50_000) | ||
| .describe('Formatted search result text with citations.'), | ||
| groundingMetadata: z | ||
| .unknown() | ||
| .describe('Raw grounding metadata from search.'), | ||
| }); |
+3
-1
@@ -8,2 +8,3 @@ import { readFileSync } from 'node:fs'; | ||
| import { getErrorMessage } from './lib/errors.js'; | ||
| import { initFileStore } from './lib/file-store.js'; | ||
| import { registerAllPrompts } from './prompts/index.js'; | ||
@@ -28,3 +29,3 @@ import { registerAllResources } from './resources/index.js'; | ||
| resources: { subscribe: true }, | ||
| tools: {}, | ||
| tools: { listChanged: true }, | ||
| tasks: { | ||
@@ -77,2 +78,3 @@ list: {}, | ||
| initDiffStore(server); | ||
| initFileStore(server); | ||
| registerAllTools(server); | ||
@@ -79,0 +81,0 @@ registerAllResources(server, SERVER_INSTRUCTIONS); |
@@ -41,3 +41,20 @@ import { buildLanguageDiffPrompt } from '../lib/format.js'; | ||
| : 'No degradation', | ||
| formatOutput: (result) => `Time=${result.timeComplexity}, Space=${result.spaceComplexity}. ${result.explanation}`, | ||
| formatOutput: (result) => { | ||
| const lines = [ | ||
| `**Time Complexity:** ${result.timeComplexity}`, | ||
| `**Space Complexity:** ${result.spaceComplexity}`, | ||
| '', | ||
| result.explanation, | ||
| ]; | ||
| if (result.potentialBottlenecks.length > 0) { | ||
| lines.push('', '### Potential Bottlenecks', ''); | ||
| for (const b of result.potentialBottlenecks) { | ||
| lines.push(`- ${b}`); | ||
| } | ||
| } | ||
| if (result.isDegradation) { | ||
| lines.push('', '> **Warning:** Performance degradation detected.'); | ||
| } | ||
| return lines.join('\n'); | ||
| }, | ||
| buildPrompt: (input, ctx) => { | ||
@@ -44,0 +61,0 @@ const { diff } = getDiffContextSnapshot(ctx); |
@@ -41,3 +41,29 @@ import { computeDiffStatsAndSummaryFromFiles } from '../lib/diff.js'; | ||
| formatOutcome: (result) => `severity: ${result.severity}`, | ||
| formatOutput: (result) => `[${result.severity}] ${result.summary}`, | ||
| formatOutput: (result) => { | ||
| const lines = [ | ||
| `**Severity:** ${result.severity}`, | ||
| `**Rollback Complexity:** ${result.rollbackComplexity}`, | ||
| '', | ||
| result.summary, | ||
| ]; | ||
| if (result.categories.length > 0) { | ||
| lines.push('', '### Categories', ''); | ||
| for (const c of result.categories) { | ||
| lines.push(`- ${c}`); | ||
| } | ||
| } | ||
| if (result.affectedAreas.length > 0) { | ||
| lines.push('', '### Affected Areas', ''); | ||
| for (const a of result.affectedAreas) { | ||
| lines.push(`- ${a}`); | ||
| } | ||
| } | ||
| if (result.breakingChanges.length > 0) { | ||
| lines.push('', '### Breaking Changes', ''); | ||
| for (const bc of result.breakingChanges) { | ||
| lines.push(`- ${bc}`); | ||
| } | ||
| } | ||
| return lines.join('\n'); | ||
| }, | ||
| buildPrompt: (input, ctx) => { | ||
@@ -44,0 +70,0 @@ const { diff, parsedFiles } = getDiffContextSnapshot(ctx); |
@@ -38,5 +38,15 @@ import { buildLanguageDiffPrompt } from '../lib/format.js'; | ||
| formatOutcome: (result) => `${result.breakingChanges.length} breaking change(s) found`, | ||
| formatOutput: (result) => result.hasBreakingChanges | ||
| ? `${result.breakingChanges.length} breaking changes found.` | ||
| : 'No breaking changes.', | ||
| formatOutput: (result) => { | ||
| if (!result.hasBreakingChanges) { | ||
| return 'No breaking changes detected.'; | ||
| } | ||
| const lines = [ | ||
| `**${String(result.breakingChanges.length)} breaking change(s) detected**`, | ||
| '', | ||
| ]; | ||
| for (const bc of result.breakingChanges) { | ||
| lines.push(`#### ${bc.element}`, '', `- **Change:** ${bc.natureOfChange}`, `- **Impact:** ${bc.consumerImpact}`, `- **Mitigation:** ${bc.suggestedMitigation}`, ''); | ||
| } | ||
| return lines.join('\n'); | ||
| }, | ||
| buildPrompt: (input, ctx) => { | ||
@@ -43,0 +53,0 @@ const { diff } = getDiffContextSnapshot(ctx); |
@@ -103,4 +103,5 @@ import { execFile } from 'node:child_process'; | ||
| const stats = computeDiffStatsFromFiles(parsedFiles); | ||
| const generatedAt = new Date().toISOString(); | ||
| storeDiff({ diff, parsedFiles, stats, generatedAt, mode }); | ||
| const generatedAtMs = Date.now(); | ||
| const generatedAt = new Date(generatedAtMs).toISOString(); | ||
| storeDiff({ diff, parsedFiles, stats, generatedAt, generatedAtMs, mode }); | ||
| const summary = `Diff cached: ${stats.files} files (+${stats.added}, -${stats.deleted})`; | ||
@@ -107,0 +108,0 @@ return createToolResponse({ |
| import { formatLanguageSegment } from '../lib/format.js'; | ||
| import { getDiffContextSnapshot } from '../lib/tools.js'; | ||
| import { buildStructuredToolExecutionOptions, registerStructuredToolTask, requireToolContract, } from '../lib/tools.js'; | ||
| import { GenerateReviewSummaryInputSchema } from '../schemas/inputs.js'; | ||
| import { GenerateReviewSummaryInputSchema, } from '../schemas/inputs.js'; | ||
| import { ReviewSummaryResultSchema } from '../schemas/outputs.js'; | ||
@@ -53,3 +53,17 @@ const ReviewSummaryModelSchema = ReviewSummaryResultSchema.omit({ | ||
| }, | ||
| formatOutput: (result) => `${result.summary}\nRecommendation: ${result.recommendation}`, | ||
| formatOutput: (result) => { | ||
| const lines = [ | ||
| `**Risk:** ${result.overallRisk}`, | ||
| `**Recommendation:** ${result.recommendation}`, | ||
| '', | ||
| result.summary, | ||
| ]; | ||
| if (result.keyChanges.length > 0) { | ||
| lines.push('', '### Key Changes', ''); | ||
| for (const kc of result.keyChanges) { | ||
| lines.push(`- ${kc}`); | ||
| } | ||
| } | ||
| return lines.join('\n'); | ||
| }, | ||
| buildPrompt: (input, ctx) => { | ||
@@ -56,0 +70,0 @@ const { diff, stats } = getDiffContextSnapshot(ctx); |
@@ -39,3 +39,18 @@ import { computeDiffStatsAndPathsFromFiles } from '../lib/diff.js'; | ||
| formatOutcome: (result) => `${result.testCases.length} test cases`, | ||
| formatOutput: (result) => `${result.summary}\n${result.testCases.length} test cases.`, | ||
| formatOutput: (result) => { | ||
| const lines = [ | ||
| result.summary, | ||
| '', | ||
| `### Test Cases (${String(result.testCases.length)})`, | ||
| '', | ||
| ]; | ||
| for (const tc of result.testCases) { | ||
| lines.push(`- **${tc.name}** \`${tc.type}\` (${tc.priority}) `); | ||
| lines.push(` ${tc.description}`); | ||
| } | ||
| if (result.coverageSummary) { | ||
| lines.push('', '### Coverage', '', result.coverageSummary); | ||
| } | ||
| return lines.join('\n'); | ||
| }, | ||
| transformResult: (input, result) => { | ||
@@ -42,0 +57,0 @@ const cappedTestCases = result.testCases.slice(0, input.maxTestCases ?? result.testCases.length); |
+14
-0
| import { registerAnalyzeComplexityTool } from './analyze-complexity.js'; | ||
| import { registerAnalyzePrImpactTool } from './analyze-pr-impact.js'; | ||
| import { registerAskTool } from './ask.js'; | ||
| import { registerDetectApiBreakingTool } from './detect-api-breaking.js'; | ||
@@ -7,2 +8,8 @@ import { registerGenerateDiffTool } from './generate-diff.js'; | ||
| import { registerGenerateTestPlanTool } from './generate-test-plan.js'; | ||
| import { registerIndexRepositoryTool } from './index-repository.js'; | ||
| import { registerLoadFileTool } from './load-file.js'; | ||
| import { registerQueryRepositoryTool } from './query-repository.js'; | ||
| import { registerRefactorCodeTool } from './refactor-code.js'; | ||
| import { registerVerifyLogicTool } from './verify-logic.js'; | ||
| import { registerWebSearchTool } from './web-search.js'; | ||
| const TOOL_REGISTRARS = [ | ||
@@ -15,2 +22,9 @@ registerGenerateDiffTool, | ||
| registerDetectApiBreakingTool, | ||
| registerLoadFileTool, | ||
| registerRefactorCodeTool, | ||
| registerAskTool, | ||
| registerVerifyLogicTool, | ||
| registerWebSearchTool, | ||
| registerIndexRepositoryTool, | ||
| registerQueryRepositoryTool, | ||
| ]; | ||
@@ -17,0 +31,0 @@ export function registerAllTools(server) { |
+12
-1
| { | ||
| "name": "@j0hanz/code-assistant", | ||
| "version": "0.9.1", | ||
| "version": "0.9.2", | ||
| "mcpName": "io.github.j0hanz/code-assistant", | ||
@@ -30,2 +30,13 @@ "description": "Gemini-powered MCP server for code analysis.", | ||
| "homepage": "https://github.com/j0hanz/code-assistant#readme", | ||
| "keywords": [ | ||
| "mcp", | ||
| "mcp-server", | ||
| "code-assistant", | ||
| "code-review", | ||
| "code-suggestions", | ||
| "code-explanations", | ||
| "code-refactorings", | ||
| "google-genai", | ||
| "typescript" | ||
| ], | ||
| "scripts": { | ||
@@ -32,0 +43,0 @@ "clean": "node scripts/tasks.mjs clean", |
+233
-209
@@ -1,31 +0,30 @@ | ||
| # Code Assistant MCP Server | ||
| # Code Assistant MCP | ||
| <!-- mcp-name: io.github.j0hanz/code-assistant --> | ||
| [](https://www.npmjs.com/package/@j0hanz/code-assistant) [](https://opensource.org/licenses/MIT) [](https://www.typescriptlang.org/) [](https://nodejs.org/) [](https://github.com/j0hanz/code-assistant/pkgs/container/code-assistant) | ||
| [](https://www.npmjs.com/package/@j0hanz/code-assistant) [](package.json) [](package.json) [](package.json) [](package.json) | ||
| Gemini-powered MCP server for code analysis with structured outputs for findings, risk assessment, and focused patch suggestions. | ||
| [](https://insiders.vscode.dev/redirect/mcp/install?name=Code+Assistant&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40j0hanz%2Fcode-assistant%40latest%22%5D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=Code+Assistant&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40j0hanz%2Fcode-assistant%40latest%22%5D%7D&quality=insiders) [](https://vs-open.link/mcp-install?%7B%22name%22%3A%22code-assistant%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40j0hanz%2Fcode-assistant%40latest%22%5D%7D) | ||
| [](https://insiders.vscode.dev/redirect/mcp/install?name=code-assistant&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40j0hanz%2Fcode-assistant%40latest%22%5D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=code-assistant&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40j0hanz%2Fcode-assistant%40latest%22%5D%7D&quality=insiders) | ||
| [](https://cursor.com/en/install-mcp?name=code-assistant&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBqMGhhbnovY29kZS1hc3Npc3RhbnRAbGF0ZXN0Il19) | ||
| [](https://lmstudio.ai/install-mcp?name=code-assistant&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBqMGhhbnovY29kZS1hc3Npc3RhbnRAbGF0ZXN0Il19) [](cursor://anysphere.cursor-deeplink/mcp/install?name=code-assistant&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBqMGhhbnovY29kZS1hc3Npc3RhbnRAbGF0ZXN0Il19) [](https://block.github.io/goose/extension?cmd=npx&arg=-y%20%40j0hanz%2Fcode-assistant%40latest&id=code-assistant&name=Code%20Assistant&description=Gemini-powered%20MCP%20server%20for%20code%20analysis) | ||
| Gemini-powered MCP server for code analysis with structured outputs for findings, risk assessment, and focused patch suggestions. | ||
| ## Overview | ||
| This server accepts unified diffs and returns structured JSON results — findings with severity, impact categories, merge risk, test plans, and verbatim search/replace fixes. It uses Gemini Thinking models (Flash for fast tools, Flash for deep analysis) and runs over **stdio transport**. | ||
| Code Assistant is a [Model Context Protocol](https://modelcontextprotocol.io/) server that connects AI assistants to the Google Gemini API for automated code review, refactoring suggestions, complexity analysis, breaking-change detection, and test plan generation. It operates over **stdio** transport and exposes **13 tools**, **7 resources**, and **2 prompts**. | ||
| ## Key Features | ||
| - **Impact Analysis** — Objective severity scoring, breaking change detection, and rollback complexity assessment. | ||
| - **Review Summary** — Concise PR digest with merge recommendation and change statistics. | ||
| - **Deep Code Inspection** — Flash model with high thinking level for context-aware analysis using full file contents. | ||
| - **Search & Replace Fixes** — Verbatim, copy-paste-ready code fixes tied to specific findings. | ||
| - **Test Plan Generation** — Systematic test case generation with priority ranking and pseudocode. | ||
| - **Async Task Support** — All tools support MCP task lifecycle with progress notifications. | ||
| - **Diff-based code review** — generate diffs from git, then analyze PR impact, produce review summaries, detect API breaking changes, and assess time/space complexity | ||
| - **File-based analysis** — load individual files for refactoring suggestions, question answering, and logic verification via Gemini's code execution sandbox | ||
| - **Repository indexing** — walk a local repository into a Gemini File Search Store for natural-language RAG queries | ||
| - **Web search** — Google Search with Grounding for up-to-date information | ||
| - **Structured outputs** — all Gemini-backed tools return validated JSON via Zod v4 schemas | ||
| - **Task lifecycle** — supports MCP Tasks API for async operation tracking with cancellation | ||
| - **Configurable thinking** — per-tool thinking levels (minimal/medium/high) balance speed vs depth | ||
| - **Multi-platform Docker** — published to GHCR for `linux/amd64` and `linux/arm64` | ||
| ## Requirements | ||
| - Node.js `>=24` | ||
| - One API key: `GEMINI_API_KEY` or `GOOGLE_API_KEY` | ||
| - MCP client that supports stdio servers and tool calls | ||
| - **Node.js** >= 24 | ||
| - A [**Google Gemini API key**](https://aistudio.google.com/apikey) (`GEMINI_API_KEY` or `GOOGLE_API_KEY`) | ||
@@ -41,3 +40,3 @@ ## Quick Start | ||
| "env": { | ||
| "GEMINI_API_KEY": "YOUR_API_KEY" | ||
| "GEMINI_API_KEY": "<your-api-key>" | ||
| } | ||
@@ -49,10 +48,13 @@ } | ||
| > [!TIP] | ||
| > Use the one-click install badges above for automatic setup in VS Code, Cursor, Goose, or LM Studio. | ||
| ## Client Configuration | ||
| <details> | ||
| <summary><b>VS Code / VS Code Insiders</b></summary> | ||
| <summary><b>Install in VS Code</b></summary> | ||
| [](https://insiders.vscode.dev/redirect/mcp/install?name=Code+Assistant&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40j0hanz%2Fcode-assistant%40latest%22%5D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=Code+Assistant&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40j0hanz%2Fcode-assistant%40latest%22%5D%7D&quality=insiders) | ||
| [](https://insiders.vscode.dev/redirect/mcp/install?name=code-assistant&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40j0hanz%2Fcode-assistant%40latest%22%5D%7D) | ||
| Add to `.vscode/mcp.json`: | ||
| Or add manually to `.vscode/mcp.json`: | ||
@@ -66,3 +68,3 @@ ```json | ||
| "env": { | ||
| "GEMINI_API_KEY": "YOUR_API_KEY" | ||
| "GEMINI_API_KEY": "<your-api-key>" | ||
| } | ||
@@ -80,11 +82,26 @@ } | ||
| For more info, see [VS Code MCP docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). | ||
| </details> | ||
| <details> | ||
| <summary><b>Cursor</b></summary> | ||
| <summary><b>Install in VS Code Insiders</b></summary> | ||
| [](https://cursor.com/en/install-mcp?name=code-assistant&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBqMGhhbnovY29kZS1hc3Npc3RhbnRAbGF0ZXN0Il19) | ||
| [](https://insiders.vscode.dev/redirect/mcp/install?name=code-assistant&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40j0hanz%2Fcode-assistant%40latest%22%5D%7D&quality=insiders) | ||
| Add to `~/.cursor/mcp.json`: | ||
| Or via CLI: | ||
| ```bash | ||
| code-insiders --add-mcp '{"name":"code-assistant","command":"npx","args":["-y","@j0hanz/code-assistant@latest"]}' | ||
| ``` | ||
| </details> | ||
| <details> | ||
| <summary><b>Install in Cursor</b></summary> | ||
| [](cursor://anysphere.cursor-deeplink/mcp/install?name=code-assistant&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBqMGhhbnovY29kZS1hc3Npc3RhbnRAbGF0ZXN0Il19) | ||
| Or add to `~/.cursor/mcp.json`: | ||
| ```json | ||
@@ -97,3 +114,3 @@ { | ||
| "env": { | ||
| "GEMINI_API_KEY": "YOUR_API_KEY" | ||
| "GEMINI_API_KEY": "<your-api-key>" | ||
| } | ||
@@ -105,15 +122,8 @@ } | ||
| </details> | ||
| For more info, see [Cursor MCP docs](https://docs.cursor.com/context/model-context-protocol). | ||
| <details> | ||
| <summary><b>Visual Studio</b></summary> | ||
| [](https://vs-open.link/mcp-install?%7B%22name%22%3A%22code-assistant%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40j0hanz%2Fcode-assistant%40latest%22%5D%7D) | ||
| For more info, see [Visual Studio MCP docs](https://learn.microsoft.com/en-us/visualstudio/ide/mcp-servers). | ||
| </details> | ||
| <details> | ||
| <summary><b>Claude Desktop</b></summary> | ||
| <summary><b>Install in Claude Desktop</b></summary> | ||
@@ -129,3 +139,3 @@ Add to `claude_desktop_config.json`: | ||
| "env": { | ||
| "GEMINI_API_KEY": "YOUR_API_KEY" | ||
| "GEMINI_API_KEY": "<your-api-key>" | ||
| } | ||
@@ -142,3 +152,3 @@ } | ||
| <details> | ||
| <summary><b>Claude Code</b></summary> | ||
| <summary><b>Install in Claude Code</b></summary> | ||
@@ -149,3 +159,3 @@ ```bash | ||
| For more info, see [Claude Code MCP docs](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/tutorials#set-up-model-context-protocol-mcp). | ||
| For more info, see [Claude Code MCP docs](https://docs.anthropic.com/en/docs/claude-code/mcp). | ||
@@ -155,5 +165,5 @@ </details> | ||
| <details> | ||
| <summary><b>Windsurf</b></summary> | ||
| <summary><b>Install in Windsurf</b></summary> | ||
| Add to MCP config: | ||
| Add to your Windsurf MCP config: | ||
@@ -167,3 +177,3 @@ ```json | ||
| "env": { | ||
| "GEMINI_API_KEY": "YOUR_API_KEY" | ||
| "GEMINI_API_KEY": "<your-api-key>" | ||
| } | ||
@@ -180,3 +190,3 @@ } | ||
| <details> | ||
| <summary><b>Amp</b></summary> | ||
| <summary><b>Install in Amp</b></summary> | ||
@@ -187,3 +197,3 @@ ```bash | ||
| For more info, see [Amp MCP docs](https://docs.amp.dev/mcp). | ||
| For more info, see [Amp MCP docs](https://docs.amp.dev). | ||
@@ -193,3 +203,3 @@ </details> | ||
| <details> | ||
| <summary><b>Cline</b></summary> | ||
| <summary><b>Install in Cline</b></summary> | ||
@@ -205,3 +215,3 @@ Add to `cline_mcp_settings.json`: | ||
| "env": { | ||
| "GEMINI_API_KEY": "YOUR_API_KEY" | ||
| "GEMINI_API_KEY": "<your-api-key>" | ||
| } | ||
@@ -213,60 +223,9 @@ } | ||
| For more info, see [Cline MCP docs](https://docs.cline.bot/mcp-servers/configuring-mcp-servers). | ||
| </details> | ||
| <details> | ||
| <summary><b>Zed</b></summary> | ||
| <summary><b>Install via Docker</b></summary> | ||
| Add to Zed `settings.json`: | ||
| ```json | ||
| { | ||
| "context_servers": { | ||
| "code-assistant": { | ||
| "command": { | ||
| "path": "npx", | ||
| "args": ["-y", "@j0hanz/code-assistant@latest"], | ||
| "env": { | ||
| "GEMINI_API_KEY": "YOUR_API_KEY" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| For more info, see [Zed MCP docs](https://zed.dev/docs/assistant/model-context-protocol). | ||
| </details> | ||
| <details> | ||
| <summary><b>Augment</b></summary> | ||
| Add to `settings.json`: | ||
| ```json | ||
| { | ||
| "augment.advanced": { | ||
| "mcpServers": [ | ||
| { | ||
| "name": "code-assistant", | ||
| "command": "npx", | ||
| "args": ["-y", "@j0hanz/code-assistant@latest"], | ||
| "env": { | ||
| "GEMINI_API_KEY": "YOUR_API_KEY" | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| ``` | ||
| </details> | ||
| <details> | ||
| <summary><b>Docker</b></summary> | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
@@ -280,5 +239,8 @@ "code-assistant": { | ||
| "-e", | ||
| "GEMINI_API_KEY=YOUR_API_KEY", | ||
| "GEMINI_API_KEY", | ||
| "ghcr.io/j0hanz/code-assistant:latest" | ||
| ] | ||
| ], | ||
| "env": { | ||
| "GEMINI_API_KEY": "<your-api-key>" | ||
| } | ||
| } | ||
@@ -289,6 +251,6 @@ } | ||
| Or build locally: | ||
| Or run directly: | ||
| ```bash | ||
| docker build -t code-assistant . | ||
| docker run -i -e GEMINI_API_KEY="<your-api-key>" ghcr.io/j0hanz/code-assistant:latest | ||
| ``` | ||
@@ -298,159 +260,221 @@ | ||
| ## Tools | ||
| ## MCP Surface | ||
| > [!IMPORTANT] | ||
| > Call `generate_diff` first (`mode: "unstaged"` or `"staged"`). All review tools read the cached server-side diff (`diff://current`) and do not accept a direct `diff` parameter. | ||
| ### Tools | ||
| ### `generate_diff` | ||
| #### `generate_diff` | ||
| Generate and cache the current branch diff for downstream review tools. | ||
| Generate a diff of current changes and cache it server-side. Must be called before diff-based analysis tools. | ||
| | Parameter | Type | Required | Description | | ||
| | --------- | -------- | -------- | -------------------------------------------------- | | ||
| | `mode` | `string` | Yes | `unstaged` (working tree) or `staged` (git index). | | ||
| | Name | Type | Required | Description | | ||
| | ------ | -------- | -------- | ---------------------------------------- | | ||
| | `mode` | `string` | yes | `'unstaged'` or `'staged'` diff capture. | | ||
| **Returns:** `diffRef`, `stats` (files, added, deleted), `generatedAt`, `mode`, `message`. | ||
| #### `analyze_pr_impact` | ||
| ### `analyze_pr_impact` | ||
| Assess severity, categories, breaking changes, and rollback complexity. | ||
| Assess the impact and risk of cached pull request changes using the Flash model. | ||
| | Name | Type | Required | Description | | ||
| | ------------ | -------- | -------- | ----------------------------------- | | ||
| | `repository` | `string` | yes | Repository identifier (owner/repo). | | ||
| | `language` | `string` | no | Primary language hint. | | ||
| | Parameter | Type | Required | Description | | ||
| | ------------ | -------- | -------- | ---------------------------------------- | | ||
| | `repository` | `string` | Yes | Repository identifier (e.g. `org/repo`). | | ||
| | `language` | `string` | No | Primary language hint. | | ||
| #### `generate_review_summary` | ||
| **Returns:** `severity` (low/medium/high/critical), `categories[]`, `breakingChanges[]`, `affectedAreas[]`, `rollbackComplexity`, `summary`. | ||
| Produce PR summary, risk rating, and merge recommendation. | ||
| ### `generate_review_summary` | ||
| | Name | Type | Required | Description | | ||
| | ------------ | -------- | -------- | ----------------------------------- | | ||
| | `repository` | `string` | yes | Repository identifier (owner/repo). | | ||
| | `language` | `string` | no | Primary language hint. | | ||
| Summarize a pull request diff and assess high-level risk using the Flash model. | ||
| #### `generate_test_plan` | ||
| | Parameter | Type | Required | Description | | ||
| | ------------ | -------- | -------- | ---------------------------------------- | | ||
| | `repository` | `string` | Yes | Repository identifier (e.g. `org/repo`). | | ||
| | `language` | `string` | No | Primary language hint. | | ||
| Generate prioritized test cases and coverage guidance. | ||
| **Returns:** `summary`, `overallRisk` (low/medium/high), `keyChanges[]`, `recommendation`, `stats` (filesChanged, linesAdded, linesRemoved). | ||
| | Name | Type | Required | Description | | ||
| | --------------- | -------- | -------- | ----------------------------------- | | ||
| | `repository` | `string` | yes | Repository identifier (owner/repo). | | ||
| | `language` | `string` | no | Primary language hint. | | ||
| | `testFramework` | `string` | no | Framework hint (jest, pytest, etc). | | ||
| | `maxTestCases` | `number` | no | Max test cases (1-30). | | ||
| ### `generate_test_plan` | ||
| #### `analyze_time_space_complexity` | ||
| Create a test plan covering the changes in the diff using the Flash model with thinking (8K token budget). | ||
| Analyze Big-O complexity and detect degradations in changed code. | ||
| | Parameter | Type | Required | Description | | ||
| | --------------- | -------- | -------- | ------------------------------------------- | | ||
| | `repository` | `string` | Yes | Repository identifier (e.g. `org/repo`). | | ||
| | `language` | `string` | No | Primary language hint. | | ||
| | `testFramework` | `string` | No | Test framework (e.g. jest, vitest, pytest). | | ||
| | `maxTestCases` | `number` | No | Maximum test cases to return (1-30). | | ||
| | Name | Type | Required | Description | | ||
| | ---------- | -------- | -------- | ---------------------- | | ||
| | `language` | `string` | no | Primary language hint. | | ||
| **Returns:** `summary`, `testCases[]` (name, type, file, description, pseudoCode, priority), `coverageSummary`. | ||
| #### `detect_api_breaking_changes` | ||
| ## Resources | ||
| Detect breaking API/interface changes in a diff. | ||
| | URI | Type | Description | | ||
| | ------------------------- | --------------- | -------------------------- | | ||
| | `internal://instructions` | `text/markdown` | Server usage instructions. | | ||
| | Name | Type | Required | Description | | ||
| | ---------- | -------- | -------- | ---------------------- | | ||
| | `language` | `string` | no | Primary language hint. | | ||
| ## Prompts | ||
| #### `load_file` | ||
| | Name | Arguments | Description | | ||
| | -------------- | ------------------- | --------------------------------------------------- | | ||
| | `get-help` | — | Return the server usage instructions. | | ||
| | `review-guide` | `tool`, `focusArea` | Guided workflow for a specific tool and focus area. | | ||
| Read a single file from disk and cache it server-side. Must be called before file analysis tools. | ||
| ## Configuration | ||
| | Name | Type | Required | Description | | ||
| | ---------- | -------- | -------- | ---------------------------------- | | ||
| | `filePath` | `string` | yes | Absolute path to the file to load. | | ||
| ### CLI Arguments | ||
| #### `refactor_code` | ||
| | Option | Description | Env Var Equivalent | | ||
| | ------------------ | ---------------------- | ------------------ | | ||
| | `--model`, `-m` | Override default model | `GEMINI_MODEL` | | ||
| | `--max-diff-chars` | Override max diff size | `MAX_DIFF_CHARS` | | ||
| Analyze cached file for naming, complexity, duplication, and grouping improvements. | ||
| ### Environment Variables | ||
| | Name | Type | Required | Description | | ||
| | ---------- | -------- | -------- | ---------------------- | | ||
| | `language` | `string` | no | Primary language hint. | | ||
| | Variable | Description | Default | Required | | ||
| | ------------------------------- | ---------------------------------------------------- | ------------ | -------- | | ||
| | `GEMINI_API_KEY` | Gemini API key | — | Yes | | ||
| | `GOOGLE_API_KEY` | Alternative API key (if `GEMINI_API_KEY` not set) | — | No | | ||
| | `GEMINI_MODEL` | Override default model selection | — | No | | ||
| | `GEMINI_HARM_BLOCK_THRESHOLD` | Safety threshold (BLOCK_NONE, BLOCK_ONLY_HIGH, etc.) | `BLOCK_NONE` | No | | ||
| | `MAX_DIFF_CHARS` | Max chars for diff input | `120000` | No | | ||
| | `MAX_CONCURRENT_CALLS` | Max concurrent Gemini requests | `10` | No | | ||
| | `MAX_CONCURRENT_BATCH_CALLS` | Max concurrent inline batch requests | `2` | No | | ||
| | `MAX_CONCURRENT_CALLS_WAIT_MS` | Max wait time for a free Gemini slot | `2000` | No | | ||
| | `MAX_SCHEMA_RETRY_ERROR_CHARS` | Max chars from schema error injected into retry text | `1500` | No | | ||
| | `GEMINI_BATCH_MODE` | Request mode for Gemini calls (`off`, `inline`) | `off` | No | | ||
| | `GEMINI_BATCH_POLL_INTERVAL_MS` | Poll interval for batch job status | `2000` | No | | ||
| | `GEMINI_BATCH_TIMEOUT_MS` | Max wait for batch completion | `120000` | No | | ||
| #### `ask_about_code` | ||
| ### Models | ||
| Answer natural-language questions about a cached file. | ||
| | Tool | Model | Thinking Level | | ||
| | ------------------------- | ------------------------ | -------------- | | ||
| | `analyze_pr_impact` | `gemini-3-flash-preview` | `minimal` | | ||
| | `generate_review_summary` | `gemini-3-flash-preview` | `minimal` | | ||
| | `generate_test_plan` | `gemini-3-flash-preview` | `medium` | | ||
| | Name | Type | Required | Description | | ||
| | ---------- | -------- | -------- | ------------------------------- | | ||
| | `question` | `string` | yes | Question about the loaded file. | | ||
| | `language` | `string` | no | Primary language hint. | | ||
| ## Workflows | ||
| #### `verify_logic` | ||
| ### Quick PR Triage | ||
| Verify algorithms and logic in cached file using Gemini code execution sandbox. | ||
| 1. Call `analyze_pr_impact` to get severity and category breakdown. | ||
| 2. If low/medium — call `generate_review_summary` for a quick digest. | ||
| 3. If high/critical — proceed to deep inspection. | ||
| | Name | Type | Required | Description | | ||
| | ---------- | -------- | -------- | ------------------------------- | | ||
| | `question` | `string` | yes | Question about the loaded file. | | ||
| | `language` | `string` | no | Primary language hint. | | ||
| ### Testing | ||
| #### `web_search` | ||
| 1. Call `generate_test_plan` to create a verification strategy. | ||
| 2. Implement tests based on returned test cases and coverage summary. | ||
| Perform a Google Search with Grounding to get up-to-date information. | ||
| | Name | Type | Required | Description | | ||
| | ------- | -------- | -------- | ------------- | | ||
| | `query` | `string` | yes | Search query. | | ||
| #### `index_repository` | ||
| Walk a local repository, upload source files to a Gemini File Search Store for RAG queries. | ||
| | Name | Type | Required | Description | | ||
| | ------------- | -------- | -------- | ---------------------------------------------- | | ||
| | `rootPath` | `string` | yes | Absolute path to the repository root. | | ||
| | `displayName` | `string` | no | Display name for the store. Default: dir name. | | ||
| #### `query_repository` | ||
| Query the indexed repository search store using natural language. | ||
| | Name | Type | Required | Description | | ||
| | ---------- | -------- | -------- | ----------------------------------------- | | ||
| | `query` | `string` | yes | Natural-language question about the repo. | | ||
| | `language` | `string` | no | Primary language hint. | | ||
| ### Resources | ||
| | URI Pattern | MIME Type | Description | | ||
| | --------------------------------- | ------------- | ------------------------------------------ | | ||
| | `internal://instructions` | text/markdown | Complete server usage instructions. | | ||
| | `internal://tool-catalog` | text/markdown | Tool reference: models, params, data flow. | | ||
| | `internal://workflows` | text/markdown | Recommended workflows and tool sequences. | | ||
| | `internal://server-config` | text/markdown | Runtime configuration and limits. | | ||
| | `internal://tool-info/{toolName}` | text/markdown | Per-tool reference (supports completions). | | ||
| | `internal://diff/current` | text/x-patch | Most recently generated diff (cached). | | ||
| | `internal://file/current` | text/plain | Most recently loaded file (cached). | | ||
| ### Prompts | ||
| | Prompt | Arguments | Description | | ||
| | -------------- | ------------------- | ----------------------------------- | | ||
| | `get-help` | none | Server instructions. | | ||
| | `review-guide` | `tool`, `focusArea` | Workflow guide for tool/focus area. | | ||
| ## Configuration | ||
| ### Environment Variables | ||
| | Variable | Default | Required | Description | | ||
| | ------------------------------- | ------------------------ | -------- | -------------------------------------------------------------------------------------------- | | ||
| | `GEMINI_API_KEY` | N/A | yes | Google Gemini API key. | | ||
| | `GOOGLE_API_KEY` | N/A | yes\* | Alternative API key variable (\*either one required). | | ||
| | `GEMINI_MODEL` | `gemini-3-flash-preview` | no | Model override for all tools. | | ||
| | `MAX_DIFF_CHARS` | `120000` | no | Max diff size in characters. | | ||
| | `GEMINI_HARM_BLOCK_THRESHOLD` | `BLOCK_NONE` | no | Safety threshold (BLOCK_NONE, BLOCK_ONLY_HIGH, BLOCK_MEDIUM_AND_ABOVE, BLOCK_LOW_AND_ABOVE). | | ||
| | `GEMINI_INCLUDE_THOUGHTS` | `false` | no | Include model thinking in responses. | | ||
| | `GEMINI_BATCH_MODE` | `off` | no | Batch mode: `off` or `inline`. | | ||
| | `GEMINI_BATCH_POLL_INTERVAL_MS` | N/A | no | Poll cadence for batch status checks. | | ||
| | `GEMINI_BATCH_TIMEOUT_MS` | N/A | no | Max wait for batch completion. | | ||
| | `MAX_CONCURRENT_CALLS` | `10` | no | Max concurrent Gemini calls. | | ||
| | `MAX_CONCURRENT_BATCH_CALLS` | `2` | no | Max concurrent batch calls. | | ||
| | `MAX_CONCURRENT_CALLS_WAIT_MS` | `2000` | no | Wait timeout for concurrency queue (ms). | | ||
| | `GEMINI_DIFF_CACHE_ENABLED` | `false` | no | Enable Gemini-side diff caching. | | ||
| | `GEMINI_DIFF_CACHE_TTL_S` | N/A | no | Cache TTL in seconds. | | ||
| ### CLI Arguments | ||
| | Flag | Short | Maps to env var | Description | | ||
| | ------------------ | ----- | ---------------- | ----------------------- | | ||
| | `--model` | `-m` | `GEMINI_MODEL` | Override default model. | | ||
| | `--max-diff-chars` | | `MAX_DIFF_CHARS` | Override diff budget. | | ||
| ## Security | ||
| | Control | Status | Evidence | | ||
| | --------------------------- | --------- | ---------------------------------------------------------- | | ||
| | Non-root Docker user | confirmed | `Dockerfile` — `adduser -D mcp`, `USER mcp` | | ||
| | Read-only volume mount | confirmed | `docker-compose.yml` — `:ro` flag | | ||
| | Diff budget enforcement | confirmed | `src/lib/diff.ts` — `MAX_DIFF_CHARS` | | ||
| | Noisy file exclusion | confirmed | `src/lib/diff.ts` — `NOISY_EXCLUDE_PATHSPECS` | | ||
| | Configurable safety filters | confirmed | `src/lib/gemini/config.ts` — `GEMINI_HARM_BLOCK_THRESHOLD` | | ||
| | npm publish provenance | confirmed | `.github/workflows/release.yml` — `--provenance` flag | | ||
| ## Development | ||
| ```bash | ||
| npm ci # Install dependencies | ||
| npm run dev # TypeScript watch mode | ||
| npm run dev:run # Run built server with .env and --watch | ||
| ``` | ||
| | Script | Command | Purpose | | ||
| | ------------ | -------------------- | ------------------------------ | | ||
| | `build` | `npm run build` | Compile TypeScript to `dist/`. | | ||
| | `dev` | `npm run dev` | Watch mode (tsc --watch). | | ||
| | `start` | `npm run start` | Run built server. | | ||
| | `type-check` | `npm run type-check` | Type-check src and tests. | | ||
| | `lint` | `npm run lint` | ESLint. | | ||
| | `format` | `npm run format` | Prettier. | | ||
| | `test` | `npm run test` | Run tests (node:test). | | ||
| | `knip` | `npm run knip` | Dead-code detection. | | ||
| | `inspector` | `npm run inspector` | MCP Inspector. | | ||
| | Script | Command | Purpose | | ||
| | -------------------- | ----------------------------------- | ------------------------------ | | ||
| | `npm run build` | `node scripts/tasks.mjs build` | Clean, compile, validate, copy | | ||
| | `npm test` | `node scripts/tasks.mjs test` | Build + run all tests | | ||
| | `npm run test:fast` | `node --test --import tsx/esm ...` | Run tests without build | | ||
| | `npm run lint` | `eslint .` | Lint all files | | ||
| | `npm run lint:fix` | `eslint . --fix` | Lint and auto-fix | | ||
| | `npm run format` | `prettier --write .` | Format all files | | ||
| | `npm run type-check` | `node scripts/tasks.mjs type-check` | Type-check without emitting | | ||
| | `npm run inspector` | Build + launch MCP Inspector | Debug with MCP Inspector | | ||
| ### Debugging with MCP Inspector | ||
| ```bash | ||
| npx @modelcontextprotocol/inspector node dist/index.js | ||
| npx @modelcontextprotocol/inspector npx -y @j0hanz/code-assistant@latest | ||
| ``` | ||
| ## Build & Release | ||
| ## Build and Release | ||
| Releases are triggered via GitHub Actions `workflow_dispatch` with version bump selection (patch/minor/major/custom). | ||
| - **Release workflow**: manual dispatch via GitHub Actions (`workflow_dispatch`) with version bump type (patch/minor/major) or custom version. | ||
| - **npm**: published to `@j0hanz/code-assistant` with OIDC trusted publishing and provenance attestation. | ||
| - **Docker**: multi-platform image (`linux/amd64`, `linux/arm64`) pushed to `ghcr.io/j0hanz/code-assistant`. | ||
| - **MCP Registry**: published to [registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io) as `io.github.j0hanz/code-assistant`. | ||
| The pipeline runs lint, type-check, test, and build, then publishes to three targets in parallel: | ||
| ### Docker Build | ||
| - **npm** — `@j0hanz/code-assistant` with OIDC trusted publishing and provenance | ||
| - **Docker** — `ghcr.io/j0hanz/code-assistant` (linux/amd64, linux/arm64) | ||
| - **MCP Registry** — `io.github.j0hanz/code-assistant` | ||
| ```bash | ||
| docker build -t code-assistant . | ||
| ``` | ||
| ## Troubleshooting | ||
| | Issue | Solution | | ||
| | ------------------------------------------ | ------------------------------------------------------------------------------------ | | ||
| | `Missing GEMINI_API_KEY or GOOGLE_API_KEY` | Set one of the API key env vars in your MCP client config. | | ||
| | `E_INPUT_TOO_LARGE` | Diff exceeds budget. Split into smaller diffs. | | ||
| | `Gemini request timed out` | Deep analysis tasks may take 60-120s. Increase your client timeout. | | ||
| | `Too many concurrent Gemini calls` | Reduce parallel tool calls or increase `MAX_CONCURRENT_CALLS`. | | ||
| | No tool output visible | Ensure your MCP client is not swallowing `stderr` — the server uses stdio transport. | | ||
| - **Missing API key**: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` in your environment or client config. | ||
| - **Diff too large**: increase `MAX_DIFF_CHARS` or use `--max-diff-chars` flag. Lock files and build artifacts are excluded automatically. | ||
| - **Inspector not connecting**: ensure the server builds cleanly with `npm run build` before running the inspector. | ||
| - **`E_NO_DIFF` error**: call `generate_diff` before any diff-based analysis tool. | ||
| - **`E_NO_FILE` error**: call `load_file` before `refactor_code`, `ask_about_code`, or `verify_logic`. | ||
| ## License | ||
| ## Contributing and License | ||
| MIT | ||
| - License: [MIT](https://opensource.org/licenses/MIT) | ||
| - Repository: [github.com/j0hanz/code-assistant](https://github.com/j0hanz/code-assistant) |
| import { EventEmitter } from 'node:events'; | ||
| import { GoogleGenAI } from '@google/genai'; | ||
| export type JsonObject = Record<string, unknown>; | ||
| export type GeminiLogHandler = (level: string, data: unknown) => Promise<void>; | ||
| export type GeminiThinkingLevel = 'minimal' | 'low' | 'medium' | 'high'; | ||
| export interface GeminiRequestExecutionOptions { | ||
| maxRetries?: number; | ||
| timeoutMs?: number; | ||
| temperature?: number; | ||
| maxOutputTokens?: number; | ||
| thinkingLevel?: GeminiThinkingLevel; | ||
| includeThoughts?: boolean; | ||
| signal?: AbortSignal; | ||
| onLog?: GeminiLogHandler; | ||
| responseKeyOrdering?: readonly string[]; | ||
| batchMode?: 'off' | 'inline'; | ||
| } | ||
| export interface GeminiStructuredRequestOptions extends GeminiRequestExecutionOptions { | ||
| model?: string; | ||
| } | ||
| export interface GeminiStructuredRequest extends GeminiStructuredRequestOptions { | ||
| systemInstruction?: string; | ||
| prompt: string; | ||
| responseSchema: Readonly<JsonObject>; | ||
| } | ||
| type JsonRecord = Record<string, unknown>; | ||
| export declare function stripJsonSchemaConstraints(schema: JsonRecord): JsonRecord; | ||
| export declare const RETRYABLE_NUMERIC_CODES: Set<number>; | ||
| export declare const RETRYABLE_TRANSIENT_CODES: Set<string>; | ||
| export declare function toUpperStringCode(candidate: unknown): string | undefined; | ||
| export declare function getNumericErrorCode(error: unknown): number | undefined; | ||
| export declare function shouldRetry(error: unknown): boolean; | ||
| export declare function getRetryDelayMs(attempt: number): number; | ||
| export declare function canRetryAttempt(attempt: number, maxRetries: number, error: unknown): boolean; | ||
| export declare const geminiEvents: EventEmitter<[never]>; | ||
| export declare function getCurrentRequestId(): string; | ||
| export declare function setClientForTesting(client: GoogleGenAI): void; | ||
| export declare function getGeminiQueueSnapshot(): { | ||
| activeWaiters: number; | ||
| activeCalls: number; | ||
| activeBatchWaiters: number; | ||
| activeBatchCalls: number; | ||
| }; | ||
| export declare function generateStructuredJson(request: GeminiStructuredRequest): Promise<unknown>; | ||
| export {}; |
| import { AsyncLocalStorage } from 'node:async_hooks'; | ||
| import { randomInt } from 'node:crypto'; | ||
| import { randomUUID } from 'node:crypto'; | ||
| import { EventEmitter } from 'node:events'; | ||
| import { performance } from 'node:perf_hooks'; | ||
| import { setTimeout as sleep } from 'node:timers/promises'; | ||
| import { debuglog } from 'node:util'; | ||
| import { FinishReason, GoogleGenAI, HarmBlockThreshold, HarmCategory, ThinkingLevel, } from '@google/genai'; | ||
| import { ConcurrencyLimiter } from './concurrency.js'; | ||
| import { createCachedEnvInt } from './config.js'; | ||
| import { getErrorMessage, RETRYABLE_UPSTREAM_ERROR_PATTERN, toRecord, } from './errors.js'; | ||
| import { formatUsNumber } from './format.js'; | ||
| const CONSTRAINT_KEY_VALUES = [ | ||
| 'minLength', | ||
| 'maxLength', | ||
| 'minimum', | ||
| 'maximum', | ||
| 'exclusiveMinimum', | ||
| 'exclusiveMaximum', | ||
| 'minItems', | ||
| 'maxItems', | ||
| 'multipleOf', | ||
| 'pattern', | ||
| 'format', | ||
| ]; | ||
| const CONSTRAINT_KEYS = new Set(CONSTRAINT_KEY_VALUES); | ||
| const INTEGER_JSON_TYPE = 'integer'; | ||
| const NUMBER_JSON_TYPE = 'number'; | ||
| function isJsonRecord(value) { | ||
| return typeof value === 'object' && value !== null && !Array.isArray(value); | ||
| } | ||
| function stripConstraintValue(value) { | ||
| if (Array.isArray(value)) { | ||
| const stripped = new Array(value.length); | ||
| for (let index = 0; index < value.length; index += 1) { | ||
| stripped[index] = stripConstraintValue(value[index]); | ||
| } | ||
| return stripped; | ||
| } | ||
| if (isJsonRecord(value)) { | ||
| return stripJsonSchemaConstraints(value); | ||
| } | ||
| return value; | ||
| } | ||
| export function stripJsonSchemaConstraints(schema) { | ||
| const result = {}; | ||
| for (const [key, value] of Object.entries(schema)) { | ||
| if (CONSTRAINT_KEYS.has(key)) { | ||
| continue; | ||
| } | ||
| if (key === 'type' && value === INTEGER_JSON_TYPE) { | ||
| result[key] = NUMBER_JSON_TYPE; | ||
| continue; | ||
| } | ||
| result[key] = stripConstraintValue(value); | ||
| } | ||
| return result; | ||
| } | ||
| const DIGITS_ONLY_PATTERN = /^\d+$/; | ||
| const RETRY_DELAY_BASE_MS = 300; | ||
| const RETRY_DELAY_MAX_MS = 5_000; | ||
| const RETRY_JITTER_RATIO = 0.2; | ||
| export const RETRYABLE_NUMERIC_CODES = new Set([429, 500, 502, 503, 504]); | ||
| export const RETRYABLE_TRANSIENT_CODES = new Set([ | ||
| 'RESOURCE_EXHAUSTED', | ||
| 'UNAVAILABLE', | ||
| 'DEADLINE_EXCEEDED', | ||
| 'INTERNAL', | ||
| 'ABORTED', | ||
| ]); | ||
| function getNestedError(error) { | ||
| const record = toRecord(error); | ||
| if (!record) { | ||
| return undefined; | ||
| } | ||
| const nested = record.error; | ||
| const nestedRecord = toRecord(nested); | ||
| if (!nestedRecord) { | ||
| return record; | ||
| } | ||
| return nestedRecord; | ||
| } | ||
| function toNumericCode(candidate) { | ||
| if (typeof candidate === 'number' && Number.isFinite(candidate)) { | ||
| return candidate; | ||
| } | ||
| if (typeof candidate === 'string' && DIGITS_ONLY_PATTERN.test(candidate)) { | ||
| return Number.parseInt(candidate, 10); | ||
| } | ||
| return undefined; | ||
| } | ||
| export function toUpperStringCode(candidate) { | ||
| if (typeof candidate !== 'string') { | ||
| return undefined; | ||
| } | ||
| const normalized = candidate.trim().toUpperCase(); | ||
| return normalized.length > 0 ? normalized : undefined; | ||
| } | ||
| function findFirstNumericCode(record, keys) { | ||
| for (const key of keys) { | ||
| const numericCode = toNumericCode(record[key]); | ||
| if (numericCode !== undefined) { | ||
| return numericCode; | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
| function findFirstStringCode(record, keys) { | ||
| for (const key of keys) { | ||
| const stringCode = toUpperStringCode(record[key]); | ||
| if (stringCode !== undefined) { | ||
| return stringCode; | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
| const NUMERIC_ERROR_KEYS = ['status', 'statusCode', 'code']; | ||
| export function getNumericErrorCode(error) { | ||
| const record = getNestedError(error); | ||
| if (!record) { | ||
| return undefined; | ||
| } | ||
| return findFirstNumericCode(record, NUMERIC_ERROR_KEYS); | ||
| } | ||
| const TRANSIENT_ERROR_KEYS = ['code', 'status', 'statusText']; | ||
| function getTransientErrorCode(error) { | ||
| const record = getNestedError(error); | ||
| if (!record) { | ||
| return undefined; | ||
| } | ||
| return findFirstStringCode(record, TRANSIENT_ERROR_KEYS); | ||
| } | ||
| export function shouldRetry(error) { | ||
| const numericCode = getNumericErrorCode(error); | ||
| if (numericCode !== undefined && RETRYABLE_NUMERIC_CODES.has(numericCode)) { | ||
| return true; | ||
| } | ||
| const transientCode = getTransientErrorCode(error); | ||
| if (transientCode !== undefined && | ||
| RETRYABLE_TRANSIENT_CODES.has(transientCode)) { | ||
| return true; | ||
| } | ||
| const message = getErrorMessage(error); | ||
| return RETRYABLE_UPSTREAM_ERROR_PATTERN.test(message); | ||
| } | ||
| export function getRetryDelayMs(attempt) { | ||
| const exponentialDelay = RETRY_DELAY_BASE_MS * 2 ** attempt; | ||
| const boundedDelay = Math.min(RETRY_DELAY_MAX_MS, exponentialDelay); | ||
| const jitterWindow = Math.max(1, Math.floor(boundedDelay * RETRY_JITTER_RATIO)); | ||
| const jitter = randomInt(0, jitterWindow); | ||
| return Math.min(RETRY_DELAY_MAX_MS, boundedDelay + jitter); | ||
| } | ||
| export function canRetryAttempt(attempt, maxRetries, error) { | ||
| return attempt < maxRetries && shouldRetry(error); | ||
| } | ||
| // Lazy-cached: first call happens after parseCommandLineArgs() sets GEMINI_MODEL. | ||
| let _defaultModel; | ||
| const DEFAULT_MODEL = 'gemini-3-flash-preview'; | ||
| const MODEL_FALLBACK_TARGET = 'gemini-2.5-flash'; | ||
| const GEMINI_MODEL_ENV_VAR = 'GEMINI_MODEL'; | ||
| const GEMINI_HARM_BLOCK_THRESHOLD_ENV_VAR = 'GEMINI_HARM_BLOCK_THRESHOLD'; | ||
| const GEMINI_INCLUDE_THOUGHTS_ENV_VAR = 'GEMINI_INCLUDE_THOUGHTS'; | ||
| const GEMINI_BATCH_MODE_ENV_VAR = 'GEMINI_BATCH_MODE'; | ||
| const GEMINI_API_KEY_ENV_VAR = 'GEMINI_API_KEY'; | ||
| const GOOGLE_API_KEY_ENV_VAR = 'GOOGLE_API_KEY'; | ||
| function getDefaultModel() { | ||
| _defaultModel ??= process.env[GEMINI_MODEL_ENV_VAR] ?? DEFAULT_MODEL; | ||
| return _defaultModel; | ||
| } | ||
| const DEFAULT_MAX_RETRIES = 3; | ||
| const DEFAULT_TIMEOUT_MS = 90_000; | ||
| const DEFAULT_MAX_OUTPUT_TOKENS = 16_384; | ||
| const DEFAULT_SAFETY_THRESHOLD = HarmBlockThreshold.BLOCK_NONE; | ||
| const DEFAULT_INCLUDE_THOUGHTS = false; | ||
| const DEFAULT_BATCH_MODE = 'off'; | ||
| const UNKNOWN_REQUEST_CONTEXT_VALUE = 'unknown'; | ||
| const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); | ||
| const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']); | ||
| const SLEEP_UNREF_OPTIONS = { ref: false }; | ||
| const JSON_CODE_BLOCK_PATTERN = /```(?:json)?\n?([\s\S]*?)(?=\n?```)/u; | ||
| const NEVER_ABORT_SIGNAL = new AbortController().signal; | ||
| const CANCELLED_REQUEST_MESSAGE = 'Gemini request was cancelled.'; | ||
| const maxConcurrentCallsConfig = createCachedEnvInt('MAX_CONCURRENT_CALLS', 10); | ||
| const maxConcurrentBatchCallsConfig = createCachedEnvInt('MAX_CONCURRENT_BATCH_CALLS', 2); | ||
| const concurrencyWaitMsConfig = createCachedEnvInt('MAX_CONCURRENT_CALLS_WAIT_MS', 2_000); | ||
| const batchPollIntervalMsConfig = createCachedEnvInt('GEMINI_BATCH_POLL_INTERVAL_MS', 2_000); | ||
| const batchTimeoutMsConfig = createCachedEnvInt('GEMINI_BATCH_TIMEOUT_MS', 120_000); | ||
| const callLimiter = new ConcurrencyLimiter(() => maxConcurrentCallsConfig.get(), () => concurrencyWaitMsConfig.get(), (limit, ms) => formatConcurrencyLimitErrorMessage(limit, ms), () => CANCELLED_REQUEST_MESSAGE); | ||
| const batchCallLimiter = new ConcurrencyLimiter(() => maxConcurrentBatchCallsConfig.get(), () => concurrencyWaitMsConfig.get(), (limit, ms) => formatConcurrencyLimitErrorMessage(limit, ms), () => CANCELLED_REQUEST_MESSAGE); | ||
| const SAFETY_CATEGORIES = [ | ||
| HarmCategory.HARM_CATEGORY_HATE_SPEECH, | ||
| HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, | ||
| HarmCategory.HARM_CATEGORY_HARASSMENT, | ||
| HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, | ||
| ]; | ||
| const SAFETY_THRESHOLD_BY_NAME = { | ||
| BLOCK_NONE: HarmBlockThreshold.BLOCK_NONE, | ||
| BLOCK_ONLY_HIGH: HarmBlockThreshold.BLOCK_ONLY_HIGH, | ||
| BLOCK_MEDIUM_AND_ABOVE: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, | ||
| BLOCK_LOW_AND_ABOVE: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE, | ||
| }; | ||
| let cachedSafetyThresholdEnv; | ||
| let cachedSafetyThreshold = DEFAULT_SAFETY_THRESHOLD; | ||
| let cachedIncludeThoughtsEnv; | ||
| let cachedIncludeThoughts = DEFAULT_INCLUDE_THOUGHTS; | ||
| const safetySettingsCache = new Map(); | ||
| function getSafetyThreshold() { | ||
| const threshold = process.env[GEMINI_HARM_BLOCK_THRESHOLD_ENV_VAR]; | ||
| if (threshold === cachedSafetyThresholdEnv) { | ||
| return cachedSafetyThreshold; | ||
| } | ||
| cachedSafetyThresholdEnv = threshold; | ||
| if (!threshold) { | ||
| cachedSafetyThreshold = DEFAULT_SAFETY_THRESHOLD; | ||
| return cachedSafetyThreshold; | ||
| } | ||
| const parsedThreshold = parseSafetyThreshold(threshold); | ||
| if (parsedThreshold) { | ||
| cachedSafetyThreshold = parsedThreshold; | ||
| return cachedSafetyThreshold; | ||
| } | ||
| cachedSafetyThreshold = DEFAULT_SAFETY_THRESHOLD; | ||
| return cachedSafetyThreshold; | ||
| } | ||
| function parseSafetyThreshold(threshold) { | ||
| const normalizedThreshold = threshold.trim().toUpperCase(); | ||
| if (!(normalizedThreshold in SAFETY_THRESHOLD_BY_NAME)) { | ||
| return undefined; | ||
| } | ||
| return SAFETY_THRESHOLD_BY_NAME[normalizedThreshold]; | ||
| } | ||
| const THINKING_LEVEL_MAP = { | ||
| minimal: ThinkingLevel.MINIMAL, | ||
| low: ThinkingLevel.LOW, | ||
| medium: ThinkingLevel.MEDIUM, | ||
| high: ThinkingLevel.HIGH, | ||
| }; | ||
| function getThinkingConfig(thinkingLevel, includeThoughts) { | ||
| if (!thinkingLevel && !includeThoughts) { | ||
| return undefined; | ||
| } | ||
| return { | ||
| ...(thinkingLevel | ||
| ? { thinkingLevel: THINKING_LEVEL_MAP[thinkingLevel] } | ||
| : {}), | ||
| ...(includeThoughts ? { includeThoughts: true } : {}), | ||
| }; | ||
| } | ||
| function parseBooleanEnv(value) { | ||
| const normalized = value.trim().toLowerCase(); | ||
| if (normalized.length === 0) { | ||
| return undefined; | ||
| } | ||
| if (TRUE_ENV_VALUES.has(normalized)) { | ||
| return true; | ||
| } | ||
| if (FALSE_ENV_VALUES.has(normalized)) { | ||
| return false; | ||
| } | ||
| return undefined; | ||
| } | ||
| function getDefaultIncludeThoughts() { | ||
| const value = process.env[GEMINI_INCLUDE_THOUGHTS_ENV_VAR]; | ||
| if (value === cachedIncludeThoughtsEnv) { | ||
| return cachedIncludeThoughts; | ||
| } | ||
| cachedIncludeThoughtsEnv = value; | ||
| if (!value) { | ||
| cachedIncludeThoughts = DEFAULT_INCLUDE_THOUGHTS; | ||
| return cachedIncludeThoughts; | ||
| } | ||
| cachedIncludeThoughts = parseBooleanEnv(value) ?? DEFAULT_INCLUDE_THOUGHTS; | ||
| return cachedIncludeThoughts; | ||
| } | ||
| function getDefaultBatchMode() { | ||
| const value = process.env[GEMINI_BATCH_MODE_ENV_VAR]?.trim().toLowerCase(); | ||
| if (value === 'inline') { | ||
| return 'inline'; | ||
| } | ||
| return DEFAULT_BATCH_MODE; | ||
| } | ||
| function applyResponseKeyOrdering(responseSchema, responseKeyOrdering) { | ||
| if (!responseKeyOrdering || responseKeyOrdering.length === 0) { | ||
| return responseSchema; | ||
| } | ||
| return { | ||
| ...responseSchema, | ||
| propertyOrdering: [...responseKeyOrdering], | ||
| }; | ||
| } | ||
| function getPromptWithFunctionCallingContext(request) { | ||
| return request.prompt; | ||
| } | ||
| function getSafetySettings(threshold) { | ||
| const cached = safetySettingsCache.get(threshold); | ||
| if (cached) { | ||
| return cached; | ||
| } | ||
| const settings = SAFETY_CATEGORIES.map((category) => ({ | ||
| category, | ||
| threshold, | ||
| })); | ||
| safetySettingsCache.set(threshold, settings); | ||
| return settings; | ||
| } | ||
| let cachedClient; | ||
| export const geminiEvents = new EventEmitter(); | ||
| const debug = debuglog('gemini'); | ||
| geminiEvents.on('log', (payload) => { | ||
| if (debug.enabled) { | ||
| debug('%j', payload); | ||
| } | ||
| }); | ||
| const geminiContext = new AsyncLocalStorage({ | ||
| name: 'gemini_request', | ||
| defaultValue: { | ||
| requestId: UNKNOWN_REQUEST_CONTEXT_VALUE, | ||
| model: UNKNOWN_REQUEST_CONTEXT_VALUE, | ||
| }, | ||
| }); | ||
| const UNKNOWN_CONTEXT = { | ||
| requestId: UNKNOWN_REQUEST_CONTEXT_VALUE, | ||
| model: UNKNOWN_REQUEST_CONTEXT_VALUE, | ||
| }; | ||
| export function getCurrentRequestId() { | ||
| const context = geminiContext.getStore(); | ||
| return context?.requestId ?? UNKNOWN_REQUEST_CONTEXT_VALUE; | ||
| } | ||
| function getApiKey() { | ||
| const apiKey = process.env[GEMINI_API_KEY_ENV_VAR] ?? process.env[GOOGLE_API_KEY_ENV_VAR]; | ||
| if (!apiKey) { | ||
| throw new Error(`Missing ${GEMINI_API_KEY_ENV_VAR} or ${GOOGLE_API_KEY_ENV_VAR}.`); | ||
| } | ||
| return apiKey; | ||
| } | ||
| function getClient() { | ||
| cachedClient ??= new GoogleGenAI({ | ||
| apiKey: getApiKey(), | ||
| apiVersion: 'v1beta', | ||
| }); | ||
| return cachedClient; | ||
| } | ||
| export function setClientForTesting(client) { | ||
| cachedClient = client; | ||
| } | ||
| function nextRequestId() { | ||
| return randomUUID(); | ||
| } | ||
| function logEvent(event, details) { | ||
| const context = geminiContext.getStore() ?? UNKNOWN_CONTEXT; | ||
| geminiEvents.emit('log', { | ||
| event, | ||
| requestId: context.requestId, | ||
| model: context.model, | ||
| ...details, | ||
| }); | ||
| } | ||
| async function safeCallOnLog(onLog, level, data) { | ||
| try { | ||
| await onLog?.(level, data); | ||
| } | ||
| catch { | ||
| // Log callbacks are best-effort; never fail the tool call. | ||
| } | ||
| } | ||
| async function emitGeminiLog(onLog, level, payload) { | ||
| logEvent(payload.event, payload.details); | ||
| await safeCallOnLog(onLog, level, { | ||
| event: payload.event, | ||
| ...payload.details, | ||
| }); | ||
| } | ||
| function buildGenerationConfig(request, abortSignal) { | ||
| const includeThoughts = request.includeThoughts ?? getDefaultIncludeThoughts(); | ||
| const thinkingConfig = getThinkingConfig(request.thinkingLevel, includeThoughts); | ||
| const config = { | ||
| temperature: request.temperature ?? 1.0, | ||
| maxOutputTokens: request.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS, | ||
| responseMimeType: 'application/json', | ||
| responseSchema: applyResponseKeyOrdering(request.responseSchema, request.responseKeyOrdering), | ||
| safetySettings: getSafetySettings(getSafetyThreshold()), | ||
| abortSignal, | ||
| }; | ||
| if (request.systemInstruction) { | ||
| config.systemInstruction = request.systemInstruction; | ||
| } | ||
| if (thinkingConfig) { | ||
| config.thinkingConfig = thinkingConfig; | ||
| } | ||
| return config; | ||
| } | ||
| function combineSignals(signal, requestSignal) { | ||
| return requestSignal ? AbortSignal.any([signal, requestSignal]) : signal; | ||
| } | ||
| function throwIfRequestCancelled(requestSignal) { | ||
| if (requestSignal?.aborted) { | ||
| throw new Error(CANCELLED_REQUEST_MESSAGE); | ||
| } | ||
| } | ||
| function getSleepOptions(signal) { | ||
| return signal ? { ...SLEEP_UNREF_OPTIONS, signal } : SLEEP_UNREF_OPTIONS; | ||
| } | ||
| function parseStructuredResponse(responseText) { | ||
| if (!responseText) { | ||
| throw new Error('Gemini returned an empty response body.'); | ||
| } | ||
| try { | ||
| return JSON.parse(responseText); | ||
| } | ||
| catch { | ||
| // fast-path failed; try extracting from markdown block | ||
| } | ||
| const jsonMatch = JSON_CODE_BLOCK_PATTERN.exec(responseText); | ||
| const jsonText = jsonMatch?.[1] ?? responseText; | ||
| try { | ||
| return JSON.parse(jsonText); | ||
| } | ||
| catch (error) { | ||
| throw new Error(`Model produced invalid JSON: ${getErrorMessage(error)}`, { | ||
| cause: error, | ||
| }); | ||
| } | ||
| } | ||
| function formatTimeoutErrorMessage(timeoutMs) { | ||
| return `Gemini request timed out after ${formatUsNumber(timeoutMs)}ms.`; | ||
| } | ||
| function formatConcurrencyLimitErrorMessage(limit, waitLimitMs) { | ||
| return `Too many concurrent Gemini calls (limit: ${formatUsNumber(limit)}; waited ${formatUsNumber(waitLimitMs)}ms).`; | ||
| } | ||
| async function generateContentWithTimeout(request, model, timeoutMs) { | ||
| const controller = new AbortController(); | ||
| const timeout = setTimeout(() => { | ||
| controller.abort(); | ||
| }, timeoutMs); | ||
| timeout.unref(); | ||
| const signal = combineSignals(controller.signal, request.signal); | ||
| try { | ||
| return await getClient().models.generateContent({ | ||
| model, | ||
| contents: getPromptWithFunctionCallingContext(request), | ||
| config: buildGenerationConfig(request, signal), | ||
| }); | ||
| } | ||
| catch (error) { | ||
| throwIfRequestCancelled(request.signal); | ||
| if (controller.signal.aborted) { | ||
| throw new Error(formatTimeoutErrorMessage(timeoutMs), { cause: error }); | ||
| } | ||
| throw error; | ||
| } | ||
| finally { | ||
| clearTimeout(timeout); | ||
| } | ||
| } | ||
| function extractThoughtsFromParts(parts) { | ||
| if (!Array.isArray(parts)) { | ||
| return undefined; | ||
| } | ||
| const thoughtParts = parts.filter((part) => typeof part === 'object' && | ||
| part !== null && | ||
| part.thought === true && | ||
| typeof part.text === 'string'); | ||
| if (thoughtParts.length === 0) { | ||
| return undefined; | ||
| } | ||
| return thoughtParts.map((part) => part.text).join('\n\n'); | ||
| } | ||
| async function executeAttempt(request, model, timeoutMs, attempt, onLog) { | ||
| const startedAt = performance.now(); | ||
| const response = await generateContentWithTimeout(request, model, timeoutMs); | ||
| const latencyMs = Math.round(performance.now() - startedAt); | ||
| const finishReason = response.candidates?.[0]?.finishReason; | ||
| const thoughts = extractThoughtsFromParts(response.candidates?.[0]?.content?.parts); | ||
| await emitGeminiLog(onLog, 'info', { | ||
| event: 'gemini_call', | ||
| details: { | ||
| attempt, | ||
| latencyMs, | ||
| finishReason: finishReason ?? null, | ||
| usageMetadata: response.usageMetadata ?? null, | ||
| ...(thoughts ? { thoughts } : {}), | ||
| }, | ||
| }); | ||
| if (finishReason === FinishReason.MAX_TOKENS) { | ||
| const limit = request.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS; | ||
| throw new Error(`Response truncated: model output exceeds limit (maxOutputTokens=${formatUsNumber(limit)}). Increase maxOutputTokens or reduce prompt complexity.`); | ||
| } | ||
| return parseStructuredResponse(response.text); | ||
| } | ||
| async function waitBeforeRetry(attempt, error, onLog, requestSignal) { | ||
| const delayMs = getRetryDelayMs(attempt); | ||
| const reason = getErrorMessage(error); | ||
| await emitGeminiLog(onLog, 'warning', { | ||
| event: 'gemini_retry', | ||
| details: { | ||
| attempt, | ||
| delayMs, | ||
| reason, | ||
| }, | ||
| }); | ||
| throwIfRequestCancelled(requestSignal); | ||
| try { | ||
| await sleep(delayMs, undefined, getSleepOptions(requestSignal)); | ||
| } | ||
| catch (sleepError) { | ||
| throwIfRequestCancelled(requestSignal); | ||
| throw sleepError; | ||
| } | ||
| } | ||
| async function throwGeminiFailure(attemptsMade, lastError, onLog) { | ||
| const message = getErrorMessage(lastError); | ||
| await emitGeminiLog(onLog, 'error', { | ||
| event: 'gemini_failure', | ||
| details: { | ||
| error: message, | ||
| attempts: attemptsMade, | ||
| }, | ||
| }); | ||
| throw new Error(`Gemini request failed after ${attemptsMade} attempts: ${message}`, { cause: lastError }); | ||
| } | ||
| function shouldUseModelFallback(error, model) { | ||
| return getNumericErrorCode(error) === 404 && model === DEFAULT_MODEL; | ||
| } | ||
| async function applyModelFallback(request, onLog, reason) { | ||
| await emitGeminiLog(onLog, 'warning', { | ||
| event: 'gemini_model_fallback', | ||
| details: { | ||
| from: DEFAULT_MODEL, | ||
| to: MODEL_FALLBACK_TARGET, | ||
| reason, | ||
| }, | ||
| }); | ||
| return { | ||
| model: MODEL_FALLBACK_TARGET, | ||
| request: omitThinkingLevel(request), | ||
| }; | ||
| } | ||
| async function tryApplyModelFallback(error, model, request, onLog, reason) { | ||
| if (!shouldUseModelFallback(error, model)) { | ||
| return undefined; | ||
| } | ||
| return applyModelFallback(request, onLog, reason); | ||
| } | ||
| function countAttemptsMade(attempt) { | ||
| return attempt + 1; | ||
| } | ||
| async function runWithRetries(request, model, timeoutMs, maxRetries, onLog) { | ||
| let lastError; | ||
| let currentModel = model; | ||
| let effectiveRequest = request; | ||
| for (let attempt = 0; attempt <= maxRetries; attempt += 1) { | ||
| try { | ||
| return await executeAttempt(effectiveRequest, currentModel, timeoutMs, attempt, onLog); | ||
| } | ||
| catch (error) { | ||
| lastError = error; | ||
| const fallback = await tryApplyModelFallback(error, currentModel, request, onLog, 'Model not found (404)'); | ||
| if (fallback) { | ||
| currentModel = fallback.model; | ||
| effectiveRequest = fallback.request; | ||
| continue; | ||
| } | ||
| if (!canRetryAttempt(attempt, maxRetries, error)) { | ||
| return throwGeminiFailure(countAttemptsMade(attempt), lastError, onLog); | ||
| } | ||
| await waitBeforeRetry(attempt, error, onLog, request.signal); | ||
| } | ||
| } | ||
| return throwGeminiFailure(maxRetries + 1, lastError, onLog); | ||
| } | ||
| function omitThinkingLevel(request) { | ||
| const copy = { ...request }; | ||
| Reflect.deleteProperty(copy, 'thinkingLevel'); | ||
| return copy; | ||
| } | ||
| function isInlineBatchMode(mode) { | ||
| return mode === 'inline'; | ||
| } | ||
| async function acquireQueueSlot(mode, requestSignal) { | ||
| const queueWaitStartedAt = performance.now(); | ||
| if (isInlineBatchMode(mode)) { | ||
| await batchCallLimiter.acquire(requestSignal); | ||
| } | ||
| else { | ||
| await callLimiter.acquire(requestSignal); | ||
| } | ||
| return { | ||
| queueWaitMs: Math.round(performance.now() - queueWaitStartedAt), | ||
| waitingCalls: isInlineBatchMode(mode) | ||
| ? batchCallLimiter.pendingCount | ||
| : callLimiter.pendingCount, | ||
| }; | ||
| } | ||
| function releaseQueueSlot(mode) { | ||
| if (isInlineBatchMode(mode)) { | ||
| batchCallLimiter.release(); | ||
| return; | ||
| } | ||
| callLimiter.release(); | ||
| } | ||
| const BatchHelper = { | ||
| getState(payload) { | ||
| const record = toRecord(payload); | ||
| if (!record) | ||
| return undefined; | ||
| const directState = toUpperStringCode(record.state); | ||
| if (directState) | ||
| return directState; | ||
| const metadata = toRecord(record.metadata); | ||
| return metadata ? toUpperStringCode(metadata.state) : undefined; | ||
| }, | ||
| getResponseText(payload) { | ||
| const record = toRecord(payload); | ||
| if (!record) | ||
| return undefined; | ||
| // Try inlineResponse.text | ||
| const inline = toRecord(record.inlineResponse); | ||
| if (typeof inline?.text === 'string') | ||
| return inline.text; | ||
| const response = toRecord(record.response); | ||
| if (!response) | ||
| return undefined; | ||
| // Try response.text | ||
| if (typeof response.text === 'string') | ||
| return response.text; | ||
| // Try response.inlineResponses[0].text | ||
| if (Array.isArray(response.inlineResponses) && | ||
| response.inlineResponses.length > 0) { | ||
| const first = toRecord(response.inlineResponses[0]); | ||
| if (typeof first?.text === 'string') | ||
| return first.text; | ||
| } | ||
| return undefined; | ||
| }, | ||
| getErrorDetail(payload) { | ||
| const record = toRecord(payload); | ||
| if (!record) | ||
| return undefined; | ||
| // Try error.message | ||
| const directError = toRecord(record.error); | ||
| if (typeof directError?.message === 'string') | ||
| return directError.message; | ||
| // Try metadata.error.message | ||
| const metadata = toRecord(record.metadata); | ||
| const metaError = toRecord(metadata?.error); | ||
| if (typeof metaError?.message === 'string') | ||
| return metaError.message; | ||
| // Try response.error.message | ||
| const response = toRecord(record.response); | ||
| const respError = toRecord(response?.error); | ||
| return typeof respError?.message === 'string' | ||
| ? respError.message | ||
| : undefined; | ||
| }, | ||
| getSuccessResponseText(polled) { | ||
| const text = this.getResponseText(polled); | ||
| if (text) | ||
| return text; | ||
| const err = this.getErrorDetail(polled); | ||
| throw new Error(err | ||
| ? `Gemini batch request succeeded but returned no response text: ${err}` | ||
| : 'Gemini batch request succeeded but returned no response text.'); | ||
| }, | ||
| handleTerminalState(state, payload) { | ||
| if (state === 'JOB_STATE_FAILED' || state === 'JOB_STATE_CANCELLED') { | ||
| const err = this.getErrorDetail(payload); | ||
| throw new Error(err | ||
| ? `Gemini batch request ended with state ${state}: ${err}` | ||
| : `Gemini batch request ended with state ${state}.`); | ||
| } | ||
| }, | ||
| }; | ||
| async function pollBatchStatusWithRetries(batches, batchName, onLog, requestSignal) { | ||
| const maxPollRetries = 2; | ||
| for (let attempt = 0; attempt <= maxPollRetries; attempt += 1) { | ||
| try { | ||
| return await batches.get({ name: batchName }); | ||
| } | ||
| catch (error) { | ||
| if (!canRetryAttempt(attempt, maxPollRetries, error)) { | ||
| throw error; | ||
| } | ||
| await waitBeforeRetry(attempt, error, onLog, requestSignal); | ||
| } | ||
| } | ||
| throw new Error('Batch polling retries exhausted unexpectedly.'); | ||
| } | ||
| async function cancelBatchIfNeeded(request, batches, batchName, onLog, completed, timedOut) { | ||
| const aborted = request.signal?.aborted === true; | ||
| const shouldCancel = !completed && (aborted || timedOut); | ||
| if (!shouldCancel || !batchName || !batches.cancel) { | ||
| return; | ||
| } | ||
| const reason = timedOut ? 'timeout' : 'aborted'; | ||
| try { | ||
| await batches.cancel({ name: batchName }); | ||
| await emitGeminiLog(onLog, 'info', { | ||
| event: 'gemini_batch_cancelled', | ||
| details: { batchName, reason }, | ||
| }); | ||
| } | ||
| catch (error) { | ||
| await emitGeminiLog(onLog, 'warning', { | ||
| event: 'gemini_batch_cancel_failed', | ||
| details: { | ||
| batchName, | ||
| reason, | ||
| error: getErrorMessage(error), | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
| async function createBatchJobWithFallback(request, batches, model, onLog) { | ||
| let currentModel = model; | ||
| let effectiveRequest = request; | ||
| const createSignal = request.signal ?? NEVER_ABORT_SIGNAL; | ||
| for (let attempt = 0; attempt <= 1; attempt += 1) { | ||
| try { | ||
| const createPayload = { | ||
| model: currentModel, | ||
| src: [ | ||
| { | ||
| contents: [ | ||
| { role: 'user', parts: [{ text: effectiveRequest.prompt }] }, | ||
| ], | ||
| config: buildGenerationConfig(effectiveRequest, createSignal), | ||
| }, | ||
| ], | ||
| }; | ||
| return await batches.create(createPayload); | ||
| } | ||
| catch (error) { | ||
| if (attempt === 0 && shouldUseModelFallback(error, currentModel)) { | ||
| const fallback = await applyModelFallback(request, onLog, 'Model not found (404) during batch create'); | ||
| currentModel = fallback.model; | ||
| effectiveRequest = fallback.request; | ||
| continue; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| throw new Error('Unexpected state: batch creation loop exited without returning or throwing.'); | ||
| } | ||
| async function pollBatchForCompletion(batches, batchName, onLog, requestSignal) { | ||
| const pollIntervalMs = batchPollIntervalMsConfig.get(); | ||
| const timeoutMs = batchTimeoutMsConfig.get(); | ||
| const pollStart = performance.now(); | ||
| // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | ||
| while (true) { | ||
| throwIfRequestCancelled(requestSignal); | ||
| const elapsedMs = Math.round(performance.now() - pollStart); | ||
| if (elapsedMs > timeoutMs) { | ||
| throw new Error(`Gemini batch request timed out after ${formatUsNumber(timeoutMs)}ms.`); | ||
| } | ||
| const polled = await pollBatchStatusWithRetries(batches, batchName, onLog, requestSignal); | ||
| const state = BatchHelper.getState(polled); | ||
| if (state === 'JOB_STATE_SUCCEEDED') { | ||
| const responseText = BatchHelper.getSuccessResponseText(polled); | ||
| return parseStructuredResponse(responseText); | ||
| } | ||
| BatchHelper.handleTerminalState(state, polled); | ||
| await sleep(pollIntervalMs, undefined, getSleepOptions(requestSignal)); | ||
| } | ||
| } | ||
| async function runInlineBatchWithPolling(request, model, onLog) { | ||
| const client = getClient(); | ||
| const { batches } = client; | ||
| if (!batches) { | ||
| throw new Error('Batch mode requires SDK batch support, but batches API is unavailable.'); | ||
| } | ||
| let batchName; | ||
| let completed = false; | ||
| let timedOut = false; | ||
| try { | ||
| const createdJob = await createBatchJobWithFallback(request, batches, model, onLog); | ||
| const createdRecord = toRecord(createdJob); | ||
| batchName = | ||
| typeof createdRecord?.name === 'string' ? createdRecord.name : undefined; | ||
| if (!batchName) | ||
| throw new Error('Batch mode failed to return a job name.'); | ||
| await emitGeminiLog(onLog, 'info', { | ||
| event: 'gemini_batch_created', | ||
| details: { batchName }, | ||
| }); | ||
| const result = await pollBatchForCompletion(batches, batchName, onLog, request.signal); | ||
| completed = true; | ||
| return result; | ||
| } | ||
| catch (error) { | ||
| if (getErrorMessage(error).includes('timed out')) { | ||
| timedOut = true; | ||
| } | ||
| throw error; | ||
| } | ||
| finally { | ||
| await cancelBatchIfNeeded(request, batches, batchName, onLog, completed, timedOut); | ||
| } | ||
| } | ||
| export function getGeminiQueueSnapshot() { | ||
| return { | ||
| activeWaiters: callLimiter.pendingCount, | ||
| activeCalls: callLimiter.active, | ||
| activeBatchWaiters: batchCallLimiter.pendingCount, | ||
| activeBatchCalls: batchCallLimiter.active, | ||
| }; | ||
| } | ||
| export async function generateStructuredJson(request) { | ||
| const model = request.model ?? getDefaultModel(); | ||
| const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| const maxRetries = request.maxRetries ?? DEFAULT_MAX_RETRIES; | ||
| const batchMode = request.batchMode ?? getDefaultBatchMode(); | ||
| const { onLog } = request; | ||
| const { queueWaitMs, waitingCalls } = await acquireQueueSlot(batchMode, request.signal); | ||
| await safeCallOnLog(onLog, 'info', { | ||
| event: 'gemini_queue_acquired', | ||
| queueWaitMs, | ||
| waitingCalls, | ||
| activeCalls: callLimiter.active, | ||
| activeBatchCalls: batchCallLimiter.active, | ||
| mode: batchMode, | ||
| }); | ||
| try { | ||
| return await geminiContext.run({ requestId: nextRequestId(), model }, () => { | ||
| if (isInlineBatchMode(batchMode)) { | ||
| return runInlineBatchWithPolling(request, model, onLog); | ||
| } | ||
| return runWithRetries(request, model, timeoutMs, maxRetries, onLog); | ||
| }); | ||
| } | ||
| finally { | ||
| releaseQueueSlot(batchMode); | ||
| } | ||
| } |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
294764
54.54%92
70.37%6608
56.59%462
5.48%14
27.27%