git-coco 0.6.2 β†’ 0.7.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
@@ -2,26 +2,27 @@
2
2
  'use strict';
3
3
 
4
4
  var yargs = require('yargs');
5
+ var prompts = require('langchain/prompts');
6
+ var fs = require('fs');
7
+ var prompts$1 = require('@inquirer/prompts');
8
+ var chalk = require('chalk');
9
+ var os = require('os');
10
+ var path = require('path');
11
+ var ini = require('ini');
12
+ var ora = require('ora');
13
+ var now = require('performance-now');
14
+ var prettyMilliseconds = require('pretty-ms');
5
15
  var pQueue = require('p-queue');
6
16
  var document = require('langchain/document');
7
17
  var hf = require('langchain/llms/hf');
8
- var prompts = require('langchain/prompts');
9
18
  var chains = require('langchain/chains');
10
19
  var openai = require('langchain/llms/openai');
11
20
  var text_splitter = require('langchain/text_splitter');
12
21
  var diff = require('diff');
13
- var GPT3NodeTokenizer = require('gpt3-tokenizer');
14
- var chalk = require('chalk');
15
- var ora = require('ora');
16
- var now = require('performance-now');
17
- var prettyMilliseconds = require('pretty-ms');
18
- var path = require('path');
19
22
  var minimatch = require('minimatch');
20
- var fs = require('fs');
21
- var prompts$1 = require('@inquirer/prompts');
22
- var os = require('os');
23
- var ini = require('ini');
24
23
  var simpleGit = require('simple-git');
24
+ var tiktoken = require('tiktoken');
25
+ var child_process = require('child_process');
25
26
 
26
27
  function _interopNamespaceDefault(e) {
27
28
  var n = Object.create(null);
@@ -40,437 +41,364 @@ function _interopNamespaceDefault(e) {
40
41
  return Object.freeze(n);
41
42
  }
42
43
 
43
- var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
44
44
  var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
45
45
  var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
46
+ var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
46
47
  var ini__namespace = /*#__PURE__*/_interopNamespaceDefault(ini);
47
48
 
48
49
  /**
49
- * Extract the path from a file path string.
50
- * @param {string} filePath - The full file path.
51
- * @returns {string} The path portion of the file path.
50
+ * Returns a new object with all undefined keys removed
51
+ *
52
+ * @param obj Object to remove undefined keys from
53
+ * @returns
52
54
  */
53
- function getPathFromFilePath(filePath) {
54
- return filePath.split('/').slice(0, -1).join('/');
55
+ function removeUndefined(obj) {
56
+ return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
55
57
  }
56
58
 
57
- async function summarize(documents, { chain, textSplitter, options }) {
58
- const { returnIntermediateSteps = false } = options || {};
59
- const docs = await textSplitter.splitDocuments(documents.map((doc) => new document.Document(doc)));
60
- const res = await chain.call({
61
- input_documents: docs,
62
- returnIntermediateSteps,
63
- });
64
- if (res.error)
65
- throw new Error(res.error);
66
- return res.text && res.text.trim();
67
- }
59
+ const template$2 = `GOAL: Use functional abstractions to summarize the following text
60
+
61
+ RULES: Avoid phrases like "this change", "this code", or "this function" etc. Instead refer to the function, variable, or class by name.
62
+
63
+ TEXT:"""{text}"""
64
+ `;
65
+ const inputVariables$2 = ['text'];
66
+ const SUMMARIZE_PROMPT = new prompts.PromptTemplate({
67
+ template: template$2,
68
+ inputVariables: inputVariables$2,
69
+ });
68
70
 
69
71
  /**
70
- * Create groups from a given node info.
71
- * @param {DiffNode} node - The node info to start grouping.
72
- * @returns {DirectoryDiff[]} The groups created.
72
+ * Default Config
73
+ *
74
+ * @type {Config}
73
75
  */
74
- function createDirectoryDiffs(node) {
75
- const groupByPath = {};
76
- function traverse(node) {
77
- node.diffs.forEach((diff) => {
78
- const path = getPathFromFilePath(diff.file);
79
- if (!groupByPath[path]) {
80
- groupByPath[path] = { diffs: [], path, tokenCount: 0 };
81
- }
82
- groupByPath[path].diffs.push(diff);
83
- groupByPath[path].tokenCount += diff.tokenCount;
84
- });
85
- node.children.forEach(traverse);
86
- }
87
- traverse(node);
88
- return Object.values(groupByPath);
89
- }
76
+ const DEFAULT_CONFIG = {
77
+ service: 'openai/gpt-4',
78
+ verbose: false,
79
+ tokenLimit: 1024,
80
+ summarizePrompt: SUMMARIZE_PROMPT.template,
81
+ temperature: 0.4,
82
+ mode: 'stdout',
83
+ ignoredFiles: ['package-lock.json'],
84
+ ignoredExtensions: ['.map', '.lock'],
85
+ defaultBranch: 'main',
86
+ };
90
87
  /**
91
- * Summarize a directory diff asynchronously.
88
+ * Create a named export of all config keys for use in other modules.
89
+ *
90
+ * @see Currently used in `src/lib/config/services/env.ts` to validate all env vars.
91
+ *
92
+ * @type {string[]}
92
93
  */
93
- async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer }) {
94
- try {
95
- const directorySummary = await summarize(directory.diffs.map((diff) => ({
96
- pageContent: diff.diff,
97
- metadata: {
98
- file: diff.file,
99
- summary: diff.summary,
100
- },
101
- })), {
102
- chain,
103
- textSplitter,
104
- options: {
105
- returnIntermediateSteps: true,
106
- },
107
- });
108
- const newTokenTotal = tokenizer.encode(directorySummary).text.length;
109
- return {
110
- diffs: directory.diffs,
111
- path: directory.path,
112
- summary: directorySummary,
113
- tokenCount: newTokenTotal,
114
- };
94
+ const CONFIG_KEYS = Object.keys({
95
+ ...DEFAULT_CONFIG,
96
+ huggingFaceHubApiKey: '',
97
+ openAIApiKey: '',
98
+ prompt: '',
99
+ });
100
+
101
+ async function updateFileSection({ filePath, startComment, endComment, getNewContent, confirmUpdate = true, confirmMessage = (path) => `A section already exists in ${path}, do you want to override it?`, }) {
102
+ const lines = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8').split(/\r?\n/) : [];
103
+ const newLines = [];
104
+ let foundSection = false;
105
+ for (let i = 0; i < lines.length; i++) {
106
+ if (lines[i].trim() === startComment) {
107
+ foundSection = true;
108
+ if (confirmUpdate) {
109
+ const confirmOverwrite = await prompts$1.confirm({
110
+ message: typeof confirmMessage === 'function' ? confirmMessage(filePath) : confirmMessage,
111
+ default: false,
112
+ });
113
+ if (!confirmOverwrite) {
114
+ // keep all lines until the end comment
115
+ while (i < lines.length && lines[i].trim() !== endComment) {
116
+ newLines.push(lines[i]);
117
+ i++;
118
+ }
119
+ newLines.push(endComment);
120
+ continue;
121
+ }
122
+ }
123
+ newLines.push(startComment);
124
+ // Insert the new content
125
+ const newContent = await getNewContent();
126
+ newLines.push(newContent);
127
+ // Skip the existing content of the section
128
+ while (i < lines.length && lines[i].trim() !== endComment) {
129
+ i++;
130
+ }
131
+ newLines.push(endComment);
132
+ continue;
133
+ }
134
+ if (!foundSection || lines[i].trim() !== endComment) {
135
+ newLines.push(lines[i]);
136
+ }
115
137
  }
116
- catch (error) {
117
- console.error(error);
118
- return directory;
138
+ // If section wasn't found, append it at the end
139
+ if (!foundSection) {
140
+ newLines.push('\n' + startComment);
141
+ const newContent = await getNewContent();
142
+ newLines.push(newContent);
143
+ newLines.push(endComment);
119
144
  }
145
+ // Write the updated contents back to the file
146
+ fs.writeFileSync(filePath, newLines.join('\n'));
120
147
  }
121
- const defaultOutputCallback = (group) => {
122
- let output = `
123
- -------\n* changes in "/${group.path}"\n\n`;
124
- if (group.summary) {
125
- output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}`).join('\n')}\n\nSummary:\n\n${group.summary}\n\n`;
126
- }
127
- else {
128
- output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}\n\n${diff.diff}`).join('\n\n')}\n\n`;
129
- }
130
- return output;
148
+
149
+ const isInteractive = (argv) => {
150
+ return argv?.mode === 'interactive' || argv.interactive;
131
151
  };
132
- async function summarizeDiffs(rootDiffNode, { tokenizer, logger, maxTokens = 2048, textSplitter, chain, handleOutput = defaultOutputCallback, }) {
133
- const queue = new pQueue({ concurrency: 8 });
134
- logger.startTimer().startSpinner(`Organizing Diffs...`, { color: 'blue' });
135
- const directoryDiffs = createDirectoryDiffs(rootDiffNode);
136
- // Sort by token count descending
137
- directoryDiffs.sort((a, b) => b.tokenCount - a.tokenCount);
138
- let totalTokenCount = directoryDiffs.reduce((sum, group) => sum + group.tokenCount, 0);
139
- logger.stopSpinner('Diffs Organized').stopTimer();
140
- logger.startSpinner(`Consolidating Diffs`, { color: 'blue' });
141
- const processingTasks = directoryDiffs.map((group, i) => {
142
- return queue.add(async () => {
143
- // If the diff token count is already less than the average req, we can skip summarizing.
144
- const isLessThanAvgTokenReq = group.tokenCount <= maxTokens / directoryDiffs.length;
145
- if (totalTokenCount <= maxTokens || isLessThanAvgTokenReq) {
146
- return group;
147
- }
148
- group = await summarizeDirectoryDiff(group, {
149
- chain,
150
- textSplitter,
151
- tokenizer,
152
- });
153
- // We need to subtract the old token count and add the new one
154
- totalTokenCount = totalTokenCount - directoryDiffs[i].tokenCount + group.tokenCount;
155
- directoryDiffs[i] = group;
156
- logger
157
- .verbose(`\n β€’ Summarized diffs in "/${group.path}" `, { color: 'blue' })
158
- .verbose(`\nTotal token count: ${totalTokenCount}`, {
159
- color: totalTokenCount > maxTokens ? 'yellow' : 'green',
160
- });
161
- return group;
162
- }, { priority: group.tokenCount });
152
+ const SEPERATOR = chalk.blue('─────────────');
153
+ const LOGO = chalk.green(`β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
154
+ β”‚β”Œβ”€β”β”Œβ”€β”β”Œβ”€β”β”Œβ”€β”β”‚
155
+ β”‚β”‚ β”‚ β”‚β”‚ β”‚ β”‚β”‚
156
+ β”‚β””β”€β”˜β””β”€β”˜β””β”€β”˜β””β”€β”˜β”‚
157
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
158
+ `);
159
+ const CONFIG_ALREADY_EXISTS = (path) => {
160
+ return `coco config found in '${path}', do you want to override it?`;
161
+ };
162
+
163
+ /**
164
+ * Load environment variables
165
+ *
166
+ * @param {Config} config
167
+ * @returns {Config} Updated config
168
+ **/
169
+ function loadEnvConfig(config) {
170
+ const envConfig = {};
171
+ CONFIG_KEYS.forEach((key) => {
172
+ const envVarName = toEnvVarName(key);
173
+ const envValue = parseEnvValue(key, process.env[envVarName]);
174
+ if (envValue === undefined)
175
+ return;
176
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
177
+ // @ts-ignore
178
+ envConfig[key] = envValue;
163
179
  });
164
- await Promise.all(processingTasks);
165
- logger.stopSpinner(`Summarized Diffs`);
166
- return directoryDiffs.map(handleOutput).join('');
180
+ return { ...config, ...removeUndefined(envConfig) };
167
181
  }
168
-
169
- class DiffTreeNode {
170
- constructor(path) {
171
- this.path = [];
172
- this.files = [];
173
- this.children = new Map();
174
- if (path)
175
- this.path = path;
176
- }
177
- addFile(file) {
178
- this.files.push(file);
182
+ function parseEnvValue(key, value) {
183
+ if (value === undefined) {
184
+ return undefined;
179
185
  }
180
- addChild(part, node) {
181
- this.children.set(part, node);
186
+ else if (key === 'tokenLimit' && typeof value === 'string') {
187
+ return parseInt(value);
182
188
  }
183
- getChild(part) {
184
- return this.children.get(part);
189
+ else if ((key === 'ignoredFiles' || key === 'ignoredExtensions') &&
190
+ typeof value === 'string' &&
191
+ value.includes(',')) {
192
+ return value.split(',');
185
193
  }
186
- getPath() {
187
- return this.path.join('/');
188
- }
189
- print(indentation = 0) {
190
- const indent = ' '.repeat(indentation);
191
- let output = `${indent}- Path: ${this.getPath()}\n`;
192
- if (this.files.length > 0) {
193
- output += `${indent} Files:\n`;
194
- for (const file of this.files) {
195
- output += `${indent} - ${file.summary}\n`;
196
- }
197
- }
198
- if (this.children.size > 0) {
199
- output += `${indent} Children:\n`;
200
- for (const [, child] of this.children) {
201
- output += child.print(indentation + 4);
202
- }
203
- }
204
- return output;
194
+ return value;
195
+ }
196
+ function toEnvVarName(key) {
197
+ switch (key) {
198
+ case 'openAIApiKey':
199
+ return 'OPENAI_API_KEY';
200
+ case 'huggingFaceHubApiKey':
201
+ return 'HUGGINGFACE_HUB_API_KEY';
202
+ default:
203
+ return 'COCO_' + key.replace(/([A-Z])/g, '_$1').toUpperCase();
205
204
  }
206
205
  }
207
- const createDiffTree = (changes) => {
208
- const root = new DiffTreeNode();
209
- for (const change of changes) {
210
- let currentParent = root;
211
- const parts = change.filePath.split('/');
212
- parts.pop();
213
- for (const part of parts) {
214
- let childNode = currentParent.getChild(part);
215
- if (!childNode) {
216
- childNode = new DiffTreeNode([...currentParent.path, part]);
217
- currentParent.addChild(part, childNode);
218
- }
219
- currentParent = childNode;
220
- }
221
- // Create a NodeFile object and add it to the parent
222
- currentParent.addFile({
223
- filePath: change.filePath,
224
- oldFilePath: change.oldFilePath,
225
- summary: change.summary,
226
- status: change.status,
227
- });
206
+ function formatEnvValue(value) {
207
+ if (typeof value === 'number') {
208
+ return `${value}`;
228
209
  }
229
- return root;
210
+ else if (Array.isArray(value)) {
211
+ return `${value.join(',')}`;
212
+ }
213
+ else if (typeof value === 'string') {
214
+ // Escape newlines and tabs in strings
215
+ return `${value.replace(/\n/g, '\\n').replace(/\t/g, '\\t')}`;
216
+ }
217
+ return `${value}`;
218
+ }
219
+ const appendToEnvFile = async (filePath, config) => {
220
+ const startComment = '# -- Start coco config --';
221
+ const endComment = '# -- End coco config --';
222
+ const getNewContent = async () => {
223
+ return Object.entries(config)
224
+ .map(([key, value]) => `${toEnvVarName(key)}=${formatEnvValue(value)}`)
225
+ .join('\n');
226
+ };
227
+ await updateFileSection({
228
+ filePath,
229
+ startComment,
230
+ endComment,
231
+ getNewContent,
232
+ confirmMessage: CONFIG_ALREADY_EXISTS,
233
+ });
230
234
  };
231
235
 
232
236
  /**
233
- * Asynchronously collect diffs for a given node and its children.
234
- */
235
- async function collectDiffs(node, getFileDiff, tokenizer, logger) {
236
- // Collect diffs for the files of the current node
237
- const diffPromises = node.files.map(async (nodeFile) => {
238
- const diff = await getFileDiff(nodeFile);
239
- // TODO: Swap out the GPT3Tokenizer for LangChain tokenizer
240
- const tokenizedDiff = tokenizer.encode(diff).text;
241
- const tokenCount = tokenizedDiff.length;
242
- logger.verbose(`Collected diff for ${nodeFile.filePath} (${tokenCount} tokens)`, {
243
- color: 'magenta',
244
- });
245
- return {
246
- file: nodeFile.filePath,
247
- summary: nodeFile.summary,
248
- diff,
249
- tokenCount,
237
+ * Load git profile config (from ~/.gitconfig)
238
+ *
239
+ * @param {Config} config
240
+ * @returns {Config} Updated config
241
+ **/
242
+ function loadGitConfig(config) {
243
+ const gitConfigPath = path__namespace.join(os__namespace.homedir(), '.gitconfig');
244
+ if (fs__namespace.existsSync(gitConfigPath)) {
245
+ const gitConfigRaw = fs__namespace.readFileSync(gitConfigPath, 'utf-8');
246
+ const gitConfigParsed = ini__namespace.parse(gitConfigRaw);
247
+ config = {
248
+ ...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,
253
+ prompt: gitConfigParsed.coco?.prompt || config.prompt,
254
+ mode: gitConfigParsed.coco?.mode || config.mode,
255
+ temperature: gitConfigParsed.coco?.temperature || config.temperature,
256
+ summarizePrompt: gitConfigParsed.coco?.summarizePrompt || config.summarizePrompt,
257
+ ignoredFiles: gitConfigParsed.coco?.ignoredFiles || config.ignoredFiles,
258
+ ignoredExtensions: gitConfigParsed.coco?.ignoredExtensions || config.ignoredExtensions,
259
+ defaultBranch: gitConfigParsed.coco?.defaultBranch || config.defaultBranch,
250
260
  };
251
- });
252
- // Collect diffs for the children of the current node
253
- const childrenPromises = Array.from(node.children.values()).map(async (child) => collectDiffs(child, getFileDiff, tokenizer, logger));
254
- const [diffs, children] = await Promise.all([
255
- Promise.all(diffPromises),
256
- Promise.all(childrenPromises),
257
- ]);
258
- return {
259
- path: node.getPath(),
260
- diffs,
261
- children,
262
- };
261
+ }
262
+ return config;
263
263
  }
264
-
265
264
  /**
266
- * Get LLM Model Based on Configuration
267
- * @param fields
268
- * @param configuration
269
- * @returns LLM Model
265
+ * Appends the provided configuration to a git config file.
266
+ *
267
+ * @param filePath - The path to the .gitconfig
268
+ * @param config - The configuration object to append.
270
269
  */
271
- function getModel(name, key, fields) {
272
- const [llm, model] = name.split(/\/(.*)/s);
273
- if (!model) {
274
- throw new Error(`Invalid model: ${name}`);
275
- }
276
- switch (llm) {
277
- case 'huggingface':
278
- return new hf.HuggingFaceInference({
279
- model: model,
280
- apiKey: key,
281
- maxConcurrency: 4,
282
- ...fields,
283
- });
284
- case 'openai':
285
- default:
286
- return new openai.OpenAI({
287
- openAIApiKey: key,
288
- modelName: model,
289
- ...fields,
290
- });
270
+ const appendToGitConfig = async (filePath, config) => {
271
+ if (!fs__namespace.existsSync(filePath)) {
272
+ throw new Error(`File ${filePath} does not exist.`);
291
273
  }
292
- }
274
+ const startComment = '# -- Start coco config --';
275
+ const endComment = '# -- End coco config --';
276
+ const header = '[coco]';
277
+ // Function to generate new content for the coco section
278
+ const getNewContent = async () => {
279
+ const contentLines = [header];
280
+ for (const key in config) {
281
+ // check if string has new lines, if so, wrap in quotes
282
+ if (typeof config[key] === 'string') {
283
+ const value = config[key];
284
+ if (value.includes('\n')) {
285
+ contentLines.push(`\t${key} = ${JSON.stringify(value)}`);
286
+ continue;
287
+ }
288
+ }
289
+ contentLines.push(`\t${key} = ${config[key]}`);
290
+ }
291
+ return contentLines.join('\n');
292
+ };
293
+ await updateFileSection({
294
+ filePath,
295
+ startComment,
296
+ endComment,
297
+ getNewContent,
298
+ confirmUpdate: true,
299
+ confirmMessage: CONFIG_ALREADY_EXISTS,
300
+ });
301
+ };
302
+
293
303
  /**
294
- * Retrieve appropriate API key based on selected model
295
- * @param name
296
- * @param options
304
+ * Load .gitignore in project root
305
+ *
306
+ * @param {Config} config
297
307
  * @returns
298
308
  */
299
- function getApiKeyForModel(name, options) {
300
- const [llm, model] = name.split(/\/(.*)/s);
301
- if (!model) {
302
- throw new Error(`Invalid model: ${name}`);
303
- }
304
- switch (llm) {
305
- case 'huggingface':
306
- return options.huggingFaceHubApiKey;
307
- case 'openai':
308
- default:
309
- return options.openAIApiKey;
309
+ function loadGitignore(config) {
310
+ if (fs__namespace.existsSync('.gitignore')) {
311
+ const gitignoreContent = fs__namespace.readFileSync('.gitignore', 'utf-8');
312
+ config.ignoredFiles = [
313
+ ...(config?.ignoredFiles || []),
314
+ ...gitignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
315
+ ];
310
316
  }
317
+ return config;
311
318
  }
312
319
  /**
313
- * Get Recursive Character Text Splitter
314
- * @param options
320
+ * Load .ignore in project root
321
+ *
322
+ * @param {Config} config
315
323
  * @returns
316
324
  */
317
- function getTextSplitter(options = {}) {
318
- return new text_splitter.RecursiveCharacterTextSplitter(options);
325
+ function loadIgnore(config) {
326
+ if (fs__namespace.existsSync('.ignore')) {
327
+ const ignoreContent = fs__namespace.readFileSync('.ignore', 'utf-8');
328
+ config.ignoredFiles = [
329
+ ...(config?.ignoredFiles || []),
330
+ ...ignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
331
+ ];
332
+ }
333
+ return config;
319
334
  }
335
+
320
336
  /**
321
- * Get Summarization Chain
322
- * @param model
323
- * @param options
324
- * @returns
325
- */
326
- function getChain(model, options = { type: 'map_reduce' }) {
327
- return chains.loadSummarizationChain(model, options);
328
- }
329
- function getPrompt({ template, variables, fallback }) {
330
- if (!template && !fallback)
331
- throw new Error('Must provide either a template or a fallback');
332
- return (template
333
- ? new prompts.PromptTemplate({
334
- template,
335
- inputVariables: variables,
336
- })
337
- : fallback);
337
+ * Load project config
338
+ *
339
+ * @param {Config} config
340
+ * @returns {Config} Updated config
341
+ **/
342
+ function loadProjectConfig(config) {
343
+ // TODO: Add validation based of JSON schema?
344
+ // @see https://github.com/acornejo/jjv
345
+ if (fs__namespace.existsSync('.coco.config.json')) {
346
+ const projectConfig = JSON.parse(fs__namespace.readFileSync('.coco.config.json', 'utf-8'));
347
+ config = { ...config, ...projectConfig };
348
+ }
349
+ return config;
338
350
  }
351
+ const appendToProjectConfig = (filePath, config) => {
352
+ fs__namespace.writeFileSync(filePath, JSON.stringify({
353
+ $schema: 'https://git-co.co/schema.json',
354
+ ...config,
355
+ }, null, 2));
356
+ };
357
+
339
358
  /**
340
- * Verify template string contains all required input variables
341
- * @param text template string
342
- * @param inputVariables template variables
343
- * @returns boolean or error message
359
+ * Load XDG config
360
+ *
361
+ * @param {Config} config
362
+ * @returns {Config} Updated config
344
363
  */
345
- function validatePromptTemplate(text, inputVariables) {
346
- if (!text) {
347
- return 'Prompt template cannot be empty';
348
- }
349
- if (!inputVariables.some((entry) => text.includes(entry))) {
350
- return ('Prompt template must include at least one of the following input variables: ' +
351
- inputVariables.map((value) => `{${value}}`).join(', '));
352
- }
353
- return true;
354
- }
355
-
356
- const template$2 = `GOAL: Use functional abstractions to summarize the following text
357
-
358
- RULES: Avoid phrases like "this change", "this code", or "this function" etc. Instead refer to the function, variable, or class by name.
359
-
360
- TEXT:"""{text}"""
361
- `;
362
- const inputVariables$2 = ['text'];
363
- const SUMMARIZE_PROMPT = new prompts.PromptTemplate({
364
- template: template$2,
365
- inputVariables: inputVariables$2,
366
- });
367
-
368
- async function parseDefaultFileDiff(nodeFile, commit = '--staged', git) {
369
- if (commit !== '--staged') {
370
- return await git.diff([`${commit}~1..${commit}`, '--', nodeFile.filePath]);
371
- }
372
- return await git.diff([commit, nodeFile.filePath]);
373
- }
374
- async function parseRenamedFileDiff(nodeFile, commit, git, logger) {
375
- let result = '';
376
- const oldFilePath = nodeFile?.oldFilePath || nodeFile.filePath;
377
- let previousCommitHash = 'HEAD';
378
- let newCommitHash = '';
379
- if (commit !== '--staged') {
380
- try {
381
- previousCommitHash = await git.revparse([`${commit}~1`]);
382
- }
383
- catch (err) {
384
- logger.verbose(`Error getting previous commit hash for ${nodeFile.filePath}`, {
385
- color: 'red',
386
- });
387
- }
388
- newCommitHash = commit;
389
- }
390
- try {
391
- const [previousContent, newContent] = await Promise.all([
392
- git.show([`${previousCommitHash}:${oldFilePath}`]),
393
- git.show([`${newCommitHash}:${nodeFile.filePath}`]),
394
- ]);
395
- if (previousContent !== newContent) {
396
- result = diff.createTwoFilesPatch(oldFilePath, nodeFile.filePath, previousContent, newContent, '', '', {
397
- context: 3,
398
- });
399
- // remove the first 4 lines of the patch (they contain the old and new file names)
400
- result = result.split('\n').slice(4).join('\n');
401
- }
402
- else {
403
- result = 'File contents are unchanged.';
404
- }
405
- }
406
- catch (err) {
407
- logger.verbose(`Error comparing file contents for ${nodeFile.filePath}`, { color: 'red' });
408
- result = 'Error comparing file contents.';
409
- }
410
- return result;
411
- }
412
- async function getDiff(nodeFile, commit, { git, logger, }) {
413
- if (nodeFile.status === 'deleted') {
414
- return 'This file has been deleted.';
415
- }
416
- if (nodeFile.status === 'renamed' && nodeFile.oldFilePath) {
417
- const renamedDiff = await parseRenamedFileDiff(nodeFile, commit, git, logger);
418
- return renamedDiff;
364
+ function loadXDGConfig(config) {
365
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME || path__namespace.join(os__namespace.homedir(), '.config');
366
+ const xdgConfigPath = path__namespace.join(xdgConfigHome, 'coco', 'config.json');
367
+ if (fs__namespace.existsSync(xdgConfigPath)) {
368
+ const xdgConfig = JSON.parse(fs__namespace.readFileSync(xdgConfigPath, 'utf-8'));
369
+ config = { ...config, ...xdgConfig };
419
370
  }
420
- // If not deleted or renamed, get the diff from the index
421
- const defaultDiff = await parseDefaultFileDiff(nodeFile, commit, git);
422
- return defaultDiff;
423
- }
424
-
425
- const MAX_TOKENS_PER_SUMMARY = 2048;
426
- async function fileChangeParser({ changes, commit, options: { tokenizer, git, model, logger }, }) {
427
- const textSplitter = getTextSplitter({ chunkSize: 2000, chunkOverlap: 125 });
428
- const summarizationChain = getChain(model, {
429
- type: 'map_reduce',
430
- combineMapPrompt: SUMMARIZE_PROMPT,
431
- combinePrompt: SUMMARIZE_PROMPT,
432
- });
433
- logger.startTimer();
434
- const rootTreeNode = createDiffTree(changes);
435
- logger.stopTimer('Created file hierarchy');
436
- // Collect diffs
437
- logger.startTimer().startSpinner(`Collecting Diffs...\n`, { color: 'blue' });
438
- const diffs = await collectDiffs(rootTreeNode, (path) => getDiff(path, commit, { git, logger }), tokenizer, logger);
439
- logger.stopSpinner('Diffs Collected').stopTimer();
440
- // Summarize diffs
441
- logger.startTimer();
442
- const summary = await summarizeDiffs(diffs, {
443
- tokenizer,
444
- maxTokens: MAX_TOKENS_PER_SUMMARY,
445
- textSplitter,
446
- chain: summarizationChain,
447
- logger,
448
- });
449
- logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
450
- return summary;
371
+ return config;
451
372
  }
452
373
 
453
374
  /**
454
- * Wrapper around GPT3NodeTokenizer to handle default export.
375
+ * Load application config
455
376
  *
456
- * @see https://github.com/botisan-ai/gpt3-tokenizer/issues/18
377
+ * Merge config from multiple sources.
457
378
  *
458
- * @returns {GPT3NodeTokenizer} The GPT3NodeTokenizer instance.
459
- */
460
- const getTokenizer = () => {
461
- let tokenizer;
462
- // eslint-disable-next-line
463
- // @ts-ignore
464
- if (GPT3NodeTokenizer.default) {
465
- // eslint-disable-next-line
466
- // @ts-ignore
467
- tokenizer = new GPT3NodeTokenizer.default({ type: 'gpt3' });
468
- }
469
- else {
470
- tokenizer = new GPT3NodeTokenizer({ type: 'gpt3' });
471
- }
472
- return tokenizer;
473
- };
379
+ * \* Order of precedence:
380
+ * \* 1. Command line flags
381
+ * \* 2. Environment variables
382
+ * \* 3. Project config
383
+ * \* 4. Git config
384
+ * \* 5. XDG config
385
+ * \* 6. .gitignore
386
+ * \* 7. .ignore
387
+ * \* 8. Default config
388
+ *
389
+ * @returns {Config} application config
390
+ **/
391
+ function loadConfig(argv = {}) {
392
+ // Default config
393
+ let config = DEFAULT_CONFIG;
394
+ config = loadGitignore(config);
395
+ config = loadIgnore(config);
396
+ config = loadXDGConfig(config);
397
+ config = loadGitConfig(config);
398
+ config = loadProjectConfig(config);
399
+ config = loadEnvConfig(config);
400
+ return { ...config, ...argv };
401
+ }
474
402
 
475
403
  class Logger {
476
404
  constructor(config) {
@@ -523,391 +451,492 @@ class Logger {
523
451
  }
524
452
  }
525
453
 
526
- const template$1 = `Write informative git commit message, in the imperative, based on the diffs & file changes provided in the "Diff Summary" section.
527
- 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.
528
-
529
- - Typically a hyphen or asterisk is used for the bullet
530
- - Write concisely using an informal tone
531
- - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
532
- - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
533
- - DO NOT use specific names or files from the code
534
- - Wrap variable, class, function, components, and dependency names in back ticks e.g. \`variable\`
535
-
536
- """{summary}"""
537
-
538
- Commit:`;
539
- const inputVariables$1 = ['summary'];
540
- const COMMIT_PROMPT = new prompts.PromptTemplate({
541
- template: template$1,
542
- inputVariables: inputVariables$1,
543
- });
544
-
545
- function getStatus(file, location = 'index') {
546
- if ('index' in file && 'working_dir' in file) {
547
- const statusCode = file[location];
548
- switch (statusCode) {
549
- case 'A':
550
- return 'added';
551
- case 'D':
552
- return 'deleted';
553
- case 'M':
554
- return 'modified';
555
- case 'R':
556
- return 'renamed';
557
- case '?':
558
- return 'untracked';
559
- default:
560
- return 'unknown';
454
+ function commandExecutor(handler) {
455
+ return async (argv) => {
456
+ const options = loadConfig(argv);
457
+ const logger = new Logger(options);
458
+ try {
459
+ await handler(argv, logger);
561
460
  }
562
- }
563
- else if ('changes' in file && 'binary' in file) {
564
- if (file.changes === 0)
565
- return 'untracked';
566
- if (file.file.includes('=>'))
567
- return 'renamed';
568
- if (file.deletions === 0 && file.insertions > 0)
569
- return 'added';
570
- if (file.insertions === 0 && file.deletions > 0)
571
- return 'deleted';
572
- if ((file.insertions > 0 && file.deletions > 0) || file.changes > 0)
573
- return 'modified';
574
- return 'unknown';
575
- }
576
- else {
577
- throw new Error('Invalid file type');
578
- }
579
- }
580
-
581
- function getSummaryText(file, change) {
582
- const status = change.status || getStatus(file);
583
- let filePath;
584
- if ('path' in file) {
585
- filePath = file.path;
586
- }
587
- else if ('file' in file) {
588
- filePath = change?.filePath || file.file;
589
- }
590
- else {
591
- throw new Error('Invalid file type');
592
- }
593
- if (change.oldFilePath) {
594
- return `${status}: ${change.oldFilePath} -> ${filePath}`;
595
- }
596
- return `${status}: ${filePath}`;
461
+ catch (error) {
462
+ logger.log('\nFailed to execute command', { color: 'yellow' });
463
+ logger.verbose(`\nError: "${error.message}"`, { color: 'red' });
464
+ logger.log('\nThanks for using coco, make it a great day! πŸ‘‹πŸ€–', { color: 'blue' });
465
+ process.exit(0);
466
+ }
467
+ };
597
468
  }
598
469
 
599
470
  /**
600
- * Returns a new object with all undefined keys removed
601
- *
602
- * @param obj Object to remove undefined keys from
603
- * @returns
471
+ * Extract the path from a file path string.
472
+ * @param {string} filePath - The full file path.
473
+ * @returns {string} The path portion of the file path.
604
474
  */
605
- function removeUndefined(obj) {
606
- return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
475
+ function getPathFromFilePath(filePath) {
476
+ return filePath.split('/').slice(0, -1).join('/');
477
+ }
478
+
479
+ async function summarize(documents, { chain, textSplitter, options }) {
480
+ const { returnIntermediateSteps = false } = options || {};
481
+ const docs = await textSplitter.splitDocuments(documents.map((doc) => new document.Document(doc)));
482
+ const res = await chain.call({
483
+ input_documents: docs,
484
+ returnIntermediateSteps,
485
+ });
486
+ if (res.error)
487
+ throw new Error(res.error);
488
+ return res.text && res.text.trim();
607
489
  }
608
490
 
609
491
  /**
610
- * Default Config
611
- *
612
- * @type {Config}
613
- */
614
- const DEFAULT_CONFIG = {
615
- model: 'openai/gpt-4',
616
- verbose: false,
617
- tokenLimit: 1024,
618
- summarizePrompt: SUMMARIZE_PROMPT.template,
619
- temperature: 0.4,
620
- mode: 'stdout',
621
- ignoredFiles: ['package-lock.json'],
622
- ignoredExtensions: ['.map', '.lock'],
623
- defaultBranch: 'main',
624
- };
625
- /**
626
- * Config keys
627
- *
628
- * @type {string[]}
492
+ * Create groups from a given node info.
493
+ * @param {DiffNode} node - The node info to start grouping.
494
+ * @returns {DirectoryDiff[]} The groups created.
629
495
  */
630
- const CONFIG_KEYS = Object.keys({
631
- ...DEFAULT_CONFIG,
632
- huggingFaceHubApiKey: '',
633
- openAIApiKey: '',
634
- prompt: '',
635
- });
636
-
637
- async function updateFileSection(filePath, startComment, endComment, getNewContent, confirmUpdate = true) {
638
- const lines = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8').split(/\r?\n/) : [];
639
- const newLines = [];
640
- let foundSection = false;
641
- for (let i = 0; i < lines.length; i++) {
642
- if (lines[i].trim() === startComment) {
643
- foundSection = true;
644
- if (confirmUpdate) {
645
- const confirmOverwrite = await prompts$1.confirm({
646
- message: `A section already exists in ${filePath}, do you want to override it?`,
647
- default: false,
648
- });
649
- if (!confirmOverwrite) {
650
- // keep all lines until the end comment
651
- while (i < lines.length && lines[i].trim() !== endComment) {
652
- newLines.push(lines[i]);
653
- i++;
654
- }
655
- newLines.push(endComment);
656
- continue;
657
- }
658
- }
659
- newLines.push(startComment);
660
- // Insert the new content
661
- const newContent = await getNewContent();
662
- newLines.push(newContent);
663
- // Skip the existing content of the section
664
- while (i < lines.length && lines[i].trim() !== endComment) {
665
- i++;
496
+ function createDirectoryDiffs(node) {
497
+ const groupByPath = {};
498
+ function traverse(node) {
499
+ node.diffs.forEach((diff) => {
500
+ const path = getPathFromFilePath(diff.file);
501
+ if (!groupByPath[path]) {
502
+ groupByPath[path] = { diffs: [], path, tokenCount: 0 };
666
503
  }
667
- newLines.push(endComment);
668
- continue;
669
- }
670
- if (!foundSection || lines[i].trim() !== endComment) {
671
- newLines.push(lines[i]);
672
- }
673
- }
674
- // If section wasn't found, append it at the end
675
- if (!foundSection) {
676
- newLines.push('\n' + startComment);
677
- const newContent = await getNewContent();
678
- newLines.push(newContent);
679
- newLines.push(endComment);
504
+ groupByPath[path].diffs.push(diff);
505
+ groupByPath[path].tokenCount += diff.tokenCount;
506
+ });
507
+ node.children.forEach(traverse);
680
508
  }
681
- // Write the updated contents back to the file
682
- fs.writeFileSync(filePath, newLines.join('\n'));
509
+ traverse(node);
510
+ return Object.values(groupByPath);
683
511
  }
684
-
685
512
  /**
686
- * Load environment variables
687
- *
688
- * @param {Config} config
689
- * @returns {Config} Updated config
690
- **/
691
- function loadEnvConfig(config) {
692
- const envConfig = {};
693
- CONFIG_KEYS.forEach((key) => {
694
- const envVarName = toEnvVarName(key);
695
- const envValue = parseEnvValue(key, process.env[envVarName]);
696
- if (envValue === undefined)
697
- return;
698
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
699
- // @ts-ignore
700
- envConfig[key] = envValue;
701
- });
702
- return { ...config, ...removeUndefined(envConfig) };
703
- }
704
- function parseEnvValue(key, value) {
705
- if (value === undefined) {
706
- return undefined;
707
- }
708
- else if (key === 'tokenLimit' && typeof value === 'string') {
709
- return parseInt(value);
513
+ * Summarize a directory diff asynchronously.
514
+ */
515
+ async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer }) {
516
+ try {
517
+ const directorySummary = await summarize(directory.diffs.map((diff) => ({
518
+ pageContent: diff.diff,
519
+ metadata: {
520
+ file: diff.file,
521
+ summary: diff.summary,
522
+ },
523
+ })), {
524
+ chain,
525
+ textSplitter,
526
+ options: {
527
+ returnIntermediateSteps: true,
528
+ },
529
+ });
530
+ const newTokenTotal = tokenizer(directorySummary);
531
+ return {
532
+ diffs: directory.diffs,
533
+ path: directory.path,
534
+ summary: directorySummary,
535
+ tokenCount: newTokenTotal,
536
+ };
710
537
  }
711
- else if ((key === 'ignoredFiles' || key === 'ignoredExtensions') &&
712
- typeof value === 'string' &&
713
- value.includes(',')) {
714
- return value.split(',');
538
+ catch (error) {
539
+ console.error(error);
540
+ return directory;
715
541
  }
716
- return value;
717
542
  }
718
- function toEnvVarName(key) {
719
- switch (key) {
720
- case 'openAIApiKey':
721
- return 'OPENAI_API_KEY';
722
- case 'huggingFaceHubApiKey':
723
- return 'HUGGINGFACE_HUB_API_KEY';
724
- default:
725
- return 'COCO_' + key.replace(/([A-Z])/g, '_$1').toUpperCase();
543
+ const defaultOutputCallback = (group) => {
544
+ let output = `
545
+ -------\n* changes in "/${group.path}"\n\n`;
546
+ if (group.summary) {
547
+ output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}`).join('\n')}\n\nSummary:\n\n${group.summary}\n\n`;
548
+ }
549
+ else {
550
+ output += `${group.diffs.map((diff) => ` β€’ ${diff.summary}\n\n${diff.diff}`).join('\n\n')}\n\n`;
726
551
  }
552
+ return output;
553
+ };
554
+ async function summarizeDiffs(rootDiffNode, { tokenizer, logger, maxTokens = 2048, textSplitter, chain, handleOutput = defaultOutputCallback, }) {
555
+ const queue = new pQueue({ concurrency: 8 });
556
+ logger.startTimer().startSpinner(`Organizing Diffs...`, { color: 'blue' });
557
+ const directoryDiffs = createDirectoryDiffs(rootDiffNode);
558
+ // Sort by token count descending
559
+ directoryDiffs.sort((a, b) => b.tokenCount - a.tokenCount);
560
+ let totalTokenCount = directoryDiffs.reduce((sum, group) => sum + group.tokenCount, 0);
561
+ logger.stopSpinner('Diffs Organized').stopTimer();
562
+ logger.startSpinner(`Consolidating Diffs`, { color: 'blue' });
563
+ const processingTasks = directoryDiffs.map((group, i) => {
564
+ return queue.add(async () => {
565
+ // If the diff token count is already less than the average req, we can skip summarizing.
566
+ const isLessThanAvgTokenReq = group.tokenCount <= maxTokens / directoryDiffs.length;
567
+ if (totalTokenCount <= maxTokens || isLessThanAvgTokenReq) {
568
+ return group;
569
+ }
570
+ group = await summarizeDirectoryDiff(group, {
571
+ chain,
572
+ textSplitter,
573
+ tokenizer,
574
+ });
575
+ // We need to subtract the old token count and add the new one
576
+ totalTokenCount = totalTokenCount - directoryDiffs[i].tokenCount + group.tokenCount;
577
+ directoryDiffs[i] = group;
578
+ logger
579
+ .verbose(`\n β€’ Summarized diffs in "/${group.path}" `, { color: 'blue' })
580
+ .verbose(`\nTotal token count: ${totalTokenCount}`, {
581
+ color: totalTokenCount > maxTokens ? 'yellow' : 'green',
582
+ });
583
+ return group;
584
+ }, { priority: group.tokenCount });
585
+ });
586
+ await Promise.all(processingTasks);
587
+ logger.stopSpinner(`Summarized Diffs`);
588
+ return directoryDiffs.map(handleOutput).join('');
727
589
  }
728
- function formatEnvValue(value) {
729
- if (typeof value === 'number') {
730
- return `${value}`;
590
+
591
+ class DiffTreeNode {
592
+ constructor(path) {
593
+ this.path = [];
594
+ this.files = [];
595
+ this.children = new Map();
596
+ if (path)
597
+ this.path = path;
731
598
  }
732
- else if (Array.isArray(value)) {
733
- return `${value.join(',')}`;
599
+ addFile(file) {
600
+ this.files.push(file);
734
601
  }
735
- else if (typeof value === 'string') {
736
- // Escape newlines and tabs in strings
737
- return `${value.replace(/\n/g, '\\n').replace(/\t/g, '\\t')}`;
602
+ addChild(part, node) {
603
+ this.children.set(part, node);
604
+ }
605
+ getChild(part) {
606
+ return this.children.get(part);
607
+ }
608
+ getPath() {
609
+ return this.path.join('/');
610
+ }
611
+ print(indentation = 0) {
612
+ const indent = ' '.repeat(indentation);
613
+ let output = `${indent}- Path: ${this.getPath()}\n`;
614
+ if (this.files.length > 0) {
615
+ output += `${indent} Files:\n`;
616
+ for (const file of this.files) {
617
+ output += `${indent} - ${file.summary}\n`;
618
+ }
619
+ }
620
+ if (this.children.size > 0) {
621
+ output += `${indent} Children:\n`;
622
+ for (const [, child] of this.children) {
623
+ output += child.print(indentation + 4);
624
+ }
625
+ }
626
+ return output;
738
627
  }
739
- return `${value}`;
740
628
  }
741
- const appendToEnvFile = async (filePath, config) => {
742
- const startComment = '# -- Start coco config --';
743
- const endComment = '# -- End coco config --';
744
- const getNewContent = async () => {
745
- return Object.entries(config)
746
- .map(([key, value]) => `${toEnvVarName(key)}=${formatEnvValue(value)}`)
747
- .join('\n');
748
- };
749
- await updateFileSection(filePath, startComment, endComment, getNewContent);
629
+ const createDiffTree = (changes) => {
630
+ const root = new DiffTreeNode();
631
+ for (const change of changes) {
632
+ let currentParent = root;
633
+ const parts = change.filePath.split('/');
634
+ parts.pop();
635
+ for (const part of parts) {
636
+ let childNode = currentParent.getChild(part);
637
+ if (!childNode) {
638
+ childNode = new DiffTreeNode([...currentParent.path, part]);
639
+ currentParent.addChild(part, childNode);
640
+ }
641
+ currentParent = childNode;
642
+ }
643
+ // Create a NodeFile object and add it to the parent
644
+ currentParent.addFile({
645
+ filePath: change.filePath,
646
+ oldFilePath: change.oldFilePath,
647
+ summary: change.summary,
648
+ status: change.status,
649
+ });
650
+ }
651
+ return root;
750
652
  };
751
653
 
752
654
  /**
753
- * Load git profile config (from ~/.gitconfig)
754
- *
755
- * @param {Config} config
756
- * @returns {Config} Updated config
757
- **/
758
- function loadGitConfig(config) {
759
- const gitConfigPath = path__namespace.join(os__namespace.homedir(), '.gitconfig');
760
- if (fs__namespace.existsSync(gitConfigPath)) {
761
- const gitConfigRaw = fs__namespace.readFileSync(gitConfigPath, 'utf-8');
762
- const gitConfigParsed = ini__namespace.parse(gitConfigRaw);
763
- config = {
764
- ...config,
765
- model: gitConfigParsed.coco?.model || config.model,
766
- openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config.openAIApiKey,
767
- huggingFaceHubApiKey: gitConfigParsed.coco?.huggingFaceHubApiKey || config.huggingFaceHubApiKey,
768
- tokenLimit: parseInt(gitConfigParsed.coco?.tokenLimit) || config.tokenLimit,
769
- prompt: gitConfigParsed.coco?.prompt || config.prompt,
770
- mode: gitConfigParsed.coco?.mode || config.mode,
771
- temperature: gitConfigParsed.coco?.temperature || config.temperature,
772
- summarizePrompt: gitConfigParsed.coco?.summarizePrompt || config.summarizePrompt,
773
- ignoredFiles: gitConfigParsed.coco?.ignoredFiles || config.ignoredFiles,
774
- ignoredExtensions: gitConfigParsed.coco?.ignoredExtensions || config.ignoredExtensions,
775
- defaultBranch: gitConfigParsed.coco?.defaultBranch || config.defaultBranch,
655
+ * Asynchronously collect diffs for a given node and its children.
656
+ */
657
+ async function collectDiffs(node, getFileDiff, tokenizer, logger) {
658
+ // Collect diffs for the files of the current node
659
+ const diffPromises = node.files.map(async (nodeFile) => {
660
+ const diff = await getFileDiff(nodeFile);
661
+ const tokenCount = tokenizer(diff);
662
+ logger.verbose(`Collected diff for ${nodeFile.filePath} (${tokenCount} tokens)`, {
663
+ color: 'magenta',
664
+ });
665
+ return {
666
+ file: nodeFile.filePath,
667
+ summary: nodeFile.summary,
668
+ diff,
669
+ tokenCount,
776
670
  };
671
+ });
672
+ // Collect diffs for the children of the current node
673
+ const childrenPromises = Array.from(node.children.values()).map(async (child) => collectDiffs(child, getFileDiff, tokenizer, logger));
674
+ const [diffs, children] = await Promise.all([
675
+ Promise.all(diffPromises),
676
+ Promise.all(childrenPromises),
677
+ ]);
678
+ return {
679
+ path: node.getPath(),
680
+ diffs,
681
+ children,
682
+ };
683
+ }
684
+
685
+ function getModelAndProviderFromService(service) {
686
+ const [provider, model] = service.split(/\/(.*)/s);
687
+ if (!model || !provider) {
688
+ throw new Error(`Invalid service: ${service}`);
777
689
  }
778
- return config;
690
+ return { provider, model };
691
+ }
692
+ function getModelFromService(service) {
693
+ const { model } = getModelAndProviderFromService(service);
694
+ return model;
779
695
  }
780
696
  /**
781
- * Appends the provided configuration to a git config file.
782
- *
783
- * @param filePath - The path to the .gitconfig
784
- * @param config - The configuration object to append.
697
+ * Get LLM Model Based on Configuration
698
+ * @param fields
699
+ * @param configuration
700
+ * @returns LLM Model
785
701
  */
786
- const appendToGitConfig = async (filePath, config) => {
787
- if (!fs__namespace.existsSync(filePath)) {
788
- throw new Error(`File ${filePath} does not exist.`);
702
+ function getLlm(service, key, fields) {
703
+ const { provider, model } = getModelAndProviderFromService(service);
704
+ if (!model) {
705
+ throw new Error(`Invalid LLM Service: ${service}`);
789
706
  }
790
- const startComment = '# -- Start coco config --';
791
- const endComment = '# -- End coco config --';
792
- const header = '[coco]';
793
- // Function to generate new content for the coco section
794
- const getNewContent = async () => {
795
- const contentLines = [header];
796
- for (const key in config) {
797
- // check if string has new lines, if so, wrap in quotes
798
- if (typeof config[key] === 'string') {
799
- const value = config[key];
800
- if (value.includes('\n')) {
801
- contentLines.push(`\t${key} = ${JSON.stringify(value)}`);
802
- continue;
803
- }
804
- }
805
- contentLines.push(`\t${key} = ${config[key]}`);
806
- }
807
- return contentLines.join('\n');
808
- };
809
- // Use the updateFileSection utility
810
- await updateFileSection(filePath, startComment, endComment, getNewContent);
811
- };
812
-
707
+ switch (provider) {
708
+ case 'huggingface':
709
+ return new hf.HuggingFaceInference({
710
+ model: model,
711
+ apiKey: key,
712
+ maxConcurrency: 4,
713
+ ...fields,
714
+ });
715
+ case 'openai':
716
+ default:
717
+ return new openai.OpenAI({
718
+ openAIApiKey: key,
719
+ modelName: model,
720
+ ...fields,
721
+ });
722
+ }
723
+ }
813
724
  /**
814
- * Load .gitignore in project root
815
- *
816
- * @param {Config} config
725
+ * Retrieve appropriate API key based on selected model
726
+ * @param service
727
+ * @param options
817
728
  * @returns
818
729
  */
819
- function loadGitignore(config) {
820
- if (fs__namespace.existsSync('.gitignore')) {
821
- const gitignoreContent = fs__namespace.readFileSync('.gitignore', 'utf-8');
822
- config.ignoredFiles = [
823
- ...(config?.ignoredFiles || []),
824
- ...gitignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
825
- ];
730
+ function getApiKeyForModel(service, options) {
731
+ const { provider } = getModelAndProviderFromService(service);
732
+ switch (provider) {
733
+ case 'huggingface':
734
+ return options.huggingFaceHubApiKey;
735
+ case 'openai':
736
+ default:
737
+ return options.openAIApiKey;
826
738
  }
827
- return config;
828
739
  }
829
740
  /**
830
- * Load .ignore in project root
831
- *
832
- * @param {Config} config
741
+ * Get Recursive Character Text Splitter
742
+ * @param options
833
743
  * @returns
834
744
  */
835
- function loadIgnore(config) {
836
- if (fs__namespace.existsSync('.ignore')) {
837
- const ignoreContent = fs__namespace.readFileSync('.ignore', 'utf-8');
838
- config.ignoredFiles = [
839
- ...(config?.ignoredFiles || []),
840
- ...ignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
841
- ];
745
+ function getTextSplitter(options = {}) {
746
+ return new text_splitter.RecursiveCharacterTextSplitter(options);
747
+ }
748
+ /**
749
+ * Get Summarization Chain
750
+ * @param model
751
+ * @param options
752
+ * @returns
753
+ */
754
+ function getSummarizationChain(model, options = { type: 'map_reduce' }) {
755
+ return chains.loadSummarizationChain(model, options);
756
+ }
757
+ function getPrompt({ template, variables, fallback }) {
758
+ if (!template && !fallback)
759
+ throw new Error('Must provide either a template or a fallback');
760
+ return (template
761
+ ? new prompts.PromptTemplate({
762
+ template,
763
+ inputVariables: variables,
764
+ })
765
+ : fallback);
766
+ }
767
+ /**
768
+ * Verify template string contains all required input variables
769
+ * @param text template string
770
+ * @param inputVariables template variables
771
+ * @returns boolean or error message
772
+ */
773
+ function validatePromptTemplate(text, inputVariables) {
774
+ if (!text) {
775
+ return 'Prompt template cannot be empty';
776
+ }
777
+ if (!inputVariables.some((entry) => text.includes(entry))) {
778
+ return ('Prompt template must include at least one of the following input variables: ' +
779
+ inputVariables.map((value) => `{${value}}`).join(', '));
780
+ }
781
+ return true;
782
+ }
783
+
784
+ async function parseDefaultFileDiff(nodeFile, commit = '--staged', git) {
785
+ if (commit !== '--staged') {
786
+ return await git.diff([`${commit}~1..${commit}`, '--', nodeFile.filePath]);
787
+ }
788
+ return await git.diff([commit, nodeFile.filePath]);
789
+ }
790
+ async function parseRenamedFileDiff(nodeFile, commit, git, logger) {
791
+ let result = '';
792
+ const oldFilePath = nodeFile?.oldFilePath || nodeFile.filePath;
793
+ let previousCommitHash = 'HEAD';
794
+ let newCommitHash = '';
795
+ if (commit !== '--staged') {
796
+ try {
797
+ previousCommitHash = await git.revparse([`${commit}~1`]);
798
+ }
799
+ catch (err) {
800
+ logger.verbose(`Error getting previous commit hash for ${nodeFile.filePath}`, {
801
+ color: 'red',
802
+ });
803
+ }
804
+ newCommitHash = commit;
805
+ }
806
+ try {
807
+ const [previousContent, newContent] = await Promise.all([
808
+ git.show([`${previousCommitHash}:${oldFilePath}`]),
809
+ git.show([`${newCommitHash}:${nodeFile.filePath}`]),
810
+ ]);
811
+ if (previousContent !== newContent) {
812
+ result = diff.createTwoFilesPatch(oldFilePath, nodeFile.filePath, previousContent, newContent, '', '', {
813
+ context: 3,
814
+ });
815
+ // remove the first 4 lines of the patch (they contain the old and new file names)
816
+ result = result.split('\n').slice(4).join('\n');
817
+ }
818
+ else {
819
+ result = 'File contents are unchanged.';
820
+ }
821
+ }
822
+ catch (err) {
823
+ logger.verbose(`Error comparing file contents for ${nodeFile.filePath}`, { color: 'red' });
824
+ result = 'Error comparing file contents.';
825
+ }
826
+ return result;
827
+ }
828
+ async function getDiff(nodeFile, commit, { git, logger, }) {
829
+ if (nodeFile.status === 'deleted') {
830
+ return 'This file has been deleted.';
831
+ }
832
+ if (nodeFile.status === 'renamed' && nodeFile.oldFilePath) {
833
+ const renamedDiff = await parseRenamedFileDiff(nodeFile, commit, git, logger);
834
+ return renamedDiff;
835
+ }
836
+ // If not deleted or renamed, get the diff from the index
837
+ const defaultDiff = await parseDefaultFileDiff(nodeFile, commit, git);
838
+ return defaultDiff;
839
+ }
840
+
841
+ const MAX_TOKENS_PER_SUMMARY = 2048;
842
+ async function fileChangeParser({ changes, commit, options: { tokenizer, git, llm: model, logger }, }) {
843
+ const textSplitter = getTextSplitter({ chunkSize: 2000, chunkOverlap: 125 });
844
+ const summarizationChain = getSummarizationChain(model, {
845
+ type: 'map_reduce',
846
+ combineMapPrompt: SUMMARIZE_PROMPT,
847
+ combinePrompt: SUMMARIZE_PROMPT,
848
+ });
849
+ logger.startTimer();
850
+ const rootTreeNode = createDiffTree(changes);
851
+ logger.stopTimer('Created file hierarchy');
852
+ // Collect diffs
853
+ logger.startTimer().startSpinner(`Collecting Diffs...\n`, { color: 'blue' });
854
+ const diffs = await collectDiffs(rootTreeNode, (path) => getDiff(path, commit, { git, logger }), tokenizer, logger);
855
+ logger.stopSpinner('Diffs Collected').stopTimer();
856
+ // Summarize diffs
857
+ logger.startTimer();
858
+ const summary = await summarizeDiffs(diffs, {
859
+ tokenizer,
860
+ maxTokens: MAX_TOKENS_PER_SUMMARY,
861
+ textSplitter,
862
+ chain: summarizationChain,
863
+ logger,
864
+ });
865
+ logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
866
+ return summary;
867
+ }
868
+
869
+ const template$1 = `Write informative git commit message, in the imperative, based on the diffs & file changes provided in the "Diff Summary" section.
870
+ 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.
871
+
872
+ - Typically a hyphen or asterisk is used for the bullet
873
+ - Write concisely using an informal tone
874
+ - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
875
+ - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
876
+ - DO NOT use specific names or files from the code
877
+ - Wrap variable, class, function, components, and dependency names in back ticks e.g. \`variable\`
878
+
879
+ """{summary}"""
880
+
881
+ Commit:`;
882
+ const inputVariables$1 = ['summary'];
883
+ const COMMIT_PROMPT = new prompts.PromptTemplate({
884
+ template: template$1,
885
+ inputVariables: inputVariables$1,
886
+ });
887
+
888
+ function getStatus(file, location = 'index') {
889
+ if ('index' in file && 'working_dir' in file) {
890
+ const statusCode = file[location];
891
+ switch (statusCode) {
892
+ case 'A':
893
+ return 'added';
894
+ case 'D':
895
+ return 'deleted';
896
+ case 'M':
897
+ return 'modified';
898
+ case 'R':
899
+ return 'renamed';
900
+ case '?':
901
+ return 'untracked';
902
+ default:
903
+ return 'unknown';
904
+ }
905
+ }
906
+ else if ('changes' in file && 'binary' in file) {
907
+ if (file.changes === 0)
908
+ return 'untracked';
909
+ if (file.file.includes('=>'))
910
+ return 'renamed';
911
+ if (file.deletions === 0 && file.insertions > 0)
912
+ return 'added';
913
+ if (file.insertions === 0 && file.deletions > 0)
914
+ return 'deleted';
915
+ if ((file.insertions > 0 && file.deletions > 0) || file.changes > 0)
916
+ return 'modified';
917
+ return 'unknown';
842
918
  }
843
- return config;
844
- }
845
-
846
- /**
847
- * Load project config
848
- *
849
- * @param {Config} config
850
- * @returns {Config} Updated config
851
- **/
852
- function loadProjectConfig(config) {
853
- // TODO: Add validation based of JSON schema?
854
- // @see https://github.com/acornejo/jjv
855
- if (fs__namespace.existsSync('.coco.config.json')) {
856
- const projectConfig = JSON.parse(fs__namespace.readFileSync('.coco.config.json', 'utf-8'));
857
- config = { ...config, ...projectConfig };
919
+ else {
920
+ throw new Error('Invalid file type');
858
921
  }
859
- return config;
860
922
  }
861
- const appendToProjectConfig = (filePath, config) => {
862
- fs__namespace.writeFileSync(filePath, JSON.stringify({
863
- $schema: 'https://git-co.co/schema.json',
864
- ...config,
865
- }, null, 2));
866
- };
867
923
 
868
- /**
869
- * Load XDG config
870
- *
871
- * @param {Config} config
872
- * @returns {Config} Updated config
873
- */
874
- function loadXDGConfig(config) {
875
- const xdgConfigHome = process.env.XDG_CONFIG_HOME || path__namespace.join(os__namespace.homedir(), '.config');
876
- const xdgConfigPath = path__namespace.join(xdgConfigHome, 'coco', 'config.json');
877
- if (fs__namespace.existsSync(xdgConfigPath)) {
878
- const xdgConfig = JSON.parse(fs__namespace.readFileSync(xdgConfigPath, 'utf-8'));
879
- config = { ...config, ...xdgConfig };
924
+ function getSummaryText(file, change) {
925
+ const status = change.status || getStatus(file);
926
+ let filePath;
927
+ if ('path' in file) {
928
+ filePath = file.path;
880
929
  }
881
- return config;
882
- }
883
-
884
- /**
885
- * Load application config
886
- *
887
- * Merge config from multiple sources.
888
- *
889
- * \* Order of precedence:
890
- * \* 1. Command line flags
891
- * \* 2. Environment variables
892
- * \* 3. Project config
893
- * \* 4. Git config
894
- * \* 5. XDG config
895
- * \* 6. .gitignore
896
- * \* 7. .ignore
897
- * \* 8. Default config
898
- *
899
- * @returns {Config} application config
900
- **/
901
- function loadConfig(argv = {}) {
902
- // Default config
903
- let config = DEFAULT_CONFIG;
904
- config = loadGitignore(config);
905
- config = loadIgnore(config);
906
- config = loadXDGConfig(config);
907
- config = loadGitConfig(config);
908
- config = loadProjectConfig(config);
909
- config = loadEnvConfig(config);
910
- return { ...config, ...argv };
930
+ else if ('file' in file) {
931
+ filePath = change?.filePath || file.file;
932
+ }
933
+ else {
934
+ throw new Error('Invalid file type');
935
+ }
936
+ if (change.oldFilePath) {
937
+ return `${status}: ${change.oldFilePath} -> ${filePath}`;
938
+ }
939
+ return `${status}: ${filePath}`;
911
940
  }
912
941
 
913
942
  const config = loadConfig();
@@ -995,11 +1024,6 @@ async function noResult({ git, logger }) {
995
1024
  }
996
1025
  }
997
1026
 
998
- const isInteractive = (argv) => {
999
- return argv?.mode === 'interactive' || argv.interactive;
1000
- };
1001
- const SEPERATOR = chalk.blue('----------------');
1002
-
1003
1027
  function logResult(label, result) {
1004
1028
  console.log(`\n${chalk.bgBlue(chalk.bold(`Proposed ${label}:`))}\n${SEPERATOR}\n${result}\n${SEPERATOR}\n`);
1005
1029
  }
@@ -1016,41 +1040,51 @@ async function editResult(result, options) {
1016
1040
  return result;
1017
1041
  }
1018
1042
 
1019
- async function getUserReviewDecision() {
1020
- return await prompts$1.select({
1021
- message: 'Would you like to make any changes to the commit message?',
1022
- choices: [
1023
- {
1024
- name: '✨ Looks good!',
1025
- value: 'approve',
1026
- description: 'Commit staged changes with generated commit message',
1027
- },
1028
- {
1029
- name: 'πŸ“ Edit',
1030
- value: 'edit',
1031
- description: 'Edit the commit message before proceeding',
1032
- },
1033
- {
1034
- name: 'πŸͺΆ Modify Prompt',
1035
- value: 'modifyPrompt',
1036
- description: 'Modify the prompt template and regenerate the commit message',
1037
- },
1038
- {
1039
- name: 'πŸ”„ Retry - Message Only',
1040
- value: 'retryMessageOnly',
1041
- description: 'Restart the function execution from generating the commit message',
1042
- },
1043
- {
1044
- name: 'πŸ”„ Retry - Full',
1045
- value: 'retryFull',
1046
- description: 'Restart the function execution from the beginning, regenerating both the diff summary and commit message',
1047
- },
1048
- {
1049
- name: 'πŸ’£ Cancel',
1050
- value: 'cancel',
1051
- },
1052
- ],
1043
+ async function getUserReviewDecision({ label, descriptions, enableRetry = true, enableFullRetry = true, enableModifyPrompt = true, }) {
1044
+ const choices = [
1045
+ {
1046
+ name: '✨ Looks good!',
1047
+ value: 'approve',
1048
+ description: descriptions?.approve || `Continue with the generated ${label}`,
1049
+ },
1050
+ {
1051
+ name: 'πŸ“ Edit',
1052
+ value: 'edit',
1053
+ description: descriptions?.edit || `Edit the generated ${label} before proceeding`,
1054
+ },
1055
+ ];
1056
+ if (enableModifyPrompt) {
1057
+ choices.push({
1058
+ name: 'πŸͺΆ Modify Prompt',
1059
+ value: 'modifyPrompt',
1060
+ description: descriptions?.modifyPrompt || `Modify the prompt template and regenerate the ${label}`,
1061
+ });
1062
+ }
1063
+ if (enableRetry) {
1064
+ choices.push({
1065
+ name: 'πŸ”„ Retry',
1066
+ value: 'retryMessageOnly',
1067
+ description: descriptions?.retryMessageOnly ||
1068
+ `Restart the function execution from generating the ${label}`,
1069
+ });
1070
+ }
1071
+ if (enableFullRetry) {
1072
+ choices.push({
1073
+ name: 'πŸ”„ Retry Full',
1074
+ value: 'retryFull',
1075
+ description: descriptions?.retryFull ||
1076
+ `Restart the function execution from the beginning, regenerating both the summary and ${label}`,
1077
+ });
1078
+ }
1079
+ choices.push({
1080
+ name: 'πŸ’£ Cancel',
1081
+ value: 'cancel',
1082
+ description: descriptions?.cancel || `Cancel the ${label}`,
1053
1083
  });
1084
+ return (await prompts$1.select({
1085
+ message: `Would you like to make any changes to the ${label}?`,
1086
+ choices,
1087
+ }));
1054
1088
  }
1055
1089
 
1056
1090
  async function editPrompt(options) {
@@ -1103,7 +1137,10 @@ async function generateAndReviewLoop({ label, factory, parser, noResult, agent,
1103
1137
  .stopTimer();
1104
1138
  if (options?.interactive) {
1105
1139
  logResult(label, result);
1106
- const reviewAnswer = await getUserReviewDecision();
1140
+ const reviewAnswer = await getUserReviewDecision({
1141
+ label,
1142
+ ...options?.review || {},
1143
+ });
1107
1144
  if (reviewAnswer === 'cancel') {
1108
1145
  process.exit(0);
1109
1146
  }
@@ -1157,20 +1194,20 @@ const executeChain = async ({ llm, prompt, variables }) => {
1157
1194
  return res.text.trim();
1158
1195
  };
1159
1196
 
1160
- async function createCommit(commitMsg, git) {
1161
- return await git.commit(commitMsg);
1162
- }
1163
-
1164
1197
  const logSuccess = () => {
1165
1198
  console.log(chalk.green(chalk.bold('\nAll set! πŸ¦ΎπŸ€–')));
1166
1199
  };
1167
1200
 
1168
- const handleResult = async (result, { mode, git }) => {
1169
- // Handle resulting commit message
1201
+ async function handleResult({ result, mode, interactiveHandler }) {
1170
1202
  switch (mode) {
1171
1203
  case 'interactive':
1172
- await createCommit(result, git);
1173
- logSuccess();
1204
+ if (interactiveHandler) {
1205
+ await interactiveHandler(result);
1206
+ }
1207
+ else {
1208
+ console.warn('No result handler provided for interactive mode.');
1209
+ logSuccess();
1210
+ }
1174
1211
  break;
1175
1212
  case 'stdout':
1176
1213
  default:
@@ -1178,7 +1215,7 @@ const handleResult = async (result, { mode, git }) => {
1178
1215
  break;
1179
1216
  }
1180
1217
  process.exit(0);
1181
- };
1218
+ }
1182
1219
 
1183
1220
  const getRepo = () => {
1184
1221
  let git;
@@ -1192,21 +1229,38 @@ const getRepo = () => {
1192
1229
  return git;
1193
1230
  };
1194
1231
 
1195
- async function handler$2(argv) {
1196
- const tokenizer = getTokenizer();
1232
+ const getTikToken = async (modelName) => {
1233
+ return await tiktoken.encoding_for_model(modelName);
1234
+ };
1235
+ const getTokenCounter = async (modelName) => getTikToken(modelName).then((tokenizer) => (text) => {
1236
+ // console.log('Running GetTokenCount', { tokenizer, length: text.length })
1237
+ const tokens = tokenizer.encode(text);
1238
+ // console.log('Tokens', { tokenCount: tokens.length })
1239
+ return tokens.length;
1240
+ });
1241
+
1242
+ async function createCommit(commitMsg, git) {
1243
+ return await git.commit(commitMsg);
1244
+ }
1245
+
1246
+ const handler$2 = async (argv, logger) => {
1197
1247
  const git = getRepo();
1198
1248
  const options = loadConfig(argv);
1199
- const logger = new Logger(options);
1200
- const key = getApiKeyForModel(options.model, options);
1249
+ const { service } = options;
1250
+ const key = getApiKeyForModel(service, options);
1251
+ const tokenizer = await getTokenCounter(getModelFromService(service));
1201
1252
  if (!key) {
1202
1253
  logger.log(`No API Key found. πŸ—οΈπŸšͺ`, { color: 'red' });
1203
1254
  process.exit(1);
1204
1255
  }
1205
- const model = getModel(options.model, key, {
1256
+ const llm = getLlm(service, key, {
1206
1257
  temperature: 0.4,
1207
1258
  maxConcurrency: 10,
1208
1259
  });
1209
1260
  const INTERACTIVE = isInteractive(options);
1261
+ if (INTERACTIVE) {
1262
+ logger.log(LOGO);
1263
+ }
1210
1264
  async function factory() {
1211
1265
  const changes = await getChanges({ git });
1212
1266
  return changes.staged;
@@ -1215,16 +1269,16 @@ async function handler$2(argv) {
1215
1269
  return await fileChangeParser({
1216
1270
  changes,
1217
1271
  commit: '--staged',
1218
- options: { tokenizer, git, model, logger },
1272
+ options: { tokenizer, git, llm, logger },
1219
1273
  });
1220
1274
  }
1221
1275
  const commitMsg = await generateAndReviewLoop({
1222
- label: 'Commit Message',
1276
+ label: 'commit message',
1223
1277
  factory,
1224
1278
  parser,
1225
1279
  agent: async (context, options) => {
1226
1280
  return await executeChain({
1227
- llm: model,
1281
+ llm,
1228
1282
  prompt: getPrompt({
1229
1283
  template: options.prompt,
1230
1284
  variables: COMMIT_PROMPT.inputVariables,
@@ -1242,14 +1296,27 @@ async function handler$2(argv) {
1242
1296
  prompt: options.prompt || COMMIT_PROMPT.template,
1243
1297
  logger,
1244
1298
  interactive: INTERACTIVE,
1299
+ review: {
1300
+ descriptions: {
1301
+ approve: `Commit staged changes with generated commit message`,
1302
+ edit: 'Edit the commit message before proceeding',
1303
+ modifyPrompt: 'Modify the prompt template and regenerate the commit message',
1304
+ retryMessageOnly: 'Restart the function execution from generating the commit message',
1305
+ retryFull: 'Restart the function execution from the beginning, regenerating both the diff summary and commit message',
1306
+ },
1307
+ },
1245
1308
  },
1246
1309
  });
1247
1310
  const MODE = (INTERACTIVE && 'interactive') || (options.commit && 'interactive') || options?.mode || 'stdout';
1248
- handleResult(commitMsg, {
1311
+ handleResult({
1312
+ result: commitMsg,
1313
+ interactiveHandler: async (result) => {
1314
+ await createCommit(result, git);
1315
+ logSuccess();
1316
+ },
1249
1317
  mode: MODE,
1250
- git,
1251
1318
  });
1252
- }
1319
+ };
1253
1320
 
1254
1321
  /**
1255
1322
  * Command line options via yargs
@@ -1308,7 +1375,7 @@ var commit = {
1308
1375
  command: 'commit',
1309
1376
  desc: 'Generate commit message',
1310
1377
  builder: builder$2,
1311
- handler: handler$2,
1378
+ handler: commandExecutor(handler$2),
1312
1379
  options: options$2,
1313
1380
  };
1314
1381
 
@@ -1385,20 +1452,22 @@ async function getCommitLogCurrentBranch({ git, logger, comparisonBranch = 'main
1385
1452
  return [];
1386
1453
  }
1387
1454
 
1388
- async function handler$1(argv) {
1455
+ const handler$1 = async (argv, logger) => {
1389
1456
  const options = loadConfig(argv);
1390
- const logger = new Logger(options);
1391
1457
  const git = getRepo();
1392
- const key = getApiKeyForModel(options.model, options);
1458
+ const key = getApiKeyForModel(options.service, options);
1393
1459
  if (!key) {
1394
1460
  logger.log(`No API Key found. πŸ—οΈπŸšͺ`, { color: 'red' });
1395
1461
  process.exit(1);
1396
1462
  }
1397
- const model = getModel(options.model, key, {
1463
+ const model = getLlm(options.service, key, {
1398
1464
  temperature: 0.4,
1399
1465
  maxConcurrency: 10,
1400
1466
  });
1401
1467
  const INTERACTIVE = isInteractive(options);
1468
+ if (INTERACTIVE) {
1469
+ logger.log(LOGO);
1470
+ }
1402
1471
  async function factory() {
1403
1472
  if (options.range) {
1404
1473
  const [from, to] = options.range?.split(':');
@@ -1416,7 +1485,7 @@ async function handler$1(argv) {
1416
1485
  return result;
1417
1486
  }
1418
1487
  const changelogMsg = await generateAndReviewLoop({
1419
- label: 'Changelog',
1488
+ label: 'changelog',
1420
1489
  factory,
1421
1490
  parser,
1422
1491
  agent: async (context, options) => {
@@ -1444,14 +1513,20 @@ async function handler$1(argv) {
1444
1513
  prompt: options.prompt || CHANGELOG_PROMPT.template,
1445
1514
  logger,
1446
1515
  interactive: INTERACTIVE,
1516
+ review: {
1517
+ enableFullRetry: false,
1518
+ },
1447
1519
  },
1448
1520
  });
1449
1521
  const MODE = (INTERACTIVE && 'interactive') || (options.commit && 'interactive') || options?.mode || 'stdout';
1450
- handleResult(changelogMsg, {
1522
+ handleResult({
1523
+ result: changelogMsg,
1524
+ interactiveHandler: async () => {
1525
+ logSuccess();
1526
+ },
1451
1527
  mode: MODE,
1452
- git,
1453
1528
  });
1454
- }
1529
+ };
1455
1530
 
1456
1531
  /**
1457
1532
  * Command line options via yargs
@@ -1510,39 +1585,138 @@ var changelog = {
1510
1585
  command: 'changelog',
1511
1586
  desc: 'Generate a changelog from a commit range',
1512
1587
  builder: builder$1,
1513
- handler: handler$1,
1588
+ handler: commandExecutor(handler$1),
1514
1589
  options: options$1,
1515
1590
  };
1516
1591
 
1517
- const handleProjectLevelConfig = async () => {
1518
- const projectConfiguration = await prompts$1.select({
1519
- message: 'select type project level configuration:',
1520
- choices: [
1521
- {
1522
- name: '.coco.config.json',
1523
- value: '.coco.config.json',
1524
- },
1525
- {
1526
- name: '.env',
1527
- value: '.env',
1528
- },
1529
- ],
1592
+ /**
1593
+ * Finds the project root directory starting from the given current directory.
1594
+ * It checks if the `.git` directory or `package.json` file exists in the current directory or any of its parent directories.
1595
+ * If found, it returns the path to the project root directory.
1596
+ * If not found, it throws an error.
1597
+ *
1598
+ * @param currentDir - The current directory to start searching from.
1599
+ * @returns The path to the project root directory.
1600
+ * @throws Error if the project root directory cannot be found.
1601
+ */
1602
+ function findProjectRoot(currentDir) {
1603
+ const root = path.parse(currentDir).root;
1604
+ while (currentDir !== root) {
1605
+ if (fs.existsSync(path.join(currentDir, '.git')) ||
1606
+ fs.existsSync(path.join(currentDir, 'package.json'))) {
1607
+ return currentDir;
1608
+ }
1609
+ currentDir = path.dirname(currentDir);
1610
+ }
1611
+ throw new Error('Unable to find project root. Are you in the right directory?');
1612
+ }
1613
+
1614
+ // Function to execute a command and return a promise
1615
+ function execPromise(command, options = {}) {
1616
+ return new Promise((resolve, reject) => {
1617
+ child_process.exec(command, options, (error, stdout, stderr) => {
1618
+ if (error) {
1619
+ reject(`Execution error: ${error}`);
1620
+ }
1621
+ else {
1622
+ resolve({ stdout, stderr });
1623
+ }
1624
+ });
1530
1625
  });
1531
- let configFile = '.coco.config.json';
1532
- if (projectConfiguration === '.env') {
1533
- configFile = '.env';
1534
- if (!fs.existsSync('.env')) {
1535
- fs.writeFileSync('.env', '');
1626
+ }
1627
+
1628
+ /**
1629
+ * Installs a package using npm.
1630
+ *
1631
+ * @param {InstallPackageInput} options - The options for installing the package.
1632
+ * @returns {Promise<boolean>} - A promise that resolves to true if the package is installed successfully, false otherwise.
1633
+ */
1634
+ async function installNpmPackage({ name, flags = [], cwd = process.cwd(), }) {
1635
+ const { stdout, stderr } = await execPromise(`npm i ${name} ${flags.join(' ')} --yes`, { cwd });
1636
+ if (stderr) {
1637
+ console.error(`Execution error: ${stderr}`);
1638
+ return false;
1639
+ }
1640
+ console.log(stdout);
1641
+ console.error(stderr);
1642
+ return true;
1643
+ }
1644
+
1645
+ /**
1646
+ * Checks if a package is installed in a project.
1647
+ *
1648
+ * @param packageName - The name of the package to check.
1649
+ * @param projectPath - The path to the project.
1650
+ * @returns True if the package is installed, false otherwise.
1651
+ */
1652
+ function isPackageInstalled(packageName, projectPath) {
1653
+ try {
1654
+ // Construct the path to the package.json file
1655
+ const packageJsonPath = path.join(projectPath, 'package.json');
1656
+ // Read the package.json file
1657
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
1658
+ // Check both dependencies and devDependencies
1659
+ const dependencies = packageJson.dependencies || {};
1660
+ const devDependencies = packageJson.devDependencies || {};
1661
+ // Return true if the package is found in either
1662
+ return dependencies.hasOwnProperty(packageName) || devDependencies.hasOwnProperty(packageName);
1663
+ }
1664
+ catch (error) {
1665
+ console.error(`Error checking package installation: ${error.message}`);
1666
+ return false;
1667
+ }
1668
+ }
1669
+
1670
+ // TODO: QoL improvement to import this from `package.json`
1671
+ const packageName = 'git-coco';
1672
+ async function checkAndHandlePackageInstallation({ global = false, logger, }) {
1673
+ try {
1674
+ // Global installation
1675
+ if (global) {
1676
+ logger.startSpinner(`Installing '${packageName}' globally...`, { color: 'blue' });
1677
+ await installNpmPackage({ name: packageName, flags: ['-g'] });
1678
+ logger.stopSpinner(`Installed '${packageName}' globally`);
1679
+ return;
1680
+ }
1681
+ // Project level installation
1682
+ const projectRoot = findProjectRoot(process.cwd());
1683
+ let shouldInstall = false;
1684
+ if (isPackageInstalled(packageName, projectRoot)) {
1685
+ shouldInstall = await prompts$1.confirm({
1686
+ message: `'${packageName}' is already installed in '${projectRoot}/package.json', would you like to update?`,
1687
+ default: shouldInstall,
1688
+ });
1689
+ }
1690
+ else {
1691
+ shouldInstall = true;
1692
+ }
1693
+ if (!shouldInstall) {
1694
+ return;
1536
1695
  }
1696
+ logger.startSpinner(`Installing '${packageName}' in project...`, { color: 'blue' });
1697
+ await installNpmPackage({ name: packageName, cwd: projectRoot });
1698
+ logger.stopSpinner(`Installed '${packageName}' in project`);
1537
1699
  }
1538
- return configFile;
1539
- };
1540
- const handleSystemLevelConfig = () => {
1700
+ catch (error) {
1701
+ console.error(`Error: ${error.message}`);
1702
+ }
1703
+ }
1704
+
1705
+ function getPathToUsersGitConfig() {
1541
1706
  return path.join(os.homedir(), '.gitconfig');
1542
- };
1543
- async function handler(argv) {
1544
- const options = loadConfig(argv);
1545
- const logger = new Logger(options);
1707
+ }
1708
+
1709
+ async function createProjectFileAndReturnPath(fileName, contents) {
1710
+ const projectRoot = findProjectRoot(process.cwd());
1711
+ const configFile = `${projectRoot}/${fileName}`;
1712
+ if (!fs.existsSync(configFile)) {
1713
+ fs.writeFileSync(configFile, contents || '');
1714
+ }
1715
+ return configFile;
1716
+ }
1717
+
1718
+ const handler = async (argv, logger) => {
1719
+ logger.log(LOGO);
1546
1720
  const level = await prompts$1.select({
1547
1721
  message: 'configure coco at the system or project level:',
1548
1722
  choices: [
@@ -1560,11 +1734,25 @@ async function handler(argv) {
1560
1734
  });
1561
1735
  let configFilePath = '';
1562
1736
  switch (level) {
1563
- case 'system':
1564
- configFilePath = await handleSystemLevelConfig();
1565
- break;
1566
1737
  case 'project':
1567
- configFilePath = await handleProjectLevelConfig();
1738
+ const projectConfiguration = await prompts$1.select({
1739
+ message: 'select type project level configuration:',
1740
+ choices: [
1741
+ {
1742
+ name: '.coco.config.json',
1743
+ value: '.coco.config.json',
1744
+ },
1745
+ {
1746
+ name: '.env',
1747
+ value: '.env',
1748
+ },
1749
+ ],
1750
+ });
1751
+ configFilePath = await createProjectFileAndReturnPath(projectConfiguration);
1752
+ break;
1753
+ case 'system':
1754
+ default:
1755
+ configFilePath = getPathToUsersGitConfig();
1568
1756
  break;
1569
1757
  }
1570
1758
  // interactive v.s stdout mode
@@ -1661,7 +1849,7 @@ async function handler(argv) {
1661
1849
  // add to config after logging, so that the API key is not logged
1662
1850
  config.openAIApiKey = apiKey;
1663
1851
  const isApproved = await prompts$1.confirm({
1664
- message: 'look good? (hiding API key for security)',
1852
+ message: 'looking good? (API key hidden for security)',
1665
1853
  });
1666
1854
  if (isApproved) {
1667
1855
  if (configFilePath.endsWith('.gitconfig')) {
@@ -1673,62 +1861,19 @@ async function handler(argv) {
1673
1861
  else if (configFilePath === '.coco.config.json') {
1674
1862
  await appendToProjectConfig(configFilePath, config);
1675
1863
  }
1676
- logger.log(`init successful! πŸ¦ΎπŸ€–πŸŽ‰`, { color: 'green' });
1864
+ // After config is written, check for package installation
1865
+ await checkAndHandlePackageInstallation({ global: level === 'system', logger });
1866
+ logger.log(`\ninit successful! πŸ¦ΎπŸ€–πŸŽ‰`, { color: 'green' });
1677
1867
  }
1678
1868
  else {
1679
- logger.log('init cancelled.', { color: 'yellow' });
1869
+ logger.log('\ninit cancelled.', { color: 'yellow' });
1680
1870
  }
1681
- }
1871
+ };
1682
1872
 
1683
1873
  /**
1684
1874
  * Command line options via yargs
1685
1875
  */
1686
- const options = {
1687
- model: { type: 'string', description: 'LLM/Model-Name' },
1688
- openAIApiKey: {
1689
- type: 'string',
1690
- description: 'OpenAI API Key',
1691
- conflicts: 'huggingFaceHubApiKey',
1692
- },
1693
- huggingFaceHubApiKey: {
1694
- type: 'string',
1695
- description: 'HuggingFace Hub API Key',
1696
- conflicts: 'openAIApiKey',
1697
- },
1698
- tokenLimit: { type: 'number', description: 'Token limit' },
1699
- prompt: {
1700
- type: 'string',
1701
- alias: 'p',
1702
- description: 'Commit message prompt',
1703
- },
1704
- i: {
1705
- type: 'boolean',
1706
- alias: 'interactive',
1707
- description: 'Toggle interactive mode',
1708
- },
1709
- s: {
1710
- type: 'boolean',
1711
- description: 'Automatically commit staged changes with generated commit message',
1712
- default: false,
1713
- },
1714
- e: {
1715
- type: 'boolean',
1716
- alias: 'edit',
1717
- description: 'Open commit message in editor before proceeding',
1718
- },
1719
- summarizePrompt: {
1720
- type: 'string',
1721
- description: 'Large file summary prompt',
1722
- },
1723
- ignoredFiles: {
1724
- type: 'array',
1725
- description: 'Ignored files',
1726
- },
1727
- ignoredExtensions: {
1728
- type: 'array',
1729
- description: 'Ignored extensions',
1730
- },
1731
- };
1876
+ const options = {};
1732
1877
  const builder = (yargs) => {
1733
1878
  return yargs.options(options);
1734
1879
  };
@@ -1737,7 +1882,7 @@ var init = {
1737
1882
  command: 'init',
1738
1883
  desc: 'Setup coco for a new project or system',
1739
1884
  builder,
1740
- handler,
1885
+ handler: commandExecutor(handler),
1741
1886
  options,
1742
1887
  };
1743
1888
 
@@ -1745,26 +1890,24 @@ var types = /*#__PURE__*/Object.freeze({
1745
1890
  __proto__: null
1746
1891
  });
1747
1892
 
1748
- yargs
1749
- .scriptName('coco')
1750
- .usage('$0 <cmd> [args]')
1751
- .command([commit.command, '$0'], commit.desc,
1893
+ const y = yargs();
1894
+ y.scriptName('coco').usage('$0 <cmd> [args]');
1895
+ y.command([commit.command, '$0'], commit.desc,
1752
1896
  // TODO: fix type on builder
1753
1897
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1754
1898
  // @ts-ignore
1755
- commit.builder, commit.handler)
1756
- .command(changelog.command, changelog.desc,
1899
+ commit.builder, commit.handler);
1900
+ y.command(changelog.command, changelog.desc,
1757
1901
  // TODO: fix type on builder
1758
1902
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1759
1903
  // @ts-ignore
1760
- changelog.builder, changelog.handler)
1761
- .command(init.command, init.desc,
1904
+ changelog.builder, changelog.handler);
1905
+ y.command(init.command, init.desc,
1762
1906
  // TODO: fix type on builder
1763
1907
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1764
1908
  // @ts-ignore
1765
- init.builder, init.handler)
1766
- .demandCommand()
1767
- .help().argv;
1909
+ init.builder, init.handler);
1910
+ y.parse(process.argv.slice(2));
1768
1911
 
1769
1912
  exports.changelog = changelog;
1770
1913
  exports.commit = commit;