diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..e69de29bb diff --git a/memAgent/cipher.yml b/memAgent/cipher.yml new file mode 100644 index 000000000..df5063682 --- /dev/null +++ b/memAgent/cipher.yml @@ -0,0 +1,15 @@ +# describes the mcp servers to use +mcpServers: + filesystem: + type: stdio + command: npx + args: + - -y + - "@modelcontextprotocol/server-filesystem" + - . +# # describes the llm configuration +llm: + provider: openai + model: o4-mini + apiKey: $OPENAI_API_KEY + maxIterations: 50 \ No newline at end of file diff --git a/examples/logger/usage.ts b/memAgent/logger/usage.ts similarity index 95% rename from examples/logger/usage.ts rename to memAgent/logger/usage.ts index 2a887b1cb..8a4ac1fa5 100644 --- a/examples/logger/usage.ts +++ b/memAgent/logger/usage.ts @@ -3,6 +3,8 @@ */ import { logger, createLogger, ChalkColor } from '../../src/core/logger/logger.js'; +import { env } from '../../src/core/env.js'; + /** * Example 1: Basic logging with the singleton logger @@ -126,8 +128,8 @@ function environmentExample(): void { console.log('\n=== Environment Variables Example ==='); console.log('Current environment settings:'); - console.log('- CIPHER_LOG_LEVEL:', process.env.CIPHER_LOG_LEVEL || 'not set (defaults to info)'); - console.log('- REDACT_SECRETS:', process.env.REDACT_SECRETS || 'not set (defaults to true)'); + console.log('- CIPHER_LOG_LEVEL:', env.CIPHER_LOG_LEVEL || 'not set (defaults to info)'); +console.log('- REDACT_SECRETS:', env.REDACT_SECRETS || 'not set (defaults to true)'); logger.info('Log level can be controlled via CIPHER_LOG_LEVEL environment variable'); logger.info('Secret redaction can be disabled via REDACT_SECRETS=false'); diff --git a/package.json b/package.json index 8dc0f9f89..e6cb08ef0 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", + "bin": { + "cipher": "./dist/src/app/index.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -19,12 +22,15 @@ "./package.json": "./package.json" }, "files": [ - "dist/" + "dist/", + "memAgent/cipher.yml" ], "scripts": { + "prebuild": "rm -rf dist", "build": "tsup", "dev": "tsc --watch", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --project tsconfig.typecheck.json", + "prepare": "husky", "test": "vitest run", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -42,30 +48,34 @@ "author": "BYTEROVER", "license": "Apache-2.0", "devDependencies": { + "@ai-sdk/openai": "^1.3.3", "@eslint/js": "^9.29.0", "@types/node": "^24.0.3", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.34.1", "@typescript-eslint/parser": "^8.34.1", "@vitest/coverage-v8": "^3.2.4", + "commander": "^11.1.0", "eslint": "^9.29.0", "eslint-config-prettier": "^10.1.5", "prettier": "^3.5.3", "ts-node": "^10.9.2", "tsup": "^8.5.0", "tsx": "^4.19.2", - "vitest": "^3.2.4", - "@ai-sdk/openai": "^1.3.3" + "vitest": "^3.2.4" }, "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", "@modelcontextprotocol/sdk": "^1.13.0", - "typescript": "^5.8.3", - "uuid": "^11.1.0", - "zod": "^3.25.67", "boxen": "^8.0.1", - "winston": "^3.17.0", "chalk": "^5.4.1", + "dotenv": "^16.6.0", + "husky": "^9.1.7", "openai": "^4.89.0", - "@anthropic-ai/sdk": "^0.39.0" + "typescript": "^5.8.3", + "uuid": "^11.1.0", + "winston": "^3.17.0", + "yaml": "^2.3.1", + "zod": "^3.25.67" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a506c13c9..721072cb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: chalk: specifier: ^5.4.1 version: 5.4.1 + dotenv: + specifier: ^16.6.0 + version: 16.6.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 openai: specifier: ^4.89.0 version: 4.104.0(zod@3.25.67) @@ -32,6 +38,9 @@ importers: winston: specifier: ^3.17.0 version: 3.17.0 + yaml: + specifier: ^2.3.1 + version: 2.8.0 zod: specifier: ^3.25.67 version: 3.25.67 @@ -56,7 +65,10 @@ importers: version: 8.35.0(eslint@9.29.0)(typescript@5.8.3) '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.0.3)(tsx@4.20.3)) + version: 3.2.4(vitest@3.2.4(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0)) + commander: + specifier: ^11.1.0 + version: 11.1.0 eslint: specifier: ^9.29.0 version: 9.29.0 @@ -71,13 +83,13 @@ importers: version: 10.9.2(@types/node@24.0.3)(typescript@5.8.3) tsup: specifier: ^8.5.0 - version: 8.5.0(postcss@8.5.6)(tsx@4.20.3)(typescript@5.8.3) + version: 8.5.0(postcss@8.5.6)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0) tsx: specifier: ^4.19.2 version: 4.20.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@24.0.3)(tsx@4.20.3) + version: 3.2.4(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0) packages: @@ -804,6 +816,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -873,6 +889,10 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + dotenv@16.6.0: + resolution: {integrity: sha512-Omf1L8paOy2VJhILjyhrhqwLIdstqm1BvcDPKg4NGAlkwEu9ODyrFbvk8UymUOMCT+HXo31jg1lArIrVAAhuGA==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1162,6 +1182,11 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -1992,6 +2017,11 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yaml@2.8.0: + resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} + engines: {node: '>= 14.6'} + hasBin: true + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -2458,7 +2488,7 @@ snapshots: '@typescript-eslint/types': 8.35.0 eslint-visitor-keys: 4.2.1 - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.0.3)(tsx@4.20.3))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -2473,7 +2503,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.0.3)(tsx@4.20.3) + vitest: 3.2.4(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0) transitivePeerDependencies: - supports-color @@ -2485,13 +2515,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.0(@types/node@24.0.3)(tsx@4.20.3))': + '@vitest/mocker@3.2.4(vite@7.0.0(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.0(@types/node@24.0.3)(tsx@4.20.3) + vite: 7.0.0(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -2698,6 +2728,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@11.1.0: {} + commander@4.1.1: {} concat-map@0.0.1: {} @@ -2743,6 +2775,8 @@ snapshots: diff@4.0.2: {} + dotenv@16.6.0: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3100,6 +3134,8 @@ snapshots: dependencies: ms: 2.1.3 + husky@9.1.7: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -3382,12 +3418,13 @@ snapshots: mlly: 1.7.4 pathe: 2.0.3 - postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.20.3): + postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.20.3)(yaml@2.8.0): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.6 tsx: 4.20.3 + yaml: 2.8.0 postcss@8.5.6: dependencies: @@ -3693,7 +3730,7 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - tsup@8.5.0(postcss@8.5.6)(tsx@4.20.3)(typescript@5.8.3): + tsup@8.5.0(postcss@8.5.6)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0): dependencies: bundle-require: 5.1.0(esbuild@0.25.5) cac: 6.7.14 @@ -3704,7 +3741,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.20.3) + postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.20.3)(yaml@2.8.0) resolve-from: 5.0.0 rollup: 4.44.0 source-map: 0.8.0-beta.0 @@ -3762,13 +3799,13 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@24.0.3)(tsx@4.20.3): + vite-node@3.2.4(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.0(@types/node@24.0.3)(tsx@4.20.3) + vite: 7.0.0(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0) transitivePeerDependencies: - '@types/node' - jiti @@ -3783,7 +3820,7 @@ snapshots: - tsx - yaml - vite@7.0.0(@types/node@24.0.3)(tsx@4.20.3): + vite@7.0.0(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0): dependencies: esbuild: 0.25.5 fdir: 6.4.6(picomatch@4.0.2) @@ -3795,12 +3832,13 @@ snapshots: '@types/node': 24.0.3 fsevents: 2.3.3 tsx: 4.20.3 + yaml: 2.8.0 - vitest@3.2.4(@types/node@24.0.3)(tsx@4.20.3): + vitest@3.2.4(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.0(@types/node@24.0.3)(tsx@4.20.3)) + '@vitest/mocker': 3.2.4(vite@7.0.0(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -3818,8 +3856,8 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.0(@types/node@24.0.3)(tsx@4.20.3) - vite-node: 3.2.4(@types/node@24.0.3)(tsx@4.20.3) + vite: 7.0.0(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0) + vite-node: 3.2.4(@types/node@24.0.3)(tsx@4.20.3)(yaml@2.8.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.0.3 @@ -3909,6 +3947,8 @@ snapshots: wrappy@1.0.2: {} + yaml@2.8.0: {} + yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/poem.txt b/poem.txt new file mode 100644 index 000000000..98f519948 --- /dev/null +++ b/poem.txt @@ -0,0 +1,14 @@ +In the hush of dawn's first light, +Silent whispers wake the sky, +Threads of gold on edges bright, +Promise woven, time slips by. + +Petals open, greet the day, +Dreams once tethered now take flight, +Hope ignites in gentle sway, +Hearts drawn to morning's light. + +Every breath a soft refrain, +Songs of longing, songs of grace, +In each moment, joy remains, +Life reborn in day's embrace. \ No newline at end of file diff --git a/src/app/cli/cli.ts b/src/app/cli/cli.ts new file mode 100644 index 000000000..f448431cd --- /dev/null +++ b/src/app/cli/cli.ts @@ -0,0 +1,104 @@ +import { MemAgent, logger } from "@core/index.js"; +import * as readline from 'readline'; +import chalk from 'chalk'; + +/** + * Start interactive CLI mode where user can continuously chat with the agent + */ +export async function startInteractiveCli(agent: MemAgent): Promise { + // Common initialization + await _initCli(agent); + + console.log(chalk.cyan('šŸš€ Welcome to Cipher Interactive CLI!')); + console.log(chalk.gray('Your memory-powered coding assistant is ready.')); + console.log(chalk.gray('Type "exit" or "quit" to end the session.\n')); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: chalk.blue('cipher> ') + }); + + // Set up graceful shutdown + const handleExit = () => { + console.log(chalk.yellow('\nšŸ‘‹ Goodbye! Your conversation has been saved to memory.')); + rl.close(); + process.exit(0); + }; + + rl.on('SIGINT', handleExit); + rl.on('SIGTERM', handleExit); + + rl.prompt(); + + rl.on('line', async (input: string) => { + const trimmedInput = input.trim(); + + // Handle exit commands + if (trimmedInput.toLowerCase() === 'exit' || trimmedInput.toLowerCase() === 'quit') { + handleExit(); + return; + } + + // Skip empty inputs + if (!trimmedInput) { + rl.prompt(); + return; + } + + try { + console.log(chalk.gray('šŸ¤” Thinking...')); + const response = await agent.run(trimmedInput); + + if (response) { + // Display the AI response with nice formatting + logger.displayAIResponse(response); + } else { + console.log(chalk.gray('No response received.')); + } + } catch (error) { + logger.error( + `Error processing input: ${error instanceof Error ? error.message : String(error)}` + ); + } + + rl.prompt(); + }); + + rl.on('close', () => { + console.log(chalk.yellow('\nšŸ‘‹ Session ended. Your conversation has been saved to memory.')); + process.exit(0); + }); +} + +/** + * Start MCP server mode for Model Context Protocol integration + */ +export async function startMcpMode(agent: MemAgent): Promise { + await _initCli(agent); + + console.log(chalk.cyan('šŸ”— Starting Cipher in MCP Server Mode...')); + console.log(chalk.gray('Ready to accept MCP client connections.')); + + // TODO: Implement MCP server functionality + // This would start an MCP server that other tools can connect to + logger.info('MCP mode is not yet fully implemented'); + logger.info('This would start a server that accepts MCP client connections'); + + // Keep the process alive + process.stdin.resume(); +} + +/** + * Common CLI initialization logic + */ +async function _initCli(agent: MemAgent): Promise { + logger.info('Initializing CLI interface...'); + + // Ensure agent is started + if (!agent) { + throw new Error('Agent is not initialized'); + } + + logger.info('CLI interface ready'); +} diff --git a/src/app/cli/utils/options.ts b/src/app/cli/utils/options.ts new file mode 100644 index 000000000..2d14be3ff --- /dev/null +++ b/src/app/cli/utils/options.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import { logger } from '@core/index.js'; + +export function validateCliOptions(opts: any): void { + logger.debug('Validating CLI options', opts); + const cliOptionShape = z.object({ + verbose: z.boolean().optional().default(true), + mode: z + .enum(['cli', 'mcp'], { + errorMap: () => ({ message: 'Mode must be either cli or mcp' }), + }) + .optional() + .default('cli'), + }); + + const cliOptionSchema = cliOptionShape; + + const result = cliOptionSchema.safeParse({ + verbose: opts.verbose, + mode: opts.mode, + }); + + if (!result.success) { + throw result.error; + } + + logger.debug('CLI options validated successfully', result.data); +} + +export function handleCliOptionsError(error: unknown): never { + if (error instanceof z.ZodError) { + logger.error('Invalid command-line options detected:'); + error.errors.forEach(err => { + const fieldName = err.path.join('.') || 'Unknown Option'; + logger.error(`- Option '${fieldName}': ${err.message}`); + }); + logger.error('Please check your command-line arguments or run with --help for usage details.'); + } else { + logger.error( + `Validation error: ${error instanceof Error ? error.message : JSON.stringify(error)}` + ); + } + process.exit(1); +} diff --git a/src/app/index.ts b/src/app/index.ts index e69de29bb..93fb52d10 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import { env } from '@core/env.js'; +import { Command } from 'commander'; +import pkg from '../../package.json' with { type: 'json' }; +import { existsSync } from 'fs'; +import { DEFAULT_CONFIG_PATH, logger, MemAgent } from '@core/index.js'; +import { handleCliOptionsError, validateCliOptions } from './cli/utils/options.js'; +import { loadAgentConfig } from '../core/brain/memAgent/loader.js'; +import { startInteractiveCli, startMcpMode } from './cli/cli.js'; + +const program = new Command(); + +program + .name('cipher') + .description('Agent that can help to remember your vibe coding agent knowledge and reinforce it') + .version(pkg.version, '-v, --version', 'output the current version') + .option('--no-verbose', 'Disable verbose output') + .option( + '--mode ', + 'The application mode for cipher memory agent - cli | mcp', + 'cli' + ); + +program + .description( + 'Cipher CLI allows you to interact with cipher memory agent in interactive mode.\n' + + 'Available modes:\n' + + ' - cli: Interactive command-line interface\n' + + ' - mcp: Model Context Protocol server mode' + ) + .action(async () => { + if (!existsSync('.env')) { + logger.error('No .env file found, copy .env.example to .env and fill in the values'); + process.exit(1); + } + + // Check if at least one API key is provided + if (!env.OPENAI_API_KEY && !env.ANTHROPIC_API_KEY) { + logger.error('No API key found, please set at least one of OPENAI_API_KEY or ANTHROPIC_API_KEY in .env file'); + logger.error('Available providers: OpenAI, Anthropic'); + process.exit(1); + } + + const opts = program.opts(); + + // validate cli options + try { + validateCliOptions(opts); + } catch (err) { + handleCliOptionsError(err); + } + + // load agent config + let agent: MemAgent; + try { + const configPath = DEFAULT_CONFIG_PATH; + logger.info(`Loading agent config from ${configPath}`); + + // Check if config file exists + if (!existsSync(configPath)) { + logger.error(`Config file not found at ${configPath}`); + logger.error('Please ensure the config file exists or create one based on memAgent/cipher.yml'); + process.exit(1); + } + + const cfg = await loadAgentConfig(configPath); + agent = new MemAgent(cfg); + + // Start the agent (initialize async services) + await agent.start(); + } catch (err) { + logger.error('Failed to load agent config:', err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + // ——— Dispatch based on --mode ——— + switch (opts.mode) { + case 'cli': + await startInteractiveCli(agent); + break; + case 'mcp': + await startMcpMode(agent); + break; + default: + logger.error(`Unknown mode '${opts.mode}'. Use cli or mcp.`); + process.exit(1); + } + }); + +program.parseAsync(process.argv); diff --git a/src/core/brain/index.ts b/src/core/brain/index.ts new file mode 100644 index 000000000..20b146690 --- /dev/null +++ b/src/core/brain/index.ts @@ -0,0 +1,2 @@ +export * from './memAgent/index.js'; +export * from './llm/index.js'; diff --git a/src/core/brain/llm/messages/manager.ts b/src/core/brain/llm/messages/manager.ts index 67cb654f0..c603a085d 100644 --- a/src/core/brain/llm/messages/manager.ts +++ b/src/core/brain/llm/messages/manager.ts @@ -7,6 +7,7 @@ import { PromptManager } from '../../../brain/systemPrompt/manager.js'; export class ContextManager { private promptManager: PromptManager; private formatter: IMessageFormatter; + private messages: InternalMessage[] = []; constructor(formatter: IMessageFormatter, promptManager: PromptManager) { if (!formatter) throw new Error('formatter is required'); @@ -67,7 +68,10 @@ export class ContextManager { throw new Error(`Unknown message role: ${(message as any).role}`); } + // Store the message in history + this.messages.push(message); logger.info(`Adding message to context: ${JSON.stringify(message, null, 2)}`); + logger.debug(`Total messages in context: ${this.messages.length}`); } /** @@ -153,14 +157,15 @@ export class ContextManager { } /** - * Get a formatted message - * @param message - The message to format - * @returns The formatted message + * Get formatted messages including conversation history + * @param message - The current message (already added to context by the service) + * @returns The formatted messages array including conversation history */ async getFormattedMessage(message: InternalMessage): Promise { try { - const prompt = await this.getSystemPrompt(); - return this.formatter.format(message, prompt); + // Don't add the message again - it's already been added by the service + // Just return all formatted messages from the existing conversation history + return this.getAllFormattedMessages(); } catch (error) { logger.error('Failed to get formatted messages', { error }); throw new Error( @@ -169,6 +174,43 @@ export class ContextManager { } } + /** + * Get all formatted messages from conversation history + * @returns The formatted messages array + */ + async getAllFormattedMessages(): Promise { + try { + // Get the system prompt + const prompt = await this.getSystemPrompt(); + + // Format all messages in conversation history + const formattedMessages: any[] = []; + + // Add system prompt as first message if using formatters that expect it in messages array + if (prompt && this.formatter.constructor.name === 'OpenAIMessageFormatter') { + formattedMessages.push({ role: 'system', content: prompt }); + } + + // Format each message in history + for (const msg of this.messages) { + const formatted = this.formatter.format(msg, prompt); + if (Array.isArray(formatted)) { + formattedMessages.push(...formatted); + } else if (formatted && formatted !== null) { + formattedMessages.push(formatted); + } + } + + logger.debug(`Formatted ${formattedMessages.length} messages from history of ${this.messages.length} messages`); + return formattedMessages; + } catch (error) { + logger.error('Failed to format all messages', { error }); + throw new Error( + `Failed to format messages: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + /** * Process a stream response from the LLM * @param response - The stream response from the LLM diff --git a/src/core/brain/llm/services/anthropic.ts b/src/core/brain/llm/services/anthropic.ts index 42dd8883e..fa9ce440c 100644 --- a/src/core/brain/llm/services/anthropic.ts +++ b/src/core/brain/llm/services/anthropic.ts @@ -18,7 +18,7 @@ export class AnthropicService implements ILLMService { model: string, mcpManager: MCPManager, contextManager: ContextManager, - maxIterations: number = 5 + maxIterations: number = 10 ) { this.anthropic = anthropic; this.model = model; @@ -139,11 +139,8 @@ export class AnthropicService implements ILLMService { while (attempts < MAX_ATTEMPTS) { attempts++; try { - // Use the new method that implements proper flow: get system prompt, compress history, format messages - const formattedMessages = await this.contextManager.getFormattedMessage({ - role: 'user', - content: userInput, - }); + // Get all formatted messages from conversation history + const formattedMessages = await this.contextManager.getAllFormattedMessages(); // For Anthropic, we need to separate system messages from the messages array const systemMessage = formattedMessages.find(msg => msg.role === 'system'); diff --git a/src/core/brain/llm/services/factory.ts b/src/core/brain/llm/services/factory.ts index 2117f7718..14d19e47b 100644 --- a/src/core/brain/llm/services/factory.ts +++ b/src/core/brain/llm/services/factory.ts @@ -2,6 +2,7 @@ import { MCPManager } from '../../../mcp/manager.js'; import { ContextManager } from '../messages/manager.js'; import { LLMConfig } from '../config.js'; import { ILLMService } from './types.js'; +import { env } from '../../../env.js'; import OpenAI from 'openai'; import { logger } from '../../../logger/index.js'; import Anthropic from '@anthropic-ai/sdk'; @@ -29,8 +30,8 @@ function getOpenAICompatibleBaseURL(llmConfig: LLMConfig): string { return llmConfig.baseURL.replace(/\/$/, ''); } // Check for environment variable as fallback - if (process.env.OPENAI_BASE_URL) { - return process.env.OPENAI_BASE_URL.replace(/\/$/, ''); + if (env.OPENAI_BASE_URL) { + return env.OPENAI_BASE_URL.replace(/\/$/, ''); } return ''; } diff --git a/src/core/brain/memAgent/__test__/agent.test.ts b/src/core/brain/memAgent/__test__/agent.test.ts index 9996bbeaf..49d9af003 100644 --- a/src/core/brain/memAgent/__test__/agent.test.ts +++ b/src/core/brain/memAgent/__test__/agent.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { MemAgent } from '../agent.js'; import { AgentConfig, AgentConfigSchema } from '../config.js'; -import {LLMConfigSchema } from '../../llm/config.js'; +import { LLMConfigSchema } from '../../llm/config.js'; import { ZodError } from 'zod'; // Mock all external dependencies @@ -82,7 +82,9 @@ describe('MemAgent', () => { model: 'gpt-4', apiKey: 'test-key', }), - addMcpServer: vi.fn().mockReturnValue({ isValid: true, config: {}, errors: [], warnings: [] }), + addMcpServer: vi + .fn() + .mockReturnValue({ isValid: true, config: {}, errors: [], warnings: [] }), removeMcpServer: vi.fn(), getRuntimeConfig: vi.fn(), }, @@ -387,7 +389,9 @@ describe('MemAgent', () => { it('should fail to start with invalid configuration during service creation', async () => { const { createAgentServices } = await import('../../../utils/service-initializer.js'); - vi.mocked(createAgentServices).mockRejectedValue(new Error('Configuration validation failed')); + vi.mocked(createAgentServices).mockRejectedValue( + new Error('Configuration validation failed') + ); const agent = new MemAgent(validConfig); diff --git a/src/core/brain/memAgent/__test__/loader.test.ts b/src/core/brain/memAgent/__test__/loader.test.ts new file mode 100644 index 000000000..f7969c081 --- /dev/null +++ b/src/core/brain/memAgent/__test__/loader.test.ts @@ -0,0 +1,533 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { loadAgentConfig } from '../loader.js'; +import { promises as fs } from 'fs'; +import { parse as parseYaml } from 'yaml'; + +// Mock external dependencies +vi.mock('fs', () => ({ + promises: { + readFile: vi.fn(), + }, +})); + +vi.mock('yaml', () => ({ + parse: vi.fn(), +})); + +vi.mock('../../../logger/index.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe('Loader', () => { + const mockFs = vi.mocked(fs); + const mockParseYaml = vi.mocked(parseYaml); + + beforeEach(() => { + vi.clearAllMocks(); + // Reset environment variables + delete process.env.TEST_VAR; + delete process.env.API_KEY; + delete process.env.PORT; + delete process.env.TIMEOUT; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Environment Variable Expansion', () => { + it('should expand simple environment variables', async () => { + process.env.TEST_VAR = 'test-value'; + process.env.API_KEY = 'secret-key'; + + const mockConfig = { + systemPrompt: '$TEST_VAR', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: '${API_KEY}', + }, + }; + + mockFs.readFile.mockResolvedValue(JSON.stringify(mockConfig)); + mockParseYaml.mockReturnValue(mockConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.systemPrompt).toBe('test-value'); + expect(result.llm.apiKey).toBe('secret-key'); + }); + + it('should handle missing environment variables by replacing with empty string', async () => { + const mockConfig = { + systemPrompt: '$MISSING_VAR', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: '${ALSO_MISSING}', + }, + }; + + mockFs.readFile.mockResolvedValue(JSON.stringify(mockConfig)); + mockParseYaml.mockReturnValue(mockConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.systemPrompt).toBe(''); + expect(result.llm.apiKey).toBe(''); + }); + + it('should convert numeric environment variables to numbers', async () => { + process.env.PORT = '3000'; + process.env.TIMEOUT = '30.5'; + process.env.SCIENTIFIC = '1.23e-4'; + + const mockConfig = { + systemPrompt: 'test prompt', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key', + maxTokens: '$PORT', + temperature: '${TIMEOUT}', + }, + sessions: { + sessionTTL: '$SCIENTIFIC', + }, + }; + + mockFs.readFile.mockResolvedValue(JSON.stringify(mockConfig)); + mockParseYaml.mockReturnValue(mockConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.llm.maxTokens).toBe(3000); + expect(result.llm.temperature).toBe(30.5); + expect(result.sessions.sessionTTL).toBe(1.23e-4); + }); + + it('should not convert non-numeric strings to numbers', async () => { + process.env.TEXT_VAR = 'not-a-number'; + + const mockConfig = { + systemPrompt: '$TEXT_VAR', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key', + }, + }; + + mockFs.readFile.mockResolvedValue(JSON.stringify(mockConfig)); + mockParseYaml.mockReturnValue(mockConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.systemPrompt).toBe('not-a-number'); + }); + + it('should handle environment variables in nested objects', async () => { + process.env.DB_HOST = 'localhost'; + process.env.DB_PORT = '5432'; + + const mockConfig = { + systemPrompt: 'test prompt', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key', + baseURL: 'https://$DB_HOST:$DB_PORT', + }, + mcpServers: { + testServer: { + command: 'test', + env: { + HOST: '$DB_HOST', + PORT: '$DB_PORT', + USERNAME: '${USERNAME}', + }, + }, + }, + }; + + mockFs.readFile.mockResolvedValue(JSON.stringify(mockConfig)); + mockParseYaml.mockReturnValue(mockConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.llm.baseURL).toBe('https://localhost:5432'); + expect(result.mcpServers.testServer.env.HOST).toBe('localhost'); + expect(result.mcpServers.testServer.env.PORT).toBe(5432); + expect(result.mcpServers.testServer.env.USERNAME).toBe(''); + }); + + it('should handle environment variables in arrays', async () => { + process.env.SERVER1 = 'server1.com'; + process.env.SERVER2 = 'server2.com'; + + const mockConfig = { + systemPrompt: 'test prompt', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key', + }, + mcpServers: { + testServer: { + command: 'test', + args: ['$SERVER1', '${SERVER2}', 'static.com'], + }, + }, + }; + + mockFs.readFile.mockResolvedValue(JSON.stringify(mockConfig)); + mockParseYaml.mockReturnValue(mockConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.mcpServers.testServer.args).toEqual([ + 'server1.com', + 'server2.com', + 'static.com', + ]); + }); + + it('should handle mixed content with environment variables', async () => { + process.env.HOST = 'api.example.com'; + process.env.VERSION = 'v1'; + + const mockConfig = { + systemPrompt: 'test prompt', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key', + baseURL: 'https://$HOST/${VERSION}/users', + }, + }; + + mockFs.readFile.mockResolvedValue(JSON.stringify(mockConfig)); + mockParseYaml.mockReturnValue(mockConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.llm.baseURL).toBe('https://api.example.com/v1/users'); + }); + + it('should preserve non-string values unchanged', async () => { + const mockConfig = { + systemPrompt: 'test prompt', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key', + }, + sessions: { + maxSessions: 42, + sessionTTL: 3600000, + }, + mcpServers: { + testServer: { + command: 'test', + disabled: true, + timeout: null, + env: { key: 'value' }, + args: [1, 2, 3], + }, + }, + }; + + mockFs.readFile.mockResolvedValue(JSON.stringify(mockConfig)); + mockParseYaml.mockReturnValue(mockConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.sessions.maxSessions).toBe(42); + expect(result.mcpServers.testServer.disabled).toBe(true); + expect(result.mcpServers.testServer.timeout).toBe(null); + expect(result.mcpServers.testServer.env).toEqual({ key: 'value' }); + expect(result.mcpServers.testServer.args).toEqual([1, 2, 3]); + }); + + it('should handle case-insensitive environment variable names', async () => { + process.env.test_var = 'lowercase'; + process.env.TEST_VAR = 'uppercase'; + + const mockConfig = { + systemPrompt: '$test_var', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: '$TEST_VAR', + }, + }; + + mockFs.readFile.mockResolvedValue(JSON.stringify(mockConfig)); + mockParseYaml.mockReturnValue(mockConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.systemPrompt).toBe('lowercase'); + expect(result.llm.apiKey).toBe('uppercase'); + }); + }); + + describe('Config File Loading', () => { + it('should successfully load and parse YAML config', async () => { + const yamlContent = ` +systemPrompt: "You are a helpful assistant" +llm: + provider: "openai" + model: "gpt-4" + apiKey: "test-key" +`; + const expectedConfig = { + systemPrompt: 'You are a helpful assistant', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key', + }, + }; + + mockFs.readFile.mockResolvedValue(yamlContent); + mockParseYaml.mockReturnValue(expectedConfig); + + const result = await loadAgentConfig('/path/to/config.yml'); + + expect(mockFs.readFile).toHaveBeenCalledWith('/path/to/config.yml', 'utf-8'); + expect(mockParseYaml).toHaveBeenCalledWith(yamlContent); + expect(result).toEqual(expectedConfig); + }); + + it('should log debug message with config path', async () => { + const { logger } = await import('../../../logger/index.js'); + const mockConfig = { test: 'value' }; + + mockFs.readFile.mockResolvedValue('test: value'); + mockParseYaml.mockReturnValue(mockConfig); + + await loadAgentConfig('/custom/path/config.yml'); + + expect(logger.debug).toHaveBeenCalledWith( + 'Loading cipher config from: /custom/path/config.yml' + ); + }); + + it('should handle file read errors', async () => { + const readError = new Error('File not found'); + mockFs.readFile.mockRejectedValue(readError); + + await expect(loadAgentConfig('/nonexistent/config.yml')).rejects.toThrow( + 'Failed to load config file at /nonexistent/config.yml: File not found' + ); + }); + + it('should handle YAML parsing errors', async () => { + const yamlContent = 'invalid: yaml: content: [unclosed'; + const parseError = new Error('Invalid YAML syntax'); + + mockFs.readFile.mockResolvedValue(yamlContent); + mockParseYaml.mockImplementation(() => { + throw parseError; + }); + + await expect(loadAgentConfig('/path/to/config.yml')).rejects.toThrow( + 'Failed to parse YAML: Invalid YAML syntax' + ); + }); + + it('should handle non-Error parsing exceptions', async () => { + const yamlContent = 'test: value'; + + mockFs.readFile.mockResolvedValue(yamlContent); + mockParseYaml.mockImplementation(() => { + throw 'String error'; + }); + + await expect(loadAgentConfig('/path/to/config.yml')).rejects.toThrow( + 'Failed to parse YAML: String error' + ); + }); + + it('should handle file system errors with path property', async () => { + const fsError = Object.assign(new Error('Permission denied'), { + path: '/restricted/config.yml', + }); + + mockFs.readFile.mockRejectedValue(fsError); + + await expect(loadAgentConfig('/path/to/config.yml')).rejects.toThrow( + 'Failed to load config file at /restricted/config.yml: Permission denied' + ); + }); + + it('should handle complex configuration with environment variables', async () => { + process.env.API_KEY = 'secret-123'; + process.env.MAX_ITERATIONS = '50'; + process.env.BASE_URL = 'https://api.openai.com/v1'; + + const yamlContent = ` +systemPrompt: "You are a helpful assistant" +llm: + provider: "openai" + model: "gpt-4" + apiKey: "\${API_KEY}" + maxIterations: \${MAX_ITERATIONS} + baseURL: \${BASE_URL} +mcpServers: + filesystem: + type: "stdio" + command: "npx" + args: ["@modelcontextprotocol/server-filesystem"] + env: + ROOT_PATH: "/tmp" +`; + + const parsedConfig = { + systemPrompt: 'You are a helpful assistant', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: '${API_KEY}', + maxIterations: '${MAX_ITERATIONS}', + baseURL: '${BASE_URL}', + }, + mcpServers: { + filesystem: { + type: 'stdio', + command: 'npx', + args: ['@modelcontextprotocol/server-filesystem'], + env: { + ROOT_PATH: '/tmp', + }, + }, + }, + }; + + mockFs.readFile.mockResolvedValue(yamlContent); + mockParseYaml.mockReturnValue(parsedConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.llm.apiKey).toBe('secret-123'); + expect(result.llm.maxIterations).toBe(50); + expect(result.llm.baseURL).toBe('https://api.openai.com/v1'); + expect(result.mcpServers.filesystem.env.ROOT_PATH).toBe('/tmp'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty config file', async () => { + mockFs.readFile.mockResolvedValue(''); + mockParseYaml.mockReturnValue({}); + + const result = await loadAgentConfig('/path/to/empty.yml'); + + expect(result).toEqual({}); + }); + + it('should handle config with only environment variables', async () => { + process.env.ONLY_ENV = 'env-value'; + + const mockConfig = '$ONLY_ENV'; + + mockFs.readFile.mockResolvedValue('value'); + mockParseYaml.mockReturnValue(mockConfig); + + const result = await loadAgentConfig('/path/to/config.yml'); + + expect(result).toBe('env-value'); + }); + + it('should handle deeply nested configuration', async () => { + process.env.DEEP_VALUE = 'found-it'; + + const mockConfig = { + systemPrompt: 'test prompt', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key', + }, + mcpServers: { + testServer: { + type: 'stdio', + command: 'test', + env: { + level1: { + level2: { + level3: { + level4: { + value: '$DEEP_VALUE', + }, + }, + }, + }, + }, + }, + }, + }; + + mockFs.readFile.mockResolvedValue('nested'); + mockParseYaml.mockReturnValue(mockConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.mcpServers.testServer.env.level1.level2.level3.level4.value).toBe('found-it'); + }); + + it('should handle environment variables with underscores and numbers', async () => { + process.env.VAR_WITH_123 = 'underscore-and-numbers'; + process.env.VAR123 = 'just-numbers'; + + const mockConfig = { + systemPrompt: '$VAR_WITH_123', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: '$VAR123', + }, + }; + + mockFs.readFile.mockResolvedValue('test'); + mockParseYaml.mockReturnValue(mockConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.systemPrompt).toBe('underscore-and-numbers'); + expect(result.llm.apiKey).toBe('just-numbers'); + }); + + it('should handle negative numbers in environment variables', async () => { + process.env.NEGATIVE_INT = '-42'; + process.env.NEGATIVE_FLOAT = '-3.14'; + + const mockConfig = { + systemPrompt: 'test prompt', + llm: { + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key', + maxIterations: '$NEGATIVE_INT', + }, + sessions: { + sessionTTL: '$NEGATIVE_FLOAT', + }, + }; + + mockFs.readFile.mockResolvedValue('negative'); + mockParseYaml.mockReturnValue(mockConfig); + + const result = (await loadAgentConfig('/path/to/config.yml')) as any; + + expect(result.llm.maxIterations).toBe(-42); + expect(result.sessions.sessionTTL).toBe(-3.14); + }); + }); +}); diff --git a/src/core/brain/memAgent/agent.ts b/src/core/brain/memAgent/agent.ts index 83e8291c8..0b26866ba 100644 --- a/src/core/brain/memAgent/agent.ts +++ b/src/core/brain/memAgent/agent.ts @@ -237,16 +237,15 @@ export class MemAgent { return this.mcpManager.getClients(); } - public getMcpFailedConnections(): Record { - this.ensureStarted(); - return this.mcpManager.getFailedConnections(); - } - - public getEffectiveConfig(sessionId?: string): Readonly { - this.ensureStarted(); - return sessionId + public getMcpFailedConnections(): Record { + this.ensureStarted(); + return this.mcpManager.getFailedConnections(); + } + + public getEffectiveConfig(sessionId?: string): Readonly { + this.ensureStarted(); + return sessionId ? this.stateManager.getRuntimeConfig(sessionId) : this.stateManager.getRuntimeConfig(); } - } diff --git a/src/core/brain/memAgent/index.ts b/src/core/brain/memAgent/index.ts index 8a05b42a4..6ece96d05 100644 --- a/src/core/brain/memAgent/index.ts +++ b/src/core/brain/memAgent/index.ts @@ -1,2 +1,2 @@ export { MemAgent } from './agent.js'; -export { ConversationSession, SessionManager } from '../../session/index.js'; \ No newline at end of file +export { ConversationSession, SessionManager } from '../../session/index.js'; diff --git a/src/core/brain/memAgent/loader.ts b/src/core/brain/memAgent/loader.ts new file mode 100644 index 000000000..1df6514f3 --- /dev/null +++ b/src/core/brain/memAgent/loader.ts @@ -0,0 +1,58 @@ +import { logger } from '../../logger/index.js'; +import { AgentConfig } from './config.js'; +import { parse as parseYaml } from 'yaml'; +import { promises as fs } from 'fs'; +import { env } from '../../env.js'; + +function expandEnvVars(config: any): any { + if (typeof config === 'string') { + const expanded = config.replace( + /\$([A-Z_][A-Z0-9_]*)|\${([A-Z_][A-Z0-9_]*)}/gi, + (_, v1, v2) => { + return env[v1 || v2] || ''; + } + ); + + // Try to convert numeric strings to numbers + if (expanded !== config && /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(expanded.trim())) { + return Number(expanded); // handles int, float, sci-notation + } + + return expanded; + } else if (Array.isArray(config)) { + return config.map(expandEnvVars); + } else if (typeof config === 'object' && config !== null) { + const result: any = {}; + for (const key in config) { + result[key] = expandEnvVars(config[key]); + } + return result; + } + return config; +} + +export async function loadAgentConfig(configPath: string): Promise { + try { + // Determine where to load from: absolute, default, or user-relative + + logger.debug(`Loading cipher config from: ${configPath}`); + + // Read and parse the config file + const fileContent = await fs.readFile(configPath, 'utf-8'); + + try { + // Parse YAML content + const config = parseYaml(fileContent); + // Expand env vars everywhere + const expandedConfig = expandEnvVars(config); + return expandedConfig; + } catch (parseError) { + throw new Error( + `Failed to parse YAML: ${parseError instanceof Error ? parseError.message : String(parseError)}` + ); + } + } catch (error: any) { + // Include path & cause for better diagnostics + throw new Error(`Failed to load config file at ${error.path || configPath}: ${error.message}`); + } +} diff --git a/src/core/env.ts b/src/core/env.ts new file mode 100644 index 000000000..45d4cce88 --- /dev/null +++ b/src/core/env.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { config } from 'dotenv'; + +// Load environment variables from .env file +config(); + +const EnvSchema = z.object({ + // API Keys + OPENAI_API_KEY: z.string().optional(), + ANTHROPIC_API_KEY: z.string().optional(), + + // API Configuration + OPENAI_BASE_URL: z.string().optional(), + + // Logger Configuration + CIPHER_LOG_LEVEL: z.string().optional(), + REDACT_SECRETS: z.string().optional(), +}); + +export const env: z.infer = { + // API Keys + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + + // API Configuration + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + + // Logger Configuration + CIPHER_LOG_LEVEL: process.env.CIPHER_LOG_LEVEL, + REDACT_SECRETS: process.env.REDACT_SECRETS, +}; + +export const validateEnv = () => { + const result = EnvSchema.safeParse(env); + if (!result.success) { + // Note: logger might not be available during early initialization + console.error('Invalid environment variables', result.error); + } + return result.success; +}; \ No newline at end of file diff --git a/src/core/index.ts b/src/core/index.ts index 0a03e8694..7e45e97b3 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,2 +1,5 @@ export * from './logger/index.js'; export * from './mcp/index.js'; +export * from './brain/index.js'; +export * from './utils/index.js'; +export * from './env.js'; diff --git a/src/core/logger/logger.ts b/src/core/logger/logger.ts index 1e0aba208..9ea0ad6a7 100644 --- a/src/core/logger/logger.ts +++ b/src/core/logger/logger.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import boxen from 'boxen'; import fs from 'fs'; import path from 'path'; +import { env } from '../env.js'; // ===== 1. Foundation Layer: Winston Configuration ===== @@ -18,7 +19,7 @@ const logLevels = { // ===== 2. Security Layer: Data Redaction ===== -const SHOULD_REDACT = process.env.REDACT_SECRETS !== 'false'; +const SHOULD_REDACT = env.REDACT_SECRETS !== 'false'; const SENSITIVE_KEYS = ['apiKey', 'password', 'secret', 'token', 'auth', 'key', 'credential']; const MASK_REGEX = new RegExp( `(${SENSITIVE_KEYS.join('|')})(["']?\\s*[:=]\\s*)(["'])?.*?\\3`, @@ -92,7 +93,7 @@ const fileFormat = winston.format.printf(({ level, message, timestamp }) => { // ===== 4. Configuration Layer ===== const getDefaultLogLevel = (): string => { - const envLevel = process.env.CIPHER_LOG_LEVEL; + const envLevel = env.CIPHER_LOG_LEVEL; if (envLevel && Object.keys(logLevels).includes(envLevel.toLowerCase())) { return envLevel.toLowerCase(); } diff --git a/src/core/mcp/client.ts b/src/core/mcp/client.ts index d50eae210..0789867cb 100644 --- a/src/core/mcp/client.ts +++ b/src/core/mcp/client.ts @@ -14,6 +14,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { fileURLToPath } from 'url'; + import type { IMCPClient, McpServerConfig, @@ -376,6 +377,22 @@ export class MCPClient implements IMCPClient { return promptNames; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + + // Check if this is a "capability not supported" error (common for filesystem servers) + const isCapabilityError = errorMessage.includes('not implemented') || + errorMessage.includes('not supported') || + errorMessage.includes('Method not found') || + errorMessage.includes('prompts') === false; // Some servers just don't respond to prompt requests + + if (isCapabilityError) { + this.logger.debug(`${LOG_PREFIXES.PROMPT} Prompts not supported by server (this is normal)`, { + serverName: this.serverName, + reason: 'Server does not implement prompt capability', + }); + return []; // Return empty array instead of throwing + } + + // Real error - log as error and throw this.logger.error(`${LOG_PREFIXES.PROMPT} Failed to list prompts`, { serverName: this.serverName, error: errorMessage, @@ -446,6 +463,22 @@ export class MCPClient implements IMCPClient { return resourceUris; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + + // Check if this is a "capability not supported" error (common for filesystem servers) + const isCapabilityError = errorMessage.includes('not implemented') || + errorMessage.includes('not supported') || + errorMessage.includes('Method not found') || + errorMessage.includes('resources') === false; // Some servers just don't respond to resource requests + + if (isCapabilityError) { + this.logger.debug(`${LOG_PREFIXES.RESOURCE} Resources not supported by server (this is normal)`, { + serverName: this.serverName, + reason: 'Server does not implement resource capability', + }); + return []; // Return empty array instead of throwing + } + + // Real error - log as error and throw this.logger.error(`${LOG_PREFIXES.RESOURCE} Failed to list resources`, { serverName: this.serverName, error: errorMessage, @@ -637,8 +670,13 @@ export class MCPClient implements IMCPClient { * Merge environment variables with current process environment. */ private _mergeEnvironment(configEnv: Record): Record { + // Filter out undefined values from env and convert to proper process.env format + const processEnv = Object.fromEntries( + Object.entries(process.env).filter(([_, value]) => value !== undefined) + ) as Record; + return { - ...process.env, + ...processEnv, ...configEnv, }; } diff --git a/src/core/mcp/manager.ts b/src/core/mcp/manager.ts index 7f2737ddd..55dca2eba 100644 --- a/src/core/mcp/manager.ts +++ b/src/core/mcp/manager.ts @@ -234,10 +234,12 @@ export class MCPManager implements IMCPManager { this.promptClientMap.set(finalPromptName, name); }); - this.logger.debug( - `${LOG_PREFIXES.MANAGER} Retrieved ${prompts.length} prompts from ${name}`, - { clientName: name, promptCount: prompts.length } - ); + if (prompts.length > 0) { + this.logger.debug( + `${LOG_PREFIXES.MANAGER} Retrieved ${prompts.length} prompts from ${name}`, + { clientName: name, promptCount: prompts.length } + ); + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); errors.push(`${name}: ${errorMessage}`); @@ -338,10 +340,12 @@ export class MCPManager implements IMCPManager { this.resourceClientMap.set(resourceUri, name); }); - this.logger.debug( - `${LOG_PREFIXES.MANAGER} Retrieved ${resources.length} resources from ${name}`, - { clientName: name, resourceCount: resources.length } - ); + if (resources.length > 0) { + this.logger.debug( + `${LOG_PREFIXES.MANAGER} Retrieved ${resources.length} resources from ${name}`, + { clientName: name, resourceCount: resources.length } + ); + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); errors.push(`${name}: ${errorMessage}`); diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts new file mode 100644 index 000000000..444510a35 --- /dev/null +++ b/src/core/utils/index.ts @@ -0,0 +1,2 @@ +export * from './path.js'; +export * from './service-initializer.js'; diff --git a/src/core/utils/path.ts b/src/core/utils/path.ts new file mode 100644 index 000000000..59dfe53d7 --- /dev/null +++ b/src/core/utils/path.ts @@ -0,0 +1,5 @@ +import * as path from 'path'; +/** + * The default path to the agent config file + */ +export const DEFAULT_CONFIG_PATH = 'memAgent/cipher.yml'; diff --git a/tsup.config.ts b/tsup.config.ts index ae7bffa26..a2d6e200a 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -9,11 +9,14 @@ export default defineConfig([ shims: true, bundle: true, }, - // App entry: only ESM, no bundling needed + // App entry: only ESM, bundle all dependencies except commander { entry: ['src/app/index.ts'], format: ['esm'], outDir: 'dist/src/app', shims: true, + bundle: true, + platform: 'node', + external: ['events', 'fs', 'path', 'child_process', 'process', 'commander'], }, ]); \ No newline at end of file