sapper-iq 1.1.27 → 1.1.29

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/sapper.mjs +192 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapper-iq",
3
- "version": "1.1.27",
3
+ "version": "1.1.29",
4
4
  "description": "AI-powered development assistant that executes commands and builds projects",
5
5
  "main": "sapper.mjs",
6
6
  "bin": {
package/sapper.mjs CHANGED
@@ -316,6 +316,99 @@ function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
316
316
  return { files, totalSize };
317
317
  }
318
318
 
319
+ // Scan directory for files (for @ file picker)
320
+ function getFilesForPicker(dir = '.', prefix = '', maxFiles = 50) {
321
+ let files = [];
322
+ try {
323
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
324
+ for (const entry of entries) {
325
+ if (files.length >= maxFiles) break;
326
+ if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
327
+
328
+ const fullPath = prefix ? `${prefix}/${entry.name}` : entry.name;
329
+
330
+ if (entry.isDirectory()) {
331
+ files.push({ path: fullPath + '/', isDir: true });
332
+ // Recurse one level for common structures
333
+ const subFiles = getFilesForPicker(`${dir}/${entry.name}`, fullPath, 20);
334
+ files = files.concat(subFiles.slice(0, 15)); // Limit subdirectory files
335
+ } else {
336
+ const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
337
+ if (CODE_EXTENSIONS.has(ext.toLowerCase()) || CODE_EXTENSIONS.has(entry.name)) {
338
+ try {
339
+ const stats = fs.statSync(`${dir}/${entry.name}`);
340
+ files.push({ path: fullPath, isDir: false, size: stats.size });
341
+ } catch (e) {
342
+ files.push({ path: fullPath, isDir: false, size: 0 });
343
+ }
344
+ }
345
+ }
346
+ }
347
+ } catch (e) {}
348
+ return files.slice(0, maxFiles);
349
+ }
350
+
351
+ // Interactive file picker
352
+ async function pickFiles() {
353
+ const files = getFilesForPicker('.', '', 50).filter(f => !f.isDir);
354
+
355
+ if (files.length === 0) {
356
+ console.log(chalk.yellow('No code files found in current directory.'));
357
+ return [];
358
+ }
359
+
360
+ console.log();
361
+ console.log(box(
362
+ `Select files by number (e.g., ${chalk.cyan('1 3 5')} or ${chalk.cyan('1-5')} or ${chalk.cyan('all')})\n` +
363
+ `Press ${chalk.cyan('Enter')} with no input to cancel`,
364
+ 'šŸ“Ž File Picker', 'cyan'
365
+ ));
366
+ console.log();
367
+
368
+ // Display files in columns
369
+ files.forEach((file, i) => {
370
+ const num = chalk.cyan.bold(`[${(i + 1).toString().padStart(2)}]`);
371
+ const size = file.size ? chalk.gray(`(${Math.round(file.size/1024)}KB)`) : '';
372
+ console.log(` ${num} ${chalk.white(file.path)} ${size}`);
373
+ });
374
+
375
+ console.log();
376
+ const selection = await safeQuestion(chalk.cyan('Select files: '));
377
+
378
+ if (!selection.trim()) {
379
+ console.log(chalk.gray('Cancelled.'));
380
+ return [];
381
+ }
382
+
383
+ // Parse selection
384
+ const selectedFiles = [];
385
+ const parts = selection.toLowerCase().split(/[\s,]+/);
386
+
387
+ for (const part of parts) {
388
+ if (part === 'all') {
389
+ return files.map(f => f.path);
390
+ }
391
+
392
+ // Handle ranges like "1-5"
393
+ const rangeMatch = part.match(/^(\d+)-(\d+)$/);
394
+ if (rangeMatch) {
395
+ const start = parseInt(rangeMatch[1]);
396
+ const end = parseInt(rangeMatch[2]);
397
+ for (let i = start; i <= end && i <= files.length; i++) {
398
+ if (i >= 1) selectedFiles.push(files[i - 1].path);
399
+ }
400
+ } else {
401
+ // Single number
402
+ const num = parseInt(part);
403
+ if (num >= 1 && num <= files.length) {
404
+ selectedFiles.push(files[num - 1].path);
405
+ }
406
+ }
407
+ }
408
+
409
+ return [...new Set(selectedFiles)]; // Remove duplicates
410
+ }
411
+
319
412
  // Format scan results for AI context
320
413
  function formatScanResults(scanResult) {
321
414
  let output = `\n══════════════════════════════════════\n`;
@@ -498,9 +591,9 @@ async function runSapper() {
498
591
 
499
592
  // Quick tips box
500
593
  console.log(box(
501
- `${chalk.yellow('šŸ’”')} Type ${chalk.cyan('/help')} for commands\n` +
594
+ `${chalk.yellow('šŸ’”')} Use ${chalk.cyan('@file')} to attach files (e.g., "fix @app.js")\n` +
502
595
  `${chalk.yellow('šŸ’”')} Type ${chalk.cyan('/scan')} to load entire codebase\n` +
503
- `${chalk.yellow('šŸ’”')} Type ${chalk.cyan('exit')} to quit`,
596
+ `${chalk.yellow('šŸ’”')} Type ${chalk.cyan('/help')} for all commands`,
504
597
  'Quick Tips', 'gray'
505
598
  ));
506
599
  console.log();
@@ -681,6 +774,8 @@ TOOL SYNTAX:
681
774
  if (input.toLowerCase() === '/help') {
682
775
  console.log();
683
776
  const helpContent =
777
+ `${chalk.cyan('@')} or ${chalk.cyan('/attach')} ${chalk.gray('│')} Pick files to attach (interactive)\n` +
778
+ `${chalk.cyan('@file')} ${chalk.gray('│')} Attach file inline (e.g., @src/app.js)\n` +
684
779
  `${chalk.cyan('/scan')} ${chalk.gray('│')} Scan codebase into context\n` +
685
780
  `${chalk.cyan('/recall')} ${chalk.gray('│')} Search memory for relevant context\n` +
686
781
  `${chalk.cyan('/reset /clear')} ${chalk.gray('│')} Clear all context\n` +
@@ -789,7 +884,101 @@ TOOL SYNTAX:
789
884
  continue;
790
885
  }
791
886
 
792
- messages.push({ role: 'user', content: input });
887
+ // Handle @ alone or /attach command - interactive file picker
888
+ if (input.trim() === '@' || input.toLowerCase() === '/attach') {
889
+ const selectedFiles = await pickFiles();
890
+
891
+ if (selectedFiles.length === 0) continue;
892
+
893
+ // Read and attach selected files
894
+ const fileAttachments = [];
895
+ for (const filePath of selectedFiles) {
896
+ try {
897
+ const stats = fs.statSync(filePath);
898
+ if (stats.size > MAX_FILE_SIZE) {
899
+ console.log(chalk.yellow(`āš ļø ${filePath} is too large, skipping`));
900
+ continue;
901
+ }
902
+ const content = fs.readFileSync(filePath, 'utf8');
903
+ fileAttachments.push({ path: filePath, content, size: stats.size });
904
+ console.log(chalk.green(`šŸ“Ž Attached: ${filePath}`));
905
+ } catch (e) {
906
+ console.log(chalk.yellow(`āš ļø Could not read ${filePath}`));
907
+ }
908
+ }
909
+
910
+ if (fileAttachments.length === 0) continue;
911
+
912
+ // Ask for the prompt to go with these files
913
+ console.log();
914
+ const prompt = await safeQuestion(chalk.cyan('Your prompt for these files: '));
915
+
916
+ if (!prompt.trim()) {
917
+ console.log(chalk.gray('Cancelled.'));
918
+ continue;
919
+ }
920
+
921
+ // Build message with attachments
922
+ let attachedContent = '\n\n══════════════════════════════════════\n';
923
+ attachedContent += `šŸ“Ž ATTACHED FILES (${fileAttachments.length})\n`;
924
+ attachedContent += '══════════════════════════════════════\n\n';
925
+
926
+ for (const file of fileAttachments) {
927
+ attachedContent += `ā”Œā”€ā”€ā”€ ${file.path} ───\n`;
928
+ attachedContent += file.content;
929
+ if (!file.content.endsWith('\n')) attachedContent += '\n';
930
+ attachedContent += `└─── END ${file.path} ───\n\n`;
931
+ }
932
+
933
+ messages.push({ role: 'user', content: prompt + attachedContent });
934
+ // Continue to AI response (don't use 'continue' here)
935
+ } else {
936
+ // Process @file attachments in prompt (e.g., "analyze @package.json" or "fix @src/index.js")
937
+ let processedInput = input;
938
+ const fileAttachments = [];
939
+ const attachRegex = /@([\w.\/\-_]+)/g;
940
+ let attachMatch;
941
+
942
+ while ((attachMatch = attachRegex.exec(input)) !== null) {
943
+ const filePath = attachMatch[1];
944
+ try {
945
+ if (fs.existsSync(filePath)) {
946
+ const stats = fs.statSync(filePath);
947
+ if (stats.isFile()) {
948
+ if (stats.size > MAX_FILE_SIZE) {
949
+ console.log(chalk.yellow(`āš ļø @${filePath} is too large (${Math.round(stats.size/1024)}KB), skipping`));
950
+ } else {
951
+ const content = fs.readFileSync(filePath, 'utf8');
952
+ fileAttachments.push({ path: filePath, content, size: stats.size });
953
+ console.log(chalk.green(`šŸ“Ž Attached: ${filePath} (${Math.round(stats.size/1024)}KB)`));
954
+ }
955
+ }
956
+ } else {
957
+ // Not a file - might be an @mention for something else, ignore
958
+ }
959
+ } catch (e) {
960
+ console.log(chalk.yellow(`āš ļø Could not read @${filePath}: ${e.message}`));
961
+ }
962
+ }
963
+
964
+ // Build the final message with attachments
965
+ if (fileAttachments.length > 0) {
966
+ let attachedContent = '\n\n══════════════════════════════════════\n';
967
+ attachedContent += `šŸ“Ž ATTACHED FILES (${fileAttachments.length})\n`;
968
+ attachedContent += '══════════════════════════════════════\n\n';
969
+
970
+ for (const file of fileAttachments) {
971
+ attachedContent += `ā”Œā”€ā”€ā”€ ${file.path} ───\n`;
972
+ attachedContent += file.content;
973
+ if (!file.content.endsWith('\n')) attachedContent += '\n';
974
+ attachedContent += `└─── END ${file.path} ───\n\n`;
975
+ }
976
+
977
+ processedInput = input + attachedContent;
978
+ }
979
+
980
+ messages.push({ role: 'user', content: processedInput });
981
+ } // End of else block for non-@ input
793
982
 
794
983
  let toolRounds = 0; // Prevent infinite loops
795
984
  const MAX_TOOL_ROUNDS = 20;