zyn-ai 1.3.7 → 1.4.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.
@@ -1,18 +1,27 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { spawn } = require('child_process');
4
3
 
5
4
  const fsp = fs.promises;
6
- const { listSkills, SKILLS_DIR } = require('../core/skills');
7
5
  const { DEFAULT_LANGUAGE, DEFAULT_MODEL_KEY, GEMINI_MODEL_WARNING, MODELS, listProvidersFromModels } = require('../config');
8
6
  const { languageLabel, normalizeLanguage, t } = require('../i18n');
9
- const { createNewSessionState, listSessions, loadSessionState, saveState } = require('../utils/sessionStorage');
10
- const { compactMemory, normalizeCompactMode } = require('../core/agent');
7
+ const { createNewSessionState, listSessions, loadSessionState, saveState, listBackgroundResults, consumeBackgroundResult, enqueueBackgroundTask } = require('../utils/sessionStorage');
11
8
  const { listGitSecrets, removeGitSecret, upsertGitSecret } = require('../utils/secretStorage');
12
9
  const { clearGmailAuth, getGmailAuthStatus, startGmailOAuthFlow } = require('../utils/gmailAuth');
13
10
  const { exportTranscriptText, formatTranscriptPreview } = require('../utils/transcriptStorage');
14
11
  const { resolveInputPath } = require('../utils/pathUtils');
15
- const { printTools } = require('../tools');
12
+ const { detachBackgroundTurn } = require('../utils/backgroundWorker');
13
+ const {
14
+ describeProviderConfig,
15
+ fetchProviderModels,
16
+ getActiveModelsForProvider,
17
+ listConfiguredProviders,
18
+ maskSecret,
19
+ removeProviderConfig,
20
+ setProviderField,
21
+ summarizeProviderConfig,
22
+ syncProvider,
23
+ unsetProviderField,
24
+ } = require('../providers/catalog');
16
25
 
17
26
 
18
27
  function getModelWarning(key) {
@@ -34,41 +43,6 @@ function printLanguageChanged(language) {
34
43
  : `Updated to language ${label} (${normalized})`);
35
44
  }
36
45
 
37
- function getCompactModeArgs(args) {
38
- const value = String(args || '').trim().toLowerCase();
39
- if (!value) return 'medium';
40
- const [mode] = value.split(/\s+/);
41
- return ['low', 'medium', 'high'].includes(mode) ? mode : null;
42
- }
43
-
44
- async function runCompactCommand(state, args, printMemoryFn) {
45
- const compactMode = getCompactModeArgs(args);
46
- if (!compactMode) {
47
- throw new Error(t(state.language, 'compactInvalid'));
48
- }
49
- const isHigh = compactMode === 'high';
50
- if (isHigh) {
51
- const warn = t(state.language, 'compactWarningHigh');
52
- console.log(warn);
53
- }
54
-
55
- const ui = {
56
- logEvent: (_state, kind, title, detail) => {
57
- const prefix = kind === 'error' ? 'ERROR' : kind === 'warn' ? 'WARN' : 'INFO';
58
- const line = [prefix, title, detail].filter(Boolean).join(' - ');
59
- if (line) console.log(line);
60
- },
61
- };
62
-
63
- const result = await compactMemory(state, ui, compactMode, { force: !isHigh });
64
- if (!result?.changed) {
65
- console.log(t(state.language, 'compactNoChange'));
66
- return true;
67
- }
68
- console.log(t(state.language, 'compactCommand', { mode: compactMode }));
69
- if (typeof printMemoryFn === 'function') printMemoryFn(state);
70
- return true;
71
- }
72
46
 
73
47
  const SLASH_COMMANDS = [
74
48
  { name: 'help', desc: 'full help', descEs: 'ayuda completa' },
@@ -76,32 +50,32 @@ const SLASH_COMMANDS = [
76
50
  { name: 'history', desc: 'recent actions', descEs: 'acciones recientes' },
77
51
  { name: 'memory', desc: 'memory summary', descEs: 'resumen de memoria' },
78
52
  { name: 'summary', desc: 'memory summary', descEs: 'resumen de memoria' },
79
- { name: 'compact', desc: 'compact memory', descEs: 'compactar memoria' },
80
53
  { name: 'session', desc: 'current session', descEs: 'sesión actual' },
81
54
  { name: 'sessions', desc: 'list sessions', descEs: 'listar sesiones' },
82
55
  { name: 'new', desc: 'new session', descEs: 'nueva sesión' },
83
56
  { name: 'resume', desc: 'resume session', descEs: 'reanudar sesión' },
84
57
  { name: 'title', desc: 'rename session', descEs: 'renombrar sesión' },
85
58
  { name: 'rename', desc: 'rename session', descEs: 'renombrar sesión' },
86
- { name: 'model', desc: 'view/change model', descEs: 'ver/cambiar modelo' },
87
59
  { name: 'models', desc: 'list models', descEs: 'listar modelos' },
88
- { name: 'providers', desc: 'list providers', descEs: 'listar proveedores' },
60
+ { name: 'providers', desc: 'list/select providers', descEs: 'listar/seleccionar proveedores' },
61
+ { name: 'provider', desc: 'manage providers (sync, set, list, remove)', descEs: 'gestionar proveedores (sync, set, list, remove)' },
89
62
  { name: 'git', desc: 'configure git credentials', descEs: 'configurar credenciales git' },
90
63
  { name: 'gmail', desc: 'connect Gmail account', descEs: 'conectar cuenta Gmail' },
91
64
  { name: 'persona', desc: 'set response tone/personality', descEs: 'definir tono/persona' },
92
65
  { name: 'lang', desc: 'change language', descEs: 'cambiar idioma' },
93
66
  { name: 'language', desc: 'change language', descEs: 'cambiar idioma' },
94
67
  { name: 'auto', desc: 'auto-approval', descEs: 'auto-aprobación' },
95
- { name: 'concuerdo', desc: 'group model mode', descEs: 'modo de grupo' },
96
- { name: 'tools', desc: 'tools', descEs: 'herramientas' },
97
- { name: 'skills', desc: 'agent skills', descEs: 'skills del agente' },
98
68
  { name: 'config', desc: 'view/change session settings', descEs: 'ver/cambiar configuración' },
99
- { name: 'web', desc: 'open web version', descEs: 'abrir versión web' },
69
+ { name: 'bg', desc: 'background: continue current turn in background', descEs: 'segundo plano: continuar el turno en background' },
70
+ { name: 'undo', desc: 'undo last turn', descEs: 'deshacer último turno' },
71
+ { name: 'redo', desc: 'redo last turn', descEs: 'rehacer último turno' },
100
72
  { name: 'stop', desc: 'stop agent', descEs: 'detener agente' },
101
73
  { name: 'abort', desc: 'stop agent', descEs: 'detener agente' },
102
74
  { name: 'reset', desc: 'reset context', descEs: 'reiniciar contexto' },
103
75
  { name: 'clear', desc: 'reset context', descEs: 'reiniciar contexto' },
104
76
  { name: 'cwd', desc: 'working directory', descEs: 'directorio de trabajo' },
77
+ { name: 'compact', desc: 'compact memory', descEs: 'compactar memoria' },
78
+ { name: 'theme', desc: 'UI theme', descEs: 'tema de la UI' },
105
79
  { name: 'transcript', desc: 'view transcript', descEs: 'ver transcripción' },
106
80
  { name: 'export', desc: 'export to txt', descEs: 'exportar a txt' },
107
81
  { name: 'exit', desc: 'exit', descEs: 'salir' },
@@ -151,17 +125,14 @@ function printHelp(state = {}) {
151
125
 
152
126
  // Configuration
153
127
  console.log(` ${paint('── Configuration ──', 'dim')}`);
154
- console.log(` ${b('/model')} Show active model`);
155
- console.log(` ${b('/model <key>')} Change active model`);
156
- console.log(` ${b('/models')} List available models`);
157
- console.log(` ${b('/providers')} List detected providers`);
128
+ console.log(` ${b('/models')} Open model picker`);
129
+ console.log(` ${b('/providers')} Open provider picker`);
158
130
  console.log(` ${b('/lang')} Show current language`);
159
131
  console.log(` ${b('/lang <en|es>')} Change language`);
160
132
  console.log(` ${b('/language <en|es>')} Alias of /lang`);
161
133
  console.log(` ${b('/auto')} Show auto-approval status`);
162
134
  console.log(` ${b('/auto on')} Enable auto-approval`);
163
135
  console.log(` ${b('/auto off')} Disable auto-approval`);
164
- console.log(` ${b('/concuerdo')} Toggle group model mode`);
165
136
  console.log(` ${b('/persona set <text>')} Set response persona/tone`);
166
137
  console.log(` ${b('/persona show')} Show active persona`);
167
138
  console.log(` ${b('/persona reset')} Reset to default persona`);
@@ -169,14 +140,11 @@ function printHelp(state = {}) {
169
140
  console.log(` ${b('/config lang <en|es>')} Change language from config`);
170
141
  console.log(` ${b('/config model <key>')} Change model from config`);
171
142
  console.log(` ${b('/config auto on|off')} Toggle auto from config`);
172
- console.log(` ${b('/config group on|off')} Toggle group mode from config`);
173
143
  console.log(` ${b('/config cwd <path>')} Change working dir from config`);
174
144
  console.log('');
175
145
 
176
146
  // Tools and Git
177
147
  console.log(` ${paint('── Tools and Git ──', 'dim')}`);
178
- console.log(` ${b('/tools')} List available agent tools`);
179
- console.log(` ${b('/skills')} List loaded skills`);
180
148
  console.log(` ${b('/git set <provider> <token>')} Configure git credentials`);
181
149
  console.log(` ${b('/git set <provider> <token> [user] [apiBaseUrl:URL] [cloneBaseUrl:URL] [name:X]')}`);
182
150
  console.log(` ${b('/git list')} List configured git profiles`);
@@ -188,10 +156,9 @@ function printHelp(state = {}) {
188
156
  console.log(` ${b('/cwd <path>')} Change working directory`);
189
157
  console.log('');
190
158
 
191
- // Web and export
192
- console.log(` ${paint('── Web and Export ──', 'dim')}`);
193
- console.log(` ${b('/web')} Open web version`);
194
- console.log(` ${b('/web <host:port>')} Open web version on custom host:port`);
159
+ // Export and background
160
+ console.log(` ${paint('── Export and Background ──', 'dim')}`);
161
+ console.log(` ${b('/bg')} Detach current turn to a background worker`);
195
162
  console.log(` ${b('/transcript')} View full session transcript`);
196
163
  console.log(` ${b('/export')} Export session to txt`);
197
164
  console.log(` ${b('/export <path>')} Export session to specific path`);
@@ -231,6 +198,155 @@ function printModels() {
231
198
  console.log('');
232
199
  }
233
200
 
201
+ function buildModelListItems() {
202
+ const providers = listProvidersFromModels(MODELS);
203
+ const items = [];
204
+ for (const provider of providers) {
205
+ for (const model of provider.models) {
206
+ const isActive = model.key === (global.__zynActiveModel || DEFAULT_MODEL_KEY);
207
+ items.push({
208
+ key: model.key,
209
+ label: `${model.key.padEnd(22)} ${model.label}`,
210
+ provider: provider.key,
211
+ active: isActive,
212
+ });
213
+ }
214
+ }
215
+ return items;
216
+ }
217
+
218
+ function buildProviderListItems() {
219
+ const fromModels = listProvidersFromModels(MODELS);
220
+ const configured = listConfiguredProviders();
221
+ const configuredKeys = new Set(configured.map(p => p.provider));
222
+ const modelKeys = new Set(fromModels.map(p => p.key));
223
+ for (const p of configured) {
224
+ if (!modelKeys.has(p.provider)) {
225
+ fromModels.push({
226
+ key: p.provider,
227
+ label: p.provider,
228
+ models: (p.models || []).map(m => typeof m === 'string' ? { key: m, label: m } : m),
229
+ });
230
+ }
231
+ }
232
+ return fromModels.map(p => ({
233
+ key: p.key,
234
+ label: `${p.key} ${p.models.length} model${p.models.length === 1 ? '' : 's'}`,
235
+ models: p.models,
236
+ }));
237
+ }
238
+
239
+ async function runModelSelector(state, deps) {
240
+ const items = buildModelListItems();
241
+ if (items.length === 0) return null;
242
+ const active = state.activeModel || DEFAULT_MODEL_KEY;
243
+ const initialIndex = Math.max(0, items.findIndex(it => it.key === active));
244
+ const choice = await deps.askSelect({
245
+ title: state.language === 'es' ? 'Selecciona un modelo' : 'Select a model',
246
+ subtitle: state.language === 'es' ? '↑/↓ navega · Enter elige · Esc cancela' : '↑/↓ move · Enter pick · Esc cancel',
247
+ items,
248
+ initialIndex: initialIndex >= 0 ? initialIndex : 0,
249
+ getLabel: (item) => `${item.active ? '● ' : ' '}${item.label}`,
250
+ getValue: (item) => item.key,
251
+ isActive: (item) => item.active,
252
+ });
253
+ return choice || null;
254
+ }
255
+
256
+ async function runProviderSelector(state, deps) {
257
+ const items = buildProviderListItems();
258
+ const addCustomKey = '__add_custom__';
259
+ items.push({ key: addCustomKey, label: '', models: [] });
260
+ const es = state?.language === 'es';
261
+ const choice = await deps.askSelect({
262
+ title: es ? 'Selecciona un proveedor' : 'Select a provider',
263
+ subtitle: es ? '↑/↓ navega · Enter elige · Esc cancela' : '↑/↓ move · Enter pick · Esc cancel',
264
+ items,
265
+ getLabel: (item) => {
266
+ if (item.key === addCustomKey) return '+ ' + (es ? 'Agregar proveedor personalizado' : 'Add custom provider');
267
+ return item.label;
268
+ },
269
+ getValue: (item) => item.key,
270
+ });
271
+ if (choice === addCustomKey) {
272
+ const name = await deps.askInput({
273
+ title: es ? 'Nombre del proveedor personalizado' : 'Custom provider name',
274
+ prompt: es ? 'Ej: ollama, groq, anthropic' : 'E.g.: ollama, groq, anthropic',
275
+ defaultValue: 'custom',
276
+ });
277
+ if (!name) return null;
278
+ const baseUrl = await deps.askInput({
279
+ title: es ? 'URL base de la API' : 'API base URL',
280
+ prompt: 'baseUrl',
281
+ defaultValue: '',
282
+ });
283
+ if (baseUrl) setProviderField(name, 'baseUrl', baseUrl);
284
+ const apiKey = await deps.askInput({
285
+ title: es ? `API Key para ${name} (opcional)` : `API Key for ${name} (optional)`,
286
+ prompt: 'apiKey',
287
+ hidden: true,
288
+ defaultValue: '',
289
+ });
290
+ if (apiKey) setProviderField(name, 'apiKey', apiKey);
291
+ const modelId = await deps.askInput({
292
+ title: es ? `Model ID para ${name}` : `Model ID for ${name}`,
293
+ prompt: 'modelId',
294
+ defaultValue: '',
295
+ });
296
+ if (modelId) setProviderField(name, 'modelId', modelId);
297
+ const ctxLen = await deps.askInput({
298
+ title: es ? `Contexto máximo (tokens) para ${name}` : `Max context length (tokens) for ${name}`,
299
+ prompt: es ? 'Ej: 128000 (vacio = 128K)' : 'E.g.: 128000 (empty = 128K)',
300
+ defaultValue: '',
301
+ });
302
+ if (ctxLen && /^\d+$/.test(ctxLen)) setProviderField(name, 'contextLength', ctxLen);
303
+ console.log(es
304
+ ? `Proveedor "${name}" agregado. /provider sync ${name} para sincronizar.`
305
+ : `Provider "${name}" added. /provider sync ${name} to sync models.`);
306
+ console.log(es ? ' Campos editables: apiKey, baseUrl, modelId, contextLength' : ' Editable fields: apiKey, baseUrl, modelId, contextLength');
307
+ return name;
308
+ }
309
+ return choice || null;
310
+ }
311
+
312
+ async function runProviderModelsSelector(state, deps, providerKey) {
313
+ const providerGroup = listProvidersFromModels(MODELS).find(p => p.key === providerKey);
314
+ if (!providerGroup) return null;
315
+ const items = providerGroup.models.map(m => ({
316
+ key: m.key,
317
+ label: `${m.key.padEnd(22)} ${m.label}`,
318
+ active: m.key === (state.activeModel || DEFAULT_MODEL_KEY),
319
+ }));
320
+ if (items.length === 0) return null;
321
+ const initialIndex = Math.max(0, items.findIndex(it => it.active));
322
+ return deps.askSelect({
323
+ title: state.language === 'es' ? `Modelos de ${providerKey}` : `Models in ${providerKey}`,
324
+ subtitle: state.language === 'es' ? 'Elige un modelo · Esc vuelve' : 'Pick a model · Esc back',
325
+ items,
326
+ initialIndex,
327
+ getLabel: (item) => `${item.active ? '● ' : ' '}${item.label}`,
328
+ getValue: (item) => item.key,
329
+ isActive: (item) => item.active,
330
+ });
331
+ }
332
+
333
+ async function switchActiveModel(state, deps, key) {
334
+ if (!MODELS[key]) {
335
+ const available = Object.keys(MODELS).join(', ');
336
+ throw new Error(`${t(state.language, 'modelInvalid')}: ${available}`);
337
+ }
338
+ state.activeModel = key;
339
+ global.__zynActiveModel = key;
340
+ await saveState(state);
341
+ if (deps.appendTranscriptEntry) {
342
+ await deps.appendTranscriptEntry(state.sessionId, {
343
+ type: 'system',
344
+ content: `Model switched to: ${MODELS[key].label}${getModelWarning(key) ? `\nWarning: ${getModelWarning(key)}` : ''}`,
345
+ });
346
+ }
347
+ return key;
348
+ }
349
+
234
350
  function printConfig(state) {
235
351
  const key = state.activeModel || DEFAULT_MODEL_KEY;
236
352
  const model = MODELS[key];
@@ -241,7 +357,6 @@ function printConfig(state) {
241
357
  console.log(` Model : ${key} (${model?.label || '?'})`);
242
358
  console.log(` Provider : ${provider}`);
243
359
  console.log(` Auto : ${state.autoApprove ? 'on' : 'off'}`);
244
- console.log(` Group : ${state.concuerdo ? 'on' : 'off'}`);
245
360
  console.log(` CWD : ${state.cwd}`);
246
361
  console.log('');
247
362
  console.log(' Commands:');
@@ -253,18 +368,6 @@ function printConfig(state) {
253
368
  console.log('');
254
369
  }
255
370
 
256
- async function startWebVersion(host = '127.0.0.1', port = 3000) {
257
- const serverPath = path.join(__dirname, '..', 'web', 'server.js');
258
- const child = spawn(process.execPath, [serverPath], {
259
- detached: true,
260
- stdio: 'ignore',
261
- windowsHide: true,
262
- env: { ...process.env, HOST: host, PORT: String(port) },
263
- });
264
- child.unref();
265
- return `http://${host}:${port}`;
266
- }
267
-
268
371
  async function handleLocalCommand(input, state, deps) {
269
372
  const parsed = parseSlashCommand(input);
270
373
  if (!parsed) return false;
@@ -279,6 +382,9 @@ async function handleLocalCommand(input, state, deps) {
279
382
  printSession,
280
383
  printSessions: renderSessions,
281
384
  printStatus,
385
+ askSelect,
386
+ askInput,
387
+ askConfirm,
282
388
  } = deps;
283
389
 
284
390
  if (commandName === 'help') {
@@ -297,17 +403,10 @@ async function handleLocalCommand(input, state, deps) {
297
403
  }
298
404
 
299
405
  if (commandName === 'memory' || commandName === 'summary') {
300
- if (args && args.startsWith('compact')) {
301
- const compactArgs = args.replace(/^compact\s*/i, '').trim();
302
- return runCompactCommand(state, compactArgs, printMemory);
303
- }
304
406
  printMemory(state);
305
407
  return true;
306
408
  }
307
409
 
308
- if (commandName === 'compact') {
309
- return runCompactCommand(state, args, printMemory);
310
- }
311
410
 
312
411
  if (commandName === 'session') {
313
412
  printSession(state);
@@ -503,16 +602,6 @@ async function handleLocalCommand(input, state, deps) {
503
602
  return true;
504
603
  }
505
604
 
506
- if (sub === 'group' || sub === 'concuerdo') {
507
- if (value !== 'on' && value !== 'off') {
508
- throw new Error('Use /config group on|off');
509
- }
510
- state.concuerdo = value === 'on';
511
- await saveState(state);
512
- console.log(state.concuerdo ? 'Group mode enabled.' : 'Group mode disabled.');
513
- return true;
514
- }
515
-
516
605
  if (sub === 'cwd' || sub === 'pwd') {
517
606
  if (!value) {
518
607
  throw new Error(t(state.language, 'missingPath'));
@@ -531,48 +620,94 @@ async function handleLocalCommand(input, state, deps) {
531
620
  throw new Error('Use /config show|lang|model|auto|group|cwd');
532
621
  }
533
622
 
534
- if (commandName === 'model') {
535
- if (!args) {
536
- const key = state.activeModel || DEFAULT_MODEL_KEY;
537
- const info = MODELS[key];
538
- console.log(`${key} (${info?.label || '?'})`);
539
- return true;
623
+ function isProviderConfigured(providerKey) {
624
+ const config = describeProviderConfig(providerKey);
625
+ if (!config) return false;
626
+ if (providerKey === 'qwenapi') return Boolean(config.apiKey || process.env.ZYN_QWEN_API_KEY);
627
+ if (providerKey === 'gemini') return Boolean(config.apiKey || process.env.ZYN_GEMINI_API_KEY);
628
+ if (providerKey === 'huggingface') return Boolean(config.apiKey || process.env.ZYN_HUGGINGFACE_TOKEN || process.env.HF_TOKEN);
629
+ if (providerKey === 'deepseek') return Boolean(config.apiKey || process.env.ZYN_DEEPSEEK_CHAT_KEY || process.env.DEEPSEEK_CHAT_KEY || process.env.ZYN_CHAT_KEY);
630
+ return true;
631
+ }
632
+
633
+ async function configureProviderInteractive(state, deps, providerKey) {
634
+ const es = state?.language === 'es';
635
+ const fields = [];
636
+ if (providerKey === 'qwenapi') {
637
+ fields.push({ name: 'apiKey', hidden: true, prompt: es ? 'API Key (dashscope)' : 'API Key (DashScope)' });
638
+ } else if (providerKey === 'gemini') {
639
+ fields.push({ name: 'apiKey', hidden: true, prompt: es ? 'API Key (Google AI Studio)' : 'API Key (Google AI Studio)' });
640
+ } else if (providerKey === 'huggingface') {
641
+ fields.push({ name: 'apiKey', hidden: true, prompt: es ? 'Token (huggingface.co/settings/tokens)' : 'Token (huggingface.co/settings/tokens)' });
642
+ } else if (providerKey === 'deepseek') {
643
+ fields.push({ name: 'apiKey', hidden: true, prompt: es ? 'Bearer Token (chat.deepseek.com)' : 'Bearer Token (chat.deepseek.com)' });
644
+ fields.push({ name: 'hifLeim', hidden: true, prompt: es ? 'x-hif-leim header (opcional)' : 'x-hif-leim header (optional)' });
645
+ }
646
+ for (const field of fields) {
647
+ const value = await deps.askInput({
648
+ title: es ? `Configurar ${field.name} de ${providerKey}` : `Set ${field.name} for ${providerKey}`,
649
+ prompt: field.prompt,
650
+ hidden: field.hidden,
651
+ defaultValue: '',
652
+ });
653
+ if (value) {
654
+ setProviderField(providerKey, field.name, value);
540
655
  }
656
+ }
657
+ }
541
658
 
542
- const key = args.toLowerCase().trim();
543
- if (!MODELS[key]) {
544
- const available = Object.keys(MODELS).join(', ');
545
- throw new Error(`${t(state.language, 'modelInvalid')}: ${available}`);
659
+ async function runProvidersFlow(state, deps) {
660
+ const es = state?.language === 'es';
661
+
662
+ // Paso 1: Elegir proveedor
663
+ const provider = await runProviderSelector(state, deps);
664
+ if (!provider) return;
665
+ if (provider === '__add_custom__') {
666
+ // Handled inside runProviderSelector; nothing more to do.
667
+ return;
668
+ }
669
+ // If it's a custom provider that hasn't been synced yet, sync it now
670
+ const configured = listConfiguredProviders();
671
+ const isCustom = configured.some(p => p.provider === provider) && !listProvidersFromModels(MODELS).some(p => p.key === provider);
672
+ if (isCustom) {
673
+ try {
674
+ const syncedModels = await syncProvider(provider);
675
+ for (const m of syncedModels) MODELS[m.key] = m;
676
+ } catch (_) {
677
+ // sync may fail silently; user can /provider sync later
546
678
  }
679
+ }
547
680
 
548
- state.activeModel = key;
549
- global.__zynActiveModel = key;
550
- await saveState(state);
551
- await appendTranscriptEntry(state.sessionId, {
552
- type: 'system',
553
- content: `Model switched to: ${MODELS[key].label}${getModelWarning(key) ? `\nWarning: ${getModelWarning(key)}` : ''}`,
681
+ // Paso 2: Si es opcional y no configurado, preguntar si configurar
682
+ const optionalProviders = ['qwenapi', 'gemini', 'huggingface', 'deepseek'];
683
+ if (optionalProviders.includes(provider) && !isProviderConfigured(provider)) {
684
+ const configure = await deps.askConfirm({
685
+ title: es ? `Configurar ${provider} ahora?` : `Configure ${provider} now?`,
686
+ detail: es ? 'Puedes configurarlo despues con variables de entorno' : 'You can configure later with env vars',
554
687
  });
555
- printModelChanged(key);
556
- return true;
688
+ if (configure) {
689
+ await configureProviderInteractive(state, deps, provider);
690
+ }
557
691
  }
558
692
 
559
- if (commandName === 'models') {
560
- printModels();
561
- return true;
693
+ // Paso 3: Mostrar modelos del proveedor
694
+ const model = await runProviderModelsSelector(state, deps, provider);
695
+ if (model) {
696
+ await switchActiveModel(state, deps, model);
697
+ printModelChanged(model);
562
698
  }
699
+ }
563
700
 
564
- if (commandName === 'providers') {
565
- const providers = listProvidersFromModels(MODELS);
566
- console.log('');
567
- for (const provider of providers) {
568
- console.log(` ${provider.key}`);
569
- for (const model of provider.models) {
570
- console.log(` ${model.key.padEnd(16)} ${model.label}`);
571
- }
572
- }
573
- console.log('');
574
- return true;
701
+ async function runModelsFlow(state, deps) {
702
+ const currentKey = state.activeModel || DEFAULT_MODEL_KEY;
703
+ const currentModel = MODELS[currentKey];
704
+ const currentProvider = currentModel?.provider || 'zen';
705
+ const model = await runProviderModelsSelector(state, deps, currentProvider);
706
+ if (model) {
707
+ await switchActiveModel(state, deps, model);
708
+ printModelChanged(model);
575
709
  }
710
+ }
576
711
 
577
712
  if (commandName === 'auto') {
578
713
  if (!args) {
@@ -594,25 +729,6 @@ async function handleLocalCommand(input, state, deps) {
594
729
  return true;
595
730
  }
596
731
 
597
- if (commandName === 'concuerdo') {
598
- state.concuerdo = !state.concuerdo;
599
- await saveState(state);
600
- const activeKey = state.activeModel || DEFAULT_MODEL_KEY;
601
- const allLabels = Object.keys(MODELS).map(k => MODELS[k].label);
602
- await appendTranscriptEntry(state.sessionId, {
603
- type: 'system',
604
- content: `Group mode: ${state.concuerdo ? 'on' : 'off'}`,
605
- });
606
- if (state.concuerdo) {
607
- console.log(`Group mode enabled — ${allLabels.join(' + ')} work together.`);
608
- console.log(` Primary: ${MODELS[activeKey]?.label || activeKey}`);
609
- } else {
610
- console.log('Group mode disabled.');
611
- }
612
- return true;
613
- }
614
-
615
-
616
732
  if (commandName === 'gmail') {
617
733
  const [subRaw, ...rest] = String(args || 'status').trim().split(/\s+/).filter(Boolean);
618
734
  const sub = (subRaw || 'status').toLowerCase();
@@ -657,21 +773,24 @@ async function handleLocalCommand(input, state, deps) {
657
773
  throw new Error('Use /gmail connect|status|disconnect');
658
774
  }
659
775
 
660
- if (commandName === 'web') {
661
- let host = '127.0.0.1';
662
- let port = 3000;
663
- if (args) {
664
- const parts = args.split(/[\s:]+/).filter(Boolean);
665
- for (const part of parts) {
666
- if (/^\d{1,5}$/.test(part)) {
667
- port = Math.min(65535, Math.max(1, Number(part)));
668
- } else if (/^[\d.]+$/.test(part) || part === 'localhost' || part === '0.0.0.0') {
669
- host = part;
670
- }
671
- }
776
+ if (commandName === 'bg') {
777
+ if (!state.__bgDetach) {
778
+ console.log('No hay un turno activo para mandar a segundo plano.');
779
+ console.log(' /bg funciona después de enviar un mensaje; el worker procesa el turno y guarda el resultado.');
780
+ return true;
781
+ }
782
+ const { input, signal } = state.__bgDetach;
783
+ const sessionId = state.sessionId;
784
+ const taskId = enqueueBackgroundTask({ sessionId, input, detachedAt: new Date().toISOString() });
785
+ detachBackgroundTurn({ taskId, sessionId, input, cwd: state.cwd, modelKey: state.activeModel, language: state.language, personaPrompt: state.personaPrompt, autoApprove: state.autoApprove });
786
+ if (signal && !signal.aborted) signal.abort();
787
+ if (typeof deps.exitAfterBg === 'function') {
788
+ deps.exitAfterBg();
789
+ } else {
790
+ console.log(`Turno enviado a segundo plano. Task: ${taskId}`);
791
+ console.log(' El worker terminará el turno y guardará la respuesta en la sesión.');
792
+ console.log(' Vuelve a abrir zyn para ver el resultado.');
672
793
  }
673
- const url = await startWebVersion(host, port);
674
- console.log(`Web version started at ${url}`);
675
794
  return true;
676
795
  }
677
796
 
@@ -684,20 +803,30 @@ async function handleLocalCommand(input, state, deps) {
684
803
  }
685
804
  return true;
686
805
  }
687
-
688
- if (commandName === 'tools') {
689
- printTools();
806
+ if (commandName === 'undo') {
807
+ const len = state.history.length;
808
+ if (len < 2) {
809
+ console.log('Nothing to undo.');
810
+ return true;
811
+ }
812
+ state.redoHistory = state.redoHistory || [];
813
+ const removed = state.history.splice(len - 2, 2);
814
+ state.redoHistory.push(...removed);
815
+ await saveState(state);
816
+ console.log('Last turn undone.');
690
817
  return true;
691
818
  }
692
819
 
693
- if (commandName === 'skills') {
694
- const skills = listSkills();
695
- console.log(`\n ${t(state.language, 'skillsLoaded')} (${skills.length}):\n`);
696
- for (const s of skills) {
697
- console.log(` \x1b[36m${s.name.padEnd(14)}\x1b[0m ${s.title}`);
820
+ if (commandName === 'redo') {
821
+ const stack = state.redoHistory || [];
822
+ if (stack.length < 2) {
823
+ console.log('Nothing to redo.');
824
+ return true;
698
825
  }
699
- console.log(`\n Directory: \x1b[90m${SKILLS_DIR}\x1b[0m`);
700
- console.log(' Edit or add .md files to customize the agent.\n');
826
+ const restored = stack.splice(stack.length - 2, 2);
827
+ state.history.push(...restored);
828
+ await saveState(state);
829
+ console.log('Last turn restored.');
701
830
  return true;
702
831
  }
703
832
 
@@ -749,6 +878,172 @@ async function handleLocalCommand(input, state, deps) {
749
878
  return true;
750
879
  }
751
880
 
881
+ if (commandName === 'models') {
882
+ if (askSelect) {
883
+ await runModelsFlow(state, deps);
884
+ return true;
885
+ }
886
+ printModels();
887
+ return true;
888
+ }
889
+
890
+ if (commandName === 'providers') {
891
+ if (askSelect) {
892
+ await runProvidersFlow(state, deps);
893
+ return true;
894
+ }
895
+ const providers = listProvidersFromModels(MODELS);
896
+ console.log('');
897
+ for (const provider of providers) {
898
+ console.log(` ${provider.key}`);
899
+ for (const model of provider.models) {
900
+ console.log(` ${model.key.padEnd(16)} ${model.label}`);
901
+ }
902
+ }
903
+ console.log('');
904
+ return true;
905
+ }
906
+
907
+ if (commandName === 'provider') {
908
+ const [sub, ...rest] = args.split(' ').filter(Boolean);
909
+ const subArg = rest.join(' ').trim();
910
+
911
+ if (!sub || sub === 'help') {
912
+ console.log('');
913
+ console.log(' /provider list list configured providers');
914
+ console.log(' /provider sync <name> sync models for a provider');
915
+ console.log(' /provider set <name> <k> <v> set a config field');
916
+ console.log(' /provider remove <name> remove a provider config');
917
+ console.log('');
918
+ console.log(' Configurable fields (via set):');
919
+ console.log(' apiKey API key');
920
+ console.log(' baseUrl API base URL');
921
+ console.log(' modelId Model ID override');
922
+ console.log(' contextLength Max context tokens (e.g. 128000)');
923
+ console.log(' email/password Basic auth');
924
+ console.log(' modelEndpoint Custom models endpoint');
925
+ console.log(' chatEndpoint Custom chat endpoint');
926
+ console.log('');
927
+ console.log(' /providers interactive provider picker');
928
+ console.log('');
929
+ return true;
930
+ }
931
+
932
+ if (sub === 'list') {
933
+ const configured = listConfiguredProviders();
934
+ console.log('');
935
+ if (configured.length === 0) {
936
+ console.log(' No configured providers. Use /provider set <name> <key> <value> to add one.');
937
+ } else {
938
+ for (const p of configured) {
939
+ const name = p.provider || p.key || '?';
940
+ const summary = summarizeProviderConfig(name);
941
+ console.log(` ${name}`);
942
+ if (summary?.fields?.length) {
943
+ for (const f of summary.fields) {
944
+ console.log(` ${f.name}: ${f.value}`);
945
+ }
946
+ }
947
+ }
948
+ }
949
+ console.log('');
950
+ return true;
951
+ }
952
+
953
+ if (sub === 'sync') {
954
+ if (!subArg) {
955
+ console.log(' Usage: /provider sync <provider-name>');
956
+ return true;
957
+ }
958
+ try {
959
+ const models = await syncProvider(subArg);
960
+ for (const k of Object.keys(MODELS)) {
961
+ if (MODELS[k]?.provider === subArg) delete MODELS[k];
962
+ }
963
+ for (const m of models) MODELS[m.key] = m;
964
+ console.log(` Synced ${models.length} models for "${subArg}":`);
965
+ for (const m of models) {
966
+ console.log(` ${m.key.padEnd(20)} ${m.label}`);
967
+ }
968
+ } catch (err) {
969
+ console.log(` Error: ${err.message}`);
970
+ }
971
+ return true;
972
+ }
973
+
974
+ if (sub === 'set') {
975
+ const [providerName, field, ...values] = rest;
976
+ if (!providerName || !field || values.length === 0) {
977
+ console.log(' Usage: /provider set <name> <field> <value>');
978
+ return true;
979
+ }
980
+ try {
981
+ setProviderField(providerName, field, values.join(' '));
982
+ console.log(` ${providerName}.${field} = ${values.join(' ')}`);
983
+ } catch (err) {
984
+ console.log(` Error: ${err.message}`);
985
+ }
986
+ return true;
987
+ }
988
+
989
+ if (sub === 'remove') {
990
+ if (!subArg) {
991
+ console.log(' Usage: /provider remove <name>');
992
+ return true;
993
+ }
994
+ try {
995
+ removeProviderConfig(subArg);
996
+ console.log(` Removed provider "${subArg}"`);
997
+ } catch (err) {
998
+ console.log(` Error: ${err.message}`);
999
+ }
1000
+ return true;
1001
+ }
1002
+
1003
+ console.log(' Unknown subcommand. Use /provider help');
1004
+ return true;
1005
+ }
1006
+
1007
+ if (commandName === 'compact') {
1008
+ if (!state.history || state.history.length === 0) {
1009
+ console.log('No history to compact.');
1010
+ return true;
1011
+ }
1012
+ const before = state.history.length;
1013
+ const beforeChars = state.history.reduce((sum, m) => sum + (m.content?.length || 0), 0);
1014
+ truncateHistory(state);
1015
+ await saveState(state);
1016
+ const after = state.history.length;
1017
+ if (before === after) {
1018
+ console.log(`Already compact (${before} messages, ${beforeChars} chars). Use /reset to clear.`);
1019
+ } else {
1020
+ console.log(`Compacted: ${before} -> ${after} messages. Summary: ~${countTokens(state.memorySummary)} tokens.`);
1021
+ }
1022
+ return true;
1023
+ }
1024
+
1025
+ if (commandName === 'theme') {
1026
+ const themes = ['dark', 'cappuccino', 'light', 'coffee', 'gruvbox', 'dracula', 'nord', 'solarized', 'monokai', 'tokyoNight'];
1027
+ const current = state.theme || 'dark';
1028
+ if (!args) {
1029
+ console.log(`Current theme: ${current}`);
1030
+ console.log('Available: ' + themes.join(', '));
1031
+ return true;
1032
+ }
1033
+ const theme = args.toLowerCase().replace(/[-\s]/g, '');
1034
+ const themeMap = { tokyonight: 'tokyoNight' };
1035
+ const resolved = themeMap[theme] || theme;
1036
+ if (!themes.includes(resolved)) {
1037
+ console.log(`Unknown theme. Available: ${themes.join(', ')}`);
1038
+ return true;
1039
+ }
1040
+ state.theme = resolved;
1041
+ await saveState(state);
1042
+ console.log(`Theme set to: ${resolved}`);
1043
+ if (global.__zynApplyTheme) global.__zynApplyTheme(resolved);
1044
+ return true;
1045
+ }
1046
+
752
1047
  return false;
753
1048
  }
754
1049