sapper-iq 1.1.33 → 1.1.35

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 +2 -1
  2. package/sapper.mjs +715 -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.35",
4
4
  "description": "AI-powered development assistant that executes commands and builds projects",
5
5
  "main": "sapper.mjs",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "start": "node sapper.mjs"
12
12
  },
13
13
  "dependencies": {
14
+ "acorn": "^8.15.0",
14
15
  "chalk": "^5.3.0",
15
16
  "marked": "^15.0.12",
16
17
  "marked-terminal": "^7.3.0",
package/sapper.mjs CHANGED
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'url';
9
9
  import { dirname, join } from 'path';
10
10
  import { marked } from 'marked';
11
11
  import TerminalRenderer from 'marked-terminal';
12
+ import * as acorn from 'acorn';
12
13
 
13
14
  const __filename = fileURLToPath(import.meta.url);
14
15
  const __dirname = dirname(__filename);
@@ -63,8 +64,495 @@ try {
63
64
  } catch (e) {}
64
65
 
65
66
  const spinner = ora();
66
- const CONTEXT_FILE = '.sapper_context.json';
67
- const EMBEDDINGS_FILE = '.sapper_embeddings.json';
67
+
68
+ // ═══════════════════════════════════════════════════════════════
69
+ // SAPPER MEMORY FOLDER - All persistent data in one place
70
+ // ═══════════════════════════════════════════════════════════════
71
+ const SAPPER_DIR = '.sapper';
72
+ const CONTEXT_FILE = `${SAPPER_DIR}/context.json`;
73
+ const EMBEDDINGS_FILE = `${SAPPER_DIR}/embeddings.json`;
74
+ const WORKSPACE_FILE = `${SAPPER_DIR}/workspace.json`;
75
+ const CONFIG_FILE = `${SAPPER_DIR}/config.json`;
76
+
77
+ // Ensure .sapper directory exists
78
+ function ensureSapperDir() {
79
+ if (!fs.existsSync(SAPPER_DIR)) {
80
+ fs.mkdirSync(SAPPER_DIR, { recursive: true });
81
+ }
82
+ }
83
+
84
+ // Load config (settings like autoAttach)
85
+ function loadConfig() {
86
+ try {
87
+ ensureSapperDir();
88
+ if (fs.existsSync(CONFIG_FILE)) {
89
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
90
+ }
91
+ } catch (e) {}
92
+ return { autoAttach: true }; // Default: auto-attach related files is ON
93
+ }
94
+
95
+ function saveConfig(config) {
96
+ ensureSapperDir();
97
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
98
+ }
99
+
100
+ // Global config
101
+ let sapperConfig = loadConfig();
102
+
103
+ // ═══════════════════════════════════════════════════════════════
104
+ // WORKSPACE GRAPH - Track file relationships and summaries
105
+ // ═══════════════════════════════════════════════════════════════
106
+
107
+ function loadWorkspaceGraph() {
108
+ try {
109
+ ensureSapperDir();
110
+ if (fs.existsSync(WORKSPACE_FILE)) {
111
+ return JSON.parse(fs.readFileSync(WORKSPACE_FILE, 'utf8'));
112
+ }
113
+ } catch (e) {}
114
+ return { indexed: null, files: {}, graph: {} };
115
+ }
116
+
117
+ function saveWorkspaceGraph(workspace) {
118
+ ensureSapperDir();
119
+ fs.writeFileSync(WORKSPACE_FILE, JSON.stringify(workspace, null, 2));
120
+ }
121
+
122
+ // Extract imports/requires from file content
123
+ function extractDependencies(content, filePath) {
124
+ const deps = new Set();
125
+ const ext = filePath.split('.').pop()?.toLowerCase();
126
+
127
+ // JavaScript/TypeScript imports
128
+ if (['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
129
+ // import ... from '...'
130
+ const importMatches = content.matchAll(/import\s+.*?\s+from\s+['"]([^'"]+)['"]/g);
131
+ for (const m of importMatches) deps.add(m[1]);
132
+
133
+ // require('...')
134
+ const requireMatches = content.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
135
+ for (const m of requireMatches) deps.add(m[1]);
136
+
137
+ // dynamic import('...')
138
+ const dynImportMatches = content.matchAll(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
139
+ for (const m of dynImportMatches) deps.add(m[1]);
140
+ }
141
+
142
+ // Python imports
143
+ if (ext === 'py') {
144
+ const fromImports = content.matchAll(/from\s+([.\w]+)\s+import/g);
145
+ for (const m of fromImports) deps.add(m[1]);
146
+
147
+ const imports = content.matchAll(/^import\s+([.\w]+)/gm);
148
+ for (const m of imports) deps.add(m[1]);
149
+ }
150
+
151
+ // Filter to only local imports (starting with . or no package scope)
152
+ return Array.from(deps).filter(d => d.startsWith('.') || d.startsWith('/'));
153
+ }
154
+
155
+ // Extract exports from file
156
+ function extractExports(content, filePath) {
157
+ const exports = new Set();
158
+ const ext = filePath.split('.').pop()?.toLowerCase();
159
+
160
+ if (['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
161
+ // export function/class/const name
162
+ const namedExports = content.matchAll(/export\s+(?:function|class|const|let|var|async function)\s+(\w+)/g);
163
+ for (const m of namedExports) exports.add(m[1]);
164
+
165
+ // export { name }
166
+ const bracketExports = content.matchAll(/export\s*\{([^}]+)\}/g);
167
+ for (const m of bracketExports) {
168
+ m[1].split(',').forEach(e => {
169
+ const name = e.trim().split(/\s+as\s+/)[0].trim();
170
+ if (name) exports.add(name);
171
+ });
172
+ }
173
+
174
+ // export default
175
+ if (content.includes('export default')) exports.add('default');
176
+ }
177
+
178
+ return Array.from(exports);
179
+ }
180
+
181
+ // Resolve relative import to actual file path
182
+ function resolveImportPath(importPath, fromFile) {
183
+ if (!importPath.startsWith('.')) return null;
184
+
185
+ const fromDir = dirname(fromFile);
186
+ let resolved = join(fromDir, importPath).replace(/\\/g, '/');
187
+
188
+ // Try common extensions
189
+ const extensions = ['', '.js', '.ts', '.jsx', '.tsx', '.mjs', '/index.js', '/index.ts'];
190
+ for (const ext of extensions) {
191
+ const fullPath = resolved + ext;
192
+ if (fs.existsSync(fullPath)) {
193
+ return fullPath.replace(/^\.\//, '');
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+
199
+ // Build workspace graph from codebase
200
+ async function buildWorkspaceGraph(showProgress = true) {
201
+ const workspace = { indexed: new Date().toISOString(), files: {}, graph: {} };
202
+
203
+ function scanDir(dir, depth = 0) {
204
+ if (depth > 5) return;
205
+
206
+ try {
207
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
208
+
209
+ for (const entry of entries) {
210
+ const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
211
+
212
+ if (entry.isDirectory()) {
213
+ if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
214
+ scanDir(fullPath, depth + 1);
215
+ } else {
216
+ const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
217
+ if (!CODE_EXTENSIONS.has(ext.toLowerCase())) continue;
218
+
219
+ try {
220
+ const stats = fs.statSync(fullPath);
221
+ if (stats.size > MAX_FILE_SIZE) continue;
222
+
223
+ const content = fs.readFileSync(fullPath, 'utf8');
224
+ const deps = extractDependencies(content, fullPath);
225
+ const exports = extractExports(content, fullPath);
226
+
227
+ // Generate brief summary (first meaningful lines)
228
+ const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('//') && !l.trim().startsWith('#'));
229
+ const summary = lines.slice(0, 3).join(' ').substring(0, 150);
230
+
231
+ workspace.files[fullPath] = {
232
+ size: stats.size,
233
+ modified: stats.mtime.toISOString(),
234
+ imports: deps,
235
+ exports: exports,
236
+ symbols: parseFileSymbols(content, fullPath), // AST-extracted symbols
237
+ summary: summary || '(no summary)'
238
+ };
239
+
240
+ // Build dependency graph
241
+ workspace.graph[fullPath] = [];
242
+ for (const dep of deps) {
243
+ const resolved = resolveImportPath(dep, fullPath);
244
+ if (resolved) {
245
+ workspace.graph[fullPath].push(resolved);
246
+ }
247
+ }
248
+ } catch (e) {}
249
+ }
250
+ }
251
+ } catch (e) {}
252
+ }
253
+
254
+ scanDir('.');
255
+ saveWorkspaceGraph(workspace);
256
+ return workspace;
257
+ }
258
+
259
+ // Get related files for a given file (imports + files that import it)
260
+ function getRelatedFiles(filePath, workspace, depth = 1) {
261
+ const related = new Set();
262
+
263
+ // Direct imports
264
+ const imports = workspace.graph[filePath] || [];
265
+ imports.forEach(f => related.add(f));
266
+
267
+ // Files that import this file (reverse lookup)
268
+ for (const [file, deps] of Object.entries(workspace.graph)) {
269
+ if (deps.includes(filePath)) {
270
+ related.add(file);
271
+ }
272
+ }
273
+
274
+ // Second level if depth > 1
275
+ if (depth > 1) {
276
+ const firstLevel = Array.from(related);
277
+ for (const f of firstLevel) {
278
+ const secondImports = workspace.graph[f] || [];
279
+ secondImports.forEach(sf => related.add(sf));
280
+ }
281
+ }
282
+
283
+ related.delete(filePath); // Don't include self
284
+ return Array.from(related);
285
+ }
286
+
287
+ // Format workspace summary for AI context
288
+ function formatWorkspaceSummary(workspace) {
289
+ const fileCount = Object.keys(workspace.files).length;
290
+ let output = `\n📊 WORKSPACE INDEX (${fileCount} files)\n`;
291
+ output += '═'.repeat(40) + '\n\n';
292
+
293
+ // Group files by directory
294
+ const byDir = {};
295
+ for (const [path, info] of Object.entries(workspace.files)) {
296
+ const dir = dirname(path) || '.';
297
+ if (!byDir[dir]) byDir[dir] = [];
298
+ byDir[dir].push({ path, ...info });
299
+ }
300
+
301
+ for (const [dir, files] of Object.entries(byDir)) {
302
+ output += `📁 ${dir}/\n`;
303
+ for (const f of files.slice(0, 10)) { // Limit per directory
304
+ const name = f.path.split('/').pop();
305
+ const exportList = f.exports?.length ? ` [${f.exports.slice(0, 3).join(', ')}${f.exports.length > 3 ? '...' : ''}]` : '';
306
+ output += ` 📄 ${name}${exportList}\n`;
307
+ }
308
+ if (files.length > 10) output += ` ... and ${files.length - 10} more\n`;
309
+ output += '\n';
310
+ }
311
+
312
+ return output;
313
+ }
314
+
315
+ // ═══════════════════════════════════════════════════════════════
316
+ // AST PARSING - Extract symbols (functions, classes, variables)
317
+ // ═══════════════════════════════════════════════════════════════
318
+
319
+ // Parse JavaScript/TypeScript file and extract symbols
320
+ function parseFileSymbols(content, filePath) {
321
+ const symbols = [];
322
+ const ext = filePath.split('.').pop()?.toLowerCase();
323
+
324
+ // Only parse JS/TS files with acorn
325
+ if (!['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
326
+ // For other languages, use regex-based extraction
327
+ return extractSymbolsWithRegex(content, filePath);
328
+ }
329
+
330
+ try {
331
+ // Parse with acorn (use loose parsing to handle more syntax)
332
+ const ast = acorn.parse(content, {
333
+ ecmaVersion: 'latest',
334
+ sourceType: 'module',
335
+ locations: true,
336
+ allowHashBang: true,
337
+ allowAwaitOutsideFunction: true,
338
+ allowImportExportEverywhere: true,
339
+ // Be lenient with errors
340
+ onComment: () => {},
341
+ });
342
+
343
+ // Walk the AST to extract symbols
344
+ function walk(node, parentName = null) {
345
+ if (!node || typeof node !== 'object') return;
346
+
347
+ switch (node.type) {
348
+ case 'FunctionDeclaration':
349
+ if (node.id?.name) {
350
+ symbols.push({
351
+ type: 'function',
352
+ name: node.id.name,
353
+ line: node.loc?.start?.line || 0,
354
+ params: node.params?.map(p => p.name || p.left?.name || '?').join(', ') || '',
355
+ async: node.async || false,
356
+ });
357
+ }
358
+ break;
359
+
360
+ case 'ClassDeclaration':
361
+ if (node.id?.name) {
362
+ symbols.push({
363
+ type: 'class',
364
+ name: node.id.name,
365
+ line: node.loc?.start?.line || 0,
366
+ extends: node.superClass?.name || null,
367
+ });
368
+ // Extract methods
369
+ if (node.body?.body) {
370
+ for (const member of node.body.body) {
371
+ if (member.type === 'MethodDefinition' && member.key?.name) {
372
+ symbols.push({
373
+ type: 'method',
374
+ name: `${node.id.name}.${member.key.name}`,
375
+ line: member.loc?.start?.line || 0,
376
+ kind: member.kind, // 'constructor', 'method', 'get', 'set'
377
+ });
378
+ }
379
+ }
380
+ }
381
+ }
382
+ break;
383
+
384
+ case 'VariableDeclaration':
385
+ for (const decl of node.declarations || []) {
386
+ if (decl.id?.name) {
387
+ // Check if it's a function expression or arrow function
388
+ const init = decl.init;
389
+ if (init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression') {
390
+ symbols.push({
391
+ type: 'function',
392
+ name: decl.id.name,
393
+ line: node.loc?.start?.line || 0,
394
+ params: init.params?.map(p => p.name || p.left?.name || '?').join(', ') || '',
395
+ async: init.async || false,
396
+ arrow: init.type === 'ArrowFunctionExpression',
397
+ });
398
+ } else {
399
+ symbols.push({
400
+ type: 'variable',
401
+ name: decl.id.name,
402
+ line: node.loc?.start?.line || 0,
403
+ kind: node.kind, // 'const', 'let', 'var'
404
+ });
405
+ }
406
+ }
407
+ }
408
+ break;
409
+
410
+ case 'ExportNamedDeclaration':
411
+ if (node.declaration) {
412
+ walk(node.declaration, parentName);
413
+ }
414
+ break;
415
+
416
+ case 'ExportDefaultDeclaration':
417
+ if (node.declaration) {
418
+ if (node.declaration.id?.name) {
419
+ symbols.push({
420
+ type: node.declaration.type === 'ClassDeclaration' ? 'class' : 'function',
421
+ name: node.declaration.id.name,
422
+ line: node.loc?.start?.line || 0,
423
+ exported: 'default',
424
+ });
425
+ }
426
+ }
427
+ break;
428
+ }
429
+
430
+ // Recursively walk children
431
+ for (const key in node) {
432
+ if (key === 'loc' || key === 'range') continue;
433
+ const child = node[key];
434
+ if (Array.isArray(child)) {
435
+ child.forEach(c => walk(c, parentName));
436
+ } else if (child && typeof child === 'object') {
437
+ walk(child, parentName);
438
+ }
439
+ }
440
+ }
441
+
442
+ walk(ast);
443
+
444
+ } catch (e) {
445
+ // If AST parsing fails, fall back to regex
446
+ return extractSymbolsWithRegex(content, filePath);
447
+ }
448
+
449
+ return symbols;
450
+ }
451
+
452
+ // Fallback: extract symbols using regex (for non-JS or when AST fails)
453
+ function extractSymbolsWithRegex(content, filePath) {
454
+ const symbols = [];
455
+ const lines = content.split('\n');
456
+ const ext = filePath.split('.').pop()?.toLowerCase();
457
+
458
+ // JavaScript/TypeScript patterns
459
+ if (['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
460
+ const funcPattern = /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/g;
461
+ const classPattern = /(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?/g;
462
+ const arrowPattern = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g;
463
+ const methodPattern = /^\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/gm;
464
+
465
+ let match;
466
+ while ((match = funcPattern.exec(content))) {
467
+ const line = content.substring(0, match.index).split('\n').length;
468
+ symbols.push({ type: 'function', name: match[1], line });
469
+ }
470
+ while ((match = classPattern.exec(content))) {
471
+ const line = content.substring(0, match.index).split('\n').length;
472
+ symbols.push({ type: 'class', name: match[1], line, extends: match[2] });
473
+ }
474
+ while ((match = arrowPattern.exec(content))) {
475
+ const line = content.substring(0, match.index).split('\n').length;
476
+ symbols.push({ type: 'function', name: match[1], line, arrow: true });
477
+ }
478
+ }
479
+
480
+ // Python patterns
481
+ if (ext === 'py') {
482
+ const funcPattern = /^(?:async\s+)?def\s+(\w+)\s*\(/gm;
483
+ const classPattern = /^class\s+(\w+)(?:\s*\([^)]*\))?:/gm;
484
+
485
+ let match;
486
+ while ((match = funcPattern.exec(content))) {
487
+ const line = content.substring(0, match.index).split('\n').length;
488
+ symbols.push({ type: 'function', name: match[1], line });
489
+ }
490
+ while ((match = classPattern.exec(content))) {
491
+ const line = content.substring(0, match.index).split('\n').length;
492
+ symbols.push({ type: 'class', name: match[1], line });
493
+ }
494
+ }
495
+
496
+ // Java/C#/Go patterns
497
+ if (['java', 'cs', 'go'].includes(ext)) {
498
+ const funcPattern = /(?:public|private|protected|static|func)?\s*(?:\w+\s+)?(\w+)\s*\([^)]*\)\s*(?:throws\s+\w+\s*)?\{/g;
499
+ const classPattern = /(?:public\s+)?(?:class|struct|interface)\s+(\w+)/g;
500
+
501
+ let match;
502
+ while ((match = funcPattern.exec(content))) {
503
+ const line = content.substring(0, match.index).split('\n').length;
504
+ if (!['if', 'for', 'while', 'switch', 'catch'].includes(match[1])) {
505
+ symbols.push({ type: 'function', name: match[1], line });
506
+ }
507
+ }
508
+ while ((match = classPattern.exec(content))) {
509
+ const line = content.substring(0, match.index).split('\n').length;
510
+ symbols.push({ type: 'class', name: match[1], line });
511
+ }
512
+ }
513
+
514
+ return symbols;
515
+ }
516
+
517
+ // Search for symbol across workspace
518
+ function searchSymbol(query, workspace) {
519
+ const results = [];
520
+ const queryLower = query.toLowerCase();
521
+
522
+ for (const [filePath, fileInfo] of Object.entries(workspace.files)) {
523
+ if (!fileInfo.symbols) continue;
524
+
525
+ for (const symbol of fileInfo.symbols) {
526
+ if (symbol.name.toLowerCase().includes(queryLower)) {
527
+ results.push({
528
+ ...symbol,
529
+ file: filePath,
530
+ score: symbol.name.toLowerCase() === queryLower ? 100 :
531
+ symbol.name.toLowerCase().startsWith(queryLower) ? 80 : 50
532
+ });
533
+ }
534
+ }
535
+ }
536
+
537
+ // Sort by score (exact match first) then by name
538
+ results.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
539
+ return results;
540
+ }
541
+
542
+ // Format symbol for display
543
+ function formatSymbol(symbol) {
544
+ const icon = symbol.type === 'function' ? '𝑓' :
545
+ symbol.type === 'class' ? '◆' :
546
+ symbol.type === 'method' ? '○' :
547
+ symbol.type === 'variable' ? '◇' : '•';
548
+
549
+ let desc = `${icon} ${symbol.name}`;
550
+ if (symbol.params !== undefined) desc += `(${symbol.params})`;
551
+ if (symbol.async) desc = 'async ' + desc;
552
+ if (symbol.extends) desc += ` extends ${symbol.extends}`;
553
+
554
+ return desc;
555
+ }
68
556
 
69
557
  // ═══════════════════════════════════════════════════════════════
70
558
  // EMBEDDINGS & SEMANTIC SEARCH
@@ -73,6 +561,7 @@ const EMBEDDINGS_FILE = '.sapper_embeddings.json';
73
561
  // Load or create embeddings store
74
562
  function loadEmbeddings() {
75
563
  try {
564
+ ensureSapperDir();
76
565
  if (fs.existsSync(EMBEDDINGS_FILE)) {
77
566
  return JSON.parse(fs.readFileSync(EMBEDDINGS_FILE, 'utf8'));
78
567
  }
@@ -81,6 +570,7 @@ function loadEmbeddings() {
81
570
  }
82
571
 
83
572
  function saveEmbeddings(embeddings) {
573
+ ensureSapperDir();
84
574
  fs.writeFileSync(EMBEDDINGS_FILE, JSON.stringify(embeddings, null, 2));
85
575
  }
86
576
 
@@ -662,6 +1152,27 @@ async function runSapper() {
662
1152
  // Check for updates
663
1153
  await checkForUpdates();
664
1154
 
1155
+ // Auto-load or build workspace graph
1156
+ let workspace = loadWorkspaceGraph();
1157
+ if (!workspace.indexed) {
1158
+ console.log(chalk.cyan('📊 Building workspace index with AST parsing...'));
1159
+ workspace = await buildWorkspaceGraph();
1160
+ const totalSymbols = Object.values(workspace.files).reduce((sum, f) => sum + (f.symbols?.length || 0), 0);
1161
+ console.log(chalk.green(`✅ Indexed ${Object.keys(workspace.files).length} files, ${totalSymbols} symbols\n`));
1162
+ } else {
1163
+ const fileCount = Object.keys(workspace.files).length;
1164
+ const symbolCount = Object.values(workspace.files).reduce((sum, f) => sum + (f.symbols?.length || 0), 0);
1165
+ const indexAge = Math.round((Date.now() - new Date(workspace.indexed).getTime()) / 1000 / 60);
1166
+ console.log(chalk.gray(`📊 Workspace: ${fileCount} files, ${symbolCount} symbols (${indexAge}m ago)`));
1167
+ if (indexAge > 60) {
1168
+ console.log(chalk.yellow(` Tip: Run /index to refresh`));
1169
+ }
1170
+ }
1171
+
1172
+ // Show memory status
1173
+ console.log(chalk.gray(`📁 Memory: .sapper/ folder`));
1174
+ console.log(chalk.gray(`🔗 Auto-attach: ${sapperConfig.autoAttach ? 'ON' : 'OFF'} (toggle with /auto)\n`));
1175
+
665
1176
  let messages = [];
666
1177
  if (fs.existsSync(CONTEXT_FILE)) {
667
1178
  console.log();
@@ -675,6 +1186,21 @@ async function runSapper() {
675
1186
  console.log(chalk.gray(' ✓ Starting fresh...\n'));
676
1187
  }
677
1188
  }
1189
+
1190
+ // Migrate old files to new .sapper/ folder
1191
+ const oldFiles = ['.sapper_context.json', '.sapper_embeddings.json', '.sapper_workspace.json'];
1192
+ for (const oldFile of oldFiles) {
1193
+ if (fs.existsSync(oldFile)) {
1194
+ ensureSapperDir();
1195
+ const newFile = `${SAPPER_DIR}/${oldFile.replace('.sapper_', '').replace('_', '.')}`;
1196
+ if (!fs.existsSync(newFile)) {
1197
+ fs.renameSync(oldFile, newFile);
1198
+ console.log(chalk.gray(`📦 Migrated ${oldFile} → ${newFile}`));
1199
+ } else {
1200
+ fs.unlinkSync(oldFile);
1201
+ }
1202
+ }
1203
+ }
678
1204
 
679
1205
  let localModels;
680
1206
  try {
@@ -824,6 +1350,7 @@ TOOL SYNTAX:
824
1350
  });
825
1351
 
826
1352
  // 5. Save to context file so it persists
1353
+ ensureSapperDir();
827
1354
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
828
1355
 
829
1356
  console.log(chalk.green(`✅ Pruned context. Sapper reminded to stay in Agent Mode.`));
@@ -838,6 +1365,10 @@ TOOL SYNTAX:
838
1365
  `${chalk.cyan('@')} or ${chalk.cyan('/attach')} ${chalk.gray('│')} Pick files to attach (interactive)\n` +
839
1366
  `${chalk.cyan('@file')} ${chalk.gray('│')} Attach file inline (e.g., @src/app.js)\n` +
840
1367
  `${chalk.cyan('/scan')} ${chalk.gray('│')} Scan codebase into context\n` +
1368
+ `${chalk.cyan('/index')} ${chalk.gray('│')} Rebuild workspace graph\n` +
1369
+ `${chalk.cyan('/graph file')} ${chalk.gray('│')} Show related files\n` +
1370
+ `${chalk.cyan('/symbol name')} ${chalk.gray('│')} Search functions/classes\n` +
1371
+ `${chalk.cyan('/auto')} ${chalk.gray('│')} Toggle auto-attach related files\n` +
841
1372
  `${chalk.cyan('/recall')} ${chalk.gray('│')} Search memory for relevant context\n` +
842
1373
  `${chalk.cyan('/reset /clear')} ${chalk.gray('│')} Clear all context\n` +
843
1374
  `${chalk.cyan('/prune')} ${chalk.gray('│')} Save to memory + keep last 4 msgs\n` +
@@ -850,6 +1381,168 @@ TOOL SYNTAX:
850
1381
  continue;
851
1382
  }
852
1383
 
1384
+ // Handle index command - rebuild workspace graph
1385
+ if (input.toLowerCase() === '/index') {
1386
+ console.log(chalk.cyan('\n📊 Rebuilding workspace index with AST parsing...'));
1387
+ workspace = await buildWorkspaceGraph();
1388
+ const totalSymbols = Object.values(workspace.files).reduce((sum, f) => sum + (f.symbols?.length || 0), 0);
1389
+ console.log(chalk.green(`✅ Indexed ${Object.keys(workspace.files).length} files`));
1390
+ console.log(chalk.gray(` 📦 ${totalSymbols} symbols (functions, classes, variables)`));
1391
+ console.log(chalk.gray(` 🔗 ${Object.values(workspace.graph).flat().length} dependencies tracked\n`));
1392
+ continue;
1393
+ }
1394
+
1395
+ // Handle symbol search command
1396
+ if (input.toLowerCase().startsWith('/symbol')) {
1397
+ const query = input.slice(7).trim();
1398
+ if (!query) {
1399
+ // Show all symbols summary
1400
+ const allSymbols = [];
1401
+ for (const [file, info] of Object.entries(workspace.files)) {
1402
+ for (const sym of info.symbols || []) {
1403
+ allSymbols.push({ ...sym, file });
1404
+ }
1405
+ }
1406
+
1407
+ // Group by type
1408
+ const functions = allSymbols.filter(s => s.type === 'function');
1409
+ const classes = allSymbols.filter(s => s.type === 'class');
1410
+ const methods = allSymbols.filter(s => s.type === 'method');
1411
+
1412
+ console.log();
1413
+ console.log(box(
1414
+ `${chalk.cyan('Functions:')} ${functions.length}\n` +
1415
+ `${chalk.cyan('Classes:')} ${classes.length}\n` +
1416
+ `${chalk.cyan('Methods:')} ${methods.length}\n` +
1417
+ chalk.gray('─'.repeat(30)) + '\n' +
1418
+ chalk.gray('Usage: /symbol <name> to search'),
1419
+ '📦 Symbol Index', 'cyan'
1420
+ ));
1421
+ continue;
1422
+ }
1423
+
1424
+ console.log(chalk.cyan(`\n🔍 Searching for: "${query}"...\n`));
1425
+ const results = searchSymbol(query, workspace);
1426
+
1427
+ if (results.length === 0) {
1428
+ console.log(chalk.yellow(`No symbols found matching "${query}"`));
1429
+ console.log(chalk.gray('Tip: Run /index to refresh symbol index'));
1430
+ continue;
1431
+ }
1432
+
1433
+ console.log(chalk.green(`Found ${results.length} symbol${results.length !== 1 ? 's' : ''}:\n`));
1434
+
1435
+ for (const sym of results.slice(0, 15)) {
1436
+ const typeIcon = sym.type === 'function' ? chalk.yellow('𝑓') :
1437
+ sym.type === 'class' ? chalk.blue('◆') :
1438
+ sym.type === 'method' ? chalk.cyan('○') : chalk.gray('◇');
1439
+ const asyncTag = sym.async ? chalk.magenta('async ') : '';
1440
+ const params = sym.params !== undefined ? chalk.gray(`(${sym.params})`) : '';
1441
+
1442
+ console.log(` ${typeIcon} ${asyncTag}${chalk.white.bold(sym.name)}${params}`);
1443
+ console.log(` ${chalk.gray(sym.file)}:${chalk.cyan(sym.line)}`);
1444
+ }
1445
+
1446
+ if (results.length > 15) {
1447
+ console.log(chalk.gray(`\n ... and ${results.length - 15} more`));
1448
+ }
1449
+
1450
+ // Offer to add file to context
1451
+ if (results.length > 0) {
1452
+ console.log();
1453
+ const addToCtx = await safeQuestion(chalk.yellow('Add first match file to context? ') + chalk.gray('(y/n): '));
1454
+ if (addToCtx.toLowerCase() === 'y') {
1455
+ const targetFile = results[0].file;
1456
+ try {
1457
+ const content = fs.readFileSync(targetFile, 'utf8');
1458
+ messages.push({
1459
+ role: 'user',
1460
+ content: `Here is ${targetFile} (contains ${results[0].type} "${results[0].name}" at line ${results[0].line}):\n\n${content}`
1461
+ });
1462
+ console.log(chalk.green(`✅ Added ${targetFile} to context`));
1463
+ } catch (e) {
1464
+ console.log(chalk.red(`Could not read ${targetFile}`));
1465
+ }
1466
+ }
1467
+ }
1468
+ continue;
1469
+ }
1470
+
1471
+ // Handle graph command - show related files
1472
+ if (input.toLowerCase().startsWith('/graph')) {
1473
+ const targetFile = input.slice(6).trim();
1474
+ if (!targetFile) {
1475
+ // Show workspace overview
1476
+ console.log(formatWorkspaceSummary(workspace));
1477
+ continue;
1478
+ }
1479
+
1480
+ // Find file (support partial match)
1481
+ const matchingFile = Object.keys(workspace.files).find(f =>
1482
+ f === targetFile || f.endsWith('/' + targetFile) || f.endsWith(targetFile)
1483
+ );
1484
+
1485
+ if (!matchingFile) {
1486
+ console.log(chalk.yellow(`File not found in index: ${targetFile}`));
1487
+ console.log(chalk.gray('Tip: Run /index to refresh workspace graph'));
1488
+ continue;
1489
+ }
1490
+
1491
+ const fileInfo = workspace.files[matchingFile];
1492
+ const related = getRelatedFiles(matchingFile, workspace);
1493
+
1494
+ console.log();
1495
+ console.log(box(
1496
+ `${chalk.white('File:')} ${chalk.cyan(matchingFile)}\n` +
1497
+ `${chalk.white('Size:')} ${Math.round(fileInfo.size/1024)}KB\n` +
1498
+ `${chalk.white('Exports:')} ${fileInfo.exports?.join(', ') || 'none'}\n` +
1499
+ `${chalk.white('Imports:')} ${fileInfo.imports?.join(', ') || 'none'}\n` +
1500
+ chalk.gray('─'.repeat(40)) + '\n' +
1501
+ `${chalk.white('Related files:')}\n` +
1502
+ (related.length > 0
1503
+ ? related.map(r => ` 📄 ${r}`).join('\n')
1504
+ : chalk.gray(' (no related files found)')),
1505
+ '🔗 File Graph', 'cyan'
1506
+ ));
1507
+ console.log();
1508
+
1509
+ // Offer to add to context
1510
+ if (related.length > 0) {
1511
+ const addRelated = await safeQuestion(chalk.yellow('Add this file + related to context? ') + chalk.gray('(y/n): '));
1512
+ if (addRelated.toLowerCase() === 'y') {
1513
+ let contextContent = `\n📄 ${matchingFile}:\n`;
1514
+ contextContent += fs.readFileSync(matchingFile, 'utf8');
1515
+
1516
+ for (const relFile of related.slice(0, 5)) { // Limit to 5 related
1517
+ try {
1518
+ contextContent += `\n\n📄 ${relFile} (related):\n`;
1519
+ contextContent += fs.readFileSync(relFile, 'utf8');
1520
+ } catch (e) {}
1521
+ }
1522
+
1523
+ messages.push({
1524
+ role: 'user',
1525
+ content: `Here is ${matchingFile} and its related files:\n${contextContent}\n\nUse this context to help me.`
1526
+ });
1527
+ console.log(chalk.green(`✅ Added ${matchingFile} + ${Math.min(related.length, 5)} related files to context`));
1528
+ }
1529
+ }
1530
+ continue;
1531
+ }
1532
+
1533
+ // Handle auto-attach toggle
1534
+ if (input.toLowerCase() === '/auto') {
1535
+ sapperConfig.autoAttach = !sapperConfig.autoAttach;
1536
+ saveConfig(sapperConfig);
1537
+ console.log(chalk.cyan(`\n🔗 Auto-attach related files: ${sapperConfig.autoAttach ? chalk.green('ON') : chalk.red('OFF')}`));
1538
+ if (sapperConfig.autoAttach) {
1539
+ console.log(chalk.gray(' When you @file, related imports will be auto-included.'));
1540
+ } else {
1541
+ console.log(chalk.gray(' Only explicitly mentioned files will be attached.'));
1542
+ }
1543
+ continue;
1544
+ }
1545
+
853
1546
  // Handle context size command
854
1547
  if (input.toLowerCase() === '/context') {
855
1548
  const contextSize = JSON.stringify(messages).length;
@@ -940,6 +1633,7 @@ TOOL SYNTAX:
940
1633
  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
1634
  });
942
1635
 
1636
+ ensureSapperDir();
943
1637
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
944
1638
  console.log(chalk.gray('📝 Codebase added to context. AI now has full picture.\n'));
945
1639
  continue;
@@ -1012,6 +1706,23 @@ TOOL SYNTAX:
1012
1706
  const content = fs.readFileSync(filePath, 'utf8');
1013
1707
  fileAttachments.push({ path: filePath, content, size: stats.size });
1014
1708
  console.log(chalk.green(`📎 Attached: ${filePath} (${Math.round(stats.size/1024)}KB)`));
1709
+
1710
+ // Auto-include related files from workspace graph (up to 3) - if enabled
1711
+ if (sapperConfig.autoAttach) {
1712
+ const related = getRelatedFiles(filePath, workspace, 1);
1713
+ for (const relFile of related.slice(0, 3)) {
1714
+ try {
1715
+ if (!fileAttachments.some(f => f.path === relFile)) {
1716
+ const relStats = fs.statSync(relFile);
1717
+ if (relStats.size <= MAX_FILE_SIZE) {
1718
+ const relContent = fs.readFileSync(relFile, 'utf8');
1719
+ fileAttachments.push({ path: relFile, content: relContent, size: relStats.size, related: true });
1720
+ console.log(chalk.gray(` ↳ +${relFile} (related)`));
1721
+ }
1722
+ }
1723
+ } catch (e) {}
1724
+ }
1725
+ } // end if autoAttach
1015
1726
  }
1016
1727
  }
1017
1728
  } else {
@@ -1201,6 +1912,7 @@ TOOL SYNTAX:
1201
1912
 
1202
1913
  messages.push({ role: 'user', content: `RESULT (${path}): ${result}` });
1203
1914
  }
1915
+ ensureSapperDir();
1204
1916
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
1205
1917
 
1206
1918
  if (toolMatches.length > 30) {
@@ -1216,6 +1928,7 @@ TOOL SYNTAX:
1216
1928
  });
1217
1929
  } else {
1218
1930
  // Normal response - save and wait for next input
1931
+ ensureSapperDir();
1219
1932
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
1220
1933
  active = false;
1221
1934
  spinner.stop(); // Ensure spinner is dead