agent-mp 0.5.25 → 0.5.27
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 +305 -48
- 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,15 +393,67 @@ 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 () => {
|
|
396
|
-
const
|
|
397
|
-
|
|
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 — Feature cargada: "${activeFeatureId}"
|
|
415
|
+
Los archivos raw YA ESTÁN en el CONTEXTO DEL PROYECTO arriba.
|
|
416
|
+
|
|
417
|
+
REGLAS CRÍTICAS:
|
|
418
|
+
1. ANTES DE ESCRIBIR CUALQUIER COSA: leé completo cada archivo raw del contexto.
|
|
419
|
+
2. NO preguntes sobre información que ya está en los archivos raw. Si el lenguaje, stack, objetivo, contexto o requisitos están en los archivos, USÁ ESA INFORMACIÓN directamente.
|
|
420
|
+
3. Solo hacé preguntas sobre información que GENUINAMENTE falta y que no podés inferir de los archivos.
|
|
421
|
+
4. Si los archivos ya tienen suficiente información para entender el objetivo, NO hagas preguntas — generá el spec.md directamente.
|
|
422
|
+
5. Cuando tengas todo claro (o si los archivos ya tienen suficiente info), generá el spec.md con este formato EXACTO:
|
|
423
|
+
|
|
424
|
+
=== SPEC.MD ===
|
|
425
|
+
# Feature: ${activeFeatureId}
|
|
426
|
+
|
|
427
|
+
## Objetivo
|
|
428
|
+
[descripción clara del objetivo]
|
|
429
|
+
|
|
430
|
+
## Alcance
|
|
431
|
+
[qué incluye y qué no incluye]
|
|
432
|
+
|
|
433
|
+
## Historias de Usuario / Requerimientos
|
|
434
|
+
[detalle de cada requerimiento]
|
|
435
|
+
|
|
436
|
+
## Criterios de Aceptación
|
|
437
|
+
[lista de criterios verificables]
|
|
438
|
+
|
|
439
|
+
## Notas Técnicas
|
|
440
|
+
[consideraciones de implementación]
|
|
441
|
+
=== END SPEC.MD ===
|
|
442
|
+
|
|
443
|
+
6. Cuando el spec esté aprobado, escribí EXACTAMENTE al final: ${READY_SENTINEL}` : `
|
|
444
|
+
MODO TAREA LIBRE — No se especificó feature todavía.
|
|
445
|
+
${featureListBlock}
|
|
446
|
+
REGLAS:
|
|
447
|
+
- Primero entendé QUÉ quiere hacer el programador.
|
|
448
|
+
- Si hay features disponibles, preguntá UNA VEZ si alguna corresponde (mencioná los nombres). No insistas.
|
|
449
|
+
- Si el programador menciona un path o ID de feature, está confirmando esa feature. NO preguntés "¿tiene que ver con X?" — ya lo dijo. Respondé EXACTAMENTE con: CARGAR_FEATURE: <id>
|
|
450
|
+
- Si el programador menciona solo el nombre o fragmento de un ID de feature disponible, igual respondé con: CARGAR_FEATURE: <id>
|
|
451
|
+
- Cuando el objetivo esté claro, escribí EXACTAMENTE al final: ${READY_SENTINEL}
|
|
452
|
+
- NUNCA escribas ${READY_SENTINEL} si solo recibiste un saludo o falta información concreta.`;
|
|
453
|
+
const prompt = `Sos el COORDINADOR de un equipo de desarrollo de software.
|
|
454
|
+
Tu trabajo es entender qué necesita el programador usando la información disponible y haciendo SOLO las preguntas estrictamente necesarias.
|
|
455
|
+
|
|
456
|
+
REGLA DE ORO: ANTES de escribir cualquier respuesta, LEÉ COMPLETO el CONTEXTO DEL PROYECTO que está arriba. Toda la información que necesitás para no hacer preguntas obvias está ahí.
|
|
398
457
|
|
|
399
458
|
CONTEXTO DEL PROYECTO:
|
|
400
459
|
${context}
|
|
@@ -402,19 +461,51 @@ ${context}
|
|
|
402
461
|
CONVERSACION PREVIA:
|
|
403
462
|
${conversationHistory}
|
|
404
463
|
|
|
405
|
-
INSTRUCCIONES:
|
|
406
|
-
-
|
|
407
|
-
-
|
|
408
|
-
-
|
|
409
|
-
-
|
|
410
|
-
-
|
|
464
|
+
INSTRUCCIONES GENERALES:
|
|
465
|
+
- Hablá en forma NATURAL, como un compañero de equipo experimentado.
|
|
466
|
+
- ANTES DE RESPONDER: leé todo el CONTEXTO DEL PROYECTO y los archivos raw de la feature. Si algo ya está documentado ahí, NO lo preguntes.
|
|
467
|
+
- NO preguntés cosas que ya están respondidas en los archivos raw o en la conversación.
|
|
468
|
+
- Hacé como máximo UNA pregunta por turno.
|
|
469
|
+
- NO uses JSON, hablá normalmente.
|
|
470
|
+
- Sé directo y eficiente — respetá el tiempo del programador.${specInstructions}`;
|
|
411
471
|
log.info('Coordinador analizando...');
|
|
412
472
|
const envOverride = {};
|
|
413
473
|
let res;
|
|
414
|
-
if (this.
|
|
474
|
+
if (this.getCoordinatorCmd().startsWith('gemini-direct')) {
|
|
475
|
+
const model = this.getCoordinatorCmd().match(/(?:-m|--model)\s+(\S+)/)?.[1] || 'gemini-model';
|
|
476
|
+
const sp = this._startSpinner(`coordinador ${model}`);
|
|
477
|
+
try {
|
|
478
|
+
const result = await callGeminiAPI(prompt, model, (c) => sp.push(c));
|
|
479
|
+
sp.stop();
|
|
480
|
+
return result;
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
sp.stop();
|
|
484
|
+
const msg = err?.message ?? '';
|
|
485
|
+
if (msg.startsWith('GEMINI_NOT_CONFIGURED')) {
|
|
486
|
+
console.log(chalk.red('\n ✗ Gemini no configurado.'));
|
|
487
|
+
console.log(chalk.yellow(' Ejecutá: /login y elegí Google Gemini.\n'));
|
|
488
|
+
}
|
|
489
|
+
else if (msg.startsWith('GEMINI_QUOTA_ZERO')) {
|
|
490
|
+
// Strip the GEMINI_QUOTA_ZERO: prefix for display
|
|
491
|
+
const detail = msg.replace('GEMINI_QUOTA_ZERO: ', '');
|
|
492
|
+
console.log(chalk.red('\n ✗ Cuota cero en free tier:'));
|
|
493
|
+
console.log(chalk.yellow(` ${detail}\n`));
|
|
494
|
+
}
|
|
495
|
+
else if (msg.startsWith('GEMINI_QUOTA_EXCEEDED')) {
|
|
496
|
+
console.log(chalk.red('\n ✗ ' + msg.replace('GEMINI_QUOTA_EXCEEDED: ', '')));
|
|
497
|
+
console.log(chalk.yellow(' Usá /model para cambiar de modelo o /provider para cambiar de proveedor.\n'));
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
console.log(chalk.red(`\n ✗ Error Gemini: ${msg}`));
|
|
501
|
+
}
|
|
502
|
+
throw new Error('COORDINATOR_FAILED');
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
else if (this.getCoordinatorCmd().startsWith('qwen')) {
|
|
415
506
|
// Use Qwen API directly — avoids the qwen CLI's own OAuth flow
|
|
416
507
|
// which causes mid-session auth popups and breaks display.
|
|
417
|
-
const model = this.
|
|
508
|
+
const model = this.getCoordinatorCmd().match(/(?:-m|--model)\s+(\S+)/)?.[1] || 'coder-model';
|
|
418
509
|
const sp = this._startSpinner(`coordinador ${model}`);
|
|
419
510
|
try {
|
|
420
511
|
const result = await callQwenAPI(prompt, model, (c) => sp.push(c));
|
|
@@ -430,12 +521,12 @@ INSTRUCCIONES:
|
|
|
430
521
|
else {
|
|
431
522
|
console.log(chalk.red(`\n ✗ Error Qwen: ${err.message}`));
|
|
432
523
|
}
|
|
433
|
-
|
|
524
|
+
throw new Error('COORDINATOR_FAILED');
|
|
434
525
|
}
|
|
435
526
|
}
|
|
436
527
|
else {
|
|
437
528
|
const sp = this._startSpinner(`coordinador`);
|
|
438
|
-
res = await runCli(this.
|
|
529
|
+
res = await runCli(this.getCoordinatorCmd(), prompt, 600000, envOverride);
|
|
439
530
|
sp.stop();
|
|
440
531
|
}
|
|
441
532
|
// Extract readable text — search for JSON array even if there's prefix text
|
|
@@ -464,42 +555,103 @@ INSTRUCCIONES:
|
|
|
464
555
|
}
|
|
465
556
|
return responseText;
|
|
466
557
|
};
|
|
558
|
+
let pendingSpecContent = null;
|
|
559
|
+
// Extract =\=\= SPEC.MD ===...=== END SPEC.MD === block from coordinator response
|
|
560
|
+
const extractSpecBlock = (text) => {
|
|
561
|
+
const match = text.match(/===\s*SPEC\.MD\s*===([\s\S]*?)=== END SPEC\.MD ===/i);
|
|
562
|
+
return match ? match[1].trim() : null;
|
|
563
|
+
};
|
|
467
564
|
// Clarification loop — coordinator is only called when there is new user input
|
|
468
565
|
let needsCoordinatorCall = true;
|
|
469
566
|
while (true) {
|
|
470
567
|
if (needsCoordinatorCall) {
|
|
471
568
|
needsCoordinatorCall = false;
|
|
472
|
-
|
|
569
|
+
let responseText;
|
|
570
|
+
try {
|
|
571
|
+
responseText = await callCoordinator();
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
if (err.message === 'COORDINATOR_FAILED') {
|
|
575
|
+
// Error ya fue mostrado al usuario — detener sin lanzar al orquestador
|
|
576
|
+
throw err;
|
|
577
|
+
}
|
|
578
|
+
throw err;
|
|
579
|
+
}
|
|
473
580
|
if (!responseText)
|
|
474
|
-
return initialTask; //
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
581
|
+
return { task: initialTask }; // fallback vacío sin error
|
|
582
|
+
// Detect CARGAR_FEATURE: <id> — coordinator signaling a feature was identified
|
|
583
|
+
const cargarMatch = responseText.match(/CARGAR_FEATURE:\s*([^\s\n]+)/);
|
|
584
|
+
if (cargarMatch) {
|
|
585
|
+
const newFeatureId = cargarMatch[1].trim();
|
|
586
|
+
const rawDir = path.join(this.projectDir, '.agent', 'docs', newFeatureId, 'raw');
|
|
587
|
+
if (await fileExists(rawDir)) {
|
|
588
|
+
activeFeatureId = newFeatureId;
|
|
589
|
+
context = await this.buildCoordinatorContext(activeFeatureId);
|
|
590
|
+
log.ok(`Feature cargada: ${newFeatureId}`);
|
|
591
|
+
conversationHistory += `\n[SISTEMA: Se cargaron los archivos raw de la feature "${newFeatureId}"]`;
|
|
592
|
+
needsCoordinatorCall = true;
|
|
593
|
+
continue; // re-call coordinator with enriched context
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
console.log(chalk.yellow(` ⚠ Feature "${newFeatureId}" no encontrada en .agent/docs/`));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// Display coordinator response (strip the CARGAR_FEATURE token if present)
|
|
600
|
+
const displayText = responseText.replace(/CARGAR_FEATURE:\s*\S+\s*/g, '').trim();
|
|
601
|
+
if (displayText) {
|
|
602
|
+
console.log('');
|
|
603
|
+
console.log(chalk.cyan(' Coordinador:'));
|
|
604
|
+
console.log(chalk.white(` ${displayText}`));
|
|
605
|
+
console.log('');
|
|
606
|
+
}
|
|
607
|
+
// Accumulate spec block when in feature mode
|
|
608
|
+
if (activeFeatureId) {
|
|
609
|
+
const specBlock = extractSpecBlock(responseText);
|
|
610
|
+
if (specBlock)
|
|
611
|
+
pendingSpecContent = specBlock;
|
|
612
|
+
}
|
|
613
|
+
// Sentinel-based detection — coordinator explicitly signals readiness
|
|
614
|
+
if (responseText.includes('[LISTO_PARA_LANZAR]')) {
|
|
615
|
+
const confirm = await ask(' ¿Confirmás lanzar el orchestrator? (y/n): ', this.rl);
|
|
484
616
|
if (confirm.toLowerCase() === 'y' || confirm.toLowerCase() === 's') {
|
|
485
|
-
|
|
617
|
+
let specPath;
|
|
618
|
+
if (activeFeatureId && pendingSpecContent) {
|
|
619
|
+
const specDir = path.join(this.projectDir, '.agent', 'docs', activeFeatureId);
|
|
620
|
+
const specFile = path.join(specDir, 'spec.md');
|
|
621
|
+
await writeFile(specFile, pendingSpecContent);
|
|
622
|
+
log.ok(`spec.md guardado en ${path.relative(this.projectDir, specFile)}`);
|
|
623
|
+
specPath = specFile;
|
|
624
|
+
}
|
|
625
|
+
return { task: conversationHistory, specPath, featureId: activeFeatureId };
|
|
486
626
|
}
|
|
487
|
-
const correction = await ask(' ¿Qué querés cambiar o agregar?: ', this.rl
|
|
627
|
+
const correction = await ask(' ¿Qué querés cambiar o agregar?: ', this.rl);
|
|
488
628
|
conversationHistory += `\nPROGRAMADOR: ${correction}`;
|
|
489
629
|
needsCoordinatorCall = true;
|
|
490
630
|
continue;
|
|
491
631
|
}
|
|
492
632
|
}
|
|
493
633
|
// Ask user — slash commands are handled here without calling coordinator again
|
|
494
|
-
const answer = await ask(' Tu respuesta: ', this.rl
|
|
634
|
+
const answer = await ask(' Tu respuesta: ', this.rl);
|
|
495
635
|
const trimmedAnswer = answer.trim();
|
|
496
636
|
if (trimmedAnswer.startsWith('/') && this.slashHandler) {
|
|
497
637
|
await this.slashHandler(trimmedAnswer);
|
|
498
|
-
// needsCoordinatorCall is still false — re-prompt without coordinator call
|
|
499
638
|
continue;
|
|
500
639
|
}
|
|
640
|
+
// Also detect feature ID in user messages mid-conversation
|
|
641
|
+
const mentionedFeature = this._detectFeaturePath(trimmedAnswer);
|
|
642
|
+
if (mentionedFeature && mentionedFeature !== activeFeatureId) {
|
|
643
|
+
const rawDir = path.join(this.projectDir, '.agent', 'docs', mentionedFeature, 'raw');
|
|
644
|
+
if (await fileExists(rawDir)) {
|
|
645
|
+
activeFeatureId = mentionedFeature;
|
|
646
|
+
context = await this.buildCoordinatorContext(activeFeatureId);
|
|
647
|
+
log.ok(`Feature detectada en mensaje: ${mentionedFeature}`);
|
|
648
|
+
conversationHistory += `\nPROGRAMADOR: ${answer}\n[SISTEMA: Se cargaron los archivos raw de la feature "${mentionedFeature}"]`;
|
|
649
|
+
needsCoordinatorCall = true;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
501
653
|
conversationHistory += `\nPROGRAMADOR: ${answer}`;
|
|
502
|
-
needsCoordinatorCall = true;
|
|
654
|
+
needsCoordinatorCall = true;
|
|
503
655
|
}
|
|
504
656
|
}
|
|
505
657
|
async runWithFallback(roleName, prompt, phaseName) {
|
|
@@ -737,7 +889,11 @@ REGLA: Al terminar, reporta todo lo que encontraste de forma clara y estructurad
|
|
|
737
889
|
const preamble = preambles[roleName] || '';
|
|
738
890
|
return preamble ? `${preamble}\n\n${taskPrompt}` : taskPrompt;
|
|
739
891
|
}
|
|
740
|
-
async generateTaskId(task) {
|
|
892
|
+
async generateTaskId(task, featureId) {
|
|
893
|
+
if (featureId) {
|
|
894
|
+
// Use featureId directly — no collision check needed, orchestrator owns the dir
|
|
895
|
+
return featureId;
|
|
896
|
+
}
|
|
741
897
|
const today = new Date().toISOString().split('T')[0];
|
|
742
898
|
const short = task.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 30);
|
|
743
899
|
let num = 1;
|
|
@@ -749,6 +905,39 @@ REGLA: Al terminar, reporta todo lo que encontraste de forma clara y estructurad
|
|
|
749
905
|
num++;
|
|
750
906
|
}
|
|
751
907
|
}
|
|
908
|
+
/** Lists feature IDs that have a raw/ subdirectory in .agent/docs/ */
|
|
909
|
+
async _listAvailableFeatures() {
|
|
910
|
+
const docsDir = path.join(this.projectDir, '.agent', 'docs');
|
|
911
|
+
if (!(await fileExists(docsDir)))
|
|
912
|
+
return [];
|
|
913
|
+
let entries = [];
|
|
914
|
+
try {
|
|
915
|
+
entries = await listDir(docsDir);
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
return [];
|
|
919
|
+
}
|
|
920
|
+
const features = [];
|
|
921
|
+
for (const entry of entries) {
|
|
922
|
+
const rawDir = path.join(docsDir, entry, 'raw');
|
|
923
|
+
if (await fileExists(rawDir))
|
|
924
|
+
features.push(entry);
|
|
925
|
+
}
|
|
926
|
+
return features.sort();
|
|
927
|
+
}
|
|
928
|
+
/** Extracts featureId if task is a path like .agent/docs/<featureId> or just <featureId> */
|
|
929
|
+
_detectFeaturePath(task) {
|
|
930
|
+
const trimmed = task.trim();
|
|
931
|
+
// Match .agent/docs/<featureId> (absolute or relative)
|
|
932
|
+
const docsMatch = trimmed.match(/(?:^|[/\\])\.agent[/\\]docs[/\\]([^/\\\s]+)/);
|
|
933
|
+
if (docsMatch)
|
|
934
|
+
return docsMatch[1];
|
|
935
|
+
// Match bare directory names that look like feature slugs (contain a hyphen or underscore)
|
|
936
|
+
if (/^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/.test(trimmed) && /[-_]/.test(trimmed)) {
|
|
937
|
+
return trimmed;
|
|
938
|
+
}
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
752
941
|
/** Context for the ORCHESTRATOR only — planning-relevant files */
|
|
753
942
|
async buildOrchestratorContext() {
|
|
754
943
|
let ctx = `Project: ${this.config.project}\nStack: ${this.config.stack}\n`;
|
|
@@ -766,23 +955,49 @@ REGLA: Al terminar, reporta todo lo que encontraste de forma clara y estructurad
|
|
|
766
955
|
return ctx;
|
|
767
956
|
}
|
|
768
957
|
/** Minimal context for COORDINATOR clarification only */
|
|
769
|
-
async buildCoordinatorContext() {
|
|
958
|
+
async buildCoordinatorContext(featureId) {
|
|
770
959
|
let ctx = `Project: ${this.config.project}\nStack: ${this.config.stack}\n`;
|
|
771
960
|
const indexPath = path.join(this.projectDir, '.agent', 'INDEX.md');
|
|
772
961
|
if (await fileExists(indexPath)) {
|
|
773
962
|
const content = await readFile(indexPath);
|
|
774
963
|
ctx += `\n--- INDEX ---\n${content.slice(0, 2000)}\n`;
|
|
775
964
|
}
|
|
965
|
+
if (featureId) {
|
|
966
|
+
const rawDir = path.join(this.projectDir, '.agent', 'docs', featureId, 'raw');
|
|
967
|
+
if (await fileExists(rawDir)) {
|
|
968
|
+
let files = [];
|
|
969
|
+
try {
|
|
970
|
+
files = await listDir(rawDir);
|
|
971
|
+
}
|
|
972
|
+
catch { }
|
|
973
|
+
const mdFiles = files.filter(f => f.endsWith('.md')).sort();
|
|
974
|
+
if (mdFiles.length > 0) {
|
|
975
|
+
ctx += `\n--- FEATURE: ${featureId} ---\n`;
|
|
976
|
+
for (const fname of mdFiles) {
|
|
977
|
+
const fpath = path.join(rawDir, fname);
|
|
978
|
+
const content = await readFile(fpath);
|
|
979
|
+
ctx += `\n=== ${fname} ===\n${content}\n`;
|
|
980
|
+
}
|
|
981
|
+
ctx += `--- END FEATURE ---\n`;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
776
985
|
return ctx;
|
|
777
986
|
}
|
|
778
|
-
async runOrchestrator(task) {
|
|
779
|
-
const taskId = await this.generateTaskId(task);
|
|
987
|
+
async runOrchestrator(task, specPath, featureId) {
|
|
988
|
+
const taskId = await this.generateTaskId(task, featureId);
|
|
780
989
|
const taskDir = path.join(this.projectDir, '.agent', 'tasks', taskId);
|
|
781
990
|
log.phase(1, 'Planificacion', this.config.roles.orchestrator.cli, this.config.roles.orchestrator.model);
|
|
782
991
|
const context = await this.buildOrchestratorContext();
|
|
992
|
+
let specContent = '';
|
|
993
|
+
if (specPath && await fileExists(specPath)) {
|
|
994
|
+
const raw = await readFile(specPath);
|
|
995
|
+
specContent = `\nSPEC.MD (documento de referencia):\n${raw}\n`;
|
|
996
|
+
}
|
|
783
997
|
const prompt = `TAREA: ${task}
|
|
784
998
|
TASK_ID: ${taskId}
|
|
785
999
|
DIRECTORIO_TRABAJO: ${this.projectDir}
|
|
1000
|
+
${specContent}
|
|
786
1001
|
|
|
787
1002
|
CONTEXTO DEL PROYECTO:
|
|
788
1003
|
${context}
|
|
@@ -1621,8 +1836,15 @@ REGLAS DE PATHS:
|
|
|
1621
1836
|
log.warn('Documentacion previa preservada (no se modifico nada).');
|
|
1622
1837
|
return text;
|
|
1623
1838
|
}
|
|
1839
|
+
// If the model returned valid markdown but without === markers (common with some models),
|
|
1840
|
+
// rescue it by wrapping the whole response as architecture.md
|
|
1841
|
+
let effectiveText = text;
|
|
1842
|
+
if (!hasMdMarkers(text)) {
|
|
1843
|
+
log.warn('Explorer devolvio markdown sin marcadores === *.md === — guardando como architecture.md');
|
|
1844
|
+
effectiveText = rescueAsArchitectureMd(text);
|
|
1845
|
+
}
|
|
1624
1846
|
// Parse sections separated by === path/file.md === markers
|
|
1625
|
-
const sections =
|
|
1847
|
+
const sections = effectiveText.split(/===\s+(.+?\.md)\s*===/).slice(1);
|
|
1626
1848
|
// First pass: collect all valid file writes WITHOUT touching disk yet (transactional)
|
|
1627
1849
|
const pendingWrites = [];
|
|
1628
1850
|
for (let i = 0; i < sections.length; i += 2) {
|
|
@@ -1693,7 +1915,42 @@ REGLAS DE PATHS:
|
|
|
1693
1915
|
// ══════════════════════════════════════════════════
|
|
1694
1916
|
// FASE 0 — Clarificacion (Coordinador ↔ Programador)
|
|
1695
1917
|
// ══════════════════════════════════════════════════
|
|
1696
|
-
|
|
1918
|
+
let clarification;
|
|
1919
|
+
// Retry loop — lets the user fix model/provider inline when coordinator fails
|
|
1920
|
+
while (true) {
|
|
1921
|
+
try {
|
|
1922
|
+
clarification = await this.runClarification(task);
|
|
1923
|
+
break;
|
|
1924
|
+
}
|
|
1925
|
+
catch (err) {
|
|
1926
|
+
if (err.message !== 'COORDINATOR_FAILED')
|
|
1927
|
+
throw err;
|
|
1928
|
+
console.log('');
|
|
1929
|
+
console.log(chalk.yellow(' ¿Qué querés hacer?'));
|
|
1930
|
+
console.log(chalk.dim(' /model — cambiar modelo del coordinador'));
|
|
1931
|
+
console.log(chalk.dim(' /provider — cambiar proveedor (Gemini ↔ Qwen)'));
|
|
1932
|
+
console.log(chalk.dim(' r — reintentar con la config actual'));
|
|
1933
|
+
console.log(chalk.dim(' c — cancelar'));
|
|
1934
|
+
console.log('');
|
|
1935
|
+
// Allow slash commands or simple actions here
|
|
1936
|
+
while (true) {
|
|
1937
|
+
const action = await ask(' > ', this.rl, this.fi);
|
|
1938
|
+
const t = action.trim();
|
|
1939
|
+
if (t === 'c' || t === 'cancel')
|
|
1940
|
+
return;
|
|
1941
|
+
if (t === 'r')
|
|
1942
|
+
break; // retry coordinator
|
|
1943
|
+
if (t.startsWith('/') && this.slashHandler) {
|
|
1944
|
+
await this.slashHandler(t);
|
|
1945
|
+
// After slash command, ask again
|
|
1946
|
+
console.log(chalk.dim(' (r = reintentar /model /provider c = cancelar)'));
|
|
1947
|
+
continue;
|
|
1948
|
+
}
|
|
1949
|
+
console.log(chalk.dim(' r = reintentar · /model · /provider · c = cancelar'));
|
|
1950
|
+
}
|
|
1951
|
+
// Retry — loop back to runClarification
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1697
1954
|
// ══════════════════════════════════════════════════
|
|
1698
1955
|
// FASE 1 — Planificacion (Orchestrator)
|
|
1699
1956
|
// ══════════════════════════════════════════════════
|
|
@@ -1702,7 +1959,7 @@ REGLAS DE PATHS:
|
|
|
1702
1959
|
while (true) {
|
|
1703
1960
|
try {
|
|
1704
1961
|
log.section('FASE 1 — Planificacion');
|
|
1705
|
-
const result = await this.runOrchestrator(
|
|
1962
|
+
const result = await this.runOrchestrator(clarification.task, clarification.specPath, clarification.featureId);
|
|
1706
1963
|
taskId = result.taskId;
|
|
1707
1964
|
plan = result.plan;
|
|
1708
1965
|
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
|
+
];
|