zyn-ai 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Zyn
1
+ # Zyn Agent
2
2
 
3
3
  <p align="center">
4
4
  <img src="http://cdn.soymaycol.icu/files/logo_zyn.png" alt="Zyn logo" width="180" />
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "zyn-ai",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Production-ready AI agent for CLI and web with real tool execution and automation",
5
- "author": "Maycol y Ado",
5
+ "author": "Maycol",
6
6
  "keywords": [
7
7
  "ai-agent",
8
8
  "cli-ai",
@@ -18,7 +18,8 @@
18
18
  "zyn"
19
19
  ],
20
20
  "bin": {
21
- "zyn": "./zyn.js"
21
+ "zyn": "./zyn.js",
22
+ "Zyn": "./zyn.js"
22
23
  },
23
24
  "type": "commonjs",
24
25
  "scripts": {
@@ -36,7 +37,8 @@
36
37
  "ink": "^6.8.0",
37
38
  "keypress": "^0.2.1",
38
39
  "react": "^19.0.0",
39
- "session-file-store": "^1.5.0"
40
+ "session-file-store": "^1.5.0",
41
+ "jimp": "^1.6.1"
40
42
  },
41
43
  "repository": {
42
44
  "type": "git",
@@ -7,6 +7,7 @@ const { listSkills, SKILLS_DIR } = require('../core/skills');
7
7
  const { DEFAULT_LANGUAGE, DEFAULT_MODEL_KEY, MODELS, listProvidersFromModels } = require('../config');
8
8
  const { languageLabel, normalizeLanguage, t } = require('../i18n');
9
9
  const { createNewSessionState, listSessions, loadSessionState, saveState } = require('../utils/sessionStorage');
10
+ const { listGitSecrets, removeGitSecret, upsertGitSecret } = require('../utils/secretStorage');
10
11
  const { exportTranscriptText, formatTranscriptPreview } = require('../utils/transcriptStorage');
11
12
  const { resolveInputPath } = require('../utils/pathUtils');
12
13
  const { printTools } = require('../tools');
@@ -24,6 +25,7 @@ const SLASH_COMMANDS = [
24
25
  { name: 'model', desc: 'view/change model' },
25
26
  { name: 'models', desc: 'list models' },
26
27
  { name: 'providers', desc: 'list providers' },
28
+ { name: 'git', desc: 'configure git credentials' },
27
29
  { name: 'lang', desc: 'change language' },
28
30
  { name: 'language', desc: 'change language' },
29
31
  { name: 'auto', desc: 'auto-approval' },
@@ -28,6 +28,7 @@ const {
28
28
  loadOrCreateSessionState,
29
29
  } = require('../utils/sessionStorage');
30
30
  const { appendTranscriptEntry } = require('../utils/transcriptStorage');
31
+ const { t } = require('../i18n');
31
32
 
32
33
  async function readPromptFromStdin() {
33
34
  if (process.stdin.isTTY) {
@@ -175,7 +176,7 @@ async function runInteractiveChatClassic(options = {}) {
175
176
  if (!input) continue;
176
177
 
177
178
  if (input === '/exit' || input === '/quit') {
178
- logEvent(state, 'info', 'Hasta luego');
179
+ logEvent(state, 'info', t(state.language, 'goodbye'));
179
180
  break;
180
181
  }
181
182
 
@@ -202,7 +203,7 @@ async function runInteractiveChatClassic(options = {}) {
202
203
  rl.removeListener('line', lineHandler);
203
204
 
204
205
  if (pendingExit) {
205
- logEvent(state, 'info', 'Hasta luego');
206
+ logEvent(state, 'info', t(state.language, 'goodbye'));
206
207
  break;
207
208
  }
208
209
  }
package/src/config.js CHANGED
@@ -89,10 +89,10 @@ const MODELS = {
89
89
  };
90
90
 
91
91
  const DEFAULT_MODEL_KEY = process.env.ZYN_DEFAULT_MODEL || 'qwen';
92
- const DEFAULT_LANGUAGE = normalizeLanguage(process.env.ZYN_DEFAULT_LANG || process.env.ZYN_LANGUAGE || 'en');
92
+ const DEFAULT_LANGUAGE = normalizeLanguage(process.env.ZYN_DEFAULT_LANG || process.env.ZYN_LANGUAGE || process.env.LANG || 'en');
93
93
 
94
- const QWEN_EMAIL = process.env.ZYN_QWEN_EMAIL || process.env.QWEN_EMAIL || 'danielalejandrobasado@gmail.com';
95
- const QWEN_PASSWORD = process.env.ZYN_QWEN_PASSWORD || process.env.QWEN_PASSWORD || 'zyzz1234';
94
+ const QWEN_EMAIL = process.env.ZYN_QWEN_EMAIL || 'danielalejandrobasado@gmail.com';
95
+ const QWEN_PASSWORD = process.env.ZYN_QWEN_PASSWORD || 'zyzz1234';
96
96
 
97
97
  const MAX_TOOL_STEPS = Number.POSITIVE_INFINITY;
98
98
  const MAX_OUTPUT_CHARS = 12000;
package/src/core/agent.js CHANGED
@@ -197,6 +197,9 @@ async function runAgentTurn(input, state, ui, options = {}) {
197
197
  }
198
198
  ui.logEvent(state, 'info', `${state.language === 'es' ? 'Turno' : 'Turn'} ${state.turnCount}`);
199
199
 
200
+ let toolUsedThisTurn = false;
201
+ let finalWithoutToolRetries = 0;
202
+
200
203
  const directAction = parseDirectAction(input);
201
204
  if (directAction) {
202
205
  await appendTranscriptEntry(state.sessionId, { type: 'user', content: input });
@@ -227,8 +230,7 @@ async function runAgentTurn(input, state, ui, options = {}) {
227
230
  let repeatCount = 0;
228
231
  let step = 0;
229
232
  const turnLanguage = detectLanguage(input, state.language);
230
- let toolUsedThisTurn = false;
231
- let finalWithoutToolRetries = 0;
233
+ state.language = turnLanguage;
232
234
 
233
235
  while (true) {
234
236
  if (signal?.aborted) {
@@ -1,13 +1,14 @@
1
1
  const { normalizeText } = require('../utils/text');
2
2
  const { buildSkillsPrompt } = require('./skills');
3
- const { getToolPromptText } = require('../tools');
3
+ const { getToolPromptText, TOOL_DEFINITIONS } = require('../tools');
4
4
  const { listProvidersFromModels, MODELS, DEFAULT_MODEL_KEY } = require('../config');
5
5
  const { detectLanguage, normalizeLanguage, languageLabel } = require('../i18n');
6
6
 
7
7
  const KNOWN_TOOLS = new Set([
8
- 'list_dir', 'read_file', 'search_text', 'glob_files', 'file_info',
9
- 'run_command', 'make_dir', 'write_file', 'append_file', 'replace_in_file',
10
- 'fetch_url', 'web_search', 'web_read',
8
+ ...TOOL_DEFINITIONS.map(tool => tool.name),
9
+ 'task_create', 'task_list', 'task_update', 'task_complete', 'task_delete', 'task_clear',
10
+ 'git_secret_set', 'git_secret_list', 'git_secret_remove',
11
+ 'git_clone_repo', 'git_api_request',
11
12
  ]);
12
13
 
13
14
 
@@ -37,6 +38,9 @@ function buildSystemPrompt(cwd, state = {}, options = {}) {
37
38
  'Nunca finjas que hiciste algo si no usaste herramientas o no tienes el resultado real.',
38
39
  'Si la tarea requiere comprobar algo, primero intenta una herramienta real y espera el resultado antes de concluir.',
39
40
  'No cierres con una conclusion si todavia no has probado nada.',
41
+ 'Si una tarea dura demasiado, usa run_command con un timeoutMs adecuado y confirma el resultado real.',
42
+ 'Para GitHub, GitLab o un Git personalizado usa git_secret_set para guardar credenciales y git_clone_repo o git_api_request para operar sin exponer secretos.',
43
+ 'Usa exclusivamente tools registradas en "Tool use". No inventes nombres de tools ni aliases.',
40
44
  ]
41
45
  : [
42
46
  'Always respond in English.',
@@ -47,6 +51,9 @@ function buildSystemPrompt(cwd, state = {}, options = {}) {
47
51
  'Never pretend you completed an action if you did not actually use tools or obtain a real result.',
48
52
  'If the task requires verification, try a real tool first and wait for its result before concluding.',
49
53
  'Do not end with a conclusion if you have not tested anything yet.',
54
+ 'If a task takes long, use run_command with an appropriate timeoutMs and verify the real result.',
55
+ 'For GitHub, GitLab, or a custom Git host, use git_secret_set to store credentials and git_clone_repo or git_api_request to operate without exposing secrets.',
56
+ 'Use only tools listed under "Tool use". Never invent tool names or aliases.',
50
57
  ];
51
58
 
52
59
  const parts = [
@@ -175,6 +182,7 @@ const TOOL_ARG_KEYS = {
175
182
  fetch_url: ['url', 'selector', 'attribute', 'limit'],
176
183
  web_search: ['query'],
177
184
  web_read: ['url'],
185
+ create_canvas_image: ['width', 'height', 'background', 'elements', 'format', 'outputPath'],
178
186
  };
179
187
 
180
188
  const LONG_VALUE_ARG = {
package/src/i18n.js CHANGED
@@ -47,6 +47,9 @@ const STRINGS = {
47
47
  transcriptLabel: 'transcript',
48
48
  fromLabel: 'created',
49
49
  updatedLabel: 'updated',
50
+ queuedLabel: 'queued',
51
+ processingQueuedMessage: 'processing queued message',
52
+ goodbye: 'Goodbye',
50
53
  },
51
54
  es: {
52
55
  helpTitle: 'Ayuda',
@@ -89,6 +92,9 @@ const STRINGS = {
89
92
  transcriptLabel: 'transcript',
90
93
  fromLabel: 'desde',
91
94
  updatedLabel: 'actualizado',
95
+ queuedLabel: 'en cola',
96
+ processingQueuedMessage: 'procesando mensaje en cola',
97
+ goodbye: 'Hasta luego',
92
98
  },
93
99
  };
94
100
 
@@ -7,6 +7,16 @@ const fsp = fs.promises;
7
7
  const {
8
8
  MAX_FILE_LINES,
9
9
  } = require('../config');
10
+ const {
11
+ buildApiHeaders,
12
+ buildCloneUrl,
13
+ getApiBaseUrl,
14
+ listGitSecrets,
15
+ normalizeProfileName,
16
+ removeGitSecret,
17
+ resolveGitProfile,
18
+ upsertGitSecret,
19
+ } = require('../utils/secretStorage');
10
20
  const { resolveInputPath } = require('../utils/pathUtils');
11
21
  const {
12
22
  formatLineRange,
@@ -28,7 +38,9 @@ const TOOL_DEFINITIONS = [
28
38
  { name: 'fetch_url', usage: '{ url, selector?, attribute?, limit?, headers? }' },
29
39
  { name: 'web_search', usage: '{ query }' },
30
40
  { name: 'web_read', usage: '{ url }' },
41
+ { name: 'create_canvas_image', usage: '{ width, height, background?, elements?, format?, outputPath? }' },
31
42
  ];
43
+ const REGISTERED_TOOLS = new Set(TOOL_DEFINITIONS.map(tool => tool.name));
32
44
 
33
45
  function getToolPromptText() {
34
46
  return [
@@ -94,6 +106,17 @@ function getToolPromptText() {
94
106
  ' Descarga una pagina web y la convierte a texto legible (sin HTML).',
95
107
  ' Ideal para leer articulos, documentacion o contenido de paginas.',
96
108
  ' Ejemplo: {"type":"tool","tool":"web_read","args":{"url":"https://docs.example.com/guide"}}',
109
+ '',
110
+ '## Imagen profesional con Jimp',
111
+ '',
112
+ 'create_canvas_image { width, height, background?, elements?, format?, outputPath? }',
113
+ ' Crea imagenes desde cero usando Jimp con composicion por elementos.',
114
+ ' width/height son obligatorios. background puede ser color HEX (#RRGGBB o #RRGGBBAA).',
115
+ ' elements permite combinar rect, circle/ellipse, line, text e image.',
116
+ ' Usa este flujo profesional: definir lienzo -> capas base -> tipografia -> detalles -> exportacion.',
117
+ ' Ejemplo:',
118
+ ' {"type":"tool","tool":"create_canvas_image","args":{"width":1200,"height":628,"background":"#0f172a","format":"png","outputPath":"generated/cover.png","elements":[{"type":"rect","x":48,"y":48,"w":1104,"h":532,"radius":24,"fill":"#111827"},{"type":"text","x":96,"y":120,"fontSize":32,"text":"Quarterly Business Report"}]}}',
119
+ '',
97
120
  ].join('\n');
98
121
  }
99
122
 
@@ -137,6 +160,8 @@ function describeToolCall(call) {
137
160
  const readUrl = cleanUrl(call.args.url || '');
138
161
  return `Leyendo ${shortText(readUrl, 60)}`;
139
162
  }
163
+ case 'create_canvas_image':
164
+ return `Creando imagen ${call.args.width || '?'}x${call.args.height || '?'}`;
140
165
  default:
141
166
  return call.tool;
142
167
  }
@@ -751,6 +776,9 @@ async function webReadTool(args, state, paint) {
751
776
  }
752
777
 
753
778
  async function executeToolCall(call, state, ui) {
779
+ if (!call || typeof call.tool !== 'string' || !REGISTERED_TOOLS.has(call.tool)) {
780
+ throw new Error(`Herramienta no registrada: ${call?.tool || 'desconocida'}`);
781
+ }
754
782
  ui.logEvent(state, 'tool', describeToolCall(call));
755
783
 
756
784
  const startTime = Date.now();
@@ -796,6 +824,9 @@ async function executeToolCall(call, state, ui) {
796
824
  case 'web_read':
797
825
  result = await webReadTool(call.args, state, ui.paint);
798
826
  break;
827
+ case 'create_canvas_image':
828
+ result = await createCanvasImageTool(call.args, state, ui.paint);
829
+ break;
799
830
  default:
800
831
  throw new Error(`Herramienta no soportada: ${call.tool}`);
801
832
  }
@@ -819,6 +850,9 @@ function buildOllamaInstallCommand() {
819
850
 
820
851
  function parseDirectAction(input) {
821
852
  const text = input.trim();
853
+ if (/^(git|npm|node|pnpm|yarn)\s+/.test(text)) {
854
+ return { tool: 'run_command', args: { command: text } };
855
+ }
822
856
 
823
857
  const runMatch = text.match(/^(?:ejecuta|corre)\s+(?:el\s+)?comando\s+([\s\S]+)$/i);
824
858
  if (runMatch) {
@@ -1002,3 +1036,245 @@ module.exports = {
1002
1036
  parseDirectAction,
1003
1037
  printTools,
1004
1038
  };
1039
+
1040
+
1041
+ function getGitSecretLabel(provider, name = '') {
1042
+ const key = normalizeProfileName(provider);
1043
+ if (key === 'custom') return name ? `custom:${name}` : 'custom';
1044
+ return key;
1045
+ }
1046
+
1047
+ function parseColor(value, fallback = 0x000000ff) {
1048
+ if (typeof value !== 'string' || !value.trim()) return fallback;
1049
+ const raw = value.trim().replace('#', '');
1050
+ if (/^[0-9a-f]{6}$/i.test(raw)) return Number.parseInt(`${raw}ff`, 16) >>> 0;
1051
+ if (/^[0-9a-f]{8}$/i.test(raw)) return Number.parseInt(raw, 16) >>> 0;
1052
+ return fallback;
1053
+ }
1054
+
1055
+ function drawRect(image, x, y, w, h, color) {
1056
+ const left = Math.max(0, Math.floor(Number(x) || 0));
1057
+ const top = Math.max(0, Math.floor(Number(y) || 0));
1058
+ const width = Math.max(1, Math.floor(Number(w) || 0));
1059
+ const height = Math.max(1, Math.floor(Number(h) || 0));
1060
+ image.scan(left, top, width, height, function (px, py, idx) {
1061
+ this.bitmap.data.writeUInt32BE(color >>> 0, idx);
1062
+ });
1063
+ }
1064
+
1065
+ function drawRoundRect(image, x, y, w, h, radius, color) {
1066
+ const left = Math.max(0, Math.floor(Number(x) || 0));
1067
+ const top = Math.max(0, Math.floor(Number(y) || 0));
1068
+ const width = Math.max(1, Math.floor(Number(w) || 0));
1069
+ const height = Math.max(1, Math.floor(Number(h) || 0));
1070
+ const r = Math.max(0, Math.min(Number(radius) || 0, Math.floor(width / 2), Math.floor(height / 2)));
1071
+ if (r <= 0) return drawRect(image, left, top, width, height, color);
1072
+
1073
+ image.scan(left, top, width, height, function (px, py, idx) {
1074
+ const cx = px < left + r ? left + r : px >= left + width - r ? left + width - r - 1 : px;
1075
+ const cy = py < top + r ? top + r : py >= top + height - r ? top + height - r - 1 : py;
1076
+ const dx = px - cx;
1077
+ const dy = py - cy;
1078
+ if ((dx * dx) + (dy * dy) <= r * r) {
1079
+ this.bitmap.data.writeUInt32BE(color >>> 0, idx);
1080
+ }
1081
+ });
1082
+ }
1083
+
1084
+ function drawLine(image, x1, y1, x2, y2, color) {
1085
+ const sx = Number(x1 || 0);
1086
+ const sy = Number(y1 || 0);
1087
+ const tx = Number(x2 || 0);
1088
+ const ty = Number(y2 || 0);
1089
+ const steps = Math.max(1, Math.ceil(Math.max(Math.abs(tx - sx), Math.abs(ty - sy))));
1090
+ for (let i = 0; i <= steps; i++) {
1091
+ const x = Math.round(sx + ((tx - sx) * i / steps));
1092
+ const y = Math.round(sy + ((ty - sy) * i / steps));
1093
+ if (x >= 0 && y >= 0 && x < image.bitmap.width && y < image.bitmap.height) {
1094
+ image.setPixelColor(color, x, y);
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ async function createCanvasImageTool(args, state, paint) {
1100
+ const { Jimp, loadFont } = require('jimp');
1101
+ const pluginPrintMain = require.resolve('@jimp/plugin-print');
1102
+ const fontsPath = path.join(path.dirname(pluginPrintMain), 'fonts.js');
1103
+ const fonts = require(fontsPath);
1104
+ const width = Math.max(1, Number(args.width || 0));
1105
+ const height = Math.max(1, Number(args.height || 0));
1106
+ if (!width || !height) {
1107
+ throw new Error('create_canvas_image requiere width y height');
1108
+ }
1109
+
1110
+ const format = String(args.format || 'png').toLowerCase();
1111
+ const safeFormat = ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif', 'tiff'].includes(format) ? format : 'png';
1112
+ const fileExt = safeFormat === 'jpeg' ? 'jpg' : safeFormat;
1113
+ const outputPath = resolveInputPath(args.outputPath || path.join('generated', `image-${Date.now()}.${fileExt}`), state.cwd);
1114
+
1115
+ const allowed = await askConfirmation(state.rl, 'Crear imagen', `${width}x${height}\nFormato: ${safeFormat}\nSalida: ${outputPath}`, paint, state);
1116
+ if (!allowed) return 'Creacion de imagen cancelada por el usuario.';
1117
+
1118
+ const bg = args.background && typeof args.background === 'object'
1119
+ ? args.background.color || args.background.fill || '#ffffff'
1120
+ : args.background || '#ffffff';
1121
+ const image = new Jimp({ width, height, color: parseColor(bg, 0xffffffff) });
1122
+ const elements = Array.isArray(args.elements) ? args.elements.filter(Boolean) : [];
1123
+
1124
+ for (const element of elements) {
1125
+ if (!element || typeof element !== 'object') continue;
1126
+ const type = String(element.type || 'text').toLowerCase();
1127
+ if (type === 'rect') {
1128
+ const color = parseColor(element.fill || element.color || '#000000', 0x000000ff);
1129
+ if (element.radius) drawRoundRect(image, element.x || 0, element.y || 0, element.w || element.width || 0, element.h || element.height || 0, element.radius || 0, color);
1130
+ else drawRect(image, element.x || 0, element.y || 0, element.w || element.width || 0, element.h || element.height || 0, color);
1131
+ continue;
1132
+ }
1133
+ if (type === 'line') {
1134
+ drawLine(image, element.x1 || 0, element.y1 || 0, element.x2 || 0, element.y2 || 0, parseColor(element.stroke || '#000000', 0x000000ff));
1135
+ continue;
1136
+ }
1137
+ if (type === 'circle' || type === 'ellipse') {
1138
+ const color = parseColor(element.fill || element.color || '#000000', 0x000000ff);
1139
+ const cx = Number(element.x || 0);
1140
+ const cy = Number(element.y || 0);
1141
+ const rx = Math.max(1, Number(element.rx || element.r || element.radius || element.width || 0));
1142
+ const ry = Math.max(1, Number(element.ry || element.r || element.radius || element.height || rx));
1143
+ image.scan(0, 0, image.bitmap.width, image.bitmap.height, function (px, py, idx) {
1144
+ const dx = (px - cx) / rx;
1145
+ const dy = (py - cy) / ry;
1146
+ if ((dx * dx) + (dy * dy) <= 1) {
1147
+ this.bitmap.data.writeUInt32BE(color >>> 0, idx);
1148
+ }
1149
+ });
1150
+ continue;
1151
+ }
1152
+ if (type === 'image') {
1153
+ const src = element.src || element.url || element.path;
1154
+ if (!src) continue;
1155
+ const loaded = await Jimp.read(src.startsWith('http') || src.startsWith('data:') ? src : resolveInputPath(src, state.cwd));
1156
+ const x = Number(element.x || 0);
1157
+ const y = Number(element.y || 0);
1158
+ const w = Math.max(1, Number(element.w || element.width || loaded.bitmap.width));
1159
+ const h = Math.max(1, Number(element.h || element.height || loaded.bitmap.height));
1160
+ const clone = loaded.clone().resize({ w, h });
1161
+ image.composite(clone, x, y);
1162
+ continue;
1163
+ }
1164
+
1165
+ const text = String(element.text || '');
1166
+ if (!text) continue;
1167
+ const size = Math.max(8, Math.min(64, Number(element.fontSize || 32)));
1168
+ const font = await loadFont(
1169
+ size <= 8 ? fonts.SANS_8_BLACK :
1170
+ size <= 16 ? fonts.SANS_16_BLACK :
1171
+ size <= 32 ? fonts.SANS_32_BLACK :
1172
+ fonts.SANS_64_BLACK,
1173
+ );
1174
+ image.print({ font, x: Number(element.x || 0), y: Number(element.y || 0), maxWidth: element.maxWidth ? Number(element.maxWidth) : undefined }, text);
1175
+ }
1176
+
1177
+ await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
1178
+ await image.write(outputPath);
1179
+ return [`Imagen creada: ${outputPath}`, `Formato: ${safeFormat}`, `Tamano: ${width}x${height}`, `Elementos: ${elements.length}`].join('\\n');
1180
+ }
1181
+
1182
+
1183
+ async function gitSecretSetTool(args) {
1184
+ const provider = normalizeProfileName(args.provider || '');
1185
+ if (!provider) throw new Error('git_secret_set requiere provider');
1186
+ if (!args.token || typeof args.token !== 'string') throw new Error('git_secret_set requiere token');
1187
+ const saved = upsertGitSecret(provider, args);
1188
+ return `Credencial guardada: ${getGitSecretLabel(provider, saved?.name || args.name || '')}`;
1189
+ }
1190
+
1191
+ async function gitSecretListTool(args) {
1192
+ const provider = normalizeProfileName(args.provider || '');
1193
+ const name = String(args.name || '').trim();
1194
+ const secrets = listGitSecrets();
1195
+ const filtered = secrets.filter(secret => {
1196
+ if (!provider) return true;
1197
+ if (provider === 'custom') return !name || secret.key === `custom:${name}`;
1198
+ return secret.key === provider;
1199
+ });
1200
+ if (!filtered.length) return 'No hay credenciales Git guardadas.';
1201
+ return filtered.map(secret => [
1202
+ `${secret.key}`,
1203
+ ` username: ${secret.username || '-'}`,
1204
+ ` apiBaseUrl: ${secret.apiBaseUrl || '-'}`,
1205
+ ` cloneBaseUrl: ${secret.cloneBaseUrl || '-'}`,
1206
+ ` authHeader: ${secret.authHeader || '-'}`,
1207
+ ` token: ${secret.token}`,
1208
+ ].join('\n')).join('\n\n');
1209
+ }
1210
+
1211
+ async function gitSecretRemoveTool(args) {
1212
+ const provider = normalizeProfileName(args.provider || '');
1213
+ if (!provider) throw new Error('git_secret_remove requiere provider');
1214
+ const name = String(args.name || '').trim();
1215
+ const removed = removeGitSecret(provider, name);
1216
+ return removed ? `Credencial eliminada: ${getGitSecretLabel(provider, name)}` : 'No encontre esa credencial.';
1217
+ }
1218
+
1219
+ async function gitCloneRepoTool(args, state, paint) {
1220
+ const repoUrl = String(args.repoUrl || '').trim();
1221
+ if (!repoUrl) throw new Error('git_clone_repo requiere repoUrl');
1222
+ const provider = normalizeProfileName(args.provider || '');
1223
+ const name = String(args.name || '').trim();
1224
+ const profile = provider ? resolveGitProfile(provider, name) : null;
1225
+ const finalUrl = buildCloneUrl(repoUrl, profile || {});
1226
+ const destination = args.destination ? resolveInputPath(args.destination, state.cwd) : '';
1227
+ const timeoutMs = Math.max(1000, Number.isFinite(Number(args.timeoutMs)) ? Number(args.timeoutMs) : 10 * 60 * 1000);
1228
+
1229
+ const allowed = await askConfirmation(state.rl, 'Clonar repositorio', [
1230
+ repoUrl,
1231
+ profile ? `Provider: ${provider}${name ? ` (${name})` : ''}` : 'Provider: direct',
1232
+ destination ? `Destino: ${destination}` : null,
1233
+ `Timeout: ${timeoutMs}ms`,
1234
+ ].filter(Boolean).join('\n'), paint, state);
1235
+ if (!allowed) return 'Clonado cancelado por el usuario.';
1236
+
1237
+ const result = await runProcess('git', ['clone', ...(args.branch ? ['--branch', String(args.branch)] : []), finalUrl, ...(destination ? [destination] : [])], { cwd: state.cwd, timeoutMs });
1238
+ const lines = [`Exit code: ${result.code}`];
1239
+ if (result.timedOut) lines.push('Timeout: el clon fue detenido por tiempo.');
1240
+ if (result.stdout.trim()) lines.push(`STDOUT:\n${result.stdout.trim()}`);
1241
+ if (result.stderr.trim()) lines.push(`STDERR:\n${result.stderr.trim()}`);
1242
+ return lines.join('\n\n');
1243
+ }
1244
+
1245
+ async function gitApiRequestTool(args, state, paint) {
1246
+ const provider = normalizeProfileName(args.provider || '');
1247
+ if (!provider) throw new Error('git_api_request requiere provider');
1248
+ const pathValue = String(args.path || '').trim();
1249
+ if (!pathValue) throw new Error('git_api_request requiere path');
1250
+ const name = String(args.name || '').trim();
1251
+ const profile = resolveGitProfile(provider, name);
1252
+ if (!profile) throw new Error(`No hay credenciales para ${provider}${provider === 'custom' && name ? `:${name}` : ''}`);
1253
+ const baseUrl = getApiBaseUrl(provider, profile);
1254
+ if (!baseUrl) throw new Error(`No hay apiBaseUrl para ${provider}`);
1255
+ const url = `${baseUrl.replace(/\/+$/, '')}/${pathValue.replace(/^\/+/, '')}`;
1256
+ const timeoutMs = Math.max(1000, Number.isFinite(Number(args.timeoutMs)) ? Number(args.timeoutMs) : 15000);
1257
+ const method = String(args.method || 'GET').toUpperCase();
1258
+ const headers = buildApiHeaders(provider, profile, args.headers && typeof args.headers === 'object' ? args.headers : {});
1259
+
1260
+ const allowed = await askConfirmation(state.rl, 'Git API request', `${method} ${url}\nTimeout: ${timeoutMs}ms`, paint, state);
1261
+ if (!allowed) return 'Request cancelado por el usuario.';
1262
+
1263
+ const axios = require('axios');
1264
+ const response = await axios({
1265
+ url,
1266
+ method,
1267
+ headers: {
1268
+ 'User-Agent': 'Zyn/1.0',
1269
+ Accept: 'application/json, text/plain, */*',
1270
+ ...headers,
1271
+ },
1272
+ data: args.body && typeof args.body === 'object' ? args.body : args.body,
1273
+ timeout: timeoutMs,
1274
+ responseType: 'text',
1275
+ validateStatus: () => true,
1276
+ });
1277
+
1278
+ const text = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
1279
+ return `Status: ${response.status}\n\n${text}`;
1280
+ }
package/src/tui/app.mjs CHANGED
@@ -17,9 +17,17 @@ const {
17
17
  DEFAULT_MODEL_KEY,
18
18
  MODELS,
19
19
  } = require('../config');
20
+ const { normalizeLanguage } = require('../i18n');
20
21
 
21
22
  const h = React.createElement;
23
+ function getTuiLang() {
24
+ return normalizeLanguage(global.__zynCurrentLanguage || 'en');
25
+ }
26
+ function uiText(en, es) {
27
+ return getTuiLang() === 'es' ? es : en;
28
+ }
22
29
  const MAX_THINKING_LINES = 20;
30
+ const MAX_PASTE_PREVIEW = 200;
23
31
  const SPIN_MS = 80;
24
32
 
25
33
  const SPIN_FRAMES = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
@@ -372,7 +380,7 @@ function Banner({ model, resumed, width, cwd }) {
372
380
  return s + ' '.repeat(Math.max(0, inner - s.length));
373
381
  };
374
382
 
375
- const sessionLabel = resumed ? 'sesion reanudada' : 'sesion nueva';
383
+ const sessionLabel = resumed ? uiText('session resumed', 'sesion reanudada') : uiText('new session', 'sesion nueva');
376
384
  const cwdShort = cwd && cwd.length > inner - 6 ? '...' + cwd.slice(-(inner - 9)) : (cwd || '.');
377
385
 
378
386
  return h(Box, { flexDirection: 'column', paddingTop: 1, paddingBottom: 0 },
@@ -429,10 +437,15 @@ function EventLine({ kind, title, detail }) {
429
437
  };
430
438
  const { sym, color } = cfg[kind] || cfg.info;
431
439
 
432
- return h(Box, { paddingLeft: 5, gap: 1 },
433
- h(Text, { color }, sym),
434
- h(Text, { color: kind === 'tool' ? T.textDim : T.textMuted }, title),
435
- detail ? h(Text, { color: T.textGhost }, detail) : null,
440
+ const compactTitle = String(title || '').replace(/\s{2,}/g, ' ').trim();
441
+ const compactDetail = String(detail || '').replace(/\s{2,}/g, ' ').trim();
442
+
443
+ return h(Box, { paddingLeft: 3, flexDirection: 'column' },
444
+ h(Box, { gap: 1 },
445
+ h(Text, { color }, sym),
446
+ h(Text, { color: kind === 'tool' ? T.textDim : T.textMuted, wrap: 'wrap' }, compactTitle),
447
+ ),
448
+ compactDetail ? h(Box, { paddingLeft: 2 }, h(Text, { color: T.textGhost, wrap: 'wrap' }, compactDetail)) : null,
436
449
  );
437
450
  }
438
451
 
@@ -441,7 +454,7 @@ function UserMessage({ text }) {
441
454
  h(Box, { flexDirection: 'column' },
442
455
  h(Box, { gap: 1, marginBottom: 0 },
443
456
  h(Text, { color: T.accent, bold: true }, '\u29bf'),
444
- h(Text, { color: T.textDim, bold: true }, 'You'),
457
+ h(Text, { color: T.textDim, bold: true }, uiText('You', 'Tú')),
445
458
  ),
446
459
  h(Box, { paddingLeft: 2 },
447
460
  h(Text, { color: T.text, wrap: 'wrap' }, text),
@@ -465,17 +478,17 @@ function ThinkingBlock({ text, elapsed, live, width }) {
465
478
  const pulseChar = live ? SPIN_FRAMES[Math.floor(Date.now() / SPIN_MS) % SPIN_FRAMES.length] : '\u25d0';
466
479
 
467
480
  const label = live
468
- ? pulseChar + ' Pensando...'
469
- : '\u25d0 Penso ' + elapsed + 's';
481
+ ? pulseChar + ' ' + uiText('Thinking...', 'Pensando...')
482
+ : '\u25d0 ' + uiText('Thought for ', 'Pensó durante ') + elapsed + 's';
470
483
 
471
- return h(Box, { flexDirection: 'column', paddingLeft: 5, marginTop: 1 },
484
+ return h(Box, { flexDirection: 'column', paddingLeft: 3, marginTop: 1 },
472
485
  h(Text, { color: T.textGhost }, label),
473
486
  lines.length > 0
474
- ? h(Box, { flexDirection: 'column', paddingLeft: 2 },
487
+ ? h(Box, { flexDirection: 'column', paddingLeft: 1 },
475
488
  ...lines.map((line, i) =>
476
489
  h(Text, { key: String(i), color: T.textInvis, wrap: 'wrap' }, line),
477
490
  ),
478
- more > 0 ? h(Text, { color: T.textGhost }, '\u00b7\u00b7\u00b7 ' + more + ' lineas mas') : null,
491
+ more > 0 ? h(Text, { color: T.textGhost }, '\u00b7\u00b7\u00b7 ' + more + ' ' + uiText('more lines', 'líneas más')) : null,
479
492
  )
480
493
  : null,
481
494
  );
@@ -534,10 +547,10 @@ function ConfirmBar({ title, detail }) {
534
547
  : null,
535
548
  h(Box, { marginTop: 0, paddingLeft: 2, gap: 2 },
536
549
  h(Text, { color: T.green, bold: true }, '[y]'),
537
- h(Text, { color: T.textMuted }, 'permitir'),
550
+ h(Text, { color: T.textMuted }, uiText('allow', 'permitir')),
538
551
  h(Text, { color: T.textInvis }, '\u00b7'),
539
552
  h(Text, { color: T.red, bold: true }, '[n]'),
540
- h(Text, { color: T.textMuted }, 'denegar'),
553
+ h(Text, { color: T.textMuted }, uiText('deny', 'denegar')),
541
554
  ),
542
555
  );
543
556
  }
@@ -566,13 +579,13 @@ function StatusBar({ model, processing, width, turnCount }) {
566
579
  ? h(Text, { color: T.accentSoft }, SPIN_FRAMES[frame])
567
580
  : null,
568
581
  turnCount > 0
569
- ? h(Text, { color: T.textInvis }, '\u00b7 ' + turnCount + (turnCount === 1 ? ' turno' : ' turnos'))
582
+ ? h(Text, { color: T.textInvis }, '\u00b7 ' + turnCount + ' ' + uiText(turnCount === 1 ? 'turn' : 'turns', turnCount === 1 ? 'turno' : 'turnos'))
570
583
  : null,
571
584
  ),
572
585
  h(Box, { gap: 1 },
573
586
  h(Text, { color: T.textInvis }, '/help'),
574
587
  h(Text, { color: T.textInvis }, '\u00b7'),
575
- h(Text, { color: T.textInvis }, 'esc salir'),
588
+ h(Text, { color: T.textInvis }, uiText('esc exit', 'esc salir')),
576
589
  ),
577
590
  ),
578
591
  );
@@ -709,8 +722,10 @@ function InputBar({ onSubmit, processing }) {
709
722
  }
710
723
 
711
724
  if (input && !key.ctrl && !key.meta) {
712
- setValue(v => v.slice(0, cursor) + input + v.slice(cursor));
713
- setCursor(c => c + input.length);
725
+ const normalizedInput = input.replace(/\r\n/g, '\n');
726
+ const safeInput = normalizedInput.includes('\n') ? normalizedInput.replace(/\n/g, ' ') : normalizedInput;
727
+ setValue(v => v.slice(0, cursor) + safeInput + v.slice(cursor));
728
+ setCursor(c => c + safeInput.length);
714
729
  setSuggestIdx(0);
715
730
  }
716
731
  });
@@ -721,7 +736,7 @@ function InputBar({ onSubmit, processing }) {
721
736
  const after = value.slice(cursor + 1);
722
737
 
723
738
  const promptColor = processing ? T.amber : T.accent;
724
- const placeholder = processing ? ' En cola — escribe y se procesará después...' : ' Escribe un mensaje...';
739
+ const placeholder = processing ? uiText(' Queued — type and it will run later...', ' En cola — escribe y se procesará después...') : uiText(' Type a message...', ' Escribe un mensaje...');
725
740
 
726
741
  const inputLine = h(Box, { paddingLeft: 3, paddingRight: 3, paddingTop: 0, paddingBottom: 0, marginTop: 1 },
727
742
  h(Text, { color: promptColor }, processing ? '\u{1F4E9} ' : '\u276f '),
@@ -749,7 +764,7 @@ function InputBar({ onSubmit, processing }) {
749
764
  inputLine,
750
765
  h(Box, { flexDirection: 'column', paddingLeft: 5, marginTop: 0 },
751
766
  hasMore && windowStart > 0
752
- ? h(Text, { color: T.textInvis }, ' \u2191 mas')
767
+ ? h(Text, { color: T.textInvis }, ' \u2191 ' + uiText('more', 'más'))
753
768
  : null,
754
769
  ...visible.map((cmd, i) => {
755
770
  const realIdx = windowStart + i;
@@ -766,10 +781,10 @@ function InputBar({ onSubmit, processing }) {
766
781
  );
767
782
  }),
768
783
  hasMore && windowStart + maxVisible < suggestions.length
769
- ? h(Text, { color: T.textInvis }, ' \u2193 mas')
784
+ ? h(Text, { color: T.textInvis }, ' \u2193 ' + uiText('more', 'más'))
770
785
  : null,
771
786
  h(Box, { paddingTop: 0 },
772
- h(Text, { color: T.textInvis }, 'Tab completar \u00b7 \u2191\u2193 navegar'),
787
+ h(Text, { color: T.textInvis }, uiText('Tab complete · ↑↓ navigate', 'Tab completar · ↑↓ navegar')),
773
788
  ),
774
789
  ),
775
790
  );
@@ -777,19 +792,20 @@ function InputBar({ onSubmit, processing }) {
777
792
 
778
793
  function App({ store, state, onSubmit }) {
779
794
  useStore(store);
795
+ global.__zynCurrentLanguage = state?.language || 'en';
780
796
  const { exit } = useApp();
781
797
  const { width } = useDimensions();
782
798
 
783
799
  const modelKey = state?.activeModel || DEFAULT_MODEL_KEY;
784
800
  const modelLabel = state?.concuerdo
785
- ? 'Concuerdo · ' + Object.values(MODELS).map(m => m.label).join(', ')
801
+ ? uiText('Concord · ', 'Concuerdo · ') + Object.values(MODELS).map(m => m.label).join(', ')
786
802
  : (MODELS[modelKey]?.label || modelKey).toLowerCase();
787
803
 
788
804
  const handleInput = useCallback((text) => {
789
805
  if (text === '/exit' || text === '/quit') {
790
806
  if (store.processing) {
791
807
  store.pendingExit = true;
792
- store.addEvent('info', 'saliendo al terminar el turno actual');
808
+ store.addEvent('info', uiText('exiting after current turn', 'saliendo al terminar el turno actual'));
793
809
  return;
794
810
  }
795
811
  exit();
@@ -809,10 +825,10 @@ function App({ store, state, onSubmit }) {
809
825
  state.abortCurrentTurn();
810
826
  }
811
827
  store.pendingExit = false;
812
- store.addEvent('warn', 'agente detenido', 'Interrumpido con ESC x2');
828
+ store.addEvent('warn', uiText('agent stopped', 'agente detenido'), uiText('Interrupted with ESC x2', 'Interrumpido con ESC x2'));
813
829
  } else {
814
830
  store.lastEscapeAt = now;
815
- store.addEvent('info', 'pulsa ESC otra vez', 'para detener el agente');
831
+ store.addEvent('info', uiText('press ESC again', 'pulsa ESC otra vez'), uiText('to stop the agent', 'para detener el agente'));
816
832
  }
817
833
  return;
818
834
  }
@@ -914,7 +930,7 @@ export async function startTUI(options = {}) {
914
930
  const processInput = async (input) => {
915
931
  if (input === '/exit' || input === '/quit') {
916
932
  store.pendingExit = true;
917
- store.addEvent('info', 'hasta luego');
933
+ store.addEvent('info', uiText('bye', 'hasta luego'));
918
934
  return;
919
935
  }
920
936
 
@@ -942,9 +958,9 @@ export async function startTUI(options = {}) {
942
958
  const clean = lines.filter(l => l.trim()).join('\n');
943
959
  if (clean) store.addItem({ type: 'system', text: clean });
944
960
  }
945
- if (!handled) store.addEvent('warn', 'comando no reconocido', input);
961
+ if (!handled) store.addEvent('warn', uiText('command not recognized', 'comando no reconocido'), input);
946
962
  } catch (err) {
947
- store.addEvent('error', 'error', err.message);
963
+ store.addEvent('error', uiText('error', 'error'), err.message);
948
964
  } finally {
949
965
  console.log = origLog;
950
966
  console.error = origError;
@@ -983,6 +999,13 @@ export async function startTUI(options = {}) {
983
999
  let appInstance = null;
984
1000
 
985
1001
  const handleSubmit = async (input) => {
1002
+ if (typeof input === 'string' && input.length > MAX_PASTE_PREVIEW) {
1003
+ store.addEvent(
1004
+ 'info',
1005
+ `[ ${uiText('Pasted Text', 'Texto pegado')} of ${input.length} ${uiText('Characters', 'Caracteres')} ]`,
1006
+ input.slice(0, MAX_PASTE_PREVIEW) + '...'
1007
+ );
1008
+ }
986
1009
  if (input.startsWith('/') && store.processing) {
987
1010
  await processInput(input);
988
1011
  return;
@@ -0,0 +1,223 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+
5
+ const HOME_DIR = os.homedir() || '/root';
6
+ const DATA_ROOT = path.join(HOME_DIR, '.zyn');
7
+ const GIT_SECRETS_FILE = path.join(DATA_ROOT, 'git-secrets.json');
8
+
9
+ function readJson(filePath) {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ function writeJson(filePath, data) {
18
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
19
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
20
+ }
21
+
22
+ function loadGitSecrets() {
23
+ const raw = readJson(GIT_SECRETS_FILE);
24
+ if (!raw || typeof raw !== 'object') {
25
+ return { github: null, gitlab: null, custom: {} };
26
+ }
27
+
28
+ return {
29
+ github: raw.github || null,
30
+ gitlab: raw.gitlab || null,
31
+ custom: raw.custom && typeof raw.custom === 'object' ? raw.custom : {},
32
+ };
33
+ }
34
+
35
+ function saveGitSecrets(data) {
36
+ writeJson(GIT_SECRETS_FILE, {
37
+ github: data.github || null,
38
+ gitlab: data.gitlab || null,
39
+ custom: data.custom && typeof data.custom === 'object' ? data.custom : {},
40
+ updatedAt: new Date().toISOString(),
41
+ });
42
+ }
43
+
44
+ function normalizeProfileName(value) {
45
+ return String(value || '').trim().toLowerCase();
46
+ }
47
+
48
+ function normalizeBaseUrl(value) {
49
+ return String(value || '').trim().replace(/\/+$/, '');
50
+ }
51
+
52
+ function sanitizeSecretConfig(input = {}) {
53
+ const output = {};
54
+ if (input.token !== undefined) output.token = String(input.token || '').trim();
55
+ if (input.username !== undefined) output.username = String(input.username || '').trim();
56
+ if (input.apiBaseUrl !== undefined) output.apiBaseUrl = normalizeBaseUrl(input.apiBaseUrl);
57
+ if (input.cloneBaseUrl !== undefined) output.cloneBaseUrl = normalizeBaseUrl(input.cloneBaseUrl);
58
+ if (input.authHeader !== undefined) output.authHeader = String(input.authHeader || '').trim();
59
+ if (input.name !== undefined) output.name = String(input.name || '').trim();
60
+ return output;
61
+ }
62
+
63
+ function upsertGitSecret(provider, input = {}) {
64
+ const key = normalizeProfileName(provider);
65
+ const config = sanitizeSecretConfig(input);
66
+ const store = loadGitSecrets();
67
+
68
+ if (key === 'github') {
69
+ store.github = {
70
+ ...store.github,
71
+ ...config,
72
+ provider: 'github',
73
+ apiBaseUrl: config.apiBaseUrl || store.github?.apiBaseUrl || 'https://api.github.com',
74
+ cloneBaseUrl: config.cloneBaseUrl || store.github?.cloneBaseUrl || 'https://github.com',
75
+ updatedAt: new Date().toISOString(),
76
+ };
77
+ saveGitSecrets(store);
78
+ return store.github;
79
+ }
80
+
81
+ if (key === 'gitlab') {
82
+ store.gitlab = {
83
+ ...store.gitlab,
84
+ ...config,
85
+ provider: 'gitlab',
86
+ apiBaseUrl: config.apiBaseUrl || store.gitlab?.apiBaseUrl || 'https://gitlab.com/api/v4',
87
+ cloneBaseUrl: config.cloneBaseUrl || store.gitlab?.cloneBaseUrl || 'https://gitlab.com',
88
+ updatedAt: new Date().toISOString(),
89
+ };
90
+ saveGitSecrets(store);
91
+ return store.gitlab;
92
+ }
93
+
94
+ const name = config.name || key || 'custom';
95
+ store.custom[name] = {
96
+ ...store.custom[name],
97
+ ...config,
98
+ provider: 'custom',
99
+ name,
100
+ updatedAt: new Date().toISOString(),
101
+ };
102
+ saveGitSecrets(store);
103
+ return store.custom[name];
104
+ }
105
+
106
+ function removeGitSecret(provider, name = '') {
107
+ const key = normalizeProfileName(provider);
108
+ const store = loadGitSecrets();
109
+ if (key === 'github') {
110
+ store.github = null;
111
+ saveGitSecrets(store);
112
+ return true;
113
+ }
114
+ if (key === 'gitlab') {
115
+ store.gitlab = null;
116
+ saveGitSecrets(store);
117
+ return true;
118
+ }
119
+ const customName = String(name || '').trim();
120
+ if (!customName) return false;
121
+ if (store.custom[customName]) {
122
+ delete store.custom[customName];
123
+ saveGitSecrets(store);
124
+ return true;
125
+ }
126
+ return false;
127
+ }
128
+
129
+ function listGitSecrets() {
130
+ const store = loadGitSecrets();
131
+ const output = [];
132
+ if (store.github) output.push({ key: 'github', ...store.github, token: '[set]' });
133
+ if (store.gitlab) output.push({ key: 'gitlab', ...store.gitlab, token: '[set]' });
134
+ for (const [name, config] of Object.entries(store.custom || {})) {
135
+ output.push({ key: `custom:${name}`, ...config, token: '[set]' });
136
+ }
137
+ return output;
138
+ }
139
+
140
+ function getGitSecret(provider, name = '') {
141
+ const key = normalizeProfileName(provider);
142
+ const store = loadGitSecrets();
143
+ if (key === 'github') return store.github || null;
144
+ if (key === 'gitlab') return store.gitlab || null;
145
+ const customName = String(name || '').trim();
146
+ if (!customName) return null;
147
+ return store.custom[customName] || null;
148
+ }
149
+
150
+ function resolveGitProfile(provider, name = '') {
151
+ const key = normalizeProfileName(provider);
152
+ if (key === 'github' || key === 'gitlab') return getGitSecret(key);
153
+ return getGitSecret('custom', name);
154
+ }
155
+
156
+ function buildCloneUrl(repoUrl, profile = {}) {
157
+ const raw = String(repoUrl || '').trim();
158
+ if (!raw) throw new Error('Missing repo URL');
159
+ if (!profile?.token) return raw;
160
+ let parsed;
161
+ try {
162
+ parsed = new URL(raw);
163
+ } catch {
164
+ return raw;
165
+ }
166
+
167
+ const host = parsed.hostname.toLowerCase();
168
+ if (host.includes('github.com')) {
169
+ parsed.username = 'x-access-token';
170
+ parsed.password = profile.token;
171
+ return parsed.toString();
172
+ }
173
+
174
+ if (host.includes('gitlab.com')) {
175
+ parsed.username = profile.username || 'oauth2';
176
+ parsed.password = profile.token;
177
+ return parsed.toString();
178
+ }
179
+
180
+ parsed.username = profile.username || 'oauth2';
181
+ parsed.password = profile.token;
182
+ return parsed.toString();
183
+ }
184
+
185
+ function getApiBaseUrl(provider, profile = {}) {
186
+ const key = normalizeProfileName(provider);
187
+ if (profile?.apiBaseUrl) return normalizeBaseUrl(profile.apiBaseUrl);
188
+ if (key === 'github') return 'https://api.github.com';
189
+ if (key === 'gitlab') return 'https://gitlab.com/api/v4';
190
+ return '';
191
+ }
192
+
193
+ function buildApiHeaders(provider, profile = {}, extraHeaders = {}) {
194
+ const key = normalizeProfileName(provider);
195
+ const token = profile?.token || '';
196
+ const headers = { ...extraHeaders };
197
+
198
+ if (token) {
199
+ if (key === 'gitlab') {
200
+ headers['PRIVATE-TOKEN'] = token;
201
+ } else if (profile?.authHeader) {
202
+ headers[profile.authHeader] = token;
203
+ } else {
204
+ headers.Authorization = `Bearer ${token}`;
205
+ }
206
+ }
207
+
208
+ return headers;
209
+ }
210
+
211
+ module.exports = {
212
+ buildApiHeaders,
213
+ buildCloneUrl,
214
+ getApiBaseUrl,
215
+ getGitSecret,
216
+ listGitSecrets,
217
+ loadGitSecrets,
218
+ normalizeProfileName,
219
+ removeGitSecret,
220
+ resolveGitProfile,
221
+ saveGitSecrets,
222
+ upsertGitSecret,
223
+ };
@@ -0,0 +1,192 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { TASKS_FILE, USER_DATA_ROOT } = require('../config');
4
+
5
+ const fsp = fs.promises;
6
+
7
+ function readJson(filePath) {
8
+ try {
9
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ function writeJson(filePath, data) {
16
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
17
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
18
+ }
19
+
20
+ function loadTaskStore() {
21
+ const raw = readJson(TASKS_FILE);
22
+ if (!raw || typeof raw !== 'object') {
23
+ return { version: 1, tasks: [] };
24
+ }
25
+ if (Array.isArray(raw)) {
26
+ return { version: 1, tasks: raw };
27
+ }
28
+ return {
29
+ version: Number(raw.version || 1),
30
+ tasks: Array.isArray(raw.tasks) ? raw.tasks : [],
31
+ };
32
+ }
33
+
34
+ function saveTaskStore(store) {
35
+ writeJson(TASKS_FILE, {
36
+ version: 1,
37
+ tasks: Array.isArray(store.tasks) ? store.tasks : [],
38
+ updatedAt: new Date().toISOString(),
39
+ });
40
+ }
41
+
42
+ function normalizeTaskText(text) {
43
+ return String(text || '').trim().replace(/\s+/g, ' ');
44
+ }
45
+
46
+ function makeTaskId() {
47
+ return `task_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
48
+ }
49
+
50
+ function enrichTask(task) {
51
+ return {
52
+ id: task.id,
53
+ title: normalizeTaskText(task.title),
54
+ description: normalizeTaskText(task.description || ''),
55
+ status: task.status || 'todo',
56
+ priority: task.priority || 'medium',
57
+ createdAt: task.createdAt || new Date().toISOString(),
58
+ updatedAt: task.updatedAt || task.createdAt || new Date().toISOString(),
59
+ dueAt: task.dueAt || null,
60
+ tags: Array.isArray(task.tags) ? task.tags : [],
61
+ source: task.source || 'manual',
62
+ sessionId: task.sessionId || null,
63
+ notes: normalizeTaskText(task.notes || ''),
64
+ };
65
+ }
66
+
67
+ function listTasks(options = {}) {
68
+ const { status, includeDone = true } = options;
69
+ const store = loadTaskStore();
70
+ return store.tasks
71
+ .map(enrichTask)
72
+ .filter(task => (includeDone ? true : task.status !== 'done'))
73
+ .filter(task => (status ? task.status === status : true))
74
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
75
+ }
76
+
77
+ function createTask(input = {}) {
78
+ const store = loadTaskStore();
79
+ const title = normalizeTaskText(input.title || input.text || input.description);
80
+ if (!title) {
81
+ throw new Error('task title requerido');
82
+ }
83
+
84
+ const now = new Date().toISOString();
85
+ const task = enrichTask({
86
+ id: makeTaskId(),
87
+ title,
88
+ description: input.description || '',
89
+ status: input.status || 'todo',
90
+ priority: input.priority || 'medium',
91
+ createdAt: now,
92
+ updatedAt: now,
93
+ dueAt: input.dueAt || null,
94
+ tags: input.tags || [],
95
+ source: input.source || 'manual',
96
+ sessionId: input.sessionId || null,
97
+ notes: input.notes || '',
98
+ });
99
+
100
+ store.tasks.push(task);
101
+ saveTaskStore(store);
102
+ return task;
103
+ }
104
+
105
+ function findTaskIndex(store, idOrTitle) {
106
+ const needle = normalizeTaskText(idOrTitle).toLowerCase();
107
+ if (!needle) return -1;
108
+ return store.tasks.findIndex(task => {
109
+ const idMatch = String(task.id || '').toLowerCase() === needle;
110
+ const titleMatch = String(task.title || '').toLowerCase() === needle;
111
+ return idMatch || titleMatch;
112
+ });
113
+ }
114
+
115
+ function updateTask(idOrTitle, updates = {}) {
116
+ const store = loadTaskStore();
117
+ const idx = findTaskIndex(store, idOrTitle);
118
+ if (idx === -1) {
119
+ throw new Error(`No existe la tarea: ${idOrTitle}`);
120
+ }
121
+
122
+ const current = store.tasks[idx];
123
+ const next = enrichTask({
124
+ ...current,
125
+ ...updates,
126
+ title: updates.title ?? current.title,
127
+ description: updates.description ?? current.description,
128
+ priority: updates.priority ?? current.priority,
129
+ status: updates.status ?? current.status,
130
+ dueAt: updates.dueAt === undefined ? current.dueAt : updates.dueAt,
131
+ tags: updates.tags ?? current.tags,
132
+ notes: updates.notes ?? current.notes,
133
+ updatedAt: new Date().toISOString(),
134
+ });
135
+
136
+ store.tasks[idx] = next;
137
+ saveTaskStore(store);
138
+ return next;
139
+ }
140
+
141
+ function completeTask(idOrTitle, notes = '') {
142
+ return updateTask(idOrTitle, {
143
+ status: 'done',
144
+ notes,
145
+ });
146
+ }
147
+
148
+ function deleteTask(idOrTitle) {
149
+ const store = loadTaskStore();
150
+ const idx = findTaskIndex(store, idOrTitle);
151
+ if (idx === -1) {
152
+ throw new Error(`No existe la tarea: ${idOrTitle}`);
153
+ }
154
+ const [removed] = store.tasks.splice(idx, 1);
155
+ saveTaskStore(store);
156
+ return enrichTask(removed);
157
+ }
158
+
159
+ function clearTasks() {
160
+ saveTaskStore({ tasks: [] });
161
+ }
162
+
163
+ function formatTask(task) {
164
+ const state = task.status === 'done' ? '✓' : task.status === 'doing' ? '▶' : '·';
165
+ const due = task.dueAt ? ` · due ${task.dueAt}` : '';
166
+ const priority = task.priority ? ` · ${task.priority}` : '';
167
+ const desc = task.description ? `\n ${task.description}` : '';
168
+ const notes = task.notes ? `\n notas: ${task.notes}` : '';
169
+ return `${state} ${task.id} :: ${task.title}${priority}${due}${desc}${notes}`;
170
+ }
171
+
172
+ function summarizeTasks(tasks = listTasks()) {
173
+ const lines = [`Tasks: ${tasks.length}`];
174
+ for (const task of tasks.slice(0, 20)) {
175
+ lines.push(formatTask(task));
176
+ }
177
+ return lines.join('\n');
178
+ }
179
+
180
+ module.exports = {
181
+ clearTasks,
182
+ completeTask,
183
+ createTask,
184
+ deleteTask,
185
+ formatTask,
186
+ listTasks,
187
+ loadTaskStore,
188
+ saveTaskStore,
189
+ summarizeTasks,
190
+ updateTask,
191
+ USER_DATA_ROOT,
192
+ };
@@ -599,7 +599,7 @@ async function executeTool(tool, args, ctx) {
599
599
  }
600
600
  }
601
601
 
602
- async function runConcuerdo(primaryContent, primaryKey, modelMessages, onEvent, isAborted) {
602
+ async function runConcuerdo(primaryContent, primaryKey, modelMessages, onEvent, isAborted, language = 'en') {
603
603
  const otherKeys = Object.keys(MODELS).filter(k => k !== primaryKey);
604
604
  if (!otherKeys.length) return null;
605
605
 
@@ -1029,7 +1029,7 @@ async function runWebAgent({ chatData, user, onEvent, isAborted }) {
1029
1029
  // ── Final response ──
1030
1030
  if (parsed.type === 'final') {
1031
1031
  if (group) {
1032
- const synthResult = await runConcuerdo(parsed.content, modelKey, modelMessages, onEvent, isAborted);
1032
+ const synthResult = await runConcuerdo(parsed.content, modelKey, modelMessages, onEvent, isAborted, language);
1033
1033
  if (synthResult) {
1034
1034
  chatData.messages.push({ role: 'assistant', content: synthResult, ts: Date.now() });
1035
1035
  store.saveChat(chatData);