vexi-cli 0.5.4 → 0.8.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/README.md +136 -14
- package/dist/agent.js +94 -10
- package/dist/cli.js +130 -9
- package/dist/config.js +18 -4
- package/dist/endpoint.js +174 -0
- package/dist/endpoint.test.js +146 -0
- package/dist/git/index.js +142 -0
- package/dist/git/index.test.js +178 -0
- package/dist/i18n/index.js +44 -12
- package/dist/mcp/client.js +8 -2
- package/dist/mcp/server.js +4 -3
- package/dist/memory/compress.test.js +52 -0
- package/dist/memory/index.js +10 -5
- package/dist/onboarding.js +105 -0
- package/dist/onboarding.test.js +76 -0
- package/dist/providers/anthropic.js +4 -3
- package/dist/providers/detect.js +18 -3
- package/dist/providers/index.js +43 -7
- package/dist/providers/openai-compat.js +1 -0
- package/dist/providers/types.js +11 -2
- package/dist/snapshots/index.js +261 -0
- package/dist/snapshots/index.test.js +105 -0
- package/dist/update/index.js +190 -0
- package/dist/version.js +4 -0
- package/package.json +11 -2
package/dist/i18n/index.js
CHANGED
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
export const SUPPORTED_LANGS = ['en', 'ar', 'es', 'pt', 'fr'];
|
|
13
13
|
const en = {
|
|
14
14
|
welcome: 'Welcome to Vexi — your AI coding agent in the terminal.',
|
|
15
|
-
firstRunIntro: 'First run: Vexi needs an API key
|
|
15
|
+
firstRunIntro: 'First run: Vexi needs an API key.\nSupported: Anthropic · OpenAI · OpenRouter · Groq · Gemini · GLM · Mistral · Cerebras · DeepSeek · Qwen · Kimi · MiniMax\nIf auto-detection picks the wrong provider, just re-enter your key and select from the list.\nStored locally in ~/.vexi/config.json — no login, no server, no telemetry.',
|
|
16
16
|
enterApiKey: 'Paste your API key',
|
|
17
17
|
detectedProvider: 'Provider detected: {provider}',
|
|
18
18
|
detectFailed: 'Could not auto-detect the provider for this key.',
|
|
19
19
|
selectProvider: 'Select your provider',
|
|
20
20
|
configSaved: 'Saved to {path} (readable only by your OS user).',
|
|
21
|
-
chatHint: 'Type your message. Commands: /help /model /clear /exit',
|
|
21
|
+
chatHint: 'Type your message. Commands: /help /model /clear /undo /redo /history /push /exit',
|
|
22
22
|
thinking: 'Thinking…',
|
|
23
23
|
goodbye: 'Goodbye! 👋',
|
|
24
24
|
invalidKey: 'The API key was rejected by the provider (unauthorized).',
|
|
@@ -26,7 +26,7 @@ const en = {
|
|
|
26
26
|
apiError: 'API error: {message}',
|
|
27
27
|
emptyKey: 'No key entered.',
|
|
28
28
|
historyCleared: 'Conversation history cleared.',
|
|
29
|
-
helpText: '/help
|
|
29
|
+
helpText: '/help show this help\n/model switch model (e.g. /model gpt-4o)\n/memory show compressed project memory\n/clear clear conversation history\n/undo revert last AI file edit\n/redo re-apply last undone edit\n/history list recent AI file edits\n/push stage, commit and push to git (/push --only to skip commit)\n/exit quit Vexi',
|
|
30
30
|
modelSwitched: 'Model switched to {model}',
|
|
31
31
|
configReset: 'Configuration deleted. Run `vexi` to set up again.',
|
|
32
32
|
configResetNone: 'No configuration found.',
|
|
@@ -59,16 +59,24 @@ const en = {
|
|
|
59
59
|
learnPreview: 'Learned from {sessions} sessions ({signals} corrections found):',
|
|
60
60
|
learnApplyHint: 'Looks right? Save it with: vexi learn --apply',
|
|
61
61
|
learnApplied: 'Learned style saved: {path} — now injected into every session.',
|
|
62
|
+
undoNone: 'Nothing to undo — no file snapshots in this session.',
|
|
63
|
+
undoDone: 'Reverted: {files}',
|
|
64
|
+
redoNone: 'Nothing to redo.',
|
|
65
|
+
redoDone: 'Re-applied: {files}',
|
|
66
|
+
historyNone: 'No file snapshots yet — make some AI edits first.',
|
|
67
|
+
historyHeader: 'Recent AI file edits (newest first):',
|
|
68
|
+
cleanDone: 'Cleaned {count} old snapshot session(s).',
|
|
69
|
+
snapshotNoSession: 'No active Vexi session found. Run `vexi` first to start a session.',
|
|
62
70
|
};
|
|
63
71
|
const es = {
|
|
64
72
|
welcome: 'Bienvenido a Vexi — tu agente de programación con IA en la terminal.',
|
|
65
|
-
firstRunIntro: 'Primer uso: Vexi necesita una clave API (Anthropic
|
|
73
|
+
firstRunIntro: 'Primer uso: Vexi necesita una clave API.\nCompatibles (varios son gratuitos): Anthropic · OpenAI · OpenRouter · Groq · Gemini · GLM · Mistral · Cerebras\nGuardada en ~/.vexi/config.json — sin registro, sin servidor, sin telemetría.',
|
|
66
74
|
enterApiKey: 'Pega tu clave API',
|
|
67
75
|
detectedProvider: 'Proveedor detectado: {provider}',
|
|
68
76
|
detectFailed: 'No se pudo detectar automáticamente el proveedor de esta clave.',
|
|
69
77
|
selectProvider: 'Selecciona tu proveedor',
|
|
70
78
|
configSaved: 'Guardado en {path} (solo legible por tu usuario del sistema).',
|
|
71
|
-
chatHint: 'Escribe tu mensaje. Comandos: /help /model /clear /exit',
|
|
79
|
+
chatHint: 'Escribe tu mensaje. Comandos: /help /model /clear /push /exit',
|
|
72
80
|
thinking: 'Pensando…',
|
|
73
81
|
goodbye: '¡Hasta luego! 👋',
|
|
74
82
|
invalidKey: 'El proveedor rechazó la clave API (no autorizada).',
|
|
@@ -76,7 +84,7 @@ const es = {
|
|
|
76
84
|
apiError: 'Error de API: {message}',
|
|
77
85
|
emptyKey: 'No se introdujo ninguna clave.',
|
|
78
86
|
historyCleared: 'Historial de conversación borrado.',
|
|
79
|
-
helpText: '/help mostrar esta ayuda\n/model cambiar modelo (p. ej. /model gpt-4o)\n/memory ver la memoria comprimida del proyecto\n/clear borrar historial de conversación\n/exit salir de Vexi',
|
|
87
|
+
helpText: '/help mostrar esta ayuda\n/model cambiar modelo (p. ej. /model gpt-4o)\n/memory ver la memoria comprimida del proyecto\n/clear borrar historial de conversación\n/push confirmar y enviar cambios a git\n/exit salir de Vexi',
|
|
80
88
|
modelSwitched: 'Modelo cambiado a {model}',
|
|
81
89
|
configReset: 'Configuración eliminada. Ejecuta `vexi` para configurar de nuevo.',
|
|
82
90
|
configResetNone: 'No se encontró configuración.',
|
|
@@ -109,16 +117,24 @@ const es = {
|
|
|
109
117
|
learnPreview: 'Aprendido de {sessions} sesiones ({signals} correcciones encontradas):',
|
|
110
118
|
learnApplyHint: '¿Se ve bien? Guárdalo con: vexi learn --apply',
|
|
111
119
|
learnApplied: 'Estilo aprendido guardado: {path} — ahora se inyecta en cada sesión.',
|
|
120
|
+
undoNone: 'Nada que deshacer — no hay instantáneas de archivos en esta sesión.',
|
|
121
|
+
undoDone: 'Revertido: {files}',
|
|
122
|
+
redoNone: 'Nada que rehacer.',
|
|
123
|
+
redoDone: 'Reaplicado: {files}',
|
|
124
|
+
historyNone: 'Aún no hay instantáneas de archivos — haz ediciones con IA primero.',
|
|
125
|
+
historyHeader: 'Ediciones de archivos recientes de la IA (más recientes primero):',
|
|
126
|
+
cleanDone: 'Se eliminaron {count} sesión(es) de instantáneas antiguas.',
|
|
127
|
+
snapshotNoSession: 'No se encontró ninguna sesión activa de Vexi. Ejecuta `vexi` primero.',
|
|
112
128
|
};
|
|
113
129
|
const pt = {
|
|
114
130
|
welcome: 'Bem-vindo ao Vexi — seu agente de programação com IA no terminal.',
|
|
115
|
-
firstRunIntro: 'Primeira execução: o Vexi precisa de uma chave de API (Anthropic
|
|
131
|
+
firstRunIntro: 'Primeira execução: o Vexi precisa de uma chave de API.\nCompatíveis (vários gratuitos): Anthropic · OpenAI · OpenRouter · Groq · Gemini · GLM · Mistral · Cerebras\nGuardada em ~/.vexi/config.json — sem login, sem servidor, sem telemetria.',
|
|
116
132
|
enterApiKey: 'Cole sua chave de API',
|
|
117
133
|
detectedProvider: 'Provedor detectado: {provider}',
|
|
118
134
|
detectFailed: 'Não foi possível detectar automaticamente o provedor desta chave.',
|
|
119
135
|
selectProvider: 'Selecione seu provedor',
|
|
120
136
|
configSaved: 'Salvo em {path} (legível apenas pelo seu usuário do sistema).',
|
|
121
|
-
chatHint: 'Digite sua mensagem. Comandos: /help /model /clear /exit',
|
|
137
|
+
chatHint: 'Digite sua mensagem. Comandos: /help /model /clear /push /exit',
|
|
122
138
|
thinking: 'Pensando…',
|
|
123
139
|
goodbye: 'Até logo! 👋',
|
|
124
140
|
invalidKey: 'O provedor rejeitou a chave de API (não autorizada).',
|
|
@@ -126,7 +142,7 @@ const pt = {
|
|
|
126
142
|
apiError: 'Erro de API: {message}',
|
|
127
143
|
emptyKey: 'Nenhuma chave inserida.',
|
|
128
144
|
historyCleared: 'Histórico de conversa apagado.',
|
|
129
|
-
helpText: '/help mostrar esta ajuda\n/model trocar modelo (ex.: /model gpt-4o)\n/memory ver a memória comprimida do projeto\n/clear apagar histórico de conversa\n/exit sair do Vexi',
|
|
145
|
+
helpText: '/help mostrar esta ajuda\n/model trocar modelo (ex.: /model gpt-4o)\n/memory ver a memória comprimida do projeto\n/clear apagar histórico de conversa\n/push confirmar e enviar mudanças ao git\n/exit sair do Vexi',
|
|
130
146
|
modelSwitched: 'Modelo alterado para {model}',
|
|
131
147
|
configReset: 'Configuração excluída. Execute `vexi` para configurar novamente.',
|
|
132
148
|
configResetNone: 'Nenhuma configuração encontrada.',
|
|
@@ -159,16 +175,24 @@ const pt = {
|
|
|
159
175
|
learnPreview: 'Aprendido de {sessions} sessões ({signals} correções encontradas):',
|
|
160
176
|
learnApplyHint: 'Parece certo? Salve com: vexi learn --apply',
|
|
161
177
|
learnApplied: 'Estilo aprendido salvo: {path} — agora é injetado em cada sessão.',
|
|
178
|
+
undoNone: 'Nada a desfazer — sem instantâneos de arquivos nesta sessão.',
|
|
179
|
+
undoDone: 'Revertido: {files}',
|
|
180
|
+
redoNone: 'Nada a refazer.',
|
|
181
|
+
redoDone: 'Reaplicado: {files}',
|
|
182
|
+
historyNone: 'Ainda não há instantâneos de arquivos — faça edições com IA primeiro.',
|
|
183
|
+
historyHeader: 'Edições de arquivos recentes da IA (mais recentes primeiro):',
|
|
184
|
+
cleanDone: 'Foram removidas {count} sessão(ões) de instantâneos antigas.',
|
|
185
|
+
snapshotNoSession: 'Nenhuma sessão ativa do Vexi encontrada. Execute `vexi` primeiro.',
|
|
162
186
|
};
|
|
163
187
|
const fr = {
|
|
164
188
|
welcome: 'Bienvenue dans Vexi — votre agent de codage IA dans le terminal.',
|
|
165
|
-
firstRunIntro: 'Première utilisation : Vexi a besoin d\'une clé API (Anthropic
|
|
189
|
+
firstRunIntro: 'Première utilisation : Vexi a besoin d\'une clé API.\nCompatibles (plusieurs gratuits) : Anthropic · OpenAI · OpenRouter · Groq · Gemini · GLM · Mistral · Cerebras\nStockée dans ~/.vexi/config.json — sans compte, sans serveur, sans télémétrie.',
|
|
166
190
|
enterApiKey: 'Collez votre clé API',
|
|
167
191
|
detectedProvider: 'Fournisseur détecté : {provider}',
|
|
168
192
|
detectFailed: 'Impossible de détecter automatiquement le fournisseur de cette clé.',
|
|
169
193
|
selectProvider: 'Sélectionnez votre fournisseur',
|
|
170
194
|
configSaved: 'Enregistré dans {path} (lisible uniquement par votre utilisateur système).',
|
|
171
|
-
chatHint: 'Tapez votre message. Commandes : /help /model /clear /exit',
|
|
195
|
+
chatHint: 'Tapez votre message. Commandes : /help /model /clear /push /exit',
|
|
172
196
|
thinking: 'Réflexion…',
|
|
173
197
|
goodbye: 'À bientôt ! 👋',
|
|
174
198
|
invalidKey: 'La clé API a été rejetée par le fournisseur (non autorisée).',
|
|
@@ -176,7 +200,7 @@ const fr = {
|
|
|
176
200
|
apiError: 'Erreur API : {message}',
|
|
177
201
|
emptyKey: 'Aucune clé saisie.',
|
|
178
202
|
historyCleared: 'Historique de conversation effacé.',
|
|
179
|
-
helpText: '/help afficher cette aide\n/model changer de modèle (ex. /model gpt-4o)\n/memory voir la mémoire compressée du projet\n/clear effacer l\'historique de conversation\n/exit quitter Vexi',
|
|
203
|
+
helpText: '/help afficher cette aide\n/model changer de modèle (ex. /model gpt-4o)\n/memory voir la mémoire compressée du projet\n/clear effacer l\'historique de conversation\n/push valider et pousser les modifications git\n/exit quitter Vexi',
|
|
180
204
|
modelSwitched: 'Modèle changé pour {model}',
|
|
181
205
|
configReset: 'Configuration supprimée. Lancez `vexi` pour reconfigurer.',
|
|
182
206
|
configResetNone: 'Aucune configuration trouvée.',
|
|
@@ -209,6 +233,14 @@ const fr = {
|
|
|
209
233
|
learnPreview: 'Appris de {sessions} sessions ({signals} corrections trouvées) :',
|
|
210
234
|
learnApplyHint: 'Ça vous convient ? Enregistrez avec : vexi learn --apply',
|
|
211
235
|
learnApplied: 'Style appris enregistré : {path} — désormais injecté dans chaque session.',
|
|
236
|
+
undoNone: 'Rien à annuler — aucun instantané de fichier dans cette session.',
|
|
237
|
+
undoDone: 'Rétabli : {files}',
|
|
238
|
+
redoNone: 'Rien à rétablir.',
|
|
239
|
+
redoDone: 'Réappliqué : {files}',
|
|
240
|
+
historyNone: 'Pas encore d\'instantanés de fichiers — effectuez d\'abord des modifications avec l\'IA.',
|
|
241
|
+
historyHeader: 'Modifications de fichiers récentes de l\'IA (plus récentes en premier) :',
|
|
242
|
+
cleanDone: '{count} session(s) d\'instantanés ancienne(s) supprimée(s).',
|
|
243
|
+
snapshotNoSession: 'Aucune session Vexi active trouvée. Exécutez `vexi` d\'abord.',
|
|
212
244
|
};
|
|
213
245
|
const STRINGS = {
|
|
214
246
|
en,
|
package/dist/mcp/client.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
11
11
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { VERSION } from '../version.js';
|
|
12
14
|
import { loadMcpConfig } from './config.js';
|
|
13
15
|
export class McpManager {
|
|
14
16
|
connections = [];
|
|
@@ -26,7 +28,7 @@ export class McpManager {
|
|
|
26
28
|
env: { ...process.env, ...server.env },
|
|
27
29
|
stderr: 'ignore',
|
|
28
30
|
});
|
|
29
|
-
const client = new Client({ name: 'vexi', version:
|
|
31
|
+
const client = new Client({ name: 'vexi', version: VERSION });
|
|
30
32
|
await client.connect(transport);
|
|
31
33
|
const { tools } = await client.listTools();
|
|
32
34
|
for (const tool of tools) {
|
|
@@ -46,12 +48,16 @@ export class McpManager {
|
|
|
46
48
|
}
|
|
47
49
|
return { connected, failed };
|
|
48
50
|
}
|
|
51
|
+
static ArgsSchema = z.record(z.string(), z.unknown());
|
|
49
52
|
/** Call a tool on a connected server; returns the text result. */
|
|
50
53
|
async callTool(server, tool, args) {
|
|
51
54
|
const connection = this.connections.find((c) => c.name === server);
|
|
52
55
|
if (!connection)
|
|
53
56
|
throw new Error(`MCP server "${server}" is not connected.`);
|
|
54
|
-
const
|
|
57
|
+
const parsed = McpManager.ArgsSchema.safeParse(args);
|
|
58
|
+
if (!parsed.success)
|
|
59
|
+
throw new Error(`Invalid tool arguments: ${parsed.error.message}`);
|
|
60
|
+
const result = await connection.client.callTool({ name: tool, arguments: parsed.data });
|
|
55
61
|
const content = Array.isArray(result.content) ? result.content : [];
|
|
56
62
|
const text = content
|
|
57
63
|
.map((c) => (c.type === 'text' ? (c.text ?? '') : `[${c.type}]`))
|
package/dist/mcp/server.js
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
24
24
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
25
25
|
import { z } from 'zod';
|
|
26
|
+
import { VERSION } from '../version.js';
|
|
26
27
|
import { loadConfig } from '../config.js';
|
|
27
|
-
import {
|
|
28
|
+
import { createProviderFromConfig } from '../providers/index.js';
|
|
28
29
|
import { scanProject, projectSummary } from '../scanner/index.js';
|
|
29
30
|
import { loadMemory, memoryBlock } from '../memory/index.js';
|
|
30
31
|
import { listSessions } from '../replay/recorder.js';
|
|
@@ -32,7 +33,7 @@ import { gatherSource, buildExplainMessages } from '../explain/index.js';
|
|
|
32
33
|
import { SUPPORTED_LANGS } from '../i18n/index.js';
|
|
33
34
|
export async function runMcpServer() {
|
|
34
35
|
const root = process.cwd();
|
|
35
|
-
const server = new McpServer({ name: 'vexi', version:
|
|
36
|
+
const server = new McpServer({ name: 'vexi', version: VERSION });
|
|
36
37
|
// ── Resources ──────────────────────────────────────────────────────────
|
|
37
38
|
server.registerResource('project-map', 'vexi://project', {
|
|
38
39
|
title: 'Project map',
|
|
@@ -92,7 +93,7 @@ export async function runMcpServer() {
|
|
|
92
93
|
};
|
|
93
94
|
}
|
|
94
95
|
try {
|
|
95
|
-
const provider =
|
|
96
|
+
const provider = createProviderFromConfig(config);
|
|
96
97
|
const source = await gatherSource(path);
|
|
97
98
|
const markdown = await provider.stream(buildExplainMessages(source, language), () => { });
|
|
98
99
|
return { content: [{ type: 'text', text: markdown }] };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { compressIntoMemory, KEEP_RECENT, COMPRESS_INTERVAL } from './index.js';
|
|
3
|
+
function makeMessages(n) {
|
|
4
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
5
|
+
role: (i % 2 === 0 ? 'user' : 'assistant'),
|
|
6
|
+
content: `message ${i}`,
|
|
7
|
+
}));
|
|
8
|
+
}
|
|
9
|
+
const failingProvider = {
|
|
10
|
+
id: 'openai',
|
|
11
|
+
model: 'gpt-4o',
|
|
12
|
+
stream: async () => { throw new Error('provider failure'); },
|
|
13
|
+
};
|
|
14
|
+
const emptyMemory = {
|
|
15
|
+
version: 1,
|
|
16
|
+
summary: '',
|
|
17
|
+
decisions: [],
|
|
18
|
+
updatedAt: '',
|
|
19
|
+
compressedCount: 0,
|
|
20
|
+
};
|
|
21
|
+
describe('Fix 1: maybeCompress data-loss guard', () => {
|
|
22
|
+
it('returns original memory unchanged when provider.stream throws', async () => {
|
|
23
|
+
const messages = makeMessages(KEEP_RECENT + COMPRESS_INTERVAL + 2);
|
|
24
|
+
const original = { ...emptyMemory, summary: 'prior summary' };
|
|
25
|
+
const result = await compressIntoMemory(failingProvider, original, messages);
|
|
26
|
+
expect(result).toBe(original); // same reference — not mutated, not replaced
|
|
27
|
+
expect(result.summary).toBe('prior summary');
|
|
28
|
+
});
|
|
29
|
+
it('returns original memory when provider returns malformed JSON', async () => {
|
|
30
|
+
const badProvider = {
|
|
31
|
+
id: 'openai',
|
|
32
|
+
model: 'gpt-4o',
|
|
33
|
+
stream: async () => 'this is not json at all',
|
|
34
|
+
};
|
|
35
|
+
const original = { ...emptyMemory, decisions: ['keep this'] };
|
|
36
|
+
const result = await compressIntoMemory(badProvider, original, makeMessages(4));
|
|
37
|
+
expect(result).toBe(original);
|
|
38
|
+
expect(result.decisions).toEqual(['keep this']);
|
|
39
|
+
});
|
|
40
|
+
it('returns updated memory when provider returns valid JSON', async () => {
|
|
41
|
+
const goodProvider = {
|
|
42
|
+
id: 'openai',
|
|
43
|
+
model: 'gpt-4o',
|
|
44
|
+
stream: async () => '{"summary":"new summary","decisions":["decision A"]}',
|
|
45
|
+
};
|
|
46
|
+
const original = { ...emptyMemory };
|
|
47
|
+
const result = await compressIntoMemory(goodProvider, original, makeMessages(4));
|
|
48
|
+
expect(result).not.toBe(original);
|
|
49
|
+
expect(result.summary).toBe('new summary');
|
|
50
|
+
expect(result.decisions).toEqual(['decision A']);
|
|
51
|
+
});
|
|
52
|
+
});
|
package/dist/memory/index.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* never forgets previous decisions — even across sessions.
|
|
15
15
|
*/
|
|
16
16
|
import { join } from 'node:path';
|
|
17
|
+
import { z } from 'zod';
|
|
17
18
|
import { readJson, writeJsonAtomic } from '../utils/fs-atomic.js';
|
|
18
19
|
/** Messages that always stay in the context verbatim. */
|
|
19
20
|
export const KEEP_RECENT = 10;
|
|
@@ -90,14 +91,12 @@ export async function compressIntoMemory(provider, memory, oldMessages) {
|
|
|
90
91
|
},
|
|
91
92
|
], () => { });
|
|
92
93
|
const parsed = extractJson(raw);
|
|
93
|
-
if (!parsed
|
|
94
|
+
if (!parsed)
|
|
94
95
|
return memory;
|
|
95
|
-
}
|
|
96
96
|
return {
|
|
97
97
|
version: 1,
|
|
98
98
|
summary: parsed.summary.trim(),
|
|
99
99
|
decisions: parsed.decisions
|
|
100
|
-
.filter((d) => typeof d === 'string')
|
|
101
100
|
.map((d) => d.trim())
|
|
102
101
|
.filter(Boolean)
|
|
103
102
|
.slice(0, 20),
|
|
@@ -109,14 +108,20 @@ export async function compressIntoMemory(provider, memory, oldMessages) {
|
|
|
109
108
|
return memory; // never let compression break the session
|
|
110
109
|
}
|
|
111
110
|
}
|
|
112
|
-
|
|
111
|
+
const MemoryJsonSchema = z.object({
|
|
112
|
+
summary: z.string(),
|
|
113
|
+
decisions: z.array(z.string()),
|
|
114
|
+
});
|
|
115
|
+
/** Extract and validate the first JSON object from a model reply (tolerates fences/prose). */
|
|
113
116
|
function extractJson(text) {
|
|
114
117
|
const start = text.indexOf('{');
|
|
115
118
|
const end = text.lastIndexOf('}');
|
|
116
119
|
if (start === -1 || end <= start)
|
|
117
120
|
return null;
|
|
118
121
|
try {
|
|
119
|
-
|
|
122
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
123
|
+
const result = MemoryJsonSchema.safeParse(parsed);
|
|
124
|
+
return result.success ? result.data : null;
|
|
120
125
|
}
|
|
121
126
|
catch {
|
|
122
127
|
return null;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive onboarding flow for `vexi setup`.
|
|
3
|
+
*
|
|
4
|
+
* The IO interface is injected so tests can drive the prompts without
|
|
5
|
+
* touching stdin/stdout. The optional discoverFn and saveFn are also
|
|
6
|
+
* injected for testability.
|
|
7
|
+
*/
|
|
8
|
+
import { saveConfig } from './config.js';
|
|
9
|
+
import { identifyFromUrl, discoverModels } from './endpoint.js';
|
|
10
|
+
export { loadConfig, saveConfig } from './config.js';
|
|
11
|
+
/**
|
|
12
|
+
* Run the interactive setup wizard.
|
|
13
|
+
*
|
|
14
|
+
* Steps:
|
|
15
|
+
* 1. Ask for the API endpoint URL
|
|
16
|
+
* 2. Identify the provider from the URL
|
|
17
|
+
* 3. Ask for the API key
|
|
18
|
+
* 4. Discover available models (or ask for a manual entry on failure)
|
|
19
|
+
* 5. Let the user choose a model (or enter one manually)
|
|
20
|
+
* 6. Save config and return it
|
|
21
|
+
*/
|
|
22
|
+
export async function runOnboarding(io, discoverFn = discoverModels, saveFn = saveConfig) {
|
|
23
|
+
io.write('');
|
|
24
|
+
io.write('Welcome to Vexi setup. Let\'s configure your AI provider.');
|
|
25
|
+
io.write('');
|
|
26
|
+
// Step 1: URL
|
|
27
|
+
const rawUrl = await io.prompt('Paste your API endpoint URL (e.g. https://openrouter.ai/api/v1): ');
|
|
28
|
+
if (!rawUrl.trim()) {
|
|
29
|
+
throw new Error('No URL provided. Run `vexi setup` again to configure.');
|
|
30
|
+
}
|
|
31
|
+
// Step 2: Identify
|
|
32
|
+
let identity;
|
|
33
|
+
try {
|
|
34
|
+
identity = identifyFromUrl(rawUrl.trim());
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error(`Could not parse URL "${rawUrl}". Please check the URL and try again.`);
|
|
38
|
+
}
|
|
39
|
+
io.write(` Detected provider: ${identity.displayName}`);
|
|
40
|
+
io.write(` Base URL: ${identity.baseUrl}`);
|
|
41
|
+
io.write('');
|
|
42
|
+
// Step 3: API key
|
|
43
|
+
const apiKey = await io.prompt(`Enter your ${identity.displayName} API key: `);
|
|
44
|
+
if (!apiKey.trim()) {
|
|
45
|
+
throw new Error('No API key provided. Run `vexi setup` again to configure.');
|
|
46
|
+
}
|
|
47
|
+
// Step 4: Discover models
|
|
48
|
+
let models = [];
|
|
49
|
+
try {
|
|
50
|
+
models = await discoverFn(identity, apiKey.trim());
|
|
51
|
+
if (models.length === 0) {
|
|
52
|
+
io.write(' No models returned by /models endpoint.');
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
io.write(` Found ${models.length} model(s).`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
io.write(` Warning: could not fetch models — ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
io.write('');
|
|
62
|
+
// Step 5: Choose or enter model
|
|
63
|
+
let model;
|
|
64
|
+
if (models.length > 0) {
|
|
65
|
+
io.write('Available models:');
|
|
66
|
+
models.slice(0, 20).forEach((m, i) => io.write(` ${String(i + 1).padStart(2)}. ${m}`));
|
|
67
|
+
if (models.length > 20) {
|
|
68
|
+
io.write(` ... and ${models.length - 20} more`);
|
|
69
|
+
}
|
|
70
|
+
io.write('');
|
|
71
|
+
const choice = await io.prompt(`Enter a number (1-${Math.min(20, models.length)}) or type a model ID directly: `);
|
|
72
|
+
const num = parseInt(choice.trim(), 10);
|
|
73
|
+
if (!isNaN(num) && num >= 1 && num <= models.length) {
|
|
74
|
+
model = models[num - 1];
|
|
75
|
+
}
|
|
76
|
+
else if (choice.trim()) {
|
|
77
|
+
model = choice.trim();
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
model = models[0];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const manualModel = await io.prompt('Enter the model ID to use (e.g. gpt-4o): ');
|
|
85
|
+
if (!manualModel.trim()) {
|
|
86
|
+
throw new Error('No model specified. Run `vexi setup` again to configure.');
|
|
87
|
+
}
|
|
88
|
+
model = manualModel.trim();
|
|
89
|
+
}
|
|
90
|
+
io.write('');
|
|
91
|
+
io.write(` Using model: ${model}`);
|
|
92
|
+
io.write('');
|
|
93
|
+
// Step 6: Save
|
|
94
|
+
const config = {
|
|
95
|
+
provider: identity.provider,
|
|
96
|
+
displayName: identity.displayName,
|
|
97
|
+
baseUrl: identity.baseUrl,
|
|
98
|
+
apiKey: apiKey.trim(),
|
|
99
|
+
model,
|
|
100
|
+
};
|
|
101
|
+
await saveFn(config);
|
|
102
|
+
io.write(`Configuration saved. You can now run: vexi`);
|
|
103
|
+
io.write('');
|
|
104
|
+
return config;
|
|
105
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { runOnboarding } from './onboarding.js';
|
|
3
|
+
function makeIO(answers) {
|
|
4
|
+
const lines = [];
|
|
5
|
+
let callIndex = 0;
|
|
6
|
+
const io = {
|
|
7
|
+
prompt: async (_q) => answers[callIndex++] ?? '',
|
|
8
|
+
write: (line) => lines.push(line),
|
|
9
|
+
};
|
|
10
|
+
return { io, lines };
|
|
11
|
+
}
|
|
12
|
+
describe('runOnboarding', () => {
|
|
13
|
+
it('happy path: discovers models and saves config', async () => {
|
|
14
|
+
const { io } = makeIO([
|
|
15
|
+
'https://openrouter.ai/api/v1', // URL
|
|
16
|
+
'sk-or-test', // API key
|
|
17
|
+
'1', // pick model #1
|
|
18
|
+
]);
|
|
19
|
+
const discoverFn = vi.fn().mockResolvedValue(['openai/gpt-4o', 'mistralai/mistral-7b']);
|
|
20
|
+
const saveFn = vi.fn().mockResolvedValue(undefined);
|
|
21
|
+
const config = await runOnboarding(io, discoverFn, saveFn);
|
|
22
|
+
expect(config.provider).toBe('openrouter');
|
|
23
|
+
expect(config.displayName).toBe('OpenRouter');
|
|
24
|
+
expect(config.baseUrl).toBe('https://openrouter.ai/api/v1');
|
|
25
|
+
expect(config.apiKey).toBe('sk-or-test');
|
|
26
|
+
expect(config.model).toBe('openai/gpt-4o');
|
|
27
|
+
expect(discoverFn).toHaveBeenCalledWith(expect.objectContaining({ provider: 'openrouter' }), 'sk-or-test');
|
|
28
|
+
expect(saveFn).toHaveBeenCalledWith(config);
|
|
29
|
+
});
|
|
30
|
+
it('falls back to manual model entry when /models fails', async () => {
|
|
31
|
+
const { io } = makeIO([
|
|
32
|
+
'https://api.groq.com/openai/v1', // URL
|
|
33
|
+
'gsk_test', // API key
|
|
34
|
+
'llama-3.3-70b-versatile', // manual model entry
|
|
35
|
+
]);
|
|
36
|
+
const discoverFn = vi.fn().mockRejectedValue(new Error('HTTP 401. Enter the model ID manually.'));
|
|
37
|
+
const saveFn = vi.fn().mockResolvedValue(undefined);
|
|
38
|
+
const config = await runOnboarding(io, discoverFn, saveFn);
|
|
39
|
+
expect(config.model).toBe('llama-3.3-70b-versatile');
|
|
40
|
+
expect(saveFn).toHaveBeenCalledWith(config);
|
|
41
|
+
});
|
|
42
|
+
it('throws when no URL is provided', async () => {
|
|
43
|
+
const { io } = makeIO(['']); // empty URL
|
|
44
|
+
await expect(runOnboarding(io, vi.fn(), vi.fn())).rejects.toThrow('No URL provided');
|
|
45
|
+
});
|
|
46
|
+
it('throws when no API key is provided', async () => {
|
|
47
|
+
const { io } = makeIO([
|
|
48
|
+
'https://openrouter.ai/api/v1', // valid URL
|
|
49
|
+
'', // empty API key
|
|
50
|
+
]);
|
|
51
|
+
const discoverFn = vi.fn().mockResolvedValue([]);
|
|
52
|
+
await expect(runOnboarding(io, discoverFn, vi.fn())).rejects.toThrow('No API key provided');
|
|
53
|
+
});
|
|
54
|
+
it('accepts a typed model id directly instead of a number', async () => {
|
|
55
|
+
const { io } = makeIO([
|
|
56
|
+
'https://api.openai.com/v1',
|
|
57
|
+
'sk-test',
|
|
58
|
+
'o3-mini', // typed directly, not a list number
|
|
59
|
+
]);
|
|
60
|
+
const discoverFn = vi.fn().mockResolvedValue(['gpt-4o', 'gpt-4o-mini']);
|
|
61
|
+
const saveFn = vi.fn().mockResolvedValue(undefined);
|
|
62
|
+
const config = await runOnboarding(io, discoverFn, saveFn);
|
|
63
|
+
expect(config.model).toBe('o3-mini');
|
|
64
|
+
});
|
|
65
|
+
it('defaults to first model when empty choice given', async () => {
|
|
66
|
+
const { io } = makeIO([
|
|
67
|
+
'https://api.openai.com/v1',
|
|
68
|
+
'sk-test',
|
|
69
|
+
'', // empty → default to first
|
|
70
|
+
]);
|
|
71
|
+
const discoverFn = vi.fn().mockResolvedValue(['gpt-4o', 'gpt-4o-mini']);
|
|
72
|
+
const saveFn = vi.fn().mockResolvedValue(undefined);
|
|
73
|
+
const config = await runOnboarding(io, discoverFn, saveFn);
|
|
74
|
+
expect(config.model).toBe('gpt-4o');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { ProviderError } from './types.js';
|
|
7
7
|
import { sseEvents, truncate } from './openai-compat.js';
|
|
8
|
-
const
|
|
8
|
+
const ANTHROPIC_BASE = 'https://api.anthropic.com/v1';
|
|
9
9
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
10
|
-
export function createAnthropicProvider(apiKey, model) {
|
|
10
|
+
export function createAnthropicProvider(apiKey, model, baseUrl) {
|
|
11
|
+
const messagesEndpoint = `${baseUrl ?? ANTHROPIC_BASE}/messages`;
|
|
11
12
|
return {
|
|
12
13
|
id: 'anthropic',
|
|
13
14
|
model,
|
|
@@ -18,7 +19,7 @@ export function createAnthropicProvider(apiKey, model) {
|
|
|
18
19
|
.map((m) => m.content)
|
|
19
20
|
.join('\n\n');
|
|
20
21
|
const chat = messages.filter((m) => m.role !== 'system');
|
|
21
|
-
const res = await fetch(
|
|
22
|
+
const res = await fetch(messagesEndpoint, {
|
|
22
23
|
method: 'POST',
|
|
23
24
|
headers: {
|
|
24
25
|
'Content-Type': 'application/json',
|
package/dist/providers/detect.js
CHANGED
|
@@ -14,13 +14,28 @@
|
|
|
14
14
|
* interactive fallback.
|
|
15
15
|
*/
|
|
16
16
|
export const PROVIDER_PATTERNS = [
|
|
17
|
+
// ── Distinctive prefixes (safe auto-detect) ───────────────────────────
|
|
17
18
|
{ provider: 'anthropic', pattern: /^sk-ant-/ },
|
|
18
19
|
{ provider: 'openrouter', pattern: /^sk-or-/ },
|
|
19
20
|
{ provider: 'groq', pattern: /^gsk_/ },
|
|
20
21
|
{ provider: 'gemini', pattern: /^AIza/ },
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
|
|
22
|
+
{ provider: 'cerebras', pattern: /^csk-/ },
|
|
23
|
+
// Zhipu AI (GLM): <32-char-lowercase-hex>.<alphanumeric>
|
|
24
|
+
// e.g. b277fbf3dc1045c79229ff3c65a6f89b.fZ3rWjaTnH6UW6rm
|
|
25
|
+
{ provider: 'glm', pattern: /^[0-9a-f]{32}\.[A-Za-z0-9]+$/ },
|
|
26
|
+
// ── OpenAI project keys (`sk-proj-...`) are distinctive ───────────────
|
|
27
|
+
{ provider: 'openai', pattern: /^sk-proj-/ },
|
|
28
|
+
// ── Ambiguous `sk-` prefix ────────────────────────────────────────────
|
|
29
|
+
// DeepSeek and Moonshot (Kimi) also start with `sk-`, just like classic
|
|
30
|
+
// OpenAI keys. Matching both patterns makes detectProvider() return null
|
|
31
|
+
// (ambiguous) so the user gets the interactive provider-selection prompt
|
|
32
|
+
// instead of silently mis-routing to the wrong API.
|
|
33
|
+
{ provider: 'deepseek', pattern: /^sk-[a-z0-9]{32}$/ }, // sk- + 32 lowercase alphanum
|
|
34
|
+
{ provider: 'moonshot', pattern: /^sk-[A-Za-z0-9]{43,}$/ }, // sk- + 43+ mixed alphanum
|
|
35
|
+
// Classic OpenAI keys (`sk-` without `proj-`, not matching deepseek/moonshot shapes):
|
|
36
|
+
{ provider: 'openai', pattern: /^sk-(?!proj-|ant-|or-)/ },
|
|
37
|
+
// Qwen (DashScope), Mistral, MiniMax have no distinctive key prefix →
|
|
38
|
+
// they always fall back to the interactive provider-selection list.
|
|
24
39
|
];
|
|
25
40
|
/**
|
|
26
41
|
* Sanitize a pasted API key: trim whitespace/newlines and strip
|
package/dist/providers/index.js
CHANGED
|
@@ -9,23 +9,31 @@ export { detectProvider, sanitizeKey, PROVIDER_PATTERNS } from './detect.js';
|
|
|
9
9
|
export { PROVIDER_INFO, ProviderError } from './types.js';
|
|
10
10
|
/** Base URLs for the OpenAI-compatible providers. */
|
|
11
11
|
const BASE_URLS = {
|
|
12
|
+
// ── International ────────────────────────────────────────────────────
|
|
12
13
|
openai: 'https://api.openai.com/v1',
|
|
13
14
|
openrouter: 'https://openrouter.ai/api/v1',
|
|
14
15
|
groq: 'https://api.groq.com/openai/v1',
|
|
15
|
-
// Gemini's OpenAI-compatibility endpoint
|
|
16
16
|
gemini: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
17
|
+
mistral: 'https://api.mistral.ai/v1',
|
|
18
|
+
cerebras: 'https://api.cerebras.ai/v1',
|
|
19
|
+
// ── Chinese AI ───────────────────────────────────────────────────────
|
|
20
|
+
glm: 'https://open.bigmodel.cn/api/paas/v4', // Zhipu AI — GLM-4-Flash free
|
|
21
|
+
deepseek: 'https://api.deepseek.com/v1', // DeepSeek V3 — very cheap, free credits
|
|
22
|
+
qwen: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', // Alibaba Qwen — international
|
|
23
|
+
moonshot: 'https://api.moonshot.cn/v1', // Kimi — free quota on signup
|
|
24
|
+
minimax: 'https://api.minimax.chat/v1', // MiniMax-Text-01 — free tier
|
|
17
25
|
};
|
|
18
26
|
export function createProvider(id, apiKey, model) {
|
|
19
|
-
const
|
|
27
|
+
const info = PROVIDER_INFO[id];
|
|
28
|
+
if (!info) {
|
|
29
|
+
throw new Error(`Unknown provider "${id}". Run \`vexi config reset\` to reconfigure.`);
|
|
30
|
+
}
|
|
31
|
+
const resolvedModel = model ?? info.defaultModel;
|
|
20
32
|
if (id === 'anthropic') {
|
|
21
33
|
return createAnthropicProvider(apiKey, resolvedModel);
|
|
22
34
|
}
|
|
23
35
|
const extraHeaders = id === 'openrouter'
|
|
24
|
-
? {
|
|
25
|
-
// OpenRouter attribution headers (optional but recommended)
|
|
26
|
-
'HTTP-Referer': 'https://github.com/Elomami1976/vexi',
|
|
27
|
-
'X-Title': 'Vexi',
|
|
28
|
-
}
|
|
36
|
+
? { 'HTTP-Referer': 'https://vexi.pro', 'X-Title': 'Vexi' }
|
|
29
37
|
: undefined;
|
|
30
38
|
return createOpenAICompatProvider({
|
|
31
39
|
id,
|
|
@@ -35,3 +43,31 @@ export function createProvider(id, apiKey, model) {
|
|
|
35
43
|
extraHeaders,
|
|
36
44
|
});
|
|
37
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Create a provider from a VexiConfig produced by `vexi setup`.
|
|
48
|
+
* When config.baseUrl is set, uses it directly (URL-based routing).
|
|
49
|
+
* Falls back to the ProviderId-based createProvider for old configs.
|
|
50
|
+
*/
|
|
51
|
+
export function createProviderFromConfig(config) {
|
|
52
|
+
if (!config.baseUrl) {
|
|
53
|
+
// Old-style config: route by ProviderId
|
|
54
|
+
return createProvider(config.provider, config.apiKey, config.model);
|
|
55
|
+
}
|
|
56
|
+
const model = config.model ?? 'gpt-4o';
|
|
57
|
+
if (config.provider === 'anthropic') {
|
|
58
|
+
return createAnthropicProvider(config.apiKey, model, config.baseUrl);
|
|
59
|
+
}
|
|
60
|
+
const extraHeaders = config.provider === 'openrouter'
|
|
61
|
+
? { 'HTTP-Referer': 'https://vexi.pro', 'X-Title': 'Vexi' }
|
|
62
|
+
: undefined;
|
|
63
|
+
// Z.ai GLM-5 models support reasoning_effort for extended thinking
|
|
64
|
+
const extraBody = model.startsWith('z-ai/glm-5') ? { reasoning_effort: 'high' } : undefined;
|
|
65
|
+
return createOpenAICompatProvider({
|
|
66
|
+
id: config.provider,
|
|
67
|
+
baseUrl: config.baseUrl,
|
|
68
|
+
apiKey: config.apiKey,
|
|
69
|
+
model,
|
|
70
|
+
extraHeaders,
|
|
71
|
+
extraBody,
|
|
72
|
+
});
|
|
73
|
+
}
|