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.
package/dist/index.js CHANGED
@@ -14,11 +14,11 @@ var now = require('performance-now');
14
14
  var prettyMilliseconds = require('pretty-ms');
15
15
  var pQueue = require('p-queue');
16
16
  var document = require('langchain/document');
17
- var hf = require('langchain/llms/hf');
18
17
  var chains = require('langchain/chains');
19
- var openai = require('langchain/llms/openai');
20
18
  var text_splitter = require('langchain/text_splitter');
21
19
  var diff = require('diff');
20
+ var ollama = require('langchain/llms/ollama');
21
+ var openai = require('langchain/llms/openai');
22
22
  var minimatch = require('minimatch');
23
23
  var simpleGit = require('simple-git');
24
24
  var tiktoken = require('tiktoken');
@@ -68,33 +68,34 @@ const SUMMARIZE_PROMPT = new prompts.PromptTemplate({
68
68
  inputVariables: inputVariables$2,
69
69
  });
70
70
 
71
+ const DEFAULT_IGNORED_FILES = ['package-lock.json'];
72
+ const DEFAULT_IGNORED_EXTENSIONS = ['.map', '.lock'];
71
73
  /**
72
74
  * Default Config
73
75
  *
74
76
  * @type {Config}
75
77
  */
76
78
  const DEFAULT_CONFIG = {
77
- service: 'openai/gpt-4',
79
+ service: 'openai',
78
80
  verbose: false,
79
81
  tokenLimit: 1024,
80
82
  summarizePrompt: SUMMARIZE_PROMPT.template,
81
83
  temperature: 0.4,
82
84
  mode: 'stdout',
83
- ignoredFiles: ['package-lock.json'],
84
- ignoredExtensions: ['.map', '.lock'],
85
+ ignoredFiles: DEFAULT_IGNORED_FILES,
86
+ ignoredExtensions: DEFAULT_IGNORED_EXTENSIONS,
85
87
  defaultBranch: 'main',
86
88
  };
87
89
  /**
88
90
  * Create a named export of all config keys for use in other modules.
89
91
  *
90
- * @see Currently used in `src/lib/config/services/env.ts` to validate all env vars.
92
+ * @see Used in `src/lib/config/services/env.ts` to validate all env vars.
91
93
  *
92
94
  * @type {string[]}
93
95
  */
94
96
  const CONFIG_KEYS = Object.keys({
95
97
  ...DEFAULT_CONFIG,
96
- huggingFaceHubApiKey: '',
97
- openAIApiKey: '',
98
+ endpoint: '',
98
99
  prompt: '',
99
100
  });
100
101
  const COCO_CONFIG_START_COMMENT = '# -- start coco config --';
@@ -173,8 +174,9 @@ function loadEnvConfig(config) {
173
174
  CONFIG_KEYS.forEach((key) => {
174
175
  const envVarName = toEnvVarName(key);
175
176
  const envValue = parseEnvValue(key, process.env[envVarName]);
176
- if (envValue === undefined)
177
+ if (envValue === undefined) {
177
178
  return;
179
+ }
178
180
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
179
181
  // @ts-ignore
180
182
  envConfig[key] = envValue;
@@ -182,27 +184,28 @@ function loadEnvConfig(config) {
182
184
  return { ...config, ...removeUndefined(envConfig) };
183
185
  }
184
186
  function parseEnvValue(key, value) {
185
- if (value === undefined) {
186
- return undefined;
187
- }
188
- else if (key === 'tokenLimit' && typeof value === 'string') {
189
- return parseInt(value);
190
- }
191
- else if ((key === 'ignoredFiles' || key === 'ignoredExtensions') &&
192
- typeof value === 'string' &&
193
- value.includes(',')) {
194
- return value.split(',');
187
+ switch (true) {
188
+ // Handle undefined values
189
+ case value === undefined:
190
+ return undefined;
191
+ // Handle comma separated strings for ignoredFiles and ignoredExtensions arrays
192
+ case (key === 'ignoredFiles' || key === 'ignoredExtensions') &&
193
+ typeof value === 'string' &&
194
+ value.includes(','):
195
+ return value.split(',');
196
+ // Handle boolean values
197
+ case typeof value === 'string' && (value === 'false' || value === 'true'):
198
+ return value === 'true';
199
+ default:
200
+ return value;
195
201
  }
196
- return value;
197
202
  }
198
203
  function toEnvVarName(key) {
199
204
  switch (key) {
200
205
  case 'openAIApiKey':
201
206
  return 'OPENAI_API_KEY';
202
- case 'huggingFaceHubApiKey':
203
- return 'HUGGINGFACE_HUB_API_KEY';
204
207
  default:
205
- return 'COCO_' + key.replace(/([A-Z])/g, '_$1').toUpperCase();
208
+ return `COCO_${key.replace(/([A-Z])/g, '_$1').toLocaleUpperCase()}`;
206
209
  }
207
210
  }
208
211
  function formatEnvValue(value) {
@@ -246,13 +249,19 @@ function loadGitConfig(config) {
246
249
  const gitConfigParsed = ini__namespace.parse(gitConfigRaw);
247
250
  config = {
248
251
  ...config,
249
- service: gitConfigParsed.coco?.model || config.service,
250
- openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config.openAIApiKey,
251
- huggingFaceHubApiKey: gitConfigParsed.coco?.huggingFaceHubApiKey || config.huggingFaceHubApiKey,
252
- tokenLimit: parseInt(gitConfigParsed.coco?.tokenLimit) || config.tokenLimit,
252
+ service: gitConfigParsed.coco?.service || config.service,
253
+ ...(config.service === 'ollama'
254
+ ? {
255
+ endpoint: gitConfigParsed.coco?.endpoint || config?.endpoint,
256
+ }
257
+ : {
258
+ openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config?.openAIApiKey,
259
+ }),
260
+ model: gitConfigParsed.coco?.model || config?.model,
261
+ temperature: gitConfigParsed.coco?.temperature || config?.temperature,
262
+ tokenLimit: gitConfigParsed.coco?.tokenLimit || config?.tokenLimit,
253
263
  prompt: gitConfigParsed.coco?.prompt || config.prompt,
254
264
  mode: gitConfigParsed.coco?.mode || config.mode,
255
- temperature: gitConfigParsed.coco?.temperature || config.temperature,
256
265
  summarizePrompt: gitConfigParsed.coco?.summarizePrompt || config.summarizePrompt,
257
266
  ignoredFiles: gitConfigParsed.coco?.ignoredFiles || config.ignoredFiles,
258
267
  ignoredExtensions: gitConfigParsed.coco?.ignoredExtensions || config.ignoredExtensions,
@@ -682,64 +691,16 @@ async function collectDiffs(node, getFileDiff, tokenizer, logger) {
682
691
  };
683
692
  }
684
693
 
685
- function getModelAndProviderFromService(service) {
686
- if (!service) {
687
- throw new Error(`Missing service`);
688
- }
689
- const [provider, model] = service.split(/\/(.*)/s);
690
- if (!model || !provider) {
691
- throw new Error(`Invalid service: ${service}`);
692
- }
693
- return { provider, model };
694
- }
695
- function getModelFromService(service) {
696
- const { model } = getModelAndProviderFromService(service);
697
- return model;
698
- }
699
694
  /**
700
- * Get LLM Model Based on Configuration
701
- * @param fields
702
- * @param configuration
703
- * @returns LLM Model
704
- */
705
- function getLlm(service, key, fields) {
706
- const { provider, model } = getModelAndProviderFromService(service);
707
- if (!model) {
708
- throw new Error(`Invalid LLM Service: ${service}`);
709
- }
710
- switch (provider) {
711
- case 'huggingface':
712
- return new hf.HuggingFaceInference({
713
- model: model,
714
- apiKey: key,
715
- maxConcurrency: 4,
716
- ...fields,
717
- });
718
- case 'openai':
719
- default:
720
- return new openai.OpenAI({
721
- openAIApiKey: key,
722
- modelName: model,
723
- ...fields,
724
- });
725
- }
726
- }
727
- /**
728
- * Retrieve appropriate API key based on selected model
729
- * @param service
695
+ * Get Summarization Chain
696
+ * @param model
730
697
  * @param options
731
698
  * @returns
732
699
  */
733
- function getApiKeyForModel(service, options) {
734
- const { provider } = getModelAndProviderFromService(service);
735
- switch (provider) {
736
- case 'huggingface':
737
- return options.huggingFaceHubApiKey;
738
- case 'openai':
739
- default:
740
- return options.openAIApiKey;
741
- }
700
+ function getSummarizationChain(model, options = { type: 'map_reduce' }) {
701
+ return chains.loadSummarizationChain(model, options);
742
702
  }
703
+
743
704
  /**
744
705
  * Get Recursive Character Text Splitter
745
706
  * @param options
@@ -748,41 +709,6 @@ function getApiKeyForModel(service, options) {
748
709
  function getTextSplitter(options = {}) {
749
710
  return new text_splitter.RecursiveCharacterTextSplitter(options);
750
711
  }
751
- /**
752
- * Get Summarization Chain
753
- * @param model
754
- * @param options
755
- * @returns
756
- */
757
- function getSummarizationChain(model, options = { type: 'map_reduce' }) {
758
- return chains.loadSummarizationChain(model, options);
759
- }
760
- function getPrompt({ template, variables, fallback }) {
761
- if (!template && !fallback)
762
- throw new Error('Must provide either a template or a fallback');
763
- return (template
764
- ? new prompts.PromptTemplate({
765
- template,
766
- inputVariables: variables,
767
- })
768
- : fallback);
769
- }
770
- /**
771
- * Verify template string contains all required input variables
772
- * @param text template string
773
- * @param inputVariables template variables
774
- * @returns boolean or error message
775
- */
776
- function validatePromptTemplate(text, inputVariables) {
777
- if (!text) {
778
- return 'Prompt template cannot be empty';
779
- }
780
- if (!inputVariables.some((entry) => text.includes(entry))) {
781
- return ('Prompt template must include at least one of the following input variables: ' +
782
- inputVariables.map((value) => `{${value}}`).join(', '));
783
- }
784
- return true;
785
- }
786
712
 
787
713
  async function parseDefaultFileDiff(nodeFile, commit = '--staged', git) {
788
714
  if (commit !== '--staged') {
@@ -870,14 +796,14 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
870
796
  }
871
797
 
872
798
  const template$1 = `Write informative git commit message, in the imperative, based on the diffs & file changes provided in the "Diff Summary" section.
873
- 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.
799
+ 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:
874
800
 
875
- - Typically a hyphen or asterisk is used for the bullet
876
801
  - Write concisely using an informal tone
877
802
  - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
878
- - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
879
803
  - DO NOT use specific names or files from the code
804
+ - DO NOT include any diffs or file changes in the commit message
880
805
  - Wrap variable, class, function, components, and dependency names in back ticks e.g. \`variable\`
806
+ - ONLY respond with the resulting commit message.
881
807
 
882
808
  """{summary}"""
883
809
 
@@ -888,6 +814,143 @@ const COMMIT_PROMPT = new prompts.PromptTemplate({
888
814
  inputVariables: inputVariables$1,
889
815
  });
890
816
 
817
+ const DEFAULT_OLLAMA_LLM_SERVICE = {
818
+ provider: 'ollama',
819
+ model: 'codellama',
820
+ endpoint: 'http://localhost:11434',
821
+ maxConcurrent: 1,
822
+ tokenLimit: 1024,
823
+ };
824
+ const DEFAULT_OPENAI_LLM_SERVICE = {
825
+ provider: 'openai',
826
+ model: 'gpt-4',
827
+ authentication: {
828
+ type: 'APIKey',
829
+ credentials: {
830
+ apiKey: '',
831
+ },
832
+ },
833
+ tokenLimit: 1024,
834
+ };
835
+
836
+ /**
837
+ * Retrieves the provider and model from the given configuration object.
838
+ * @param config The configuration object.
839
+ * @returns An object containing the provider and model.
840
+ * @throws Error if the configuration is invalid or missing required properties.
841
+ */
842
+ function getModelAndProviderFromConfig(config) {
843
+ if (!config.service) {
844
+ throw new Error('Invalid service: undefined');
845
+ }
846
+ let result;
847
+ switch (typeof config.service) {
848
+ case 'string':
849
+ result = getDefaultServiceConfigFromAlias(config.service, config?.model);
850
+ break;
851
+ case 'object':
852
+ default:
853
+ result = config.service;
854
+ break;
855
+ }
856
+ const { provider, model } = result;
857
+ if (!model || !provider) {
858
+ throw new Error(`Invalid service: ${config.service}`);
859
+ }
860
+ return { provider, model };
861
+ }
862
+ /**
863
+ * Retrieve appropriate API key based on selected model
864
+ * @param service
865
+ * @param options
866
+ * @returns API Key
867
+ */
868
+ function getApiKeyForModel(config) {
869
+ const { provider } = getModelAndProviderFromConfig(config);
870
+ switch (provider) {
871
+ case 'openai':
872
+ return config.openAIApiKey || getDefaultServiceApiKey(config);
873
+ default:
874
+ return getDefaultServiceApiKey(config);
875
+ }
876
+ }
877
+ /**
878
+ * Retrieves the default service API key from the given configuration.
879
+ * @param config The configuration object.
880
+ * @returns The default service API key.
881
+ */
882
+ function getDefaultServiceApiKey(config) {
883
+ const service = config.service;
884
+ if (service.authentication.type === 'APIKey') {
885
+ return service.authentication.credentials?.apiKey;
886
+ }
887
+ else if (service.authentication.type === 'OAuth') {
888
+ return service.authentication.credentials?.token;
889
+ }
890
+ return '';
891
+ }
892
+ /**
893
+ * Retrieves the default service configuration based on the provided alias and optional model.
894
+ * @param alias - The alias of the service.
895
+ * @param model - The optional model to be used.
896
+ * @returns The default service configuration.
897
+ * @throws Error if the alias is invalid or undefined.
898
+ */
899
+ function getDefaultServiceConfigFromAlias(alias, model) {
900
+ if (!alias) {
901
+ throw new Error('Invalid alias: undefined');
902
+ }
903
+ switch (alias) {
904
+ case 'openai':
905
+ return {
906
+ ...DEFAULT_OPENAI_LLM_SERVICE,
907
+ model: model || DEFAULT_OPENAI_LLM_SERVICE.model,
908
+ };
909
+ case 'ollama':
910
+ return {
911
+ ...DEFAULT_OLLAMA_LLM_SERVICE,
912
+ model: model || DEFAULT_OLLAMA_LLM_SERVICE.model,
913
+ };
914
+ }
915
+ }
916
+
917
+ /**
918
+ * Get LLM Model Based on Configuration
919
+ *
920
+ * @param fields
921
+ * @param configuration
922
+ * @returns LLM Model
923
+ */
924
+ function getLlm(provider, model, config) {
925
+ if (!model) {
926
+ throw new Error(`Invalid LLM Service: ${provider}/${model}`);
927
+ }
928
+ switch (provider) {
929
+ case 'ollama':
930
+ return new ollama.Ollama({
931
+ baseUrl: DEFAULT_OLLAMA_LLM_SERVICE.endpoint,
932
+ model,
933
+ });
934
+ case 'openai':
935
+ default:
936
+ return new openai.OpenAI({
937
+ openAIApiKey: config.openAIApiKey,
938
+ modelName: model,
939
+ });
940
+ }
941
+ }
942
+
943
+ function getPrompt({ template, variables, fallback }) {
944
+ if (!template && !fallback)
945
+ throw new Error('Must provide either a template or a fallback');
946
+ return (template
947
+ ? new prompts.PromptTemplate({
948
+ template,
949
+ inputVariables: variables,
950
+ })
951
+ : fallback);
952
+ }
953
+
891
954
  function getStatus(file, location = 'index') {
892
955
  if ('index' in file && 'working_dir' in file) {
893
956
  const statusCode = file[location];
@@ -942,9 +1005,6 @@ function getSummaryText(file, change) {
942
1005
  return `${status}: ${filePath}`;
943
1006
  }
944
1007
 
945
- const config = loadConfig();
946
- const DEFAULT_IGNORED_FILES = config?.ignoredFiles?.length ? config.ignoredFiles : [];
947
- const DEFAULT_IGNORED_EXTENSIONS = config?.ignoredExtensions?.length ? config.ignoredExtensions : [];
948
1008
  async function getChanges({ git, options }) {
949
1009
  const { ignoredFiles = DEFAULT_IGNORED_FILES, ignoredExtensions = DEFAULT_IGNORED_EXTENSIONS } = options || {};
950
1010
  const staged = [];
@@ -1010,13 +1070,13 @@ async function noResult({ git, logger }) {
1010
1070
  else if (hasUnstaged || hasUntracked) {
1011
1071
  logger.log('Forget something? No staged changes found... 👻', { color: 'red' });
1012
1072
  if (hasUnstaged) {
1013
- logger.log('\nDetected unstaged changes', { color: 'yellow' });
1073
+ logger.log('\nChanges not staged for commit:', { color: 'yellow' });
1014
1074
  logger.verbose(`\t${unstaged.map(({ summary }) => summary).join('\n\t')}`, {
1015
1075
  color: 'red',
1016
1076
  });
1017
1077
  }
1018
1078
  if (hasUntracked) {
1019
- logger.log('\nDetected untracked changes', { color: 'yellow' });
1079
+ logger.log('\nUntracked changes:', { color: 'yellow' });
1020
1080
  logger.verbose(`\t${untracked.map(({ summary }) => summary).join('\n\t')}`, {
1021
1081
  color: 'red',
1022
1082
  });
@@ -1090,6 +1150,23 @@ async function getUserReviewDecision({ label, descriptions, enableRetry = true,
1090
1150
  }));
1091
1151
  }
1092
1152
 
1153
+ /**
1154
+ * Verify template string contains all required input variables
1155
+ * @param text template string
1156
+ * @param inputVariables template variables
1157
+ * @returns boolean or error message
1158
+ */
1159
+ function validatePromptTemplate(text, inputVariables) {
1160
+ if (!text) {
1161
+ return 'Prompt template cannot be empty';
1162
+ }
1163
+ if (!inputVariables.some((entry) => text.includes(entry))) {
1164
+ return ('Prompt template must include at least one of the following input variables: ' +
1165
+ inputVariables.map((value) => `{${value}}`).join(', '));
1166
+ }
1167
+ return true;
1168
+ }
1169
+
1093
1170
  async function editPrompt(options) {
1094
1171
  return await prompts$1.editor({
1095
1172
  message: 'Edit the prompt',
@@ -1235,12 +1312,14 @@ const getRepo = () => {
1235
1312
  const getTikToken = async (modelName) => {
1236
1313
  return await tiktoken.encoding_for_model(modelName);
1237
1314
  };
1238
- const getTokenCounter = async (modelName) => getTikToken(modelName).then((tokenizer) => (text) => {
1239
- // console.log('Running GetTokenCount', { tokenizer, length: text.length })
1240
- const tokens = tokenizer.encode(text);
1241
- // console.log('Tokens', { tokenCount: tokens.length })
1242
- return tokens.length;
1243
- });
1315
+ const getTokenCounter = async (modelName) => {
1316
+ return getTikToken(modelName).then((tokenizer) => (text) => {
1317
+ // console.log('Running GetTokenCount', { tokenizer, length: text.length })
1318
+ const tokens = tokenizer.encode(text);
1319
+ // console.log('Tokens', { tokenCount: tokens.length })
1320
+ return tokens.length;
1321
+ });
1322
+ };
1244
1323
 
1245
1324
  async function createCommit(commitMsg, git) {
1246
1325
  return await git.commit(commitMsg);
@@ -1249,17 +1328,14 @@ async function createCommit(commitMsg, git) {
1249
1328
  const handler$2 = async (argv, logger) => {
1250
1329
  const git = getRepo();
1251
1330
  const options = loadConfig(argv);
1252
- const { service } = options;
1253
- const key = getApiKeyForModel(service, options);
1254
- const tokenizer = await getTokenCounter(getModelFromService(service));
1331
+ const key = getApiKeyForModel(options);
1255
1332
  if (!key) {
1256
1333
  logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
1257
1334
  process.exit(1);
1258
1335
  }
1259
- const llm = getLlm(service, key, {
1260
- temperature: 0.4,
1261
- maxConcurrency: 10,
1262
- });
1336
+ const { provider, model } = getModelAndProviderFromConfig(options);
1337
+ const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4');
1338
+ const llm = getLlm(provider, model, options);
1263
1339
  const INTERACTIVE = isInteractive(options);
1264
1340
  if (INTERACTIVE) {
1265
1341
  logger.log(LOGO);
@@ -1325,16 +1401,10 @@ const handler$2 = async (argv, logger) => {
1325
1401
  * Command line options via yargs
1326
1402
  */
1327
1403
  const options$2 = {
1328
- model: { type: 'string', description: 'LLM/Model-Name' },
1404
+ service: { type: 'string', description: 'LLM/Model-Name', choices: ['openai', 'ollama'] },
1329
1405
  openAIApiKey: {
1330
1406
  type: 'string',
1331
1407
  description: 'OpenAI API Key',
1332
- conflicts: 'huggingFaceHubApiKey',
1333
- },
1334
- huggingFaceHubApiKey: {
1335
- type: 'string',
1336
- description: 'HuggingFace Hub API Key',
1337
- conflicts: 'openAIApiKey',
1338
1408
  },
1339
1409
  tokenLimit: { type: 'number', description: 'Token limit' },
1340
1410
  prompt: {
@@ -1458,15 +1528,13 @@ async function getCommitLogCurrentBranch({ git, logger, comparisonBranch = 'main
1458
1528
  const handler$1 = async (argv, logger) => {
1459
1529
  const options = loadConfig(argv);
1460
1530
  const git = getRepo();
1461
- const key = getApiKeyForModel(options.service, options);
1531
+ const key = getApiKeyForModel(options);
1462
1532
  if (!key) {
1463
1533
  logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
1464
1534
  process.exit(1);
1465
1535
  }
1466
- const model = getLlm(options.service, key, {
1467
- temperature: 0.4,
1468
- maxConcurrency: 10,
1469
- });
1536
+ const { provider, model } = getModelAndProviderFromConfig(options);
1537
+ const llm = getLlm(provider, model, options);
1470
1538
  const INTERACTIVE = isInteractive(options);
1471
1539
  if (INTERACTIVE) {
1472
1540
  logger.log(LOGO);
@@ -1498,7 +1566,7 @@ const handler$1 = async (argv, logger) => {
1498
1566
  fallback: CHANGELOG_PROMPT,
1499
1567
  });
1500
1568
  return await executeChain({
1501
- llm: model,
1569
+ llm,
1502
1570
  prompt,
1503
1571
  variables: { summary: context },
1504
1572
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-coco",
3
- "version": "0.7.6",
3
+ "version": "0.8.0",
4
4
  "description": "zero-effort git commits with coco.",
5
5
  "author": "gfargo <ghfargo@gmail.com>",
6
6
  "license": "MIT",
@@ -33,6 +33,7 @@
33
33
  "test": "npm run test:jest && npm run test:publish",
34
34
  "test:publish": "npm run lint && npm publish --dry-run",
35
35
  "test:jest": "jest",
36
+ "test:jest:watch": "jest --watch",
36
37
  "release": "release-it",
37
38
  "prepublishOnly": "npm run clean && npm run build",
38
39
  "coco": "npm run coco:ts",