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.
- package/README.md +33 -41
- package/next.config.ts +0 -1
- package/package.json +2 -2
- package/{src/app/icon.svg → public/favicon.svg} +2 -2
- package/src/app/api/filesystem/route.ts +49 -0
- package/src/app/api/projects/[id]/cleanup/route.ts +32 -0
- package/src/app/api/projects/[id]/items/[itemId]/refine/route.ts +36 -0
- package/src/app/api/projects/[id]/items/[itemId]/route.ts +23 -1
- package/src/app/api/projects/[id]/items/route.ts +51 -1
- package/src/app/api/projects/[id]/scan/route.ts +73 -0
- package/src/app/api/projects/[id]/scan/stream/route.ts +112 -0
- package/src/app/api/projects/[id]/structure/route.ts +34 -3
- package/src/app/api/projects/[id]/structure/stream/route.ts +157 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/route.ts +39 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +60 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route.ts +26 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.ts +39 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/route.ts +33 -0
- package/src/app/api/projects/[id]/sub-projects/route.ts +31 -0
- package/src/app/api/projects/route.ts +1 -1
- package/src/app/globals.css +465 -5
- package/src/app/layout.tsx +3 -0
- package/src/app/page.tsx +260 -88
- package/src/app/projects/[id]/page.tsx +366 -183
- package/src/cli.ts +10 -10
- package/src/components/DirectoryPicker.tsx +137 -0
- package/src/components/ScanPanel.tsx +743 -0
- package/src/components/brainstorm/Editor.tsx +20 -4
- package/src/components/brainstorm/MemoPin.tsx +91 -5
- package/src/components/dashboard/SubProjectCard.tsx +76 -0
- package/src/components/dashboard/TabBar.tsx +42 -0
- package/src/components/task/ProjectTree.tsx +223 -0
- package/src/components/task/PromptEditor.tsx +107 -0
- package/src/components/task/StatusFlow.tsx +43 -0
- package/src/components/task/TaskChat.tsx +134 -0
- package/src/components/task/TaskDetail.tsx +205 -0
- package/src/components/task/TaskList.tsx +119 -0
- package/src/components/tree/CardView.tsx +206 -0
- package/src/components/tree/RefinePopover.tsx +157 -0
- package/src/components/tree/TreeNode.tsx +147 -38
- package/src/components/tree/TreeView.tsx +270 -26
- package/src/components/ui/ConfirmDialog.tsx +88 -0
- package/src/lib/ai/chat-responder.ts +4 -2
- package/src/lib/ai/cleanup.ts +87 -0
- package/src/lib/ai/client.ts +175 -58
- package/src/lib/ai/prompter.ts +19 -24
- package/src/lib/ai/refiner.ts +128 -0
- package/src/lib/ai/structurer.ts +340 -11
- package/src/lib/db/queries/context.ts +76 -0
- package/src/lib/db/queries/items.ts +133 -12
- package/src/lib/db/queries/projects.ts +12 -8
- package/src/lib/db/queries/sub-projects.ts +122 -0
- package/src/lib/db/queries/task-conversations.ts +27 -0
- package/src/lib/db/queries/task-prompts.ts +32 -0
- package/src/lib/db/queries/tasks.ts +133 -0
- package/src/lib/db/schema.ts +75 -0
- package/src/lib/mcp/server.ts +38 -39
- package/src/lib/mcp/tools.ts +47 -45
- package/src/lib/scanner.ts +573 -0
- package/src/lib/task-store.ts +97 -0
- 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
|
+
}
|