@fastino-ai/pioneer-cli 0.1.0 → 0.2.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,321 @@
1
+ /**
2
+ * FileResolver - Parse and resolve @file references in messages
3
+ * Similar to Claude Code's @ mention functionality
4
+ */
5
+
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+
9
+ export interface FileReference {
10
+ original: string; // The original @path text
11
+ path: string; // Resolved absolute path
12
+ relativePath: string; // Path as typed by user
13
+ exists: boolean;
14
+ isDirectory: boolean;
15
+ content?: string;
16
+ error?: string;
17
+ }
18
+
19
+ export interface ResolvedMessage {
20
+ originalMessage: string;
21
+ cleanMessage: string; // Message with @refs replaced by placeholders
22
+ references: FileReference[];
23
+ contextBlock: string; // Formatted context to prepend to message
24
+ }
25
+
26
+ const MAX_FILE_SIZE = 1024 * 1024; // 1MB max per file
27
+ const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB total max
28
+ const MAX_DIRECTORY_FILES = 50; // Max files to include from a directory
29
+
30
+ export class FileResolver {
31
+ private basePath: string;
32
+
33
+ constructor(basePath: string = process.cwd()) {
34
+ this.basePath = basePath;
35
+ }
36
+
37
+ setBasePath(basePath: string): void {
38
+ this.basePath = basePath;
39
+ }
40
+
41
+ /**
42
+ * Parse a message and resolve all @file references
43
+ */
44
+ resolve(message: string): ResolvedMessage {
45
+ const references: FileReference[] = [];
46
+
47
+ // Match @path patterns - supports paths with extensions, slashes, etc.
48
+ // Matches: @file.ts, @src/file.ts, @./relative/path, @../parent/path
49
+ // Stops at whitespace, quotes, or common punctuation
50
+ const atPattern = /@((?:\.\.?\/)?[\w\-./]+(?:\.\w+)?)/g;
51
+
52
+ let match;
53
+ const foundPaths = new Set<string>();
54
+
55
+ while ((match = atPattern.exec(message)) !== null) {
56
+ const relativePath = match[1];
57
+
58
+ // Skip if we've already processed this path
59
+ if (foundPaths.has(relativePath)) continue;
60
+ foundPaths.add(relativePath);
61
+
62
+ const ref = this.resolveReference(relativePath, match[0]);
63
+ references.push(ref);
64
+ }
65
+
66
+ // Build context block from resolved files
67
+ const contextBlock = this.buildContextBlock(references);
68
+
69
+ // Create clean message (optionally replace @refs with indicators)
70
+ let cleanMessage = message;
71
+ for (const ref of references) {
72
+ if (ref.exists) {
73
+ // Keep the @reference in the message so the LLM knows what was referenced
74
+ // but the actual content is in the context block
75
+ }
76
+ }
77
+
78
+ return {
79
+ originalMessage: message,
80
+ cleanMessage,
81
+ references,
82
+ contextBlock,
83
+ };
84
+ }
85
+
86
+ private resolveReference(relativePath: string, original: string): FileReference {
87
+ // Resolve to absolute path
88
+ const absolutePath = path.isAbsolute(relativePath)
89
+ ? relativePath
90
+ : path.resolve(this.basePath, relativePath);
91
+
92
+ const ref: FileReference = {
93
+ original,
94
+ path: absolutePath,
95
+ relativePath,
96
+ exists: false,
97
+ isDirectory: false,
98
+ };
99
+
100
+ try {
101
+ if (!fs.existsSync(absolutePath)) {
102
+ ref.error = "File or directory not found";
103
+ return ref;
104
+ }
105
+
106
+ const stats = fs.statSync(absolutePath);
107
+ ref.exists = true;
108
+ ref.isDirectory = stats.isDirectory();
109
+
110
+ if (ref.isDirectory) {
111
+ ref.content = this.readDirectory(absolutePath);
112
+ } else {
113
+ if (stats.size > MAX_FILE_SIZE) {
114
+ ref.error = `File too large (${(stats.size / 1024).toFixed(1)}KB > ${MAX_FILE_SIZE / 1024}KB limit)`;
115
+ return ref;
116
+ }
117
+ ref.content = this.readFile(absolutePath);
118
+ }
119
+ } catch (err) {
120
+ ref.error = err instanceof Error ? err.message : String(err);
121
+ }
122
+
123
+ return ref;
124
+ }
125
+
126
+ private readFile(filePath: string): string {
127
+ try {
128
+ return fs.readFileSync(filePath, "utf-8");
129
+ } catch (err) {
130
+ throw new Error(`Failed to read file: ${err instanceof Error ? err.message : err}`);
131
+ }
132
+ }
133
+
134
+ private readDirectory(dirPath: string): string {
135
+ const entries: string[] = [];
136
+ const files = this.listDirectoryRecursive(dirPath, "", 0, 3);
137
+
138
+ // Build a tree-like structure
139
+ let output = `Directory: ${dirPath}\n`;
140
+ output += "```\n";
141
+ output += files.join("\n");
142
+ output += "\n```";
143
+
144
+ return output;
145
+ }
146
+
147
+ private listDirectoryRecursive(
148
+ basePath: string,
149
+ relativePath: string,
150
+ depth: number,
151
+ maxDepth: number
152
+ ): string[] {
153
+ const results: string[] = [];
154
+ const fullPath = path.join(basePath, relativePath);
155
+
156
+ try {
157
+ const entries = fs.readdirSync(fullPath, { withFileTypes: true });
158
+
159
+ // Sort: directories first, then files
160
+ entries.sort((a, b) => {
161
+ if (a.isDirectory() && !b.isDirectory()) return -1;
162
+ if (!a.isDirectory() && b.isDirectory()) return 1;
163
+ return a.name.localeCompare(b.name);
164
+ });
165
+
166
+ for (const entry of entries) {
167
+ // Skip hidden files and common ignored directories
168
+ if (entry.name.startsWith(".")) continue;
169
+ if (["node_modules", "__pycache__", "dist", "build", ".git"].includes(entry.name)) continue;
170
+
171
+ const indent = " ".repeat(depth);
172
+ const entryRelPath = path.join(relativePath, entry.name);
173
+
174
+ if (entry.isDirectory()) {
175
+ results.push(`${indent}${entry.name}/`);
176
+ if (depth < maxDepth) {
177
+ results.push(...this.listDirectoryRecursive(basePath, entryRelPath, depth + 1, maxDepth));
178
+ }
179
+ } else {
180
+ results.push(`${indent}${entry.name}`);
181
+ }
182
+
183
+ if (results.length >= MAX_DIRECTORY_FILES) {
184
+ results.push(`${indent}... (truncated)`);
185
+ break;
186
+ }
187
+ }
188
+ } catch {
189
+ // Skip inaccessible directories
190
+ }
191
+
192
+ return results;
193
+ }
194
+
195
+ private buildContextBlock(references: FileReference[]): string {
196
+ if (references.length === 0) return "";
197
+
198
+ const successfulRefs = references.filter((r) => r.exists && r.content);
199
+ if (successfulRefs.length === 0) return "";
200
+
201
+ let totalSize = 0;
202
+ const blocks: string[] = [];
203
+
204
+ blocks.push("<referenced_files>");
205
+
206
+ for (const ref of successfulRefs) {
207
+ if (!ref.content) continue;
208
+
209
+ // Check total size limit
210
+ if (totalSize + ref.content.length > MAX_TOTAL_SIZE) {
211
+ blocks.push(`\n<!-- Skipped ${ref.relativePath}: would exceed total size limit -->`);
212
+ continue;
213
+ }
214
+
215
+ totalSize += ref.content.length;
216
+
217
+ if (ref.isDirectory) {
218
+ blocks.push(`\n<directory path="${ref.relativePath}">`);
219
+ blocks.push(ref.content);
220
+ blocks.push("</directory>");
221
+ } else {
222
+ const ext = path.extname(ref.relativePath).slice(1) || "txt";
223
+ blocks.push(`\n<file path="${ref.relativePath}">`);
224
+ blocks.push("```" + ext);
225
+ blocks.push(ref.content);
226
+ blocks.push("```");
227
+ blocks.push("</file>");
228
+ }
229
+ }
230
+
231
+ blocks.push("\n</referenced_files>\n");
232
+
233
+ // Add any errors
234
+ const errors = references.filter((r) => r.error);
235
+ if (errors.length > 0) {
236
+ blocks.push("\n<!-- File reference errors:");
237
+ for (const err of errors) {
238
+ blocks.push(` ${err.original}: ${err.error}`);
239
+ }
240
+ blocks.push("-->\n");
241
+ }
242
+
243
+ return blocks.join("\n");
244
+ }
245
+
246
+ /**
247
+ * Get file suggestions for autocomplete
248
+ * Handles: "" (list cwd), "src/" (list src dir), "src/a" (filter by prefix)
249
+ */
250
+ getSuggestions(partial: string, limit = 15): string[] {
251
+ const suggestions: string[] = [];
252
+
253
+ let searchDir: string;
254
+ let prefix: string;
255
+ let dirPrefix: string;
256
+
257
+ if (!partial || partial === "") {
258
+ // Empty: list current directory
259
+ searchDir = this.basePath;
260
+ prefix = "";
261
+ dirPrefix = "";
262
+ } else if (partial.endsWith("/")) {
263
+ // Ends with /: list that directory
264
+ searchDir = path.resolve(this.basePath, partial);
265
+ prefix = "";
266
+ dirPrefix = partial;
267
+ } else if (partial.includes("/")) {
268
+ // Has / but doesn't end with it: filter in parent dir
269
+ searchDir = path.resolve(this.basePath, path.dirname(partial));
270
+ prefix = path.basename(partial).toLowerCase();
271
+ dirPrefix = path.dirname(partial) + "/";
272
+ } else {
273
+ // No slash: filter in current dir
274
+ searchDir = this.basePath;
275
+ prefix = partial.toLowerCase();
276
+ dirPrefix = "";
277
+ }
278
+
279
+ try {
280
+ if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) {
281
+ return [];
282
+ }
283
+
284
+ const entries = fs.readdirSync(searchDir, { withFileTypes: true });
285
+
286
+ // Sort: directories first, then alphabetically
287
+ entries.sort((a, b) => {
288
+ if (a.isDirectory() && !b.isDirectory()) return -1;
289
+ if (!a.isDirectory() && b.isDirectory()) return 1;
290
+ return a.name.localeCompare(b.name);
291
+ });
292
+
293
+ for (const entry of entries) {
294
+ if (entry.name.startsWith(".")) continue;
295
+ if (["node_modules", "__pycache__", "dist", "build", ".git", "venv"].includes(entry.name)) continue;
296
+
297
+ if (!prefix || entry.name.toLowerCase().startsWith(prefix)) {
298
+ const relativePath = dirPrefix + entry.name;
299
+ suggestions.push(entry.isDirectory() ? `${relativePath}/` : relativePath);
300
+ }
301
+
302
+ if (suggestions.length >= limit) break;
303
+ }
304
+ } catch {
305
+ // Ignore errors
306
+ }
307
+
308
+ return suggestions;
309
+ }
310
+ }
311
+
312
+ // Singleton for easy access
313
+ let defaultResolver: FileResolver | null = null;
314
+
315
+ export function getFileResolver(basePath?: string): FileResolver {
316
+ if (!defaultResolver || basePath) {
317
+ defaultResolver = new FileResolver(basePath);
318
+ }
319
+ return defaultResolver;
320
+ }
321
+