@lucenaone/coder 1.0.0 → 1.1.0
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/.WorkspaceBrain/workspace.json +4 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/grammars/web-tree-sitter.wasm +0 -0
- package/package.json +10 -4
- package/src/agent.js +131 -20
- package/src/cli-indexer.js +466 -0
- package/src/main.js +2 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lucenaone/coder",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Private tunnel for connecting LucenaCoder.com to your local folder. Always remains folder scoped while providing full terminal access.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,10 +10,16 @@
|
|
|
10
10
|
"start": "node bin/lucena.js"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
+
"chokidar": "^4.0.0",
|
|
13
14
|
"firebase": "^11.0.0",
|
|
14
|
-
"
|
|
15
|
+
"web-tree-sitter": "^0.26.9"
|
|
15
16
|
},
|
|
16
|
-
"keywords": [
|
|
17
|
+
"keywords": [
|
|
18
|
+
"lucena",
|
|
19
|
+
"lucenacoder",
|
|
20
|
+
"tunnel",
|
|
21
|
+
"lucenaone"
|
|
22
|
+
],
|
|
17
23
|
"author": "LucenaOne",
|
|
18
24
|
"license": "MIT"
|
|
19
|
-
}
|
|
25
|
+
}
|
package/src/agent.js
CHANGED
|
@@ -3,13 +3,14 @@ import { getDatabase, ref, push, set, onChildAdded, onDisconnect, serverTimestam
|
|
|
3
3
|
import { spawn } from 'child_process';
|
|
4
4
|
import { watch } from 'chokidar';
|
|
5
5
|
import { readFile, writeFile, mkdir, readdir, stat, unlink, rm } from 'fs/promises';
|
|
6
|
-
import { join, resolve, dirname, basename, relative, isAbsolute } from 'path';
|
|
7
|
-
import { existsSync } from 'fs';
|
|
6
|
+
import { join, resolve, dirname, basename, relative, isAbsolute, extname } from 'path';
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
8
8
|
import { FIREBASE_CONFIG } from './config.js';
|
|
9
|
+
import { buildIndex, reindexFile } from './cli-indexer.js';
|
|
9
10
|
|
|
10
11
|
const IGNORED_PATTERNS = [
|
|
11
12
|
'node_modules', '.git', '.next', '.wrangler', '.DS_Store',
|
|
12
|
-
'dist', 'build', '.cache', '.turbo', '.vercel', '.firebase'
|
|
13
|
+
'dist', 'build', '.cache', '.turbo', '.vercel', '.firebase'
|
|
13
14
|
];
|
|
14
15
|
|
|
15
16
|
// ── The CLI Jailer ──
|
|
@@ -36,6 +37,19 @@ function getJailedPath(baseDir, rawPath) {
|
|
|
36
37
|
return resolve(baseDir, safeParts.join('/'));
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
// ── Path Sanitizer ──
|
|
41
|
+
// Strips the absolute cwd from any text output so the AI never sees
|
|
42
|
+
// real filesystem paths. Replaces /Users/whoever/projects/foo with /
|
|
43
|
+
function stripCwd(cwd, text) {
|
|
44
|
+
if (!text || typeof text !== 'string') return text;
|
|
45
|
+
// Normalize both to forward-slash form for reliable matching
|
|
46
|
+
const normalized = cwd.replace(/\\/g, '/');
|
|
47
|
+
// Escape regex special chars in the path
|
|
48
|
+
const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
49
|
+
// Replace the absolute path (with or without trailing slash) with just /
|
|
50
|
+
return text.replace(new RegExp(escaped + '/?', 'g'), '/');
|
|
51
|
+
}
|
|
52
|
+
|
|
39
53
|
export class LucenaAgent {
|
|
40
54
|
constructor(cwd) {
|
|
41
55
|
this.cwd = resolve(cwd);
|
|
@@ -45,9 +59,49 @@ export class LucenaAgent {
|
|
|
45
59
|
this.watcher = null;
|
|
46
60
|
this.activeCommands = new Map();
|
|
47
61
|
this.connected = false;
|
|
62
|
+
this.indexData = null; // Pre-built index from CLI-side parsing
|
|
63
|
+
this.indexPromise = null; // The in-flight indexing promise
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_scaffoldWorkspaceBrain() {
|
|
67
|
+
const brainDir = join(this.cwd, '.WorkspaceBrain');
|
|
68
|
+
const subdirs = ['memories', 'skills', 'threads'];
|
|
69
|
+
try {
|
|
70
|
+
if (!existsSync(brainDir)) mkdirSync(brainDir, { recursive: true });
|
|
71
|
+
for (const sub of subdirs) {
|
|
72
|
+
const subPath = join(brainDir, sub);
|
|
73
|
+
if (!existsSync(subPath)) mkdirSync(subPath, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
// Scaffold workspace.json if it doesn't exist
|
|
76
|
+
const wsJson = join(brainDir, 'workspace.json');
|
|
77
|
+
if (!existsSync(wsJson)) {
|
|
78
|
+
writeFileSync(wsJson, JSON.stringify({ name: basename(this.cwd), createdAt: new Date().toISOString() }, null, 2));
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// Non-fatal — the brain is best-effort
|
|
82
|
+
console.warn('[workspace-brain] scaffold failed:', err.message);
|
|
83
|
+
}
|
|
48
84
|
}
|
|
49
85
|
|
|
50
86
|
async start() {
|
|
87
|
+
// Ensure .WorkspaceBrain/ exists on disk with subdirectories
|
|
88
|
+
this._scaffoldWorkspaceBrain();
|
|
89
|
+
|
|
90
|
+
// ── Start indexing immediately on the CLI machine ──
|
|
91
|
+
this.indexPromise = buildIndex(this.cwd, (progress) => {
|
|
92
|
+
if (progress.phase === 'parsing' && progress.current > 0) {
|
|
93
|
+
process.stdout.write(`\r ${'\x1b[90m'}⏳ Indexing... ${progress.current}/${progress.total} files${'\x1b[0m'}`);
|
|
94
|
+
}
|
|
95
|
+
}).then((result) => {
|
|
96
|
+
this.indexData = result;
|
|
97
|
+
const { stats } = result;
|
|
98
|
+
process.stdout.write(`\r ${'\x1b[32m'}✔ Indexed ${stats.filesParsed} files — ${stats.symbolCount} symbols, ${stats.stringCount} strings${'\x1b[0m'} \n`);
|
|
99
|
+
return result;
|
|
100
|
+
}).catch((err) => {
|
|
101
|
+
console.warn(`\n ${'\x1b[33m'}⚠ Indexing failed: ${err.message}${'\x1b[0m'}`);
|
|
102
|
+
return null;
|
|
103
|
+
});
|
|
104
|
+
|
|
51
105
|
this.app = initializeApp(FIREBASE_CONFIG, `agent-${this.tunnelId}`);
|
|
52
106
|
this.db = getDatabase(this.app);
|
|
53
107
|
const tunnelRef = ref(this.db, `tunnels/${this.tunnelId}`);
|
|
@@ -55,8 +109,7 @@ export class LucenaAgent {
|
|
|
55
109
|
await set(tunnelRef, {
|
|
56
110
|
meta: {
|
|
57
111
|
createdAt: serverTimestamp(),
|
|
58
|
-
|
|
59
|
-
cwdName: basename(this.cwd),
|
|
112
|
+
cwdName: basename(this.cwd), // Never expose full cwd to the browser
|
|
60
113
|
status: 'active',
|
|
61
114
|
pid: process.pid,
|
|
62
115
|
platform: process.platform
|
|
@@ -69,6 +122,21 @@ export class LucenaAgent {
|
|
|
69
122
|
onDisconnect(presenceRef).set(false);
|
|
70
123
|
onDisconnect(ref(this.db, `tunnels/${this.tunnelId}/meta/status`)).set('disconnected');
|
|
71
124
|
|
|
125
|
+
// ── Listen for browser connect events ──
|
|
126
|
+
// When the browser connects, push the pre-built index immediately
|
|
127
|
+
const connectRef = ref(this.db, `tunnels/${this.tunnelId}/meta/browserConnected`);
|
|
128
|
+
onChildAdded(ref(this.db, `tunnels/${this.tunnelId}/browserConnect`), async (snapshot) => {
|
|
129
|
+
const data = snapshot.val();
|
|
130
|
+
if (!data) return;
|
|
131
|
+
remove(snapshot.ref);
|
|
132
|
+
|
|
133
|
+
// Wait for indexing to finish if it hasn't yet
|
|
134
|
+
const index = await this.indexPromise;
|
|
135
|
+
if (index) {
|
|
136
|
+
this.pushIndexSnapshot(index);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
72
140
|
const commandsRef = ref(this.db, `tunnels/${this.tunnelId}/commands`);
|
|
73
141
|
onChildAdded(commandsRef, async (snapshot) => {
|
|
74
142
|
const command = snapshot.val();
|
|
@@ -78,7 +146,7 @@ export class LucenaAgent {
|
|
|
78
146
|
try {
|
|
79
147
|
await this.handleCommand(command);
|
|
80
148
|
} catch (err) {
|
|
81
|
-
this.pushResponse(command.messageId, 'error', err.message);
|
|
149
|
+
this.pushResponse(command.messageId, 'error', stripCwd(this.cwd, err.message));
|
|
82
150
|
}
|
|
83
151
|
});
|
|
84
152
|
|
|
@@ -87,6 +155,37 @@ export class LucenaAgent {
|
|
|
87
155
|
return this.tunnelId;
|
|
88
156
|
}
|
|
89
157
|
|
|
158
|
+
// ── Push the full pre-built index to the browser via RTDB ──
|
|
159
|
+
pushIndexSnapshot(index) {
|
|
160
|
+
const snapshotRef = ref(this.db, `tunnels/${this.tunnelId}/indexSnapshot`);
|
|
161
|
+
// Push as a single message — the browser hydrates from this
|
|
162
|
+
set(snapshotRef, {
|
|
163
|
+
symbols: index.symbols,
|
|
164
|
+
strings: index.strings,
|
|
165
|
+
stats: index.stats,
|
|
166
|
+
timestamp: serverTimestamp(),
|
|
167
|
+
});
|
|
168
|
+
console.log(` ${'\x1b[36m'}📡 Index snapshot pushed to browser${'\x1b[0m'}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Push an incremental delta when a file changes ──
|
|
172
|
+
async pushIndexDelta(relPath) {
|
|
173
|
+
const ext = extname(relPath);
|
|
174
|
+
const SUPPORTED_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.py', '.rb', '.go', '.rs'];
|
|
175
|
+
if (!SUPPORTED_EXTS.includes(ext)) return;
|
|
176
|
+
|
|
177
|
+
const delta = await reindexFile(this.cwd, '/' + relPath);
|
|
178
|
+
if (!delta) return;
|
|
179
|
+
|
|
180
|
+
const deltaRef = ref(this.db, `tunnels/${this.tunnelId}/indexDeltas`);
|
|
181
|
+
push(deltaRef, {
|
|
182
|
+
filePath: delta.filePath,
|
|
183
|
+
symbols: delta.symbols,
|
|
184
|
+
strings: delta.strings,
|
|
185
|
+
timestamp: serverTimestamp(),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
90
189
|
async handleCommand(command) {
|
|
91
190
|
const { type, messageId } = command;
|
|
92
191
|
switch (type) {
|
|
@@ -117,8 +216,12 @@ export class LucenaAgent {
|
|
|
117
216
|
async executeCommand({ messageId, command }) {
|
|
118
217
|
const child = spawn('sh', ['-c', command], { cwd: this.cwd });
|
|
119
218
|
|
|
120
|
-
child.stdout.on('data', (data) =>
|
|
121
|
-
|
|
219
|
+
child.stdout.on('data', (data) => {
|
|
220
|
+
this.pushResponse(messageId, 'output', stripCwd(this.cwd, data.toString()));
|
|
221
|
+
});
|
|
222
|
+
child.stderr.on('data', (data) => {
|
|
223
|
+
this.pushResponse(messageId, 'output', stripCwd(this.cwd, data.toString()));
|
|
224
|
+
});
|
|
122
225
|
child.on('close', (code) => {
|
|
123
226
|
this.pushResponse(messageId, 'done', '', { exitCode: code ?? 0 });
|
|
124
227
|
this.activeCommands.delete(messageId);
|
|
@@ -134,18 +237,19 @@ export class LucenaAgent {
|
|
|
134
237
|
this.pushResponse(messageId, 'output', content);
|
|
135
238
|
this.pushResponse(messageId, 'done', '');
|
|
136
239
|
} catch (err) {
|
|
137
|
-
this.pushResponse(messageId, 'error', err.message);
|
|
240
|
+
this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
|
|
138
241
|
}
|
|
139
242
|
}
|
|
140
243
|
|
|
141
244
|
async writeFileCmd({ messageId, path: filePath, content }) {
|
|
142
245
|
const fullPath = getJailedPath(this.cwd, filePath);
|
|
246
|
+
const relPath = '/' + relative(this.cwd, fullPath);
|
|
143
247
|
try {
|
|
144
248
|
await mkdir(dirname(fullPath), { recursive: true });
|
|
145
249
|
await writeFile(fullPath, content, 'utf-8');
|
|
146
|
-
this.pushResponse(messageId, 'done', `Wrote ${
|
|
250
|
+
this.pushResponse(messageId, 'done', `Wrote ${relPath}`);
|
|
147
251
|
} catch (err) {
|
|
148
|
-
this.pushResponse(messageId, 'error', err.message);
|
|
252
|
+
this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
|
|
149
253
|
}
|
|
150
254
|
}
|
|
151
255
|
|
|
@@ -159,7 +263,7 @@ export class LucenaAgent {
|
|
|
159
263
|
.join('\n');
|
|
160
264
|
this.pushResponse(messageId, 'done', listing || '(empty)');
|
|
161
265
|
} catch (err) {
|
|
162
|
-
this.pushResponse(messageId, 'error', err.message);
|
|
266
|
+
this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
|
|
163
267
|
}
|
|
164
268
|
}
|
|
165
269
|
|
|
@@ -175,31 +279,33 @@ export class LucenaAgent {
|
|
|
175
279
|
created: s.birthtime
|
|
176
280
|
}));
|
|
177
281
|
} catch (err) {
|
|
178
|
-
this.pushResponse(messageId, 'error', err.message);
|
|
282
|
+
this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
|
|
179
283
|
}
|
|
180
284
|
}
|
|
181
285
|
|
|
182
286
|
async deleteFile({ messageId, path: filePath }) {
|
|
183
287
|
const fullPath = getJailedPath(this.cwd, filePath);
|
|
288
|
+
const relPath = '/' + relative(this.cwd, fullPath);
|
|
184
289
|
try {
|
|
185
290
|
if ((await stat(fullPath)).isDirectory()) {
|
|
186
291
|
await rm(fullPath, { recursive: true });
|
|
187
292
|
} else {
|
|
188
293
|
await unlink(fullPath);
|
|
189
294
|
}
|
|
190
|
-
this.pushResponse(messageId, 'done', `Deleted ${
|
|
295
|
+
this.pushResponse(messageId, 'done', `Deleted ${relPath}`);
|
|
191
296
|
} catch (err) {
|
|
192
|
-
this.pushResponse(messageId, 'error', err.message);
|
|
297
|
+
this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
|
|
193
298
|
}
|
|
194
299
|
}
|
|
195
300
|
|
|
196
301
|
async mkdirCmd({ messageId, path: dirPath }) {
|
|
197
302
|
const fullPath = getJailedPath(this.cwd, dirPath);
|
|
303
|
+
const relPath = '/' + relative(this.cwd, fullPath);
|
|
198
304
|
try {
|
|
199
305
|
await mkdir(fullPath, { recursive: true });
|
|
200
|
-
this.pushResponse(messageId, 'done', `Created ${
|
|
306
|
+
this.pushResponse(messageId, 'done', `Created ${relPath}`);
|
|
201
307
|
} catch (err) {
|
|
202
|
-
this.pushResponse(messageId, 'error', err.message);
|
|
308
|
+
this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
|
|
203
309
|
}
|
|
204
310
|
}
|
|
205
311
|
|
|
@@ -216,10 +322,10 @@ export class LucenaAgent {
|
|
|
216
322
|
child.stderr.on('data', (d) => { output += d.toString(); });
|
|
217
323
|
|
|
218
324
|
child.on('close', (code) => {
|
|
219
|
-
this.pushResponse(messageId, 'done', output || 'No matches found');
|
|
325
|
+
this.pushResponse(messageId, 'done', stripCwd(this.cwd, output) || 'No matches found');
|
|
220
326
|
});
|
|
221
327
|
} catch (err) {
|
|
222
|
-
this.pushResponse(messageId, 'error', err.message);
|
|
328
|
+
this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
|
|
223
329
|
}
|
|
224
330
|
}
|
|
225
331
|
|
|
@@ -244,6 +350,11 @@ export class LucenaAgent {
|
|
|
244
350
|
path: relPath,
|
|
245
351
|
timestamp: serverTimestamp()
|
|
246
352
|
});
|
|
353
|
+
|
|
354
|
+
// ── Push incremental index delta for changed files ──
|
|
355
|
+
if (event === 'change' || event === 'add') {
|
|
356
|
+
this.pushIndexDelta(relPath).catch(() => {}); // Non-blocking, non-fatal
|
|
357
|
+
}
|
|
247
358
|
});
|
|
248
359
|
}
|
|
249
360
|
|
|
@@ -262,4 +373,4 @@ export class LucenaAgent {
|
|
|
262
373
|
|
|
263
374
|
this.connected = false;
|
|
264
375
|
}
|
|
265
|
-
}
|
|
376
|
+
}
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// src/cli-indexer.js — Tree-sitter WASM indexer for the CLI agent
|
|
2
|
+
// Runs on the user's machine at startup, parses all code files locally,
|
|
3
|
+
// and builds a symbol + string index that can be pushed to the browser
|
|
4
|
+
// via RTDB when the tunnel connects.
|
|
5
|
+
|
|
6
|
+
import { readFile, readdir } from 'fs/promises';
|
|
7
|
+
import { join, extname, relative } from 'path';
|
|
8
|
+
import { existsSync } from 'fs';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import { dirname } from 'path';
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = dirname(__filename);
|
|
14
|
+
|
|
15
|
+
// ── Language Support ──
|
|
16
|
+
const LANGUAGE_MAP = {
|
|
17
|
+
'.js': 'javascript',
|
|
18
|
+
'.jsx': 'javascript',
|
|
19
|
+
'.ts': 'typescript',
|
|
20
|
+
'.tsx': 'tsx',
|
|
21
|
+
'.py': 'python',
|
|
22
|
+
'.rb': 'ruby',
|
|
23
|
+
'.go': 'go',
|
|
24
|
+
'.rs': 'rust',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const IGNORED_PATTERNS = [
|
|
28
|
+
'node_modules', '.git', '.next', '.wrangler', '.DS_Store',
|
|
29
|
+
'dist', 'build', '.cache', '.turbo', '.vercel', '.firebase',
|
|
30
|
+
'.WorkspaceBrain',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// ── Parser singleton ──
|
|
34
|
+
let _Parser = null;
|
|
35
|
+
let _Language = null;
|
|
36
|
+
let isInitialized = false;
|
|
37
|
+
const languageCache = new Map();
|
|
38
|
+
|
|
39
|
+
function getGrammarsDir() {
|
|
40
|
+
return join(__dirname, '..', 'grammars');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function initParser() {
|
|
44
|
+
if (isInitialized) return;
|
|
45
|
+
const mod = await import('web-tree-sitter');
|
|
46
|
+
_Parser = mod.Parser;
|
|
47
|
+
_Language = mod.Language;
|
|
48
|
+
|
|
49
|
+
const grammarsDir = getGrammarsDir();
|
|
50
|
+
|
|
51
|
+
await _Parser.init({
|
|
52
|
+
locateFile: (scriptName) => {
|
|
53
|
+
return join(grammarsDir, scriptName);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
isInitialized = true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function loadLanguage(langExt) {
|
|
60
|
+
if (languageCache.has(langExt)) return languageCache.get(langExt);
|
|
61
|
+
|
|
62
|
+
const langName = LANGUAGE_MAP[langExt];
|
|
63
|
+
if (!langName) throw new Error(`Unsupported language: ${langExt}`);
|
|
64
|
+
|
|
65
|
+
const grammarsDir = getGrammarsDir();
|
|
66
|
+
const wasmFile = join(grammarsDir, `tree-sitter-${langName}.wasm`);
|
|
67
|
+
const language = await _Language.load(wasmFile);
|
|
68
|
+
languageCache.set(langExt, language);
|
|
69
|
+
return language;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Chunk Extraction (mirrors ast-worker.js exactly) ──
|
|
73
|
+
|
|
74
|
+
function extractChunks(rootNode, code) {
|
|
75
|
+
const chunks = [];
|
|
76
|
+
const strings = [];
|
|
77
|
+
|
|
78
|
+
function walk(node, depth, parentType) {
|
|
79
|
+
const type = node.type;
|
|
80
|
+
|
|
81
|
+
// ── Imports ──
|
|
82
|
+
if (type === 'import_declaration' || type === 'import_statement' || type === 'import_from_statement') {
|
|
83
|
+
chunks.push({
|
|
84
|
+
type: 'import',
|
|
85
|
+
name: node.text.slice(0, 80),
|
|
86
|
+
startByte: node.startIndex,
|
|
87
|
+
endByte: node.endIndex,
|
|
88
|
+
text: node.text,
|
|
89
|
+
});
|
|
90
|
+
extractStringsFromNode(node, strings);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Classes ──
|
|
95
|
+
if (type === 'class_declaration' || type === 'class_definition' || type === 'class') {
|
|
96
|
+
const nameNode = node.childForFieldName('name');
|
|
97
|
+
const name = nameNode ? nameNode.text : '<anonymous>';
|
|
98
|
+
const isExported = isExportedNode(node);
|
|
99
|
+
chunks.push({
|
|
100
|
+
type: 'class',
|
|
101
|
+
name,
|
|
102
|
+
exported: isExported,
|
|
103
|
+
startByte: node.startIndex,
|
|
104
|
+
endByte: node.endIndex,
|
|
105
|
+
text: node.text,
|
|
106
|
+
});
|
|
107
|
+
extractNestedNames(node, chunks, name);
|
|
108
|
+
extractStringsFromNode(node, strings);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Function declarations ──
|
|
113
|
+
if (type === 'function_declaration' || type === 'function_definition' || type === 'method_definition') {
|
|
114
|
+
if (depth > 1) {
|
|
115
|
+
extractStringsFromNode(node, strings);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const nameNode = node.childForFieldName('name');
|
|
119
|
+
const name = nameNode ? nameNode.text : '<anonymous>';
|
|
120
|
+
const isExported = isExportedNode(node) || isExportedNode(node.parent);
|
|
121
|
+
chunks.push({
|
|
122
|
+
type: 'function',
|
|
123
|
+
name,
|
|
124
|
+
exported: isExported,
|
|
125
|
+
startByte: node.startIndex,
|
|
126
|
+
endByte: node.endIndex,
|
|
127
|
+
text: node.text,
|
|
128
|
+
});
|
|
129
|
+
extractStringsFromNode(node, strings);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Variable declarations (top-level only) ──
|
|
134
|
+
if ((type === 'variable_declarator' || type === 'variable_declaration' || type === 'lexical_declaration') && depth === 0) {
|
|
135
|
+
if (type === 'variable_declaration' || type === 'lexical_declaration') {
|
|
136
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
137
|
+
const child = node.child(i);
|
|
138
|
+
if (child.type === 'variable_declarator') {
|
|
139
|
+
const nameNode = child.childForFieldName('name');
|
|
140
|
+
const valueNode = child.childForFieldName('value');
|
|
141
|
+
const name = nameNode ? nameNode.text : '<unknown>';
|
|
142
|
+
const isArrowFn = valueNode && (valueNode.type === 'arrow_function' || valueNode.type === 'function');
|
|
143
|
+
const isExported = isExportedNode(node);
|
|
144
|
+
chunks.push({
|
|
145
|
+
type: isArrowFn ? 'function' : 'variable',
|
|
146
|
+
name,
|
|
147
|
+
exported: isExported,
|
|
148
|
+
startByte: node.startIndex,
|
|
149
|
+
endByte: node.endIndex,
|
|
150
|
+
text: node.text,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
extractStringsFromNode(node, strings);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const nameNode = node.childForFieldName('name');
|
|
159
|
+
const valueNode = node.childForFieldName('value');
|
|
160
|
+
const name = nameNode ? nameNode.text : '<unknown>';
|
|
161
|
+
const isArrowFn = valueNode && (valueNode.type === 'arrow_function' || valueNode.type === 'function');
|
|
162
|
+
const isExported = isExportedNode(node.parent);
|
|
163
|
+
chunks.push({
|
|
164
|
+
type: isArrowFn ? 'function' : 'variable',
|
|
165
|
+
name,
|
|
166
|
+
exported: isExported,
|
|
167
|
+
startByte: node.parent ? node.parent.startIndex : node.startIndex,
|
|
168
|
+
endByte: node.parent ? node.parent.endIndex : node.endIndex,
|
|
169
|
+
text: node.parent ? node.parent.text : node.text,
|
|
170
|
+
});
|
|
171
|
+
extractStringsFromNode(node, strings);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Python assignments at top level ──
|
|
176
|
+
if (type === 'assignment' && depth === 0) {
|
|
177
|
+
const leftNode = node.childForFieldName('left');
|
|
178
|
+
const name = leftNode ? leftNode.text : '<unknown>';
|
|
179
|
+
chunks.push({
|
|
180
|
+
type: 'variable',
|
|
181
|
+
name,
|
|
182
|
+
exported: false,
|
|
183
|
+
startByte: node.startIndex,
|
|
184
|
+
endByte: node.endIndex,
|
|
185
|
+
text: node.text,
|
|
186
|
+
});
|
|
187
|
+
extractStringsFromNode(node, strings);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Go top-level var/const ──
|
|
192
|
+
if ((type === 'var_declaration' || type === 'const_declaration') && depth === 0) {
|
|
193
|
+
chunks.push({
|
|
194
|
+
type: 'variable',
|
|
195
|
+
name: node.text.slice(0, 60),
|
|
196
|
+
exported: false,
|
|
197
|
+
startByte: node.startIndex,
|
|
198
|
+
endByte: node.endIndex,
|
|
199
|
+
text: node.text,
|
|
200
|
+
});
|
|
201
|
+
extractStringsFromNode(node, strings);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Ruby method definitions ──
|
|
206
|
+
if (type === 'method' || type === 'singleton_method') {
|
|
207
|
+
const nameNode = node.childForFieldName('name');
|
|
208
|
+
const name = nameNode ? nameNode.text : '<anonymous>';
|
|
209
|
+
chunks.push({
|
|
210
|
+
type: 'function',
|
|
211
|
+
name,
|
|
212
|
+
exported: false,
|
|
213
|
+
startByte: node.startIndex,
|
|
214
|
+
endByte: node.endIndex,
|
|
215
|
+
text: node.text,
|
|
216
|
+
});
|
|
217
|
+
extractStringsFromNode(node, strings);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Rust function definitions ──
|
|
222
|
+
if (type === 'function_item' || type === 'function_signature_item') {
|
|
223
|
+
const nameNode = node.childForFieldName('name');
|
|
224
|
+
const name = nameNode ? nameNode.text : '<anonymous>';
|
|
225
|
+
const isExported = isExportedNode(node) || isExportedNode(node.parent);
|
|
226
|
+
chunks.push({
|
|
227
|
+
type: 'function',
|
|
228
|
+
name,
|
|
229
|
+
exported: isExported,
|
|
230
|
+
startByte: node.startIndex,
|
|
231
|
+
endByte: node.endIndex,
|
|
232
|
+
text: node.text,
|
|
233
|
+
});
|
|
234
|
+
extractStringsFromNode(node, strings);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Rust impl blocks ──
|
|
239
|
+
if (type === 'impl_item') {
|
|
240
|
+
const typeNode = node.childForFieldName('type');
|
|
241
|
+
const name = typeNode ? typeNode.text : '<impl>';
|
|
242
|
+
chunks.push({
|
|
243
|
+
type: 'class',
|
|
244
|
+
name,
|
|
245
|
+
exported: false,
|
|
246
|
+
startByte: node.startIndex,
|
|
247
|
+
endByte: node.endIndex,
|
|
248
|
+
text: node.text,
|
|
249
|
+
});
|
|
250
|
+
extractStringsFromNode(node, strings);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Export statements wrapping other declarations ──
|
|
255
|
+
if (type === 'export_statement' || type === 'export_default_declaration') {
|
|
256
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
257
|
+
walk(node.child(i), depth, type);
|
|
258
|
+
}
|
|
259
|
+
extractStringsFromNode(node, strings);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Continue walking children ──
|
|
264
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
265
|
+
walk(node.child(i), depth + 1, type);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
walk(rootNode, 0, null);
|
|
270
|
+
return { chunks, strings };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isExportedNode(node) {
|
|
274
|
+
if (!node) return false;
|
|
275
|
+
if (node.type === 'export_statement' || node.type === 'export_default_declaration') return true;
|
|
276
|
+
if (node.type === 'pub') return true; // Rust
|
|
277
|
+
if (node.parent && (node.parent.type === 'export_statement' || node.parent.type === 'export_default_declaration')) return true;
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function extractNestedNames(node, chunks, parentName) {
|
|
282
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
283
|
+
const child = node.child(i);
|
|
284
|
+
if (child.type === 'method_definition' || child.type === 'function_definition' || child.type === 'method') {
|
|
285
|
+
const nameNode = child.childForFieldName('name');
|
|
286
|
+
if (nameNode) {
|
|
287
|
+
chunks.push({
|
|
288
|
+
type: 'nested_function',
|
|
289
|
+
name: nameNode.text,
|
|
290
|
+
parent: parentName,
|
|
291
|
+
startByte: child.startIndex,
|
|
292
|
+
endByte: child.endIndex,
|
|
293
|
+
text: child.text,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function extractStringsFromNode(node, strings) {
|
|
301
|
+
const stringTypes = new Set([
|
|
302
|
+
'string', 'string_literal', 'template_string', 'template_literal',
|
|
303
|
+
'comment', 'line_comment', 'block_comment', 'jsx_text',
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
function walkStrings(n) {
|
|
307
|
+
if (stringTypes.has(n.type)) {
|
|
308
|
+
const text = n.text.trim();
|
|
309
|
+
if (text.length > 1 && text.length < 500) {
|
|
310
|
+
strings.push({
|
|
311
|
+
text,
|
|
312
|
+
startByte: n.startIndex,
|
|
313
|
+
endByte: n.endIndex,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
for (let i = 0; i < n.childCount; i++) {
|
|
319
|
+
walkStrings(n.child(i));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
walkStrings(node);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── File Tree Walker ──
|
|
327
|
+
|
|
328
|
+
async function walkDirectory(dirPath, cwd, results = []) {
|
|
329
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
330
|
+
|
|
331
|
+
for (const entry of entries) {
|
|
332
|
+
if (IGNORED_PATTERNS.some(p => entry.name.includes(p))) continue;
|
|
333
|
+
|
|
334
|
+
const fullPath = join(dirPath, entry.name);
|
|
335
|
+
if (entry.isDirectory()) {
|
|
336
|
+
await walkDirectory(fullPath, cwd, results);
|
|
337
|
+
} else if (entry.isFile()) {
|
|
338
|
+
const ext = extname(entry.name);
|
|
339
|
+
if (LANGUAGE_MAP[ext]) {
|
|
340
|
+
results.push({
|
|
341
|
+
filePath: '/' + relative(cwd, fullPath),
|
|
342
|
+
fullPath,
|
|
343
|
+
ext,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return results;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Main Index Builder ──
|
|
352
|
+
|
|
353
|
+
export async function buildIndex(cwd, onProgress) {
|
|
354
|
+
await initParser();
|
|
355
|
+
|
|
356
|
+
const files = await walkDirectory(cwd, cwd);
|
|
357
|
+
const totalFiles = files.length;
|
|
358
|
+
|
|
359
|
+
if (onProgress) onProgress({ phase: 'parsing', current: 0, total: totalFiles });
|
|
360
|
+
|
|
361
|
+
// Symbol index: Array<{ name, filePath, type, exported, parent }>
|
|
362
|
+
const symbolEntries = [];
|
|
363
|
+
// String index: Array<{ text, filePath }>
|
|
364
|
+
const stringEntries = [];
|
|
365
|
+
|
|
366
|
+
let parsed = 0;
|
|
367
|
+
let errors = 0;
|
|
368
|
+
|
|
369
|
+
for (const file of files) {
|
|
370
|
+
try {
|
|
371
|
+
const code = await readFile(file.fullPath, 'utf-8');
|
|
372
|
+
const lang = await loadLanguage(file.ext);
|
|
373
|
+
const parser = new _Parser();
|
|
374
|
+
parser.setLanguage(lang);
|
|
375
|
+
const tree = parser.parse(code);
|
|
376
|
+
const { chunks, strings } = extractChunks(tree.rootNode, code);
|
|
377
|
+
tree.delete();
|
|
378
|
+
|
|
379
|
+
// Build symbol entries
|
|
380
|
+
for (const chunk of chunks) {
|
|
381
|
+
if (chunk.type === 'import') continue; // Skip imports for symbol index
|
|
382
|
+
symbolEntries.push({
|
|
383
|
+
name: chunk.name,
|
|
384
|
+
filePath: file.filePath,
|
|
385
|
+
type: chunk.type,
|
|
386
|
+
exported: chunk.exported || false,
|
|
387
|
+
parent: chunk.parent || null,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Build string entries
|
|
392
|
+
for (const str of strings) {
|
|
393
|
+
stringEntries.push({
|
|
394
|
+
text: str.text,
|
|
395
|
+
filePath: file.filePath,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
parsed++;
|
|
400
|
+
if (onProgress && parsed % 25 === 0) {
|
|
401
|
+
onProgress({ phase: 'parsing', current: parsed, total: totalFiles });
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
errors++;
|
|
405
|
+
console.error(`[cli-indexer] Error parsing ${file.filePath}: ${err.message}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (onProgress) onProgress({ phase: 'done', current: parsed, total: totalFiles });
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
symbols: symbolEntries,
|
|
413
|
+
strings: stringEntries,
|
|
414
|
+
stats: {
|
|
415
|
+
filesParsed: parsed,
|
|
416
|
+
filesErrored: errors,
|
|
417
|
+
symbolCount: symbolEntries.length,
|
|
418
|
+
stringCount: stringEntries.length,
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── Single-file re-index (for incremental updates) ──
|
|
424
|
+
|
|
425
|
+
export async function reindexFile(cwd, relPath) {
|
|
426
|
+
await initParser();
|
|
427
|
+
|
|
428
|
+
const ext = extname(relPath);
|
|
429
|
+
const langName = LANGUAGE_MAP[ext];
|
|
430
|
+
if (!langName) return null;
|
|
431
|
+
|
|
432
|
+
const fullPath = join(cwd, relPath.replace(/^\//, ''));
|
|
433
|
+
if (!existsSync(fullPath)) return { filePath: relPath, symbols: [], strings: [] };
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
const code = await readFile(fullPath, 'utf-8');
|
|
437
|
+
const lang = await loadLanguage(ext);
|
|
438
|
+
const parser = new _Parser();
|
|
439
|
+
parser.setLanguage(lang);
|
|
440
|
+
const tree = parser.parse(code);
|
|
441
|
+
const { chunks, strings } = extractChunks(tree.rootNode, code);
|
|
442
|
+
tree.delete();
|
|
443
|
+
|
|
444
|
+
const symbolEntries = [];
|
|
445
|
+
for (const chunk of chunks) {
|
|
446
|
+
if (chunk.type === 'import') continue;
|
|
447
|
+
symbolEntries.push({
|
|
448
|
+
name: chunk.name,
|
|
449
|
+
filePath: relPath,
|
|
450
|
+
type: chunk.type,
|
|
451
|
+
exported: chunk.exported || false,
|
|
452
|
+
parent: chunk.parent || null,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const stringEntries = strings.map(s => ({
|
|
457
|
+
text: s.text,
|
|
458
|
+
filePath: relPath,
|
|
459
|
+
}));
|
|
460
|
+
|
|
461
|
+
return { filePath: relPath, symbols: symbolEntries, strings: stringEntries };
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.error(`[cli-indexer] Error re-indexing ${relPath}: ${err.message}`);
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
}
|
package/src/main.js
CHANGED
|
@@ -42,6 +42,7 @@ export async function main() {
|
|
|
42
42
|
console.log(` ${c.cyan}📍 Scoped to:${c.reset} ${cwd}`);
|
|
43
43
|
console.log(` ${c.yellow}🛡️ Safe Mode:${c.reset} ON by default (All edits require approval)`);
|
|
44
44
|
console.log(` ${c.dim}Optionally switch to YOLO on LucenaCoder.com${c.reset}\n`);
|
|
45
|
+
console.log(` ${c.dim}⏳ Indexing workspace...${c.reset}`);
|
|
45
46
|
console.log(` Starting tunnel...`);
|
|
46
47
|
|
|
47
48
|
const agent = new LucenaAgent(cwd);
|
|
@@ -81,4 +82,4 @@ export async function main() {
|
|
|
81
82
|
console.error(`\n ${c.yellow}✖ Failed to start tunnel: ${err.message}${c.reset}\n`);
|
|
82
83
|
process.exit(1);
|
|
83
84
|
}
|
|
84
|
-
}
|
|
85
|
+
}
|