knoxis-helper 1.4.5 → 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.
@@ -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.5",
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"