@exagent/agent 0.2.1 → 0.3.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/src/config.ts CHANGED
@@ -1,4 +1,7 @@
1
- import { readFileSync, existsSync, writeFileSync } from 'node:fs';
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, resolve } from 'node:path';
2
5
  import { z } from 'zod';
3
6
  import type { LLMProvider } from '@exagent/sdk';
4
7
 
@@ -20,6 +23,11 @@ export interface RuntimeConfig {
20
23
  strategy: {
21
24
  file?: string;
22
25
  template?: string;
26
+ prompt?: {
27
+ name?: string;
28
+ systemPrompt: string;
29
+ venues?: string[];
30
+ };
23
31
  };
24
32
  trading: {
25
33
  mode: 'live' | 'paper';
@@ -79,15 +87,43 @@ export interface RuntimeConfig {
79
87
  };
80
88
  }
81
89
 
82
- const configSchema = z.object({
90
+ export interface LocalSecretPayload {
91
+ apiToken: string;
92
+ walletPrivateKey?: string;
93
+ llmApiKey?: string;
94
+ }
95
+
96
+ export interface SecureStoreFile {
97
+ version: 1;
98
+ algorithm: 'aes-256-gcm';
99
+ kdf: {
100
+ name: 'scrypt';
101
+ salt: string;
102
+ keyLength: 32;
103
+ cost: number;
104
+ blockSize: number;
105
+ parallelization: number;
106
+ };
107
+ iv: string;
108
+ ciphertext: string;
109
+ authTag: string;
110
+ }
111
+
112
+ export interface LoadConfigOptions {
113
+ getSecretPassword?: () => Promise<string>;
114
+ }
115
+
116
+ const providerEnum = z.enum(['openai', 'anthropic', 'google', 'deepseek', 'mistral', 'groq', 'together', 'ollama']);
117
+
118
+ const runtimeSchema = z.object({
83
119
  agentId: z.string(),
84
120
  apiUrl: z.string().url(),
85
- apiToken: z.string(),
121
+ apiToken: z.string().min(1),
86
122
  wallet: z.object({
87
- privateKey: z.string(),
123
+ privateKey: z.string().regex(/^0x[a-fA-F0-9]{64}$/),
88
124
  }).optional(),
89
125
  llm: z.object({
90
- provider: z.enum(['openai', 'anthropic', 'google', 'deepseek', 'mistral', 'groq', 'together', 'ollama']),
126
+ provider: providerEnum,
91
127
  model: z.string().optional(),
92
128
  apiKey: z.string().optional(),
93
129
  endpoint: z.string().optional(),
@@ -97,6 +133,11 @@ const configSchema = z.object({
97
133
  strategy: z.object({
98
134
  file: z.string().optional(),
99
135
  template: z.string().optional(),
136
+ prompt: z.object({
137
+ name: z.string().optional(),
138
+ systemPrompt: z.string().min(1),
139
+ venues: z.array(z.string()).optional(),
140
+ }).optional(),
100
141
  }),
101
142
  trading: z.object({
102
143
  mode: z.enum(['live', 'paper']).default('paper'),
@@ -156,23 +197,172 @@ const configSchema = z.object({
156
197
  }).optional(),
157
198
  });
158
199
 
159
- export function loadConfig(path: string = 'agent-config.json'): RuntimeConfig {
200
+ const configFileSchema = z.object({
201
+ agentId: z.string(),
202
+ apiUrl: z.string().url(),
203
+ apiToken: z.string().min(1).optional(),
204
+ wallet: z.object({
205
+ privateKey: z.string().regex(/^0x[a-fA-F0-9]{64}$/),
206
+ }).optional(),
207
+ llm: z.object({
208
+ provider: providerEnum.optional(),
209
+ model: z.string().optional(),
210
+ apiKey: z.string().optional(),
211
+ endpoint: z.string().optional(),
212
+ temperature: z.number().min(0).max(2).optional(),
213
+ maxTokens: z.number().optional(),
214
+ }).default({}),
215
+ strategy: z.object({
216
+ file: z.string().optional(),
217
+ template: z.string().optional(),
218
+ prompt: z.object({
219
+ name: z.string().optional(),
220
+ systemPrompt: z.string().min(1),
221
+ venues: z.array(z.string()).optional(),
222
+ }).optional(),
223
+ }),
224
+ trading: runtimeSchema.shape.trading,
225
+ venues: runtimeSchema.shape.venues,
226
+ relay: runtimeSchema.shape.relay,
227
+ llmBudget: runtimeSchema.shape.llmBudget,
228
+ rpcOverrides: runtimeSchema.shape.rpcOverrides,
229
+ logging: runtimeSchema.shape.logging,
230
+ secrets: z.object({
231
+ bootstrapToken: z.string().optional(),
232
+ bootstrapExpiresAt: z.string().optional(),
233
+ secureStorePath: z.string().optional(),
234
+ }).optional(),
235
+ });
236
+
237
+ const secureStoreSchema = z.object({
238
+ version: z.literal(1),
239
+ algorithm: z.literal('aes-256-gcm'),
240
+ kdf: z.object({
241
+ name: z.literal('scrypt'),
242
+ salt: z.string(),
243
+ keyLength: z.literal(32),
244
+ cost: z.number().int().positive(),
245
+ blockSize: z.number().int().positive(),
246
+ parallelization: z.number().int().positive(),
247
+ }),
248
+ iv: z.string(),
249
+ ciphertext: z.string(),
250
+ authTag: z.string(),
251
+ });
252
+
253
+ export type RuntimeConfigFile = z.infer<typeof configFileSchema>;
254
+
255
+ const DEFAULT_SCRYPT_COST = 32768;
256
+ const DEFAULT_SCRYPT_BLOCK_SIZE = 8;
257
+ const DEFAULT_SCRYPT_PARALLELIZATION = 1;
258
+
259
+ function expandHomeDir(path: string): string {
260
+ if (!path.startsWith('~/')) return path;
261
+ return resolve(homedir(), path.slice(2));
262
+ }
263
+
264
+ export function getDefaultSecureStorePath(agentId: string): string {
265
+ return resolve(homedir(), '.exagent', 'agents', agentId, 'secrets.json');
266
+ }
267
+
268
+ export function readConfigFile(path: string = 'agent-config.json'): RuntimeConfigFile {
160
269
  if (!existsSync(path)) {
161
270
  throw new Error(`Config file not found: ${path}. Run 'exagent init' first.`);
162
271
  }
163
272
 
164
- const raw = readFileSync(path, 'utf-8');
165
273
  let parsed: unknown;
166
-
167
274
  try {
168
- parsed = JSON.parse(raw);
275
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
169
276
  } catch {
170
277
  throw new Error(`Invalid JSON in ${path}`);
171
278
  }
172
279
 
173
- // Apply environment variable overrides
174
- const config = parsed as Record<string, unknown>;
175
- const llm = (config.llm || {}) as Record<string, unknown>;
280
+ const result = configFileSchema.safeParse(parsed);
281
+ if (!result.success) {
282
+ const issues = result.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n');
283
+ throw new Error(`Invalid config file:\n${issues}`);
284
+ }
285
+
286
+ return result.data;
287
+ }
288
+
289
+ export function writeConfigFile(path: string, config: RuntimeConfigFile): void {
290
+ writeFileSync(path, JSON.stringify(config, null, 2));
291
+ }
292
+
293
+ export function encryptSecretPayload(payload: LocalSecretPayload, password: string): SecureStoreFile {
294
+ const salt = randomBytes(16);
295
+ const iv = randomBytes(12);
296
+ const key = scryptSync(password, salt, 32, {
297
+ N: DEFAULT_SCRYPT_COST,
298
+ r: DEFAULT_SCRYPT_BLOCK_SIZE,
299
+ p: DEFAULT_SCRYPT_PARALLELIZATION,
300
+ });
301
+
302
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
303
+ const plaintext = Buffer.from(JSON.stringify(payload), 'utf8');
304
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
305
+
306
+ return {
307
+ version: 1,
308
+ algorithm: 'aes-256-gcm',
309
+ kdf: {
310
+ name: 'scrypt',
311
+ salt: salt.toString('hex'),
312
+ keyLength: 32,
313
+ cost: DEFAULT_SCRYPT_COST,
314
+ blockSize: DEFAULT_SCRYPT_BLOCK_SIZE,
315
+ parallelization: DEFAULT_SCRYPT_PARALLELIZATION,
316
+ },
317
+ iv: iv.toString('hex'),
318
+ ciphertext: ciphertext.toString('hex'),
319
+ authTag: cipher.getAuthTag().toString('hex'),
320
+ };
321
+ }
322
+
323
+ export function decryptSecretPayload(path: string, password: string): LocalSecretPayload {
324
+ const secureStorePath = expandHomeDir(path);
325
+ if (!existsSync(secureStorePath)) {
326
+ throw new Error(`Encrypted secret store not found: ${secureStorePath}`);
327
+ }
328
+
329
+ let parsed: unknown;
330
+ try {
331
+ parsed = JSON.parse(readFileSync(secureStorePath, 'utf-8'));
332
+ } catch {
333
+ throw new Error(`Invalid JSON in encrypted secret store: ${secureStorePath}`);
334
+ }
335
+
336
+ const result = secureStoreSchema.safeParse(parsed);
337
+ if (!result.success) {
338
+ const issues = result.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n');
339
+ throw new Error(`Invalid encrypted secret store:\n${issues}`);
340
+ }
341
+
342
+ const store = result.data;
343
+ const key = scryptSync(password, Buffer.from(store.kdf.salt, 'hex'), store.kdf.keyLength, {
344
+ N: store.kdf.cost,
345
+ r: store.kdf.blockSize,
346
+ p: store.kdf.parallelization,
347
+ });
348
+
349
+ try {
350
+ const decipher = createDecipheriv(store.algorithm, key, Buffer.from(store.iv, 'hex'));
351
+ decipher.setAuthTag(Buffer.from(store.authTag, 'hex'));
352
+ const plaintext = Buffer.concat([
353
+ decipher.update(Buffer.from(store.ciphertext, 'hex')),
354
+ decipher.final(),
355
+ ]);
356
+ return JSON.parse(plaintext.toString('utf8')) as LocalSecretPayload;
357
+ } catch {
358
+ throw new Error('Unable to decrypt local secret store. Check your password.');
359
+ }
360
+ }
361
+
362
+ export async function loadConfig(path: string = 'agent-config.json', options: LoadConfigOptions = {}): Promise<RuntimeConfig> {
363
+ const parsed = readConfigFile(path);
364
+ const config = structuredClone(parsed) as RuntimeConfigFile & Record<string, unknown>;
365
+ const llm = { ...(config.llm || {}) } as Record<string, unknown>;
176
366
 
177
367
  if (process.env.EXAGENT_LLM_PROVIDER) llm.provider = process.env.EXAGENT_LLM_PROVIDER;
178
368
  if (process.env.EXAGENT_LLM_MODEL) llm.model = process.env.EXAGENT_LLM_MODEL;
@@ -183,9 +373,24 @@ export function loadConfig(path: string = 'agent-config.json'): RuntimeConfig {
183
373
  config.wallet = { privateKey: process.env.EXAGENT_WALLET_PRIVATE_KEY };
184
374
  }
185
375
 
376
+ if ((!config.apiToken || !llm.apiKey || !config.wallet) && parsed.secrets?.secureStorePath) {
377
+ const password = process.env.EXAGENT_SECRET_PASSWORD || await options.getSecretPassword?.();
378
+ if (!password) {
379
+ throw new Error('Encrypted secret store found, but no password was provided.');
380
+ }
381
+
382
+ const secrets = decryptSecretPayload(parsed.secrets.secureStorePath, password);
383
+ if (!config.apiToken) config.apiToken = secrets.apiToken;
384
+ if (!config.wallet && secrets.walletPrivateKey) config.wallet = { privateKey: secrets.walletPrivateKey };
385
+ if (!llm.apiKey && secrets.llmApiKey) llm.apiKey = secrets.llmApiKey;
386
+ }
387
+
388
+ if ((!config.apiToken || !llm.apiKey || !config.wallet) && parsed.secrets?.bootstrapToken && !parsed.secrets?.secureStorePath) {
389
+ throw new Error(`Config ${path} still requires first-time secure setup. Run 'exagent setup --config ${path}' or start the agent interactively.`);
390
+ }
391
+
186
392
  config.llm = llm;
187
393
 
188
- // Apply RPC endpoint overrides from environment
189
394
  const rpcOverrides: Record<string, string> = (config.rpcOverrides as Record<string, string>) || {};
190
395
  if (process.env.EXAGENT_RPC_BASE) rpcOverrides.base = process.env.EXAGENT_RPC_BASE;
191
396
  if (process.env.EXAGENT_RPC_ARBITRUM) rpcOverrides.arbitrum = process.env.EXAGENT_RPC_ARBITRUM;
@@ -195,9 +400,9 @@ export function loadConfig(path: string = 'agent-config.json'): RuntimeConfig {
195
400
  config.rpcOverrides = rpcOverrides;
196
401
  }
197
402
 
198
- const result = configSchema.safeParse(config);
403
+ const result = runtimeSchema.safeParse(config);
199
404
  if (!result.success) {
200
- const issues = result.error.issues.map(i => ` ${i.path.join('.')}: ${i.message}`).join('\n');
405
+ const issues = result.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n');
201
406
  throw new Error(`Invalid config:\n${issues}`);
202
407
  }
203
408
 
@@ -205,18 +410,10 @@ export function loadConfig(path: string = 'agent-config.json'): RuntimeConfig {
205
410
  }
206
411
 
207
412
  export function generateSampleConfig(agentId: string, apiUrl: string): string {
208
- const config = {
413
+ const config: RuntimeConfigFile = {
209
414
  agentId,
210
415
  apiUrl,
211
- apiToken: '<your-jwt-token>',
212
- wallet: {
213
- privateKey: '<your-hex-private-key>',
214
- },
215
- llm: {
216
- provider: 'openai',
217
- model: 'gpt-4o',
218
- apiKey: '<your-api-key>',
219
- },
416
+ llm: {},
220
417
  strategy: {
221
418
  template: 'momentum',
222
419
  },
@@ -266,6 +463,9 @@ export function generateSampleConfig(agentId: string, apiUrl: string): string {
266
463
  heartbeatIntervalMs: 30000,
267
464
  reconnectMaxAttempts: 50,
268
465
  },
466
+ secrets: {
467
+ secureStorePath: getDefaultSecureStorePath(agentId),
468
+ },
269
469
  };
270
470
 
271
471
  return JSON.stringify(config, null, 2);
package/src/setup.ts ADDED
@@ -0,0 +1,233 @@
1
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { stdin as input, stdout as output } from 'node:process';
6
+ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
7
+ import {
8
+ encryptSecretPayload,
9
+ getDefaultSecureStorePath,
10
+ readConfigFile,
11
+ type LocalSecretPayload,
12
+ type RuntimeConfigFile,
13
+ writeConfigFile,
14
+ } from './config.js';
15
+
16
+ interface BootstrapPayload {
17
+ apiToken: string;
18
+ walletPrivateKey?: string;
19
+ llm?: {
20
+ provider?: string;
21
+ model?: string;
22
+ apiKey?: string;
23
+ };
24
+ }
25
+
26
+ function expandHomeDir(path: string): string {
27
+ if (!path.startsWith('~/')) return path;
28
+ return resolve(homedir(), path.slice(2));
29
+ }
30
+
31
+ async function prompt(question: string): Promise<string> {
32
+ const rl = createInterface({ input, output });
33
+ try {
34
+ return (await rl.question(question)).trim();
35
+ } finally {
36
+ rl.close();
37
+ }
38
+ }
39
+
40
+ async function promptSecret(question: string): Promise<string> {
41
+ const rl = createInterface({ input, output, terminal: true }) as ReturnType<typeof createInterface> & {
42
+ stdoutMuted?: boolean;
43
+ _writeToOutput?: (text: string) => void;
44
+ };
45
+ rl.stdoutMuted = true;
46
+ const originalWrite = rl._writeToOutput?.bind(rl);
47
+ rl._writeToOutput = (text: string) => {
48
+ if (!rl.stdoutMuted) {
49
+ originalWrite?.(text);
50
+ }
51
+ };
52
+
53
+ try {
54
+ const answer = (await rl.question(question)).trim();
55
+ output.write('\n');
56
+ return answer;
57
+ } finally {
58
+ rl.close();
59
+ }
60
+ }
61
+
62
+ export async function promptSecretPassword(question: string = 'Device password: '): Promise<string> {
63
+ return promptSecret(question);
64
+ }
65
+
66
+ async function promptPasswordWithConfirmation(): Promise<string> {
67
+ output.write('\n');
68
+ output.write('Important: this password encrypts the local wallet key, relay token, and agent LLM key for this device.\n');
69
+ output.write('If you lose this password and the agent wallet holds funds, those funds cannot be recovered.\n\n');
70
+
71
+ const ack = await prompt('Type "I UNDERSTAND" to continue: ');
72
+ if (ack !== 'I UNDERSTAND') {
73
+ throw new Error('Secure setup aborted');
74
+ }
75
+
76
+ while (true) {
77
+ const password = await promptSecret('Create a device password (min 12 chars): ');
78
+ if (password.length < 12) {
79
+ output.write('Password must be at least 12 characters.\n');
80
+ continue;
81
+ }
82
+
83
+ const confirm = await promptSecret('Confirm device password: ');
84
+ if (password !== confirm) {
85
+ output.write('Passwords did not match. Try again.\n');
86
+ continue;
87
+ }
88
+
89
+ return password;
90
+ }
91
+ }
92
+
93
+ async function promptWalletPrivateKey(): Promise<string> {
94
+ while (true) {
95
+ const choice = (await prompt('Wallet setup — [1] generate new wallet locally, [2] use existing private key: ')).trim();
96
+ if (choice === '1') {
97
+ const privateKey = generatePrivateKey();
98
+ const address = privateKeyToAccount(privateKey).address;
99
+ output.write(`Generated wallet address: ${address}\n`);
100
+ return privateKey;
101
+ }
102
+
103
+ if (choice === '2') {
104
+ const privateKey = await promptSecret('Wallet private key (0x...): ');
105
+ if (/^0x[a-fA-F0-9]{64}$/.test(privateKey)) {
106
+ return privateKey;
107
+ }
108
+ output.write('Invalid private key. Expected a 32-byte hex string prefixed with 0x.\n');
109
+ continue;
110
+ }
111
+
112
+ output.write('Enter 1 or 2.\n');
113
+ }
114
+ }
115
+
116
+ async function promptLlmProvider(): Promise<string> {
117
+ while (true) {
118
+ const provider = (await prompt('Agent LLM provider (openai/anthropic/google/deepseek/mistral/groq/together/ollama): ')).toLowerCase();
119
+ if (['openai', 'anthropic', 'google', 'deepseek', 'mistral', 'groq', 'together', 'ollama'].includes(provider)) {
120
+ return provider;
121
+ }
122
+ output.write('Unsupported provider.\n');
123
+ }
124
+ }
125
+
126
+ async function consumeBootstrapPackage(config: RuntimeConfigFile): Promise<BootstrapPayload> {
127
+ if (!config.secrets?.bootstrapToken) {
128
+ return { apiToken: '' };
129
+ }
130
+
131
+ const res = await fetch(`${config.apiUrl}/v1/agents/bootstrap/consume`, {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify({
135
+ agentId: config.agentId,
136
+ token: config.secrets.bootstrapToken,
137
+ }),
138
+ });
139
+
140
+ if (!res.ok) {
141
+ const body = await res.text();
142
+ throw new Error(`Failed to consume secure setup package: ${body}`);
143
+ }
144
+
145
+ const data = await res.json() as { payload: BootstrapPayload };
146
+ return data.payload;
147
+ }
148
+
149
+ async function buildLocalSecrets(config: RuntimeConfigFile, bootstrapPayload: BootstrapPayload): Promise<{ config: RuntimeConfigFile; secrets: LocalSecretPayload }> {
150
+ const nextConfig = structuredClone(config);
151
+ const llm = { ...(nextConfig.llm || {}) };
152
+
153
+ if (bootstrapPayload.llm?.provider && !llm.provider) {
154
+ llm.provider = bootstrapPayload.llm.provider as RuntimeConfigFile['llm']['provider'];
155
+ }
156
+ if (bootstrapPayload.llm?.model && !llm.model) {
157
+ llm.model = bootstrapPayload.llm.model;
158
+ }
159
+
160
+ if (!llm.provider) {
161
+ llm.provider = await promptLlmProvider() as RuntimeConfigFile['llm']['provider'];
162
+ }
163
+ if (!llm.model) {
164
+ llm.model = await prompt('Agent LLM model: ');
165
+ if (!llm.model) {
166
+ throw new Error('Agent LLM model is required');
167
+ }
168
+ }
169
+
170
+ const secrets: LocalSecretPayload = {
171
+ apiToken: bootstrapPayload.apiToken || nextConfig.apiToken || await promptSecret('Agent relay token: '),
172
+ walletPrivateKey: bootstrapPayload.walletPrivateKey || nextConfig.wallet?.privateKey || await promptWalletPrivateKey(),
173
+ llmApiKey: bootstrapPayload.llm?.apiKey || nextConfig.llm.apiKey || await promptSecret('Agent LLM API key: '),
174
+ };
175
+
176
+ if (!secrets.apiToken) {
177
+ throw new Error('Agent relay token is required');
178
+ }
179
+
180
+ nextConfig.llm = llm;
181
+ delete nextConfig.apiToken;
182
+ delete nextConfig.wallet;
183
+ delete nextConfig.llm.apiKey;
184
+
185
+ return { config: nextConfig, secrets };
186
+ }
187
+
188
+ function writeSecureStore(path: string, secrets: LocalSecretPayload, password: string): string {
189
+ const secureStorePath = expandHomeDir(path);
190
+ const encrypted = encryptSecretPayload(secrets, password);
191
+ const dir = dirname(secureStorePath);
192
+ if (!existsSync(dir)) {
193
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
194
+ }
195
+ writeFileSync(secureStorePath, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
196
+ try {
197
+ chmodSync(secureStorePath, 0o600);
198
+ } catch {
199
+ // Best effort only — some platforms ignore chmod semantics.
200
+ }
201
+ return secureStorePath;
202
+ }
203
+
204
+ export async function ensureLocalSetup(configPath: string): Promise<void> {
205
+ const config = readConfigFile(configPath);
206
+ const existingSecureStorePath = config.secrets?.secureStorePath ? expandHomeDir(config.secrets.secureStorePath) : null;
207
+ if (
208
+ existingSecureStorePath &&
209
+ !config.secrets?.bootstrapToken &&
210
+ existsSync(existingSecureStorePath) &&
211
+ !config.apiToken &&
212
+ !config.wallet?.privateKey &&
213
+ !config.llm.apiKey
214
+ ) {
215
+ return;
216
+ }
217
+
218
+ const bootstrapPayload = await consumeBootstrapPackage(config);
219
+ const { config: nextConfig, secrets } = await buildLocalSecrets(config, bootstrapPayload);
220
+ const password = await promptPasswordWithConfirmation();
221
+ const secureStorePath = writeSecureStore(
222
+ nextConfig.secrets?.secureStorePath || getDefaultSecureStorePath(nextConfig.agentId),
223
+ secrets,
224
+ password,
225
+ );
226
+
227
+ nextConfig.secrets = {
228
+ secureStorePath,
229
+ };
230
+
231
+ writeConfigFile(configPath, nextConfig);
232
+ output.write(`Encrypted local secret store created at ${secureStorePath}\n`);
233
+ }
@@ -1,16 +1,44 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
+ import { z } from 'zod';
3
4
  import type { StrategyFunction, StrategyContext, TradeSignal } from '@exagent/sdk';
4
5
  import { getTemplate } from './templates.js';
5
6
 
7
+ const promptSignalSchema = z.object({
8
+ symbol: z.string().min(1),
9
+ side: z.enum(['buy', 'sell', 'long', 'short']),
10
+ confidence: z.number().min(0).max(1).optional(),
11
+ reasoning: z.string().optional(),
12
+ venue: z.string().optional(),
13
+ chain: z.string().optional(),
14
+ size: z.number().positive().optional(),
15
+ price: z.number().positive().optional(),
16
+ fee: z.number().min(0).optional(),
17
+ venueFillId: z.string().optional(),
18
+ venueTimestamp: z.string().optional(),
19
+ leverage: z.number().positive().optional(),
20
+ orderType: z.string().optional(),
21
+ });
22
+
23
+ const promptSignalArraySchema = z.array(promptSignalSchema);
24
+
6
25
  export async function loadStrategy(config: {
7
26
  file?: string;
8
27
  template?: string;
28
+ prompt?: {
29
+ name?: string;
30
+ systemPrompt: string;
31
+ venues?: string[];
32
+ };
9
33
  }): Promise<StrategyFunction> {
10
34
  if (config.file) {
11
35
  return loadFromFile(config.file);
12
36
  }
13
37
 
38
+ if (config.prompt) {
39
+ return loadFromPrompt(config.prompt);
40
+ }
41
+
14
42
  if (config.template) {
15
43
  const template = getTemplate(config.template);
16
44
  if (!template) {
@@ -51,6 +79,76 @@ async function loadFromCode(code: string): Promise<StrategyFunction> {
51
79
  return fn;
52
80
  }
53
81
 
82
+ function loadFromPrompt(config: {
83
+ name?: string;
84
+ systemPrompt: string;
85
+ venues?: string[];
86
+ }): StrategyFunction {
87
+ return async (context: StrategyContext): Promise<TradeSignal[]> => {
88
+ const prices = context.market.getPrices();
89
+ const positions = context.position.openPositions.map((position) => ({
90
+ token: position.token,
91
+ quantity: position.quantity,
92
+ costBasisPerUnit: position.costBasisPerUnit,
93
+ venue: position.venue,
94
+ chain: position.chain,
95
+ }));
96
+
97
+ const response = await context.llm.chat([
98
+ { role: 'system', content: config.systemPrompt },
99
+ {
100
+ role: 'user',
101
+ content: [
102
+ `Strategy: ${config.name || 'Prompt Strategy'}`,
103
+ `Allowed venues: ${(config.venues || []).join(', ') || 'any'}`,
104
+ `Current prices: ${JSON.stringify(prices)}`,
105
+ `Open positions: ${JSON.stringify(positions)}`,
106
+ `Risk config: ${JSON.stringify(context.config)}`,
107
+ 'Return ONLY a JSON array of trade signals.',
108
+ ].join('\n'),
109
+ },
110
+ ]);
111
+
112
+ const match = response.content.match(/\[[\s\S]*\]/);
113
+ if (!match) {
114
+ context.log('Prompt strategy returned a non-JSON response; no signals emitted.');
115
+ return [];
116
+ }
117
+
118
+ try {
119
+ const parsed = promptSignalArraySchema.parse(JSON.parse(match[0]));
120
+ const signals: TradeSignal[] = [];
121
+ for (const signal of parsed) {
122
+ const price = signal.price ?? prices[signal.symbol.toUpperCase()];
123
+ if (!price || price <= 0) {
124
+ context.log(`Prompt strategy skipped ${signal.symbol}: no usable price in response or market cache.`);
125
+ continue;
126
+ }
127
+
128
+ signals.push({
129
+ symbol: signal.symbol,
130
+ side: signal.side,
131
+ confidence: signal.confidence ?? 0.5,
132
+ reasoning: signal.reasoning,
133
+ venue: signal.venue || config.venues?.[0] || 'manual',
134
+ chain: signal.chain,
135
+ size: signal.size ?? 1,
136
+ price,
137
+ fee: signal.fee ?? 0,
138
+ venueFillId: signal.venueFillId ?? '',
139
+ venueTimestamp: signal.venueTimestamp ?? new Date().toISOString(),
140
+ leverage: signal.leverage,
141
+ orderType: signal.orderType,
142
+ });
143
+ }
144
+ return signals;
145
+ } catch (err) {
146
+ context.log(`Prompt strategy parse failed: ${(err as Error).message}`);
147
+ return [];
148
+ }
149
+ };
150
+ }
151
+
54
152
  const holdStrategy: StrategyFunction = async (_context: StrategyContext): Promise<TradeSignal[]> => {
55
153
  return []; // No trades — hold position
56
154
  };