From 5a3dac1533914b72cfcbed31ad38bebe115919e1 Mon Sep 17 00:00:00 2001 From: Monoquark Date: Sat, 24 Jan 2026 12:30:20 +0100 Subject: [PATCH] feat: Add ideation context settings - Add settings popover to the ideation view - Migrate previous context to toggles (memory, context, features, ideas) - Add app specifications as new context option --- .../ideation/routes/suggestions-generate.ts | 6 +- apps/server/src/services/ideation-service.ts | 194 +++++++++++++----- .../unit/services/ideation-service.test.ts | 151 +++++++++++++- .../components/ideation-settings-popover.tsx | 132 ++++++++++++ .../components/views/ideation-view/index.tsx | 15 +- .../hooks/mutations/use-ideation-mutations.ts | 11 +- apps/ui/src/lib/electron.ts | 4 +- apps/ui/src/lib/http-api-client.ts | 12 +- apps/ui/src/store/ideation-store.ts | 42 +++- libs/types/src/ideation.ts | 32 +++ libs/types/src/index.ts | 2 + libs/utils/src/context-loader.ts | 63 +++--- 12 files changed, 573 insertions(+), 91 deletions(-) create mode 100644 apps/ui/src/components/views/ideation-view/components/ideation-settings-popover.tsx diff --git a/apps/server/src/routes/ideation/routes/suggestions-generate.ts b/apps/server/src/routes/ideation/routes/suggestions-generate.ts index 8add2af5..1aa7487b 100644 --- a/apps/server/src/routes/ideation/routes/suggestions-generate.ts +++ b/apps/server/src/routes/ideation/routes/suggestions-generate.ts @@ -4,6 +4,7 @@ import type { Request, Response } from 'express'; import type { IdeationService } from '../../../services/ideation-service.js'; +import type { IdeationContextSources } from '@automaker/types'; import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; @@ -12,7 +13,7 @@ const logger = createLogger('ideation:suggestions-generate'); export function createSuggestionsGenerateHandler(ideationService: IdeationService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, promptId, category, count } = req.body; + const { projectPath, promptId, category, count, contextSources } = req.body; if (!projectPath) { res.status(400).json({ success: false, error: 'projectPath is required' }); @@ -38,7 +39,8 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic projectPath, promptId, category, - suggestionCount + suggestionCount, + contextSources as IdeationContextSources | undefined ); res.json({ diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index 990a4552..dbfd1cc0 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -23,7 +23,9 @@ import type { SendMessageOptions, PromptCategory, IdeationPrompt, + IdeationContextSources, } from '@automaker/types'; +import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types'; import { getIdeationDir, getIdeasDir, @@ -32,8 +34,10 @@ import { getIdeationSessionsDir, getIdeationSessionPath, getIdeationAnalysisPath, + getAppSpecPath, ensureIdeationDir, } from '@automaker/platform'; +import { extractXmlElements, extractImplementedFeatures } from '../lib/xml-extractor.js'; import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils'; import { ProviderFactory } from '../providers/provider-factory.js'; import type { SettingsService } from './settings-service.js'; @@ -638,8 +642,12 @@ export class IdeationService { projectPath: string, promptId: string, category: IdeaCategory, - count: number = 10 + count: number = 10, + contextSources?: IdeationContextSources ): Promise { + const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20); + // Merge with defaults for backward compatibility + const sources = { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...contextSources }; validateWorkingDirectory(projectPath); // Get the prompt @@ -656,16 +664,26 @@ export class IdeationService { }); try { - // Load context files + // Load context files (respecting toggle settings) const contextResult = await loadContextFiles({ projectPath, fsModule: secureFs as Parameters[0]['fsModule'], + includeContextFiles: sources.useContextFiles, + includeMemory: sources.useMemoryFiles, }); // Build context from multiple sources let contextPrompt = contextResult.formattedPrompt; - // If no context files, try to gather basic project info + // Add app spec context if enabled + if (sources.useAppSpec) { + const appSpecContext = await this.buildAppSpecContext(projectPath); + if (appSpecContext) { + contextPrompt = contextPrompt ? `${contextPrompt}\n\n${appSpecContext}` : appSpecContext; + } + } + + // If no context was found, try to gather basic project info if (!contextPrompt) { const projectInfo = await this.gatherBasicProjectInfo(projectPath); if (projectInfo) { @@ -673,8 +691,11 @@ export class IdeationService { } } - // Gather existing features and ideas to prevent duplicates - const existingWorkContext = await this.gatherExistingWorkContext(projectPath); + // Gather existing features and ideas to prevent duplicates (respecting toggle settings) + const existingWorkContext = await this.gatherExistingWorkContext(projectPath, { + includeFeatures: sources.useExistingFeatures, + includeIdeas: sources.useExistingIdeas, + }); // Get customized prompts from settings const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]'); @@ -684,7 +705,7 @@ export class IdeationService { prompts.ideation.suggestionsSystemPrompt, contextPrompt, category, - count, + suggestionCount, existingWorkContext ); @@ -751,7 +772,11 @@ export class IdeationService { } // Parse the response into structured suggestions - const suggestions = this.parseSuggestionsFromResponse(responseText, category); + const suggestions = this.parseSuggestionsFromResponse( + responseText, + category, + suggestionCount + ); // Emit complete event this.events.emit('ideation:suggestions', { @@ -814,40 +839,49 @@ ${contextSection}${existingWorkSection}`; */ private parseSuggestionsFromResponse( response: string, - category: IdeaCategory + category: IdeaCategory, + count: number = 10 ): AnalysisSuggestion[] { + const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20); try { // Try to extract JSON from the response const jsonMatch = response.match(/\[[\s\S]*\]/); if (!jsonMatch) { logger.warn('No JSON array found in response, falling back to text parsing'); - return this.parseTextResponse(response, category); + return this.parseTextResponse(response, category, suggestionCount); } const parsed = JSON.parse(jsonMatch[0]); if (!Array.isArray(parsed)) { - return this.parseTextResponse(response, category); + return this.parseTextResponse(response, category, suggestionCount); } - return parsed.map((item: any, index: number) => ({ - id: this.generateId('sug'), - category, - title: item.title || `Suggestion ${index + 1}`, - description: item.description || '', - rationale: item.rationale || '', - priority: item.priority || 'medium', - relatedFiles: item.relatedFiles || [], - })); + return parsed + .map((item: any, index: number) => ({ + id: this.generateId('sug'), + category, + title: item.title || `Suggestion ${index + 1}`, + description: item.description || '', + rationale: item.rationale || '', + priority: item.priority || 'medium', + relatedFiles: item.relatedFiles || [], + })) + .slice(0, suggestionCount); } catch (error) { logger.warn('Failed to parse JSON response:', error); - return this.parseTextResponse(response, category); + return this.parseTextResponse(response, category, suggestionCount); } } /** * Fallback: parse text response into suggestions */ - private parseTextResponse(response: string, category: IdeaCategory): AnalysisSuggestion[] { + private parseTextResponse( + response: string, + category: IdeaCategory, + count: number = 10 + ): AnalysisSuggestion[] { + const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20); const suggestions: AnalysisSuggestion[] = []; // Try to find numbered items or headers @@ -907,7 +941,7 @@ ${contextSection}${existingWorkSection}`; }); } - return suggestions.slice(0, 5); // Max 5 suggestions + return suggestions.slice(0, suggestionCount); } // ============================================================================ @@ -1345,6 +1379,68 @@ ${contextSection}${existingWorkSection}`; return descriptions[category] || ''; } + /** + * Build context from app_spec.txt for suggestion generation + * Extracts project name, overview, capabilities, and implemented features + */ + private async buildAppSpecContext(projectPath: string): Promise { + try { + const specPath = getAppSpecPath(projectPath); + const specContent = (await secureFs.readFile(specPath, 'utf-8')) as string; + + const parts: string[] = []; + parts.push('## App Specification'); + + // Extract project name + const projectNames = extractXmlElements(specContent, 'project_name'); + if (projectNames.length > 0 && projectNames[0]) { + parts.push(`**Project:** ${projectNames[0]}`); + } + + // Extract overview + const overviews = extractXmlElements(specContent, 'overview'); + if (overviews.length > 0 && overviews[0]) { + parts.push(`**Overview:** ${overviews[0]}`); + } + + // Extract core capabilities + const capabilities = extractXmlElements(specContent, 'capability'); + if (capabilities.length > 0) { + parts.push('**Core Capabilities:**'); + for (const cap of capabilities) { + parts.push(`- ${cap}`); + } + } + + // Extract implemented features + const implementedFeatures = extractImplementedFeatures(specContent); + if (implementedFeatures.length > 0) { + parts.push('**Implemented Features:**'); + for (const feature of implementedFeatures) { + if (feature.description) { + parts.push(`- ${feature.name}: ${feature.description}`); + } else { + parts.push(`- ${feature.name}`); + } + } + } + + // Only return content if we extracted something meaningful + if (parts.length > 1) { + return parts.join('\n'); + } + return ''; + } catch (error) { + // If file doesn't exist, return empty string silently + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return ''; + } + // For other errors, log and return empty string + logger.warn('Failed to build app spec context:', error); + return ''; + } + } + /** * Gather basic project information for context when no context files exist */ @@ -1440,11 +1536,15 @@ ${contextSection}${existingWorkSection}`; * Gather existing features and ideas to prevent duplicate suggestions * Returns a concise list of titles grouped by status to avoid polluting context */ - private async gatherExistingWorkContext(projectPath: string): Promise { + private async gatherExistingWorkContext( + projectPath: string, + options?: { includeFeatures?: boolean; includeIdeas?: boolean } + ): Promise { + const { includeFeatures = true, includeIdeas = true } = options ?? {}; const parts: string[] = []; // Load existing features from the board - if (this.featureLoader) { + if (includeFeatures && this.featureLoader) { try { const features = await this.featureLoader.getAll(projectPath); if (features.length > 0) { @@ -1492,34 +1592,36 @@ ${contextSection}${existingWorkSection}`; } // Load existing ideas - try { - const ideas = await this.getIdeas(projectPath); - // Filter out archived ideas - const activeIdeas = ideas.filter((idea) => idea.status !== 'archived'); + if (includeIdeas) { + try { + const ideas = await this.getIdeas(projectPath); + // Filter out archived ideas + const activeIdeas = ideas.filter((idea) => idea.status !== 'archived'); - if (activeIdeas.length > 0) { - parts.push('## Existing Ideas (Do NOT regenerate these)'); - parts.push( - 'The following ideas have already been captured. Do NOT suggest similar ideas:\n' - ); + if (activeIdeas.length > 0) { + parts.push('## Existing Ideas (Do NOT regenerate these)'); + parts.push( + 'The following ideas have already been captured. Do NOT suggest similar ideas:\n' + ); - // Group by category for organization - const byCategory: Record = {}; - for (const idea of activeIdeas) { - const cat = idea.category || 'feature'; - if (!byCategory[cat]) { - byCategory[cat] = []; + // Group by category for organization + const byCategory: Record = {}; + for (const idea of activeIdeas) { + const cat = idea.category || 'feature'; + if (!byCategory[cat]) { + byCategory[cat] = []; + } + byCategory[cat].push(idea.title); } - byCategory[cat].push(idea.title); - } - for (const [category, titles] of Object.entries(byCategory)) { - parts.push(`**${category}:** ${titles.join(', ')}`); + for (const [category, titles] of Object.entries(byCategory)) { + parts.push(`**${category}:** ${titles.join(', ')}`); + } + parts.push(''); } - parts.push(''); + } catch (error) { + logger.warn('Failed to load existing ideas:', error); } - } catch (error) { - logger.warn('Failed to load existing ideas:', error); } return parts.join('\n'); diff --git a/apps/server/tests/unit/services/ideation-service.test.ts b/apps/server/tests/unit/services/ideation-service.test.ts index 6b862fa5..1be24cbe 100644 --- a/apps/server/tests/unit/services/ideation-service.test.ts +++ b/apps/server/tests/unit/services/ideation-service.test.ts @@ -15,7 +15,7 @@ import type { } from '@automaker/types'; import { ProviderFactory } from '@/providers/provider-factory.js'; -// Create a shared mock logger instance for assertions using vi.hoisted +// Create shared mock instances for assertions using vi.hoisted const mockLogger = vi.hoisted(() => ({ info: vi.fn(), error: vi.fn(), @@ -23,6 +23,13 @@ const mockLogger = vi.hoisted(() => ({ debug: vi.fn(), })); +const mockCreateChatOptions = vi.hoisted(() => + vi.fn(() => ({ + model: 'claude-sonnet-4-20250514', + systemPrompt: 'test prompt', + })) +); + // Mock dependencies vi.mock('@/lib/secure-fs.js'); vi.mock('@automaker/platform'); @@ -37,10 +44,7 @@ vi.mock('@automaker/utils', async () => { }); vi.mock('@/providers/provider-factory.js'); vi.mock('@/lib/sdk-options.js', () => ({ - createChatOptions: vi.fn(() => ({ - model: 'claude-sonnet-4-20250514', - systemPrompt: 'test prompt', - })), + createChatOptions: mockCreateChatOptions, validateWorkingDirectory: vi.fn(), })); @@ -786,6 +790,143 @@ describe('IdeationService', () => { service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5) ).rejects.toThrow('Prompt non-existent not found'); }); + + it('should include app spec context when useAppSpec is enabled', async () => { + const mockAppSpec = ` + + Test Project + A test application for unit testing + + User authentication + Data visualization + + + + Login System + Basic auth with email/password + + + + `; + + vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt'); + + // First call returns app spec, subsequent calls return empty JSON + vi.mocked(secureFs.readFile) + .mockResolvedValueOnce(mockAppSpec) + .mockResolvedValue(JSON.stringify({})); + + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: JSON.stringify([{ title: 'Test', description: 'Test' }]), + }; + }, + }), + }; + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const prompts = service.getAllPrompts(); + await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, { + useAppSpec: true, + useContextFiles: false, + useMemoryFiles: false, + useExistingFeatures: false, + useExistingIdeas: false, + }); + + // Verify createChatOptions was called with systemPrompt containing app spec info + expect(mockCreateChatOptions).toHaveBeenCalled(); + const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0]; + expect(chatOptionsCall.systemPrompt).toContain('Test Project'); + expect(chatOptionsCall.systemPrompt).toContain('A test application for unit testing'); + expect(chatOptionsCall.systemPrompt).toContain('User authentication'); + expect(chatOptionsCall.systemPrompt).toContain('Login System'); + }); + + it('should exclude app spec context when useAppSpec is disabled', async () => { + const mockAppSpec = ` + + Hidden Project + This should not appear + + `; + + vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt'); + vi.mocked(secureFs.readFile).mockResolvedValue(mockAppSpec); + + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: JSON.stringify([{ title: 'Test', description: 'Test' }]), + }; + }, + }), + }; + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const prompts = service.getAllPrompts(); + await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, { + useAppSpec: false, + useContextFiles: false, + useMemoryFiles: false, + useExistingFeatures: false, + useExistingIdeas: false, + }); + + // Verify createChatOptions was called with systemPrompt NOT containing app spec info + expect(mockCreateChatOptions).toHaveBeenCalled(); + const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0]; + expect(chatOptionsCall.systemPrompt).not.toContain('Hidden Project'); + expect(chatOptionsCall.systemPrompt).not.toContain('This should not appear'); + }); + + it('should handle missing app spec file gracefully', async () => { + vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt'); + + const enoentError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException; + enoentError.code = 'ENOENT'; + + // First call fails with ENOENT for app spec, subsequent calls return empty JSON + vi.mocked(secureFs.readFile) + .mockRejectedValueOnce(enoentError) + .mockResolvedValue(JSON.stringify({})); + + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: JSON.stringify([{ title: 'Test', description: 'Test' }]), + }; + }, + }), + }; + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const prompts = service.getAllPrompts(); + + // Should not throw + await expect( + service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, { + useAppSpec: true, + useContextFiles: false, + useMemoryFiles: false, + useExistingFeatures: false, + useExistingIdeas: false, + }) + ).resolves.toBeDefined(); + + // Should not log warning for ENOENT + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/apps/ui/src/components/views/ideation-view/components/ideation-settings-popover.tsx b/apps/ui/src/components/views/ideation-view/components/ideation-settings-popover.tsx new file mode 100644 index 00000000..a96becdd --- /dev/null +++ b/apps/ui/src/components/views/ideation-view/components/ideation-settings-popover.tsx @@ -0,0 +1,132 @@ +/** + * IdeationSettingsPopover - Configure context sources for idea generation + */ + +import { useMemo } from 'react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Settings2, FileText, Brain, LayoutGrid, Lightbulb, ScrollText } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; +import { useIdeationStore } from '@/store/ideation-store'; +import { DEFAULT_IDEATION_CONTEXT_SOURCES, type IdeationContextSources } from '@automaker/types'; + +interface IdeationSettingsPopoverProps { + projectPath: string; +} + +const IDEATION_CONTEXT_OPTIONS: Array<{ + key: keyof IdeationContextSources; + label: string; + description: string; + icon: typeof FileText; +}> = [ + { + key: 'useAppSpec', + label: 'App Specification', + description: 'Overview, capabilities, features', + icon: ScrollText, + }, + { + key: 'useContextFiles', + label: 'Context Files', + description: '.automaker/context/*.md|.txt', + icon: FileText, + }, + { + key: 'useMemoryFiles', + label: 'Memory Files', + description: '.automaker/memory/*.md', + icon: Brain, + }, + { + key: 'useExistingFeatures', + label: 'Existing Features', + description: 'Board features list', + icon: LayoutGrid, + }, + { + key: 'useExistingIdeas', + label: 'Existing Ideas', + description: 'Ideation ideas list', + icon: Lightbulb, + }, +]; + +export function IdeationSettingsPopover({ projectPath }: IdeationSettingsPopoverProps) { + const { projectOverrides, setContextSource } = useIdeationStore( + useShallow((state) => ({ + projectOverrides: state.contextSourcesByProject[projectPath], + setContextSource: state.setContextSource, + })) + ); + const contextSources = useMemo( + () => ({ ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...projectOverrides }), + [projectOverrides] + ); + + return ( + + + + + +
+
+

Generation Settings

+

+ Configure which context sources are included when generating ideas. +

+
+ +
+ {IDEATION_CONTEXT_OPTIONS.map((option) => { + const Icon = option.icon; + return ( +
+
+ +
+ + + {option.description} + +
+
+ + setContextSource(projectPath, option.key, checked) + } + data-testid={`ideation-context-toggle-${option.key}`} + /> +
+ ); + })} +
+ +

+ Disable sources to generate more focused ideas or reduce context size. +

+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/ideation-view/index.tsx b/apps/ui/src/components/views/ideation-view/index.tsx index 50cbd8d3..1346d925 100644 --- a/apps/ui/src/components/views/ideation-view/index.tsx +++ b/apps/ui/src/components/views/ideation-view/index.tsx @@ -13,6 +13,7 @@ import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import { Button } from '@/components/ui/button'; import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; +import { IdeationSettingsPopover } from './components/ideation-settings-popover'; import type { IdeaCategory } from '@automaker/types'; import type { IdeationMode } from '@/store/ideation-store'; @@ -75,6 +76,7 @@ function IdeationHeader({ discardAllReady, discardAllCount, onDiscardAll, + projectPath, }: { currentMode: IdeationMode; selectedCategory: IdeaCategory | null; @@ -88,6 +90,7 @@ function IdeationHeader({ discardAllReady: boolean; discardAllCount: number; onDiscardAll: () => void; + projectPath: string; }) { const { getCategoryById } = useGuidedPrompts(); const showBackButton = currentMode === 'prompts'; @@ -157,10 +160,13 @@ function IdeationHeader({ Accept All ({acceptAllCount}) )} - +
+ + +
); @@ -282,6 +288,7 @@ export function IdeationView() { discardAllReady={discardAllReady} discardAllCount={discardAllCount} onDiscardAll={handleDiscardAll} + projectPath={currentProject.path} /> {/* Dashboard - main view */} diff --git a/apps/ui/src/hooks/mutations/use-ideation-mutations.ts b/apps/ui/src/hooks/mutations/use-ideation-mutations.ts index 2c81b3ee..10cdfab7 100644 --- a/apps/ui/src/hooks/mutations/use-ideation-mutations.ts +++ b/apps/ui/src/hooks/mutations/use-ideation-mutations.ts @@ -68,7 +68,16 @@ export function useGenerateIdeationSuggestions(projectPath: string) { throw new Error('Ideation API not available'); } - const result = await api.ideation.generateSuggestions(projectPath, promptId, category); + // Get context sources from store + const contextSources = useIdeationStore.getState().getContextSources(projectPath); + + const result = await api.ideation.generateSuggestions( + projectPath, + promptId, + category, + undefined, // count - use default + contextSources + ); if (!result.success) { throw new Error(result.error || 'Failed to generate suggestions'); diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index b2065b2b..812def33 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -27,6 +27,7 @@ import type { CreateIdeaInput, UpdateIdeaInput, ConvertToFeatureOptions, + IdeationContextSources, } from '@automaker/types'; import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types'; import { getJSON, setJSON, removeItem } from './storage'; @@ -114,7 +115,8 @@ export interface IdeationAPI { projectPath: string, promptId: string, category: IdeaCategory, - count?: number + count?: number, + contextSources?: IdeationContextSources ) => Promise<{ success: boolean; suggestions?: AnalysisSuggestion[]; error?: string }>; // Convert to feature diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 1ef03fee..c78a8642 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -32,6 +32,7 @@ import type { NotificationsAPI, EventHistoryAPI, } from './electron'; +import type { IdeationContextSources } from '@automaker/types'; import type { EventHistoryFilter } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; @@ -2739,9 +2740,16 @@ export class HttpApiClient implements ElectronAPI { projectPath: string, promptId: string, category: IdeaCategory, - count?: number + count?: number, + contextSources?: IdeationContextSources ) => - this.post('/api/ideation/suggestions/generate', { projectPath, promptId, category, count }), + this.post('/api/ideation/suggestions/generate', { + projectPath, + promptId, + category, + count, + contextSources, + }), convertToFeature: (projectPath: string, ideaId: string, options?: ConvertToFeatureOptions) => this.post('/api/ideation/convert', { projectPath, ideaId, ...options }), diff --git a/apps/ui/src/store/ideation-store.ts b/apps/ui/src/store/ideation-store.ts index fd292299..dde9bdf6 100644 --- a/apps/ui/src/store/ideation-store.ts +++ b/apps/ui/src/store/ideation-store.ts @@ -11,7 +11,9 @@ import type { IdeationPrompt, AnalysisSuggestion, ProjectAnalysisResult, + IdeationContextSources, } from '@automaker/types'; +import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types'; // ============================================================================ // Generation Job Types @@ -61,6 +63,9 @@ interface IdeationState { currentMode: IdeationMode; selectedCategory: IdeaCategory | null; filterStatus: IdeaStatus | 'all'; + + // Context sources per project + contextSourcesByProject: Record>; } // ============================================================================ @@ -110,6 +115,14 @@ interface IdeationActions { setCategory: (category: IdeaCategory | null) => void; setFilterStatus: (status: IdeaStatus | 'all') => void; + // Context sources + getContextSources: (projectPath: string) => IdeationContextSources; + setContextSource: ( + projectPath: string, + key: keyof IdeationContextSources, + value: boolean + ) => void; + // Reset reset: () => void; resetSuggestions: () => void; @@ -135,6 +148,7 @@ const initialState: IdeationState = { currentMode: 'dashboard', selectedCategory: null, filterStatus: 'all', + contextSourcesByProject: {}, }; // ============================================================================ @@ -300,6 +314,24 @@ export const useIdeationStore = create()( setFilterStatus: (status) => set({ filterStatus: status }), + // Context sources + getContextSources: (projectPath) => { + const state = get(); + const projectOverrides = state.contextSourcesByProject[projectPath] ?? {}; + return { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...projectOverrides }; + }, + + setContextSource: (projectPath, key, value) => + set((state) => ({ + contextSourcesByProject: { + ...state.contextSourcesByProject, + [projectPath]: { + ...state.contextSourcesByProject[projectPath], + [key]: value, + }, + }, + })), + // Reset reset: () => set(initialState), @@ -313,13 +345,14 @@ export const useIdeationStore = create()( }), { name: 'automaker-ideation-store', - version: 4, + version: 5, partialize: (state) => ({ // Only persist these fields ideas: state.ideas, generationJobs: state.generationJobs, analysisResult: state.analysisResult, filterStatus: state.filterStatus, + contextSourcesByProject: state.contextSourcesByProject, }), migrate: (persistedState: unknown, version: number) => { const state = persistedState as Record; @@ -331,6 +364,13 @@ export const useIdeationStore = create()( generationJobs: jobs.filter((job) => job.projectPath !== undefined), }; } + if (version < 5) { + // Initialize contextSourcesByProject if not present + return { + ...state, + contextSourcesByProject: state.contextSourcesByProject ?? {}, + }; + } return state; }, } diff --git a/libs/types/src/ideation.ts b/libs/types/src/ideation.ts index c1c80903..be2c5228 100644 --- a/libs/types/src/ideation.ts +++ b/libs/types/src/ideation.ts @@ -228,3 +228,35 @@ export interface IdeationAnalysisEvent { result?: ProjectAnalysisResult; error?: string; } + +// ============================================================================ +// Context Sources Configuration +// ============================================================================ + +/** + * Configuration for which context sources to include when generating ideas. + * All values default to true for backward compatibility. + */ +export interface IdeationContextSources { + /** Include .automaker/context/*.md|.txt files */ + useContextFiles: boolean; + /** Include .automaker/memory/*.md files */ + useMemoryFiles: boolean; + /** Include existing features from the board */ + useExistingFeatures: boolean; + /** Include existing ideas from ideation */ + useExistingIdeas: boolean; + /** Include app specification (.automaker/app_spec.txt) */ + useAppSpec: boolean; +} + +/** + * Default context sources configuration - all enabled for backward compatibility + */ +export const DEFAULT_IDEATION_CONTEXT_SOURCES: IdeationContextSources = { + useContextFiles: true, + useMemoryFiles: true, + useExistingFeatures: true, + useExistingIdeas: true, + useAppSpec: true, +}; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index a4a7635e..e7ae5ba3 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -325,7 +325,9 @@ export type { IdeationEventType, IdeationStreamEvent, IdeationAnalysisEvent, + IdeationContextSources, } from './ideation.js'; +export { DEFAULT_IDEATION_CONTEXT_SOURCES } from './ideation.js'; // Notification types export type { NotificationType, Notification, NotificationsFile } from './notification.js'; diff --git a/libs/utils/src/context-loader.ts b/libs/utils/src/context-loader.ts index 3a981990..9f68e23b 100644 --- a/libs/utils/src/context-loader.ts +++ b/libs/utils/src/context-loader.ts @@ -97,6 +97,8 @@ export interface LoadContextFilesOptions { projectPath: string; /** Optional custom secure fs module (for dependency injection) */ fsModule?: ContextFsModule; + /** Whether to include context files from .automaker/context/ (default: true) */ + includeContextFiles?: boolean; /** Whether to include memory files from .automaker/memory/ (default: true) */ includeMemory?: boolean; /** Whether to initialize memory folder if it doesn't exist (default: true) */ @@ -210,6 +212,7 @@ export async function loadContextFiles( const { projectPath, fsModule = secureFs, + includeContextFiles = true, includeMemory = true, initializeMemory = true, taskContext, @@ -220,42 +223,44 @@ export async function loadContextFiles( const files: ContextFileInfo[] = []; const memoryFiles: MemoryFileInfo[] = []; - // Load context files - try { - // Check if directory exists - await fsModule.access(contextDir); + // Load context files if enabled + if (includeContextFiles) { + try { + // Check if directory exists + await fsModule.access(contextDir); - // Read directory contents - const allFiles = await fsModule.readdir(contextDir); + // Read directory contents + const allFiles = await fsModule.readdir(contextDir); - // Filter for text-based context files (case-insensitive for cross-platform) - const textFiles = allFiles.filter((f) => { - const lower = f.toLowerCase(); - return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json'; - }); + // Filter for text-based context files (case-insensitive for cross-platform) + const textFiles = allFiles.filter((f) => { + const lower = f.toLowerCase(); + return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json'; + }); - if (textFiles.length > 0) { - // Load metadata for descriptions - const metadata = await loadContextMetadata(contextDir, fsModule); + if (textFiles.length > 0) { + // Load metadata for descriptions + const metadata = await loadContextMetadata(contextDir, fsModule); - // Load each file with its content and metadata - for (const fileName of textFiles) { - const filePath = path.join(contextDir, fileName); - try { - const content = await fsModule.readFile(filePath, 'utf-8'); - files.push({ - name: fileName, - path: filePath, - content: content as string, - description: metadata.files[fileName]?.description, - }); - } catch (error) { - console.warn(`[ContextLoader] Failed to read context file ${fileName}:`, error); + // Load each file with its content and metadata + for (const fileName of textFiles) { + const filePath = path.join(contextDir, fileName); + try { + const content = await fsModule.readFile(filePath, 'utf-8'); + files.push({ + name: fileName, + path: filePath, + content: content as string, + description: metadata.files[fileName]?.description, + }); + } catch (error) { + console.warn(`[ContextLoader] Failed to read context file ${fileName}:`, error); + } } } + } catch { + // Context directory doesn't exist or is inaccessible - that's fine } - } catch { - // Context directory doesn't exist or is inaccessible - that's fine } // Load memory files if enabled (with smart selection)