sapper-iq 1.1.33 → 1.1.34

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 +390 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapper-iq",
3
- "version": "1.1.33",
3
+ "version": "1.1.34",
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
@@ -63,8 +63,252 @@ try {
63
63
  } catch (e) {}
64
64
 
65
65
  const spinner = ora();
66
- const CONTEXT_FILE = '.sapper_context.json';
67
- const EMBEDDINGS_FILE = '.sapper_embeddings.json';
66
+
67
+ // ═══════════════════════════════════════════════════════════════
68
+ // SAPPER MEMORY FOLDER - All persistent data in one place
69
+ // ═══════════════════════════════════════════════════════════════
70
+ const SAPPER_DIR = '.sapper';
71
+ const CONTEXT_FILE = `${SAPPER_DIR}/context.json`;
72
+ const EMBEDDINGS_FILE = `${SAPPER_DIR}/embeddings.json`;
73
+ const WORKSPACE_FILE = `${SAPPER_DIR}/workspace.json`;
74
+ const CONFIG_FILE = `${SAPPER_DIR}/config.json`;
75
+
76
+ // Ensure .sapper directory exists
77
+ function ensureSapperDir() {
78
+ if (!fs.existsSync(SAPPER_DIR)) {
79
+ fs.mkdirSync(SAPPER_DIR, { recursive: true });
80
+ }
81
+ }
82
+
83
+ // Load config (settings like autoAttach)
84
+ function loadConfig() {
85
+ try {
86
+ ensureSapperDir();
87
+ if (fs.existsSync(CONFIG_FILE)) {
88
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
89
+ }
90
+ } catch (e) {}
91
+ return { autoAttach: true }; // Default: auto-attach related files is ON
92
+ }
93
+
94
+ function saveConfig(config) {
95
+ ensureSapperDir();
96
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
97
+ }
98
+
99
+ // Global config
100
+ let sapperConfig = loadConfig();
101
+
102
+ // ═══════════════════════════════════════════════════════════════
103
+ // WORKSPACE GRAPH - Track file relationships and summaries
104
+ // ═══════════════════════════════════════════════════════════════
105
+
106
+ function loadWorkspaceGraph() {
107
+ try {
108
+ ensureSapperDir();
109
+ if (fs.existsSync(WORKSPACE_FILE)) {
110
+ return JSON.parse(fs.readFileSync(WORKSPACE_FILE, 'utf8'));
111
+ }
112
+ } catch (e) {}
113
+ return { indexed: null, files: {}, graph: {} };
114
+ }
115
+
116
+ function saveWorkspaceGraph(workspace) {
117
+ ensureSapperDir();
118
+ fs.writeFileSync(WORKSPACE_FILE, JSON.stringify(workspace, null, 2));
119
+ }
120
+
121
+ // Extract imports/requires from file content
122
+ function extractDependencies(content, filePath) {
123
+ const deps = new Set();
124
+ const ext = filePath.split('.').pop()?.toLowerCase();
125
+
126
+ // JavaScript/TypeScript imports
127
+ if (['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
128
+ // import ... from '...'
129
+ const importMatches = content.matchAll(/import\s+.*?\s+from\s+['"]([^'"]+)['"]/g);
130
+ for (const m of importMatches) deps.add(m[1]);
131
+
132
+ // require('...')
133
+ const requireMatches = content.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
134
+ for (const m of requireMatches) deps.add(m[1]);
135
+
136
+ // dynamic import('...')
137
+ const dynImportMatches = content.matchAll(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
138
+ for (const m of dynImportMatches) deps.add(m[1]);
139
+ }
140
+
141
+ // Python imports
142
+ if (ext === 'py') {
143
+ const fromImports = content.matchAll(/from\s+([.\w]+)\s+import/g);
144
+ for (const m of fromImports) deps.add(m[1]);
145
+
146
+ const imports = content.matchAll(/^import\s+([.\w]+)/gm);
147
+ for (const m of imports) deps.add(m[1]);
148
+ }
149
+
150
+ // Filter to only local imports (starting with . or no package scope)
151
+ return Array.from(deps).filter(d => d.startsWith('.') || d.startsWith('/'));
152
+ }
153
+
154
+ // Extract exports from file
155
+ function extractExports(content, filePath) {
156
+ const exports = new Set();
157
+ const ext = filePath.split('.').pop()?.toLowerCase();
158
+
159
+ if (['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
160
+ // export function/class/const name
161
+ const namedExports = content.matchAll(/export\s+(?:function|class|const|let|var|async function)\s+(\w+)/g);
162
+ for (const m of namedExports) exports.add(m[1]);
163
+
164
+ // export { name }
165
+ const bracketExports = content.matchAll(/export\s*\{([^}]+)\}/g);
166
+ for (const m of bracketExports) {
167
+ m[1].split(',').forEach(e => {
168
+ const name = e.trim().split(/\s+as\s+/)[0].trim();
169
+ if (name) exports.add(name);
170
+ });
171
+ }
172
+
173
+ // export default
174
+ if (content.includes('export default')) exports.add('default');
175
+ }
176
+
177
+ return Array.from(exports);
178
+ }
179
+
180
+ // Resolve relative import to actual file path
181
+ function resolveImportPath(importPath, fromFile) {
182
+ if (!importPath.startsWith('.')) return null;
183
+
184
+ const fromDir = dirname(fromFile);
185
+ let resolved = join(fromDir, importPath).replace(/\\/g, '/');
186
+
187
+ // Try common extensions
188
+ const extensions = ['', '.js', '.ts', '.jsx', '.tsx', '.mjs', '/index.js', '/index.ts'];
189
+ for (const ext of extensions) {
190
+ const fullPath = resolved + ext;
191
+ if (fs.existsSync(fullPath)) {
192
+ return fullPath.replace(/^\.\//, '');
193
+ }
194
+ }
195
+ return null;
196
+ }
197
+
198
+ // Build workspace graph from codebase
199
+ async function buildWorkspaceGraph(showProgress = true) {
200
+ const workspace = { indexed: new Date().toISOString(), files: {}, graph: {} };
201
+
202
+ function scanDir(dir, depth = 0) {
203
+ if (depth > 5) return;
204
+
205
+ try {
206
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
207
+
208
+ for (const entry of entries) {
209
+ const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
210
+
211
+ if (entry.isDirectory()) {
212
+ if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
213
+ scanDir(fullPath, depth + 1);
214
+ } else {
215
+ const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
216
+ if (!CODE_EXTENSIONS.has(ext.toLowerCase())) continue;
217
+
218
+ try {
219
+ const stats = fs.statSync(fullPath);
220
+ if (stats.size > MAX_FILE_SIZE) continue;
221
+
222
+ const content = fs.readFileSync(fullPath, 'utf8');
223
+ const deps = extractDependencies(content, fullPath);
224
+ const exports = extractExports(content, fullPath);
225
+
226
+ // Generate brief summary (first meaningful lines)
227
+ const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('//') && !l.trim().startsWith('#'));
228
+ const summary = lines.slice(0, 3).join(' ').substring(0, 150);
229
+
230
+ workspace.files[fullPath] = {
231
+ size: stats.size,
232
+ modified: stats.mtime.toISOString(),
233
+ imports: deps,
234
+ exports: exports,
235
+ summary: summary || '(no summary)'
236
+ };
237
+
238
+ // Build dependency graph
239
+ workspace.graph[fullPath] = [];
240
+ for (const dep of deps) {
241
+ const resolved = resolveImportPath(dep, fullPath);
242
+ if (resolved) {
243
+ workspace.graph[fullPath].push(resolved);
244
+ }
245
+ }
246
+ } catch (e) {}
247
+ }
248
+ }
249
+ } catch (e) {}
250
+ }
251
+
252
+ scanDir('.');
253
+ saveWorkspaceGraph(workspace);
254
+ return workspace;
255
+ }
256
+
257
+ // Get related files for a given file (imports + files that import it)
258
+ function getRelatedFiles(filePath, workspace, depth = 1) {
259
+ const related = new Set();
260
+
261
+ // Direct imports
262
+ const imports = workspace.graph[filePath] || [];
263
+ imports.forEach(f => related.add(f));
264
+
265
+ // Files that import this file (reverse lookup)
266
+ for (const [file, deps] of Object.entries(workspace.graph)) {
267
+ if (deps.includes(filePath)) {
268
+ related.add(file);
269
+ }
270
+ }
271
+
272
+ // Second level if depth > 1
273
+ if (depth > 1) {
274
+ const firstLevel = Array.from(related);
275
+ for (const f of firstLevel) {
276
+ const secondImports = workspace.graph[f] || [];
277
+ secondImports.forEach(sf => related.add(sf));
278
+ }
279
+ }
280
+
281
+ related.delete(filePath); // Don't include self
282
+ return Array.from(related);
283
+ }
284
+
285
+ // Format workspace summary for AI context
286
+ function formatWorkspaceSummary(workspace) {
287
+ const fileCount = Object.keys(workspace.files).length;
288
+ let output = `\n📊 WORKSPACE INDEX (${fileCount} files)\n`;
289
+ output += '═'.repeat(40) + '\n\n';
290
+
291
+ // Group files by directory
292
+ const byDir = {};
293
+ for (const [path, info] of Object.entries(workspace.files)) {
294
+ const dir = dirname(path) || '.';
295
+ if (!byDir[dir]) byDir[dir] = [];
296
+ byDir[dir].push({ path, ...info });
297
+ }
298
+
299
+ for (const [dir, files] of Object.entries(byDir)) {
300
+ output += `📁 ${dir}/\n`;
301
+ for (const f of files.slice(0, 10)) { // Limit per directory
302
+ const name = f.path.split('/').pop();
303
+ const exportList = f.exports?.length ? ` [${f.exports.slice(0, 3).join(', ')}${f.exports.length > 3 ? '...' : ''}]` : '';
304
+ output += ` 📄 ${name}${exportList}\n`;
305
+ }
306
+ if (files.length > 10) output += ` ... and ${files.length - 10} more\n`;
307
+ output += '\n';
308
+ }
309
+
310
+ return output;
311
+ }
68
312
 
69
313
  // ═══════════════════════════════════════════════════════════════
70
314
  // EMBEDDINGS & SEMANTIC SEARCH
@@ -73,6 +317,7 @@ const EMBEDDINGS_FILE = '.sapper_embeddings.json';
73
317
  // Load or create embeddings store
74
318
  function loadEmbeddings() {
75
319
  try {
320
+ ensureSapperDir();
76
321
  if (fs.existsSync(EMBEDDINGS_FILE)) {
77
322
  return JSON.parse(fs.readFileSync(EMBEDDINGS_FILE, 'utf8'));
78
323
  }
@@ -81,6 +326,7 @@ function loadEmbeddings() {
81
326
  }
82
327
 
83
328
  function saveEmbeddings(embeddings) {
329
+ ensureSapperDir();
84
330
  fs.writeFileSync(EMBEDDINGS_FILE, JSON.stringify(embeddings, null, 2));
85
331
  }
86
332
 
@@ -662,6 +908,25 @@ async function runSapper() {
662
908
  // Check for updates
663
909
  await checkForUpdates();
664
910
 
911
+ // Auto-load or build workspace graph
912
+ let workspace = loadWorkspaceGraph();
913
+ if (!workspace.indexed) {
914
+ console.log(chalk.cyan('📊 Building workspace index...'));
915
+ workspace = await buildWorkspaceGraph();
916
+ console.log(chalk.green(`✅ Indexed ${Object.keys(workspace.files).length} files\n`));
917
+ } else {
918
+ const fileCount = Object.keys(workspace.files).length;
919
+ const indexAge = Math.round((Date.now() - new Date(workspace.indexed).getTime()) / 1000 / 60);
920
+ console.log(chalk.gray(`📊 Workspace: ${fileCount} files indexed (${indexAge}m ago)`));
921
+ if (indexAge > 60) {
922
+ console.log(chalk.yellow(` Tip: Run /index to refresh`));
923
+ }
924
+ }
925
+
926
+ // Show memory status
927
+ console.log(chalk.gray(`📁 Memory: .sapper/ folder`));
928
+ console.log(chalk.gray(`🔗 Auto-attach: ${sapperConfig.autoAttach ? 'ON' : 'OFF'} (toggle with /auto)\n`));
929
+
665
930
  let messages = [];
666
931
  if (fs.existsSync(CONTEXT_FILE)) {
667
932
  console.log();
@@ -675,6 +940,21 @@ async function runSapper() {
675
940
  console.log(chalk.gray(' ✓ Starting fresh...\n'));
676
941
  }
677
942
  }
943
+
944
+ // Migrate old files to new .sapper/ folder
945
+ const oldFiles = ['.sapper_context.json', '.sapper_embeddings.json', '.sapper_workspace.json'];
946
+ for (const oldFile of oldFiles) {
947
+ if (fs.existsSync(oldFile)) {
948
+ ensureSapperDir();
949
+ const newFile = `${SAPPER_DIR}/${oldFile.replace('.sapper_', '').replace('_', '.')}`;
950
+ if (!fs.existsSync(newFile)) {
951
+ fs.renameSync(oldFile, newFile);
952
+ console.log(chalk.gray(`📦 Migrated ${oldFile} → ${newFile}`));
953
+ } else {
954
+ fs.unlinkSync(oldFile);
955
+ }
956
+ }
957
+ }
678
958
 
679
959
  let localModels;
680
960
  try {
@@ -824,6 +1104,7 @@ TOOL SYNTAX:
824
1104
  });
825
1105
 
826
1106
  // 5. Save to context file so it persists
1107
+ ensureSapperDir();
827
1108
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
828
1109
 
829
1110
  console.log(chalk.green(`✅ Pruned context. Sapper reminded to stay in Agent Mode.`));
@@ -838,6 +1119,9 @@ TOOL SYNTAX:
838
1119
  `${chalk.cyan('@')} or ${chalk.cyan('/attach')} ${chalk.gray('│')} Pick files to attach (interactive)\n` +
839
1120
  `${chalk.cyan('@file')} ${chalk.gray('│')} Attach file inline (e.g., @src/app.js)\n` +
840
1121
  `${chalk.cyan('/scan')} ${chalk.gray('│')} Scan codebase into context\n` +
1122
+ `${chalk.cyan('/index')} ${chalk.gray('│')} Rebuild workspace graph\n` +
1123
+ `${chalk.cyan('/graph file')} ${chalk.gray('│')} Show related files\n` +
1124
+ `${chalk.cyan('/auto')} ${chalk.gray('│')} Toggle auto-attach related files\n` +
841
1125
  `${chalk.cyan('/recall')} ${chalk.gray('│')} Search memory for relevant context\n` +
842
1126
  `${chalk.cyan('/reset /clear')} ${chalk.gray('│')} Clear all context\n` +
843
1127
  `${chalk.cyan('/prune')} ${chalk.gray('│')} Save to memory + keep last 4 msgs\n` +
@@ -850,6 +1134,90 @@ TOOL SYNTAX:
850
1134
  continue;
851
1135
  }
852
1136
 
1137
+ // Handle index command - rebuild workspace graph
1138
+ if (input.toLowerCase() === '/index') {
1139
+ console.log(chalk.cyan('\n📊 Rebuilding workspace index...'));
1140
+ workspace = await buildWorkspaceGraph();
1141
+ console.log(chalk.green(`✅ Indexed ${Object.keys(workspace.files).length} files`));
1142
+ console.log(chalk.gray(` Graph: ${Object.values(workspace.graph).flat().length} dependencies tracked\n`));
1143
+ continue;
1144
+ }
1145
+
1146
+ // Handle graph command - show related files
1147
+ if (input.toLowerCase().startsWith('/graph')) {
1148
+ const targetFile = input.slice(6).trim();
1149
+ if (!targetFile) {
1150
+ // Show workspace overview
1151
+ console.log(formatWorkspaceSummary(workspace));
1152
+ continue;
1153
+ }
1154
+
1155
+ // Find file (support partial match)
1156
+ const matchingFile = Object.keys(workspace.files).find(f =>
1157
+ f === targetFile || f.endsWith('/' + targetFile) || f.endsWith(targetFile)
1158
+ );
1159
+
1160
+ if (!matchingFile) {
1161
+ console.log(chalk.yellow(`File not found in index: ${targetFile}`));
1162
+ console.log(chalk.gray('Tip: Run /index to refresh workspace graph'));
1163
+ continue;
1164
+ }
1165
+
1166
+ const fileInfo = workspace.files[matchingFile];
1167
+ const related = getRelatedFiles(matchingFile, workspace);
1168
+
1169
+ console.log();
1170
+ console.log(box(
1171
+ `${chalk.white('File:')} ${chalk.cyan(matchingFile)}\n` +
1172
+ `${chalk.white('Size:')} ${Math.round(fileInfo.size/1024)}KB\n` +
1173
+ `${chalk.white('Exports:')} ${fileInfo.exports?.join(', ') || 'none'}\n` +
1174
+ `${chalk.white('Imports:')} ${fileInfo.imports?.join(', ') || 'none'}\n` +
1175
+ chalk.gray('─'.repeat(40)) + '\n' +
1176
+ `${chalk.white('Related files:')}\n` +
1177
+ (related.length > 0
1178
+ ? related.map(r => ` 📄 ${r}`).join('\n')
1179
+ : chalk.gray(' (no related files found)')),
1180
+ '🔗 File Graph', 'cyan'
1181
+ ));
1182
+ console.log();
1183
+
1184
+ // Offer to add to context
1185
+ if (related.length > 0) {
1186
+ const addRelated = await safeQuestion(chalk.yellow('Add this file + related to context? ') + chalk.gray('(y/n): '));
1187
+ if (addRelated.toLowerCase() === 'y') {
1188
+ let contextContent = `\n📄 ${matchingFile}:\n`;
1189
+ contextContent += fs.readFileSync(matchingFile, 'utf8');
1190
+
1191
+ for (const relFile of related.slice(0, 5)) { // Limit to 5 related
1192
+ try {
1193
+ contextContent += `\n\n📄 ${relFile} (related):\n`;
1194
+ contextContent += fs.readFileSync(relFile, 'utf8');
1195
+ } catch (e) {}
1196
+ }
1197
+
1198
+ messages.push({
1199
+ role: 'user',
1200
+ content: `Here is ${matchingFile} and its related files:\n${contextContent}\n\nUse this context to help me.`
1201
+ });
1202
+ console.log(chalk.green(`✅ Added ${matchingFile} + ${Math.min(related.length, 5)} related files to context`));
1203
+ }
1204
+ }
1205
+ continue;
1206
+ }
1207
+
1208
+ // Handle auto-attach toggle
1209
+ if (input.toLowerCase() === '/auto') {
1210
+ sapperConfig.autoAttach = !sapperConfig.autoAttach;
1211
+ saveConfig(sapperConfig);
1212
+ console.log(chalk.cyan(`\n🔗 Auto-attach related files: ${sapperConfig.autoAttach ? chalk.green('ON') : chalk.red('OFF')}`));
1213
+ if (sapperConfig.autoAttach) {
1214
+ console.log(chalk.gray(' When you @file, related imports will be auto-included.'));
1215
+ } else {
1216
+ console.log(chalk.gray(' Only explicitly mentioned files will be attached.'));
1217
+ }
1218
+ continue;
1219
+ }
1220
+
853
1221
  // Handle context size command
854
1222
  if (input.toLowerCase() === '/context') {
855
1223
  const contextSize = JSON.stringify(messages).length;
@@ -940,6 +1308,7 @@ TOOL SYNTAX:
940
1308
  content: `I've scanned the entire codebase. Here are all the files:\n${formattedScan}\n\nYou now have the full codebase context. Use this information to help me.`
941
1309
  });
942
1310
 
1311
+ ensureSapperDir();
943
1312
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
944
1313
  console.log(chalk.gray('📝 Codebase added to context. AI now has full picture.\n'));
945
1314
  continue;
@@ -1012,6 +1381,23 @@ TOOL SYNTAX:
1012
1381
  const content = fs.readFileSync(filePath, 'utf8');
1013
1382
  fileAttachments.push({ path: filePath, content, size: stats.size });
1014
1383
  console.log(chalk.green(`📎 Attached: ${filePath} (${Math.round(stats.size/1024)}KB)`));
1384
+
1385
+ // Auto-include related files from workspace graph (up to 3) - if enabled
1386
+ if (sapperConfig.autoAttach) {
1387
+ const related = getRelatedFiles(filePath, workspace, 1);
1388
+ for (const relFile of related.slice(0, 3)) {
1389
+ try {
1390
+ if (!fileAttachments.some(f => f.path === relFile)) {
1391
+ const relStats = fs.statSync(relFile);
1392
+ if (relStats.size <= MAX_FILE_SIZE) {
1393
+ const relContent = fs.readFileSync(relFile, 'utf8');
1394
+ fileAttachments.push({ path: relFile, content: relContent, size: relStats.size, related: true });
1395
+ console.log(chalk.gray(` ↳ +${relFile} (related)`));
1396
+ }
1397
+ }
1398
+ } catch (e) {}
1399
+ }
1400
+ } // end if autoAttach
1015
1401
  }
1016
1402
  }
1017
1403
  } else {
@@ -1201,6 +1587,7 @@ TOOL SYNTAX:
1201
1587
 
1202
1588
  messages.push({ role: 'user', content: `RESULT (${path}): ${result}` });
1203
1589
  }
1590
+ ensureSapperDir();
1204
1591
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
1205
1592
 
1206
1593
  if (toolMatches.length > 30) {
@@ -1216,6 +1603,7 @@ TOOL SYNTAX:
1216
1603
  });
1217
1604
  } else {
1218
1605
  // Normal response - save and wait for next input
1606
+ ensureSapperDir();
1219
1607
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
1220
1608
  active = false;
1221
1609
  spinner.stop(); // Ensure spinner is dead