agent-mp 0.5.24 → 0.5.26
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/dist/commands/repl.js +171 -36
- package/dist/core/engine.d.ts +13 -5
- package/dist/core/engine.js +296 -42
- package/dist/utils/gemini.d.ts +17 -0
- package/dist/utils/gemini.js +95 -0
- package/package.json +1 -1
package/dist/commands/repl.js
CHANGED
|
@@ -11,6 +11,7 @@ import { loadAuth, saveAuth, loadCliConfig, saveCliConfig, loadProjectConfig, re
|
|
|
11
11
|
import { log } from '../utils/logger.js';
|
|
12
12
|
import { AgentEngine, ExitError } from '../core/engine.js';
|
|
13
13
|
import { qwenAuthStatus, QWEN_AGENT_HOME, fetchQwenModels, loadApiKeyConfig, saveApiKeyConfig, fetchApiKeyModels, DEFAULT_API_BASE_URL } from '../utils/qwen-auth.js';
|
|
14
|
+
import { loadGeminiApiKey, saveGeminiApiKey, geminiAuthStatus, deleteGeminiApiKey, GEMINI_MODELS } from '../utils/gemini.js';
|
|
14
15
|
import { renderWelcomePanel, renderHelpHint, renderSectionBox, renderMultiSectionBox } from '../ui/theme.js';
|
|
15
16
|
import { FixedInput } from '../ui/input.js';
|
|
16
17
|
import { newSession, saveSession } from '../utils/sessions.js';
|
|
@@ -299,8 +300,44 @@ async function promptApiKeySetup(rl, askFn) {
|
|
|
299
300
|
console.log(chalk.green(`\n ✓ API key saved — ${cfg.model}\n`));
|
|
300
301
|
return true;
|
|
301
302
|
}
|
|
303
|
+
async function promptGeminiKeySetup(rl, askFn) {
|
|
304
|
+
const existing = await loadGeminiApiKey();
|
|
305
|
+
if (existing) {
|
|
306
|
+
console.log(chalk.dim(` Current: Google Gemini / ${existing.model}`));
|
|
307
|
+
}
|
|
308
|
+
const apiKey = await askFn(` Gemini API Key${existing ? ' [Enter to keep]' : ''}: `);
|
|
309
|
+
const resolvedKey = apiKey.trim() || existing?.api_key || '';
|
|
310
|
+
if (!resolvedKey) {
|
|
311
|
+
console.log(chalk.red(' API key es requerida.'));
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
console.log(chalk.bold('\n Modelos disponibles:'));
|
|
315
|
+
GEMINI_MODELS.forEach((m, i) => console.log(chalk.dim(` ${i + 1}. ${m}`)));
|
|
316
|
+
const defaultModel = existing?.model ?? 'gemini-2.5-flash';
|
|
317
|
+
const pick = await askFn(`\n Modelo [${defaultModel}]: `);
|
|
318
|
+
const num = parseInt(pick.trim());
|
|
319
|
+
let chosenModel = defaultModel;
|
|
320
|
+
if (!isNaN(num) && num >= 1 && num <= GEMINI_MODELS.length) {
|
|
321
|
+
chosenModel = GEMINI_MODELS[num - 1];
|
|
322
|
+
}
|
|
323
|
+
else if (pick.trim()) {
|
|
324
|
+
chosenModel = pick.trim();
|
|
325
|
+
}
|
|
326
|
+
await saveGeminiApiKey({ api_key: resolvedKey, model: chosenModel });
|
|
327
|
+
console.log(chalk.green(`\n ✓ Gemini API key guardada — ${chosenModel}\n`));
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
302
330
|
async function cmdLogin(rl) {
|
|
303
331
|
console.log(chalk.bold.cyan('\n Configure API Key\n'));
|
|
332
|
+
console.log(chalk.bold(' Proveedor:'));
|
|
333
|
+
console.log(chalk.dim(' 1. Qwen / OpenAI-compatible'));
|
|
334
|
+
console.log(chalk.dim(' 2. Google Gemini'));
|
|
335
|
+
console.log('');
|
|
336
|
+
const pick = await ask(rl, ' Elegí [1]: ');
|
|
337
|
+
const choice = pick.trim() || '1';
|
|
338
|
+
if (choice === '2') {
|
|
339
|
+
return promptGeminiKeySetup(rl, (q) => ask(rl, q));
|
|
340
|
+
}
|
|
304
341
|
return promptApiKeySetup(rl, (q) => ask(rl, q));
|
|
305
342
|
}
|
|
306
343
|
async function cmdSetup(rl) {
|
|
@@ -566,13 +603,21 @@ async function cmdStatus(fi) {
|
|
|
566
603
|
const cliConfig = await loadCliConfig();
|
|
567
604
|
const auth = await loadAuth();
|
|
568
605
|
const sections = [];
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
606
|
+
const geminiStatus = await geminiAuthStatus();
|
|
607
|
+
const apiKeyCfg = await loadApiKeyConfig();
|
|
608
|
+
const authRows = [];
|
|
609
|
+
if (geminiStatus.authenticated) {
|
|
610
|
+
authRows.push({ key: 'Google Gemini', value: `✓ ${geminiStatus.model} (activo como coordinador)` });
|
|
611
|
+
}
|
|
612
|
+
if (apiKeyCfg?.api_key) {
|
|
613
|
+
authRows.push({ key: apiKeyCfg.provider, value: `✓ ${apiKeyCfg.model}` });
|
|
614
|
+
}
|
|
615
|
+
if (auth.entries.length > 0) {
|
|
616
|
+
authRows.push({ key: 'OAuth', value: auth.entries.map((e) => `${e.provider} (${e.method})`).join(', ') });
|
|
617
|
+
}
|
|
618
|
+
if (authRows.length === 0)
|
|
619
|
+
authRows.push({ key: 'Status', value: 'none — run /login' });
|
|
620
|
+
sections.push({ title: 'Auth', rows: authRows });
|
|
576
621
|
const roleRows = [];
|
|
577
622
|
try {
|
|
578
623
|
const projectConfig = await loadProjectConfig(process.cwd());
|
|
@@ -627,32 +672,39 @@ async function cmdStatus(fi) {
|
|
|
627
672
|
fi.println('');
|
|
628
673
|
}
|
|
629
674
|
async function cmdModels(roleArg, fi, rl) {
|
|
675
|
+
const geminiCfg = await loadGeminiApiKey();
|
|
676
|
+
const apiKeyCfg = await loadApiKeyConfig();
|
|
630
677
|
const auth = await loadAuth();
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
fi.println(chalk.dim(' Fetching models from your OAuth account...'));
|
|
638
|
-
const prov = authedProviders[0];
|
|
678
|
+
// Determine active provider from current gCoordinatorCmd
|
|
679
|
+
const isGeminiActive = gCoordinatorCmd.startsWith('gemini-direct');
|
|
680
|
+
const activeProvider = isGeminiActive ? 'gemini' : (apiKeyCfg?.provider ?? auth.entries[0]?.provider ?? '');
|
|
681
|
+
const resume = fi.suspend();
|
|
682
|
+
const cliConfig = await loadCliConfig();
|
|
683
|
+
const currentModel = cliConfig.coordinatorModel ?? '(auto)';
|
|
639
684
|
let models;
|
|
640
|
-
if (
|
|
685
|
+
if (isGeminiActive || activeProvider === 'gemini') {
|
|
686
|
+
models = GEMINI_MODELS;
|
|
687
|
+
}
|
|
688
|
+
else if (activeProvider === 'qwen') {
|
|
689
|
+
console.log(chalk.dim(' Fetching Qwen models...'));
|
|
641
690
|
models = await fetchQwenModels();
|
|
691
|
+
if (!models.length)
|
|
692
|
+
models = detectModels('qwen');
|
|
693
|
+
}
|
|
694
|
+
else if (apiKeyCfg?.api_key) {
|
|
695
|
+
models = await fetchApiKeyModels(apiKeyCfg);
|
|
696
|
+
if (!models.length)
|
|
697
|
+
models = detectModels(activeProvider);
|
|
642
698
|
}
|
|
643
699
|
else {
|
|
644
|
-
models = detectModels(
|
|
700
|
+
models = activeProvider ? detectModels(activeProvider) : [];
|
|
645
701
|
}
|
|
646
702
|
if (!models.length) {
|
|
647
|
-
|
|
703
|
+
resume();
|
|
704
|
+
fi.println(chalk.red(' No models available. Configure un proveedor primero con /login.'));
|
|
648
705
|
return;
|
|
649
706
|
}
|
|
650
|
-
|
|
651
|
-
const resume = fi.suspend();
|
|
652
|
-
const cliConfig = await loadCliConfig();
|
|
653
|
-
const currentModel = cliConfig.coordinatorModel ?? '(auto)';
|
|
654
|
-
console.log(chalk.bold.cyan(`\n Coordinator model [current: ${currentModel}]\n`));
|
|
655
|
-
console.log(chalk.dim(' These are your OAuth account models. CLIs are configured separately with /setup.\n'));
|
|
707
|
+
console.log(chalk.bold.cyan(`\n Coordinator model [current: ${currentModel}] provider: ${activeProvider || '?'}\n`));
|
|
656
708
|
models.forEach((m, i) => console.log(chalk.dim(` ${String(i + 1).padStart(2)}. ${m}`)));
|
|
657
709
|
const modelInput = await ask(rl, chalk.bold('\n Model # or name (Enter to keep current): '));
|
|
658
710
|
resume();
|
|
@@ -664,15 +716,66 @@ async function cmdModels(roleArg, fi, rl) {
|
|
|
664
716
|
const selectedModel = (!isNaN(num) && num >= 1 && num <= models.length)
|
|
665
717
|
? models[num - 1]
|
|
666
718
|
: modelInput.trim();
|
|
667
|
-
// Save as coordinator model only
|
|
668
719
|
cliConfig.coordinatorModel = selectedModel;
|
|
669
720
|
await saveCliConfig(cliConfig);
|
|
670
|
-
|
|
671
|
-
|
|
721
|
+
if (isGeminiActive || activeProvider === 'gemini') {
|
|
722
|
+
gCoordinatorCmd = `gemini-direct -m ${selectedModel}`;
|
|
723
|
+
if (geminiCfg)
|
|
724
|
+
await saveGeminiApiKey({ ...geminiCfg, model: selectedModel });
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
gCoordinatorCmd = buildCmd(activeProvider, selectedModel);
|
|
728
|
+
}
|
|
672
729
|
fi.println(chalk.green(`\n ✓ Coordinator model → ${selectedModel}`));
|
|
673
730
|
fi.println(chalk.dim(` CMD: ${gCoordinatorCmd}`));
|
|
674
731
|
fi.println('');
|
|
675
732
|
}
|
|
733
|
+
/** /provider — switch coordinator provider between configured ones */
|
|
734
|
+
async function cmdProvider(fi, rl) {
|
|
735
|
+
const geminiCfg = await loadGeminiApiKey();
|
|
736
|
+
const apiKeyCfg = await loadApiKeyConfig();
|
|
737
|
+
const options = [];
|
|
738
|
+
if (geminiCfg?.api_key)
|
|
739
|
+
options.push({ label: `Google Gemini (${geminiCfg.model})`, value: 'gemini' });
|
|
740
|
+
if (apiKeyCfg?.api_key)
|
|
741
|
+
options.push({ label: `${apiKeyCfg.provider} (${apiKeyCfg.model})`, value: apiKeyCfg.provider });
|
|
742
|
+
if (options.length === 0) {
|
|
743
|
+
fi.println(chalk.yellow(' No hay proveedores configurados.'));
|
|
744
|
+
fi.println(chalk.dim(' Usá /login para configurar Qwen o Google Gemini.'));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (options.length === 1) {
|
|
748
|
+
fi.println(chalk.dim(` Activo: ${options[0].label}`));
|
|
749
|
+
fi.println(chalk.dim(' Para agregar Google Gemini: /login → elegí opción 2.'));
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const resume = fi.suspend();
|
|
753
|
+
const current = gCoordinatorCmd.startsWith('gemini-direct') ? 'gemini' : (apiKeyCfg?.provider ?? '?');
|
|
754
|
+
console.log(chalk.bold.cyan(`\n Cambiar proveedor [activo: ${current}]\n`));
|
|
755
|
+
options.forEach((o, i) => console.log(chalk.dim(` ${i + 1}. ${o.label}`)));
|
|
756
|
+
const pick = await ask(rl, chalk.bold('\n Proveedor # (Enter to keep): '));
|
|
757
|
+
resume();
|
|
758
|
+
if (!pick.trim()) {
|
|
759
|
+
fi.println(chalk.dim(' → no change'));
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const idx = parseInt(pick.trim()) - 1;
|
|
763
|
+
if (isNaN(idx) || idx < 0 || idx >= options.length) {
|
|
764
|
+
fi.println(chalk.red(' Opción inválida.'));
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const chosen = options[idx];
|
|
768
|
+
const cliConfig = await loadCliConfig();
|
|
769
|
+
if (chosen.value === 'gemini') {
|
|
770
|
+
gCoordinatorCmd = `gemini-direct -m ${geminiCfg.model}`;
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
gCoordinatorCmd = `qwen-direct -m ${apiKeyCfg.model}`;
|
|
774
|
+
}
|
|
775
|
+
fi.println(chalk.green(`\n ✓ Proveedor → ${chosen.label}`));
|
|
776
|
+
fi.println(chalk.dim(` CMD: ${gCoordinatorCmd}`));
|
|
777
|
+
fi.println('');
|
|
778
|
+
}
|
|
676
779
|
async function cmdAuthStatus(fi) {
|
|
677
780
|
const auth = await loadAuth();
|
|
678
781
|
const rows = auth.entries.length === 0
|
|
@@ -749,9 +852,9 @@ function cmdHelp(fi) {
|
|
|
749
852
|
{ key: '/run rev <id>', value: 'Run only reviewer' },
|
|
750
853
|
{ key: '/run explorer [task]', value: 'Run only explorer' },
|
|
751
854
|
{ key: '/explorer [task]', value: 'Run explorer (shortcut)' },
|
|
752
|
-
{ key: '/
|
|
753
|
-
{ key: '/
|
|
754
|
-
{ key: '/login', value: '
|
|
855
|
+
{ key: '/model', value: 'Cambiar modelo del coordinador' },
|
|
856
|
+
{ key: '/provider', value: 'Cambiar proveedor activo (Gemini / Qwen)' },
|
|
857
|
+
{ key: '/login', value: 'Configurar API key (Qwen o Gemini)' },
|
|
755
858
|
{ key: '/logout', value: 'Logout and clear credentials' },
|
|
756
859
|
{ key: '/auth-status', value: 'Show authentication status' },
|
|
757
860
|
{ key: '/usage', value: 'Check quota status for all role CLIs' },
|
|
@@ -806,13 +909,23 @@ export async function initCoordinator() {
|
|
|
806
909
|
const auth = await loadAuth();
|
|
807
910
|
const currentAuth = auth.activeProvider;
|
|
808
911
|
let activeCli = installed.find((c) => c.name === currentAuth);
|
|
809
|
-
// Fast-path: API key configured
|
|
912
|
+
// Fast-path: Gemini API key configured
|
|
913
|
+
const geminiCfg = await loadGeminiApiKey();
|
|
914
|
+
if (geminiCfg?.api_key) {
|
|
915
|
+
gCoordinatorCmd = `gemini-direct -m ${geminiCfg.model}`;
|
|
916
|
+
console.log(chalk.green(` ✓ Auth: Google Gemini / ${geminiCfg.model}\n`));
|
|
917
|
+
const syntheticCli = {
|
|
918
|
+
name: 'gemini',
|
|
919
|
+
info: { command: 'gemini-direct', modelFlag: '-m', promptFlag: '-p', description: 'Gemini API key' },
|
|
920
|
+
path: 'gemini-direct',
|
|
921
|
+
};
|
|
922
|
+
return { activeCli: syntheticCli, installed, coordinatorCmd: gCoordinatorCmd };
|
|
923
|
+
}
|
|
924
|
+
// Fast-path: Qwen/OpenAI-compatible API key configured
|
|
810
925
|
const apiKeyCfg = await loadApiKeyConfig();
|
|
811
926
|
if (apiKeyCfg?.api_key) {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
gCoordinatorCmd = `qwen-direct -m ${model}`;
|
|
815
|
-
console.log(chalk.green(` ✓ Auth: ${apiKeyCfg.provider} / ${model}\n`));
|
|
927
|
+
gCoordinatorCmd = `qwen-direct -m ${apiKeyCfg.model}`;
|
|
928
|
+
console.log(chalk.green(` ✓ Auth: ${apiKeyCfg.provider} / ${apiKeyCfg.model}\n`));
|
|
816
929
|
const syntheticCli = {
|
|
817
930
|
name: apiKeyCfg.provider,
|
|
818
931
|
info: { command: 'qwen-direct', modelFlag: '-m', promptFlag: '-p', description: 'API key' },
|
|
@@ -828,6 +941,24 @@ export async function initCoordinator() {
|
|
|
828
941
|
});
|
|
829
942
|
if (doSetup) {
|
|
830
943
|
const askFn = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
944
|
+
// Ask provider
|
|
945
|
+
rl.question(' Provider: 1=Qwen/OpenAI-compatible 2=Google Gemini [1]: ', async () => { });
|
|
946
|
+
const providerPick = await new Promise((resolve) => rl.question(' Provider [1]: ', resolve));
|
|
947
|
+
if (providerPick.trim() === '2') {
|
|
948
|
+
const ok = await promptGeminiKeySetup(rl, askFn);
|
|
949
|
+
if (!ok)
|
|
950
|
+
return null;
|
|
951
|
+
const saved = await loadGeminiApiKey();
|
|
952
|
+
if (!saved)
|
|
953
|
+
return null;
|
|
954
|
+
gCoordinatorCmd = `gemini-direct -m ${saved.model}`;
|
|
955
|
+
const syntheticCli = {
|
|
956
|
+
name: 'gemini',
|
|
957
|
+
info: { command: 'gemini-direct', modelFlag: '-m', promptFlag: '-p', description: 'Gemini API key' },
|
|
958
|
+
path: 'gemini-direct',
|
|
959
|
+
};
|
|
960
|
+
return { coordinatorCmd: gCoordinatorCmd, activeCli: syntheticCli, installed };
|
|
961
|
+
}
|
|
831
962
|
const ok = await promptApiKeySetup(rl, askFn);
|
|
832
963
|
if (!ok)
|
|
833
964
|
return null;
|
|
@@ -905,7 +1036,7 @@ export async function runRepl(resumeSession) {
|
|
|
905
1036
|
try {
|
|
906
1037
|
const dir = await resolveProjectDir(process.cwd());
|
|
907
1038
|
const config = await loadProjectConfig(dir);
|
|
908
|
-
const engine = new AgentEngine(config, dir, gCoordinatorCmd, tempRl, fi, handleCmd);
|
|
1039
|
+
const engine = new AgentEngine(config, dir, () => gCoordinatorCmd, tempRl, fi, handleCmd);
|
|
909
1040
|
return await fn(engine);
|
|
910
1041
|
}
|
|
911
1042
|
finally {
|
|
@@ -1066,6 +1197,9 @@ export async function runRepl(resumeSession) {
|
|
|
1066
1197
|
case 'model':
|
|
1067
1198
|
await withRl((rl) => cmdModels(args[0], fi, rl));
|
|
1068
1199
|
break;
|
|
1200
|
+
case 'provider':
|
|
1201
|
+
await withRl((rl) => cmdProvider(fi, rl));
|
|
1202
|
+
break;
|
|
1069
1203
|
case 'login':
|
|
1070
1204
|
await withRl(async (rl) => { await cmdLogin(rl); });
|
|
1071
1205
|
break;
|
|
@@ -1073,6 +1207,7 @@ export async function runRepl(resumeSession) {
|
|
|
1073
1207
|
const { getApiKeyConfigPath } = await import('../utils/qwen-auth.js');
|
|
1074
1208
|
await fs.unlink(path.join(QWEN_AGENT_HOME, 'oauth_creds.json')).catch(() => { });
|
|
1075
1209
|
await fs.unlink(await getApiKeyConfigPath()).catch(() => { });
|
|
1210
|
+
await deleteGeminiApiKey();
|
|
1076
1211
|
const authStore = await loadAuth();
|
|
1077
1212
|
authStore.entries = [];
|
|
1078
1213
|
delete authStore.activeProvider;
|
package/dist/core/engine.d.ts
CHANGED
|
@@ -9,13 +9,13 @@ export type SlashHandler = (input: string) => Promise<void>;
|
|
|
9
9
|
export declare class AgentEngine {
|
|
10
10
|
private config;
|
|
11
11
|
private projectDir;
|
|
12
|
-
private
|
|
12
|
+
private getCoordinatorCmd;
|
|
13
13
|
private rl?;
|
|
14
14
|
private fi?;
|
|
15
15
|
private slashHandler?;
|
|
16
16
|
private totalTokens;
|
|
17
17
|
private phaseTokens;
|
|
18
|
-
constructor(config: AgentConfig, projectDir: string, coordinatorCmd?: string, rl?: readline.Interface, fi?: FixedInput, slashHandler?: SlashHandler);
|
|
18
|
+
constructor(config: AgentConfig, projectDir: string, coordinatorCmd?: string | (() => string), rl?: readline.Interface, fi?: FixedInput, slashHandler?: SlashHandler);
|
|
19
19
|
/** Start the activity box for a subagent call. Returns { stop, push }. */
|
|
20
20
|
private _startSpinner;
|
|
21
21
|
/** Extract readable text lines from a qwen/CLI streaming chunk. */
|
|
@@ -25,15 +25,23 @@ export declare class AgentEngine {
|
|
|
25
25
|
* El coordinador (CLI activo, ej: Qwen) conversa con el usuario
|
|
26
26
|
* hasta tener toda la info necesaria. Retorna la tarea refinada.
|
|
27
27
|
*/
|
|
28
|
-
runClarification(initialTask: string): Promise<
|
|
28
|
+
runClarification(initialTask: string): Promise<{
|
|
29
|
+
task: string;
|
|
30
|
+
specPath?: string;
|
|
31
|
+
featureId?: string;
|
|
32
|
+
}>;
|
|
29
33
|
private runWithFallback;
|
|
30
34
|
private buildRolePrompt;
|
|
31
|
-
generateTaskId(task: string): Promise<string>;
|
|
35
|
+
generateTaskId(task: string, featureId?: string): Promise<string>;
|
|
36
|
+
/** Lists feature IDs that have a raw/ subdirectory in .agent/docs/ */
|
|
37
|
+
private _listAvailableFeatures;
|
|
38
|
+
/** Extracts featureId if task is a path like .agent/docs/<featureId> or just <featureId> */
|
|
39
|
+
private _detectFeaturePath;
|
|
32
40
|
/** Context for the ORCHESTRATOR only — planning-relevant files */
|
|
33
41
|
private buildOrchestratorContext;
|
|
34
42
|
/** Minimal context for COORDINATOR clarification only */
|
|
35
43
|
private buildCoordinatorContext;
|
|
36
|
-
runOrchestrator(task: string): Promise<{
|
|
44
|
+
runOrchestrator(task: string, specPath?: string, featureId?: string): Promise<{
|
|
37
45
|
taskId: string;
|
|
38
46
|
plan: TaskPlan;
|
|
39
47
|
}>;
|
package/dist/core/engine.js
CHANGED
|
@@ -3,10 +3,11 @@ import * as path from 'path';
|
|
|
3
3
|
import * as os from 'os';
|
|
4
4
|
import * as readline from 'readline';
|
|
5
5
|
import { CLI_REGISTRY } from '../types.js';
|
|
6
|
-
import { writeJson, readJson, fileExists, writeFile, readFile } from '../utils/fs.js';
|
|
6
|
+
import { writeJson, readJson, fileExists, writeFile, readFile, listDir } from '../utils/fs.js';
|
|
7
7
|
import { log } from '../utils/logger.js';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { callQwenAPI, callQwenAPIFromCreds } from '../utils/qwen-auth.js';
|
|
10
|
+
import { callGeminiAPI } from '../utils/gemini.js';
|
|
10
11
|
import * as fs from 'fs/promises';
|
|
11
12
|
/** Thrown when a slash command inside a conversation requests exit */
|
|
12
13
|
export class ExitError extends Error {
|
|
@@ -298,18 +299,24 @@ function looksLikeHallucinatedToolCalls(text) {
|
|
|
298
299
|
/^\s*<function_call>/m,
|
|
299
300
|
/^\s*```tool_use/im,
|
|
300
301
|
];
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
return TOOL_PATTERNS.some(re => re.test(text));
|
|
303
|
+
}
|
|
304
|
+
/** Check whether the text contains at least one valid === *.md === marker. */
|
|
305
|
+
function hasMdMarkers(text) {
|
|
304
306
|
const fileMarkerMatches = text.match(/===\s+[^\n=]+?\.md\s*===/g);
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
307
|
+
return !!(fileMarkerMatches && fileMarkerMatches.length > 0);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* When a model returns valid markdown but without === markers, rescue it by
|
|
311
|
+
* wrapping the whole content as a single architecture.md block.
|
|
312
|
+
*/
|
|
313
|
+
function rescueAsArchitectureMd(text) {
|
|
314
|
+
return `=== .agent/context/architecture.md ===\n${text.trim()}\n`;
|
|
308
315
|
}
|
|
309
316
|
export class AgentEngine {
|
|
310
317
|
config;
|
|
311
318
|
projectDir;
|
|
312
|
-
|
|
319
|
+
getCoordinatorCmd;
|
|
313
320
|
rl;
|
|
314
321
|
fi;
|
|
315
322
|
slashHandler;
|
|
@@ -318,7 +325,7 @@ export class AgentEngine {
|
|
|
318
325
|
constructor(config, projectDir, coordinatorCmd, rl, fi, slashHandler) {
|
|
319
326
|
this.config = config;
|
|
320
327
|
this.projectDir = projectDir;
|
|
321
|
-
this.
|
|
328
|
+
this.getCoordinatorCmd = typeof coordinatorCmd === 'function' ? coordinatorCmd : () => coordinatorCmd || '';
|
|
322
329
|
this.rl = rl;
|
|
323
330
|
this.fi = fi;
|
|
324
331
|
this.slashHandler = slashHandler;
|
|
@@ -386,13 +393,60 @@ export class AgentEngine {
|
|
|
386
393
|
* hasta tener toda la info necesaria. Retorna la tarea refinada.
|
|
387
394
|
*/
|
|
388
395
|
async runClarification(initialTask) {
|
|
389
|
-
if (!this.
|
|
390
|
-
return initialTask;
|
|
391
|
-
}
|
|
392
|
-
|
|
396
|
+
if (!this.getCoordinatorCmd()) {
|
|
397
|
+
return { task: initialTask };
|
|
398
|
+
}
|
|
399
|
+
// Detect feature ID from initial task (may be undefined — user hasn't provided it yet)
|
|
400
|
+
let activeFeatureId = this._detectFeaturePath(initialTask) ?? undefined;
|
|
401
|
+
// List available features so the coordinator can mention them
|
|
402
|
+
const availableFeatures = await this._listAvailableFeatures();
|
|
403
|
+
// Context is mutable — rebuilt when activeFeatureId changes mid-conversation
|
|
404
|
+
let context = await this.buildCoordinatorContext(activeFeatureId);
|
|
393
405
|
let conversationHistory = `TAREA INICIAL: ${initialTask}`;
|
|
394
406
|
// Helper: call coordinator CLI and return response text
|
|
395
407
|
const callCoordinator = async () => {
|
|
408
|
+
const hasFeature = !!activeFeatureId;
|
|
409
|
+
const featureListBlock = availableFeatures.length > 0
|
|
410
|
+
? `\nFEATURES DISPONIBLES en .agent/docs/:\n${availableFeatures.map(f => ` - ${f}`).join('\n')}\n`
|
|
411
|
+
: '\nNo hay features en .agent/docs/ todavía.\n';
|
|
412
|
+
const READY_SENTINEL = '[LISTO_PARA_LANZAR]';
|
|
413
|
+
const specInstructions = hasFeature ? `
|
|
414
|
+
MODO SPEC: Se cargó la feature "${activeFeatureId}". Los archivos raw están en el contexto.
|
|
415
|
+
Tu objetivo es construir colaborativamente un spec.md con el programador.
|
|
416
|
+
|
|
417
|
+
FLUJO:
|
|
418
|
+
1. Revisá los archivos raw en el contexto.
|
|
419
|
+
2. Hacé UNA pregunta a la vez para aclarar dudas o completar info faltante.
|
|
420
|
+
3. Cuando tengas todo claro, generá el spec.md con este formato EXACTO:
|
|
421
|
+
|
|
422
|
+
=== SPEC.MD ===
|
|
423
|
+
# Feature: ${activeFeatureId}
|
|
424
|
+
|
|
425
|
+
## Objetivo
|
|
426
|
+
[descripción clara del objetivo]
|
|
427
|
+
|
|
428
|
+
## Alcance
|
|
429
|
+
[qué incluye y qué no incluye]
|
|
430
|
+
|
|
431
|
+
## Historias de Usuario / Requerimientos
|
|
432
|
+
[detalle de cada requerimiento]
|
|
433
|
+
|
|
434
|
+
## Criterios de Aceptación
|
|
435
|
+
[lista de criterios verificables]
|
|
436
|
+
|
|
437
|
+
## Notas Técnicas
|
|
438
|
+
[consideraciones de implementación]
|
|
439
|
+
=== END SPEC.MD ===
|
|
440
|
+
|
|
441
|
+
4. Cuando el spec esté aprobado por el programador, escribí EXACTAMENTE al final de tu mensaje: ${READY_SENTINEL}` : `
|
|
442
|
+
MODO TAREA LIBRE: El programador no especificó una feature de .agent/docs/.
|
|
443
|
+
${featureListBlock}
|
|
444
|
+
REGLAS:
|
|
445
|
+
- Primero entendé QUÉ quiere hacer el programador. Hacé preguntas hasta tener claro el objetivo.
|
|
446
|
+
- Si hay features disponibles, preguntá si alguna corresponde a esta tarea (mencioná los nombres).
|
|
447
|
+
- Si el programador da un ID de feature, respondé EXACTAMENTE con: CARGAR_FEATURE: <id>
|
|
448
|
+
- SOLO cuando tengas el objetivo completamente claro y el programador esté de acuerdo, escribí EXACTAMENTE al final de tu mensaje: ${READY_SENTINEL}
|
|
449
|
+
- NUNCA escribas ${READY_SENTINEL} si solo recibiste un saludo o no tenés un objetivo concreto.`;
|
|
396
450
|
const prompt = `Sos el COORDINADOR de un equipo multi-agente de desarrollo.
|
|
397
451
|
Tu trabajo es ENTENDER lo que el programador necesita haciendo PREGUNTAS si es necesario.
|
|
398
452
|
|
|
@@ -405,16 +459,46 @@ ${conversationHistory}
|
|
|
405
459
|
INSTRUCCIONES:
|
|
406
460
|
- Habla de forma NATURAL, como un compañero de equipo.
|
|
407
461
|
- Si necesitas mas info, hace UNA pregunta clara y espera la respuesta.
|
|
408
|
-
- Si ya entendiste el objetivo, decilo claramente y pregunta: "¿Confirmás para lanzar el orchestrator?"
|
|
409
462
|
- NO uses JSON, habla normalmente.
|
|
410
|
-
- Sé breve y directo
|
|
463
|
+
- Sé breve y directo.${specInstructions}`;
|
|
411
464
|
log.info('Coordinador analizando...');
|
|
412
465
|
const envOverride = {};
|
|
413
466
|
let res;
|
|
414
|
-
if (this.
|
|
467
|
+
if (this.getCoordinatorCmd().startsWith('gemini-direct')) {
|
|
468
|
+
const model = this.getCoordinatorCmd().match(/(?:-m|--model)\s+(\S+)/)?.[1] || 'gemini-model';
|
|
469
|
+
const sp = this._startSpinner(`coordinador ${model}`);
|
|
470
|
+
try {
|
|
471
|
+
const result = await callGeminiAPI(prompt, model, (c) => sp.push(c));
|
|
472
|
+
sp.stop();
|
|
473
|
+
return result;
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
sp.stop();
|
|
477
|
+
const msg = err?.message ?? '';
|
|
478
|
+
if (msg.startsWith('GEMINI_NOT_CONFIGURED')) {
|
|
479
|
+
console.log(chalk.red('\n ✗ Gemini no configurado.'));
|
|
480
|
+
console.log(chalk.yellow(' Ejecutá: /login y elegí Google Gemini.\n'));
|
|
481
|
+
}
|
|
482
|
+
else if (msg.startsWith('GEMINI_QUOTA_ZERO')) {
|
|
483
|
+
// Strip the GEMINI_QUOTA_ZERO: prefix for display
|
|
484
|
+
const detail = msg.replace('GEMINI_QUOTA_ZERO: ', '');
|
|
485
|
+
console.log(chalk.red('\n ✗ Cuota cero en free tier:'));
|
|
486
|
+
console.log(chalk.yellow(` ${detail}\n`));
|
|
487
|
+
}
|
|
488
|
+
else if (msg.startsWith('GEMINI_QUOTA_EXCEEDED')) {
|
|
489
|
+
console.log(chalk.red('\n ✗ ' + msg.replace('GEMINI_QUOTA_EXCEEDED: ', '')));
|
|
490
|
+
console.log(chalk.yellow(' Usá /model para cambiar de modelo o /provider para cambiar de proveedor.\n'));
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
console.log(chalk.red(`\n ✗ Error Gemini: ${msg}`));
|
|
494
|
+
}
|
|
495
|
+
throw new Error('COORDINATOR_FAILED');
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
else if (this.getCoordinatorCmd().startsWith('qwen')) {
|
|
415
499
|
// Use Qwen API directly — avoids the qwen CLI's own OAuth flow
|
|
416
500
|
// which causes mid-session auth popups and breaks display.
|
|
417
|
-
const model = this.
|
|
501
|
+
const model = this.getCoordinatorCmd().match(/(?:-m|--model)\s+(\S+)/)?.[1] || 'coder-model';
|
|
418
502
|
const sp = this._startSpinner(`coordinador ${model}`);
|
|
419
503
|
try {
|
|
420
504
|
const result = await callQwenAPI(prompt, model, (c) => sp.push(c));
|
|
@@ -430,12 +514,12 @@ INSTRUCCIONES:
|
|
|
430
514
|
else {
|
|
431
515
|
console.log(chalk.red(`\n ✗ Error Qwen: ${err.message}`));
|
|
432
516
|
}
|
|
433
|
-
|
|
517
|
+
throw new Error('COORDINATOR_FAILED');
|
|
434
518
|
}
|
|
435
519
|
}
|
|
436
520
|
else {
|
|
437
521
|
const sp = this._startSpinner(`coordinador`);
|
|
438
|
-
res = await runCli(this.
|
|
522
|
+
res = await runCli(this.getCoordinatorCmd(), prompt, 600000, envOverride);
|
|
439
523
|
sp.stop();
|
|
440
524
|
}
|
|
441
525
|
// Extract readable text — search for JSON array even if there's prefix text
|
|
@@ -464,42 +548,103 @@ INSTRUCCIONES:
|
|
|
464
548
|
}
|
|
465
549
|
return responseText;
|
|
466
550
|
};
|
|
551
|
+
let pendingSpecContent = null;
|
|
552
|
+
// Extract =\=\= SPEC.MD ===...=== END SPEC.MD === block from coordinator response
|
|
553
|
+
const extractSpecBlock = (text) => {
|
|
554
|
+
const match = text.match(/===\s*SPEC\.MD\s*===([\s\S]*?)=== END SPEC\.MD ===/i);
|
|
555
|
+
return match ? match[1].trim() : null;
|
|
556
|
+
};
|
|
467
557
|
// Clarification loop — coordinator is only called when there is new user input
|
|
468
558
|
let needsCoordinatorCall = true;
|
|
469
559
|
while (true) {
|
|
470
560
|
if (needsCoordinatorCall) {
|
|
471
561
|
needsCoordinatorCall = false;
|
|
472
|
-
|
|
562
|
+
let responseText;
|
|
563
|
+
try {
|
|
564
|
+
responseText = await callCoordinator();
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
if (err.message === 'COORDINATOR_FAILED') {
|
|
568
|
+
// Error ya fue mostrado al usuario — detener sin lanzar al orquestador
|
|
569
|
+
throw err;
|
|
570
|
+
}
|
|
571
|
+
throw err;
|
|
572
|
+
}
|
|
473
573
|
if (!responseText)
|
|
474
|
-
return initialTask; //
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
574
|
+
return { task: initialTask }; // fallback vacío sin error
|
|
575
|
+
// Detect CARGAR_FEATURE: <id> — coordinator signaling a feature was identified
|
|
576
|
+
const cargarMatch = responseText.match(/CARGAR_FEATURE:\s*([^\s\n]+)/);
|
|
577
|
+
if (cargarMatch) {
|
|
578
|
+
const newFeatureId = cargarMatch[1].trim();
|
|
579
|
+
const rawDir = path.join(this.projectDir, '.agent', 'docs', newFeatureId, 'raw');
|
|
580
|
+
if (await fileExists(rawDir)) {
|
|
581
|
+
activeFeatureId = newFeatureId;
|
|
582
|
+
context = await this.buildCoordinatorContext(activeFeatureId);
|
|
583
|
+
log.ok(`Feature cargada: ${newFeatureId}`);
|
|
584
|
+
conversationHistory += `\n[SISTEMA: Se cargaron los archivos raw de la feature "${newFeatureId}"]`;
|
|
585
|
+
needsCoordinatorCall = true;
|
|
586
|
+
continue; // re-call coordinator with enriched context
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
console.log(chalk.yellow(` ⚠ Feature "${newFeatureId}" no encontrada en .agent/docs/`));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// Display coordinator response (strip the CARGAR_FEATURE token if present)
|
|
593
|
+
const displayText = responseText.replace(/CARGAR_FEATURE:\s*\S+\s*/g, '').trim();
|
|
594
|
+
if (displayText) {
|
|
595
|
+
console.log('');
|
|
596
|
+
console.log(chalk.cyan(' Coordinador:'));
|
|
597
|
+
console.log(chalk.white(` ${displayText}`));
|
|
598
|
+
console.log('');
|
|
599
|
+
}
|
|
600
|
+
// Accumulate spec block when in feature mode
|
|
601
|
+
if (activeFeatureId) {
|
|
602
|
+
const specBlock = extractSpecBlock(responseText);
|
|
603
|
+
if (specBlock)
|
|
604
|
+
pendingSpecContent = specBlock;
|
|
605
|
+
}
|
|
606
|
+
// Sentinel-based detection — coordinator explicitly signals readiness
|
|
607
|
+
if (responseText.includes('[LISTO_PARA_LANZAR]')) {
|
|
608
|
+
const confirm = await ask(' ¿Confirmás lanzar el orchestrator? (y/n): ', this.rl);
|
|
484
609
|
if (confirm.toLowerCase() === 'y' || confirm.toLowerCase() === 's') {
|
|
485
|
-
|
|
610
|
+
let specPath;
|
|
611
|
+
if (activeFeatureId && pendingSpecContent) {
|
|
612
|
+
const specDir = path.join(this.projectDir, '.agent', 'docs', activeFeatureId);
|
|
613
|
+
const specFile = path.join(specDir, 'spec.md');
|
|
614
|
+
await writeFile(specFile, pendingSpecContent);
|
|
615
|
+
log.ok(`spec.md guardado en ${path.relative(this.projectDir, specFile)}`);
|
|
616
|
+
specPath = specFile;
|
|
617
|
+
}
|
|
618
|
+
return { task: conversationHistory, specPath, featureId: activeFeatureId };
|
|
486
619
|
}
|
|
487
|
-
const correction = await ask(' ¿Qué querés cambiar o agregar?: ', this.rl
|
|
620
|
+
const correction = await ask(' ¿Qué querés cambiar o agregar?: ', this.rl);
|
|
488
621
|
conversationHistory += `\nPROGRAMADOR: ${correction}`;
|
|
489
622
|
needsCoordinatorCall = true;
|
|
490
623
|
continue;
|
|
491
624
|
}
|
|
492
625
|
}
|
|
493
626
|
// Ask user — slash commands are handled here without calling coordinator again
|
|
494
|
-
const answer = await ask(' Tu respuesta: ', this.rl
|
|
627
|
+
const answer = await ask(' Tu respuesta: ', this.rl);
|
|
495
628
|
const trimmedAnswer = answer.trim();
|
|
496
629
|
if (trimmedAnswer.startsWith('/') && this.slashHandler) {
|
|
497
630
|
await this.slashHandler(trimmedAnswer);
|
|
498
|
-
// needsCoordinatorCall is still false — re-prompt without coordinator call
|
|
499
631
|
continue;
|
|
500
632
|
}
|
|
633
|
+
// Also detect feature ID in user messages mid-conversation
|
|
634
|
+
const mentionedFeature = this._detectFeaturePath(trimmedAnswer);
|
|
635
|
+
if (mentionedFeature && mentionedFeature !== activeFeatureId) {
|
|
636
|
+
const rawDir = path.join(this.projectDir, '.agent', 'docs', mentionedFeature, 'raw');
|
|
637
|
+
if (await fileExists(rawDir)) {
|
|
638
|
+
activeFeatureId = mentionedFeature;
|
|
639
|
+
context = await this.buildCoordinatorContext(activeFeatureId);
|
|
640
|
+
log.ok(`Feature detectada en mensaje: ${mentionedFeature}`);
|
|
641
|
+
conversationHistory += `\nPROGRAMADOR: ${answer}\n[SISTEMA: Se cargaron los archivos raw de la feature "${mentionedFeature}"]`;
|
|
642
|
+
needsCoordinatorCall = true;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
501
646
|
conversationHistory += `\nPROGRAMADOR: ${answer}`;
|
|
502
|
-
needsCoordinatorCall = true;
|
|
647
|
+
needsCoordinatorCall = true;
|
|
503
648
|
}
|
|
504
649
|
}
|
|
505
650
|
async runWithFallback(roleName, prompt, phaseName) {
|
|
@@ -737,7 +882,11 @@ REGLA: Al terminar, reporta todo lo que encontraste de forma clara y estructurad
|
|
|
737
882
|
const preamble = preambles[roleName] || '';
|
|
738
883
|
return preamble ? `${preamble}\n\n${taskPrompt}` : taskPrompt;
|
|
739
884
|
}
|
|
740
|
-
async generateTaskId(task) {
|
|
885
|
+
async generateTaskId(task, featureId) {
|
|
886
|
+
if (featureId) {
|
|
887
|
+
// Use featureId directly — no collision check needed, orchestrator owns the dir
|
|
888
|
+
return featureId;
|
|
889
|
+
}
|
|
741
890
|
const today = new Date().toISOString().split('T')[0];
|
|
742
891
|
const short = task.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 30);
|
|
743
892
|
let num = 1;
|
|
@@ -749,6 +898,39 @@ REGLA: Al terminar, reporta todo lo que encontraste de forma clara y estructurad
|
|
|
749
898
|
num++;
|
|
750
899
|
}
|
|
751
900
|
}
|
|
901
|
+
/** Lists feature IDs that have a raw/ subdirectory in .agent/docs/ */
|
|
902
|
+
async _listAvailableFeatures() {
|
|
903
|
+
const docsDir = path.join(this.projectDir, '.agent', 'docs');
|
|
904
|
+
if (!(await fileExists(docsDir)))
|
|
905
|
+
return [];
|
|
906
|
+
let entries = [];
|
|
907
|
+
try {
|
|
908
|
+
entries = await listDir(docsDir);
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
return [];
|
|
912
|
+
}
|
|
913
|
+
const features = [];
|
|
914
|
+
for (const entry of entries) {
|
|
915
|
+
const rawDir = path.join(docsDir, entry, 'raw');
|
|
916
|
+
if (await fileExists(rawDir))
|
|
917
|
+
features.push(entry);
|
|
918
|
+
}
|
|
919
|
+
return features.sort();
|
|
920
|
+
}
|
|
921
|
+
/** Extracts featureId if task is a path like .agent/docs/<featureId> or just <featureId> */
|
|
922
|
+
_detectFeaturePath(task) {
|
|
923
|
+
const trimmed = task.trim();
|
|
924
|
+
// Match .agent/docs/<featureId> (absolute or relative)
|
|
925
|
+
const docsMatch = trimmed.match(/(?:^|[/\\])\.agent[/\\]docs[/\\]([^/\\\s]+)/);
|
|
926
|
+
if (docsMatch)
|
|
927
|
+
return docsMatch[1];
|
|
928
|
+
// Match bare directory names that look like feature slugs (contain a hyphen or underscore)
|
|
929
|
+
if (/^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/.test(trimmed) && /[-_]/.test(trimmed)) {
|
|
930
|
+
return trimmed;
|
|
931
|
+
}
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
752
934
|
/** Context for the ORCHESTRATOR only — planning-relevant files */
|
|
753
935
|
async buildOrchestratorContext() {
|
|
754
936
|
let ctx = `Project: ${this.config.project}\nStack: ${this.config.stack}\n`;
|
|
@@ -766,23 +948,49 @@ REGLA: Al terminar, reporta todo lo que encontraste de forma clara y estructurad
|
|
|
766
948
|
return ctx;
|
|
767
949
|
}
|
|
768
950
|
/** Minimal context for COORDINATOR clarification only */
|
|
769
|
-
async buildCoordinatorContext() {
|
|
951
|
+
async buildCoordinatorContext(featureId) {
|
|
770
952
|
let ctx = `Project: ${this.config.project}\nStack: ${this.config.stack}\n`;
|
|
771
953
|
const indexPath = path.join(this.projectDir, '.agent', 'INDEX.md');
|
|
772
954
|
if (await fileExists(indexPath)) {
|
|
773
955
|
const content = await readFile(indexPath);
|
|
774
956
|
ctx += `\n--- INDEX ---\n${content.slice(0, 2000)}\n`;
|
|
775
957
|
}
|
|
958
|
+
if (featureId) {
|
|
959
|
+
const rawDir = path.join(this.projectDir, '.agent', 'docs', featureId, 'raw');
|
|
960
|
+
if (await fileExists(rawDir)) {
|
|
961
|
+
let files = [];
|
|
962
|
+
try {
|
|
963
|
+
files = await listDir(rawDir);
|
|
964
|
+
}
|
|
965
|
+
catch { }
|
|
966
|
+
const mdFiles = files.filter(f => f.endsWith('.md')).sort();
|
|
967
|
+
if (mdFiles.length > 0) {
|
|
968
|
+
ctx += `\n--- FEATURE: ${featureId} ---\n`;
|
|
969
|
+
for (const fname of mdFiles) {
|
|
970
|
+
const fpath = path.join(rawDir, fname);
|
|
971
|
+
const content = await readFile(fpath);
|
|
972
|
+
ctx += `\n=== ${fname} ===\n${content}\n`;
|
|
973
|
+
}
|
|
974
|
+
ctx += `--- END FEATURE ---\n`;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
776
978
|
return ctx;
|
|
777
979
|
}
|
|
778
|
-
async runOrchestrator(task) {
|
|
779
|
-
const taskId = await this.generateTaskId(task);
|
|
980
|
+
async runOrchestrator(task, specPath, featureId) {
|
|
981
|
+
const taskId = await this.generateTaskId(task, featureId);
|
|
780
982
|
const taskDir = path.join(this.projectDir, '.agent', 'tasks', taskId);
|
|
781
983
|
log.phase(1, 'Planificacion', this.config.roles.orchestrator.cli, this.config.roles.orchestrator.model);
|
|
782
984
|
const context = await this.buildOrchestratorContext();
|
|
985
|
+
let specContent = '';
|
|
986
|
+
if (specPath && await fileExists(specPath)) {
|
|
987
|
+
const raw = await readFile(specPath);
|
|
988
|
+
specContent = `\nSPEC.MD (documento de referencia):\n${raw}\n`;
|
|
989
|
+
}
|
|
783
990
|
const prompt = `TAREA: ${task}
|
|
784
991
|
TASK_ID: ${taskId}
|
|
785
992
|
DIRECTORIO_TRABAJO: ${this.projectDir}
|
|
993
|
+
${specContent}
|
|
786
994
|
|
|
787
995
|
CONTEXTO DEL PROYECTO:
|
|
788
996
|
${context}
|
|
@@ -1621,8 +1829,15 @@ REGLAS DE PATHS:
|
|
|
1621
1829
|
log.warn('Documentacion previa preservada (no se modifico nada).');
|
|
1622
1830
|
return text;
|
|
1623
1831
|
}
|
|
1832
|
+
// If the model returned valid markdown but without === markers (common with some models),
|
|
1833
|
+
// rescue it by wrapping the whole response as architecture.md
|
|
1834
|
+
let effectiveText = text;
|
|
1835
|
+
if (!hasMdMarkers(text)) {
|
|
1836
|
+
log.warn('Explorer devolvio markdown sin marcadores === *.md === — guardando como architecture.md');
|
|
1837
|
+
effectiveText = rescueAsArchitectureMd(text);
|
|
1838
|
+
}
|
|
1624
1839
|
// Parse sections separated by === path/file.md === markers
|
|
1625
|
-
const sections =
|
|
1840
|
+
const sections = effectiveText.split(/===\s+(.+?\.md)\s*===/).slice(1);
|
|
1626
1841
|
// First pass: collect all valid file writes WITHOUT touching disk yet (transactional)
|
|
1627
1842
|
const pendingWrites = [];
|
|
1628
1843
|
for (let i = 0; i < sections.length; i += 2) {
|
|
@@ -1634,6 +1849,10 @@ REGLAS DE PATHS:
|
|
|
1634
1849
|
content = content.replace(/^```markdown\s*/i, '').replace(/^```\s*$/gm, '').trim();
|
|
1635
1850
|
if (!content)
|
|
1636
1851
|
continue;
|
|
1852
|
+
// If the model emitted literal \n instead of real newlines (single-line output), unescape
|
|
1853
|
+
if (!content.includes('\n') && content.includes('\\n')) {
|
|
1854
|
+
content = content.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
|
1855
|
+
}
|
|
1637
1856
|
// All explorer output goes under .agent/context/ — docs/ is manual-only
|
|
1638
1857
|
const relPath = fileName.replace(/^\.agent\/context\//i, '').replace(/^\/+/, '');
|
|
1639
1858
|
let targetPath = null;
|
|
@@ -1689,7 +1908,42 @@ REGLAS DE PATHS:
|
|
|
1689
1908
|
// ══════════════════════════════════════════════════
|
|
1690
1909
|
// FASE 0 — Clarificacion (Coordinador ↔ Programador)
|
|
1691
1910
|
// ══════════════════════════════════════════════════
|
|
1692
|
-
|
|
1911
|
+
let clarification;
|
|
1912
|
+
// Retry loop — lets the user fix model/provider inline when coordinator fails
|
|
1913
|
+
while (true) {
|
|
1914
|
+
try {
|
|
1915
|
+
clarification = await this.runClarification(task);
|
|
1916
|
+
break;
|
|
1917
|
+
}
|
|
1918
|
+
catch (err) {
|
|
1919
|
+
if (err.message !== 'COORDINATOR_FAILED')
|
|
1920
|
+
throw err;
|
|
1921
|
+
console.log('');
|
|
1922
|
+
console.log(chalk.yellow(' ¿Qué querés hacer?'));
|
|
1923
|
+
console.log(chalk.dim(' /model — cambiar modelo del coordinador'));
|
|
1924
|
+
console.log(chalk.dim(' /provider — cambiar proveedor (Gemini ↔ Qwen)'));
|
|
1925
|
+
console.log(chalk.dim(' r — reintentar con la config actual'));
|
|
1926
|
+
console.log(chalk.dim(' c — cancelar'));
|
|
1927
|
+
console.log('');
|
|
1928
|
+
// Allow slash commands or simple actions here
|
|
1929
|
+
while (true) {
|
|
1930
|
+
const action = await ask(' > ', this.rl, this.fi);
|
|
1931
|
+
const t = action.trim();
|
|
1932
|
+
if (t === 'c' || t === 'cancel')
|
|
1933
|
+
return;
|
|
1934
|
+
if (t === 'r')
|
|
1935
|
+
break; // retry coordinator
|
|
1936
|
+
if (t.startsWith('/') && this.slashHandler) {
|
|
1937
|
+
await this.slashHandler(t);
|
|
1938
|
+
// After slash command, ask again
|
|
1939
|
+
console.log(chalk.dim(' (r = reintentar /model /provider c = cancelar)'));
|
|
1940
|
+
continue;
|
|
1941
|
+
}
|
|
1942
|
+
console.log(chalk.dim(' r = reintentar · /model · /provider · c = cancelar'));
|
|
1943
|
+
}
|
|
1944
|
+
// Retry — loop back to runClarification
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1693
1947
|
// ══════════════════════════════════════════════════
|
|
1694
1948
|
// FASE 1 — Planificacion (Orchestrator)
|
|
1695
1949
|
// ══════════════════════════════════════════════════
|
|
@@ -1698,7 +1952,7 @@ REGLAS DE PATHS:
|
|
|
1698
1952
|
while (true) {
|
|
1699
1953
|
try {
|
|
1700
1954
|
log.section('FASE 1 — Planificacion');
|
|
1701
|
-
const result = await this.runOrchestrator(
|
|
1955
|
+
const result = await this.runOrchestrator(clarification.task, clarification.specPath, clarification.featureId);
|
|
1702
1956
|
taskId = result.taskId;
|
|
1703
1957
|
plan = result.plan;
|
|
1704
1958
|
break;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface GeminiKeyConfig {
|
|
2
|
+
api_key: string;
|
|
3
|
+
model: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function loadGeminiApiKey(): Promise<GeminiKeyConfig | null>;
|
|
6
|
+
export declare function saveGeminiApiKey(cfg: GeminiKeyConfig): Promise<void>;
|
|
7
|
+
export declare function deleteGeminiApiKey(): Promise<void>;
|
|
8
|
+
export declare function geminiAuthStatus(): Promise<{
|
|
9
|
+
authenticated: boolean;
|
|
10
|
+
model?: string;
|
|
11
|
+
}>;
|
|
12
|
+
/**
|
|
13
|
+
* Call Gemini API directly using @google/generative-ai.
|
|
14
|
+
* When onData is provided, streams response chunks as they arrive.
|
|
15
|
+
*/
|
|
16
|
+
export declare function callGeminiAPI(prompt: string, model?: string, onData?: (chunk: string) => void): Promise<string>;
|
|
17
|
+
export declare const GEMINI_MODELS: string[];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
4
|
+
import { AGENT_HOME } from './config.js';
|
|
5
|
+
const GEMINI_KEY_FILE = path.join(AGENT_HOME, 'gemini_key.json');
|
|
6
|
+
export async function loadGeminiApiKey() {
|
|
7
|
+
try {
|
|
8
|
+
const content = await fs.readFile(GEMINI_KEY_FILE, 'utf-8');
|
|
9
|
+
const cfg = JSON.parse(content);
|
|
10
|
+
if (!cfg.api_key)
|
|
11
|
+
return null;
|
|
12
|
+
return cfg;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function saveGeminiApiKey(cfg) {
|
|
19
|
+
await fs.mkdir(AGENT_HOME, { recursive: true });
|
|
20
|
+
await fs.writeFile(GEMINI_KEY_FILE, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
21
|
+
}
|
|
22
|
+
export async function deleteGeminiApiKey() {
|
|
23
|
+
await fs.unlink(GEMINI_KEY_FILE).catch(() => { });
|
|
24
|
+
}
|
|
25
|
+
export async function geminiAuthStatus() {
|
|
26
|
+
const cfg = await loadGeminiApiKey();
|
|
27
|
+
if (!cfg?.api_key)
|
|
28
|
+
return { authenticated: false };
|
|
29
|
+
return { authenticated: true, model: cfg.model };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Call Gemini API directly using @google/generative-ai.
|
|
33
|
+
* When onData is provided, streams response chunks as they arrive.
|
|
34
|
+
*/
|
|
35
|
+
export async function callGeminiAPI(prompt, model = 'gemini-2.5-flash', onData) {
|
|
36
|
+
const cfg = await loadGeminiApiKey();
|
|
37
|
+
if (!cfg?.api_key) {
|
|
38
|
+
throw new Error('GEMINI_NOT_CONFIGURED: Run /login y elegí Google Gemini');
|
|
39
|
+
}
|
|
40
|
+
const useModel = (model && model !== 'gemini-model') ? model : cfg.model;
|
|
41
|
+
const genAI = new GoogleGenerativeAI(cfg.api_key);
|
|
42
|
+
const gemini = genAI.getGenerativeModel({ model: useModel });
|
|
43
|
+
const MAX_RETRIES = 3;
|
|
44
|
+
let lastErr;
|
|
45
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
46
|
+
try {
|
|
47
|
+
if (!onData) {
|
|
48
|
+
const result = await gemini.generateContent(prompt);
|
|
49
|
+
return result.response.text();
|
|
50
|
+
}
|
|
51
|
+
const streamResult = await gemini.generateContentStream(prompt);
|
|
52
|
+
let fullText = '';
|
|
53
|
+
for await (const chunk of streamResult.stream) {
|
|
54
|
+
const delta = chunk.text();
|
|
55
|
+
if (delta) {
|
|
56
|
+
fullText += delta;
|
|
57
|
+
onData(delta);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return fullText;
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
lastErr = err;
|
|
64
|
+
const msg = err?.message ?? '';
|
|
65
|
+
// 429 — extract retryDelay and wait
|
|
66
|
+
if (msg.includes('429') || msg.includes('Too Many Requests')) {
|
|
67
|
+
// Check for "limit: 0" — free tier doesn't support this model at all
|
|
68
|
+
if (msg.includes('limit: 0')) {
|
|
69
|
+
throw new Error(`GEMINI_QUOTA_ZERO: El modelo ${useModel} requiere plan de pago en Google AI.\n` +
|
|
70
|
+
` Cambiá a un modelo gratuito con /model (ej: gemini-2.5-flash o gemini-1.5-flash).`);
|
|
71
|
+
}
|
|
72
|
+
// Extract retry delay in seconds from error
|
|
73
|
+
const delayMatch = msg.match(/retryDelay["\s:]+(\d+)s/) || msg.match(/retry in ([\d.]+)s/);
|
|
74
|
+
const delaySecs = delayMatch ? Math.ceil(parseFloat(delayMatch[1])) : 15;
|
|
75
|
+
if (attempt < MAX_RETRIES) {
|
|
76
|
+
onData?.(`\n[Gemini 429 — reintentando en ${delaySecs}s...]`);
|
|
77
|
+
await new Promise(r => setTimeout(r, delaySecs * 1000));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`GEMINI_QUOTA_EXCEEDED: Cuota agotada en ${useModel}. Usá /model para cambiar de modelo.`);
|
|
81
|
+
}
|
|
82
|
+
// Other errors — don't retry
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw lastErr;
|
|
87
|
+
}
|
|
88
|
+
export const GEMINI_MODELS = [
|
|
89
|
+
'gemini-flash-latest',
|
|
90
|
+
'gemini-2.5-flash',
|
|
91
|
+
'gemini-2.0-flash',
|
|
92
|
+
'gemini-1.5-flash',
|
|
93
|
+
'gemini-2.5-pro',
|
|
94
|
+
'gemini-1.5-pro',
|
|
95
|
+
];
|