idea-manager 0.2.0 → 0.3.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.
Files changed (60) hide show
  1. package/next.config.ts +0 -1
  2. package/package.json +2 -2
  3. package/{src/app/icon.svg → public/favicon.svg} +2 -2
  4. package/src/app/api/filesystem/route.ts +49 -0
  5. package/src/app/api/projects/[id]/cleanup/route.ts +32 -0
  6. package/src/app/api/projects/[id]/items/[itemId]/refine/route.ts +36 -0
  7. package/src/app/api/projects/[id]/items/[itemId]/route.ts +23 -1
  8. package/src/app/api/projects/[id]/items/route.ts +51 -1
  9. package/src/app/api/projects/[id]/scan/route.ts +73 -0
  10. package/src/app/api/projects/[id]/scan/stream/route.ts +112 -0
  11. package/src/app/api/projects/[id]/structure/route.ts +34 -3
  12. package/src/app/api/projects/[id]/structure/stream/route.ts +157 -0
  13. package/src/app/api/projects/[id]/sub-projects/[subId]/route.ts +39 -0
  14. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +60 -0
  15. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route.ts +26 -0
  16. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.ts +39 -0
  17. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/route.ts +33 -0
  18. package/src/app/api/projects/[id]/sub-projects/route.ts +31 -0
  19. package/src/app/api/projects/route.ts +1 -1
  20. package/src/app/globals.css +465 -5
  21. package/src/app/layout.tsx +3 -0
  22. package/src/app/page.tsx +260 -88
  23. package/src/app/projects/[id]/page.tsx +366 -183
  24. package/src/cli.ts +10 -10
  25. package/src/components/DirectoryPicker.tsx +137 -0
  26. package/src/components/ScanPanel.tsx +743 -0
  27. package/src/components/brainstorm/Editor.tsx +20 -4
  28. package/src/components/brainstorm/MemoPin.tsx +91 -5
  29. package/src/components/dashboard/SubProjectCard.tsx +76 -0
  30. package/src/components/dashboard/TabBar.tsx +42 -0
  31. package/src/components/task/ProjectTree.tsx +223 -0
  32. package/src/components/task/PromptEditor.tsx +107 -0
  33. package/src/components/task/StatusFlow.tsx +43 -0
  34. package/src/components/task/TaskChat.tsx +134 -0
  35. package/src/components/task/TaskDetail.tsx +205 -0
  36. package/src/components/task/TaskList.tsx +119 -0
  37. package/src/components/tree/CardView.tsx +206 -0
  38. package/src/components/tree/RefinePopover.tsx +157 -0
  39. package/src/components/tree/TreeNode.tsx +147 -38
  40. package/src/components/tree/TreeView.tsx +270 -26
  41. package/src/components/ui/ConfirmDialog.tsx +88 -0
  42. package/src/lib/ai/chat-responder.ts +4 -2
  43. package/src/lib/ai/cleanup.ts +87 -0
  44. package/src/lib/ai/client.ts +175 -58
  45. package/src/lib/ai/prompter.ts +19 -24
  46. package/src/lib/ai/refiner.ts +128 -0
  47. package/src/lib/ai/structurer.ts +340 -11
  48. package/src/lib/db/queries/context.ts +76 -0
  49. package/src/lib/db/queries/items.ts +133 -12
  50. package/src/lib/db/queries/projects.ts +12 -8
  51. package/src/lib/db/queries/sub-projects.ts +122 -0
  52. package/src/lib/db/queries/task-conversations.ts +27 -0
  53. package/src/lib/db/queries/task-prompts.ts +32 -0
  54. package/src/lib/db/queries/tasks.ts +133 -0
  55. package/src/lib/db/schema.ts +75 -0
  56. package/src/lib/mcp/server.ts +38 -39
  57. package/src/lib/mcp/tools.ts +47 -45
  58. package/src/lib/scanner.ts +573 -0
  59. package/src/lib/task-store.ts +97 -0
  60. package/src/types/index.ts +65 -0
@@ -1,9 +1,18 @@
1
- import { runStructure, runStructureWithQuestions, type IStructuredItem } from './client';
2
- import { replaceItems } from '../db/queries/items';
1
+ import { runStructure, runStructureWithQuestions, runAnalysis, runClaude, extractJson, type IStructuredItem, type OnTextChunk, type OnRawEvent } from './client';
2
+ import { replaceItems, appendItems, getItemTree } from '../db/queries/items';
3
3
  import { getRecentConversations, addMessage } from '../db/queries/conversations';
4
+ import {
5
+ getProjectContextSummary,
6
+ getProjectContextsBySubProject,
7
+ buildSubProjectSummary,
8
+ } from '../db/queries/context';
4
9
  import { resolveMemos, createMemosFromQuestions } from '../db/queries/memos';
5
10
  import type { IItemTree, IMemo, IConversation } from '@/types';
6
11
 
12
+ const AI_CONTEXT_LIMIT = 150_000; // 150KB - threshold for chunking
13
+ const PHASE3_CONTEXT_LIMIT = 300_000; // 300KB - Phase 3 final structuring (hub doc can be large)
14
+ const AI_CHUNK_LIMIT = 80_000; // 80KB - max context per AI call
15
+
7
16
  export async function structureBrainstorm(
8
17
  projectId: string,
9
18
  brainstormId: string,
@@ -13,7 +22,8 @@ export async function structureBrainstorm(
13
22
  return [];
14
23
  }
15
24
 
16
- const structured = await runStructure(content);
25
+ const projectContext = getProjectContextSummary(projectId) || undefined;
26
+ const structured = await runStructure(content, projectContext);
17
27
 
18
28
  const dbItems = mapToDbFormat(structured);
19
29
 
@@ -25,28 +35,316 @@ export async function structureWithChat(
25
35
  brainstormId: string,
26
36
  content: string,
27
37
  ): Promise<{ items: IItemTree[]; memos: IMemo[]; message: IConversation | null }> {
28
- if (!content.trim()) {
29
- return { items: [], memos: [], message: null };
38
+ // Always use single mode for auto-structuring (triggered by brainstorming edits).
39
+ // Multi-agent analysis is only used via streaming endpoint (structureWithChatDirect).
40
+ const projectContext = getProjectContextSummary(projectId) || undefined;
41
+ const safeContext = projectContext ? truncateContext(projectContext, AI_CONTEXT_LIMIT) : undefined;
42
+ return structureSingle(projectId, brainstormId, content, safeContext);
43
+ }
44
+
45
+ /**
46
+ * Streaming structure with direct SSE callback.
47
+ * Instead of async generator, takes a `send` callback to emit SSE events directly.
48
+ * This avoids timing issues with generator yield + async queue.
49
+ */
50
+ export async function structureWithChatDirect(
51
+ projectId: string,
52
+ brainstormId: string,
53
+ content: string,
54
+ send: (event: string, data: unknown) => void | Promise<void>,
55
+ ): Promise<void> {
56
+ const projectContext = getProjectContextSummary(projectId) || undefined;
57
+ const contextSize = projectContext?.length || 0;
58
+
59
+ await send('status', { message: '컨텍스트 크기 확인 중...' });
60
+
61
+ const onText: OnTextChunk = (text) => {
62
+ send('ai_text', { text });
63
+ };
64
+
65
+ const onRawEvent: OnRawEvent = (event) => {
66
+ send('ai_event', event);
67
+ };
68
+
69
+ if (contextSize <= AI_CONTEXT_LIMIT) {
70
+ await send('status', { message: 'AI 구조화 중...', mode: 'single' });
71
+
72
+ const history = getRecentConversations(projectId, 20);
73
+ const historyForAi = history.map(h => ({ role: h.role, content: h.content }));
74
+ const safeContext = projectContext ? truncateContext(projectContext, AI_CONTEXT_LIMIT) : undefined;
75
+
76
+ const existingItems = getItemTree(projectId);
77
+ const existingContext = existingItems.length > 0
78
+ ? serializeExistingItems(existingItems)
79
+ : undefined;
80
+
81
+ const result = await runStructureWithQuestions(content, historyForAi, safeContext, onText, onRawEvent, existingContext);
82
+
83
+ const dbItems = mapToDbFormat(result.items as IStructuredItem[]);
84
+ const tree = replaceItems(projectId, brainstormId, dbItems);
85
+ resolveMemos(projectId);
86
+
87
+ let aiMessage: IConversation | null = null;
88
+ let memos: IMemo[] = [];
89
+ if (result.questions.length > 0) {
90
+ const messageContent = result.questions
91
+ .map((q, i) => `${i + 1}. ${q.question}`)
92
+ .join('\n');
93
+ aiMessage = addMessage(projectId, 'assistant', messageContent);
94
+ memos = createMemosFromQuestions(projectId, aiMessage.id, result.questions);
95
+ }
96
+
97
+ await send('done', { items: tree, memos, message: aiMessage });
98
+ return;
99
+ }
100
+
101
+ // ============================================================
102
+ // 3-Phase Multi-Agent Analysis
103
+ // ============================================================
104
+ const subProjects = getProjectContextsBySubProject(projectId);
105
+ const brainstormContext = content.trim()
106
+ ? `\n\n사용자의 브레인스토밍 메모:\n${content}`
107
+ : '';
108
+
109
+ // Send phase list to frontend
110
+ await send('phase_list', {
111
+ phases: [
112
+ { name: '전체 아키텍처 분석', status: 'pending' },
113
+ { name: `서브 프로젝트 병렬 분석 (${subProjects.length}개)`, status: 'pending' },
114
+ { name: '최종 구조화', status: 'pending' },
115
+ ],
116
+ });
117
+
118
+ // Send sub-project list
119
+ await send('chunk_list', {
120
+ chunks: subProjects.map((c, i) => ({
121
+ name: c.name,
122
+ index: i + 1,
123
+ fileCount: c.contexts.length,
124
+ })),
125
+ total: subProjects.length,
126
+ });
127
+
128
+ // ----------------------------------------------------------
129
+ // Phase 1: Build hub document from docs/configs
130
+ // ----------------------------------------------------------
131
+ await send('phase_update', { index: 0, status: 'active' });
132
+ await send('status', { message: 'Phase 1: 문서/설정 기반 아키텍처 분석 중...' });
133
+
134
+ // Collect root files + all docs/configs for overview
135
+ const rootSub = subProjects.find(s => s.name === '(root)');
136
+ const rootContext = rootSub ? truncateContext(buildSubProjectSummary(rootSub), AI_CHUNK_LIMIT) : '';
137
+
138
+ // Also collect file listing for all sub-projects
139
+ const projectFileTree = subProjects
140
+ .map(s => `[${s.name}] (${s.contexts.length}개 파일)\n${s.contexts.map(c => ` ${c.file_path}`).join('\n')}`)
141
+ .join('\n\n');
142
+
143
+ const phase1Prompt = `당신은 소프트웨어 프로젝트 아키텍트입니다. 아래 프로젝트의 문서와 설정 파일을 분석하여 "프로젝트 개요 문서"를 작성하세요.
144
+
145
+ 이 문서는 다른 AI 에이전트들이 각 서브 프로젝트를 분석할 때 참조하는 중추 문서가 됩니다.
146
+
147
+ 다음 내용을 포함해주세요:
148
+ 1. 프로젝트 전체 목적과 구조
149
+ 2. 기술 스택 (프레임워크, 언어, 주요 라이브러리)
150
+ 3. 서브 프로젝트 간의 관계와 의존성
151
+ 4. 아키텍처 패턴 (모노레포, 마이크로서비스, 등)
152
+ 5. 주요 컨벤션과 규칙
153
+ 6. 배포/인프라 구조 (파악 가능한 경우)
154
+
155
+ 한국어로 작성하세요. Markdown 형식으로 작성하세요.
156
+ ${brainstormContext}
157
+
158
+ === 프로젝트 파일 트리 ===
159
+ ${projectFileTree}
160
+
161
+ === 루트 문서/설정 파일 ===
162
+ ${rootContext}`;
163
+
164
+ let hubDocument = '';
165
+ try {
166
+ hubDocument = await runAnalysis(phase1Prompt, onText, onRawEvent);
167
+ } catch (err) {
168
+ const errMsg = err instanceof Error ? err.message : String(err);
169
+ hubDocument = `# 프로젝트 개요 (자동 생성 실패)\n오류: ${errMsg.slice(0, 300)}\n\n## 서브 프로젝트 목록\n${subProjects.map(s => `- ${s.name}`).join('\n')}`;
170
+ }
171
+
172
+ await send('phase_update', { index: 0, status: 'done' });
173
+ await send('hub_document', { content: hubDocument });
174
+
175
+ // ----------------------------------------------------------
176
+ // Phase 2: Parallel sub-project analysis
177
+ // ----------------------------------------------------------
178
+ await send('phase_update', { index: 1, status: 'active' });
179
+ await send('status', { message: 'Phase 2: 서브 프로젝트 병렬 분석 중...' });
180
+
181
+ const CONCURRENCY = 2;
182
+ const subAnalyses = new Map<string, string>();
183
+ const nonRootSubs = subProjects.filter(s => s.name !== '(root)');
184
+
185
+ // Process in batches of CONCURRENCY
186
+ for (let batch = 0; batch < nonRootSubs.length; batch += CONCURRENCY) {
187
+ const batchSubs = nonRootSubs.slice(batch, batch + CONCURRENCY);
188
+
189
+ const batchPromises = batchSubs.map(async (sub, batchIdx) => {
190
+ const globalIdx = batch + batchIdx;
191
+ const subIdx = subProjects.indexOf(sub);
192
+
193
+ await send('structuring_sub', {
194
+ subProject: sub.name,
195
+ current: globalIdx + 1,
196
+ total: nonRootSubs.length,
197
+ files: sub.contexts.map(c => c.file_path),
198
+ });
199
+
200
+ const subContext = truncateContext(buildSubProjectSummary(sub), AI_CHUNK_LIMIT);
201
+ const phase2Prompt = `당신은 소프트웨어 분석가입니다. 아래 "프로젝트 개요 문서"를 참조하여 서브 프로젝트 "${sub.name}"의 소스코드를 분석하세요.
202
+
203
+ 분석 결과를 다음 형식으로 작성하세요:
204
+ 1. **역할**: 이 서브 프로젝트가 전체 시스템에서 하는 역할
205
+ 2. **주요 기능**: 구현된 핵심 기능 목록 (각 기능의 구현 상태 포함)
206
+ 3. **기술 스택**: 사용 중인 기술/라이브러리
207
+ 4. **구현 상태**: done/in_progress/pending 판단 근거
208
+ 5. **TODO/개선점**: 코드에서 발견된 TODO, 미구현 부분, 개선 가능 사항
209
+ 6. **다른 서브 프로젝트와의 관계**: 의존하거나 의존받는 프로젝트
210
+
211
+ 한국어로 작성하세요. Markdown 형식으로 작성하세요.
212
+
213
+ === 프로젝트 개요 문서 (중추) ===
214
+ ${truncateContext(hubDocument, 30_000)}
215
+
216
+ === ${sub.name} 소스코드 ===
217
+ ${subContext}`;
218
+
219
+ try {
220
+ // Each sub gets its own text stream tagged with sub name
221
+ const subOnText: OnTextChunk = (text) => {
222
+ send('ai_text', { text, subProject: sub.name });
223
+ };
224
+
225
+ const analysis = await runAnalysis(phase2Prompt, subOnText);
226
+ subAnalyses.set(sub.name, analysis);
227
+
228
+ await send('structuring_sub_done', {
229
+ subProject: sub.name,
230
+ current: globalIdx + 1,
231
+ total: nonRootSubs.length,
232
+ itemCount: 0,
233
+ });
234
+ } catch (err) {
235
+ const errMsg = err instanceof Error ? err.message : String(err);
236
+ console.error(`[structurer] Phase 2 "${sub.name}" failed:`, errMsg);
237
+ subAnalyses.set(sub.name, `# ${sub.name} (분석 실패)\n오류: ${errMsg.slice(0, 300)}`);
238
+
239
+ await send('structuring_sub_done', {
240
+ subProject: sub.name,
241
+ current: globalIdx + 1,
242
+ total: nonRootSubs.length,
243
+ error: errMsg.slice(0, 200),
244
+ });
245
+ }
246
+ });
247
+
248
+ await Promise.all(batchPromises);
30
249
  }
31
250
 
32
- // Load recent conversation history
251
+ // Build complete hub document with all analyses
252
+ const completeDocument = `${hubDocument}\n\n---\n\n# 서브 프로젝트 상세 분석\n\n${
253
+ Array.from(subAnalyses.entries())
254
+ .map(([name, analysis]) => `## ${name}\n\n${analysis}`)
255
+ .join('\n\n---\n\n')
256
+ }`;
257
+
258
+ await send('phase_update', { index: 1, status: 'done' });
259
+ await send('hub_document', { content: completeDocument });
260
+
261
+ // ----------------------------------------------------------
262
+ // Phase 3: Final structuring from complete hub document
263
+ // ----------------------------------------------------------
264
+ await send('phase_update', { index: 2, status: 'active' });
265
+ await send('status', { message: 'Phase 3: 중추 문서 기반 최종 구조화 중...' });
266
+ await send('ai_text_reset', {});
267
+
268
+ const phase3Prompt = `You are a JSON-only structuring machine. You NEVER respond with text, explanations, or conversation.
269
+ You ALWAYS output ONLY a raw JSON array, nothing else.
270
+
271
+ Your job: convert the comprehensive project analysis document below into a structured JSON tree.
272
+
273
+ Schema per item:
274
+ { "title": string, "description": string, "item_type": "feature"|"task"|"bug"|"idea"|"note", "priority": "high"|"medium"|"low", "status": "pending"|"in_progress"|"done", "children": [same schema] }
275
+
276
+ Rules:
277
+ - Output MUST start with [ and end with ]
278
+ - No markdown fences, no explanation, no text before or after the JSON
279
+ - Top-level items should be sub-projects (one per analyzed project)
280
+ - Each top-level item should have children representing features/tasks/bugs found in that sub-project
281
+ - Keep titles concise (under 50 chars)
282
+ - Judge status based on the analysis:
283
+ - "done": fully implemented as described
284
+ - "in_progress": partially implemented or has TODOs
285
+ - "pending": not yet started or only planned
286
+ - Prioritize items that have TODOs or are in_progress as "high" priority
287
+ - Include bugs, improvements, and missing features mentioned in the analysis
288
+ ${brainstormContext}
289
+
290
+ === 프로젝트 분석 문서 ===
291
+ ${truncateContext(completeDocument, PHASE3_CONTEXT_LIMIT)}`;
292
+
293
+ try {
294
+ const resultText = await runClaude(phase3Prompt, onText, onRawEvent);
295
+ const json = extractJson(resultText, 'array');
296
+ const structured = JSON.parse(json) as IStructuredItem[];
297
+
298
+ const dbItems = mapToDbFormat(structured);
299
+ const tree = appendItems(projectId, brainstormId, dbItems);
300
+ resolveMemos(projectId);
301
+
302
+ const summaryMsg = addMessage(
303
+ projectId,
304
+ 'assistant',
305
+ `3단계 분석 완료: ${nonRootSubs.length}개 서브 프로젝트를 병렬 분석 후 구조화했습니다.`,
306
+ );
307
+
308
+ await send('phase_update', { index: 2, status: 'done' });
309
+ await send('done', { items: tree, memos: [], message: summaryMsg });
310
+ } catch (err) {
311
+ const errMsg = err instanceof Error ? err.message : String(err);
312
+ console.error('[structurer] Phase 3 failed:', errMsg);
313
+
314
+ await send('phase_update', { index: 2, status: 'error' });
315
+ await send('error', { error: `Phase 3 구조화 실패: ${errMsg.slice(0, 300)}` });
316
+ }
317
+ }
318
+
319
+ // ============================================================
320
+ // Internal helpers
321
+ // ============================================================
322
+
323
+ async function structureSingle(
324
+ projectId: string,
325
+ brainstormId: string,
326
+ content: string,
327
+ projectContext?: string,
328
+ ): Promise<{ items: IItemTree[]; memos: IMemo[]; message: IConversation | null }> {
33
329
  const history = getRecentConversations(projectId, 20);
34
330
  const historyForAi = history.map(h => ({
35
331
  role: h.role,
36
332
  content: h.content,
37
333
  }));
38
334
 
39
- // AI call with questions
40
- const result = await runStructureWithQuestions(content, historyForAi);
335
+ const existingItems = getItemTree(projectId);
336
+ const existingContext = existingItems.length > 0
337
+ ? serializeExistingItems(existingItems)
338
+ : undefined;
339
+
340
+ const safeContext = projectContext ? truncateContext(projectContext, AI_CONTEXT_LIMIT) : undefined;
341
+ const result = await runStructureWithQuestions(content, historyForAi, safeContext, undefined, undefined, existingContext);
41
342
 
42
- // Replace items in DB
43
343
  const dbItems = mapToDbFormat(result.items as IStructuredItem[]);
44
344
  const tree = replaceItems(projectId, brainstormId, dbItems);
45
345
 
46
- // Resolve old memos
47
346
  resolveMemos(projectId);
48
347
 
49
- // Build AI message from questions
50
348
  let aiMessage: IConversation | null = null;
51
349
  let memos: IMemo[] = [];
52
350
 
@@ -62,6 +360,36 @@ export async function structureWithChat(
62
360
  return { items: tree, memos, message: aiMessage };
63
361
  }
64
362
 
363
+ function serializeExistingItems(items: IItemTree[], depth = 0): string {
364
+ if (items.length === 0) return '';
365
+ const lines: string[] = [];
366
+ for (const item of items) {
367
+ const indent = ' '.repeat(depth);
368
+ lines.push(`${indent}- [${item.item_type}/${item.priority}] ${item.title}: ${item.description || ''}`);
369
+ if (item.children && item.children.length > 0) {
370
+ lines.push(serializeExistingItems(item.children, depth + 1));
371
+ }
372
+ }
373
+ return lines.join('\n');
374
+ }
375
+
376
+ function truncateContext(context: string, limit: number): string {
377
+ if (context.length <= limit) return context;
378
+
379
+ const fileSections = context.split(/(?=--- .+ ---\n)/);
380
+ let result = '';
381
+
382
+ for (const section of fileSections) {
383
+ if (result.length + section.length > limit) {
384
+ result += `\n\n--- (${fileSections.length - result.split('---').length / 2}개 파일 생략됨, 컨텍스트 크기 제한) ---\n`;
385
+ break;
386
+ }
387
+ result += section;
388
+ }
389
+
390
+ return result;
391
+ }
392
+
65
393
  function mapToDbFormat(items: IStructuredItem[]): Parameters<typeof replaceItems>[2] {
66
394
  return items.map((item) => ({
67
395
  parent_id: null,
@@ -69,6 +397,7 @@ function mapToDbFormat(items: IStructuredItem[]): Parameters<typeof replaceItems
69
397
  description: item.description,
70
398
  item_type: item.item_type,
71
399
  priority: item.priority,
400
+ status: item.status,
72
401
  children: item.children ? mapToDbFormat(item.children) : undefined,
73
402
  }));
74
403
  }
@@ -0,0 +1,76 @@
1
+ import { getDb } from '../index';
2
+ import { generateId } from '../../utils/id';
3
+ import type { IProjectContext } from '@/types';
4
+
5
+ export function getProjectContexts(projectId: string): IProjectContext[] {
6
+ const db = getDb();
7
+ return db.prepare(
8
+ 'SELECT * FROM project_context WHERE project_id = ? ORDER BY file_path ASC'
9
+ ).all(projectId) as IProjectContext[];
10
+ }
11
+
12
+ export function getProjectContextSummary(projectId: string): string {
13
+ const contexts = getProjectContexts(projectId);
14
+ if (contexts.length === 0) return '';
15
+
16
+ return contexts
17
+ .map(c => `--- ${c.file_path} ---\n${c.content}`)
18
+ .join('\n\n');
19
+ }
20
+
21
+ export interface SubProjectContext {
22
+ name: string; // sub-project directory name (or "(root)")
23
+ contexts: IProjectContext[];
24
+ totalSize: number;
25
+ }
26
+
27
+ export function getProjectContextsBySubProject(projectId: string): SubProjectContext[] {
28
+ const contexts = getProjectContexts(projectId);
29
+ if (contexts.length === 0) return [];
30
+
31
+ // Simple grouping: first path segment = sub-project
32
+ // e.g. "jabis-template/apps/prototype/src/App.tsx" → "jabis-template"
33
+ // "package.json" or "__directory_tree.txt" → "(root)"
34
+ const groups = new Map<string, IProjectContext[]>();
35
+
36
+ for (const ctx of contexts) {
37
+ const parts = ctx.file_path.split('/');
38
+ const group = (parts.length <= 1 || ctx.file_path.startsWith('__'))
39
+ ? '(root)'
40
+ : parts[0];
41
+
42
+ if (!groups.has(group)) groups.set(group, []);
43
+ groups.get(group)!.push(ctx);
44
+ }
45
+
46
+ return Array.from(groups.entries()).map(([name, ctxs]) => ({
47
+ name,
48
+ contexts: ctxs,
49
+ totalSize: ctxs.reduce((sum, c) => sum + c.content.length, 0),
50
+ }));
51
+ }
52
+
53
+ export function buildSubProjectSummary(sub: SubProjectContext): string {
54
+ return sub.contexts
55
+ .map(c => `--- ${c.file_path} ---\n${c.content}`)
56
+ .join('\n\n');
57
+ }
58
+
59
+ export function replaceProjectContexts(projectId: string, files: { file_path: string; content: string }[]): IProjectContext[] {
60
+ const db = getDb();
61
+ const now = new Date().toISOString();
62
+
63
+ const replace = db.transaction(() => {
64
+ db.prepare('DELETE FROM project_context WHERE project_id = ?').run(projectId);
65
+
66
+ for (const file of files) {
67
+ const id = generateId();
68
+ db.prepare(
69
+ 'INSERT INTO project_context (id, project_id, file_path, content, scanned_at) VALUES (?, ?, ?, ?, ?)'
70
+ ).run(id, projectId, file.file_path, file.content, now);
71
+ }
72
+ });
73
+
74
+ replace();
75
+ return getProjectContexts(projectId);
76
+ }
@@ -9,6 +9,11 @@ export function getItems(projectId: string): IItem[] {
9
9
  ).all(projectId) as IItem[];
10
10
  }
11
11
 
12
+ export function getItem(id: string): IItem | undefined {
13
+ const db = getDb();
14
+ return db.prepare('SELECT * FROM items WHERE id = ?').get(id) as IItem | undefined;
15
+ }
16
+
12
17
  export function getItemTree(projectId: string): IItemTree[] {
13
18
  const items = getItems(projectId);
14
19
  return buildTree(items);
@@ -50,8 +55,8 @@ export function createItem(data: {
50
55
 
51
56
  db.prepare(`
52
57
  INSERT INTO items (id, project_id, brainstorm_id, parent_id, title, description,
53
- item_type, priority, status, is_locked, sort_order, created_at, updated_at)
54
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 1, ?, ?, ?)
58
+ item_type, priority, status, is_locked, is_pinned, sort_order, created_at, updated_at)
59
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 1, 1, ?, ?, ?)
55
60
  `).run(
56
61
  id,
57
62
  data.project_id,
@@ -74,6 +79,7 @@ export function updateItem(id: string, data: {
74
79
  description?: string;
75
80
  status?: ItemStatus;
76
81
  is_locked?: boolean;
82
+ is_pinned?: boolean;
77
83
  priority?: ItemPriority;
78
84
  sort_order?: number;
79
85
  }): IItem | undefined {
@@ -84,7 +90,7 @@ export function updateItem(id: string, data: {
84
90
  const now = new Date().toISOString();
85
91
  db.prepare(`
86
92
  UPDATE items SET
87
- title = ?, description = ?, status = ?, is_locked = ?,
93
+ title = ?, description = ?, status = ?, is_locked = ?, is_pinned = ?,
88
94
  priority = ?, sort_order = ?, updated_at = ?
89
95
  WHERE id = ?
90
96
  `).run(
@@ -92,6 +98,7 @@ export function updateItem(id: string, data: {
92
98
  data.description ?? item.description,
93
99
  data.status ?? item.status,
94
100
  data.is_locked !== undefined ? (data.is_locked ? 1 : 0) : (item.is_locked ? 1 : 0),
101
+ data.is_pinned !== undefined ? (data.is_pinned ? 1 : 0) : (item.is_pinned ? 1 : 0),
95
102
  data.priority ?? item.priority,
96
103
  data.sort_order ?? item.sort_order,
97
104
  now,
@@ -101,37 +108,151 @@ export function updateItem(id: string, data: {
101
108
  return db.prepare('SELECT * FROM items WHERE id = ?').get(id) as IItem;
102
109
  }
103
110
 
111
+ export function deleteItem(id: string): boolean {
112
+ const db = getDb();
113
+ const item = db.prepare('SELECT * FROM items WHERE id = ?').get(id) as IItem | undefined;
114
+ if (!item) return false;
115
+
116
+ // Delete children first (recursive via collecting all descendant IDs)
117
+ const deleteRecursive = db.transaction(() => {
118
+ const collectIds = (parentId: string): string[] => {
119
+ const children = db.prepare('SELECT id FROM items WHERE parent_id = ?').all(parentId) as { id: string }[];
120
+ const ids: string[] = [];
121
+ for (const child of children) {
122
+ ids.push(...collectIds(child.id));
123
+ ids.push(child.id);
124
+ }
125
+ return ids;
126
+ };
127
+
128
+ const idsToDelete = [...collectIds(id), id];
129
+ const placeholders = idsToDelete.map(() => '?').join(',');
130
+ db.prepare(`DELETE FROM prompts WHERE item_id IN (${placeholders})`).run(...idsToDelete);
131
+ db.prepare(`DELETE FROM items WHERE id IN (${placeholders})`).run(...idsToDelete);
132
+ });
133
+
134
+ deleteRecursive();
135
+ return true;
136
+ }
137
+
104
138
  export function deleteItemsByProject(projectId: string): void {
105
139
  const db = getDb();
106
140
  db.prepare('DELETE FROM items WHERE project_id = ?').run(projectId);
107
141
  }
108
142
 
109
- export function replaceItems(projectId: string, brainstormId: string, newItems: {
143
+ export function bulkUpdateStatus(projectId: string, status: ItemStatus): void {
144
+ const db = getDb();
145
+ const now = new Date().toISOString();
146
+ db.prepare('UPDATE items SET status = ?, updated_at = ? WHERE project_id = ?').run(status, now, projectId);
147
+ }
148
+
149
+ type NewItemInput = {
110
150
  parent_id: string | null;
111
151
  title: string;
112
152
  description: string;
113
153
  item_type: ItemType;
114
154
  priority: ItemPriority;
115
- children?: typeof newItems;
116
- }[]): IItemTree[] {
155
+ status?: ItemStatus;
156
+ children?: NewItemInput[];
157
+ };
158
+
159
+ /**
160
+ * Append new items to existing ones (additive).
161
+ * Existing items are preserved — only new items are inserted.
162
+ */
163
+ export function appendItems(projectId: string, brainstormId: string, newItems: NewItemInput[]): IItemTree[] {
164
+ const db = getDb();
165
+
166
+ const insertItems = db.transaction(() => {
167
+ const maxOrder = db.prepare(
168
+ 'SELECT MAX(sort_order) as max_order FROM items WHERE project_id = ?'
169
+ ).get(projectId) as { max_order: number | null };
170
+ let sortOrder = (maxOrder?.max_order ?? -1) + 1;
171
+
172
+ const insertRecursive = (items: NewItemInput[], parentId: string | null) => {
173
+ for (const item of items) {
174
+ const id = generateId();
175
+ const now = new Date().toISOString();
176
+ db.prepare(`
177
+ INSERT INTO items (id, project_id, brainstorm_id, parent_id, title, description,
178
+ item_type, priority, status, is_locked, is_pinned, sort_order, created_at, updated_at)
179
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
180
+ `).run(id, projectId, brainstormId, parentId, item.title, item.description,
181
+ item.item_type, item.priority, item.status || 'pending', sortOrder++, now, now);
182
+
183
+ if (item.children?.length) {
184
+ insertRecursive(item.children, id);
185
+ }
186
+ }
187
+ };
188
+
189
+ insertRecursive(newItems, null);
190
+ });
191
+
192
+ insertItems();
193
+ return getItemTree(projectId);
194
+ }
195
+
196
+ /**
197
+ * Add children under a specific parent item.
198
+ */
199
+ export function addChildItems(projectId: string, parentId: string, newChildren: NewItemInput[]): IItemTree[] {
117
200
  const db = getDb();
118
201
 
119
202
  const insertItems = db.transaction(() => {
120
- // Delete existing items for this project
203
+ const maxOrder = db.prepare(
204
+ 'SELECT MAX(sort_order) as max_order FROM items WHERE project_id = ?'
205
+ ).get(projectId) as { max_order: number | null };
206
+ let sortOrder = (maxOrder?.max_order ?? -1) + 1;
207
+
208
+ const insertRecursive = (items: NewItemInput[], pid: string | null) => {
209
+ for (const item of items) {
210
+ const id = generateId();
211
+ const now = new Date().toISOString();
212
+ db.prepare(`
213
+ INSERT INTO items (id, project_id, brainstorm_id, parent_id, title, description,
214
+ item_type, priority, status, is_locked, is_pinned, sort_order, created_at, updated_at)
215
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
216
+ `).run(id, projectId, null, pid, item.title, item.description,
217
+ item.item_type, item.priority, item.status || 'pending', sortOrder++, now, now);
218
+
219
+ if (item.children?.length) {
220
+ insertRecursive(item.children, id);
221
+ }
222
+ }
223
+ };
224
+
225
+ insertRecursive(newChildren, parentId);
226
+ });
227
+
228
+ insertItems();
229
+ return getItemTree(projectId);
230
+ }
231
+
232
+ /**
233
+ * Replace ALL items for the project with new ones.
234
+ * Deletes all existing items first, then inserts the new tree.
235
+ */
236
+ export function replaceItems(projectId: string, brainstormId: string, newItems: NewItemInput[]): IItemTree[] {
237
+ const db = getDb();
238
+
239
+ const insertItems = db.transaction(() => {
240
+ // Delete ALL existing items and their prompts
241
+ db.prepare('DELETE FROM prompts WHERE project_id = ?').run(projectId);
121
242
  db.prepare('DELETE FROM items WHERE project_id = ?').run(projectId);
122
243
 
123
- // Insert new items recursively
124
244
  let sortOrder = 0;
125
- const insertRecursive = (items: typeof newItems, parentId: string | null) => {
245
+
246
+ const insertRecursive = (items: NewItemInput[], parentId: string | null) => {
126
247
  for (const item of items) {
127
248
  const id = generateId();
128
249
  const now = new Date().toISOString();
129
250
  db.prepare(`
130
251
  INSERT INTO items (id, project_id, brainstorm_id, parent_id, title, description,
131
- item_type, priority, status, is_locked, sort_order, created_at, updated_at)
132
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 1, ?, ?, ?)
252
+ item_type, priority, status, is_locked, is_pinned, sort_order, created_at, updated_at)
253
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
133
254
  `).run(id, projectId, brainstormId, parentId, item.title, item.description,
134
- item.item_type, item.priority, sortOrder++, now, now);
255
+ item.item_type, item.priority, item.status || 'pending', sortOrder++, now, now);
135
256
 
136
257
  if (item.children?.length) {
137
258
  insertRecursive(item.children, id);