diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts new file mode 100644 index 000000000..5d3b47050 --- /dev/null +++ b/packages/cli/src/commands/setup.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2025 protoLabs Studio + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule, Argv } from 'yargs'; +import { runSetupWizard } from './setup/handler.js'; +import { t } from '../i18n/index.js'; + +export const setupCommand: CommandModule = { + command: 'setup', + describe: t( + 'Interactive setup wizard — configure a model provider, API key, and default model', + ), + builder: (yargs: Argv) => yargs.version(false), + handler: async () => { + await runSetupWizard(); + }, +}; diff --git a/packages/cli/src/commands/setup/handler.ts b/packages/cli/src/commands/setup/handler.ts new file mode 100644 index 000000000..a43b5fa0d --- /dev/null +++ b/packages/cli/src/commands/setup/handler.ts @@ -0,0 +1,578 @@ +/** + * @license + * Copyright 2025 protoLabs Studio + * SPDX-License-Identifier: Apache-2.0 + */ + +import { stdin, stdout } from 'node:process'; +import { + AuthType, + getErrorMessage, + type ProviderModelConfig as ModelConfig, +} from '@qwen-code/qwen-code-core'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { loadSettings } from '../../config/settings.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { backupSettingsFile } from '../../utils/settingsUtils.js'; +import { InteractiveSelector } from '../auth/interactiveSelector.js'; +import { + fetchAvailableModels, + type DiscoveredModel, +} from './modelDiscovery.js'; + +/** Provider presets with sensible defaults. */ +const PROVIDER_PRESETS = { + openai: { + label: 'OpenAI', + authType: AuthType.USE_OPENAI, + defaultBaseUrl: 'https://api.openai.com/v1', + defaultEnvKey: 'OPENAI_API_KEY', + }, + 'openai-compatible': { + label: 'OpenAI-compatible (custom endpoint)', + authType: AuthType.USE_OPENAI, + defaultBaseUrl: '', + defaultEnvKey: 'OPENAI_API_KEY', + }, + anthropic: { + label: 'Anthropic', + authType: AuthType.USE_ANTHROPIC, + defaultBaseUrl: 'https://api.anthropic.com/v1', + defaultEnvKey: 'ANTHROPIC_API_KEY', + }, + gemini: { + label: 'Google Gemini', + authType: AuthType.USE_GEMINI, + defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1beta', + defaultEnvKey: 'GEMINI_API_KEY', + }, +} as const; + +type ProviderPresetKey = keyof typeof PROVIDER_PRESETS; + +/** + * Run the interactive setup wizard. + * Can be invoked from `proto setup` CLI command or `/setup` slash command. + */ +export async function runSetupWizard(): Promise { + try { + writeStdoutLine('\nšŸ”§ proto setup\n'); + writeStdoutLine('Configure a model provider to get started.\n'); + + // Step 1: Select provider + const providerKey = await selectProvider(); + const preset = PROVIDER_PRESETS[providerKey]; + + // Step 2: Base URL + const baseUrl = await promptBaseUrl(preset); + + // Step 3: API key + const { key: apiKey, fromEnv: apiKeyFromEnv } = await promptApiKey(preset); + + // Step 4: Discover models + writeStdoutLine('\nā³ Discovering models...\n'); + const { models, error } = await fetchAvailableModels(baseUrl, apiKey); + + if (error) { + writeStderrLine(`\nāš ļø ${error}\n`); + if (models.length === 0) { + writeStderrLine( + 'Could not discover models. You can still configure a model manually.\n', + ); + const manualModelId = await promptText('Enter a model ID to use: '); + if (manualModelId.trim()) { + await persistSetup( + preset, + baseUrl, + apiKey, + apiKeyFromEnv, + manualModelId.trim(), + [], + ); + return; + } + writeStderrLine('Setup cancelled — no model selected.\n'); + process.exit(1); + } + } + + writeStdoutLine(`Found ${models.length} model(s).\n`); + + // Step 5: Select default model + const selectedModel = await selectModel(models); + + // Step 6: Voice / STT configuration + const sttConfig = await promptSttSetup(baseUrl, preset); + + // Step 7: Provider label (optional) + const providerLabel = await promptText( + `Provider label (default: ${preset.label}): `, + ); + + // Step 8: Persist + const label = providerLabel.trim() || preset.label; + await persistSetup( + preset, + baseUrl, + apiKey, + apiKeyFromEnv, + selectedModel.id, + models, + label, + sttConfig, + ); + + writeStdoutLine(`\nāœ… Setup complete!\n`); + writeStdoutLine(` Provider: ${label}`); + writeStdoutLine(` Endpoint: ${baseUrl}`); + writeStdoutLine(` Model: ${selectedModel.id}`); + if (sttConfig) { + writeStdoutLine( + ` STT: ${sttConfig.endpoint} (${sttConfig.enabled ? 'enabled' : 'disabled'})`, + ); + } + writeStdoutLine(`\nRun \`proto\` to start chatting.\n`); + + process.exit(0); + } catch (err) { + if ((err as Error).message === 'Interrupted') { + writeStdoutLine('\nSetup cancelled.\n'); + process.exit(0); + } + writeStderrLine(`\nSetup failed: ${getErrorMessage(err)}\n`); + process.exit(1); + } +} + +// ─── Interactive prompts ──────────────────────────────────────────── + +async function selectProvider(): Promise { + const selector = new InteractiveSelector( + [ + { + value: 'openai' as ProviderPresetKey, + label: 'OpenAI', + description: 'api.openai.com', + }, + { + value: 'openai-compatible' as ProviderPresetKey, + label: 'OpenAI-compatible', + description: + 'Custom endpoint (Ollama, LiteLLM, vLLM, OpenRouter, etc.)', + }, + { + value: 'anthropic' as ProviderPresetKey, + label: 'Anthropic', + description: 'api.anthropic.com', + }, + { + value: 'gemini' as ProviderPresetKey, + label: 'Google Gemini', + description: 'generativelanguage.googleapis.com', + }, + ], + 'Select a provider:', + ); + + return selector.select(); +} + +async function promptBaseUrl( + preset: (typeof PROVIDER_PRESETS)[ProviderPresetKey], +): Promise { + if (preset.defaultBaseUrl) { + const label = `Base URL (default: ${preset.defaultBaseUrl}): `; + const input = await promptText(label); + return input.trim() || preset.defaultBaseUrl; + } + // No default — require input + let url = ''; + while (!url) { + url = (await promptText('Base URL: ')).trim(); + if (!url) { + writeStderrLine('A base URL is required for custom endpoints.\n'); + } + } + return url; +} + +interface ApiKeyResult { + key: string; + /** True when the key came from an existing env var — don't persist to disk. */ + fromEnv: boolean; +} + +async function promptApiKey( + preset: (typeof PROVIDER_PRESETS)[ProviderPresetKey], +): Promise { + // Check if already in env + const envValue = process.env[preset.defaultEnvKey]; + if (envValue) { + const masked = envValue.slice(0, 4) + '...' + envValue.slice(-4); + writeStdoutLine( + `\nFound ${preset.defaultEnvKey} in environment (${masked}).`, + ); + const useExisting = await promptText('Use this key? (Y/n): '); + if (!useExisting.trim() || useExisting.trim().toLowerCase() === 'y') { + // Key stays in env — don't write it back to disk + return { key: envValue, fromEnv: true }; + } + } + + let key = ''; + while (!key) { + key = await promptMaskedText(`\nAPI key: `); + if (!key) { + writeStderrLine('An API key is required.\n'); + } + } + return { key, fromEnv: false }; +} + +async function selectModel( + models: DiscoveredModel[], +): Promise { + // If only 1 model, auto-select it + if (models.length === 1) { + writeStdoutLine(`Auto-selecting only available model: ${models[0].id}\n`); + return models[0]; + } + + // For large lists, paginate with the interactive selector in batches + const PAGE_SIZE = 15; + if (models.length <= PAGE_SIZE) { + const selector = new InteractiveSelector( + models.map((m) => ({ + value: m, + label: m.id, + description: m.ownedBy || undefined, + })), + 'Select a default model:', + ); + return selector.select(); + } + + // Paginated selection for large model lists + return selectModelPaginated(models, PAGE_SIZE); +} + +type PaginatedOption = + | { type: 'model'; model: DiscoveredModel } + | { type: 'prev' } + | { type: 'next' }; + +async function selectModelPaginated( + models: DiscoveredModel[], + pageSize: number, +): Promise { + let page = 0; + const totalPages = Math.ceil(models.length / pageSize); + + while (true) { + const start = page * pageSize; + const end = Math.min(start + pageSize, models.length); + const pageModels = models.slice(start, end); + + const options: Array<{ + value: PaginatedOption; + label: string; + description?: string; + }> = pageModels.map((m) => ({ + value: { type: 'model' as const, model: m }, + label: m.id, + description: m.ownedBy || undefined, + })); + + // Add navigation options + if (page > 0) { + options.unshift({ + value: { type: 'prev' as const }, + label: '← Previous page', + description: undefined, + }); + } + if (end < models.length) { + options.push({ + value: { type: 'next' as const }, + label: '→ Next page', + description: `Page ${page + 1}/${totalPages}`, + }); + } + + const selector = new InteractiveSelector( + options, + `Select a default model (page ${page + 1}/${totalPages}, ${models.length} total):`, + ); + + const result = await selector.select(); + + if (result.type === 'prev') { + page--; + continue; + } + if (result.type === 'next') { + page++; + continue; + } + return result.model; + } +} + +// ─── STT setup ────────────────────────────────────────────────────── + +interface SttConfig { + endpoint: string; + envKey: string; + enabled: boolean; +} + +/** + * Build the default STT endpoint from the provider base URL. + * Normalises to `{baseUrl}/audio/transcriptions` (OpenAI standard). + */ +function buildDefaultSttEndpoint(baseUrl: string): string { + let normalised = baseUrl.replace(/\/+$/, ''); + + // Add /v1 only if no version segment exists (e.g. /v1, /v1beta, /v2alpha1) + if (!/\/v\d+[A-Za-z0-9._-]*(\/|$)/.test(normalised)) { + normalised += '/v1'; + } + + return `${normalised}/audio/transcriptions`; +} + +async function promptSttSetup( + baseUrl: string, + preset: (typeof PROVIDER_PRESETS)[ProviderPresetKey], +): Promise { + writeStdoutLine('\nšŸŽ¤ Voice Input (Speech-to-Text)\n'); + + const selector = new InteractiveSelector( + [ + { + value: 'yes', + label: 'Yes, configure STT', + description: + 'Set up voice input using an OpenAI-compatible transcription endpoint', + }, + { + value: 'skip', + label: 'Skip for now', + description: 'You can enable later via /voice or settings.json', + }, + ], + 'Enable voice input (push-to-talk)?', + ); + + const choice = await selector.select(); + if (choice === 'skip') { + return null; + } + + // Default STT endpoint derived from the provider base URL + const defaultEndpoint = buildDefaultSttEndpoint(baseUrl); + writeStdoutLine(`\nDefault STT endpoint: ${defaultEndpoint}`); + const endpointInput = await promptText( + `STT endpoint (press Enter to use default): `, + ); + const endpoint = endpointInput.trim() || defaultEndpoint; + + // STT API key env var — default to the same env key as the provider + const defaultEnvKey = preset.defaultEnvKey; + const envKeyInput = await promptText( + `API key env var for STT (default: ${defaultEnvKey}): `, + ); + const envKey = envKeyInput.trim() || defaultEnvKey; + + return { endpoint, envKey, enabled: true }; +} + +// ─── Text input helpers ───────────────────────────────────────────── + +function promptText(label: string): Promise { + return new Promise((resolve, reject) => { + stdout.write(label); + + if (!stdin.setRawMode) { + reject( + new Error( + 'Raw mode not available. Please run in an interactive terminal.', + ), + ); + return; + } + + const wasRaw = stdin.isRaw; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + + let input = ''; + + const onData = (chunk: string) => { + for (const char of chunk) { + switch (char) { + case '\r': + case '\n': + stdin.removeListener('data', onData); + stdin.setRawMode(wasRaw); + stdout.write('\n'); + resolve(input); + return; + case '\x03': // Ctrl+C + stdin.removeListener('data', onData); + stdin.setRawMode(wasRaw); + stdout.write('\n'); + reject(new Error('Interrupted')); + return; + case '\x08': // Backspace + case '\x7F': // Delete + if (input.length > 0) { + input = input.slice(0, -1); + stdout.write('\x1B[D \x1B[D'); + } + break; + default: + if (char.charCodeAt(0) >= 32) { + input += char; + stdout.write(char); + } + break; + } + } + }; + + stdin.on('data', onData); + }); +} + +function promptMaskedText(label: string): Promise { + return new Promise((resolve, reject) => { + stdout.write(label); + + if (!stdin.setRawMode) { + reject( + new Error( + 'Raw mode not available. Please run in an interactive terminal.', + ), + ); + return; + } + + const wasRaw = stdin.isRaw; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + + let input = ''; + + const onData = (chunk: string) => { + for (const char of chunk) { + switch (char) { + case '\r': + case '\n': + stdin.removeListener('data', onData); + stdin.setRawMode(wasRaw); + stdout.write('\n'); + resolve(input); + return; + case '\x03': // Ctrl+C + stdin.removeListener('data', onData); + stdin.setRawMode(wasRaw); + stdout.write('\n'); + reject(new Error('Interrupted')); + return; + case '\x08': + case '\x7F': + if (input.length > 0) { + input = input.slice(0, -1); + stdout.write('\x1B[D \x1B[D'); + } + break; + default: + if (char.charCodeAt(0) >= 32) { + input += char; + stdout.write('*'); + } + break; + } + } + }; + + stdin.on('data', onData); + }); +} + +// ─── Persistence ──────────────────────────────────────────────────── + +async function persistSetup( + preset: (typeof PROVIDER_PRESETS)[ProviderPresetKey], + baseUrl: string, + apiKey: string, + apiKeyFromEnv: boolean, + defaultModelId: string, + discoveredModels: DiscoveredModel[], + label?: string, + sttConfig?: SttConfig | null, +): Promise { + const settings = loadSettings(); + const scope = getPersistScopeForModelSelection(settings); + + // Backup before mutation + const settingsFile = settings.forScope(scope); + backupSettingsFile(settingsFile.path); + + // Build model configs from discovered models (or just the default) + const envKey = preset.defaultEnvKey; + const modelConfigs: ModelConfig[] = + discoveredModels.length > 0 + ? discoveredModels.map((m) => ({ + id: m.id, + name: label ? `${label} — ${m.id}` : m.id, + envKey, + baseUrl, + })) + : [ + { + id: defaultModelId, + name: label ? `${label} — ${defaultModelId}` : defaultModelId, + envKey, + baseUrl, + }, + ]; + + // Merge with existing configs, removing duplicates by baseUrl+id + const existing = + (settings.merged.modelProviders as Record)?.[ + preset.authType + ] || []; + + const existingNonOverlapping = existing.filter( + (e) => !modelConfigs.some((n) => n.id === e.id && n.baseUrl === e.baseUrl), + ); + + const merged = [...modelConfigs, ...existingNonOverlapping]; + + // Persist model providers + settings.setValue(scope, `modelProviders.${preset.authType}`, merged); + + // Only write the API key to disk if the user explicitly typed it. + // Keys already present in the environment stay there — no silent promotion + // of env-only secrets to plaintext on disk. + if (!apiKeyFromEnv) { + settings.setValue(scope, `env.${envKey}`, apiKey); + } + + // Set auth type + settings.setValue(scope, 'security.auth.selectedType', preset.authType); + + // Set default model + settings.setValue(scope, 'model.name', defaultModelId); + + // Persist voice / STT settings + if (sttConfig) { + settings.setValue(scope, 'voice.enabled', sttConfig.enabled); + settings.setValue(scope, 'voice.sttEndpoint', sttConfig.endpoint); + settings.setValue(scope, 'voice.sttEnvKey', sttConfig.envKey); + } +} diff --git a/packages/cli/src/commands/setup/modelDiscovery.test.ts b/packages/cli/src/commands/setup/modelDiscovery.test.ts new file mode 100644 index 000000000..cae0ad827 --- /dev/null +++ b/packages/cli/src/commands/setup/modelDiscovery.test.ts @@ -0,0 +1,282 @@ +/** + * @license + * Copyright 2025 protoLabs Studio + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fetchAvailableModels } from './modelDiscovery.js'; + +describe('fetchAvailableModels', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('should parse standard OpenAI /models response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { id: 'gpt-4', created: 1000, owned_by: 'openai' }, + { id: 'gpt-3.5-turbo', created: 900, owned_by: 'openai' }, + ], + }), + }); + + const result = await fetchAvailableModels( + 'https://api.openai.com/v1', + 'sk-test', + ); + + expect(result.error).toBeUndefined(); + expect(result.models).toHaveLength(2); + // Sorted alphabetically + expect(result.models[0].id).toBe('gpt-3.5-turbo'); + expect(result.models[1].id).toBe('gpt-4'); + expect(result.models[1].ownedBy).toBe('openai'); + }); + + it('should handle plain array response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [{ id: 'model-a' }, { id: 'model-b' }], + }); + + const result = await fetchAvailableModels( + 'https://custom.api.com/v1', + 'key-123', + ); + + expect(result.error).toBeUndefined(); + expect(result.models).toHaveLength(2); + }); + + it('should return auth error on 401', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + const result = await fetchAvailableModels( + 'https://api.openai.com/v1', + 'bad-key', + ); + + expect(result.models).toHaveLength(0); + expect(result.error).toContain('Authentication failed'); + }); + + it('should return auth error on 403', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + }); + + const result = await fetchAvailableModels( + 'https://api.openai.com/v1', + 'bad-key', + ); + + expect(result.models).toHaveLength(0); + expect(result.error).toContain('Authentication failed'); + }); + + it('should return server error on 500', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + + const result = await fetchAvailableModels( + 'https://api.openai.com/v1', + 'sk-test', + ); + + expect(result.models).toHaveLength(0); + expect(result.error).toContain('500'); + }); + + it('should handle network errors', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed')); + + const result = await fetchAvailableModels( + 'https://bad-host.example.com/v1', + 'sk-test', + ); + + expect(result.models).toHaveLength(0); + expect(result.error).toContain('Could not connect'); + }); + + it('should handle unexpected response format', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ something: 'unexpected' }), + }); + + const result = await fetchAvailableModels( + 'https://api.openai.com/v1', + 'sk-test', + ); + + expect(result.models).toHaveLength(0); + expect(result.error).toContain('Unexpected response format'); + }); + + it('should handle empty model list', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [] }), + }); + + const result = await fetchAvailableModels( + 'https://api.openai.com/v1', + 'sk-test', + ); + + expect(result.models).toHaveLength(0); + expect(result.error).toContain('No models found'); + }); + + it('should filter out entries without id', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { id: 'valid-model' }, + { name: 'no-id-model' }, + null, + { id: 123 }, // id is not a string + ], + }), + }); + + const result = await fetchAvailableModels( + 'https://api.openai.com/v1', + 'sk-test', + ); + + expect(result.models).toHaveLength(1); + expect(result.models[0].id).toBe('valid-model'); + }); + + it('should append /v1/models to bare base URL', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: 'test-model' }] }), + }); + + await fetchAvailableModels('https://api.openai.com', 'sk-test'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1/models', + expect.any(Object), + ); + }); + + it('should append /models to URL already ending with /v1', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: 'test-model' }] }), + }); + + await fetchAvailableModels('https://api.openai.com/v1', 'sk-test'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1/models', + expect.any(Object), + ); + }); + + it('should strip trailing slashes from base URL', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: 'test-model' }] }), + }); + + await fetchAvailableModels('https://api.openai.com/v1/', 'sk-test'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1/models', + expect.any(Object), + ); + }); + + it('should send Authorization header with Bearer token', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: 'test-model' }] }), + }); + + await fetchAvailableModels('https://api.openai.com/v1', 'sk-my-key'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer sk-my-key', + }), + }), + ); + }); + + it('should NOT append /v1 to URLs with a versioned path like /v1beta', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: 'gemini-pro' }] }), + }); + + await fetchAvailableModels( + 'https://generativelanguage.googleapis.com/v1beta', + 'api-key', + ); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://generativelanguage.googleapis.com/v1beta/models', + expect.any(Object), + ); + }); + + it('should NOT append /v1 to URLs with a /v1/ path segment', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: 'model-x' }] }), + }); + + await fetchAvailableModels( + 'https://custom.example.com/v1/openai', + 'api-key', + ); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://custom.example.com/v1/openai/models', + expect.any(Object), + ); + }); + + it('should handle models with optional fields missing', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [{ id: 'bare-model' }], + }), + }); + + const result = await fetchAvailableModels( + 'https://api.openai.com/v1', + 'sk-test', + ); + + expect(result.models[0].created).toBeUndefined(); + expect(result.models[0].ownedBy).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/setup/modelDiscovery.ts b/packages/cli/src/commands/setup/modelDiscovery.ts new file mode 100644 index 000000000..47599a771 --- /dev/null +++ b/packages/cli/src/commands/setup/modelDiscovery.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2025 protoLabs Studio + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('setup:modelDiscovery'); + +export interface DiscoveredModel { + id: string; + created?: number; + ownedBy?: string; +} + +export interface ModelDiscoveryResult { + models: DiscoveredModel[]; + error?: string; +} + +/** + * Fetch available models from an OpenAI-compatible `/models` endpoint. + * + * Works with any provider that implements the OpenAI `/v1/models` standard + * (OpenAI, Anthropic via proxy, Ollama, LiteLLM, vLLM, OpenRouter, etc.). + */ +export async function fetchAvailableModels( + baseUrl: string, + apiKey: string, +): Promise { + const url = buildModelsUrl(baseUrl); + + debugLogger.info(`Fetching models from ${url}`); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + const status = response.status; + if (status === 401 || status === 403) { + return { + models: [], + error: `Authentication failed (${status}). Check your API key.`, + }; + } + return { + models: [], + error: `Server returned ${status}: ${response.statusText}`, + }; + } + + const body = (await response.json()) as unknown; + return parseModelsResponse(body); + } catch (err) { + if (err instanceof DOMException && err.name === 'TimeoutError') { + return { models: [], error: 'Request timed out after 15s.' }; + } + if (err instanceof TypeError && (err as Error).message.includes('fetch')) { + return { + models: [], + error: `Could not connect to ${url}. Check the base URL.`, + }; + } + const message = err instanceof Error ? err.message : String(err); + debugLogger.error(`Model discovery failed: ${message}`); + return { models: [], error: message }; + } +} + +/** + * Normalise the base URL and append `/models`. + */ +function buildModelsUrl(baseUrl: string): string { + let normalised = baseUrl.replace(/\/+$/, ''); + + // If the URL already ends with /models, use it as-is + if (normalised.endsWith('/models')) { + return normalised; + } + + // Add /v1 only if no version segment exists (e.g. /v1, /v1beta, /v2alpha1) + if (!/\/v\d+[A-Za-z0-9._-]*(\/|$)/.test(normalised)) { + normalised += '/v1'; + } + + return `${normalised}/models`; +} + +/** + * Parse the response body from a `/models` endpoint. + * Handles both the standard `{ data: [...] }` shape and plain arrays. + */ +function parseModelsResponse(body: unknown): ModelDiscoveryResult { + if (!body || typeof body !== 'object') { + return { models: [], error: 'Unexpected response format.' }; + } + + const obj = body as Record; + let rawModels: unknown[]; + + if (Array.isArray(obj['data'])) { + rawModels = obj['data'] as unknown[]; + } else if (Array.isArray(body)) { + rawModels = body; + } else { + return { + models: [], + error: 'Unexpected response format — no model list found.', + }; + } + + const models: DiscoveredModel[] = rawModels + .filter( + (m): m is Record => + !!m && + typeof m === 'object' && + typeof (m as Record)['id'] === 'string', + ) + .map((m) => ({ + id: m['id'] as string, + created: + typeof m['created'] === 'number' ? (m['created'] as number) : undefined, + ownedBy: + typeof m['owned_by'] === 'string' + ? (m['owned_by'] as string) + : undefined, + })) + .sort((a, b) => a.id.localeCompare(b.id)); + + if (models.length === 0) { + return { models: [], error: 'No models found at this endpoint.' }; + } + + return { models }; +} diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index e1ca51b51..8683e897d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -36,6 +36,7 @@ import { hooksCommand } from '../commands/hooks.js'; import type { Settings } from './settings.js'; import { loadSettings, SettingScope } from './settings.js'; import { authCommand } from '../commands/auth.js'; +import { setupCommand } from '../commands/setup.js'; import { resolveCliGenerationConfig, getAuthTypeFromEnv, @@ -587,6 +588,8 @@ export async function parseArguments(): Promise { .command(extensionsCommand) // Register Auth subcommands .command(authCommand) + // Register Setup subcommand + .command(setupCommand) // Register Hooks subcommands .command(hooksCommand); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 9e6fdcdc7..ce7ed06df 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -47,6 +47,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; +import { setupCommand } from '../ui/commands/setupCommand.js'; import { insightCommand } from '../ui/commands/insightCommand.js'; import { teamCommand } from '../ui/commands/teamCommand.js'; import { voiceCommand } from '../ui/commands/voiceCommand.js'; @@ -105,6 +106,7 @@ export class BuiltinCommandLoader implements ICommandLoader { toolsCommand, settingsCommand, vimCommand, + setupCommand, setupGithubCommand, teamCommand, terminalSetupCommand, diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 4ceb69a7b..f4a4f37b9 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -13,6 +13,9 @@ import { deleteMemory, formatAge, getStaleWarning, + listProposals, + acceptProposal, + rejectProposal, } from '@qwen-code/qwen-code-core'; import path from 'node:path'; import os from 'node:os'; @@ -398,6 +401,173 @@ export const memoryCommand: SlashCommand = { ); }, }, + { + name: 'proposals', + get description() { + return t( + 'List pending memory proposals awaiting your approval. Accept with /memory accept , reject with /memory reject .', + ); + }, + kind: CommandKind.BUILT_IN, + action: async (context) => { + const cwd = context.services.config?.getWorkingDir?.() ?? process.cwd(); + const projectProposals = await listProposals('project', cwd); + const globalProposals = await listProposals('global'); + const all = [ + ...projectProposals.map((m) => ({ ...m, scope: 'project' as const })), + ...globalProposals.map((m) => ({ ...m, scope: 'global' as const })), + ]; + + if (all.length === 0) { + context.ui.addItem( + { + type: MessageType.INFO, + text: t( + 'No pending memory proposals. Proposals appear here after the memory extractor runs.', + ), + }, + Date.now(), + ); + return; + } + + const lines = all.map((m) => { + const age = formatAge(m.mtimeMs); + const id = path.basename(m.filePath, '.md'); + const preview = m.content.slice(0, 120).replace(/\n/g, ' ').trim(); + return ` [${m.header.type}] ${m.header.name} (${m.scope}, ${age})\n ${m.header.description}\n Preview: ${preview}…\n ID: ${id}`; + }); + + context.ui.addItem( + { + type: MessageType.INFO, + text: `${all.length} pending proposal${all.length === 1 ? '' : 's'}:\n\n${lines.join('\n\n')}\n\nUse /memory accept or /memory reject `, + }, + Date.now(), + ); + }, + }, + { + name: 'accept', + get description() { + return t('Accept a memory proposal by ID. Usage: /memory accept '); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context, + args, + ): Promise => { + const id = args?.trim(); + if (!id) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Usage: /memory accept (see /memory proposals for IDs)', + ), + }; + } + + const cwd = context.services.config?.getWorkingDir?.() ?? process.cwd(); + + for (const scope of ['project', 'global'] as const) { + const proposals = await listProposals(scope, cwd); + const match = proposals.find( + (p) => + path.basename(p.filePath, '.md') === id || + p.header.name.toLowerCase() === id.toLowerCase(), + ); + if (match) { + const dest = await acceptProposal(match.filePath, scope, cwd); + if (dest) { + context.ui.addItem( + { + type: MessageType.INFO, + text: t('Accepted memory: {{name}} ({{scope}})', { + name: match.header.name, + scope, + }), + }, + Date.now(), + ); + return; + } + } + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: t( + 'No proposal found with ID "{{id}}". Run /memory proposals to see pending IDs.', + { id }, + ), + }, + Date.now(), + ); + }, + }, + { + name: 'reject', + get description() { + return t( + 'Reject (discard) a memory proposal by ID. Usage: /memory reject ', + ); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context, + args, + ): Promise => { + const id = args?.trim(); + if (!id) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Usage: /memory reject (see /memory proposals for IDs)', + ), + }; + } + + const cwd = context.services.config?.getWorkingDir?.() ?? process.cwd(); + + for (const scope of ['project', 'global'] as const) { + const proposals = await listProposals(scope, cwd); + const match = proposals.find( + (p) => + path.basename(p.filePath, '.md') === id || + p.header.name.toLowerCase() === id.toLowerCase(), + ); + if (match) { + const deleted = await rejectProposal(match.filePath); + if (deleted) { + context.ui.addItem( + { + type: MessageType.INFO, + text: t('Rejected proposal: {{name}}', { + name: match.header.name, + }), + }, + Date.now(), + ); + return; + } + } + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: t( + 'No proposal found with ID "{{id}}". Run /memory proposals to see pending IDs.', + { id }, + ), + }, + Date.now(), + ); + }, + }, { name: 'refresh', get description() { diff --git a/packages/cli/src/ui/commands/setupCommand.test.ts b/packages/cli/src/ui/commands/setupCommand.test.ts new file mode 100644 index 000000000..263469726 --- /dev/null +++ b/packages/cli/src/ui/commands/setupCommand.test.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2025 protoLabs Studio + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { setupCommand } from './setupCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('setupCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should have the correct name and description', () => { + expect(setupCommand.name).toBe('setup'); + expect(setupCommand.description).toContain('model provider'); + }); + + it('should return a message action pointing to proto setup', () => { + if (!setupCommand.action) { + throw new Error('The setup command must have an action.'); + } + const result = setupCommand.action(mockContext, ''); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('proto setup'), + }); + }); +}); diff --git a/packages/cli/src/ui/commands/setupCommand.ts b/packages/cli/src/ui/commands/setupCommand.ts new file mode 100644 index 000000000..9b0e650bf --- /dev/null +++ b/packages/cli/src/ui/commands/setupCommand.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 protoLabs Studio + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { MessageActionReturn, SlashCommand } from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; + +export const setupCommand: SlashCommand = { + name: 'setup', + get description() { + return t( + 'Configure a model provider interactively (run `proto setup` from terminal for full wizard)', + ); + }, + kind: CommandKind.BUILT_IN, + action: (_context, _args): MessageActionReturn => ({ + type: 'message', + messageType: 'info', + content: t( + 'šŸ”§ The setup wizard requires exclusive terminal access.\n\nRun `proto setup` from your terminal to configure a provider, API key, and default model interactively.', + ), + }), +}; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index a00099edd..f2e6e5df4 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1435,6 +1435,26 @@ export const useGeminiStream = ( }) .catch(() => {}); + // Fire-and-forget evolve pass: detect reusable skill patterns every N turns + import('@qwen-code/qwen-code-core') + .then((core) => { + if (core.runEvolvePass && config) { + const history = geminiClient.getHistory?.() ?? []; + const recentMessages: Array<{ role: string; text: string }> = + []; + for (const entry of history.slice(-20)) { + const text = (entry.parts ?? []) + .map((p: { text?: string }) => p.text ?? '') + .join(' ') + .trim(); + if (text) + recentMessages.push({ role: entry.role as string, text }); + } + core.runEvolvePass(config, recentMessages).catch(() => {}); + } + }) + .catch(() => {}); + // Fire-and-forget timed microcompact: clear old tool-result bodies // on a timer (default 10 min) to reduce context without an LLM call. import('@qwen-code/qwen-code-core') diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6cc02b7bf..f554cd995 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -42,6 +42,7 @@ import { } from '../services/fileSystemService.js'; import { GitService } from '../services/gitService.js'; import { CronScheduler } from '../services/cronScheduler.js'; +import { PermissionBlockerService } from '../services/permissionBlockerService.js'; import { SprintContractService } from '../services/sprintContractService.js'; // Tools @@ -601,6 +602,7 @@ export class Config { private geminiClient!: GeminiClient; private baseLlmClient!: BaseLlmClient; private cronScheduler: CronScheduler | null = null; + private permissionBlockerService: PermissionBlockerService | null = null; private readonly fileFiltering: { respectGitIgnore: boolean; respectQwenIgnore: boolean; @@ -1790,11 +1792,22 @@ export class Config { getCronScheduler(): CronScheduler { if (!this.cronScheduler) { - this.cronScheduler = new CronScheduler(); + this.cronScheduler = new CronScheduler( + this.storage.getProjectCronJobsPath(), + ); } return this.cronScheduler; } + getPermissionBlockerService(): PermissionBlockerService { + if (!this.permissionBlockerService) { + this.permissionBlockerService = new PermissionBlockerService( + this.storage.getProjectBlockersPath(), + ); + } + return this.permissionBlockerService; + } + isCronEnabled(): boolean { // Cron is experimental and opt-in: enabled via settings or env var if (process.env['QWEN_CODE_ENABLE_CRON'] === '1') return true; diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index eba72fc3e..c7e2ef956 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -201,6 +201,18 @@ export class Storage { return path.join(targetDir, QWEN_DIR, 'memory'); } + static getGlobalCronJobsPath(): string { + return path.join(Storage.getGlobalQwenDir(), 'cron-jobs.json'); + } + + getProjectCronJobsPath(): string { + return path.join(this.getQwenDir(), 'cron-jobs.json'); + } + + getProjectBlockersPath(): string { + return path.join(this.getQwenDir(), 'permission-blockers.json'); + } + static getGlobalTempDir(): string { return path.join(Storage.getRuntimeBaseDir(), TMP_DIR_NAME); } diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 4e74f73fe..dd5f98792 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -165,7 +165,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -430,7 +430,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -707,7 +707,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -967,7 +967,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1227,7 +1227,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1487,7 +1487,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1747,7 +1747,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -2007,7 +2007,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -2267,7 +2267,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -2527,7 +2527,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -2810,7 +2810,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -3156,7 +3156,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -3439,7 +3439,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -3781,7 +3781,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -4041,7 +4041,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use 'list_directory' to list the contents of a directory. Prefer 'glob' when you need to find files by pattern and 'grep_search' when searching file contents — use 'list_directory' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use 'web_fetch' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over 'web_fetch'. - **LSP (Code Intelligence):** When an LSP server is active, use 'lsp' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use 'grep_search' or 'glob' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use 'cron_create' to schedule a prompt to run at a future time (one-shot or recurring). Use 'cron_list' to inspect active jobs. Use 'cron_delete' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 1803fd172..3f96d62ba 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -31,7 +31,12 @@ import { Turn, type ChatCompressionInfo, } from './turn.js'; -import { getCoreSystemPrompt, getCustomSystemPrompt } from './prompts.js'; +import { + assemblePromptSections, + getCoreSystemPrompt, + getCustomSystemPrompt, + type PromptSection, +} from './prompts.js'; import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { promptIdContext } from '../utils/promptIdContext.js'; @@ -280,6 +285,17 @@ describe('Gemini Client (client.ts)', () => { full: 'mock system prompt', }); + // assemblePromptSections is also mocked by vi.mock('./prompts'); give it a + // pass-through implementation so getMainSessionSystemInstruction() produces + // a usable string rather than undefined. + vi.mocked(assemblePromptSections).mockImplementation( + (sections: PromptSection[]) => + sections + .filter((s) => s.content) + .map((s) => s.content) + .join('\n\n'), + ); + mockGenerateContentFn = vi.fn().mockResolvedValue({ candidates: [{ content: { parts: [{ text: '{"key": "value"}' }] } }], }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 17cce8529..f449c28b7 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -23,12 +23,15 @@ const debugLogger = createDebugLogger('CLIENT'); import type { ContentGenerator } from './contentGenerator.js'; import { GeminiChat } from './geminiChat.js'; import { + assemblePromptSections, + buildCapabilityManifest, getArenaSystemReminder, getCoreSystemPrompt, getCustomSystemPrompt, getPlanModeSystemReminder, getSubagentSystemReminder, } from './prompts.js'; +import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import { CompressionStatus, GeminiEventType, @@ -280,39 +283,65 @@ export class GeminiClient { const userMemory = this.config.getUserMemory(); const overrideSystemPrompt = this.config.getSystemPrompt(); const appendSystemPrompt = this.config.getAppendSystemPrompt(); + const toolRegistry = this.config.getToolRegistry(); - // Collect instructions from MCP servers that provided them in their - // initialize response. Append each block under a labelled header so the - // model can attribute guidance to the right server. + // --- workspace: MCP server instructions --- let mcpInstructions = ''; - const toolRegistry = this.config.getToolRegistry(); if (toolRegistry) { const serverInstructions = toolRegistry.getMcpServerInstructions(); if (serverInstructions.size > 0) { const blocks = Array.from(serverInstructions.entries()) .map(([name, text]) => `## MCP Server: ${name}\n\n${text}`) .join('\n\n'); - mcpInstructions = `\n\n# MCP Server Instructions\n\n${blocks}`; + mcpInstructions = `# MCP Server Instructions\n\n${blocks}`; } } - if (overrideSystemPrompt) { - return ( - getCustomSystemPrompt( - overrideSystemPrompt, - userMemory, - appendSystemPrompt, - ) + mcpInstructions - ); + // --- workspace: capability manifest of session-specific MCP tools --- + let capabilityManifest = ''; + if (toolRegistry && typeof toolRegistry.getAllTools === 'function') { + const mcpToolsByServer = new Map(); + for (const tool of toolRegistry.getAllTools()) { + if (tool instanceof DiscoveredMCPTool) { + const existing = mcpToolsByServer.get(tool.serverName) ?? []; + existing.push(tool.name); + mcpToolsByServer.set(tool.serverName, existing); + } + } + capabilityManifest = buildCapabilityManifest(mcpToolsByServer, []) ?? ''; } - return ( - getCoreSystemPrompt( + // --- run: per-turn permission blockers --- + const blockerNote = + this.config.getPermissionBlockerService?.()?.buildPromptNote() ?? ''; + + if (overrideSystemPrompt) { + const base = getCustomSystemPrompt( + overrideSystemPrompt, userMemory, - this.config.getModel(), appendSystemPrompt, - ).full + mcpInstructions + ); + return assemblePromptSections([ + { volatility: 'stable', content: base }, + { volatility: 'workspace', content: mcpInstructions }, + { volatility: 'workspace', content: capabilityManifest }, + { volatility: 'run', content: blockerNote }, + ]); + } + + const corePrompt = getCoreSystemPrompt( + userMemory, + this.config.getModel(), + appendSystemPrompt, ); + + return assemblePromptSections([ + { volatility: 'stable', content: corePrompt.staticPrefix }, + { volatility: 'workspace', content: corePrompt.dynamicSuffix }, + { volatility: 'workspace', content: mcpInstructions }, + { volatility: 'workspace', content: capabilityManifest }, + { volatility: 'run', content: blockerNote }, + ]); } async startChat(extraHistory?: Content[]): Promise { diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 1401e508e..c3322fe01 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -1195,6 +1195,10 @@ export class CoreToolScheduler { const cancelMessage = payload?.cancelMessage || 'User did not allow tool call'; this.setStatusInternal(callId, 'cancelled', cancelMessage); + // Record the denial so persistent blockers can warn the agent next session + this.config + .getPermissionBlockerService?.() + ?.recordDenial(toolCall.request.name); } else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { const waitingToolCall = toolCall as WaitingToolCall; if (isModifiableDeclarativeTool(waitingToolCall.tool)) { diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 6cb483c05..3f018b37a 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -1138,6 +1138,50 @@ describe('OpenAIContentConverter', () => { const parts = chunk.candidates?.[0]?.content?.parts; expect(parts).toEqual([]); }); + + it('should drop malformed streaming tool_calls and emit a recovery text part', () => { + // Reproduces the upstream vLLM-with-broken-chat-template failure mode + // where tool_call.function.arguments chunks contain prose interleaved + // with partial JSON. Emitting such a tool call would corrupt the + // conversation history (downstream provider Pydantic validator fails) + // and the tool would be invoked with empty args. + const chunk = converter.convertOpenAIChunkToGemini({ + object: 'chat.completion.chunk', + id: 'chunk-malformed', + created: 789, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_bad', + type: 'function', + function: { + name: 'Read', + arguments: + 'Current state — research in progress{"file_path": and more prose without closure', + }, + }, + ], + }, + finish_reason: 'tool_calls', + logprobs: null, + }, + ], + model: 'qwen-test', + } as unknown as OpenAI.Chat.ChatCompletionChunk); + + const parts = chunk.candidates?.[0]?.content?.parts ?? []; + expect(parts.some((p) => 'functionCall' in p)).toBe(false); + const textPart = parts.find( + (p) => 'text' in p && typeof p.text === 'string', + ) as { text: string } | undefined; + expect(textPart).toBeDefined(); + expect(textPart!.text).toMatch(/tool_call dropped/); + expect(textPart!.text).toContain('Read'); + }); }); describe('inline tag parsing (Minimax / QwQ style)', () => { diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 316763d70..c02611d98 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -1151,17 +1151,29 @@ export class OpenAIContentConverter { this.streamingToolCallParser.getCompletedToolCalls(); for (const toolCall of completedToolCalls) { - if (toolCall.name) { + if (!toolCall.name) continue; + + // Drop malformed tool calls — emitting them with empty args would + // both fail the actual tool invocation and poison the conversation + // history, causing downstream provider (LiteLLM/Pydantic) failures + // on subsequent turns. Surface a visible text note so the model + // can recover instead of silently stalling. + if (toolCall.malformed) { parts.push({ - functionCall: { - id: - toolCall.id || - `call_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, - name: toolCall.name, - args: toolCall.args, - }, + text: `[tool_call dropped: upstream streamed malformed JSON arguments for \`${toolCall.name}\`. Retry the call with properly-formed arguments.]`, }); + continue; } + + parts.push({ + functionCall: { + id: + toolCall.id || + `call_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + name: toolCall.name, + args: toolCall.args, + }, + }); } // Clear the parser for the next stream diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts index 1735097be..13df4049c 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts @@ -382,6 +382,25 @@ describe('StreamingToolCallParser', () => { expect(completed).toHaveLength(1); // jsonrepair should fix the malformed JSON by setting invalid to null expect(completed[0].args).toEqual({ valid: 'data', invalid: null }); + expect(completed[0].malformed).toBeUndefined(); + }); + + it('should flag unrecoverable tool call arguments as malformed', () => { + // Reproduces the upstream "interleaved content + arguments" failure + // mode (vLLM serving Qwen with a broken chat template): the accumulated + // buffer contains prose mixed with partial JSON, which neither JSON.parse + // nor jsonrepair can resolve into an object. + parser.addChunk( + 0, + 'Current state — research in progress{"file_path": and more prose without closure', + 'call_1', + 'Read', + ); + + const completed = parser.getCompletedToolCalls(); + expect(completed).toHaveLength(1); + expect(completed[0].malformed).toBe(true); + expect(completed[0].args).toEqual({}); }); it('should not return tool calls without function name', () => { diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts index 19a659ab3..91b392771 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { safeJsonParse } from '../../utils/safeJsonParse.js'; +import { jsonrepair } from 'jsonrepair'; +import { createDebugLogger } from '../../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('STREAMING_TOOL_CALL_PARSER'); /** * Type definition for the result of parsing a JSON chunk in tool calls @@ -238,42 +241,26 @@ export class StreamingToolCallParser { name?: string; args: Record; index: number; + malformed?: boolean; }> { const completed: Array<{ id?: string; name?: string; args: Record; index: number; + malformed?: boolean; }> = []; for (const [index, buffer] of this.buffers.entries()) { const meta = this.toolCallMeta.get(index); if (meta?.name && buffer.trim()) { - let args: Record = {}; - - // Try to parse the final buffer - try { - args = JSON.parse(buffer); - } catch { - // Try with repair (auto-close strings) - const inString = this.inStrings.get(index); - if (inString) { - try { - args = JSON.parse(buffer + '"'); - } catch { - // If all parsing fails, use safeJsonParse as fallback - args = safeJsonParse(buffer, {}); - } - } else { - args = safeJsonParse(buffer, {}); - } - } - + const { args, malformed } = this.parseArgsBuffer(index, buffer); completed.push({ id: meta.id, name: meta.name, args, index, + ...(malformed ? { malformed: true } : {}), }); } } @@ -281,6 +268,52 @@ export class StreamingToolCallParser { return completed; } + /** + * Parse an accumulated arguments buffer with escalating recovery strategies. + * Returns `malformed: true` when every strategy fails — callers should treat + * these as poison and drop the tool call rather than emit it with empty args, + * which would otherwise corrupt conversation history (the upstream `vLLM + + * broken Qwen chat template` failure mode that interleaves content into + * `tool_calls.function.arguments`). + */ + private parseArgsBuffer( + index: number, + buffer: string, + ): { args: Record; malformed: boolean } { + try { + return { args: JSON.parse(buffer), malformed: false }; + } catch { + // fall through + } + + if (this.inStrings.get(index)) { + try { + return { args: JSON.parse(buffer + '"'), malformed: false }; + } catch { + // fall through + } + } + + try { + const parsed = JSON.parse(jsonrepair(buffer)); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { args: parsed as Record, malformed: false }; + } + } catch { + // fall through + } + + debugLogger.error( + 'Failed to parse tool_call arguments; marking malformed', + { + index, + bufferPreview: buffer.slice(0, 200), + bufferLength: buffer.length, + }, + ); + return { args: {}, malformed: true }; + } + /** * Finds the next available index for a new tool call * diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 0b6991fbe..95f357781 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -38,6 +38,46 @@ export interface StructuredSystemPrompt { */ export const CACHE_BOUNDARY_SENTINEL = '\n__CACHE_BOUNDARY__\n'; +/** + * Volatility tiers for prompt sections. + * + * - `stable` — core identity and instructions; never changes between turns. + * Suitable for prompt caching. + * - `workspace` — project/session-level context (MCP instructions, capability + * manifest, user memory). Changes when the workspace or tool set + * changes, but not on every turn. + * - `run` — per-turn content (permission blockers, transient reminders). + * Always placed after the cache boundary. + */ +export type PromptVolatility = 'stable' | 'workspace' | 'run'; + +export interface PromptSection { + volatility: PromptVolatility; + content: string; +} + +/** + * Assembles tagged prompt sections into a single string. + * + * Sections are ordered: stable → workspace → run. The `CACHE_BOUNDARY_SENTINEL` + * is inserted between the last stable section and the first non-stable section + * so providers can apply prompt-cache control to the stable prefix only. + */ +export function assemblePromptSections(sections: PromptSection[]): string { + const stable = sections.filter((s) => s.volatility === 'stable' && s.content); + const workspace = sections.filter( + (s) => s.volatility === 'workspace' && s.content, + ); + const run = sections.filter((s) => s.volatility === 'run' && s.content); + + const stablePart = stable.map((s) => s.content).join('\n\n'); + const nonStable = [...workspace, ...run].map((s) => s.content).join('\n\n'); + + if (!stablePart) return nonStable; + if (!nonStable) return stablePart; + return `${stablePart}${CACHE_BOUNDARY_SENTINEL}${nonStable}`; +} + export function resolvePathFromEnv(envVar?: string): { isSwitch: boolean; value: string | null; @@ -337,7 +377,7 @@ When you encounter an obstacle, do not resort to destructive shortcuts. Investig - **Directory Listing:** Use '${ToolNames.LS}' to list the contents of a directory. Prefer '${ToolNames.GLOB}' when you need to find files by pattern and '${ToolNames.GREP}' when searching file contents — use '${ToolNames.LS}' only when you need to enumerate a directory's direct children. - **Web Fetch:** Use '${ToolNames.WEB_FETCH}' to retrieve and analyze content from a URL. Pass a focused prompt describing what to extract. If an MCP-provided web tool is available (any tool starting with \`mcp__\`), prefer it over '${ToolNames.WEB_FETCH}'. - **LSP (Code Intelligence):** When an LSP server is active, use '${ToolNames.LSP}' as the primary tool for code intelligence queries (go-to-definition, find-references, hover, symbols, diagnostics). Do NOT use '${ToolNames.GREP}' or '${ToolNames.GLOB}' for these queries when LSP is available — LSP results are semantically accurate where text search is not. -- **Scheduled Jobs (Cron):** Use '${ToolNames.CRON_CREATE}' to schedule a prompt to run at a future time (one-shot or recurring). Use '${ToolNames.CRON_LIST}' to inspect active jobs. Use '${ToolNames.CRON_DELETE}' to cancel a job by ID. Jobs are session-only — they are gone when the session ends. Recurring jobs auto-expire after 3 days. +- **Scheduled Jobs (Cron):** Use '${ToolNames.CRON_CREATE}' to schedule a prompt to run at a future time (one-shot or recurring). Use '${ToolNames.CRON_LIST}' to inspect active jobs. Use '${ToolNames.CRON_DELETE}' to cancel a job by ID. Jobs persist across sessions and are saved to disk. Recurring jobs auto-expire after 3 days. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1221,3 +1261,40 @@ Call respond_in_schema function with A VALID JSON OBJECT as argument: export function getInsightPrompt(type: InsightPromptType): string { return INSIGHT_PROMPTS[type]; } + +/** + * Builds a compact capability manifest from the active tool registry. + * Focuses on session-variable content (MCP tools, skills) that the agent + * might not know about without explicit enumeration. + * + * Returns null when there is nothing session-specific to report. + */ +export function buildCapabilityManifest( + mcpToolsByServer: Map, + activeSkills: Array<{ name: string; description: string }>, +): string | null { + const sections: string[] = []; + + if (mcpToolsByServer.size > 0) { + const lines: string[] = ['**MCP tools available this session:**']; + for (const [server, tools] of mcpToolsByServer) { + lines.push(` • ${server}: ${tools.join(', ')}`); + } + sections.push(lines.join('\n')); + } + + if (activeSkills.length > 0) { + const lines: string[] = ['**Skills (invoke via /skill or agent tool):**']; + for (const skill of activeSkills) { + const desc = skill.description + ? ` — ${skill.description.slice(0, 80)}${skill.description.length > 80 ? '…' : ''}` + : ''; + lines.push(` • /${skill.name}${desc}`); + } + sections.push(lines.join('\n')); + } + + if (sections.length === 0) return null; + + return `# Active Capabilities\n\n${sections.join('\n\n')}`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 902cbe05e..37342b957 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -115,6 +115,7 @@ export * from './tools/cron-delete.js'; export * from './services/chatRecordingService.js'; export * from './services/cronScheduler.js'; +export { runEvolvePass } from './services/evolveService.js'; export * from './services/fileDiscoveryService.js'; export * from './services/fileSystemService.js'; export * from './services/gitService.js'; diff --git a/packages/core/src/memory/index.ts b/packages/core/src/memory/index.ts index 6506c0f32..ff08e7aae 100644 --- a/packages/core/src/memory/index.ts +++ b/packages/core/src/memory/index.ts @@ -38,3 +38,10 @@ export { type ScannedMemory, } from './memoryScan.js'; export { extractMemories } from './memoryExtractor.js'; +export { + getProposalsDir, + listProposals, + acceptProposal, + rejectProposal, + PROPOSALS_DIR_NAME, +} from './proposalStore.js'; diff --git a/packages/core/src/memory/memoryAge.ts b/packages/core/src/memory/memoryAge.ts index 4a9c4088d..892e57fc7 100644 --- a/packages/core/src/memory/memoryAge.ts +++ b/packages/core/src/memory/memoryAge.ts @@ -4,8 +4,31 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { MemoryType } from './types.js'; + const DAY_MS = 86_400_000; +/** + * Staleness thresholds (in days) per memory type. + * null = never stale (stable across time). + */ +export const STALENESS_THRESHOLDS: Record = { + user: null, + feedback: null, + project: 21, + reference: 7, +}; + +/** + * Verification guidance per memory type shown when stale. + */ +const VERIFICATION_GUIDANCE: Record = { + user: '', + feedback: '', + project: 'verify this is still current before acting', + reference: 're-confirm this reference before using it', +}; + /** * Format a timestamp as a human-readable age string. */ @@ -23,16 +46,39 @@ export function formatAge(mtimeMs: number): string { } /** - * Check if a memory is stale (older than threshold). + * Check if a memory is stale based on its type's threshold. + * Returns false for types with no staleness threshold. */ -export function isStale(mtimeMs: number, thresholdDays = 30): boolean { - return Date.now() - mtimeMs > thresholdDays * DAY_MS; +export function isStale( + mtimeMs: number, + type?: MemoryType, + thresholdDays?: number, +): boolean { + let threshold = thresholdDays; + if (threshold === undefined) { + if (type) { + const typedThreshold = STALENESS_THRESHOLDS[type]; + if (typedThreshold === null) return false; + threshold = typedThreshold; + } else { + threshold = 30; + } + } + return Date.now() - mtimeMs > threshold * DAY_MS; } /** - * Get a staleness warning for old memories. Returns null for fresh ones. + * Get a staleness warning for a memory entry. + * Returns null when the memory is fresh or has no staleness threshold. */ -export function getStaleWarning(mtimeMs: number): string | null { - if (!isStale(mtimeMs)) return null; - return `(${formatAge(mtimeMs)} — may be outdated, verify before acting)`; +export function getStaleWarning( + mtimeMs: number, + type?: MemoryType, +): string | null { + if (!isStale(mtimeMs, type)) return null; + const age = formatAge(mtimeMs); + const guidance = type ? VERIFICATION_GUIDANCE[type] : ''; + return guidance + ? `(last updated ${age} — ${guidance})` + : `(last updated ${age} — may be outdated)`; } diff --git a/packages/core/src/memory/memoryExtractor.ts b/packages/core/src/memory/memoryExtractor.ts index 72ebde62b..0dd60deec 100644 --- a/packages/core/src/memory/memoryExtractor.ts +++ b/packages/core/src/memory/memoryExtractor.ts @@ -17,11 +17,8 @@ import type { ToolConfig, } from '../agents/runtime/agent-types.js'; import { ToolNames } from '../tools/tool-names.js'; -import { - getMemoryDir, - regenerateIndex, - scanMemoryHeaders, -} from './memoryStore.js'; +import { getMemoryDir, scanMemoryHeaders } from './memoryStore.js'; +import { getProposalsDir } from './proposalStore.js'; import { formatMemoryManifest } from './memoryScan.js'; import type { MemoryScope } from './types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -38,28 +35,28 @@ const MAX_EXTRACTOR_MINUTES = 1; function buildExtractionPrompt( newMessageCount: number, existingMemories: string, - memoryDir: string, + proposalsDir: string, ): string { - return `You are a memory extraction agent. Analyze the last ~${newMessageCount} messages and persist useful facts to ${memoryDir}. + return `You are a memory extraction agent. Analyze the last ~${newMessageCount} messages and write PROPOSALS for useful facts to ${proposalsDir}. ## Strict 2-Turn Budget - **Turn 1:** Issue ALL read_file and glob calls in parallel to gather existing memory state. -- **Turn 2:** Issue ALL write_file calls in parallel to create/update memories. Then stop. +- **Turn 2:** Issue ALL write_file calls in parallel to create proposal files. Then stop. Do NOT interleave reads and writes. Do NOT use more than 2 turns. ## What to Extract (from last ~${newMessageCount} messages ONLY) Do NOT investigate source code or verify facts. Only record what was discussed. -Save when: +Propose when: - User explicitly asks to remember something - User states a preference, role, or personal fact (type: user) - User corrects your approach or confirms a non-obvious method (type: feedback) - A deadline, decision, or project-specific fact is mentioned (type: project) - An external system or resource URL is referenced (type: reference) -Do NOT save: code patterns derivable from reading code, git history, debugging solutions already in the repo, anything in PROTO.md/AGENTS.md, ephemeral task details. +Do NOT propose: code patterns derivable from reading code, git history, debugging solutions already in the repo, anything in PROTO.md/AGENTS.md, ephemeral task details. -## Memory File Format +## Proposal File Format \`\`\`markdown --- name: short-kebab-name @@ -70,12 +67,13 @@ type: user|feedback|project|reference The actual memory content here. \`\`\` -Save as \`{type}_{name}.md\` in ${memoryDir}. +Save as \`{type}_{name}.md\` in ${proposalsDir}. +Note: These are PROPOSALS pending user approval. The user will review them with /memory proposals and accept or reject each one. -## Existing Memories +## Existing Memories (do not duplicate) ${existingMemories} -Check before creating. Update existing if similar. If nothing worth saving, stop immediately.`; +Check before creating. If nothing worth proposing, stop immediately.`; } /** @@ -120,7 +118,7 @@ export async function extractMemories( scope: MemoryScope = 'project', ): Promise { const cwd = config.getProjectRoot(); - const memoryDir = getMemoryDir(scope, cwd); + const proposalsDir = getProposalsDir(scope, cwd); // Read cursor to determine how many new messages const cursor = await getCursor(scope, cwd); @@ -133,6 +131,9 @@ export async function extractMemories( return; } + // Ensure proposals directory exists + await fs.mkdir(proposalsDir, { recursive: true }); + // Scan existing memories to include in the prompt const existingHeaders = await scanMemoryHeaders(scope, cwd); const manifest = formatMemoryManifest(existingHeaders); @@ -140,7 +141,7 @@ export async function extractMemories( const systemPrompt = buildExtractionPrompt( newMessageCount, manifest, - memoryDir, + proposalsDir, ); const promptConfig: PromptConfig = { @@ -167,13 +168,13 @@ export async function extractMemories( ); const context = new ContextState(); - context.set('memoryDir', memoryDir); + context.set('proposalsDir', proposalsDir); context.set('newMessageCount', newMessageCount); await agent.execute(context); - // Regenerate the index after extraction - await regenerateIndex(scope, cwd); + // Do NOT regenerate the main index — proposals require user acceptance first. + // The index is updated when the user accepts proposals via /memory accept. // Advance cursor await setCursor(scope, messageCount, cwd); diff --git a/packages/core/src/memory/memoryStore.ts b/packages/core/src/memory/memoryStore.ts index df0d49d7d..c5fd3b769 100644 --- a/packages/core/src/memory/memoryStore.ts +++ b/packages/core/src/memory/memoryStore.ts @@ -23,6 +23,7 @@ import { FRONTMATTER_MAX_LINES, } from './types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { getStaleWarning } from './memoryAge.js'; const logger = createDebugLogger('MEMORY_STORE'); @@ -272,7 +273,9 @@ export async function regenerateIndex( for (const mem of memories) { const filename = path.basename(mem.filePath); - const line = `- [${mem.header.name}](${filename}) — ${mem.header.description}`; + const staleWarning = getStaleWarning(mem.mtimeMs, mem.header.type); + const stalenessTag = staleWarning ? ` ${staleWarning}` : ''; + const line = `- [${mem.header.name}](${filename}) — ${mem.header.description}${stalenessTag}`; lines.push(line); } diff --git a/packages/core/src/memory/proposalStore.ts b/packages/core/src/memory/proposalStore.ts new file mode 100644 index 000000000..29adb0a42 --- /dev/null +++ b/packages/core/src/memory/proposalStore.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 protoLabs + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { parseFrontmatter } from './frontmatterParser.js'; +import { getMemoryDir, regenerateIndex } from './memoryStore.js'; +import type { MemoryFile, MemoryScope } from './types.js'; +import { ENTRYPOINT_NAME, FRONTMATTER_MAX_LINES } from './types.js'; + +export const PROPOSALS_DIR_NAME = 'proposals'; + +/** + * Returns the proposals directory for a given scope. + */ +export function getProposalsDir(scope: MemoryScope, cwd?: string): string { + return path.join(getMemoryDir(scope, cwd), PROPOSALS_DIR_NAME); +} + +/** + * List all pending proposal files in the proposals directory. + */ +export async function listProposals( + scope: MemoryScope, + cwd?: string, +): Promise { + const proposalsDir = getProposalsDir(scope, cwd); + let entries: string[]; + + try { + entries = await fs.readdir(proposalsDir); + } catch { + return []; + } + + const mdFiles = entries.filter( + (f) => f.endsWith('.md') && f !== ENTRYPOINT_NAME, + ); + + const results: MemoryFile[] = []; + await Promise.allSettled( + mdFiles.map(async (filename) => { + const filePath = path.join(proposalsDir, filename); + try { + const handle = await fs.open(filePath, 'r'); + try { + const buf = Buffer.alloc(4096); + const { bytesRead } = await handle.read(buf, 0, 4096, 0); + const raw = buf.toString('utf-8', 0, bytesRead); + const lines = raw.split('\n').slice(0, FRONTMATTER_MAX_LINES); + const { header, body } = parseFrontmatter(lines.join('\n')); + if (header) { + const stat = await fs.stat(filePath); + results.push({ + header, + content: body, + filePath, + mtimeMs: stat.mtimeMs, + }); + } + } finally { + await handle.close(); + } + } catch { + // Skip unreadable files + } + }), + ); + + results.sort((a, b) => b.mtimeMs - a.mtimeMs); + return results; +} + +/** + * Accept a proposal by moving it from proposals/ to the memory directory. + * Returns the path of the accepted file, or null if the source does not exist. + */ +export async function acceptProposal( + proposalFilePath: string, + scope: MemoryScope, + cwd?: string, +): Promise { + const memoryDir = getMemoryDir(scope, cwd); + const destPath = path.join(memoryDir, path.basename(proposalFilePath)); + + try { + await fs.mkdir(memoryDir, { recursive: true }); + await fs.rename(proposalFilePath, destPath); + await regenerateIndex(scope, cwd); + return destPath; + } catch { + return null; + } +} + +/** + * Reject (delete) a proposal file. + */ +export async function rejectProposal( + proposalFilePath: string, +): Promise { + try { + await fs.unlink(proposalFilePath); + return true; + } catch { + return false; + } +} diff --git a/packages/core/src/services/cronScheduler.test.ts b/packages/core/src/services/cronScheduler.test.ts index b6f9bf681..11b0edf26 100644 --- a/packages/core/src/services/cronScheduler.test.ts +++ b/packages/core/src/services/cronScheduler.test.ts @@ -242,7 +242,9 @@ describe('CronScheduler', () => { it('returns summary with single job', () => { scheduler.create('*/5 * * * *', 'check the build', true); const summary = scheduler.getExitSummary()!; - expect(summary).toContain('1 active loop cancelled:'); + expect(summary).toContain( + '1 active loop saved (will resume next session):', + ); expect(summary).toContain('Every 5 minutes'); expect(summary).toContain('check the build'); }); @@ -251,7 +253,9 @@ describe('CronScheduler', () => { scheduler.create('*/5 * * * *', 'check the build', true); scheduler.create('*/30 * * * *', 'check PR reviews', true); const summary = scheduler.getExitSummary()!; - expect(summary).toContain('2 active loops cancelled:'); + expect(summary).toContain( + '2 active loops saved (will resume next session):', + ); expect(summary).toContain('check the build'); expect(summary).toContain('check PR reviews'); }); diff --git a/packages/core/src/services/cronScheduler.ts b/packages/core/src/services/cronScheduler.ts index 9685dde5b..3779cc778 100644 --- a/packages/core/src/services/cronScheduler.ts +++ b/packages/core/src/services/cronScheduler.ts @@ -1,8 +1,10 @@ /** - * In-session cron scheduler. Jobs live in memory and are gone when the - * process exits. Ticks every second, fires callbacks when jobs are due. + * Cron scheduler with optional disk persistence. When a persistPath is + * provided, jobs survive process restarts; expired jobs are pruned on load. */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { matches, nextFireTime } from '../utils/cronParser.js'; import { humanReadableCron } from '../utils/cronDisplay.js'; @@ -92,6 +94,44 @@ export class CronScheduler { private jobs = new Map(); private timer: ReturnType | null = null; private onFire: ((job: CronJob) => void) | null = null; + private readonly persistPath: string | null; + + constructor(persistPath?: string) { + this.persistPath = persistPath ?? null; + if (this.persistPath) { + this.loadFromDisk(); + } + } + + private loadFromDisk(): void { + if (!this.persistPath) return; + try { + const raw = fs.readFileSync(this.persistPath, 'utf-8'); + const jobs = JSON.parse(raw) as CronJob[]; + const now = Date.now(); + for (const job of jobs) { + if (now < job.expiresAt) { + this.jobs.set(job.id, job); + } + } + } catch { + // File doesn't exist or is corrupt — start with empty store + } + } + + private saveToDisk(): void { + if (!this.persistPath) return; + try { + fs.mkdirSync(path.dirname(this.persistPath), { recursive: true }); + fs.writeFileSync( + this.persistPath, + JSON.stringify([...this.jobs.values()], null, 2), + 'utf-8', + ); + } catch { + // Non-fatal — session continues without persistence + } + } /** * Creates a new cron job. Returns the created job. @@ -119,6 +159,7 @@ export class CronScheduler { }; this.jobs.set(id, job); + this.saveToDisk(); return job; } @@ -126,7 +167,9 @@ export class CronScheduler { * Deletes a job by ID. Returns true if the job existed. */ delete(id: string): boolean { - return this.jobs.delete(id); + const existed = this.jobs.delete(id); + if (existed) this.saveToDisk(); + return existed; } /** @@ -245,6 +288,8 @@ export class CronScheduler { this.jobs.delete(job.id); } + this.saveToDisk(); + if (this.onFire) { this.onFire(job); } @@ -260,7 +305,7 @@ export class CronScheduler { const count = this.jobs.size; const lines = [ - `Session ending. ${count} active loop${count === 1 ? '' : 's'} cancelled:`, + `Session ending. ${count} active loop${count === 1 ? '' : 's'} saved (will resume next session):`, ]; for (const job of this.jobs.values()) { const schedule = humanReadableCron(job.cronExpr); diff --git a/packages/core/src/services/evolveService.ts b/packages/core/src/services/evolveService.ts new file mode 100644 index 000000000..ee9246079 --- /dev/null +++ b/packages/core/src/services/evolveService.ts @@ -0,0 +1,190 @@ +/** + * Post-turn evolve pipeline. + * + * Every SKILL_REVIEW_INTERVAL turns, a lightweight background agent analyzes + * recent conversation history to detect reusable workflow patterns. When found, + * it drafts a SKILL.md candidate in .proto/evolve/skills/ and queues it as a + * pending memory proposal for user review. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { Config } from '../config/config.js'; +import { + AgentHeadless, + ContextState, +} from '../agents/runtime/agent-headless.js'; +import type { + PromptConfig, + RunConfig, + ToolConfig, +} from '../agents/runtime/agent-types.js'; +import { ToolNames } from '../tools/tool-names.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { getProposalsDir } from '../memory/proposalStore.js'; +import { QWEN_DIR } from '../config/storage.js'; + +const logger = createDebugLogger('EVOLVE_SERVICE'); + +const SKILL_REVIEW_INTERVAL = 3; +const MAX_EVOLVE_TURNS = 2; +const MAX_EVOLVE_MINUTES = 2; +const MIN_CONFIDENCE_WORDS = 30; +const EVOLVE_DIR_NAME = 'evolve'; + +export const EVOLVE_SKILLS_DIR = path.join(QWEN_DIR, EVOLVE_DIR_NAME, 'skills'); + +let turnsSinceLastReview = 0; + +/** + * Call after each agent turn completes. Runs skill candidate detection every + * SKILL_REVIEW_INTERVAL turns. Fire-and-forget; errors are logged only. + */ +export async function runEvolvePass( + config: Config, + recentMessages: Array<{ role: string; text: string }>, +): Promise { + turnsSinceLastReview++; + if (turnsSinceLastReview < SKILL_REVIEW_INTERVAL) return; + turnsSinceLastReview = 0; + + if (recentMessages.length < 4) return; + + const projectRoot = config.getProjectRoot(); + const evolveSkilsDir = path.join( + projectRoot, + QWEN_DIR, + EVOLVE_DIR_NAME, + 'skills', + ); + const proposalsDir = getProposalsDir('project', projectRoot); + + await fs.mkdir(evolveSkilsDir, { recursive: true }).catch(() => {}); + await fs.mkdir(proposalsDir, { recursive: true }).catch(() => {}); + + const recentText = recentMessages + .slice(-10) + .map((m) => `[${m.role}]: ${m.text.slice(0, 300)}`) + .join('\n\n'); + + const existingSkills = await getExistingSkillNames(projectRoot); + + const systemPrompt = buildEvolvePrompt( + recentText, + existingSkills, + evolveSkilsDir, + proposalsDir, + ); + + const promptConfig: PromptConfig = { systemPrompt }; + const runConfig: RunConfig = { + max_turns: MAX_EVOLVE_TURNS, + max_time_minutes: MAX_EVOLVE_MINUTES, + }; + const toolConfig: ToolConfig = { + tools: [ToolNames.WRITE_FILE, ToolNames.READ_FILE], + }; + + try { + const agent = await AgentHeadless.create( + 'evolve-skill-detector', + config, + promptConfig, + { model: config.getModel() }, + runConfig, + toolConfig, + ); + + const context = new ContextState(); + context.set('evolveSkilsDir', evolveSkilsDir); + context.set('proposalsDir', proposalsDir); + + await agent.execute(context); + logger.debug('Evolve pass complete'); + } catch (err) { + logger.debug('Evolve pass skipped:', err); + } +} + +async function getExistingSkillNames(projectRoot: string): Promise { + const skillsDir = path.join(projectRoot, QWEN_DIR, 'skills'); + try { + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch { + return []; + } +} + +function buildEvolvePrompt( + recentText: string, + existingSkills: string[], + evolveSkilsDir: string, + proposalsDir: string, +): string { + const skillList = + existingSkills.length > 0 ? existingSkills.join(', ') : '(none yet)'; + + return `You are a skill evolution agent. Analyze the recent conversation and decide whether a reusable workflow pattern was demonstrated. + +## Task (2 turns max) + +Turn 1: Analyze the conversation below. Decide: was there a multi-step workflow the user or agent repeated, or a pattern worth encoding as a reusable /skill? + +**Criteria for YES:** +- A workflow requiring 3+ steps that could be templatized +- Something the user explicitly asked for by name ("run the usual deploy sequence", "do the PR review") +- A sequence that would save time if it had a name and could be re-invoked + +**Criteria for NO:** +- One-off tasks with no generalization value +- Work that's already covered by an existing skill: ${skillList} +- Pure code changes with no reusable process pattern + +If NO: output only the word "SKIP" and stop. Do NOT write any files. + +If YES (confidence: you can write at least ${MIN_CONFIDENCE_WORDS} words describing the skill): + +Turn 2: Write TWO files: + +**File 1** — Draft skill at ${evolveSkilsDir}//SKILL.md +Use kebab-case slug from the skill name. +Format: +\`\`\`markdown +--- +name: +description: +--- + +# / + +## When to use +<1-2 sentences> + +## Steps + + +## Examples +<1-2 concrete examples> +\`\`\` + +**File 2** — Proposal at ${proposalsDir}/feedback_skill-candidate-.md +\`\`\`markdown +--- +name: skill-candidate- +description: Skill candidate detected from recent work — review and promote if useful +type: feedback +--- + +A skill candidate was detected from recent conversation turns. + +**Skill name:** / +**Draft location:** ${evolveSkilsDir}//SKILL.md + +To promote: copy the SKILL.md to .proto/skills//SKILL.md +To dismiss: delete the draft file and reject this proposal. +\`\`\` + +## Recent conversation +${recentText}`; +} diff --git a/packages/core/src/services/memory-consolidation.ts b/packages/core/src/services/memory-consolidation.ts index 4e8e19848..6dc895302 100644 --- a/packages/core/src/services/memory-consolidation.ts +++ b/packages/core/src/services/memory-consolidation.ts @@ -150,7 +150,7 @@ export class MemoryConsolidationService { // Phase 1: Prune stale entries (>90 days) const staleFiles = memories.filter((m) => - isStale(m.mtimeMs, STALE_THRESHOLD_DAYS), + isStale(m.mtimeMs, undefined, STALE_THRESHOLD_DAYS), ); for (const stale of staleFiles) { await deleteMemory(stale.filePath, scope, this.projectDir); @@ -159,7 +159,7 @@ export class MemoryConsolidationService { } const remaining = memories.filter( - (m) => !isStale(m.mtimeMs, STALE_THRESHOLD_DAYS), + (m) => !isStale(m.mtimeMs, undefined, STALE_THRESHOLD_DAYS), ); if (remaining.length < 2) { diff --git a/packages/core/src/services/permissionBlockerService.ts b/packages/core/src/services/permissionBlockerService.ts new file mode 100644 index 000000000..bf3fcbc60 --- /dev/null +++ b/packages/core/src/services/permissionBlockerService.ts @@ -0,0 +1,109 @@ +/** + * Tracks repeatedly denied tool calls and persists them across sessions so the + * agent can avoid re-attempting blocked actions. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const DENY_THRESHOLD = 2; + +interface DenialRecord { + count: number; + lastSeenAt: number; +} + +interface BlockerStore { + denials: Record; +} + +export class PermissionBlockerService { + private denials = new Map(); + private readonly persistPath: string; + + constructor(persistPath: string) { + this.persistPath = persistPath; + this.loadFromDisk(); + } + + private loadFromDisk(): void { + try { + const raw = fs.readFileSync(this.persistPath, 'utf-8'); + const store = JSON.parse(raw) as BlockerStore; + for (const [key, rec] of Object.entries(store.denials ?? {})) { + this.denials.set(key, rec); + } + } catch { + // File doesn't exist yet — start with empty store + } + } + + private saveToDisk(): void { + try { + fs.mkdirSync(path.dirname(this.persistPath), { recursive: true }); + const store: BlockerStore = { + denials: Object.fromEntries(this.denials.entries()), + }; + fs.writeFileSync( + this.persistPath, + JSON.stringify(store, null, 2), + 'utf-8', + ); + } catch { + // Non-fatal + } + } + + /** + * Record a user-initiated denial for the given tool. + * Once the denial count reaches the threshold the entry is persisted. + */ + recordDenial(toolName: string): void { + const existing = this.denials.get(toolName) ?? { count: 0, lastSeenAt: 0 }; + const updated: DenialRecord = { + count: existing.count + 1, + lastSeenAt: Date.now(), + }; + this.denials.set(toolName, updated); + if (updated.count >= DENY_THRESHOLD) { + this.saveToDisk(); + } + } + + /** + * Returns tool names that have been denied at or above the threshold. + */ + getBlockedTools(): Array<{ tool: string; count: number }> { + const result: Array<{ tool: string; count: number }> = []; + for (const [tool, rec] of this.denials.entries()) { + if (rec.count >= DENY_THRESHOLD) { + result.push({ tool, count: rec.count }); + } + } + return result.sort((a, b) => b.count - a.count); + } + + /** + * Returns a system prompt note listing blocked tools, or null if none. + */ + buildPromptNote(): string | null { + const blocked = this.getBlockedTools(); + if (blocked.length === 0) return null; + const list = blocked + .map(({ tool, count }) => ` • ${tool} (denied ${count}x)`) + .join('\n'); + return `# Previously Blocked Actions\n\nThe user has repeatedly denied these tools in past sessions. Ask before attempting them again or propose an alternative:\n${list}`; + } + + /** + * Clears all denial records (for the current tool name or all). + */ + clear(toolName?: string): void { + if (toolName) { + this.denials.delete(toolName); + } else { + this.denials.clear(); + } + this.saveToDisk(); + } +} diff --git a/packages/core/src/telemetry/sdk.test.ts b/packages/core/src/telemetry/sdk.test.ts index 9dc811c7e..1273bd8f5 100644 --- a/packages/core/src/telemetry/sdk.test.ts +++ b/packages/core/src/telemetry/sdk.test.ts @@ -282,5 +282,23 @@ describe('Telemetry SDK', () => { // Should still start the SDK because Langfuse processor is non-null expect(NodeSDK.prototype.start).toHaveBeenCalled(); }); + + it('does not create the default gRPC OTLP exporter when only Langfuse is configured', () => { + process.env['LANGFUSE_PUBLIC_KEY'] = 'pk-lf-test'; + process.env['LANGFUSE_SECRET_KEY'] = 'sk-lf-test'; + vi.spyOn(mockConfig, 'getTelemetryEnabled').mockReturnValue(false); + + initializeTelemetry(mockConfig); + + // The default endpoint is localhost:4317 but with telemetry disabled, + // no gRPC exporter should be instantiated (only the Langfuse HTTP one). + expect(OTLPTraceExporter).not.toHaveBeenCalled(); + expect(OTLPLogExporter).not.toHaveBeenCalled(); + expect(OTLPMetricExporter).not.toHaveBeenCalled(); + + const sdkCalls = vi.mocked(NodeSDK).mock.calls; + const spanProcessors = sdkCalls[0][0]?.spanProcessors; + expect(spanProcessors).toHaveLength(1); + }); }); }); diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index 6d91d7069..c34a3f5e2 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -126,7 +126,11 @@ export function initializeTelemetry(config: Config): void { const otlpProtocol = config.getTelemetryOtlpProtocol(); const parsedEndpoint = parseOtlpEndpoint(otlpEndpoint, otlpProtocol); const telemetryOutfile = config.getTelemetryOutfile(); - const useOtlp = !!parsedEndpoint && !telemetryOutfile; + // OTLP export requires telemetry.enabled — the endpoint alone defaults to + // localhost:4317, so without this gate a Langfuse-only user would still + // get a gRPC exporter aimed at localhost spamming ECONNREFUSED. + const useOtlp = + config.getTelemetryEnabled() && !!parsedEndpoint && !telemetryOutfile; // No destination configured — skip SDK init to avoid flooding the console. if (!useOtlp && !telemetryOutfile && !langfuse) {