knoxis-helper 1.4.4 → 1.4.6

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.
@@ -28,6 +28,8 @@ const DEFAULT_PORT = parseInt(process.env.KNOXIS_AGENT_PORT || '3456', 10);
28
28
  const CERT_DIR = process.env.KNOXIS_CERT_DIR || path.join(os.homedir(), '.knoxis', 'certs');
29
29
  const CERT_FILE = process.env.KNOXIS_CERT_FILE || path.join(CERT_DIR, 'localhost.pem');
30
30
  const KEY_FILE = process.env.KNOXIS_CERT_KEY || path.join(CERT_DIR, 'localhost-key.pem');
31
+ const PFX_FILE = process.env.KNOXIS_CERT_PFX || path.join(CERT_DIR, 'localhost.pfx');
32
+ const PFX_PASS = 'knoxis-local'; // Not a real secret — self-signed cert for localhost only
31
33
 
32
34
  // Trusted origins for CORS (deployed frontends)
33
35
  const TRUSTED_ORIGINS = [
@@ -77,10 +79,116 @@ function generateSelfSignedCert() {
77
79
  return true;
78
80
  }
79
81
 
80
- console.warn('⚠️ OpenSSL failed - running in HTTP mode');
82
+ console.warn('⚠️ OpenSSL not available');
81
83
  return false;
82
84
  }
83
85
 
86
+ /**
87
+ * Windows fallback: generate a self-signed certificate via PowerShell.
88
+ * Uses New-SelfSignedCertificate (built into Windows 10+ / Server 2016+).
89
+ * Always produces a PFX file. Also attempts PEM export when .NET 5+ is present.
90
+ * Cert is created in the user's personal store (no admin required) and removed
91
+ * from the store after export.
92
+ */
93
+ function generateSelfSignedCertWindows() {
94
+ console.log('🔐 Generating self-signed certificate via PowerShell...');
95
+
96
+ if (!fs.existsSync(CERT_DIR)) {
97
+ fs.mkdirSync(CERT_DIR, { recursive: true });
98
+ }
99
+
100
+ // Write a PS1 script to a temp file to avoid all shell-escaping issues.
101
+ const psLines = [
102
+ '$ErrorActionPreference = "Stop"',
103
+ '$cert = New-SelfSignedCertificate -Subject "CN=localhost" -DnsName "localhost","127.0.0.1" `',
104
+ ' -CertStoreLocation "Cert:\\CurrentUser\\My" -NotAfter (Get-Date).AddDays(365) `',
105
+ ' -KeyExportPolicy Exportable -KeySpec KeyExchange',
106
+ '$thumb = $cert.Thumbprint',
107
+ '',
108
+ '# Export PFX (works on all Windows versions with this cmdlet)',
109
+ '$pwd = ConvertTo-SecureString -String "' + PFX_PASS + '" -Force -AsPlainText',
110
+ 'Export-PfxCertificate -Cert "Cert:\\CurrentUser\\My\\$thumb" -FilePath "' + PFX_FILE + '" -Password $pwd | Out-Null',
111
+ '',
112
+ '# Attempt PEM export — only works on .NET 5+ (PowerShell 7+)',
113
+ 'try {',
114
+ ' $certDer = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)',
115
+ ' $certB64 = [Convert]::ToBase64String($certDer, "InsertLineBreaks")',
116
+ ' $certPem = "-----BEGIN CERTIFICATE-----`r`n$certB64`r`n-----END CERTIFICATE-----"',
117
+ ' [System.IO.File]::WriteAllText("' + CERT_FILE + '", $certPem)',
118
+ '',
119
+ ' $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)',
120
+ ' $keyBytes = $rsa.ExportPkcs8PrivateKey()',
121
+ ' $keyB64 = [Convert]::ToBase64String($keyBytes, "InsertLineBreaks")',
122
+ ' $keyPem = "-----BEGIN PRIVATE KEY-----`r`n$keyB64`r`n-----END PRIVATE KEY-----"',
123
+ ' [System.IO.File]::WriteAllText("' + KEY_FILE + '", $keyPem)',
124
+ '} catch {',
125
+ ' # PEM export not available on this .NET version — PFX is sufficient',
126
+ '}',
127
+ '',
128
+ '# Remove cert from user store (the exported files are all we need)',
129
+ 'Remove-Item "Cert:\\CurrentUser\\My\\$thumb" -Force -ErrorAction SilentlyContinue',
130
+ ];
131
+
132
+ const tmpScript = path.join(os.tmpdir(), 'knoxis-cert-' + Date.now() + '.ps1');
133
+ fs.writeFileSync(tmpScript, psLines.join('\r\n'), 'utf8');
134
+
135
+ try {
136
+ const result = spawnSync('powershell', [
137
+ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
138
+ '-File', tmpScript
139
+ ], { stdio: 'pipe', timeout: 30000 });
140
+
141
+ if (result.status === 0 && fs.existsSync(PFX_FILE)) {
142
+ console.log('✅ Certificate generated via PowerShell');
143
+ if (fs.existsSync(CERT_FILE) && fs.existsSync(KEY_FILE)) {
144
+ console.log(' PEM files exported (PFX + PEM)');
145
+ } else {
146
+ console.log(' PFX exported (PEM not available on this .NET version)');
147
+ }
148
+ return true;
149
+ }
150
+
151
+ console.warn('⚠️ PowerShell certificate generation failed');
152
+ if (result.stderr) {
153
+ const firstLine = result.stderr.toString().trim().split('\n')[0];
154
+ if (firstLine) console.warn(' ' + firstLine);
155
+ }
156
+ return false;
157
+ } finally {
158
+ try { fs.unlinkSync(tmpScript); } catch (e) {}
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Try to create an HTTPS server from the available cert material.
164
+ * Returns { opts, source } on success, null on failure.
165
+ * Checks PEM first (cross-platform), then PFX (Windows PowerShell fallback).
166
+ */
167
+ function tryLoadCerts() {
168
+ // Prefer PEM files (generated by openssl or PS .NET 5+)
169
+ try {
170
+ if (fs.existsSync(KEY_FILE) && fs.existsSync(CERT_FILE)) {
171
+ const key = fs.readFileSync(KEY_FILE);
172
+ const cert = fs.readFileSync(CERT_FILE);
173
+ return { opts: { key, cert }, source: 'PEM' };
174
+ }
175
+ } catch (err) {
176
+ console.warn('⚠️ Failed to load PEM certificates:', err.message);
177
+ }
178
+
179
+ // Fallback: PFX file (Windows PS 5.1 where PEM export isn't available)
180
+ try {
181
+ if (fs.existsSync(PFX_FILE)) {
182
+ const pfx = fs.readFileSync(PFX_FILE);
183
+ return { opts: { pfx, passphrase: PFX_PASS }, source: 'PFX' };
184
+ }
185
+ } catch (err) {
186
+ console.warn('⚠️ Failed to load PFX certificate:', err.message);
187
+ }
188
+
189
+ return null;
190
+ }
191
+
84
192
  /**
85
193
  * Get CORS headers for the given request origin
86
194
  */
@@ -132,6 +240,38 @@ function buildShellCommand(command) {
132
240
  return { cmd: 'bash', args: ['-lc', command] };
133
241
  }
134
242
 
243
+ /**
244
+ * Build a cross-platform command to pipe a file's contents to stdout.
245
+ * Uses `type` on Windows (cmd.exe) and `cat` on Unix.
246
+ */
247
+ function buildCatCommand(filePath) {
248
+ if (os.platform() === 'win32') {
249
+ return `type "${filePath}"`;
250
+ }
251
+ return `cat "${filePath}"`;
252
+ }
253
+
254
+ /**
255
+ * Build a cross-platform command string that sets environment variables
256
+ * before running a child command.
257
+ * Unix: VAR="val" command
258
+ * Windows: set "VAR=val" && command
259
+ * @param {Record<string,string>} envVars
260
+ * @param {string} command
261
+ */
262
+ function buildEnvCommand(envVars, command) {
263
+ if (os.platform() === 'win32') {
264
+ const sets = Object.entries(envVars)
265
+ .map(([k, v]) => `set "${k}=${v}"`)
266
+ .join(' && ');
267
+ return `${sets} && ${command}`;
268
+ }
269
+ const prefix = Object.entries(envVars)
270
+ .map(([k, v]) => `${k}="${v}"`)
271
+ .join(' ');
272
+ return `${prefix} ${command}`;
273
+ }
274
+
135
275
  function commandExists(cmd) {
136
276
  const detector = os.platform() === 'win32' ? 'where' : 'which';
137
277
  const result = spawnSync(detector, [cmd], { stdio: 'ignore' });
@@ -509,7 +649,7 @@ async function handleRequest(req, res) {
509
649
  if (prompt && prompt.trim().length > 0) {
510
650
  promptFile = path.join(os.tmpdir(), 'knoxis-task-' + (sessionId || Date.now()) + '.txt');
511
651
  fs.writeFileSync(promptFile, prompt, 'utf8');
512
- finalCommand = 'cat "' + promptFile + '" | claude --dangerously-skip-permissions';
652
+ finalCommand = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
513
653
  console.log('📝 Task written to ' + promptFile + ' (' + prompt.length + ' chars)');
514
654
  }
515
655
 
@@ -768,17 +908,17 @@ async function handleRequest(req, res) {
768
908
  const scriptPath = resolveInteractiveScript();
769
909
  if (scriptPath) {
770
910
  // Interactive mode: multi-turn with Groq pair programmer
771
- command = `KNOXIS_TASK_FILE="${promptFile}" node "${scriptPath}"`;
911
+ command = buildEnvCommand({ KNOXIS_TASK_FILE: promptFile }, `node "${scriptPath}"`);
772
912
  mode = 'interactive';
773
913
  console.log(`🤝 Interactive mode: ${scriptPath}`);
774
914
  } else {
775
915
  // Interactive requested but script not found - fall back to single-shot
776
916
  console.warn('⚠️ Interactive script not found, falling back to single-shot');
777
- command = `cat "${promptFile}" | claude --dangerously-skip-permissions`;
917
+ command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
778
918
  }
779
919
  } else {
780
920
  // Standard single-shot mode: pipe task to Claude
781
- command = `cat "${promptFile}" | claude --dangerously-skip-permissions`;
921
+ command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
782
922
  }
783
923
 
784
924
  if (headless) {
@@ -979,34 +1119,38 @@ function openLinuxTerminal(workspaceDir, command) {
979
1119
  }
980
1120
 
981
1121
  function createServer() {
982
- // Try to load existing certs
983
- try {
984
- if (fs.existsSync(KEY_FILE) && fs.existsSync(CERT_FILE)) {
985
- const key = fs.readFileSync(KEY_FILE);
986
- const cert = fs.readFileSync(CERT_FILE);
1122
+ // 1. Try to load existing certs (PEM or PFX)
1123
+ const existing = tryLoadCerts();
1124
+ if (existing) {
1125
+ try {
987
1126
  serverMeta.secure = true;
988
- return https.createServer({ key, cert }, handleRequest);
1127
+ console.log('🔒 Loaded existing ' + existing.source + ' certificate');
1128
+ return https.createServer(existing.opts, handleRequest);
1129
+ } catch (err) {
1130
+ console.warn('⚠️ Failed to create HTTPS server from existing certs:', err.message);
989
1131
  }
990
- } catch (err) {
991
- console.warn('⚠️ Failed to load TLS certificates:', err.message);
992
1132
  }
993
1133
 
994
- // No certs exist - try to generate them
995
- if (!fs.existsSync(CERT_FILE) || !fs.existsSync(KEY_FILE)) {
996
- const generated = generateSelfSignedCert();
997
- if (generated && fs.existsSync(KEY_FILE) && fs.existsSync(CERT_FILE)) {
1134
+ // 2. Generate new certs try openssl first, then Windows PowerShell
1135
+ let generated = generateSelfSignedCert();
1136
+ if (!generated && os.platform() === 'win32') {
1137
+ generated = generateSelfSignedCertWindows();
1138
+ }
1139
+
1140
+ if (generated) {
1141
+ const fresh = tryLoadCerts();
1142
+ if (fresh) {
998
1143
  try {
999
- const key = fs.readFileSync(KEY_FILE);
1000
- const cert = fs.readFileSync(CERT_FILE);
1001
1144
  serverMeta.secure = true;
1002
- return https.createServer({ key, cert }, handleRequest);
1145
+ console.log('🔒 Using freshly generated ' + fresh.source + ' certificate');
1146
+ return https.createServer(fresh.opts, handleRequest);
1003
1147
  } catch (err) {
1004
- console.warn('⚠️ Failed to load generated certificates:', err.message);
1148
+ console.warn('⚠️ Failed to create HTTPS server from generated certs:', err.message);
1005
1149
  }
1006
1150
  }
1007
1151
  }
1008
1152
 
1009
- // Fallback to HTTP (will cause mixed content issues from HTTPS frontends)
1153
+ // 3. Fallback to HTTP (will cause mixed content issues from HTTPS frontends)
1010
1154
  serverMeta.secure = false;
1011
1155
  console.warn('');
1012
1156
  console.warn('⚠️ RUNNING IN HTTP MODE - This will cause 405/CORS errors from HTTPS frontends!');
@@ -1203,14 +1347,14 @@ function connectRelayWebSocket() {
1203
1347
  // Interactive mode: use multi-turn pair programming script
1204
1348
  const scriptPath = resolveInteractiveScript();
1205
1349
  if (scriptPath) {
1206
- command = `KNOXIS_TASK_FILE="${promptFile}" node "${scriptPath}"`;
1350
+ command = buildEnvCommand({ KNOXIS_TASK_FILE: promptFile }, `node "${scriptPath}"`);
1207
1351
  console.log(` 🤝 Interactive mode: ${scriptPath}`);
1208
1352
  } else {
1209
- command = `cat "${promptFile}" | claude --dangerously-skip-permissions`;
1353
+ command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
1210
1354
  console.warn(` ⚠️ Interactive script not found, falling back to single-shot`);
1211
1355
  }
1212
1356
  } else {
1213
- command = `cat "${promptFile}" | claude --dangerously-skip-permissions`;
1357
+ command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
1214
1358
  }
1215
1359
  console.log(` 📝 Task written to ${promptFile} (${taskPrompt.length} chars)`);
1216
1360
  } else {
@@ -1429,9 +1573,17 @@ server.listen(serverMeta.port, () => {
1429
1573
  console.warn('║ Browsers will block requests from HTTPS sites (like qig.ai) ║');
1430
1574
  console.warn('╚══════════════════════════════════════════════════════════════╝');
1431
1575
  console.warn('');
1432
- console.warn('To fix this, either:');
1433
- console.warn(` 1. Install OpenSSL and restart (auto-generates certs)`);
1434
- console.warn(` 2. Manually place certs in: ${CERT_DIR}`);
1576
+ if (os.platform() === 'win32') {
1577
+ console.warn('To fix this on Windows, try one of:');
1578
+ console.warn(' 1. winget install ShiningLight.OpenSSL.Light (then restart)');
1579
+ console.warn(' 2. choco install openssl (then restart)');
1580
+ console.warn(' 3. Install Git for Windows (ships with OpenSSL)');
1581
+ console.warn(` 4. Manually place PEM or PFX certs in: ${CERT_DIR}`);
1582
+ } else {
1583
+ console.warn('To fix this, either:');
1584
+ console.warn(' 1. Install OpenSSL and restart (auto-generates certs)');
1585
+ console.warn(` 2. Manually place certs in: ${CERT_DIR}`);
1586
+ }
1435
1587
  console.warn('');
1436
1588
  } else {
1437
1589
  console.log('✅ HTTPS enabled - ready for secure connections from deployed frontends');
@@ -5,6 +5,24 @@ const path = require('path');
5
5
  const os = require('os');
6
6
  const { spawn, spawnSync, execSync } = require('child_process');
7
7
 
8
+ // ===== RETRY CONFIGURATION =====
9
+ // Can be overridden via environment variables
10
+ const RETRY_CONFIG = {
11
+ maxRetries: parseInt(process.env.KNOXIS_MAX_RETRIES || '3'),
12
+ retryDelay: parseInt(process.env.KNOXIS_RETRY_DELAY || '30000'), // 30 seconds
13
+ gitTimeout: parseInt(process.env.KNOXIS_GIT_TIMEOUT || '30000'), // 30 seconds
14
+ aiCallTimeout: parseInt(process.env.KNOXIS_AI_TIMEOUT || '300000'), // 5 minutes
15
+ enableRetryLogging: process.env.KNOXIS_RETRY_LOG !== 'false'
16
+ };
17
+
18
+ // Global retry statistics
19
+ let retryStats = {
20
+ totalRetries: 0,
21
+ successfulRetries: 0,
22
+ failedCommands: [],
23
+ startTime: Date.now()
24
+ };
25
+
8
26
  function parseArgs(argv) {
9
27
  const args = {};
10
28
  const multi = {};
@@ -59,7 +77,45 @@ function commandExists(cmd) {
59
77
  return result.status === 0;
60
78
  }
61
79
 
62
- // Resolve workspace name to path using knoxis registry
80
+ // Resolve workspace name to path using knoxis registry (async version)
81
+ async function resolveWorkspacePathAsync(nameOrPath) {
82
+ const os = require('os');
83
+
84
+ // Direct path - check if exists
85
+ try {
86
+ await fs.promises.stat(nameOrPath);
87
+ return path.resolve(nameOrPath);
88
+ } catch (e) {
89
+ // Not a direct path, continue
90
+ }
91
+
92
+ // Try knoxis workspace registry
93
+ const workspacesFile = path.join(os.homedir(), '.knoxis', 'workspaces.json');
94
+ try {
95
+ const data = await fs.promises.readFile(workspacesFile, 'utf8');
96
+ const workspaces = JSON.parse(data);
97
+
98
+ // Exact match
99
+ if (workspaces[nameOrPath]) {
100
+ return workspaces[nameOrPath];
101
+ }
102
+
103
+ // Fuzzy match
104
+ const lower = nameOrPath.toLowerCase();
105
+ for (const [name, wsPath] of Object.entries(workspaces)) {
106
+ if (name.toLowerCase().includes(lower)) {
107
+ console.log(`Matched workspace: ${name} -> ${wsPath}`);
108
+ return wsPath;
109
+ }
110
+ }
111
+ } catch (e) {
112
+ // Registry file not found or parse error
113
+ }
114
+
115
+ return null;
116
+ }
117
+
118
+ // Sync version kept for backward compatibility
63
119
  function resolveWorkspacePath(nameOrPath) {
64
120
  const os = require('os');
65
121
 
@@ -132,32 +188,62 @@ function toArray(value) {
132
188
  return [value];
133
189
  }
134
190
 
135
- function gatherContext(workspace, inputs) {
191
+ async function gatherContext(workspace, inputs) {
136
192
  const sections = [];
137
193
  const labels = [];
138
194
  const seen = new Set();
195
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB limit
196
+ const ALLOWED_EXTENSIONS = /\.(js|jsx|ts|tsx|java|py|json|md|txt|yml|yaml|xml|html|css|scss|sql|sh|bash|env|config|conf)$/i;
139
197
 
140
- toArray(inputs).forEach(entry => {
198
+ for (const entry of toArray(inputs)) {
141
199
  if (typeof entry !== 'string') {
142
- return;
200
+ continue;
143
201
  }
144
202
  const trimmed = entry.trim();
145
203
  if (!trimmed) {
146
- return;
204
+ continue;
147
205
  }
148
206
  const absolute = path.isAbsolute(trimmed) ? trimmed : path.join(workspace, trimmed);
149
- if (!fs.existsSync(absolute)) {
150
- return;
151
- }
152
- if (seen.has(absolute)) {
153
- return;
207
+
208
+ try {
209
+ const stats = await fs.promises.stat(absolute);
210
+
211
+ // Skip directories
212
+ if (stats.isDirectory()) {
213
+ console.warn(`[Context] Skipping directory: ${absolute}`);
214
+ continue;
215
+ }
216
+
217
+ // Skip if already processed
218
+ if (seen.has(absolute)) {
219
+ continue;
220
+ }
221
+
222
+ // Check file size
223
+ if (stats.size > MAX_FILE_SIZE) {
224
+ console.warn(`[Context] Skipping large file (${(stats.size/1048576).toFixed(2)}MB): ${absolute}`);
225
+ labels.push(path.basename(absolute) + ' [too large]');
226
+ sections.push(formatSection(path.basename(absolute), `[File too large: ${(stats.size/1048576).toFixed(2)}MB]`));
227
+ continue;
228
+ }
229
+
230
+ // Check file extension
231
+ if (!ALLOWED_EXTENSIONS.test(absolute)) {
232
+ console.warn(`[Context] Skipping non-text file: ${absolute}`);
233
+ continue;
234
+ }
235
+
236
+ seen.add(absolute);
237
+ const content = await fs.promises.readFile(absolute, 'utf8');
238
+ const title = path.relative(workspace, absolute) || path.basename(absolute);
239
+ labels.push(title);
240
+ sections.push(formatSection(title, content));
241
+ } catch (err) {
242
+ if (err.code !== 'ENOENT') {
243
+ console.error(`[Context] Error reading ${absolute}: ${err.message}`);
244
+ }
154
245
  }
155
- seen.add(absolute);
156
- const content = fs.readFileSync(absolute, 'utf8');
157
- const title = path.relative(workspace, absolute) || path.basename(absolute);
158
- labels.push(title);
159
- sections.push(formatSection(title, content));
160
- });
246
+ }
161
247
 
162
248
  return { sections, labels };
163
249
  }
@@ -309,7 +395,7 @@ function buildPrompt(options) {
309
395
  return sections.join('\n\n');
310
396
  }
311
397
 
312
- async function callAi(aiConfig, prompt, livePrinter) {
398
+ async function callAiBase(aiConfig, prompt, livePrinter) {
313
399
  return new Promise((resolve, reject) => {
314
400
  const proc = spawn(aiConfig.cmd, aiConfig.args, { stdio: ['pipe', 'pipe', 'pipe'] });
315
401
  let stdout = '';
@@ -350,6 +436,60 @@ async function callAi(aiConfig, prompt, livePrinter) {
350
436
  });
351
437
  }
352
438
 
439
+ async function callAi(aiConfig, prompt, livePrinter, options = {}) {
440
+ const maxRetries = options.maxRetries || RETRY_CONFIG.maxRetries;
441
+ const retryDelay = options.retryDelay || RETRY_CONFIG.retryDelay;
442
+ const enableLogging = options.silent === false || RETRY_CONFIG.enableRetryLogging;
443
+
444
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
445
+ try {
446
+ const timestamp = new Date().toISOString();
447
+ if (enableLogging) {
448
+ console.log(`[${timestamp}] [AI Call - Attempt ${attempt}/${maxRetries}] Invoking ${aiConfig.label}...`);
449
+ }
450
+ if (attempt > 1) {
451
+ retryStats.totalRetries++;
452
+ }
453
+
454
+ const result = await callAiBase(aiConfig, prompt, livePrinter);
455
+
456
+ if (attempt > 1) {
457
+ retryStats.successfulRetries++;
458
+ if (enableLogging) {
459
+ console.log(`[AI Call - Success] Recovered after ${attempt} attempts`);
460
+ }
461
+ }
462
+ return result;
463
+ } catch (error) {
464
+ const isLastAttempt = attempt === maxRetries;
465
+ const timestamp = new Date().toISOString();
466
+
467
+ if (enableLogging) {
468
+ console.error(`[${timestamp}] [AI Call - Attempt ${attempt}/${maxRetries}] Failed`);
469
+ console.error(` Provider: ${aiConfig.label}`);
470
+ console.error(` Error: ${error.message}`);
471
+ }
472
+
473
+ if (!isLastAttempt) {
474
+ if (enableLogging) {
475
+ console.log(`[AI Call - Retry] Waiting ${retryDelay/1000} seconds before retry...`);
476
+ }
477
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
478
+ } else {
479
+ if (enableLogging) {
480
+ console.error(`[AI Call - Failed] Max retries reached. AI call failed permanently.`);
481
+ }
482
+ retryStats.failedCommands.push({
483
+ command: `AI call to ${aiConfig.label}`,
484
+ error: error.message,
485
+ timestamp: timestamp
486
+ });
487
+ throw error;
488
+ }
489
+ }
490
+ }
491
+ }
492
+
353
493
  // ===== SESSION RECORDING =====
354
494
  // Records full prompts, responses, git diffs, and timing for model training
355
495
 
@@ -359,8 +499,134 @@ function ensureSessionDir() {
359
499
  if (!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
360
500
  }
361
501
 
362
- function safeExec(cmd, cwd) {
363
- try { return execSync(cmd, { cwd, encoding: 'utf8', timeout: 10000 }).trim(); } catch (e) { return null; }
502
+ // Async version with proper setTimeout
503
+ async function safeExecAsync(cmd, cwd, options = {}) {
504
+ const { exec } = require('child_process');
505
+ const util = require('util');
506
+ const execAsync = util.promisify(exec);
507
+
508
+ const maxRetries = options.maxRetries || RETRY_CONFIG.maxRetries;
509
+ const retryDelay = options.retryDelay || RETRY_CONFIG.retryDelay;
510
+ const timeout = options.timeout || RETRY_CONFIG.gitTimeout;
511
+ const silent = options.silent || !RETRY_CONFIG.enableRetryLogging;
512
+
513
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
514
+ try {
515
+ if (!silent && attempt > 1) {
516
+ console.log(`[Git Retry ${attempt}/${maxRetries}] Executing: ${cmd.substring(0, 50)}...`);
517
+ retryStats.totalRetries++;
518
+ }
519
+
520
+ const { stdout } = await execAsync(cmd, {
521
+ cwd,
522
+ encoding: 'utf8',
523
+ timeout,
524
+ maxBuffer: 10 * 1024 * 1024 // 10MB buffer
525
+ });
526
+
527
+ const result = stdout.trim();
528
+
529
+ if (attempt > 1) {
530
+ retryStats.successfulRetries++;
531
+ if (!silent) {
532
+ console.log(`[Git Success] Command recovered after ${attempt} attempts`);
533
+ }
534
+ }
535
+ return result;
536
+ } catch (e) {
537
+ const isLastAttempt = attempt === maxRetries;
538
+ if (!silent) {
539
+ const timestamp = new Date().toISOString();
540
+ console.error(`[${timestamp}] [Git Attempt ${attempt}/${maxRetries}] Command failed: ${cmd.substring(0, 50)}...`);
541
+ console.error(` Error: ${e.message || 'Unknown error'}`);
542
+ if (e.code === 'ETIMEDOUT') {
543
+ console.error(` Timeout after ${timeout/1000} seconds`);
544
+ }
545
+ }
546
+
547
+ if (!isLastAttempt) {
548
+ if (!silent) {
549
+ console.log(` Waiting ${retryDelay/1000} seconds before retry...`);
550
+ }
551
+ // Proper async delay
552
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
553
+ } else {
554
+ if (!silent) {
555
+ console.error(` Max retries reached. Command failed permanently.`);
556
+ }
557
+ retryStats.failedCommands.push({
558
+ command: cmd.substring(0, 100),
559
+ error: e.message,
560
+ timestamp: new Date().toISOString()
561
+ });
562
+ return null;
563
+ }
564
+ }
565
+ }
566
+ return null;
567
+ }
568
+
569
+ // Sync version (kept for backward compatibility)
570
+ function safeExec(cmd, cwd, options = {}) {
571
+ const maxRetries = options.maxRetries || RETRY_CONFIG.maxRetries;
572
+ const retryDelay = options.retryDelay || RETRY_CONFIG.retryDelay;
573
+ const timeout = options.timeout || RETRY_CONFIG.gitTimeout;
574
+ const silent = options.silent || !RETRY_CONFIG.enableRetryLogging;
575
+
576
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
577
+ try {
578
+ if (!silent && attempt > 1) {
579
+ console.log(`[Git Retry ${attempt}/${maxRetries}] Executing: ${cmd.substring(0, 50)}...`);
580
+ retryStats.totalRetries++;
581
+ }
582
+ const result = execSync(cmd, { cwd, encoding: 'utf8', timeout }).trim();
583
+ if (attempt > 1) {
584
+ retryStats.successfulRetries++;
585
+ if (!silent) {
586
+ console.log(`[Git Success] Command recovered after ${attempt} attempts`);
587
+ }
588
+ }
589
+ return result;
590
+ } catch (e) {
591
+ const isLastAttempt = attempt === maxRetries;
592
+ if (!silent) {
593
+ const timestamp = new Date().toISOString();
594
+ console.error(`[${timestamp}] [Git Attempt ${attempt}/${maxRetries}] Command failed: ${cmd.substring(0, 50)}...`);
595
+ console.error(` Error: ${e.message || 'Unknown error'}`);
596
+ if (e.code === 'ETIMEDOUT') {
597
+ console.error(` Timeout after ${timeout/1000} seconds`);
598
+ }
599
+ }
600
+
601
+ if (!isLastAttempt) {
602
+ if (!silent) {
603
+ console.log(` Waiting ${retryDelay/1000} seconds before retry...`);
604
+ }
605
+ // Cross-platform sleep
606
+ const sleepCmd = process.platform === 'win32'
607
+ ? `powershell -Command "Start-Sleep -Seconds ${retryDelay/1000}"`
608
+ : `sleep ${retryDelay/1000}`;
609
+ try {
610
+ execSync(sleepCmd, { stdio: 'ignore' });
611
+ } catch (sleepError) {
612
+ // If sleep command fails, we have to skip the delay
613
+ // Busy-wait is too CPU intensive and blocks the event loop
614
+ console.warn(`[Warning] Unable to sleep between retries, continuing immediately`);
615
+ }
616
+ } else {
617
+ if (!silent) {
618
+ console.error(` Max retries reached. Command failed permanently.`);
619
+ }
620
+ retryStats.failedCommands.push({
621
+ command: cmd.substring(0, 100),
622
+ error: e.message,
623
+ timestamp: new Date().toISOString()
624
+ });
625
+ return null;
626
+ }
627
+ }
628
+ }
629
+ return null;
364
630
  }
365
631
 
366
632
  function slugify(text) {
@@ -404,6 +670,34 @@ class SessionRecorder {
404
670
  s.error = error || null;
405
671
  }
406
672
 
673
+ async saveAsync() {
674
+ const record = {
675
+ sessionId: this.sessionId, version: '1.0.0',
676
+ task: this.task, workspace: this.workspace, aiProvider: this.aiProvider,
677
+ startedAt: this.startedAt, completedAt: new Date().toISOString(),
678
+ totalDurationMs: Date.now() - new Date(this.startedAt).getTime(),
679
+ steps: this.steps,
680
+ totalSteps: this.steps.length,
681
+ completedSteps: this.steps.filter(s => s.completedAt && !s.error).length,
682
+ git: {
683
+ initialCommit: this.initialCommit,
684
+ finalCommit: await safeExecAsync('git rev-parse --short HEAD', this.workspace) || '',
685
+ totalDiff: await safeExecAsync('git diff', this.workspace) || ''
686
+ },
687
+ environment: { platform: os.platform(), nodeVersion: process.version }
688
+ };
689
+ const filename = `${this.sessionId}-${slugify(this.task)}.json`;
690
+ const filepath = path.join(SESSIONS_DIR, filename);
691
+ await fs.promises.writeFile(filepath, JSON.stringify(record, null, 2), 'utf8');
692
+ // Append to index
693
+ try {
694
+ await fs.promises.appendFile(path.join(SESSIONS_DIR, 'index.jsonl'),
695
+ JSON.stringify({ sessionId: record.sessionId, task: record.task, startedAt: record.startedAt, totalDurationMs: record.totalDurationMs, file: filename }) + '\n');
696
+ } catch (e) {}
697
+ return filepath;
698
+ }
699
+
700
+ // Sync version kept for backward compatibility
407
701
  save() {
408
702
  const record = {
409
703
  sessionId: this.sessionId, version: '1.0.0',
@@ -484,7 +778,7 @@ async function run() {
484
778
  globalContextInputs.push(timeline.sharedContext);
485
779
  }
486
780
 
487
- const globalContext = gatherContext(workspace, globalContextInputs);
781
+ const globalContext = await gatherContext(workspace, globalContextInputs);
488
782
  const globalContextBlock = globalContext.sections.join('\n\n');
489
783
 
490
784
  let scheduledSteps;
@@ -557,7 +851,7 @@ IMPORTANT: Work autonomously. Do not ask questions or wait for confirmation. Mak
557
851
  Only work inside the provided workspace and preserve user data.`;
558
852
 
559
853
  for (const step of scheduledSteps) {
560
- const stepContext = gatherContext(workspace, step.contextPaths);
854
+ const stepContext = await gatherContext(workspace, step.contextPaths);
561
855
  if (stepContext.labels.length) {
562
856
  console.log(`Step context for ${step.displayName}: ${stepContext.labels.join(', ')}`);
563
857
  console.log('');
@@ -614,10 +908,49 @@ Only work inside the provided workspace and preserve user data.`;
614
908
  console.log('');
615
909
  }
616
910
 
911
+ // Print retry statistics if any retries occurred
912
+ printRetryStatistics();
913
+
617
914
  console.log('Session complete. Knoxis and the AI partner are standing by for further instructions.');
618
915
  }
619
916
 
917
+ function printRetryStatistics() {
918
+ if (retryStats.totalRetries === 0 && retryStats.failedCommands.length === 0) {
919
+ return; // No retries needed, don't print stats
920
+ }
921
+
922
+ console.log('');
923
+ console.log('===== RETRY STATISTICS =====');
924
+ console.log(`Total Retries: ${retryStats.totalRetries}`);
925
+ console.log(`Successful Recoveries: ${retryStats.successfulRetries}`);
926
+ console.log(`Failed After Max Retries: ${retryStats.failedCommands.length}`);
927
+
928
+ if (retryStats.failedCommands.length > 0) {
929
+ console.log('\nFailed Commands:');
930
+ retryStats.failedCommands.forEach((failure, index) => {
931
+ console.log(` ${index + 1}. [${failure.timestamp}]`);
932
+ console.log(` Command: ${failure.command}`);
933
+ console.log(` Error: ${failure.error}`);
934
+ });
935
+ }
936
+
937
+ const sessionDuration = Date.now() - retryStats.startTime;
938
+ const minutes = Math.floor(sessionDuration / 60000);
939
+ const seconds = Math.floor((sessionDuration % 60000) / 1000);
940
+ console.log(`\nSession Duration: ${minutes}m ${seconds}s`);
941
+
942
+ if (retryStats.totalRetries > 0) {
943
+ const retryOverhead = retryStats.totalRetries * RETRY_CONFIG.retryDelay;
944
+ const overheadMinutes = Math.floor(retryOverhead / 60000);
945
+ const overheadSeconds = Math.floor((retryOverhead % 60000) / 1000);
946
+ console.log(`Estimated Retry Overhead: ${overheadMinutes}m ${overheadSeconds}s`);
947
+ }
948
+ console.log('============================');
949
+ console.log('');
950
+ }
951
+
620
952
  run().catch(err => {
621
- console.error(err.message);
953
+ console.error('Fatal error:', err.message);
954
+ printRetryStatistics();
622
955
  process.exit(1);
623
956
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knoxis-helper",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "Local helper for Knoxis pair programming - connects your machine to Knoxis on qig.ai",
5
5
  "bin": {
6
6
  "knoxis-helper": "./bin/knoxis-helper.js"