Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@j0hanz/code-assistant

Package Overview
Dependencies
Maintainers
1
Versions
9
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@j0hanz/code-assistant - npm Package Compare versions

Comparing version
0.9.1
to
0.9.2
+23
dist/lib/file-store.d.ts
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 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 ?? {},
});
},
});
}
+5
-5

@@ -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();
}
}
}

@@ -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;

@@ -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>;
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;

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>;

@@ -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);

@@ -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;
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>;
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.'),
});

@@ -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);

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) {

{
"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 -->
[![npm](https://img.shields.io/npm/v/@j0hanz/code-assistant?style=flat-square&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@j0hanz/code-assistant) [![License: MIT](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D24-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![Docker](https://img.shields.io/badge/Docker-Available-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/j0hanz/code-assistant/pkgs/container/code-assistant)
[![npm](https://img.shields.io/npm/v/%40j0hanz%2Fcode-assistant?style=flat-square&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@j0hanz/code-assistant) [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D24-339933?style=flat-square&logo=nodedotjs&logoColor=white)](package.json) [![TypeScript](https://img.shields.io/badge/TypeScript-5.9%2B-3178C6?style=flat-square&logo=typescript&logoColor=white)](package.json) [![MCP SDK](https://img.shields.io/badge/MCP_SDK-1.26%2B-6f42c1?style=flat-square)](package.json) [![License](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](package.json)
Gemini-powered MCP server for code analysis with structured outputs for findings, risk assessment, and focused patch suggestions.
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](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) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](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) [![Install in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Server-C16FDE?logo=visualstudio&logoColor=white)](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)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](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) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](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)
[![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=code-assistant&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBqMGhhbnovY29kZS1hc3Npc3RhbnRAbGF0ZXN0Il19)
[![Add to LM Studio](https://files.lmstudio.ai/deeplink/mcp-install-light.svg)](https://lmstudio.ai/install-mcp?name=code-assistant&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBqMGhhbnovY29kZS1hc3Npc3RhbnRAbGF0ZXN0Il19) [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](cursor://anysphere.cursor-deeplink/mcp/install?name=code-assistant&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBqMGhhbnovY29kZS1hc3Npc3RhbnRAbGF0ZXN0Il19) [![Install in Goose](https://block.github.io/goose/img/extension-install-dark.svg)](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>
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](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) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](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)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](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>
[![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=code-assistant&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBqMGhhbnovY29kZS1hc3Npc3RhbnRAbGF0ZXN0Il19)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](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>
[![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](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>
[![Install in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Server-C16FDE?logo=visualstudio&logoColor=white)](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);
}
}