vexi-cli 0.5.1

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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Vexi Learn — Feature 7 (Phase 5).
3
+ *
4
+ * Vexi adapts to your personal coding style by mining your own recorded
5
+ * sessions (`.vexi/sessions/*.json`, written by the replay recorder).
6
+ * Your corrections to the AI are the strongest signal: "use tabs",
7
+ * "don't use classes", "always answer in Arabic", "prefer fetch over
8
+ * axios" — durable preferences hidden inside everyday messages.
9
+ *
10
+ * vexi learn analyze recent sessions, preview the learned style
11
+ * vexi learn --apply save it as .vexi/skills/learned-style.md
12
+ *
13
+ * The saved skill is a normal markdown skill file, so it is automatically
14
+ * injected into the system prompt of every future session (Feature 2.5) —
15
+ * the agent literally gets more "you" over time. Everything stays local;
16
+ * the only network call is the one to your own model provider.
17
+ */
18
+ import { join } from 'node:path';
19
+ import { promises as fs } from 'node:fs';
20
+ import { listSessions, loadSession } from '../replay/recorder.js';
21
+ /** The skill file Vexi Learn owns (re-running learn overwrites it). */
22
+ export const LEARNED_SKILL_NAME = 'learned-style';
23
+ /** How many recent sessions to analyze by default. */
24
+ export const DEFAULT_MAX_SESSIONS = 10;
25
+ /** Per-message and total caps keep the prompt well under context limits. */
26
+ const MAX_MSG_LEN = 600;
27
+ const MAX_TOTAL_CHARS = 24_000;
28
+ /**
29
+ * Heuristics for "correction / preference" messages — the user pushing
30
+ * back or stating a durable rule. Multilingual on purpose (en/es/pt/fr/ar).
31
+ */
32
+ const SIGNAL_RE = new RegExp([
33
+ // English
34
+ String.raw `\b(don'?t|do not|stop|never|always|instead( of)?|prefer|rather than|please use|use\s+\S+\s+(not|instead)|no,|actually|wrong|rename|i (like|want|prefer))\b`,
35
+ // Spanish / Portuguese
36
+ String.raw `\b(no uses|en lugar de|prefiero|siempre|nunca|mejor usa|não use|em vez de|prefiro|sempre)\b`,
37
+ // French
38
+ String.raw `\b(n'utilise pas|au lieu de|je préfère|toujours|jamais|plutôt)\b`,
39
+ // Arabic
40
+ String.raw `(لا تستخدم|بدلًا من|بدلا من|أفضّل|افضل|دائمًا|دائما|أبدًا|استخدم)`,
41
+ ].join('|'), 'iu');
42
+ /** True when a user message looks like a correction or stated preference. */
43
+ export function isPreferenceSignal(message) {
44
+ return SIGNAL_RE.test(message);
45
+ }
46
+ /**
47
+ * Collect evidence from recent sessions: every user message, with
48
+ * correction-style messages separated out. Newest sessions first.
49
+ */
50
+ export async function gatherEvidence(root, maxSessions = DEFAULT_MAX_SESSIONS) {
51
+ const names = (await listSessions(root)).slice(0, maxSessions);
52
+ const signals = [];
53
+ const other = [];
54
+ let total = 0;
55
+ let sessionsAnalyzed = 0;
56
+ for (const name of names) {
57
+ const loaded = await loadSession(root, name);
58
+ if (!loaded)
59
+ continue;
60
+ sessionsAnalyzed++;
61
+ for (const event of loaded.session.events) {
62
+ if (event.role !== 'user')
63
+ continue;
64
+ const text = event.content.trim().slice(0, MAX_MSG_LEN);
65
+ if (!text || text.startsWith('/'))
66
+ continue; // skip slash commands
67
+ if (total + text.length > MAX_TOTAL_CHARS)
68
+ break;
69
+ total += text.length;
70
+ (isPreferenceSignal(text) ? signals : other).push(text);
71
+ }
72
+ }
73
+ return { sessionsAnalyzed, signals, other };
74
+ }
75
+ const LEARN_PROMPT = `You are a coding-style analyst. Below are real messages a developer
76
+ sent to their AI coding agent across past sessions. CORRECTIONS are messages where they
77
+ pushed back or stated a rule — these are the strongest evidence of durable preferences.
78
+
79
+ Extract the developer's PERSONAL CODING STYLE as a markdown skill file that a coding
80
+ agent will follow in every future session.
81
+
82
+ Rules:
83
+ - Only include DURABLE preferences (style, naming, libraries, language, formatting,
84
+ testing habits, communication preferences). Ignore one-off task instructions.
85
+ - Each preference must be backed by evidence in the messages. Never invent.
86
+ - Write in English (the file is consumed by an AI model). Be concise: short bullet
87
+ points grouped under a few ## headings, max ~300 words.
88
+ - Start the file with the line: # Learned coding style
89
+ - If the messages contain NO durable preferences at all, respond with exactly: NOTHING`;
90
+ /** Build the chat messages for the learn call (exported for tests). */
91
+ export function buildLearnMessages(evidence) {
92
+ const sections = [];
93
+ if (evidence.signals.length > 0) {
94
+ sections.push('CORRECTIONS / STATED PREFERENCES:\n' + evidence.signals.map((m) => `- ${m}`).join('\n'));
95
+ }
96
+ if (evidence.other.length > 0) {
97
+ sections.push('OTHER MESSAGES (context):\n' + evidence.other.map((m) => `- ${m}`).join('\n'));
98
+ }
99
+ return [
100
+ { role: 'system', content: LEARN_PROMPT },
101
+ { role: 'user', content: sections.join('\n\n') },
102
+ ];
103
+ }
104
+ /**
105
+ * Analyze recent sessions and distill the user's coding style.
106
+ * Returns `markdown: null` when there are no sessions or no durable signal.
107
+ */
108
+ export async function learn(provider, root, maxSessions = DEFAULT_MAX_SESSIONS) {
109
+ const evidence = await gatherEvidence(root, maxSessions);
110
+ if (evidence.signals.length === 0 && evidence.other.length === 0) {
111
+ return { markdown: null, evidence };
112
+ }
113
+ const raw = (await provider.stream(buildLearnMessages(evidence), () => { })).trim();
114
+ if (!raw || /^NOTHING\b/i.test(raw)) {
115
+ return { markdown: null, evidence };
116
+ }
117
+ // Strip accidental markdown fences around the whole reply.
118
+ const markdown = raw.replace(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/m, '$1').trim();
119
+ return { markdown, evidence };
120
+ }
121
+ /**
122
+ * Save the learned style as a regular skill file so it is injected into
123
+ * every future session. Returns the absolute file path.
124
+ */
125
+ export async function applyLearned(root, markdown) {
126
+ const dir = join(root, '.vexi', 'skills');
127
+ await fs.mkdir(dir, { recursive: true });
128
+ const path = join(dir, `${LEARNED_SKILL_NAME}.md`);
129
+ await fs.writeFile(path, markdown.trim() + '\n', 'utf8');
130
+ return path;
131
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * MCP client — Feature 6a (Vexi as MCP client).
3
+ *
4
+ * Connects to the stdio MCP servers configured in ~/.vexi/mcp.json and
5
+ * exposes their tools to the chat agent. Because Vexi is provider-agnostic
6
+ * (no native function-calling API), tool use is text-based: the model is
7
+ * shown the available tools in the system prompt and replies with a fenced
8
+ * ```vexi-tool``` JSON block, which the agent parses and executes here.
9
+ */
10
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
12
+ import { loadMcpConfig } from './config.js';
13
+ export class McpManager {
14
+ connections = [];
15
+ tools = [];
16
+ /** Connect to every configured server (errors are collected, not thrown). */
17
+ async connect() {
18
+ const config = await loadMcpConfig();
19
+ const connected = [];
20
+ const failed = [];
21
+ for (const [name, server] of Object.entries(config.mcpServers)) {
22
+ try {
23
+ const transport = new StdioClientTransport({
24
+ command: server.command,
25
+ args: server.args ?? [],
26
+ env: { ...process.env, ...server.env },
27
+ stderr: 'ignore',
28
+ });
29
+ const client = new Client({ name: 'vexi', version: '0.5.0' });
30
+ await client.connect(transport);
31
+ const { tools } = await client.listTools();
32
+ for (const tool of tools) {
33
+ this.tools.push({
34
+ server: name,
35
+ name: tool.name,
36
+ description: tool.description ?? '',
37
+ inputSchema: tool.inputSchema,
38
+ });
39
+ }
40
+ this.connections.push({ name, client });
41
+ connected.push(name);
42
+ }
43
+ catch (e) {
44
+ failed.push({ name, error: e instanceof Error ? e.message : String(e) });
45
+ }
46
+ }
47
+ return { connected, failed };
48
+ }
49
+ /** Call a tool on a connected server; returns the text result. */
50
+ async callTool(server, tool, args) {
51
+ const connection = this.connections.find((c) => c.name === server);
52
+ if (!connection)
53
+ throw new Error(`MCP server "${server}" is not connected.`);
54
+ const result = await connection.client.callTool({ name: tool, arguments: args });
55
+ const content = Array.isArray(result.content) ? result.content : [];
56
+ const text = content
57
+ .map((c) => (c.type === 'text' ? (c.text ?? '') : `[${c.type}]`))
58
+ .join('\n');
59
+ return result.isError ? `TOOL ERROR: ${text}` : text;
60
+ }
61
+ /** System-prompt block describing the available tools ('' when none). */
62
+ promptBlock() {
63
+ if (this.tools.length === 0)
64
+ return '';
65
+ const list = this.tools
66
+ .map((t) => `- ${t.server}/${t.name}: ${t.description.split('\n')[0]}\n input schema: ${JSON.stringify(t.inputSchema)}`)
67
+ .join('\n');
68
+ return [
69
+ '## External tools (MCP)',
70
+ 'You can call these tools. To call one, reply with ONLY a fenced block:',
71
+ '```vexi-tool',
72
+ '{"server": "<server>", "tool": "<name>", "arguments": { ... }}',
73
+ '```',
74
+ 'You will receive the tool result in the next message; then continue.',
75
+ 'Available tools:',
76
+ list,
77
+ ].join('\n');
78
+ }
79
+ async close() {
80
+ await Promise.allSettled(this.connections.map((c) => c.client.close()));
81
+ this.connections = [];
82
+ }
83
+ }
84
+ /** Parse a ```vexi-tool``` call from a model reply, or null. */
85
+ export function parseToolCall(reply) {
86
+ const match = reply.match(/```vexi-tool\s*\n([\s\S]*?)```/);
87
+ if (!match)
88
+ return null;
89
+ try {
90
+ const json = JSON.parse(match[1]);
91
+ if (typeof json.server === 'string' && typeof json.tool === 'string') {
92
+ return { server: json.server, tool: json.tool, arguments: json.arguments ?? {} };
93
+ }
94
+ }
95
+ catch {
96
+ // fall through
97
+ }
98
+ return null;
99
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * MCP client configuration — Feature 6a.
3
+ *
4
+ * External MCP servers are declared in ~/.vexi/mcp.json using the same
5
+ * shape as Claude Desktop's config, so users can copy entries verbatim:
6
+ *
7
+ * {
8
+ * "mcpServers": {
9
+ * "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"] }
10
+ * }
11
+ * }
12
+ */
13
+ import { join } from 'node:path';
14
+ import { VEXI_DIR } from '../config.js';
15
+ import { readJson, writeJsonAtomic } from '../utils/fs-atomic.js';
16
+ export const MCP_CONFIG_PATH = join(VEXI_DIR, 'mcp.json');
17
+ export async function loadMcpConfig() {
18
+ const config = await readJson(MCP_CONFIG_PATH);
19
+ if (!config || typeof config.mcpServers !== 'object' || config.mcpServers === null) {
20
+ return { mcpServers: {} };
21
+ }
22
+ return config;
23
+ }
24
+ export async function saveMcpConfig(config) {
25
+ // mode 600: server entries may carry env secrets (API tokens)
26
+ await writeJsonAtomic(MCP_CONFIG_PATH, config, { mode: 0o600, dirMode: 0o700 });
27
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * MCP server mode — Feature 6b (Vexi as MCP server, unique differentiator).
3
+ *
4
+ * `vexi --mcp-server` exposes Vexi's capabilities to OTHER AI agents
5
+ * (Claude Desktop, Claude Code, Cursor, any MCP client) over stdio:
6
+ *
7
+ * Resources:
8
+ * vexi://project project structure map (.vexi/project.json, rescanned)
9
+ * vexi://memory compressed project memory — decisions & summary
10
+ * vexi://sessions list of recorded sessions
11
+ *
12
+ * Tools:
13
+ * scan_project rescan the project and return the map
14
+ * explain_code explain any file/folder in en/ar/es/pt/fr
15
+ * project_memory read the running summary + key decisions
16
+ *
17
+ * Example client config (Claude Desktop):
18
+ * { "mcpServers": { "vexi": { "command": "vexi", "args": ["--mcp-server"] } } }
19
+ *
20
+ * IMPORTANT: stdout is the JSON-RPC channel — never console.log here
21
+ * (diagnostics go to stderr).
22
+ */
23
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
24
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
25
+ import { z } from 'zod';
26
+ import { loadConfig } from '../config.js';
27
+ import { createProvider } from '../providers/index.js';
28
+ import { scanProject, projectSummary } from '../scanner/index.js';
29
+ import { loadMemory, memoryBlock } from '../memory/index.js';
30
+ import { listSessions } from '../replay/recorder.js';
31
+ import { gatherSource, buildExplainMessages } from '../explain/index.js';
32
+ import { SUPPORTED_LANGS } from '../i18n/index.js';
33
+ export async function runMcpServer() {
34
+ const root = process.cwd();
35
+ const server = new McpServer({ name: 'vexi', version: '0.5.0' });
36
+ // ── Resources ──────────────────────────────────────────────────────────
37
+ server.registerResource('project-map', 'vexi://project', {
38
+ title: 'Project map',
39
+ description: 'Languages, stack, architecture layers and file list of the current project.',
40
+ mimeType: 'application/json',
41
+ }, async (uri) => {
42
+ const map = await scanProject(root);
43
+ return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(map, null, 2) }] };
44
+ });
45
+ server.registerResource('project-memory', 'vexi://memory', {
46
+ title: 'Project memory',
47
+ description: 'Compressed running summary and key decisions from past Vexi sessions.',
48
+ mimeType: 'application/json',
49
+ }, async (uri) => {
50
+ const memory = await loadMemory(root);
51
+ return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(memory, null, 2) }] };
52
+ });
53
+ server.registerResource('sessions', 'vexi://sessions', {
54
+ title: 'Recorded sessions',
55
+ description: 'List of recorded Vexi chat sessions (.vexi/sessions/).',
56
+ mimeType: 'application/json',
57
+ }, async (uri) => {
58
+ const sessions = await listSessions(root);
59
+ return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(sessions, null, 2) }] };
60
+ });
61
+ // ── Tools ──────────────────────────────────────────────────────────────
62
+ server.registerTool('scan_project', {
63
+ title: 'Scan project',
64
+ description: 'Scan the current project and return a compact structure summary (languages, stack, architecture layers).',
65
+ inputSchema: {},
66
+ }, async () => {
67
+ const map = await scanProject(root);
68
+ return { content: [{ type: 'text', text: projectSummary(map) }] };
69
+ });
70
+ server.registerTool('project_memory', {
71
+ title: 'Project memory',
72
+ description: 'Return the compressed project memory: running summary + key decisions from past sessions.',
73
+ inputSchema: {},
74
+ }, async () => {
75
+ const memory = await loadMemory(root);
76
+ const text = memoryBlock(memory) || 'No project memory recorded yet.';
77
+ return { content: [{ type: 'text', text }] };
78
+ });
79
+ server.registerTool('explain_code', {
80
+ title: 'Explain code',
81
+ description: `Explain a file or folder in any supported language (${SUPPORTED_LANGS.join('/')}). Returns a structured Markdown explanation.`,
82
+ inputSchema: {
83
+ path: z.string().describe('File or folder path, relative to the project root or absolute'),
84
+ language: z.enum(SUPPORTED_LANGS).default('en').describe('Output language'),
85
+ },
86
+ }, async ({ path, language }) => {
87
+ const config = await loadConfig();
88
+ if (!config) {
89
+ return {
90
+ content: [{ type: 'text', text: 'No API key configured. Run `vexi` once in a terminal to set up.' }],
91
+ isError: true,
92
+ };
93
+ }
94
+ try {
95
+ const provider = createProvider(config.provider, config.apiKey, config.model);
96
+ const source = await gatherSource(path);
97
+ const markdown = await provider.stream(buildExplainMessages(source, language), () => { });
98
+ return { content: [{ type: 'text', text: markdown }] };
99
+ }
100
+ catch (e) {
101
+ return {
102
+ content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }],
103
+ isError: true,
104
+ };
105
+ }
106
+ });
107
+ const transport = new StdioServerTransport();
108
+ await server.connect(transport);
109
+ console.error('Vexi MCP server running on stdio (project: ' + root + ')');
110
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Context Compression Engine — Feature 1 (Running Summary approach).
3
+ *
4
+ * Problem: in long sessions the conversation outgrows the context window.
5
+ * Naively deleting old messages makes the model forget earlier decisions.
6
+ *
7
+ * Strategy:
8
+ * - Recent messages always stay in full (KEEP_RECENT).
9
+ * - Every COMPRESS_INTERVAL messages, older messages are folded into a
10
+ * "running summary" + a list of key decision points, produced by the
11
+ * model itself (e.g. "User chose JWT for authentication").
12
+ * - The summary is persisted per-project in .vexi/memory.json (atomic
13
+ * writes) and loaded automatically on every session start, so Vexi
14
+ * never forgets previous decisions — even across sessions.
15
+ */
16
+ import { join } from 'node:path';
17
+ import { readJson, writeJsonAtomic } from '../utils/fs-atomic.js';
18
+ /** Messages that always stay in the context verbatim. */
19
+ export const KEEP_RECENT = 10;
20
+ /** Compress after this many new messages accumulate beyond KEEP_RECENT. */
21
+ export const COMPRESS_INTERVAL = 6;
22
+ const EMPTY_MEMORY = {
23
+ version: 1,
24
+ summary: '',
25
+ decisions: [],
26
+ updatedAt: '',
27
+ compressedCount: 0,
28
+ };
29
+ function memoryPath(root) {
30
+ return join(root, '.vexi', 'memory.json');
31
+ }
32
+ /** Load this project's memory (or an empty one). */
33
+ export async function loadMemory(root) {
34
+ const memory = await readJson(memoryPath(root));
35
+ return memory && memory.version === 1 ? memory : { ...EMPTY_MEMORY };
36
+ }
37
+ /** Persist memory atomically. */
38
+ export async function saveMemory(root, memory) {
39
+ await writeJsonAtomic(memoryPath(root), memory);
40
+ }
41
+ /**
42
+ * Render the memory as a system-prompt block.
43
+ * Returns '' when there is nothing remembered yet.
44
+ */
45
+ export function memoryBlock(memory) {
46
+ if (!memory.summary && memory.decisions.length === 0)
47
+ return '';
48
+ const parts = ['## Project memory (earlier context, compressed)'];
49
+ if (memory.summary)
50
+ parts.push(memory.summary);
51
+ if (memory.decisions.length > 0) {
52
+ parts.push('Key decisions:\n' + memory.decisions.map((d) => `- ${d}`).join('\n'));
53
+ }
54
+ return parts.join('\n');
55
+ }
56
+ /** Prompt used to fold old messages into the running summary. */
57
+ const COMPRESS_PROMPT = `You maintain the long-term memory of a coding session.
58
+ Merge the EXISTING MEMORY and the NEW MESSAGES below into an updated memory.
59
+
60
+ Rules:
61
+ - Keep it dense and factual; max ~200 words for the summary.
62
+ - "decisions" lists durable decision points worth remembering forever
63
+ (e.g. "User chose JWT for authentication", "Database switched to PostgreSQL").
64
+ Carry over old decisions unless they were explicitly reversed; max 20 items.
65
+ - Never invent information.
66
+
67
+ Respond with ONLY valid JSON, no markdown fences:
68
+ {"summary": "...", "decisions": ["...", "..."]}`;
69
+ /**
70
+ * Fold `oldMessages` into the running summary using the model itself.
71
+ *
72
+ * On any failure the previous memory is returned unchanged — compression
73
+ * is an optimization and must never break the chat loop.
74
+ */
75
+ export async function compressIntoMemory(provider, memory, oldMessages) {
76
+ if (oldMessages.length === 0)
77
+ return memory;
78
+ const transcript = oldMessages
79
+ .map((m) => `${m.role.toUpperCase()}: ${truncate(m.content, 1500)}`)
80
+ .join('\n\n');
81
+ const existing = memory.summary || memory.decisions.length
82
+ ? `Summary: ${memory.summary || '(none)'}\nDecisions:\n${memory.decisions.map((d) => `- ${d}`).join('\n') || '(none)'}`
83
+ : '(empty)';
84
+ try {
85
+ const raw = await provider.stream([
86
+ { role: 'system', content: COMPRESS_PROMPT },
87
+ {
88
+ role: 'user',
89
+ content: `EXISTING MEMORY:\n${existing}\n\nNEW MESSAGES:\n${transcript}`,
90
+ },
91
+ ], () => { });
92
+ const parsed = extractJson(raw);
93
+ if (!parsed || typeof parsed.summary !== 'string' || !Array.isArray(parsed.decisions)) {
94
+ return memory;
95
+ }
96
+ return {
97
+ version: 1,
98
+ summary: parsed.summary.trim(),
99
+ decisions: parsed.decisions
100
+ .filter((d) => typeof d === 'string')
101
+ .map((d) => d.trim())
102
+ .filter(Boolean)
103
+ .slice(0, 20),
104
+ updatedAt: new Date().toISOString(),
105
+ compressedCount: memory.compressedCount + oldMessages.length,
106
+ };
107
+ }
108
+ catch {
109
+ return memory; // never let compression break the session
110
+ }
111
+ }
112
+ /** Extract the first JSON object from a model reply (tolerates fences/prose). */
113
+ function extractJson(text) {
114
+ const start = text.indexOf('{');
115
+ const end = text.lastIndexOf('}');
116
+ if (start === -1 || end <= start)
117
+ return null;
118
+ try {
119
+ return JSON.parse(text.slice(start, end + 1));
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ function truncate(text, max) {
126
+ return text.length > max ? `${text.slice(0, max)}…` : text;
127
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Streaming client for the native Anthropic Messages API.
3
+ * Anthropic separates the system prompt from the messages array,
4
+ * so it gets its own small client instead of the OpenAI-compatible one.
5
+ */
6
+ import { ProviderError } from './types.js';
7
+ import { sseEvents, truncate } from './openai-compat.js';
8
+ const ANTHROPIC_API = 'https://api.anthropic.com/v1/messages';
9
+ const ANTHROPIC_VERSION = '2023-06-01';
10
+ export function createAnthropicProvider(apiKey, model) {
11
+ return {
12
+ id: 'anthropic',
13
+ model,
14
+ async stream(messages, onText) {
15
+ // Anthropic takes the system prompt as a top-level field
16
+ const system = messages
17
+ .filter((m) => m.role === 'system')
18
+ .map((m) => m.content)
19
+ .join('\n\n');
20
+ const chat = messages.filter((m) => m.role !== 'system');
21
+ const res = await fetch(ANTHROPIC_API, {
22
+ method: 'POST',
23
+ headers: {
24
+ 'Content-Type': 'application/json',
25
+ 'x-api-key': apiKey,
26
+ 'anthropic-version': ANTHROPIC_VERSION,
27
+ },
28
+ body: JSON.stringify({
29
+ model,
30
+ max_tokens: 8192,
31
+ ...(system ? { system } : {}),
32
+ messages: chat,
33
+ stream: true,
34
+ }),
35
+ }).catch((err) => {
36
+ throw new ProviderError(`Network error: ${err.message}`);
37
+ });
38
+ if (!res.ok || !res.body) {
39
+ const body = await res.text().catch(() => '');
40
+ throw new ProviderError(`anthropic API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
41
+ }
42
+ let full = '';
43
+ for await (const data of sseEvents(res.body)) {
44
+ try {
45
+ const json = JSON.parse(data);
46
+ if (json.type === 'content_block_delta' && json.delta?.type === 'text_delta') {
47
+ const text = json.delta.text;
48
+ full += text;
49
+ onText(text);
50
+ }
51
+ }
52
+ catch {
53
+ // Ignore malformed/keep-alive chunks
54
+ }
55
+ }
56
+ return full;
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * API key → provider auto-detection.
3
+ *
4
+ * Key formats change over time, so all patterns live in this single file
5
+ * and are intentionally loose (prefix-based). To support a new key format,
6
+ * just add/update an entry in PROVIDER_PATTERNS below.
7
+ */
8
+ /**
9
+ * Ordered list of detection patterns.
10
+ *
11
+ * Patterns are written to be mutually exclusive (the generic OpenAI `sk-`
12
+ * pattern uses a negative lookahead for `sk-ant-` / `sk-or-`), so a key
13
+ * matching more than one pattern is a genuine ambiguity and triggers the
14
+ * interactive fallback.
15
+ */
16
+ export const PROVIDER_PATTERNS = [
17
+ { provider: 'anthropic', pattern: /^sk-ant-/ },
18
+ { provider: 'openrouter', pattern: /^sk-or-/ },
19
+ { provider: 'groq', pattern: /^gsk_/ },
20
+ { provider: 'gemini', pattern: /^AIza/ },
21
+ // OpenAI: classic `sk-...` and new project keys `sk-proj-...`,
22
+ // excluding the Anthropic / OpenRouter prefixes above.
23
+ { provider: 'openai', pattern: /^sk-(?!ant-|or-)/ },
24
+ ];
25
+ /**
26
+ * Sanitize a pasted API key: trim whitespace/newlines and strip
27
+ * surrounding quotes (users often paste keys with extra characters).
28
+ */
29
+ export function sanitizeKey(raw) {
30
+ let key = raw.trim();
31
+ // Strip matching surrounding quotes ("key", 'key', `key`)
32
+ while (key.length >= 2 && `"'\``.includes(key[0]) && key[0] === key[key.length - 1]) {
33
+ key = key.slice(1, -1).trim();
34
+ }
35
+ return key;
36
+ }
37
+ /**
38
+ * Detect the provider for a (sanitized) API key.
39
+ *
40
+ * Returns the provider id when exactly one pattern matches.
41
+ * Returns `null` when zero or multiple patterns match — callers should
42
+ * fall back to asking the user to pick the provider manually.
43
+ */
44
+ export function detectProvider(key) {
45
+ const matches = PROVIDER_PATTERNS.filter(({ pattern }) => pattern.test(key));
46
+ return matches.length === 1 ? matches[0].provider : null;
47
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Provider factory: builds the right streaming client for a given
3
+ * provider id + API key.
4
+ */
5
+ import { PROVIDER_INFO } from './types.js';
6
+ import { createAnthropicProvider } from './anthropic.js';
7
+ import { createOpenAICompatProvider } from './openai-compat.js';
8
+ export { detectProvider, sanitizeKey, PROVIDER_PATTERNS } from './detect.js';
9
+ export { PROVIDER_INFO, ProviderError } from './types.js';
10
+ /** Base URLs for the OpenAI-compatible providers. */
11
+ const BASE_URLS = {
12
+ openai: 'https://api.openai.com/v1',
13
+ openrouter: 'https://openrouter.ai/api/v1',
14
+ groq: 'https://api.groq.com/openai/v1',
15
+ // Gemini's OpenAI-compatibility endpoint
16
+ gemini: 'https://generativelanguage.googleapis.com/v1beta/openai',
17
+ };
18
+ export function createProvider(id, apiKey, model) {
19
+ const resolvedModel = model ?? PROVIDER_INFO[id].defaultModel;
20
+ if (id === 'anthropic') {
21
+ return createAnthropicProvider(apiKey, resolvedModel);
22
+ }
23
+ 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
+ }
29
+ : undefined;
30
+ return createOpenAICompatProvider({
31
+ id,
32
+ baseUrl: BASE_URLS[id],
33
+ apiKey,
34
+ model: resolvedModel,
35
+ extraHeaders,
36
+ });
37
+ }