@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.
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "lucenacoder",
3
+ "createdAt": "2026-05-24T01:17:13.939Z"
4
+ }
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.0.0",
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
- "chokidar": "^4.0.0"
15
+ "web-tree-sitter": "^0.26.9"
15
16
  },
16
- "keywords": ["lucena", "lucenacoder", "tunnel", "lucenaone"],
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', 'Users'
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
- cwd: this.cwd,
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) => this.pushResponse(messageId, 'output', data.toString()));
121
- child.stderr.on('data', (data) => this.pushResponse(messageId, 'output', data.toString()));
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 ${fullPath}`);
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 ${fullPath}`);
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 ${fullPath}`);
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
+ }