idea-manager 0.2.0 → 0.3.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.
Files changed (61) hide show
  1. package/README.md +33 -41
  2. package/next.config.ts +0 -1
  3. package/package.json +2 -2
  4. package/{src/app/icon.svg → public/favicon.svg} +2 -2
  5. package/src/app/api/filesystem/route.ts +49 -0
  6. package/src/app/api/projects/[id]/cleanup/route.ts +32 -0
  7. package/src/app/api/projects/[id]/items/[itemId]/refine/route.ts +36 -0
  8. package/src/app/api/projects/[id]/items/[itemId]/route.ts +23 -1
  9. package/src/app/api/projects/[id]/items/route.ts +51 -1
  10. package/src/app/api/projects/[id]/scan/route.ts +73 -0
  11. package/src/app/api/projects/[id]/scan/stream/route.ts +112 -0
  12. package/src/app/api/projects/[id]/structure/route.ts +34 -3
  13. package/src/app/api/projects/[id]/structure/stream/route.ts +157 -0
  14. package/src/app/api/projects/[id]/sub-projects/[subId]/route.ts +39 -0
  15. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +60 -0
  16. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route.ts +26 -0
  17. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.ts +39 -0
  18. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/route.ts +33 -0
  19. package/src/app/api/projects/[id]/sub-projects/route.ts +31 -0
  20. package/src/app/api/projects/route.ts +1 -1
  21. package/src/app/globals.css +465 -5
  22. package/src/app/layout.tsx +3 -0
  23. package/src/app/page.tsx +260 -88
  24. package/src/app/projects/[id]/page.tsx +366 -183
  25. package/src/cli.ts +10 -10
  26. package/src/components/DirectoryPicker.tsx +137 -0
  27. package/src/components/ScanPanel.tsx +743 -0
  28. package/src/components/brainstorm/Editor.tsx +20 -4
  29. package/src/components/brainstorm/MemoPin.tsx +91 -5
  30. package/src/components/dashboard/SubProjectCard.tsx +76 -0
  31. package/src/components/dashboard/TabBar.tsx +42 -0
  32. package/src/components/task/ProjectTree.tsx +223 -0
  33. package/src/components/task/PromptEditor.tsx +107 -0
  34. package/src/components/task/StatusFlow.tsx +43 -0
  35. package/src/components/task/TaskChat.tsx +134 -0
  36. package/src/components/task/TaskDetail.tsx +205 -0
  37. package/src/components/task/TaskList.tsx +119 -0
  38. package/src/components/tree/CardView.tsx +206 -0
  39. package/src/components/tree/RefinePopover.tsx +157 -0
  40. package/src/components/tree/TreeNode.tsx +147 -38
  41. package/src/components/tree/TreeView.tsx +270 -26
  42. package/src/components/ui/ConfirmDialog.tsx +88 -0
  43. package/src/lib/ai/chat-responder.ts +4 -2
  44. package/src/lib/ai/cleanup.ts +87 -0
  45. package/src/lib/ai/client.ts +175 -58
  46. package/src/lib/ai/prompter.ts +19 -24
  47. package/src/lib/ai/refiner.ts +128 -0
  48. package/src/lib/ai/structurer.ts +340 -11
  49. package/src/lib/db/queries/context.ts +76 -0
  50. package/src/lib/db/queries/items.ts +133 -12
  51. package/src/lib/db/queries/projects.ts +12 -8
  52. package/src/lib/db/queries/sub-projects.ts +122 -0
  53. package/src/lib/db/queries/task-conversations.ts +27 -0
  54. package/src/lib/db/queries/task-prompts.ts +32 -0
  55. package/src/lib/db/queries/tasks.ts +133 -0
  56. package/src/lib/db/schema.ts +75 -0
  57. package/src/lib/mcp/server.ts +38 -39
  58. package/src/lib/mcp/tools.ts +47 -45
  59. package/src/lib/scanner.ts +573 -0
  60. package/src/lib/task-store.ts +97 -0
  61. package/src/types/index.ts +65 -0
@@ -0,0 +1,573 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const MAX_FILE_SIZE = 200_000; // 200KB per file — 개별 파일 크기 제한만 유지
5
+
6
+ const SOURCE_SUMMARY_THRESHOLD = 10_000; // 10KB — summarize source files larger than this
7
+
8
+ const IGNORED_DIRS = new Set([
9
+ 'node_modules', '.git', '.next', 'dist', 'build', '__pycache__',
10
+ '.cache', '.tmp', 'coverage', '.turbo', '.vercel', '.output',
11
+ 'vendor', 'target', '.gradle', '.idea', '.vscode', '.svn',
12
+ '.hg', 'out', '.parcel-cache', '.nuxt', '.svelte-kit',
13
+ ]);
14
+
15
+ // Priority 0: Root project config files
16
+ const PRIORITY_FILES = new Set([
17
+ 'README.md', 'CLAUDE.md', '.cursorrules',
18
+ 'package.json', 'tsconfig.json', 'Cargo.toml', 'go.mod',
19
+ 'pyproject.toml', 'requirements.txt', 'pom.xml', 'build.gradle',
20
+ 'Makefile', 'docker-compose.yml', 'Dockerfile',
21
+ 'docker-compose.yaml', '.env.example', 'turbo.json',
22
+ 'nx.json', 'workspace.json', 'lerna.json',
23
+ ]);
24
+
25
+ // Entry point file stems
26
+ const ENTRY_POINT_PATTERNS = [
27
+ 'index', 'main', 'app', 'server', 'mod', 'lib',
28
+ ];
29
+
30
+ // Route/component path segments (Priority 2)
31
+ const ROUTE_COMPONENT_SEGMENTS = new Set([
32
+ 'routes', 'pages', 'app', 'components', 'controllers',
33
+ 'services', 'hooks', 'middleware', 'handlers', 'api',
34
+ 'views', 'modules', 'features', 'stores', 'utils',
35
+ 'helpers', 'providers', 'contexts', 'layouts',
36
+ ]);
37
+
38
+ const DOC_EXTENSIONS = new Set([
39
+ '.md', '.txt', '.rst', '.adoc',
40
+ ]);
41
+
42
+ const CONFIG_EXTENSIONS = new Set([
43
+ '.json', '.toml', '.yaml', '.yml', '.xml',
44
+ '.cfg', '.ini', '.env.example', '.properties',
45
+ ]);
46
+
47
+ const SOURCE_EXTENSIONS = new Set([
48
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
49
+ '.py', '.java', '.go', '.rs', '.rb', '.php',
50
+ '.swift', '.kt', '.scala', '.cs', '.cpp', '.c', '.h',
51
+ '.vue', '.svelte', '.astro',
52
+ '.css', '.scss', '.less',
53
+ '.sql', '.graphql', '.gql', '.prisma',
54
+ '.sh', '.bash', '.zsh',
55
+ ]);
56
+
57
+ // Extensions eligible for source summary extraction
58
+ const SUMMARIZABLE_EXTENSIONS = new Set([
59
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
60
+ '.py', '.java', '.go', '.rs', '.rb', '.php',
61
+ '.swift', '.kt', '.scala', '.cs', '.cpp', '.c', '.h',
62
+ ]);
63
+
64
+ export interface ScannedFile {
65
+ file_path: string;
66
+ content: string;
67
+ }
68
+
69
+ // ============================================================
70
+ // Source summary extraction — compress large source files
71
+ // ============================================================
72
+
73
+ /**
74
+ * Extract structural summary from source code:
75
+ * - Keep: imports, exports, interface/type/enum blocks, function/class signatures, decorators
76
+ * - Remove: function bodies (replaced with summary marker)
77
+ */
78
+ export function extractSourceSummary(content: string, filePath: string): string {
79
+ const ext = path.extname(filePath).toLowerCase();
80
+ if (!SUMMARIZABLE_EXTENSIONS.has(ext)) return content;
81
+
82
+ const lines = content.split('\n');
83
+ const result: string[] = [];
84
+ let braceDepth = 0;
85
+ let inFunctionBody = false;
86
+ let functionStartDepth = 0;
87
+ let lastSignatureLine = -1;
88
+
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const line = lines[i];
91
+ const trimmed = line.trimStart();
92
+
93
+ // Always keep: empty lines at top level, imports, exports (re-exports), decorators, comments at top level
94
+ if (braceDepth === 0 && !inFunctionBody) {
95
+ // Import/export statements
96
+ if (/^(import |export \{|export \*|export type |export default |from )/.test(trimmed)) {
97
+ result.push(line);
98
+ continue;
99
+ }
100
+
101
+ // Decorators
102
+ if (trimmed.startsWith('@')) {
103
+ result.push(line);
104
+ continue;
105
+ }
106
+
107
+ // Interface, type alias, enum — keep entire block
108
+ if (/^(export\s+)?(interface|type|enum)\s/.test(trimmed)) {
109
+ result.push(line);
110
+ // If block opens on this line, collect entire block
111
+ if (trimmed.includes('{')) {
112
+ let blockDepth = 0;
113
+ for (let j = i; j < lines.length; j++) {
114
+ const bLine = lines[j];
115
+ if (j > i) result.push(bLine);
116
+ for (const ch of bLine) {
117
+ if (ch === '{') blockDepth++;
118
+ if (ch === '}') blockDepth--;
119
+ }
120
+ if (blockDepth <= 0 && j > i) {
121
+ i = j;
122
+ break;
123
+ }
124
+ if (j === lines.length - 1) i = j;
125
+ }
126
+ }
127
+ continue;
128
+ }
129
+
130
+ // Function/class/method signature detection
131
+ const isFunctionLike = /^(export\s+)?(export\s+default\s+)?(async\s+)?(function\s+|const\s+\w+\s*=\s*(async\s*)?\(|const\s+\w+\s*=\s*(async\s*)?(\w+|\([^)]*\))\s*=>|class\s+|function\*?\s+)/.test(trimmed);
132
+ const isArrowOrMethod = /^(export\s+)?(const|let|var)\s+\w+\s*[:=]/.test(trimmed) && (trimmed.includes('=>') || trimmed.includes('function'));
133
+
134
+ if (isFunctionLike || isArrowOrMethod) {
135
+ result.push(line);
136
+ lastSignatureLine = result.length - 1;
137
+
138
+ // Check if body opens on this line
139
+ if (trimmed.includes('{')) {
140
+ let openCount = 0;
141
+ for (const ch of line) {
142
+ if (ch === '{') openCount++;
143
+ if (ch === '}') openCount--;
144
+ }
145
+ if (openCount > 0) {
146
+ inFunctionBody = true;
147
+ functionStartDepth = openCount;
148
+ braceDepth = openCount;
149
+ // Replace rest with summary marker
150
+ result[lastSignatureLine] = line.substring(0, line.indexOf('{') + 1) + ' /* ... */ }';
151
+ inFunctionBody = false;
152
+ braceDepth = 0;
153
+
154
+ // Skip to matching close brace
155
+ let depth = openCount;
156
+ for (let j = i + 1; j < lines.length; j++) {
157
+ for (const ch of lines[j]) {
158
+ if (ch === '{') depth++;
159
+ if (ch === '}') depth--;
160
+ }
161
+ if (depth <= 0) {
162
+ i = j;
163
+ break;
164
+ }
165
+ if (j === lines.length - 1) i = j;
166
+ }
167
+ }
168
+ }
169
+ continue;
170
+ }
171
+
172
+ // Top-level variable declarations (keep)
173
+ if (/^(export\s+)?(const|let|var)\s+/.test(trimmed)) {
174
+ result.push(line);
175
+ // If it spans multiple lines with opening brace/bracket, skip body
176
+ if ((trimmed.includes('{') || trimmed.includes('[')) && !trimmed.includes(';')) {
177
+ let depth = 0;
178
+ for (const ch of line) {
179
+ if (ch === '{' || ch === '[') depth++;
180
+ if (ch === '}' || ch === ']') depth--;
181
+ }
182
+ if (depth > 0) {
183
+ // Multi-line — skip to close
184
+ for (let j = i + 1; j < lines.length; j++) {
185
+ for (const ch of lines[j]) {
186
+ if (ch === '{' || ch === '[') depth++;
187
+ if (ch === '}' || ch === ']') depth--;
188
+ }
189
+ if (depth <= 0) {
190
+ result.push(lines[j]);
191
+ i = j;
192
+ break;
193
+ }
194
+ if (j === lines.length - 1) i = j;
195
+ }
196
+ }
197
+ }
198
+ continue;
199
+ }
200
+
201
+ // Single-line comments at top level (keep for context)
202
+ if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) {
203
+ result.push(line);
204
+ continue;
205
+ }
206
+
207
+ // Empty lines (keep some for readability)
208
+ if (trimmed === '') {
209
+ // Only keep if previous line wasn't also empty
210
+ if (result.length === 0 || result[result.length - 1].trim() !== '') {
211
+ result.push(line);
212
+ }
213
+ continue;
214
+ }
215
+
216
+ // Anything else at top level — keep
217
+ result.push(line);
218
+ continue;
219
+ }
220
+
221
+ // Inside a function body — skip (already handled by forward-scanning above)
222
+ // This handles edge cases where brace counting doesn't align
223
+ if (inFunctionBody) {
224
+ for (const ch of line) {
225
+ if (ch === '{') braceDepth++;
226
+ if (ch === '}') braceDepth--;
227
+ }
228
+ if (braceDepth <= 0) {
229
+ inFunctionBody = false;
230
+ braceDepth = 0;
231
+ }
232
+ }
233
+ }
234
+
235
+ return result.join('\n');
236
+ }
237
+
238
+ // ============================================================
239
+ // Non-streaming version (for existing scan API)
240
+ // ============================================================
241
+ export function scanProjectDirectory(projectPath: string): ScannedFile[] {
242
+ if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) {
243
+ throw new Error(`경로를 찾을 수 없습니다: ${projectPath}`);
244
+ }
245
+
246
+ const seen = new Set<string>();
247
+ const results: ScannedFile[] = [];
248
+ let totalSize = 0;
249
+
250
+ const addFile = (relativePath: string, content: string): boolean => {
251
+ if (seen.has(relativePath)) return false;
252
+ seen.add(relativePath);
253
+ results.push({ file_path: relativePath, content });
254
+ totalSize += content.length;
255
+ return true;
256
+ };
257
+
258
+ // Phase 1: Directory tree (compact overview)
259
+ const tree = buildDirectoryTree(projectPath);
260
+ addFile('__directory_tree.txt', tree);
261
+
262
+ // Phase 2: Walk and collect files by priority
263
+ const allFiles = collectAllFiles(projectPath);
264
+
265
+ allFiles.sort((a, b) => filePriority(a.relativePath) - filePriority(b.relativePath));
266
+
267
+ for (const file of allFiles) {
268
+ const content = readFileSafe(file.absolutePath);
269
+ if (!content) continue;
270
+
271
+ const category = getFileCategory(file.relativePath);
272
+ let finalContent = file.relativePath.endsWith('package.json')
273
+ ? extractPackageJsonSummary(content)
274
+ : content;
275
+
276
+ // Apply source summary for large source files
277
+ if (category === 'source' && finalContent.length > SOURCE_SUMMARY_THRESHOLD) {
278
+ finalContent = extractSourceSummary(finalContent, file.relativePath);
279
+ }
280
+
281
+ addFile(file.relativePath, finalContent);
282
+ }
283
+
284
+ return results;
285
+ }
286
+
287
+ // ============================================================
288
+ // SSE streaming version
289
+ // ============================================================
290
+ export function* scanProjectDirectoryStream(projectPath: string): Generator<{
291
+ type: 'scanning_dir' | 'file_found' | 'done';
292
+ dir?: string;
293
+ file?: { file_path: string; size: number; category: string; folder: string; summarized: boolean };
294
+ results?: ScannedFile[];
295
+ total?: number;
296
+ totalSize?: number;
297
+ treeSize?: number;
298
+ }> {
299
+ if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) {
300
+ throw new Error(`경로를 찾을 수 없습니다: ${projectPath}`);
301
+ }
302
+
303
+ const seen = new Set<string>();
304
+ const results: ScannedFile[] = [];
305
+ let totalSize = 0;
306
+
307
+ // Phase 1: Directory tree
308
+ yield { type: 'scanning_dir', dir: '(디렉토리 구조 분석)' };
309
+ const tree = buildDirectoryTree(projectPath);
310
+ seen.add('__directory_tree.txt');
311
+ results.push({ file_path: '__directory_tree.txt', content: tree });
312
+ totalSize += tree.length;
313
+ yield {
314
+ type: 'file_found',
315
+ file: { file_path: '__directory_tree.txt', size: tree.length, category: 'tree', folder: '(root)', summarized: false },
316
+ };
317
+
318
+ // Phase 2: Walk directories and collect files
319
+ const allFiles = collectAllFilesWithDirs(projectPath);
320
+
321
+ // Sort by priority (source-first)
322
+ allFiles.sort((a, b) => {
323
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
324
+ if (a.type === 'file' && b.type === 'file') {
325
+ return filePriority(a.relativePath) - filePriority(b.relativePath);
326
+ }
327
+ return 0;
328
+ });
329
+
330
+ let lastDir = '';
331
+
332
+ for (const entry of allFiles) {
333
+ if (entry.type === 'dir') {
334
+ if (entry.relativePath !== lastDir) {
335
+ lastDir = entry.relativePath;
336
+ yield { type: 'scanning_dir', dir: entry.relativePath };
337
+ }
338
+ continue;
339
+ }
340
+
341
+ const content = readFileSafe(entry.absolutePath);
342
+ if (!content) continue;
343
+
344
+ const category = getFileCategory(entry.relativePath);
345
+ let finalContent = entry.relativePath.endsWith('package.json')
346
+ ? extractPackageJsonSummary(content)
347
+ : content;
348
+
349
+ let summarized = false;
350
+ if (category === 'source' && finalContent.length > SOURCE_SUMMARY_THRESHOLD) {
351
+ finalContent = extractSourceSummary(finalContent, entry.relativePath);
352
+ summarized = true;
353
+ }
354
+
355
+ if (seen.has(entry.relativePath)) continue;
356
+
357
+ seen.add(entry.relativePath);
358
+ results.push({ file_path: entry.relativePath, content: finalContent });
359
+ totalSize += finalContent.length;
360
+
361
+ const folder = getFolder(entry.relativePath);
362
+
363
+ yield {
364
+ type: 'file_found',
365
+ file: {
366
+ file_path: entry.relativePath,
367
+ size: finalContent.length,
368
+ category,
369
+ folder,
370
+ summarized,
371
+ },
372
+ };
373
+ }
374
+
375
+ yield { type: 'done', results, total: results.length, totalSize, treeSize: tree.length };
376
+ }
377
+
378
+ // ============================================================
379
+ // Helpers
380
+ // ============================================================
381
+
382
+ interface FileEntry {
383
+ type: 'file';
384
+ relativePath: string;
385
+ absolutePath: string;
386
+ }
387
+
388
+ interface DirEntry {
389
+ type: 'dir';
390
+ relativePath: string;
391
+ absolutePath: string;
392
+ }
393
+
394
+ function collectAllFiles(basePath: string): FileEntry[] {
395
+ const files: FileEntry[] = [];
396
+ walkCollect(basePath, basePath, files);
397
+ return files;
398
+ }
399
+
400
+ function collectAllFilesWithDirs(basePath: string): (FileEntry | DirEntry)[] {
401
+ const entries: (FileEntry | DirEntry)[] = [];
402
+ walkCollectWithDirs(basePath, basePath, entries);
403
+ return entries;
404
+ }
405
+
406
+ function walkCollect(dirPath: string, basePath: string, files: FileEntry[]) {
407
+ try {
408
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
409
+
410
+ for (const entry of entries) {
411
+ const fullPath = path.join(dirPath, entry.name);
412
+ if (entry.isFile() && isScannableFile(entry.name)) {
413
+ files.push({
414
+ type: 'file',
415
+ relativePath: path.relative(basePath, fullPath),
416
+ absolutePath: fullPath,
417
+ });
418
+ } else if (entry.isDirectory() && !shouldIgnoreDir(entry.name)) {
419
+ walkCollect(fullPath, basePath, files);
420
+ }
421
+ }
422
+ } catch {
423
+ // ignore permission errors
424
+ }
425
+ }
426
+
427
+ function walkCollectWithDirs(dirPath: string, basePath: string, entries: (FileEntry | DirEntry)[]) {
428
+ try {
429
+ const dirEntries = fs.readdirSync(dirPath, { withFileTypes: true });
430
+ const relDir = path.relative(basePath, dirPath) || '.';
431
+ entries.push({ type: 'dir', relativePath: relDir, absolutePath: dirPath });
432
+
433
+ for (const entry of dirEntries) {
434
+ const fullPath = path.join(dirPath, entry.name);
435
+ if (entry.isFile() && isScannableFile(entry.name)) {
436
+ entries.push({
437
+ type: 'file',
438
+ relativePath: path.relative(basePath, fullPath),
439
+ absolutePath: fullPath,
440
+ });
441
+ } else if (entry.isDirectory() && !shouldIgnoreDir(entry.name)) {
442
+ walkCollectWithDirs(fullPath, basePath, entries);
443
+ }
444
+ }
445
+ } catch {
446
+ // ignore
447
+ }
448
+ }
449
+
450
+ function shouldIgnoreDir(name: string): boolean {
451
+ return name.startsWith('.') || IGNORED_DIRS.has(name);
452
+ }
453
+
454
+ function isScannableFile(name: string): boolean {
455
+ if (PRIORITY_FILES.has(name)) return true;
456
+ const ext = path.extname(name).toLowerCase();
457
+ return DOC_EXTENSIONS.has(ext) || CONFIG_EXTENSIONS.has(ext) || SOURCE_EXTENSIONS.has(ext);
458
+ }
459
+
460
+ function isRouteOrComponent(relativePath: string): boolean {
461
+ const parts = relativePath.split('/');
462
+ return parts.some(p => ROUTE_COMPONENT_SEGMENTS.has(p.toLowerCase()));
463
+ }
464
+
465
+ function filePriority(relativePath: string): number {
466
+ const name = path.basename(relativePath);
467
+ const ext = path.extname(name).toLowerCase();
468
+ const stem = path.basename(name, ext).toLowerCase();
469
+
470
+ // Priority 0: Top-level project files
471
+ if (PRIORITY_FILES.has(name) && !relativePath.includes('/')) return 0;
472
+ // Priority 1: Entry point source files
473
+ if (SOURCE_EXTENSIONS.has(ext) && ENTRY_POINT_PATTERNS.includes(stem)) return 1;
474
+ // Priority 2: Route/component source files
475
+ if (SOURCE_EXTENSIONS.has(ext) && isRouteOrComponent(relativePath)) return 2;
476
+ // Priority 3: Other source files
477
+ if (SOURCE_EXTENSIONS.has(ext)) return 3;
478
+ // Priority 4: Doc files
479
+ if (DOC_EXTENSIONS.has(ext)) return 4;
480
+ // Priority 5: Nested project config files
481
+ if (PRIORITY_FILES.has(name)) return 5;
482
+ // Priority 6: Other config files
483
+ if (CONFIG_EXTENSIONS.has(ext)) return 6;
484
+ return 7;
485
+ }
486
+
487
+ export function getFileCategory(relativePath: string): string {
488
+ const name = path.basename(relativePath);
489
+ const ext = path.extname(name).toLowerCase();
490
+ if (SOURCE_EXTENSIONS.has(ext)) return 'source';
491
+ if (DOC_EXTENSIONS.has(ext)) return 'doc';
492
+ if (PRIORITY_FILES.has(name) || CONFIG_EXTENSIONS.has(ext)) return 'config';
493
+ return 'other';
494
+ }
495
+
496
+ function getFolder(relativePath: string): string {
497
+ const parts = relativePath.split('/');
498
+ if (parts.length <= 1) return '(root)';
499
+ // For monorepo patterns (apps/*, packages/*, libs/*), use 2-depth
500
+ const top = parts[0].toLowerCase();
501
+ if ((top === 'apps' || top === 'packages' || top === 'libs' || top === 'modules') && parts.length >= 3) {
502
+ return `${parts[0]}/${parts[1]}`;
503
+ }
504
+ // Otherwise use top-level directory as project root
505
+ return parts[0];
506
+ }
507
+
508
+ function buildDirectoryTree(basePath: string, prefix = '', depth = 0): string {
509
+ if (depth > 3) return ''; // max depth - keep tree compact for AI context
510
+ const lines: string[] = [];
511
+
512
+ try {
513
+ const entries = fs.readdirSync(basePath, { withFileTypes: true })
514
+ .filter(e => !shouldIgnoreDir(e.name) || e.isFile())
515
+ .filter(e => !(e.isFile() && e.name.startsWith('.')))
516
+ .sort((a, b) => {
517
+ // dirs first, then files
518
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
519
+ return a.name.localeCompare(b.name);
520
+ });
521
+
522
+ for (let i = 0; i < entries.length; i++) {
523
+ const entry = entries[i];
524
+ const isLast = i === entries.length - 1;
525
+ const connector = isLast ? '└── ' : '├── ';
526
+ const nextPrefix = prefix + (isLast ? ' ' : '│ ');
527
+
528
+ if (entry.isDirectory() && !shouldIgnoreDir(entry.name)) {
529
+ lines.push(`${prefix}${connector}${entry.name}/`);
530
+ const subTree = buildDirectoryTree(
531
+ path.join(basePath, entry.name),
532
+ nextPrefix,
533
+ depth + 1,
534
+ );
535
+ if (subTree) lines.push(subTree);
536
+ } else if (entry.isFile()) {
537
+ lines.push(`${prefix}${connector}${entry.name}`);
538
+ }
539
+ }
540
+ } catch {
541
+ // ignore
542
+ }
543
+
544
+ return lines.join('\n');
545
+ }
546
+
547
+ function readFileSafe(filePath: string): string | null {
548
+ try {
549
+ if (!fs.existsSync(filePath)) return null;
550
+ const stat = fs.statSync(filePath);
551
+ if (!stat.isFile() || stat.size > MAX_FILE_SIZE) return null;
552
+ return fs.readFileSync(filePath, 'utf-8');
553
+ } catch {
554
+ return null;
555
+ }
556
+ }
557
+
558
+ function extractPackageJsonSummary(content: string): string {
559
+ try {
560
+ const pkg = JSON.parse(content);
561
+ const summary: Record<string, unknown> = {};
562
+
563
+ if (pkg.name) summary.name = pkg.name;
564
+ if (pkg.description) summary.description = pkg.description;
565
+ if (pkg.scripts) summary.scripts = pkg.scripts;
566
+ if (pkg.dependencies) summary.dependencies = Object.keys(pkg.dependencies);
567
+ if (pkg.devDependencies) summary.devDependencies = Object.keys(pkg.devDependencies);
568
+
569
+ return JSON.stringify(summary, null, 2);
570
+ } catch {
571
+ return content;
572
+ }
573
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * In-memory background task store for structuring jobs.
3
+ * Survives page refreshes (server-side singleton).
4
+ * Tasks are per-project — only one active task per project.
5
+ */
6
+
7
+ export interface TaskEvent {
8
+ event: string;
9
+ data: unknown;
10
+ timestamp: number;
11
+ }
12
+
13
+ export interface BackgroundTask {
14
+ projectId: string;
15
+ status: 'running' | 'done' | 'error';
16
+ events: TaskEvent[]; // full event log (for replay on reconnect)
17
+ startedAt: number;
18
+ finishedAt?: number;
19
+ result?: unknown; // final 'done' event data
20
+ error?: string;
21
+ }
22
+
23
+ const tasks = new Map<string, BackgroundTask>();
24
+
25
+ export function getTask(projectId: string): BackgroundTask | undefined {
26
+ return tasks.get(projectId);
27
+ }
28
+
29
+ export function startTask(projectId: string): BackgroundTask {
30
+ const task: BackgroundTask = {
31
+ projectId,
32
+ status: 'running',
33
+ events: [],
34
+ startedAt: Date.now(),
35
+ };
36
+ tasks.set(projectId, task);
37
+ return task;
38
+ }
39
+
40
+ export function addTaskEvent(projectId: string, event: string, data: unknown) {
41
+ const task = tasks.get(projectId);
42
+ if (!task) return;
43
+ task.events.push({ event, data, timestamp: Date.now() });
44
+
45
+ // Notify all listeners
46
+ const listeners = taskListeners.get(projectId);
47
+ if (listeners) {
48
+ for (const listener of listeners) {
49
+ listener(event, data);
50
+ }
51
+ }
52
+ }
53
+
54
+ export function finishTask(projectId: string, result?: unknown) {
55
+ const task = tasks.get(projectId);
56
+ if (!task) return;
57
+ task.status = 'done';
58
+ task.finishedAt = Date.now();
59
+ task.result = result;
60
+ }
61
+
62
+ export function failTask(projectId: string, error: string) {
63
+ const task = tasks.get(projectId);
64
+ if (!task) return;
65
+ task.status = 'error';
66
+ task.finishedAt = Date.now();
67
+ task.error = error;
68
+ }
69
+
70
+ // Clean up old finished tasks (older than 5 minutes)
71
+ export function cleanupTasks() {
72
+ const now = Date.now();
73
+ for (const [id, task] of tasks) {
74
+ if (task.status !== 'running' && task.finishedAt && now - task.finishedAt > 5 * 60 * 1000) {
75
+ tasks.delete(id);
76
+ taskListeners.delete(id);
77
+ }
78
+ }
79
+ }
80
+
81
+ // --- Listener system for SSE streaming ---
82
+ type TaskListener = (event: string, data: unknown) => void;
83
+ const taskListeners = new Map<string, Set<TaskListener>>();
84
+
85
+ export function addTaskListener(projectId: string, listener: TaskListener): () => void {
86
+ if (!taskListeners.has(projectId)) taskListeners.set(projectId, new Set());
87
+ taskListeners.get(projectId)!.add(listener);
88
+
89
+ // Return unsubscribe function
90
+ return () => {
91
+ const listeners = taskListeners.get(projectId);
92
+ if (listeners) {
93
+ listeners.delete(listener);
94
+ if (listeners.size === 0) taskListeners.delete(projectId);
95
+ }
96
+ };
97
+ }