@traisetech/autopilot 2.0.0 → 2.1.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.
@@ -2,27 +2,52 @@ const fs = require('fs-extra');
2
2
  const path = require('path');
3
3
  const logger = require('../utils/logger');
4
4
  const { DEFAULT_CONFIG } = require('./defaults');
5
- const { getConfigPath } = require('../utils/paths');
5
+ const { getConfigPath, getConfigDir, ensureConfigDir } = require('../utils/paths');
6
+
7
+ const getGlobalConfigPath = () => path.join(getConfigDir(), 'config.json');
6
8
 
7
9
  const loadConfig = async (repoPath) => {
10
+ let config = { ...DEFAULT_CONFIG };
11
+
12
+ // 1. Load Global Config
13
+ try {
14
+ const globalPath = getGlobalConfigPath();
15
+ if (await fs.pathExists(globalPath)) {
16
+ const globalConfig = await fs.readJson(globalPath);
17
+ config = { ...config, ...globalConfig };
18
+ logger.debug(`Loaded global config from ${globalPath}`);
19
+ }
20
+ } catch (error) {
21
+ logger.warn(`Error loading global config: ${error.message}`);
22
+ }
23
+
24
+ // 2. Load Local Config (Overrides Global)
8
25
  const configPath = getConfigPath(repoPath);
9
26
  try {
10
27
  if (await fs.pathExists(configPath)) {
11
- const config = await fs.readJson(configPath);
12
- logger.debug(`Loaded config from ${configPath}`);
13
- return { ...DEFAULT_CONFIG, ...config };
28
+ const localConfig = await fs.readJson(configPath);
29
+ config = { ...config, ...localConfig };
30
+ logger.debug(`Loaded local config from ${configPath}`);
14
31
  }
15
32
  } catch (error) {
16
- logger.warn(`Error loading config: ${error.message}`);
33
+ logger.warn(`Error loading local config: ${error.message}`);
17
34
  }
18
- return { ...DEFAULT_CONFIG };
35
+
36
+ return config;
19
37
  };
20
38
 
21
- const saveConfig = async (repoPath, config) => {
22
- const configPath = getConfigPath(repoPath);
39
+ const saveConfig = async (repoPath, config, isGlobal = false) => {
23
40
  try {
24
- await fs.writeJson(configPath, config, { spaces: 2 });
25
- logger.success(`Config saved to ${configPath}`);
41
+ let targetPath;
42
+ if (isGlobal) {
43
+ await ensureConfigDir();
44
+ targetPath = getGlobalConfigPath();
45
+ } else {
46
+ targetPath = getConfigPath(repoPath);
47
+ }
48
+
49
+ await fs.writeJson(targetPath, config, { spaces: 2 });
50
+ logger.success(`Config saved to ${targetPath}`);
26
51
  } catch (error) {
27
52
  logger.error(`Failed to save config: ${error.message}`);
28
53
  }
@@ -44,4 +69,5 @@ module.exports = {
44
69
  loadConfig,
45
70
  saveConfig,
46
71
  createDefaultConfig,
72
+ getGlobalConfigPath
47
73
  };
@@ -5,7 +5,8 @@
5
5
 
6
6
  const path = require('path');
7
7
  const logger = require('../utils/logger');
8
- const { generateAICommitMessage } = require('./gemini');
8
+ const gemini = require('./gemini');
9
+ const grok = require('./grok');
9
10
  const HistoryManager = require('./history');
10
11
 
11
12
  /**
@@ -19,13 +20,22 @@ async function generateCommitMessage(files, diffContent, config = {}) {
19
20
  let message = '';
20
21
  if (!files || files.length === 0) {
21
22
  message = 'chore: update changes';
22
- } else if (config.commitMessageMode === 'ai' && config.ai?.enabled && config.ai?.apiKey) {
23
+ } else if (config.commitMessageMode === 'ai' && config.ai?.enabled) {
23
24
  // AI Mode
24
25
  try {
25
- logger.info('Generating AI commit message...');
26
- message = await generateAICommitMessage(diffContent, config.ai.apiKey);
26
+ const provider = config.ai.provider || 'gemini';
27
+ logger.info(`Generating AI commit message using ${provider}...`);
28
+
29
+ if (provider === 'grok') {
30
+ if (!config.ai.grokApiKey) throw new Error('Grok API Key not configured');
31
+ message = await grok.generateGrokCommitMessage(diffContent, config.ai.grokApiKey, config.ai.grokModel);
32
+ } else {
33
+ // Default to Gemini
34
+ if (!config.ai.apiKey) throw new Error('Gemini API Key not configured');
35
+ message = await gemini.generateAICommitMessage(diffContent, config.ai.apiKey, config.ai.model);
36
+ }
27
37
  } catch (error) {
28
- logger.warn('AI generation failed, falling back to smart generation.');
38
+ logger.warn(`AI generation failed (${error.message}), falling back to smart generation.`);
29
39
  message = generateSmartCommitMessage(files, diffContent);
30
40
  }
31
41
  } else {
@@ -340,7 +350,32 @@ function generateBody(analysis, files) {
340
350
  return bullets;
341
351
  }
342
352
 
353
+ async function addTrailers(message) {
354
+ const { getIdentity } = require('../utils/identity');
355
+ const { generateSignature } = require('../utils/crypto');
356
+ const { version } = require('../../package.json');
357
+
358
+ const identity = await getIdentity();
359
+ const timestamp = Date.now().toString();
360
+
361
+ // Content to sign: message|timestamp|version|userId
362
+ // This ensures integrity of the whole commit data
363
+ const contentToSign = `${message}|${timestamp}|${version}|${identity.id}`;
364
+ const signature = generateSignature(contentToSign, identity.id);
365
+
366
+ const trailers = [
367
+ '',
368
+ 'Autopilot-Commit: true',
369
+ `Autopilot-Version: ${version}`,
370
+ `Autopilot-User: ${identity.id}`,
371
+ `Autopilot-Signature: ${signature}`
372
+ ];
373
+
374
+ return message + trailers.join('\n');
375
+ }
376
+
343
377
  module.exports = {
344
378
  generateCommitMessage,
345
- parseDiff
379
+ parseDiff,
380
+ addTrailers
346
381
  };
@@ -0,0 +1,105 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const { getConfigDir } = require('../utils/paths');
4
+ const logger = require('../utils/logger');
5
+
6
+ const getQueuePath = () => path.join(getConfigDir(), 'events-queue.json');
7
+
8
+ /**
9
+ * Telemetry/Event System for Autopilot
10
+ * Handles reliable event emission with local queuing
11
+ */
12
+ class EventSystem {
13
+ constructor() {
14
+ this.queue = [];
15
+ this.isFlushing = false;
16
+ }
17
+
18
+ async loadQueue() {
19
+ try {
20
+ const queuePath = getQueuePath();
21
+ if (await fs.pathExists(queuePath)) {
22
+ this.queue = await fs.readJson(queuePath);
23
+ }
24
+ } catch (error) {
25
+ logger.debug(`Failed to load event queue: ${error.message}`);
26
+ }
27
+ }
28
+
29
+ async saveQueue() {
30
+ try {
31
+ await fs.ensureDir(getConfigDir());
32
+ await fs.writeJson(getQueuePath(), this.queue, { spaces: 2 });
33
+ } catch (error) {
34
+ logger.error(`Failed to save event queue: ${error.message}`);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Queue an event for emission
40
+ * @param {object} event - Event payload
41
+ */
42
+ async emit(event) {
43
+ await this.loadQueue();
44
+
45
+ // Add metadata
46
+ const enrichedEvent = {
47
+ ...event,
48
+ queuedAt: Date.now(),
49
+ retryCount: 0
50
+ };
51
+
52
+ this.queue.push(enrichedEvent);
53
+ await this.saveQueue();
54
+
55
+ // Try to flush immediately
56
+ this.flush();
57
+ }
58
+
59
+ /**
60
+ * Attempt to send queued events to backend
61
+ */
62
+ async flush() {
63
+ if (this.isFlushing || this.queue.length === 0) return;
64
+ this.isFlushing = true;
65
+
66
+ try {
67
+ const remaining = [];
68
+
69
+ for (const event of this.queue) {
70
+ try {
71
+ await this.sendToBackend(event);
72
+ } catch (error) {
73
+ // Keep in queue if failed
74
+ event.retryCount++;
75
+ remaining.push(event);
76
+ }
77
+ }
78
+
79
+ this.queue = remaining;
80
+ await this.saveQueue();
81
+ } finally {
82
+ this.isFlushing = false;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Mock backend transmission
88
+ * In production, this would POST to an API
89
+ */
90
+ async sendToBackend(event) {
91
+ // Simulate network delay
92
+ // await new Promise(resolve => setTimeout(resolve, 100));
93
+
94
+ // For now, we just log that we would have sent it
95
+ // logger.debug(`[Telemetry] Event emitted: ${event.type}`);
96
+
97
+ // In a real implementation:
98
+ // const response = await fetch('https://api.autopilot.com/events', { ... });
99
+ // if (!response.ok) throw new Error('Network error');
100
+
101
+ return true;
102
+ }
103
+ }
104
+
105
+ module.exports = new EventSystem();
package/src/core/git.js CHANGED
@@ -241,6 +241,42 @@ async function getLatestCommitHash(root) {
241
241
  }
242
242
  }
243
243
 
244
+ const fs = require('fs-extra');
245
+ const path = require('path');
246
+
247
+ /**
248
+ * Check if repository is in a merge/rebase/cherry-pick state
249
+ * @param {string} root - Repository root path
250
+ * @returns {Promise<boolean>} True if operation in progress
251
+ */
252
+ async function isMergeInProgress(root) {
253
+ try {
254
+ const gitDir = path.join(root, '.git');
255
+ const files = [
256
+ 'MERGE_HEAD',
257
+ 'REBASE_HEAD',
258
+ 'CHERRY_PICK_HEAD',
259
+ 'REVERT_HEAD',
260
+ 'BISECT_LOG'
261
+ ];
262
+
263
+ // Check if .git/rebase-merge or .git/rebase-apply exists (directory check)
264
+ if (await fs.pathExists(path.join(gitDir, 'rebase-merge')) ||
265
+ await fs.pathExists(path.join(gitDir, 'rebase-apply'))) {
266
+ return true;
267
+ }
268
+
269
+ for (const file of files) {
270
+ if (await fs.pathExists(path.join(gitDir, file))) {
271
+ return true;
272
+ }
273
+ }
274
+ return false;
275
+ } catch (error) {
276
+ return false;
277
+ }
278
+ }
279
+
244
280
  module.exports = {
245
281
  getBranch,
246
282
  hasChanges,
@@ -255,5 +291,6 @@ module.exports = {
255
291
  revert,
256
292
  resetSoft,
257
293
  commitExists,
258
- getLatestCommitHash
294
+ getLatestCommitHash,
295
+ isMergeInProgress
259
296
  };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * xAI Grok Integration for Autopilot
3
+ * Generates commit messages using the Grok API
4
+ */
5
+
6
+ const logger = require('../utils/logger');
7
+
8
+ const BASE_API_URL = 'https://api.x.ai/v1/chat/completions';
9
+ const DEFAULT_MODEL = 'grok-beta'; // or current stable model
10
+
11
+ /**
12
+ * Generate a commit message using Grok API
13
+ * @param {string} diff - The git diff content
14
+ * @param {string} apiKey - xAI API Key
15
+ * @param {string} [model] - Grok Model ID
16
+ * @returns {Promise<string>} Generated commit message
17
+ */
18
+ async function generateGrokCommitMessage(diff, apiKey, model = DEFAULT_MODEL) {
19
+ if (!diff || !diff.trim()) {
20
+ return 'chore: update changes';
21
+ }
22
+
23
+ // Truncate diff to avoid token limits (safe limit)
24
+ const truncatedDiff = diff.length > 30000 ? diff.slice(0, 30000) + '\n...(truncated)' : diff;
25
+
26
+ const systemPrompt = `You are an expert software engineer.
27
+ Generate a concise, standardized commit message following the Conventional Commits specification based on the provided git diff.
28
+
29
+ Rules:
30
+ 1. Format: <type>(<scope>): <subject>
31
+ 2. Keep the subject line under 72 characters.
32
+ 3. If there are multiple changes, use a bulleted body.
33
+ 4. Detect breaking changes and add "BREAKING CHANGE:" footer if necessary.
34
+ 5. Use types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.
35
+ 6. Return ONLY the commit message, no explanations or markdown code blocks.`;
36
+
37
+ try {
38
+ const response = await fetch(BASE_API_URL, {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Content-Type': 'application/json',
42
+ 'Authorization': `Bearer ${apiKey}`
43
+ },
44
+ body: JSON.stringify({
45
+ model: model,
46
+ messages: [
47
+ { role: 'system', content: systemPrompt },
48
+ { role: 'user', content: `Diff:\n${truncatedDiff}` }
49
+ ],
50
+ temperature: 0.2,
51
+ stream: false
52
+ })
53
+ });
54
+
55
+ if (!response.ok) {
56
+ const errorData = await response.json().catch(() => ({}));
57
+ throw new Error(`Grok API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
58
+ }
59
+
60
+ const data = await response.json();
61
+
62
+ if (!data.choices || data.choices.length === 0 || !data.choices[0].message) {
63
+ throw new Error('No response content from Grok');
64
+ }
65
+
66
+ let message = data.choices[0].message.content.trim();
67
+
68
+ // Cleanup markdown if present
69
+ message = message.replace(/^```[a-z]*\n?/, '').replace(/\n?```$/, '').trim();
70
+
71
+ return message;
72
+
73
+ } catch (error) {
74
+ logger.error(`Grok Generation failed: ${error.message}`);
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Validate Grok API Key
81
+ * @param {string} apiKey
82
+ * @returns {Promise<{valid: boolean, error?: string}>}
83
+ */
84
+ async function validateGrokApiKey(apiKey) {
85
+ try {
86
+ const response = await fetch(BASE_API_URL, {
87
+ method: 'POST',
88
+ headers: {
89
+ 'Content-Type': 'application/json',
90
+ 'Authorization': `Bearer ${apiKey}`
91
+ },
92
+ body: JSON.stringify({
93
+ model: DEFAULT_MODEL,
94
+ messages: [{ role: 'user', content: 'test' }],
95
+ max_tokens: 1
96
+ })
97
+ });
98
+
99
+ if (response.ok) return { valid: true };
100
+ return { valid: false, error: `Status: ${response.status}` };
101
+ } catch (error) {
102
+ return { valid: false, error: error.message };
103
+ }
104
+ }
105
+
106
+ module.exports = {
107
+ generateGrokCommitMessage,
108
+ validateGrokApiKey
109
+ };
@@ -56,6 +56,12 @@ const validateBeforeCommit = async (repoPath, config) => {
56
56
  return { ok: true, errors: [] };
57
57
  }
58
58
 
59
+ // 0. Merge/Rebase Safety Check (Hard Guarantee)
60
+ const isMerge = await git.isMergeInProgress(repoPath);
61
+ if (isMerge) {
62
+ return { ok: false, errors: ['Repository is in a merge/rebase state. Autopilot paused for safety.'] };
63
+ }
64
+
59
65
  try {
60
66
  // Get staged files
61
67
  const statusObj = await git.getPorcelainStatus(repoPath);
@@ -10,7 +10,10 @@ const readline = require('readline');
10
10
  const logger = require('../utils/logger');
11
11
  const git = require('./git');
12
12
  const FocusEngine = require('./focus');
13
- const { generateCommitMessage } = require('./commit');
13
+ const { generateCommitMessage, addTrailers } = require('./commit');
14
+ const eventSystem = require('./events');
15
+ const { getIdentity } = require('../utils/identity');
16
+ const { version } = require('../../package.json');
14
17
  const { savePid, removePid, registerProcessHandlers } = require('../utils/process');
15
18
  const { loadConfig } = require('../config/loader');
16
19
  const { readIgnoreFile, createIgnoredFilter, normalizePath } = require('../config/ignore');
@@ -356,6 +359,9 @@ class Watcher {
356
359
  message = approval.message;
357
360
  }
358
361
 
362
+ // Add Trust/Attribution Trailers
363
+ message = await addTrailers(message);
364
+
359
365
  const commitResult = await git.commit(this.repoPath, message);
360
366
 
361
367
  if (commitResult.ok) {
@@ -388,9 +394,31 @@ class Watcher {
388
394
  logger.info('Pushing to remote...');
389
395
  const pushResult = await git.push(this.repoPath);
390
396
  if (!pushResult.ok) {
391
- logger.warn(`Push failed (will retry next time): ${pushResult.stderr}`);
397
+ logger.warn(`Push failed: ${pushResult.stderr}`);
398
+
399
+ // Safety: Pause on critical push failures (Auth, Permissions, or Persistent errors)
400
+ // This aligns with "Failure Behavior: If a push fails -> pause watcher"
401
+ logger.error('Push failed! Pausing Autopilot to prevent issues.');
402
+ this.stateManager.pause(`Push failed: ${pushResult.stderr.split('\n')[0]}`);
403
+ logger.info('Run "autopilot resume" to restart after fixing the issue.');
392
404
  } else {
393
405
  logger.success('Push complete');
406
+
407
+ // Emit Event for Leaderboard/Telemetry
408
+ try {
409
+ const identity = await getIdentity();
410
+ const latestHash = await git.getLatestCommitHash(this.repoPath);
411
+
412
+ await eventSystem.emit({
413
+ type: 'push_success',
414
+ userId: identity.id,
415
+ commitHash: latestHash,
416
+ timestamp: Date.now(),
417
+ version: version
418
+ });
419
+ } catch (err) {
420
+ logger.debug(`Failed to emit push event: ${err.message}`);
421
+ }
394
422
  }
395
423
  }
396
424
 
@@ -0,0 +1,18 @@
1
+ const crypto = require('crypto');
2
+
3
+ /**
4
+ * Generate HMAC signature for commit verification
5
+ * @param {string} content - Content to sign (message + timestamp + version)
6
+ * @param {string} secret - Secret key (using anonymous ID as salt for now)
7
+ * @returns {string} HMAC SHA256 signature
8
+ */
9
+ function generateSignature(content, secret) {
10
+ return crypto
11
+ .createHmac('sha256', secret)
12
+ .update(content)
13
+ .digest('hex');
14
+ }
15
+
16
+ module.exports = {
17
+ generateSignature
18
+ };
@@ -0,0 +1,41 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const { getConfigDir } = require('./paths');
5
+ const logger = require('./logger');
6
+
7
+ const getIdentityPath = () => path.join(getConfigDir(), 'identity.json');
8
+
9
+ /**
10
+ * Get or create the anonymous user identity
11
+ * @returns {Promise<{id: string, created: number}>} Identity object
12
+ */
13
+ async function getIdentity() {
14
+ try {
15
+ const identityPath = getIdentityPath();
16
+
17
+ if (await fs.pathExists(identityPath)) {
18
+ return await fs.readJson(identityPath);
19
+ }
20
+
21
+ // Create new identity
22
+ const identity = {
23
+ id: crypto.randomUUID(),
24
+ created: Date.now()
25
+ };
26
+
27
+ await fs.ensureDir(getConfigDir());
28
+ await fs.writeJson(identityPath, identity, { spaces: 2 });
29
+ logger.debug(`Created new anonymous identity: ${identity.id}`);
30
+
31
+ return identity;
32
+ } catch (error) {
33
+ logger.error(`Failed to manage identity: ${error.message}`);
34
+ // Fallback to memory-only ID if filesystem fails
35
+ return { id: crypto.randomUUID(), created: Date.now() };
36
+ }
37
+ }
38
+
39
+ module.exports = {
40
+ getIdentity
41
+ };
@@ -39,6 +39,9 @@ const getGitPath = (repoPath) => {
39
39
  * @returns {string} Config directory path
40
40
  */
41
41
  const getConfigDir = () => {
42
+ if (process.env.AUTOPILOT_CONFIG_DIR) {
43
+ return process.env.AUTOPILOT_CONFIG_DIR;
44
+ }
42
45
  return path.join(os.homedir(), '.autopilot');
43
46
  };
44
47