@lucenaone/coder 1.0.0 → 1.1.1

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.1",
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,20 @@ 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
+ // Index is guaranteed ready (awaited in start()), push immediately
134
+ if (this.indexData) {
135
+ this.pushIndexSnapshot(this.indexData);
136
+ }
137
+ });
138
+
72
139
  const commandsRef = ref(this.db, `tunnels/${this.tunnelId}/commands`);
73
140
  onChildAdded(commandsRef, async (snapshot) => {
74
141
  const command = snapshot.val();
@@ -78,15 +145,51 @@ export class LucenaAgent {
78
145
  try {
79
146
  await this.handleCommand(command);
80
147
  } catch (err) {
81
- this.pushResponse(command.messageId, 'error', err.message);
148
+ this.pushResponse(command.messageId, 'error', stripCwd(this.cwd, err.message));
82
149
  }
83
150
  });
84
151
 
85
152
  this.startWatcher();
86
153
  this.connected = true;
154
+
155
+ // ── Wait for indexing to finish before opening the browser ──
156
+ // This guarantees the snapshot is ready the instant the browser connects
157
+ await this.indexPromise;
158
+
87
159
  return this.tunnelId;
88
160
  }
89
161
 
162
+ // ── Push the full pre-built index to the browser via RTDB ──
163
+ pushIndexSnapshot(index) {
164
+ const snapshotRef = ref(this.db, `tunnels/${this.tunnelId}/indexSnapshot`);
165
+ // Push as a single message — the browser hydrates from this
166
+ set(snapshotRef, {
167
+ symbols: index.symbols,
168
+ strings: index.strings,
169
+ stats: index.stats,
170
+ timestamp: serverTimestamp(),
171
+ });
172
+ console.log(` ${'\x1b[36m'}📡 Index snapshot pushed to browser${'\x1b[0m'}`);
173
+ }
174
+
175
+ // ── Push an incremental delta when a file changes ──
176
+ async pushIndexDelta(relPath) {
177
+ const ext = extname(relPath);
178
+ const SUPPORTED_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.py', '.rb', '.go', '.rs'];
179
+ if (!SUPPORTED_EXTS.includes(ext)) return;
180
+
181
+ const delta = await reindexFile(this.cwd, '/' + relPath);
182
+ if (!delta) return;
183
+
184
+ const deltaRef = ref(this.db, `tunnels/${this.tunnelId}/indexDeltas`);
185
+ push(deltaRef, {
186
+ filePath: delta.filePath,
187
+ symbols: delta.symbols,
188
+ strings: delta.strings,
189
+ timestamp: serverTimestamp(),
190
+ });
191
+ }
192
+
90
193
  async handleCommand(command) {
91
194
  const { type, messageId } = command;
92
195
  switch (type) {
@@ -117,8 +220,12 @@ export class LucenaAgent {
117
220
  async executeCommand({ messageId, command }) {
118
221
  const child = spawn('sh', ['-c', command], { cwd: this.cwd });
119
222
 
120
- child.stdout.on('data', (data) => this.pushResponse(messageId, 'output', data.toString()));
121
- child.stderr.on('data', (data) => this.pushResponse(messageId, 'output', data.toString()));
223
+ child.stdout.on('data', (data) => {
224
+ this.pushResponse(messageId, 'output', stripCwd(this.cwd, data.toString()));
225
+ });
226
+ child.stderr.on('data', (data) => {
227
+ this.pushResponse(messageId, 'output', stripCwd(this.cwd, data.toString()));
228
+ });
122
229
  child.on('close', (code) => {
123
230
  this.pushResponse(messageId, 'done', '', { exitCode: code ?? 0 });
124
231
  this.activeCommands.delete(messageId);
@@ -134,18 +241,19 @@ export class LucenaAgent {
134
241
  this.pushResponse(messageId, 'output', content);
135
242
  this.pushResponse(messageId, 'done', '');
136
243
  } catch (err) {
137
- this.pushResponse(messageId, 'error', err.message);
244
+ this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
138
245
  }
139
246
  }
140
247
 
141
248
  async writeFileCmd({ messageId, path: filePath, content }) {
142
249
  const fullPath = getJailedPath(this.cwd, filePath);
250
+ const relPath = '/' + relative(this.cwd, fullPath);
143
251
  try {
144
252
  await mkdir(dirname(fullPath), { recursive: true });
145
253
  await writeFile(fullPath, content, 'utf-8');
146
- this.pushResponse(messageId, 'done', `Wrote ${fullPath}`);
254
+ this.pushResponse(messageId, 'done', `Wrote ${relPath}`);
147
255
  } catch (err) {
148
- this.pushResponse(messageId, 'error', err.message);
256
+ this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
149
257
  }
150
258
  }
151
259
 
@@ -159,7 +267,7 @@ export class LucenaAgent {
159
267
  .join('\n');
160
268
  this.pushResponse(messageId, 'done', listing || '(empty)');
161
269
  } catch (err) {
162
- this.pushResponse(messageId, 'error', err.message);
270
+ this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
163
271
  }
164
272
  }
165
273
 
@@ -175,31 +283,33 @@ export class LucenaAgent {
175
283
  created: s.birthtime
176
284
  }));
177
285
  } catch (err) {
178
- this.pushResponse(messageId, 'error', err.message);
286
+ this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
179
287
  }
180
288
  }
181
289
 
182
290
  async deleteFile({ messageId, path: filePath }) {
183
291
  const fullPath = getJailedPath(this.cwd, filePath);
292
+ const relPath = '/' + relative(this.cwd, fullPath);
184
293
  try {
185
294
  if ((await stat(fullPath)).isDirectory()) {
186
295
  await rm(fullPath, { recursive: true });
187
296
  } else {
188
297
  await unlink(fullPath);
189
298
  }
190
- this.pushResponse(messageId, 'done', `Deleted ${fullPath}`);
299
+ this.pushResponse(messageId, 'done', `Deleted ${relPath}`);
191
300
  } catch (err) {
192
- this.pushResponse(messageId, 'error', err.message);
301
+ this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
193
302
  }
194
303
  }
195
304
 
196
305
  async mkdirCmd({ messageId, path: dirPath }) {
197
306
  const fullPath = getJailedPath(this.cwd, dirPath);
307
+ const relPath = '/' + relative(this.cwd, fullPath);
198
308
  try {
199
309
  await mkdir(fullPath, { recursive: true });
200
- this.pushResponse(messageId, 'done', `Created ${fullPath}`);
310
+ this.pushResponse(messageId, 'done', `Created ${relPath}`);
201
311
  } catch (err) {
202
- this.pushResponse(messageId, 'error', err.message);
312
+ this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
203
313
  }
204
314
  }
205
315
 
@@ -216,10 +326,10 @@ export class LucenaAgent {
216
326
  child.stderr.on('data', (d) => { output += d.toString(); });
217
327
 
218
328
  child.on('close', (code) => {
219
- this.pushResponse(messageId, 'done', output || 'No matches found');
329
+ this.pushResponse(messageId, 'done', stripCwd(this.cwd, output) || 'No matches found');
220
330
  });
221
331
  } catch (err) {
222
- this.pushResponse(messageId, 'error', err.message);
332
+ this.pushResponse(messageId, 'error', stripCwd(this.cwd, err.message));
223
333
  }
224
334
  }
225
335
 
@@ -244,6 +354,11 @@ export class LucenaAgent {
244
354
  path: relPath,
245
355
  timestamp: serverTimestamp()
246
356
  });
357
+
358
+ // ── Push incremental index delta for changed files ──
359
+ if (event === 'change' || event === 'add') {
360
+ this.pushIndexDelta(relPath).catch(() => {}); // Non-blocking, non-fatal
361
+ }
247
362
  });
248
363
  }
249
364
 
@@ -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,7 +42,6 @@ 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(` Starting tunnel...`);
46
45
 
47
46
  const agent = new LucenaAgent(cwd);
48
47
 
@@ -56,8 +55,10 @@ export async function main() {
56
55
  process.on('SIGTERM', shutdown);
57
56
 
58
57
  try {
58
+ console.log(` ${c.dim}⏳ Indexing workspace...${c.reset}`);
59
59
  const tunnelId = await agent.start();
60
- console.log(`\n ${c.green}✔ Tunnel active!${c.reset}\n`);
60
+ console.log(` ${c.green}✔ Indexed${c.reset}\n`);
61
+ console.log(` ${c.green}✔ Tunnel active!${c.reset}\n`);
61
62
 
62
63
  const idLabel = "Tunnel ID:";
63
64
  const boxWidth = idLabel.length + tunnelId.length + 5;