vexi-cli 0.5.5 → 0.9.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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Remote model manifest — Feature #4.
3
+ *
4
+ * Default models drift as providers ship new versions. Hardcoding them in
5
+ * PROVIDER_INFO means a stale default requires a full Vexi release to fix.
6
+ * This module lets Vexi pick up refreshed defaults from a small, version-pinned
7
+ * JSON manifest hosted at vexi.pro, cached locally, with the compiled-in
8
+ * PROVIDER_INFO defaults as the always-safe fallback.
9
+ *
10
+ * Design mirrors the npm update check (src/update): a synchronous read of the
11
+ * local cache on the hot path, plus a best-effort background refresh that never
12
+ * blocks startup and swallows all errors.
13
+ *
14
+ * manifest shape: { "version": 1, "defaults": { "openai": "gpt-4o-mini", … } }
15
+ */
16
+ import { promises as fs } from 'node:fs';
17
+ import { readFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { VEXI_DIR } from '../config.js';
20
+ const MANIFEST_URL = 'https://vexi.pro/models.json';
21
+ const MANIFEST_CACHE_PATH = join(VEXI_DIR, 'models.json');
22
+ const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
23
+ const FETCH_TIMEOUT_MS = 1500;
24
+ /** Manifest schema version this Vexi understands. Newer manifests are ignored. */
25
+ const SUPPORTED_MANIFEST_VERSION = 1;
26
+ /** Validate an unknown value as a usable manifest, or return null. */
27
+ function parseManifest(raw) {
28
+ if (!raw || typeof raw !== 'object')
29
+ return null;
30
+ const obj = raw;
31
+ if (obj.version !== SUPPORTED_MANIFEST_VERSION)
32
+ return null;
33
+ if (!obj.defaults || typeof obj.defaults !== 'object')
34
+ return null;
35
+ const defaults = {};
36
+ for (const [k, v] of Object.entries(obj.defaults)) {
37
+ if (typeof v === 'string' && v.trim())
38
+ defaults[k] = v.trim();
39
+ }
40
+ return { version: SUPPORTED_MANIFEST_VERSION, defaults };
41
+ }
42
+ /**
43
+ * Read the locally cached manifest synchronously (hot path). Returns null when
44
+ * absent, unreadable, malformed, or a version this build doesn't support.
45
+ */
46
+ function loadCachedManifest() {
47
+ try {
48
+ const raw = readFileSync(MANIFEST_CACHE_PATH, 'utf8');
49
+ return parseManifest(JSON.parse(raw));
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /**
56
+ * Resolve the default model for a provider id: a cached remote override if one
57
+ * exists, otherwise the compiled-in fallback. Never throws.
58
+ */
59
+ export function defaultModelFor(id, fallback) {
60
+ const manifest = loadCachedManifest();
61
+ return manifest?.defaults[id] ?? fallback;
62
+ }
63
+ /**
64
+ * Best-effort background refresh of the model manifest. Fire-and-forget from
65
+ * startup — returns immediately if the cache is fresh, otherwise fetches (max
66
+ * 1500 ms) and rewrites the cache. All errors are swallowed.
67
+ */
68
+ export async function refreshManifest() {
69
+ try {
70
+ const cacheRaw = await fs.readFile(MANIFEST_CACHE_PATH, 'utf8').catch(() => null);
71
+ if (cacheRaw) {
72
+ try {
73
+ const cache = JSON.parse(cacheRaw);
74
+ const age = Date.now() - new Date(cache.lastCheck).getTime();
75
+ if (Number.isFinite(age) && age < REFRESH_INTERVAL_MS)
76
+ return; // still fresh
77
+ }
78
+ catch {
79
+ // malformed cache — fall through and refetch
80
+ }
81
+ }
82
+ const controller = new AbortController();
83
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
84
+ const res = await fetch(MANIFEST_URL, { signal: controller.signal }).finally(() => clearTimeout(timer));
85
+ if (!res.ok)
86
+ return;
87
+ const manifest = parseManifest(await res.json());
88
+ if (!manifest)
89
+ return;
90
+ await fs.mkdir(VEXI_DIR, { recursive: true }).catch(() => { });
91
+ await fs.writeFile(MANIFEST_CACHE_PATH, JSON.stringify({ ...manifest, lastCheck: new Date().toISOString() }), 'utf8').catch(() => { });
92
+ }
93
+ catch {
94
+ // network error, timeout, or bad JSON — silently ignored
95
+ }
96
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ // config.ts derives VEXI_DIR from os.homedir() at import time, and manifest.ts
6
+ // imports VEXI_DIR from it. Mock homedir to a throwaway dir and re-import so the
7
+ // cache path is deterministic on every platform (no reliance on $HOME/$USERPROFILE).
8
+ let home;
9
+ let defaultModelFor;
10
+ let refreshManifest;
11
+ beforeEach(async () => {
12
+ home = await fs.mkdtemp(join(tmpdir(), 'vexi-manifest-'));
13
+ vi.resetModules();
14
+ vi.doMock('node:os', async (importOriginal) => {
15
+ const actual = await importOriginal();
16
+ return { ...actual, homedir: () => home };
17
+ });
18
+ const mod = await import('./manifest.js');
19
+ defaultModelFor = mod.defaultModelFor;
20
+ refreshManifest = mod.refreshManifest;
21
+ });
22
+ afterEach(async () => {
23
+ vi.doUnmock('node:os');
24
+ vi.restoreAllMocks();
25
+ await fs.rm(home, { recursive: true, force: true });
26
+ });
27
+ async function writeCache(obj) {
28
+ await fs.mkdir(join(home, '.vexi'), { recursive: true });
29
+ await fs.writeFile(join(home, '.vexi', 'models.json'), JSON.stringify(obj), 'utf8');
30
+ }
31
+ describe('defaultModelFor', () => {
32
+ it('returns the compiled-in fallback when no cache exists', () => {
33
+ expect(defaultModelFor('openai', 'gpt-4o-mini')).toBe('gpt-4o-mini');
34
+ });
35
+ it('prefers a cached remote override', async () => {
36
+ await writeCache({ version: 1, defaults: { openai: 'gpt-5-mini' }, lastCheck: new Date().toISOString() });
37
+ expect(defaultModelFor('openai', 'gpt-4o-mini')).toBe('gpt-5-mini');
38
+ expect(defaultModelFor('anthropic', 'claude-sonnet-5')).toBe('claude-sonnet-5'); // not overridden → fallback
39
+ });
40
+ it('ignores a manifest with an unsupported version', async () => {
41
+ await writeCache({ version: 99, defaults: { openai: 'from-the-future' }, lastCheck: new Date().toISOString() });
42
+ expect(defaultModelFor('openai', 'gpt-4o-mini')).toBe('gpt-4o-mini');
43
+ });
44
+ it('ignores malformed cache files', async () => {
45
+ await fs.mkdir(join(home, '.vexi'), { recursive: true });
46
+ await fs.writeFile(join(home, '.vexi', 'models.json'), 'not json', 'utf8');
47
+ expect(defaultModelFor('groq', 'llama-3.3-70b-versatile')).toBe('llama-3.3-70b-versatile');
48
+ });
49
+ });
50
+ describe('refreshManifest', () => {
51
+ it('fetches and caches a valid manifest, which then feeds defaultModelFor', async () => {
52
+ vi.stubGlobal('fetch', vi.fn(async () => ({
53
+ ok: true,
54
+ json: async () => ({ version: 1, defaults: { deepseek: 'deepseek-v4' } }),
55
+ })));
56
+ await refreshManifest();
57
+ expect(defaultModelFor('deepseek', 'deepseek-chat')).toBe('deepseek-v4');
58
+ });
59
+ it('skips the network when the cache is still fresh', async () => {
60
+ await writeCache({ version: 1, defaults: { openai: 'cached' }, lastCheck: new Date().toISOString() });
61
+ const fetchSpy = vi.fn();
62
+ vi.stubGlobal('fetch', fetchSpy);
63
+ await refreshManifest();
64
+ expect(fetchSpy).not.toHaveBeenCalled();
65
+ });
66
+ it('swallows network errors and leaves the fallback intact', async () => {
67
+ vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
68
+ await expect(refreshManifest()).resolves.toBeUndefined();
69
+ expect(defaultModelFor('openai', 'gpt-4o-mini')).toBe('gpt-4o-mini');
70
+ });
71
+ });
@@ -7,47 +7,218 @@
7
7
  * Vexi dependency-free (plain `fetch`, no SDKs).
8
8
  */
9
9
  import { ProviderError } from './types.js';
10
+ /** Report OpenAI-style `usage` ({prompt_tokens, completion_tokens}) if present. */
11
+ function reportUsage(json, onUsage) {
12
+ if (!onUsage)
13
+ return;
14
+ const u = json.usage;
15
+ if (u && (u.prompt_tokens || u.completion_tokens)) {
16
+ onUsage({ inputTokens: u.prompt_tokens ?? 0, outputTokens: u.completion_tokens ?? 0 });
17
+ }
18
+ }
19
+ /** Translate Vexi's neutral ChatMessage[] into OpenAI chat-completions format. */
20
+ function toOpenAIMessages(messages) {
21
+ return messages.map((m) => {
22
+ if (m.role === 'assistant' && m.toolCalls?.length) {
23
+ return {
24
+ role: 'assistant',
25
+ content: m.content || null,
26
+ tool_calls: m.toolCalls.map((tc) => ({
27
+ id: tc.id,
28
+ type: 'function',
29
+ function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
30
+ })),
31
+ };
32
+ }
33
+ if (m.role === 'tool') {
34
+ return { role: 'tool', tool_call_id: m.toolCallId ?? '', content: m.content };
35
+ }
36
+ return { role: m.role, content: m.content };
37
+ });
38
+ }
10
39
  export function createOpenAICompatProvider(opts) {
11
40
  return {
12
41
  id: opts.id,
13
42
  model: opts.model,
14
- async stream(messages, onText) {
15
- const res = await fetch(`${opts.baseUrl}/chat/completions`, {
16
- method: 'POST',
17
- headers: {
18
- 'Content-Type': 'application/json',
19
- Authorization: `Bearer ${opts.apiKey}`,
20
- ...opts.extraHeaders,
21
- },
22
- body: JSON.stringify({
23
- model: opts.model,
24
- messages,
25
- stream: true,
26
- }),
27
- }).catch((err) => {
28
- throw new ProviderError(`Network error: ${err.message}`);
29
- });
30
- if (!res.ok || !res.body) {
31
- const body = await res.text().catch(() => '');
32
- throw new ProviderError(`${opts.id} API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
43
+ supportsTools: opts.supportsTools ?? false,
44
+ async streamTools(messages, tools, onText, onUsage) {
45
+ const guard = createStreamTimeoutGuard(opts.id);
46
+ try {
47
+ const res = await fetch(`${opts.baseUrl}/chat/completions`, {
48
+ method: 'POST',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ Authorization: `Bearer ${opts.apiKey}`,
52
+ ...opts.extraHeaders,
53
+ },
54
+ body: JSON.stringify({
55
+ model: opts.model,
56
+ messages: toOpenAIMessages(messages),
57
+ tools: tools.map((t) => ({
58
+ type: 'function',
59
+ function: { name: t.name, description: t.description, parameters: t.inputSchema },
60
+ })),
61
+ stream: true,
62
+ stream_options: { include_usage: true },
63
+ ...opts.extraBody,
64
+ }),
65
+ signal: guard.signal,
66
+ }).catch((err) => {
67
+ throw guard.wrapNetworkError(err);
68
+ });
69
+ if (!res.ok || !res.body) {
70
+ const body = await res.text().catch(() => '');
71
+ throw new ProviderError(`${opts.id} API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
72
+ }
73
+ let full = '';
74
+ // Streamed tool calls arrive as fragments keyed by their array index;
75
+ // accumulate id/name/arguments across deltas, then parse at the end.
76
+ const acc = new Map();
77
+ try {
78
+ for await (const data of sseEvents(res.body)) {
79
+ guard.resetIdleTimer();
80
+ if (data === '[DONE]')
81
+ break;
82
+ try {
83
+ const json = JSON.parse(data);
84
+ const delta = json.choices?.[0]?.delta;
85
+ if (delta?.content) {
86
+ full += delta.content;
87
+ onText(delta.content);
88
+ }
89
+ if (Array.isArray(delta?.tool_calls)) {
90
+ for (const tc of delta.tool_calls) {
91
+ const idx = tc.index ?? 0;
92
+ const cur = acc.get(idx) ?? { id: '', name: '', args: '' };
93
+ if (tc.id)
94
+ cur.id = tc.id;
95
+ if (tc.function?.name)
96
+ cur.name = tc.function.name;
97
+ if (tc.function?.arguments)
98
+ cur.args += tc.function.arguments;
99
+ acc.set(idx, cur);
100
+ }
101
+ }
102
+ reportUsage(json, onUsage);
103
+ }
104
+ catch {
105
+ // Ignore malformed/keep-alive chunks
106
+ }
107
+ }
108
+ }
109
+ catch (e) {
110
+ throw guard.wrapStreamError(e);
111
+ }
112
+ const toolCalls = [...acc.entries()]
113
+ .sort((a, b) => a[0] - b[0])
114
+ .map(([idx, c]) => {
115
+ let args = {};
116
+ try {
117
+ args = c.args ? JSON.parse(c.args) : {};
118
+ }
119
+ catch {
120
+ args = {};
121
+ }
122
+ return { id: c.id || `call_${idx}`, name: c.name, arguments: args };
123
+ })
124
+ .filter((c) => c.name);
125
+ return { text: full, toolCalls };
126
+ }
127
+ finally {
128
+ guard.dispose();
33
129
  }
34
- let full = '';
35
- for await (const data of sseEvents(res.body)) {
36
- if (data === '[DONE]')
37
- break;
130
+ },
131
+ async stream(messages, onText, onUsage) {
132
+ const guard = createStreamTimeoutGuard(opts.id);
133
+ try {
134
+ const res = await fetch(`${opts.baseUrl}/chat/completions`, {
135
+ method: 'POST',
136
+ headers: {
137
+ 'Content-Type': 'application/json',
138
+ Authorization: `Bearer ${opts.apiKey}`,
139
+ ...opts.extraHeaders,
140
+ },
141
+ body: JSON.stringify({
142
+ model: opts.model,
143
+ messages: toOpenAIMessages(messages),
144
+ stream: true,
145
+ stream_options: { include_usage: true },
146
+ ...opts.extraBody,
147
+ }),
148
+ signal: guard.signal,
149
+ }).catch((err) => {
150
+ throw guard.wrapNetworkError(err);
151
+ });
152
+ if (!res.ok || !res.body) {
153
+ const body = await res.text().catch(() => '');
154
+ throw new ProviderError(`${opts.id} API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
155
+ }
156
+ let full = '';
38
157
  try {
39
- const json = JSON.parse(data);
40
- const text = json.choices?.[0]?.delta?.content;
41
- if (text) {
42
- full += text;
43
- onText(text);
158
+ for await (const data of sseEvents(res.body)) {
159
+ guard.resetIdleTimer();
160
+ if (data === '[DONE]')
161
+ break;
162
+ try {
163
+ const json = JSON.parse(data);
164
+ const text = json.choices?.[0]?.delta?.content;
165
+ if (text) {
166
+ full += text;
167
+ onText(text);
168
+ }
169
+ reportUsage(json, onUsage);
170
+ }
171
+ catch {
172
+ // Ignore malformed/keep-alive chunks
173
+ }
44
174
  }
45
175
  }
46
- catch {
47
- // Ignore malformed/keep-alive chunks
176
+ catch (e) {
177
+ throw guard.wrapStreamError(e);
48
178
  }
179
+ return full;
49
180
  }
50
- return full;
181
+ finally {
182
+ guard.dispose();
183
+ }
184
+ },
185
+ };
186
+ }
187
+ /** Abort the connection if the server goes silent for this long between chunks. */
188
+ const IDLE_TIMEOUT_MS = 60_000;
189
+ /** Hard ceiling on total stream duration, regardless of activity. */
190
+ const MAX_STREAM_MS = 10 * 60 * 1000;
191
+ /**
192
+ * Shared guard against hung provider connections: aborts the fetch if no
193
+ * bytes arrive for IDLE_TIMEOUT_MS, or if the whole stream runs past
194
+ * MAX_STREAM_MS, so a stalled provider can never freeze the chat loop.
195
+ */
196
+ export function createStreamTimeoutGuard(providerId) {
197
+ const controller = new AbortController();
198
+ let idleTimer;
199
+ const hardTimer = setTimeout(() => controller.abort(), MAX_STREAM_MS);
200
+ const resetIdleTimer = () => {
201
+ clearTimeout(idleTimer);
202
+ idleTimer = setTimeout(() => controller.abort(), IDLE_TIMEOUT_MS);
203
+ };
204
+ resetIdleTimer();
205
+ return {
206
+ signal: controller.signal,
207
+ resetIdleTimer,
208
+ wrapNetworkError(err) {
209
+ if (controller.signal.aborted)
210
+ return new ProviderError(`${providerId} request timed out (no response).`);
211
+ return new ProviderError(`Network error: ${err.message}`);
212
+ },
213
+ wrapStreamError(e) {
214
+ if (controller.signal.aborted) {
215
+ return new ProviderError(`${providerId} stream stalled or exceeded the time limit and was aborted.`);
216
+ }
217
+ return e;
218
+ },
219
+ dispose() {
220
+ clearTimeout(idleTimer);
221
+ clearTimeout(hardTimer);
51
222
  },
52
223
  };
53
224
  }
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { createOpenAICompatProvider } from './openai-compat.js';
3
+ /** Build a fake fetch Response whose body streams the given SSE lines. */
4
+ function sseResponse(lines) {
5
+ const body = new ReadableStream({
6
+ start(controller) {
7
+ const enc = new TextEncoder();
8
+ for (const l of lines)
9
+ controller.enqueue(enc.encode(`data: ${l}\n`));
10
+ controller.close();
11
+ },
12
+ });
13
+ return { ok: true, status: 200, body };
14
+ }
15
+ afterEach(() => vi.restoreAllMocks());
16
+ describe('openai-compat streamTools', () => {
17
+ it('reassembles a tool call split across streamed deltas', async () => {
18
+ const chunks = [
19
+ JSON.stringify({ choices: [{ delta: { content: 'let me edit' } }] }),
20
+ JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'edit_file' } }] } }] }),
21
+ JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"path":"a.ts",' } }] } }] }),
22
+ JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '"old":"x","new":"y"}' } }] } }] }),
23
+ '[DONE]',
24
+ ];
25
+ vi.stubGlobal('fetch', vi.fn(async () => sseResponse(chunks)));
26
+ const provider = createOpenAICompatProvider({
27
+ id: 'openai', baseUrl: 'https://x/v1', apiKey: 'k', model: 'gpt', supportsTools: true,
28
+ });
29
+ let streamed = '';
30
+ const { text, toolCalls } = await provider.streamTools([{ role: 'user', content: 'go' }], [{ name: 'edit_file', description: 'd', inputSchema: { type: 'object' } }], (c) => { streamed += c; });
31
+ expect(streamed).toBe('let me edit');
32
+ expect(text).toBe('let me edit');
33
+ expect(toolCalls).toEqual([
34
+ { id: 'call_1', name: 'edit_file', arguments: { path: 'a.ts', old: 'x', new: 'y' } },
35
+ ]);
36
+ });
37
+ it('returns no tool calls for a plain text answer', async () => {
38
+ vi.stubGlobal('fetch', vi.fn(async () => sseResponse([
39
+ JSON.stringify({ choices: [{ delta: { content: 'hi' } }] }),
40
+ '[DONE]',
41
+ ])));
42
+ const provider = createOpenAICompatProvider({
43
+ id: 'openai', baseUrl: 'https://x/v1', apiKey: 'k', model: 'gpt', supportsTools: true,
44
+ });
45
+ const { text, toolCalls } = await provider.streamTools([{ role: 'user', content: 'hi' }], [], () => { });
46
+ expect(text).toBe('hi');
47
+ expect(toolCalls).toEqual([]);
48
+ });
49
+ it('reports token usage from the final include_usage chunk', async () => {
50
+ vi.stubGlobal('fetch', vi.fn(async () => sseResponse([
51
+ JSON.stringify({ choices: [{ delta: { content: 'hi' } }] }),
52
+ JSON.stringify({ choices: [], usage: { prompt_tokens: 42, completion_tokens: 7 } }),
53
+ '[DONE]',
54
+ ])));
55
+ const provider = createOpenAICompatProvider({ id: 'openai', baseUrl: 'x', apiKey: 'k', model: 'gpt' });
56
+ let usage = null;
57
+ await provider.stream([{ role: 'user', content: 'hi' }], () => { }, (u) => { usage = u; });
58
+ expect(usage).toEqual({ inputTokens: 42, outputTokens: 7 });
59
+ });
60
+ it('advertises native tool support via the flag', () => {
61
+ const p = createOpenAICompatProvider({ id: 'openai', baseUrl: 'x', apiKey: 'k', model: 'gpt', supportsTools: true });
62
+ expect(p.supportsTools).toBe(true);
63
+ const noTools = createOpenAICompatProvider({ id: 'minimax', baseUrl: 'x', apiKey: 'k', model: 'm' });
64
+ expect(noTools.supportsTools).toBe(false);
65
+ });
66
+ });
@@ -16,9 +16,18 @@ export class ProviderError extends Error {
16
16
  }
17
17
  /** Human-friendly provider metadata. */
18
18
  export const PROVIDER_INFO = {
19
- anthropic: { label: 'Anthropic (Claude)', defaultModel: 'claude-sonnet-4-5' },
19
+ // ── International providers ───────────────────────────────────────────
20
+ anthropic: { label: 'Anthropic (Claude)', defaultModel: 'claude-sonnet-5' },
20
21
  openai: { label: 'OpenAI (GPT)', defaultModel: 'gpt-4o-mini' },
21
22
  openrouter: { label: 'OpenRouter', defaultModel: 'openrouter/auto' },
22
- groq: { label: 'Groq', defaultModel: 'llama-3.3-70b-versatile' },
23
- gemini: { label: 'Google Gemini', defaultModel: 'gemini-2.5-flash' },
23
+ groq: { label: 'Groq (free tier)', defaultModel: 'llama-3.3-70b-versatile', free: true },
24
+ gemini: { label: 'Google Gemini (free tier)', defaultModel: 'gemini-2.5-flash', free: true },
25
+ mistral: { label: 'Mistral AI', defaultModel: 'mistral-small-latest' },
26
+ cerebras: { label: 'Cerebras (free tier)', defaultModel: 'llama-3.3-70b', free: true },
27
+ // ── Chinese AI providers ──────────────────────────────────────────────
28
+ glm: { label: 'Zhipu AI — GLM (free tier)', defaultModel: 'glm-4-flash', free: true },
29
+ deepseek: { label: 'DeepSeek (free tier)', defaultModel: 'deepseek-chat', free: true },
30
+ qwen: { label: 'Alibaba Qwen (free tier)', defaultModel: 'qwen-turbo', free: true },
31
+ moonshot: { label: 'Kimi — Moonshot AI (free tier)', defaultModel: 'moonshot-v1-8k', free: true },
32
+ minimax: { label: 'MiniMax (free tier)', defaultModel: 'MiniMax-Text-01', free: true },
24
33
  };
@@ -16,6 +16,7 @@
16
16
  import { promises as fs } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
  import { loadSession } from './recorder.js';
19
+ import { escapeHtml } from '../utils/html.js';
19
20
  /** UI labels for the generated page, per export language. */
20
21
  const LABELS = {
21
22
  en: {
@@ -306,10 +307,3 @@ document.getElementById('record').onclick = async () => {
306
307
  </html>
307
308
  `;
308
309
  }
309
- function escapeHtml(text) {
310
- return text
311
- .replaceAll('&', '&amp;')
312
- .replaceAll('<', '&lt;')
313
- .replaceAll('>', '&gt;')
314
- .replaceAll('"', '&quot;');
315
- }
@@ -50,7 +50,7 @@ export function skillsBlock(skills) {
50
50
  * Add a skill from a local file path or a URL (GitHub URLs are converted
51
51
  * to raw content automatically). Returns the saved skill name.
52
52
  */
53
- export async function addSkill(root, source) {
53
+ export async function addSkill(root, source, opts = {}) {
54
54
  let content;
55
55
  let name;
56
56
  if (/^https?:\/\//i.test(source)) {
@@ -61,14 +61,22 @@ export async function addSkill(root, source) {
61
61
  }
62
62
  content = await res.text();
63
63
  name = skillNameFromUrl(source);
64
+ content = content.slice(0, MAX_SKILL_SIZE).trim();
65
+ if (!content)
66
+ throw new Error('Skill source is empty.');
67
+ if (opts.confirmRemoteContent) {
68
+ const proceed = await opts.confirmRemoteContent(content, sanitizeName(name), url);
69
+ if (!proceed)
70
+ throw new Error('Skill add cancelled.');
71
+ }
64
72
  }
65
73
  else {
66
74
  content = await fs.readFile(source, 'utf8');
67
75
  name = basename(source, extname(source));
76
+ content = content.slice(0, MAX_SKILL_SIZE).trim();
77
+ if (!content)
78
+ throw new Error('Skill source is empty.');
68
79
  }
69
- content = content.slice(0, MAX_SKILL_SIZE).trim();
70
- if (!content)
71
- throw new Error('Skill source is empty.');
72
80
  name = sanitizeName(name);
73
81
  const dir = skillsDir(root);
74
82
  await fs.mkdir(dir, { recursive: true });