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.
- package/package.json +2 -1
- package/sapper.mjs +715 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sapper-iq",
|
|
3
|
-
"version": "1.1.
|
|
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
|
-
|
|
67
|
-
|
|
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
|