git-coco 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,11 +15,11 @@ import now from 'performance-now';
15
15
  import prettyMilliseconds from 'pretty-ms';
16
16
  import pQueue from 'p-queue';
17
17
  import { Document } from 'langchain/document';
18
- import { HuggingFaceInference } from 'langchain/llms/hf';
19
18
  import { loadSummarizationChain, LLMChain } from 'langchain/chains';
20
- import { OpenAI } from 'langchain/llms/openai';
21
19
  import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
22
20
  import { createTwoFilesPatch } from 'diff';
21
+ import { Ollama } from 'langchain/llms/ollama';
22
+ import { OpenAI } from 'langchain/llms/openai';
23
23
  import { minimatch } from 'minimatch';
24
24
  import { simpleGit } from 'simple-git';
25
25
  import { encoding_for_model } from 'tiktoken';
@@ -47,33 +47,34 @@ const SUMMARIZE_PROMPT = new PromptTemplate({
47
47
  inputVariables: inputVariables$2,
48
48
  });
49
49
 
50
+ const DEFAULT_IGNORED_FILES = ['package-lock.json'];
51
+ const DEFAULT_IGNORED_EXTENSIONS = ['.map', '.lock'];
50
52
  /**
51
53
  * Default Config
52
54
  *
53
55
  * @type {Config}
54
56
  */
55
57
  const DEFAULT_CONFIG = {
56
- service: 'openai/gpt-4',
58
+ service: 'openai',
57
59
  verbose: false,
58
60
  tokenLimit: 1024,
59
61
  summarizePrompt: SUMMARIZE_PROMPT.template,
60
62
  temperature: 0.4,
61
63
  mode: 'stdout',
62
- ignoredFiles: ['package-lock.json'],
63
- ignoredExtensions: ['.map', '.lock'],
64
+ ignoredFiles: DEFAULT_IGNORED_FILES,
65
+ ignoredExtensions: DEFAULT_IGNORED_EXTENSIONS,
64
66
  defaultBranch: 'main',
65
67
  };
66
68
  /**
67
69
  * Create a named export of all config keys for use in other modules.
68
70
  *
69
- * @see Currently used in `src/lib/config/services/env.ts` to validate all env vars.
71
+ * @see Used in `src/lib/config/services/env.ts` to validate all env vars.
70
72
  *
71
73
  * @type {string[]}
72
74
  */
73
75
  const CONFIG_KEYS = Object.keys({
74
76
  ...DEFAULT_CONFIG,
75
- huggingFaceHubApiKey: '',
76
- openAIApiKey: '',
77
+ endpoint: '',
77
78
  prompt: '',
78
79
  });
79
80
  const COCO_CONFIG_START_COMMENT = '# -- start coco config --';
@@ -152,8 +153,9 @@ function loadEnvConfig(config) {
152
153
  CONFIG_KEYS.forEach((key) => {
153
154
  const envVarName = toEnvVarName(key);
154
155
  const envValue = parseEnvValue(key, process.env[envVarName]);
155
- if (envValue === undefined)
156
+ if (envValue === undefined) {
156
157
  return;
158
+ }
157
159
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
158
160
  // @ts-ignore
159
161
  envConfig[key] = envValue;
@@ -161,27 +163,28 @@ function loadEnvConfig(config) {
161
163
  return { ...config, ...removeUndefined(envConfig) };
162
164
  }
163
165
  function parseEnvValue(key, value) {
164
- if (value === undefined) {
165
- return undefined;
166
- }
167
- else if (key === 'tokenLimit' && typeof value === 'string') {
168
- return parseInt(value);
169
- }
170
- else if ((key === 'ignoredFiles' || key === 'ignoredExtensions') &&
171
- typeof value === 'string' &&
172
- value.includes(',')) {
173
- return value.split(',');
166
+ switch (true) {
167
+ // Handle undefined values
168
+ case value === undefined:
169
+ return undefined;
170
+ // Handle comma separated strings for ignoredFiles and ignoredExtensions arrays
171
+ case (key === 'ignoredFiles' || key === 'ignoredExtensions') &&
172
+ typeof value === 'string' &&
173
+ value.includes(','):
174
+ return value.split(',');
175
+ // Handle boolean values
176
+ case typeof value === 'string' && (value === 'false' || value === 'true'):
177
+ return value === 'true';
178
+ default:
179
+ return value;
174
180
  }
175
- return value;
176
181
  }
177
182
  function toEnvVarName(key) {
178
183
  switch (key) {
179
184
  case 'openAIApiKey':
180
185
  return 'OPENAI_API_KEY';
181
- case 'huggingFaceHubApiKey':
182
- return 'HUGGINGFACE_HUB_API_KEY';
183
186
  default:
184
- return 'COCO_' + key.replace(/([A-Z])/g, '_$1').toUpperCase();
187
+ return `COCO_${key.replace(/([A-Z])/g, '_$1').toLocaleUpperCase()}`;
185
188
  }
186
189
  }
187
190
  function formatEnvValue(value) {
@@ -225,13 +228,19 @@ function loadGitConfig(config) {
225
228
  const gitConfigParsed = ini.parse(gitConfigRaw);
226
229
  config = {
227
230
  ...config,
228
- service: gitConfigParsed.coco?.model || config.service,
229
- openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config.openAIApiKey,
230
- huggingFaceHubApiKey: gitConfigParsed.coco?.huggingFaceHubApiKey || config.huggingFaceHubApiKey,
231
- tokenLimit: parseInt(gitConfigParsed.coco?.tokenLimit) || config.tokenLimit,
231
+ service: gitConfigParsed.coco?.service || config.service,
232
+ ...(config.service === 'ollama'
233
+ ? {
234
+ endpoint: gitConfigParsed.coco?.endpoint || config?.endpoint,
235
+ }
236
+ : {
237
+ openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config?.openAIApiKey,
238
+ }),
239
+ model: gitConfigParsed.coco?.model || config?.model,
240
+ temperature: gitConfigParsed.coco?.temperature || config?.temperature,
241
+ tokenLimit: gitConfigParsed.coco?.tokenLimit || config?.tokenLimit,
232
242
  prompt: gitConfigParsed.coco?.prompt || config.prompt,
233
243
  mode: gitConfigParsed.coco?.mode || config.mode,
234
- temperature: gitConfigParsed.coco?.temperature || config.temperature,
235
244
  summarizePrompt: gitConfigParsed.coco?.summarizePrompt || config.summarizePrompt,
236
245
  ignoredFiles: gitConfigParsed.coco?.ignoredFiles || config.ignoredFiles,
237
246
  ignoredExtensions: gitConfigParsed.coco?.ignoredExtensions || config.ignoredExtensions,
@@ -661,64 +670,16 @@ async function collectDiffs(node, getFileDiff, tokenizer, logger) {
661
670
  };
662
671
  }
663
672
 
664
- function getModelAndProviderFromService(service) {
665
- if (!service) {
666
- throw new Error(`Missing service`);
667
- }
668
- const [provider, model] = service.split(/\/(.*)/s);
669
- if (!model || !provider) {
670
- throw new Error(`Invalid service: ${service}`);
671
- }
672
- return { provider, model };
673
- }
674
- function getModelFromService(service) {
675
- const { model } = getModelAndProviderFromService(service);
676
- return model;
677
- }
678
673
  /**
679
- * Get LLM Model Based on Configuration
680
- * @param fields
681
- * @param configuration
682
- * @returns LLM Model
683
- */
684
- function getLlm(service, key, fields) {
685
- const { provider, model } = getModelAndProviderFromService(service);
686
- if (!model) {
687
- throw new Error(`Invalid LLM Service: ${service}`);
688
- }
689
- switch (provider) {
690
- case 'huggingface':
691
- return new HuggingFaceInference({
692
- model: model,
693
- apiKey: key,
694
- maxConcurrency: 4,
695
- ...fields,
696
- });
697
- case 'openai':
698
- default:
699
- return new OpenAI({
700
- openAIApiKey: key,
701
- modelName: model,
702
- ...fields,
703
- });
704
- }
705
- }
706
- /**
707
- * Retrieve appropriate API key based on selected model
708
- * @param service
674
+ * Get Summarization Chain
675
+ * @param model
709
676
  * @param options
710
677
  * @returns
711
678
  */
712
- function getApiKeyForModel(service, options) {
713
- const { provider } = getModelAndProviderFromService(service);
714
- switch (provider) {
715
- case 'huggingface':
716
- return options.huggingFaceHubApiKey;
717
- case 'openai':
718
- default:
719
- return options.openAIApiKey;
720
- }
679
+ function getSummarizationChain(model, options = { type: 'map_reduce' }) {
680
+ return loadSummarizationChain(model, options);
721
681
  }
682
+
722
683
  /**
723
684
  * Get Recursive Character Text Splitter
724
685
  * @param options
@@ -727,41 +688,6 @@ function getApiKeyForModel(service, options) {
727
688
  function getTextSplitter(options = {}) {
728
689
  return new RecursiveCharacterTextSplitter(options);
729
690
  }
730
- /**
731
- * Get Summarization Chain
732
- * @param model
733
- * @param options
734
- * @returns
735
- */
736
- function getSummarizationChain(model, options = { type: 'map_reduce' }) {
737
- return loadSummarizationChain(model, options);
738
- }
739
- function getPrompt({ template, variables, fallback }) {
740
- if (!template && !fallback)
741
- throw new Error('Must provide either a template or a fallback');
742
- return (template
743
- ? new PromptTemplate({
744
- template,
745
- inputVariables: variables,
746
- })
747
- : fallback);
748
- }
749
- /**
750
- * Verify template string contains all required input variables
751
- * @param text template string
752
- * @param inputVariables template variables
753
- * @returns boolean or error message
754
- */
755
- function validatePromptTemplate(text, inputVariables) {
756
- if (!text) {
757
- return 'Prompt template cannot be empty';
758
- }
759
- if (!inputVariables.some((entry) => text.includes(entry))) {
760
- return ('Prompt template must include at least one of the following input variables: ' +
761
- inputVariables.map((value) => `{${value}}`).join(', '));
762
- }
763
- return true;
764
- }
765
691
 
766
692
  async function parseDefaultFileDiff(nodeFile, commit = '--staged', git) {
767
693
  if (commit !== '--staged') {
@@ -849,14 +775,14 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
849
775
  }
850
776
 
851
777
  const template$1 = `Write informative git commit message, in the imperative, based on the diffs & file changes provided in the "Diff Summary" section.
852
- Commit Messages must have a short description that is less than 50 characters followed by a newline character and then a more verbose detailed description.
778
+ Commit Messages must have a short description that is less than 50 characters and a longer detailed summary no more than 300 characters, the shorter and more concise the better. The detailed summary should be separated from the short description by a blank line. Please follow the guidelines below when writing your commit message:
853
779
 
854
- - Typically a hyphen or asterisk is used for the bullet
855
780
  - Write concisely using an informal tone
856
781
  - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
857
- - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
858
782
  - DO NOT use specific names or files from the code
783
+ - DO NOT include any diffs or file changes in the commit message
859
784
  - Wrap variable, class, function, components, and dependency names in back ticks e.g. \`variable\`
785
+ - ONLY respond with the resulting commit message.
860
786
 
861
787
  """{summary}"""
862
788
 
@@ -867,6 +793,143 @@ const COMMIT_PROMPT = new PromptTemplate({
867
793
  inputVariables: inputVariables$1,
868
794
  });
869
795
 
796
+ const DEFAULT_OLLAMA_LLM_SERVICE = {
797
+ provider: 'ollama',
798
+ model: 'codellama',
799
+ endpoint: 'http://localhost:11434',
800
+ maxConcurrent: 1,
801
+ tokenLimit: 1024,
802
+ };
803
+ const DEFAULT_OPENAI_LLM_SERVICE = {
804
+ provider: 'openai',
805
+ model: 'gpt-4',
806
+ authentication: {
807
+ type: 'APIKey',
808
+ credentials: {
809
+ apiKey: '',
810
+ },
811
+ },
812
+ tokenLimit: 1024,
813
+ };
814
+
815
+ /**
816
+ * Retrieves the provider and model from the given configuration object.
817
+ * @param config The configuration object.
818
+ * @returns An object containing the provider and model.
819
+ * @throws Error if the configuration is invalid or missing required properties.
820
+ */
821
+ function getModelAndProviderFromConfig(config) {
822
+ if (!config.service) {
823
+ throw new Error('Invalid service: undefined');
824
+ }
825
+ let result;
826
+ switch (typeof config.service) {
827
+ case 'string':
828
+ result = getDefaultServiceConfigFromAlias(config.service, config?.model);
829
+ break;
830
+ case 'object':
831
+ default:
832
+ result = config.service;
833
+ break;
834
+ }
835
+ const { provider, model } = result;
836
+ if (!model || !provider) {
837
+ throw new Error(`Invalid service: ${config.service}`);
838
+ }
839
+ return { provider, model };
840
+ }
841
+ /**
842
+ * Retrieve appropriate API key based on selected model
843
+ * @param service
844
+ * @param options
845
+ * @returns API Key
846
+ */
847
+ function getApiKeyForModel(config) {
848
+ const { provider } = getModelAndProviderFromConfig(config);
849
+ switch (provider) {
850
+ case 'openai':
851
+ return config.openAIApiKey || getDefaultServiceApiKey(config);
852
+ default:
853
+ return getDefaultServiceApiKey(config);
854
+ }
855
+ }
856
+ /**
857
+ * Retrieves the default service API key from the given configuration.
858
+ * @param config The configuration object.
859
+ * @returns The default service API key.
860
+ */
861
+ function getDefaultServiceApiKey(config) {
862
+ const service = config.service;
863
+ if (service.authentication.type === 'APIKey') {
864
+ return service.authentication.credentials?.apiKey;
865
+ }
866
+ else if (service.authentication.type === 'OAuth') {
867
+ return service.authentication.credentials?.token;
868
+ }
869
+ return '';
870
+ }
871
+ /**
872
+ * Retrieves the default service configuration based on the provided alias and optional model.
873
+ * @param alias - The alias of the service.
874
+ * @param model - The optional model to be used.
875
+ * @returns The default service configuration.
876
+ * @throws Error if the alias is invalid or undefined.
877
+ */
878
+ function getDefaultServiceConfigFromAlias(alias, model) {
879
+ if (!alias) {
880
+ throw new Error('Invalid alias: undefined');
881
+ }
882
+ switch (alias) {
883
+ case 'openai':
884
+ return {
885
+ ...DEFAULT_OPENAI_LLM_SERVICE,
886
+ model: model || DEFAULT_OPENAI_LLM_SERVICE.model,
887
+ };
888
+ case 'ollama':
889
+ return {
890
+ ...DEFAULT_OLLAMA_LLM_SERVICE,
891
+ model: model || DEFAULT_OLLAMA_LLM_SERVICE.model,
892
+ };
893
+ }
894
+ }
895
+
896
+ /**
897
+ * Get LLM Model Based on Configuration
898
+ *
899
+ * @param fields
900
+ * @param configuration
901
+ * @returns LLM Model
902
+ */
903
+ function getLlm(provider, model, config) {
904
+ if (!model) {
905
+ throw new Error(`Invalid LLM Service: ${provider}/${model}`);
906
+ }
907
+ switch (provider) {
908
+ case 'ollama':
909
+ return new Ollama({
910
+ baseUrl: DEFAULT_OLLAMA_LLM_SERVICE.endpoint,
911
+ model,
912
+ });
913
+ case 'openai':
914
+ default:
915
+ return new OpenAI({
916
+ openAIApiKey: config.openAIApiKey,
917
+ modelName: model,
918
+ });
919
+ }
920
+ }
921
+
922
+ function getPrompt({ template, variables, fallback }) {
923
+ if (!template && !fallback)
924
+ throw new Error('Must provide either a template or a fallback');
925
+ return (template
926
+ ? new PromptTemplate({
927
+ template,
928
+ inputVariables: variables,
929
+ })
930
+ : fallback);
931
+ }
932
+
870
933
  function getStatus(file, location = 'index') {
871
934
  if ('index' in file && 'working_dir' in file) {
872
935
  const statusCode = file[location];
@@ -921,9 +984,6 @@ function getSummaryText(file, change) {
921
984
  return `${status}: ${filePath}`;
922
985
  }
923
986
 
924
- const config = loadConfig();
925
- const DEFAULT_IGNORED_FILES = config?.ignoredFiles?.length ? config.ignoredFiles : [];
926
- const DEFAULT_IGNORED_EXTENSIONS = config?.ignoredExtensions?.length ? config.ignoredExtensions : [];
927
987
  async function getChanges({ git, options }) {
928
988
  const { ignoredFiles = DEFAULT_IGNORED_FILES, ignoredExtensions = DEFAULT_IGNORED_EXTENSIONS } = options || {};
929
989
  const staged = [];
@@ -989,13 +1049,13 @@ async function noResult({ git, logger }) {
989
1049
  else if (hasUnstaged || hasUntracked) {
990
1050
  logger.log('Forget something? No staged changes found... 👻', { color: 'red' });
991
1051
  if (hasUnstaged) {
992
- logger.log('\nDetected unstaged changes', { color: 'yellow' });
1052
+ logger.log('\nChanges not staged for commit:', { color: 'yellow' });
993
1053
  logger.verbose(`\t${unstaged.map(({ summary }) => summary).join('\n\t')}`, {
994
1054
  color: 'red',
995
1055
  });
996
1056
  }
997
1057
  if (hasUntracked) {
998
- logger.log('\nDetected untracked changes', { color: 'yellow' });
1058
+ logger.log('\nUntracked changes:', { color: 'yellow' });
999
1059
  logger.verbose(`\t${untracked.map(({ summary }) => summary).join('\n\t')}`, {
1000
1060
  color: 'red',
1001
1061
  });
@@ -1069,6 +1129,23 @@ async function getUserReviewDecision({ label, descriptions, enableRetry = true,
1069
1129
  }));
1070
1130
  }
1071
1131
 
1132
+ /**
1133
+ * Verify template string contains all required input variables
1134
+ * @param text template string
1135
+ * @param inputVariables template variables
1136
+ * @returns boolean or error message
1137
+ */
1138
+ function validatePromptTemplate(text, inputVariables) {
1139
+ if (!text) {
1140
+ return 'Prompt template cannot be empty';
1141
+ }
1142
+ if (!inputVariables.some((entry) => text.includes(entry))) {
1143
+ return ('Prompt template must include at least one of the following input variables: ' +
1144
+ inputVariables.map((value) => `{${value}}`).join(', '));
1145
+ }
1146
+ return true;
1147
+ }
1148
+
1072
1149
  async function editPrompt(options) {
1073
1150
  return await editor({
1074
1151
  message: 'Edit the prompt',
@@ -1214,12 +1291,14 @@ const getRepo = () => {
1214
1291
  const getTikToken = async (modelName) => {
1215
1292
  return await encoding_for_model(modelName);
1216
1293
  };
1217
- const getTokenCounter = async (modelName) => getTikToken(modelName).then((tokenizer) => (text) => {
1218
- // console.log('Running GetTokenCount', { tokenizer, length: text.length })
1219
- const tokens = tokenizer.encode(text);
1220
- // console.log('Tokens', { tokenCount: tokens.length })
1221
- return tokens.length;
1222
- });
1294
+ const getTokenCounter = async (modelName) => {
1295
+ return getTikToken(modelName).then((tokenizer) => (text) => {
1296
+ // console.log('Running GetTokenCount', { tokenizer, length: text.length })
1297
+ const tokens = tokenizer.encode(text);
1298
+ // console.log('Tokens', { tokenCount: tokens.length })
1299
+ return tokens.length;
1300
+ });
1301
+ };
1223
1302
 
1224
1303
  async function createCommit(commitMsg, git) {
1225
1304
  return await git.commit(commitMsg);
@@ -1228,17 +1307,14 @@ async function createCommit(commitMsg, git) {
1228
1307
  const handler$2 = async (argv, logger) => {
1229
1308
  const git = getRepo();
1230
1309
  const options = loadConfig(argv);
1231
- const { service } = options;
1232
- const key = getApiKeyForModel(service, options);
1233
- const tokenizer = await getTokenCounter(getModelFromService(service));
1310
+ const key = getApiKeyForModel(options);
1234
1311
  if (!key) {
1235
1312
  logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
1236
1313
  process.exit(1);
1237
1314
  }
1238
- const llm = getLlm(service, key, {
1239
- temperature: 0.4,
1240
- maxConcurrency: 10,
1241
- });
1315
+ const { provider, model } = getModelAndProviderFromConfig(options);
1316
+ const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4');
1317
+ const llm = getLlm(provider, model, options);
1242
1318
  const INTERACTIVE = isInteractive(options);
1243
1319
  if (INTERACTIVE) {
1244
1320
  logger.log(LOGO);
@@ -1304,16 +1380,10 @@ const handler$2 = async (argv, logger) => {
1304
1380
  * Command line options via yargs
1305
1381
  */
1306
1382
  const options$2 = {
1307
- model: { type: 'string', description: 'LLM/Model-Name' },
1383
+ service: { type: 'string', description: 'LLM/Model-Name', choices: ['openai', 'ollama'] },
1308
1384
  openAIApiKey: {
1309
1385
  type: 'string',
1310
1386
  description: 'OpenAI API Key',
1311
- conflicts: 'huggingFaceHubApiKey',
1312
- },
1313
- huggingFaceHubApiKey: {
1314
- type: 'string',
1315
- description: 'HuggingFace Hub API Key',
1316
- conflicts: 'openAIApiKey',
1317
1387
  },
1318
1388
  tokenLimit: { type: 'number', description: 'Token limit' },
1319
1389
  prompt: {
@@ -1437,15 +1507,13 @@ async function getCommitLogCurrentBranch({ git, logger, comparisonBranch = 'main
1437
1507
  const handler$1 = async (argv, logger) => {
1438
1508
  const options = loadConfig(argv);
1439
1509
  const git = getRepo();
1440
- const key = getApiKeyForModel(options.service, options);
1510
+ const key = getApiKeyForModel(options);
1441
1511
  if (!key) {
1442
1512
  logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
1443
1513
  process.exit(1);
1444
1514
  }
1445
- const model = getLlm(options.service, key, {
1446
- temperature: 0.4,
1447
- maxConcurrency: 10,
1448
- });
1515
+ const { provider, model } = getModelAndProviderFromConfig(options);
1516
+ const llm = getLlm(provider, model, options);
1449
1517
  const INTERACTIVE = isInteractive(options);
1450
1518
  if (INTERACTIVE) {
1451
1519
  logger.log(LOGO);
@@ -1477,7 +1545,7 @@ const handler$1 = async (argv, logger) => {
1477
1545
  fallback: CHANGELOG_PROMPT,
1478
1546
  });
1479
1547
  return await executeChain({
1480
- llm: model,
1548
+ llm,
1481
1549
  prompt,
1482
1550
  variables: { summary: context },
1483
1551
  });