@soleri/forge 0.0.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -0
- package/README.md +199 -0
- package/dist/facades/forge.facade.d.ts +9 -0
- package/dist/facades/forge.facade.js +134 -0
- package/dist/facades/forge.facade.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +81 -0
- package/dist/index.js.map +1 -0
- package/dist/knowledge-installer.d.ts +31 -0
- package/dist/knowledge-installer.js +437 -0
- package/dist/knowledge-installer.js.map +1 -0
- package/dist/scaffolder.d.ts +13 -0
- package/dist/scaffolder.js +330 -0
- package/dist/scaffolder.js.map +1 -0
- package/dist/templates/activate.d.ts +9 -0
- package/dist/templates/activate.js +139 -0
- package/dist/templates/activate.js.map +1 -0
- package/dist/templates/brain.d.ts +6 -0
- package/dist/templates/brain.js +478 -0
- package/dist/templates/brain.js.map +1 -0
- package/dist/templates/claude-md-template.d.ts +11 -0
- package/dist/templates/claude-md-template.js +73 -0
- package/dist/templates/claude-md-template.js.map +1 -0
- package/dist/templates/core-facade.d.ts +6 -0
- package/dist/templates/core-facade.js +456 -0
- package/dist/templates/core-facade.js.map +1 -0
- package/dist/templates/domain-facade.d.ts +7 -0
- package/dist/templates/domain-facade.js +119 -0
- package/dist/templates/domain-facade.js.map +1 -0
- package/dist/templates/entry-point.d.ts +5 -0
- package/dist/templates/entry-point.js +116 -0
- package/dist/templates/entry-point.js.map +1 -0
- package/dist/templates/facade-factory.d.ts +1 -0
- package/dist/templates/facade-factory.js +63 -0
- package/dist/templates/facade-factory.js.map +1 -0
- package/dist/templates/facade-types.d.ts +1 -0
- package/dist/templates/facade-types.js +46 -0
- package/dist/templates/facade-types.js.map +1 -0
- package/dist/templates/inject-claude-md.d.ts +11 -0
- package/dist/templates/inject-claude-md.js +92 -0
- package/dist/templates/inject-claude-md.js.map +1 -0
- package/dist/templates/intelligence-loader.d.ts +1 -0
- package/dist/templates/intelligence-loader.js +43 -0
- package/dist/templates/intelligence-loader.js.map +1 -0
- package/dist/templates/intelligence-types.d.ts +1 -0
- package/dist/templates/intelligence-types.js +24 -0
- package/dist/templates/intelligence-types.js.map +1 -0
- package/dist/templates/llm-client.d.ts +7 -0
- package/dist/templates/llm-client.js +300 -0
- package/dist/templates/llm-client.js.map +1 -0
- package/dist/templates/llm-key-pool.d.ts +7 -0
- package/dist/templates/llm-key-pool.js +211 -0
- package/dist/templates/llm-key-pool.js.map +1 -0
- package/dist/templates/llm-types.d.ts +5 -0
- package/dist/templates/llm-types.js +161 -0
- package/dist/templates/llm-types.js.map +1 -0
- package/dist/templates/llm-utils.d.ts +5 -0
- package/dist/templates/llm-utils.js +260 -0
- package/dist/templates/llm-utils.js.map +1 -0
- package/dist/templates/package-json.d.ts +2 -0
- package/dist/templates/package-json.js +37 -0
- package/dist/templates/package-json.js.map +1 -0
- package/dist/templates/persona.d.ts +2 -0
- package/dist/templates/persona.js +42 -0
- package/dist/templates/persona.js.map +1 -0
- package/dist/templates/planner.d.ts +5 -0
- package/dist/templates/planner.js +150 -0
- package/dist/templates/planner.js.map +1 -0
- package/dist/templates/readme.d.ts +5 -0
- package/dist/templates/readme.js +316 -0
- package/dist/templates/readme.js.map +1 -0
- package/dist/templates/setup-script.d.ts +6 -0
- package/dist/templates/setup-script.js +112 -0
- package/dist/templates/setup-script.js.map +1 -0
- package/dist/templates/test-brain.d.ts +6 -0
- package/dist/templates/test-brain.js +474 -0
- package/dist/templates/test-brain.js.map +1 -0
- package/dist/templates/test-facades.d.ts +6 -0
- package/dist/templates/test-facades.js +649 -0
- package/dist/templates/test-facades.js.map +1 -0
- package/dist/templates/test-llm.d.ts +7 -0
- package/dist/templates/test-llm.js +574 -0
- package/dist/templates/test-llm.js.map +1 -0
- package/dist/templates/test-loader.d.ts +5 -0
- package/dist/templates/test-loader.js +146 -0
- package/dist/templates/test-loader.js.map +1 -0
- package/dist/templates/test-planner.d.ts +5 -0
- package/dist/templates/test-planner.js +271 -0
- package/dist/templates/test-planner.js.map +1 -0
- package/dist/templates/test-vault.d.ts +5 -0
- package/dist/templates/test-vault.js +380 -0
- package/dist/templates/test-vault.js.map +1 -0
- package/dist/templates/tsconfig.d.ts +1 -0
- package/dist/templates/tsconfig.js +25 -0
- package/dist/templates/tsconfig.js.map +1 -0
- package/dist/templates/vault.d.ts +5 -0
- package/dist/templates/vault.js +263 -0
- package/dist/templates/vault.js.map +1 -0
- package/dist/templates/vitest-config.d.ts +1 -0
- package/dist/templates/vitest-config.js +27 -0
- package/dist/templates/vitest-config.js.map +1 -0
- package/dist/types.d.ts +89 -0
- package/dist/types.js +21 -0
- package/dist/types.js.map +1 -0
- package/package.json +42 -4
- package/src/__tests__/knowledge-installer.test.ts +805 -0
- package/src/__tests__/scaffolder.test.ts +323 -0
- package/src/facades/forge.facade.ts +150 -0
- package/src/index.ts +101 -0
- package/src/knowledge-installer.ts +532 -0
- package/src/scaffolder.ts +386 -0
- package/src/templates/activate.ts +145 -0
- package/src/templates/claude-md-template.ts +137 -0
- package/src/templates/core-facade.ts +457 -0
- package/src/templates/domain-facade.ts +121 -0
- package/src/templates/entry-point.ts +120 -0
- package/src/templates/inject-claude-md.ts +94 -0
- package/src/templates/llm-client.ts +301 -0
- package/src/templates/package-json.ts +39 -0
- package/src/templates/persona.ts +45 -0
- package/src/templates/readme.ts +319 -0
- package/src/templates/setup-script.ts +113 -0
- package/src/templates/test-facades.ts +656 -0
- package/src/templates/tsconfig.ts +25 -0
- package/src/templates/vitest-config.ts +26 -0
- package/src/types.ts +68 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { AgentConfig } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate the main index.ts entry point for the agent.
|
|
5
|
+
*/
|
|
6
|
+
export function generateEntryPoint(config: AgentConfig): string {
|
|
7
|
+
const facadeImports = config.domains
|
|
8
|
+
.map((d) => {
|
|
9
|
+
const fn = `create${pascalCase(d)}Facade`;
|
|
10
|
+
const file = `${d}.facade.js`;
|
|
11
|
+
return `import { ${fn} } from './facades/${file}';`;
|
|
12
|
+
})
|
|
13
|
+
.join('\n');
|
|
14
|
+
|
|
15
|
+
const facadeCreations = config.domains
|
|
16
|
+
.map((d) => ` create${pascalCase(d)}Facade(vault, brain),`)
|
|
17
|
+
.join('\n');
|
|
18
|
+
|
|
19
|
+
return `#!/usr/bin/env node
|
|
20
|
+
|
|
21
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
22
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
|
|
27
|
+
import { Vault, Brain, Planner, KeyPool, loadKeyPoolConfig, loadIntelligenceData, registerAllFacades } from '@soleri/core';
|
|
28
|
+
${facadeImports}
|
|
29
|
+
import { createCoreFacade } from './facades/core.facade.js';
|
|
30
|
+
import { LLMClient } from './llm/llm-client.js';
|
|
31
|
+
import { PERSONA, getPersonaPrompt } from './identity/persona.js';
|
|
32
|
+
|
|
33
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
|
|
35
|
+
async function main(): Promise<void> {
|
|
36
|
+
// Initialize persistent vault at ~/.${config.id}/vault.db
|
|
37
|
+
const vaultPath = join(homedir(), '.${config.id}', 'vault.db');
|
|
38
|
+
const vault = new Vault(vaultPath);
|
|
39
|
+
console.error(\`[\${PERSONA.name.toLowerCase()}] Vault: \${vaultPath}\`);
|
|
40
|
+
|
|
41
|
+
// Load and seed intelligence data
|
|
42
|
+
const dataDir = join(__dirname, 'intelligence', 'data');
|
|
43
|
+
const entries = loadIntelligenceData(dataDir);
|
|
44
|
+
if (entries.length > 0) {
|
|
45
|
+
const seeded = vault.seed(entries);
|
|
46
|
+
console.error(\`[\${PERSONA.name.toLowerCase()}] Seeded vault with \${seeded} intelligence entries\`);
|
|
47
|
+
} else {
|
|
48
|
+
console.error(\`[\${PERSONA.name.toLowerCase()}] Vault is empty — ready for knowledge capture\`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Initialize planner at ~/.${config.id}/plans.json
|
|
52
|
+
const plansPath = join(homedir(), '.${config.id}', 'plans.json');
|
|
53
|
+
const planner = new Planner(plansPath);
|
|
54
|
+
console.error(\`[\${PERSONA.name.toLowerCase()}] Planner: \${plansPath}\`);
|
|
55
|
+
|
|
56
|
+
// Initialize brain — intelligence layer for ranked search, auto-tagging, dedup
|
|
57
|
+
const brain = new Brain(vault);
|
|
58
|
+
console.error(\`[\${PERSONA.name.toLowerCase()}] Brain: vocabulary \${brain.getVocabularySize()} terms\`);
|
|
59
|
+
|
|
60
|
+
// Initialize LLM client (optional — works without API keys)
|
|
61
|
+
const keyPoolFiles = loadKeyPoolConfig('${config.id}');
|
|
62
|
+
const openaiKeyPool = new KeyPool(keyPoolFiles.openai);
|
|
63
|
+
const anthropicKeyPool = new KeyPool(keyPoolFiles.anthropic);
|
|
64
|
+
const llmClient = new LLMClient(openaiKeyPool, anthropicKeyPool);
|
|
65
|
+
const llmAvail = llmClient.isAvailable();
|
|
66
|
+
console.error(\`[\${PERSONA.name.toLowerCase()}] LLM: OpenAI \${llmAvail.openai ? 'available' : 'not configured'}, Anthropic \${llmAvail.anthropic ? 'available' : 'not configured'}\`);
|
|
67
|
+
|
|
68
|
+
// Create MCP server
|
|
69
|
+
const server = new McpServer({
|
|
70
|
+
name: '${config.id}-mcp',
|
|
71
|
+
version: '1.0.0',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Register persona prompt
|
|
75
|
+
server.prompt('persona', 'Get agent persona and principles', async () => ({
|
|
76
|
+
messages: [{ role: 'assistant' as const, content: { type: 'text' as const, text: getPersonaPrompt() } }],
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
// Create and register facades
|
|
80
|
+
const facades = [
|
|
81
|
+
${facadeCreations}
|
|
82
|
+
createCoreFacade(vault, planner, brain, llmClient, openaiKeyPool, anthropicKeyPool),
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
registerAllFacades(server, facades);
|
|
86
|
+
|
|
87
|
+
const stats = vault.stats();
|
|
88
|
+
console.error(\`[\${PERSONA.name.toLowerCase()}] \${PERSONA.name} — \${PERSONA.role}\`);
|
|
89
|
+
console.error(\`[\${PERSONA.name.toLowerCase()}] Vault: \${stats.totalEntries} entries across \${Object.keys(stats.byDomain).length} domains\`);
|
|
90
|
+
console.error(\`[\${PERSONA.name.toLowerCase()}] Registered \${facades.length} facades with \${facades.reduce((sum, f) => sum + f.ops.length, 0)} operations\`);
|
|
91
|
+
|
|
92
|
+
// Stdio transport
|
|
93
|
+
const transport = new StdioServerTransport();
|
|
94
|
+
await server.connect(transport);
|
|
95
|
+
console.error(\`[\${PERSONA.name.toLowerCase()}] Connected via stdio transport\`);
|
|
96
|
+
console.error(\`[\${PERSONA.name.toLowerCase()}] Say "Hello, \${PERSONA.name}!" to activate\`);
|
|
97
|
+
|
|
98
|
+
// Graceful shutdown
|
|
99
|
+
const shutdown = async (): Promise<void> => {
|
|
100
|
+
console.error(\`[\${PERSONA.name.toLowerCase()}] Shutting down...\`);
|
|
101
|
+
vault.close();
|
|
102
|
+
process.exit(0);
|
|
103
|
+
};
|
|
104
|
+
process.on('SIGTERM', shutdown);
|
|
105
|
+
process.on('SIGINT', shutdown);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
main().catch((err) => {
|
|
109
|
+
console.error('[${config.id}] Fatal error:', err);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
});
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pascalCase(s: string): string {
|
|
116
|
+
return s
|
|
117
|
+
.split(/[-_\s]+/)
|
|
118
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
119
|
+
.join('');
|
|
120
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { AgentConfig } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generates src/activation/inject-claude-md.ts for a new agent.
|
|
5
|
+
* Provides idempotent CLAUDE.md injection — creates, appends, or updates
|
|
6
|
+
* the agent's section between HTML comment markers.
|
|
7
|
+
* Supports both project-level and global (~/.claude/CLAUDE.md) injection.
|
|
8
|
+
*
|
|
9
|
+
* Uses array-joined pattern because the generated code contains
|
|
10
|
+
* template literals (backticks) for path operations and string interpolation.
|
|
11
|
+
*/
|
|
12
|
+
export function generateInjectClaudeMd(config: AgentConfig): string {
|
|
13
|
+
const marker = `${config.id}:mode`;
|
|
14
|
+
|
|
15
|
+
return [
|
|
16
|
+
"import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';",
|
|
17
|
+
"import { homedir } from 'node:os';",
|
|
18
|
+
"import { join } from 'node:path';",
|
|
19
|
+
"import { getClaudeMdContent, getClaudeMdMarker } from './claude-md-content.js';",
|
|
20
|
+
'',
|
|
21
|
+
'export interface InjectResult {',
|
|
22
|
+
' injected: boolean;',
|
|
23
|
+
' path: string;',
|
|
24
|
+
" action: 'created' | 'updated' | 'appended';",
|
|
25
|
+
'}',
|
|
26
|
+
'',
|
|
27
|
+
'/**',
|
|
28
|
+
' * Inject into a specific CLAUDE.md file path.',
|
|
29
|
+
' * Shared logic for both project-level and global injection.',
|
|
30
|
+
' */',
|
|
31
|
+
'function injectIntoFile(filePath: string): InjectResult {',
|
|
32
|
+
' const content = getClaudeMdContent();',
|
|
33
|
+
' const marker = getClaudeMdMarker();',
|
|
34
|
+
` const startMarker = '<!-- ' + marker + ' -->';`,
|
|
35
|
+
` const endMarker = '<!-- /' + marker + ' -->';`,
|
|
36
|
+
'',
|
|
37
|
+
' if (!existsSync(filePath)) {',
|
|
38
|
+
" writeFileSync(filePath, content + '\\n', 'utf-8');",
|
|
39
|
+
" return { injected: true, path: filePath, action: 'created' };",
|
|
40
|
+
' }',
|
|
41
|
+
'',
|
|
42
|
+
" const existing = readFileSync(filePath, 'utf-8');",
|
|
43
|
+
'',
|
|
44
|
+
' if (existing.includes(startMarker)) {',
|
|
45
|
+
' const startIdx = existing.indexOf(startMarker);',
|
|
46
|
+
' const endIdx = existing.indexOf(endMarker);',
|
|
47
|
+
' if (endIdx === -1) {',
|
|
48
|
+
" const updated = existing.slice(0, startIdx) + content + '\\n' + existing.slice(startIdx + startMarker.length);",
|
|
49
|
+
" writeFileSync(filePath, updated, 'utf-8');",
|
|
50
|
+
" return { injected: true, path: filePath, action: 'updated' };",
|
|
51
|
+
' }',
|
|
52
|
+
' const updated = existing.slice(0, startIdx) + content + existing.slice(endIdx + endMarker.length);',
|
|
53
|
+
" writeFileSync(filePath, updated, 'utf-8');",
|
|
54
|
+
" return { injected: true, path: filePath, action: 'updated' };",
|
|
55
|
+
' }',
|
|
56
|
+
'',
|
|
57
|
+
" const separator = existing.endsWith('\\n') ? '\\n' : '\\n\\n';",
|
|
58
|
+
" writeFileSync(filePath, existing + separator + content + '\\n', 'utf-8');",
|
|
59
|
+
" return { injected: true, path: filePath, action: 'appended' };",
|
|
60
|
+
'}',
|
|
61
|
+
'',
|
|
62
|
+
'/**',
|
|
63
|
+
' * Inject agent CLAUDE.md section into a project.',
|
|
64
|
+
' * - If no CLAUDE.md exists: creates one',
|
|
65
|
+
' * - If markers found: replaces content between them (update)',
|
|
66
|
+
' * - If no markers: appends section to end',
|
|
67
|
+
' */',
|
|
68
|
+
'export function injectClaudeMd(projectPath: string): InjectResult {',
|
|
69
|
+
" return injectIntoFile(join(projectPath, 'CLAUDE.md'));",
|
|
70
|
+
'}',
|
|
71
|
+
'',
|
|
72
|
+
'/**',
|
|
73
|
+
' * Inject agent CLAUDE.md section into the global ~/.claude/CLAUDE.md.',
|
|
74
|
+
" * Creates ~/.claude/ directory if it doesn't exist.",
|
|
75
|
+
' * This makes the activation phrase work in any project.',
|
|
76
|
+
' */',
|
|
77
|
+
'export function injectClaudeMdGlobal(): InjectResult {',
|
|
78
|
+
" const claudeDir = join(homedir(), '.claude');",
|
|
79
|
+
' if (!existsSync(claudeDir)) {',
|
|
80
|
+
' mkdirSync(claudeDir, { recursive: true });',
|
|
81
|
+
' }',
|
|
82
|
+
" return injectIntoFile(join(claudeDir, 'CLAUDE.md'));",
|
|
83
|
+
'}',
|
|
84
|
+
'',
|
|
85
|
+
'/**',
|
|
86
|
+
' * Check if the agent marker exists in a CLAUDE.md file.',
|
|
87
|
+
' */',
|
|
88
|
+
'export function hasAgentMarker(filePath: string): boolean {',
|
|
89
|
+
' if (!existsSync(filePath)) return false;',
|
|
90
|
+
" const content = readFileSync(filePath, 'utf-8');",
|
|
91
|
+
` return content.includes('<!-- ${marker} -->');`,
|
|
92
|
+
'}',
|
|
93
|
+
].join('\n');
|
|
94
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import type { AgentConfig } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate the LLM client file for a new agent.
|
|
5
|
+
* Contains LLMClient (OpenAI fetch + Anthropic SDK) and ModelRouter (inlined).
|
|
6
|
+
* Uses config.id to resolve ~/.{agentId}/model-routing.json.
|
|
7
|
+
*/
|
|
8
|
+
export function generateLLMClient(config: AgentConfig): string {
|
|
9
|
+
return `/**
|
|
10
|
+
* LLM Client — Unified OpenAI/Anthropic caller with key pool rotation
|
|
11
|
+
* Generated by Soleri — do not edit manually.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
15
|
+
import { SecretString, LLMError, CircuitBreaker, retry, parseRateLimitHeaders } from '@soleri/core';
|
|
16
|
+
import type {
|
|
17
|
+
LLMCallOptions,
|
|
18
|
+
LLMCallResult,
|
|
19
|
+
RouteEntry,
|
|
20
|
+
RoutingConfig,
|
|
21
|
+
KeyPool,
|
|
22
|
+
} from '@soleri/core';
|
|
23
|
+
import * as fs from 'node:fs';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// CONSTANTS
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// MODEL ROUTER (inlined)
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
function loadRoutingConfig(): RoutingConfig {
|
|
38
|
+
const defaultConfig: RoutingConfig = {
|
|
39
|
+
routes: [],
|
|
40
|
+
defaultOpenAIModel: 'gpt-4o-mini',
|
|
41
|
+
defaultAnthropicModel: 'claude-sonnet-4-20250514',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const configPath = path.join(
|
|
45
|
+
homedir(),
|
|
46
|
+
'.${config.id}',
|
|
47
|
+
'model-routing.json',
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
if (fs.existsSync(configPath)) {
|
|
52
|
+
const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Partial<RoutingConfig>;
|
|
53
|
+
if (data.routes && Array.isArray(data.routes)) {
|
|
54
|
+
defaultConfig.routes = data.routes;
|
|
55
|
+
}
|
|
56
|
+
if (data.defaultOpenAIModel) {
|
|
57
|
+
defaultConfig.defaultOpenAIModel = data.defaultOpenAIModel;
|
|
58
|
+
}
|
|
59
|
+
if (data.defaultAnthropicModel) {
|
|
60
|
+
defaultConfig.defaultAnthropicModel = data.defaultAnthropicModel;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Config not available — use defaults
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return defaultConfig;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function inferProvider(model: string): 'openai' | 'anthropic' {
|
|
71
|
+
if (model.startsWith('claude-') || model.startsWith('anthropic/')) {
|
|
72
|
+
return 'anthropic';
|
|
73
|
+
}
|
|
74
|
+
return 'openai';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
class ModelRouter {
|
|
78
|
+
private config: RoutingConfig;
|
|
79
|
+
|
|
80
|
+
constructor(config?: RoutingConfig) {
|
|
81
|
+
this.config = config || loadRoutingConfig();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
resolve(
|
|
85
|
+
caller: string,
|
|
86
|
+
task?: string,
|
|
87
|
+
originalModel?: string,
|
|
88
|
+
): { model: string; provider: 'openai' | 'anthropic' } {
|
|
89
|
+
if (task) {
|
|
90
|
+
const exactMatch = this.config.routes.find(
|
|
91
|
+
(r) => r.caller === caller && r.task === task,
|
|
92
|
+
);
|
|
93
|
+
if (exactMatch) {
|
|
94
|
+
return { model: exactMatch.model, provider: exactMatch.provider };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const callerMatch = this.config.routes.find(
|
|
99
|
+
(r) => r.caller === caller && !r.task,
|
|
100
|
+
);
|
|
101
|
+
if (callerMatch) {
|
|
102
|
+
return { model: callerMatch.model, provider: callerMatch.provider };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (originalModel) {
|
|
106
|
+
const provider = inferProvider(originalModel);
|
|
107
|
+
return { model: originalModel, provider };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { model: this.config.defaultOpenAIModel, provider: 'openai' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getRoutes(): RouteEntry[] {
|
|
114
|
+
return [...this.config.routes];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// =============================================================================
|
|
119
|
+
// LLM CLIENT
|
|
120
|
+
// =============================================================================
|
|
121
|
+
|
|
122
|
+
export class LLMClient {
|
|
123
|
+
private openaiKeyPool: KeyPool;
|
|
124
|
+
private anthropicKeyPool: KeyPool;
|
|
125
|
+
private anthropicClient: Anthropic | null = null;
|
|
126
|
+
private anthropicBreaker: CircuitBreaker;
|
|
127
|
+
private anthropicKeyFingerprint: string = '';
|
|
128
|
+
private router: ModelRouter;
|
|
129
|
+
|
|
130
|
+
constructor(openaiKeyPool: KeyPool, anthropicKeyPool: KeyPool) {
|
|
131
|
+
this.openaiKeyPool = openaiKeyPool;
|
|
132
|
+
this.anthropicKeyPool = anthropicKeyPool;
|
|
133
|
+
this.anthropicBreaker = new CircuitBreaker({
|
|
134
|
+
name: 'llm-anthropic',
|
|
135
|
+
failureThreshold: 5,
|
|
136
|
+
resetTimeoutMs: 60_000,
|
|
137
|
+
});
|
|
138
|
+
this.router = new ModelRouter();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async complete(options: LLMCallOptions): Promise<LLMCallResult> {
|
|
142
|
+
const routed = this.router.resolve(
|
|
143
|
+
options.caller,
|
|
144
|
+
options.task,
|
|
145
|
+
options.model,
|
|
146
|
+
);
|
|
147
|
+
const resolvedOptions = { ...options, model: routed.model, provider: routed.provider };
|
|
148
|
+
|
|
149
|
+
return resolvedOptions.provider === 'anthropic'
|
|
150
|
+
? this.callAnthropic(resolvedOptions)
|
|
151
|
+
: this.callOpenAI(resolvedOptions);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
isAvailable(): { openai: boolean; anthropic: boolean } {
|
|
155
|
+
return {
|
|
156
|
+
openai: this.openaiKeyPool.hasKeys,
|
|
157
|
+
anthropic: this.anthropicKeyPool.hasKeys,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getRoutes(): RouteEntry[] {
|
|
162
|
+
return this.router.getRoutes();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ===========================================================================
|
|
166
|
+
// OPENAI
|
|
167
|
+
// ===========================================================================
|
|
168
|
+
|
|
169
|
+
private async callOpenAI(options: LLMCallOptions): Promise<LLMCallResult> {
|
|
170
|
+
const keyPool = this.openaiKeyPool.hasKeys ? this.openaiKeyPool : null;
|
|
171
|
+
|
|
172
|
+
if (!keyPool) {
|
|
173
|
+
throw new LLMError('OpenAI API key not configured', { retryable: false });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const start = Date.now();
|
|
177
|
+
|
|
178
|
+
const doRequest = async (): Promise<LLMCallResult> => {
|
|
179
|
+
const apiKey = keyPool.getActiveKey().expose();
|
|
180
|
+
const keyIndex = keyPool.activeKeyIndex;
|
|
181
|
+
|
|
182
|
+
const response = await fetch(OPENAI_API_URL, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: {
|
|
185
|
+
'Content-Type': 'application/json',
|
|
186
|
+
Authorization: \`Bearer \${apiKey}\`,
|
|
187
|
+
},
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
model: options.model,
|
|
190
|
+
messages: [
|
|
191
|
+
{ role: 'system', content: options.systemPrompt },
|
|
192
|
+
{ role: 'user', content: options.userPrompt },
|
|
193
|
+
],
|
|
194
|
+
temperature: options.temperature ?? 0.3,
|
|
195
|
+
max_completion_tokens: options.maxTokens ?? 500,
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (response.headers) {
|
|
200
|
+
const rateLimits = parseRateLimitHeaders(response.headers);
|
|
201
|
+
if (rateLimits.remaining !== null) {
|
|
202
|
+
keyPool.updateQuota(keyIndex, rateLimits.remaining);
|
|
203
|
+
keyPool.rotatePreemptive();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
if (response.status === 429 && keyPool.poolSize > 1) {
|
|
209
|
+
keyPool.rotateOnError();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const errorBody = await response.text();
|
|
213
|
+
throw new LLMError(
|
|
214
|
+
\`OpenAI API error: \${response.status} - \${errorBody}\`,
|
|
215
|
+
{ retryable: response.status === 429 || response.status >= 500, statusCode: response.status },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const data = (await response.json()) as {
|
|
220
|
+
choices: Array<{ message: { content: string } }>;
|
|
221
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
text: data.choices[0]?.message?.content || '',
|
|
226
|
+
model: options.model,
|
|
227
|
+
provider: 'openai' as const,
|
|
228
|
+
inputTokens: data.usage?.prompt_tokens,
|
|
229
|
+
outputTokens: data.usage?.completion_tokens,
|
|
230
|
+
durationMs: Date.now() - start,
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return retry(doRequest, { maxAttempts: 3 });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ===========================================================================
|
|
238
|
+
// ANTHROPIC
|
|
239
|
+
// ===========================================================================
|
|
240
|
+
|
|
241
|
+
private async callAnthropic(options: LLMCallOptions): Promise<LLMCallResult> {
|
|
242
|
+
const client = this.getAnthropicClient();
|
|
243
|
+
if (!client) {
|
|
244
|
+
throw new LLMError('Anthropic API key not configured', { retryable: false });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const start = Date.now();
|
|
248
|
+
|
|
249
|
+
return this.anthropicBreaker.call(() =>
|
|
250
|
+
retry(
|
|
251
|
+
async () => {
|
|
252
|
+
const response = await client.messages.create(
|
|
253
|
+
{
|
|
254
|
+
model: options.model,
|
|
255
|
+
max_tokens: options.maxTokens ?? 1024,
|
|
256
|
+
system: options.systemPrompt,
|
|
257
|
+
messages: [{ role: 'user', content: options.userPrompt }],
|
|
258
|
+
},
|
|
259
|
+
{ timeout: 60_000 },
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const text = response.content
|
|
263
|
+
.filter(
|
|
264
|
+
(block): block is Anthropic.TextBlock => block.type === 'text',
|
|
265
|
+
)
|
|
266
|
+
.map((block) => block.text)
|
|
267
|
+
.join('\\n');
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
text,
|
|
271
|
+
model: options.model,
|
|
272
|
+
provider: 'anthropic' as const,
|
|
273
|
+
inputTokens: response.usage?.input_tokens,
|
|
274
|
+
outputTokens: response.usage?.output_tokens,
|
|
275
|
+
durationMs: Date.now() - start,
|
|
276
|
+
};
|
|
277
|
+
},
|
|
278
|
+
{ maxAttempts: 2 },
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private getAnthropicClient(): Anthropic | null {
|
|
284
|
+
if (!this.anthropicKeyPool.hasKeys) return null;
|
|
285
|
+
|
|
286
|
+
const currentKey = this.anthropicKeyPool.getActiveKey().expose();
|
|
287
|
+
const currentFingerprint = currentKey.slice(-8);
|
|
288
|
+
|
|
289
|
+
if (currentFingerprint !== this.anthropicKeyFingerprint) {
|
|
290
|
+
this.anthropicClient = null;
|
|
291
|
+
this.anthropicKeyFingerprint = currentFingerprint;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (this.anthropicClient) return this.anthropicClient;
|
|
295
|
+
|
|
296
|
+
this.anthropicClient = new Anthropic({ apiKey: currentKey });
|
|
297
|
+
return this.anthropicClient;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
`;
|
|
301
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AgentConfig } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export function generatePackageJson(config: AgentConfig): string {
|
|
4
|
+
const pkg = {
|
|
5
|
+
name: `${config.id}-mcp`,
|
|
6
|
+
version: '1.0.0',
|
|
7
|
+
description: config.description,
|
|
8
|
+
type: 'module',
|
|
9
|
+
main: 'dist/index.js',
|
|
10
|
+
bin: { [`${config.id}-mcp`]: 'dist/index.js' },
|
|
11
|
+
scripts: {
|
|
12
|
+
dev: 'tsx src/index.ts',
|
|
13
|
+
build: 'tsc && node scripts/copy-assets.js',
|
|
14
|
+
start: 'node dist/index.js',
|
|
15
|
+
typecheck: 'tsc --noEmit',
|
|
16
|
+
test: 'vitest run',
|
|
17
|
+
'test:watch': 'vitest',
|
|
18
|
+
'test:coverage': 'vitest run --coverage',
|
|
19
|
+
},
|
|
20
|
+
keywords: ['mcp', 'agent', config.id, ...config.domains.slice(0, 5)],
|
|
21
|
+
license: 'MIT',
|
|
22
|
+
engines: { node: '>=18.0.0' },
|
|
23
|
+
dependencies: {
|
|
24
|
+
'@anthropic-ai/sdk': '^0.39.0',
|
|
25
|
+
'@modelcontextprotocol/sdk': '^1.12.1',
|
|
26
|
+
'@soleri/core': '^1.0.0',
|
|
27
|
+
zod: '^3.24.2',
|
|
28
|
+
},
|
|
29
|
+
devDependencies: {
|
|
30
|
+
'@types/node': '^22.13.4',
|
|
31
|
+
'@vitest/coverage-v8': '^3.0.5',
|
|
32
|
+
tsx: '^4.19.2',
|
|
33
|
+
typescript: '^5.7.3',
|
|
34
|
+
vitest: '^3.0.5',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return JSON.stringify(pkg, null, 2);
|
|
39
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AgentConfig } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export function generatePersona(config: AgentConfig): string {
|
|
4
|
+
const principleLines = config.principles.map((p) => ` '${escapeQuotes(p)}',`).join('\n');
|
|
5
|
+
|
|
6
|
+
return `export interface AgentPersona {
|
|
7
|
+
name: string;
|
|
8
|
+
role: string;
|
|
9
|
+
description: string;
|
|
10
|
+
principles: string[];
|
|
11
|
+
greeting: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const PERSONA: AgentPersona = {
|
|
15
|
+
name: '${escapeQuotes(config.name)}',
|
|
16
|
+
role: '${escapeQuotes(config.role)}',
|
|
17
|
+
description: '${escapeQuotes(config.description)}',
|
|
18
|
+
principles: [
|
|
19
|
+
${principleLines}
|
|
20
|
+
],
|
|
21
|
+
greeting: '${escapeQuotes(config.greeting)}',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function getPersonaPrompt(): string {
|
|
25
|
+
return [
|
|
26
|
+
\`You are \${PERSONA.name}, a \${PERSONA.role}.\`,
|
|
27
|
+
'',
|
|
28
|
+
PERSONA.description,
|
|
29
|
+
'',
|
|
30
|
+
'Core principles:',
|
|
31
|
+
...PERSONA.principles.map((p) => \`- \${p}\`),
|
|
32
|
+
'',
|
|
33
|
+
'When advising:',
|
|
34
|
+
'- Reference specific patterns from the knowledge vault',
|
|
35
|
+
'- Provide concrete examples, not just theory',
|
|
36
|
+
'- Flag anti-patterns with severity level',
|
|
37
|
+
'- Suggest the simplest approach that solves the problem',
|
|
38
|
+
].join('\\n');
|
|
39
|
+
}
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function escapeQuotes(s: string): string {
|
|
44
|
+
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
45
|
+
}
|