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
This commit is contained in:
Monoquark
2026-01-24 12:30:20 +01:00
parent 900bbb5e80
commit 5a3dac1533
12 changed files with 573 additions and 91 deletions

View File

@@ -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<void> => {
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({

View File

@@ -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<AnalysisSuggestion[]> {
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<typeof loadContextFiles>[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<string> {
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<string> {
private async gatherExistingWorkContext(
projectPath: string,
options?: { includeFeatures?: boolean; includeIdeas?: boolean }
): Promise<string> {
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<string, string[]> = {};
for (const idea of activeIdeas) {
const cat = idea.category || 'feature';
if (!byCategory[cat]) {
byCategory[cat] = [];
// Group by category for organization
const byCategory: Record<string, string[]> = {};
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');

View File

@@ -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 = `
<project_specification>
<project_name>Test Project</project_name>
<overview>A test application for unit testing</overview>
<core_capabilities>
<capability>User authentication</capability>
<capability>Data visualization</capability>
</core_capabilities>
<implemented_features>
<feature>
<name>Login System</name>
<description>Basic auth with email/password</description>
</feature>
</implemented_features>
</project_specification>
`;
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 = `
<project_specification>
<project_name>Hidden Project</project_name>
<overview>This should not appear</overview>
</project_specification>
`;
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();
});
});
});
});

View File

@@ -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 (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Generation Settings"
aria-label="Generation settings"
data-testid="ideation-context-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end" sideOffset={8}>
<div className="space-y-3">
<div>
<h4 className="font-medium text-sm mb-1">Generation Settings</h4>
<p className="text-xs text-muted-foreground">
Configure which context sources are included when generating ideas.
</p>
</div>
<div className="space-y-2">
{IDEATION_CONTEXT_OPTIONS.map((option) => {
const Icon = option.icon;
return (
<div
key={option.key}
className="flex items-center justify-between gap-3 p-2 rounded-md bg-secondary/50"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Icon className="w-4 h-4 text-brand-500 shrink-0" />
<div className="min-w-0">
<Label
htmlFor={`ideation-context-toggle-${option.key}`}
className="text-xs font-medium cursor-pointer block"
>
{option.label}
</Label>
<span className="text-[10px] text-muted-foreground truncate block">
{option.description}
</span>
</div>
</div>
<Switch
id={`ideation-context-toggle-${option.key}`}
checked={contextSources[option.key]}
onCheckedChange={(checked) =>
setContextSource(projectPath, option.key, checked)
}
data-testid={`ideation-context-toggle-${option.key}`}
/>
</div>
);
})}
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
Disable sources to generate more focused ideas or reduce context size.
</p>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -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})
</Button>
)}
<Button onClick={onGenerateIdeas} className="gap-2">
<Lightbulb className="w-4 h-4" />
Generate Ideas
</Button>
<div className="flex items-center gap-1">
<Button onClick={onGenerateIdeas} className="gap-2">
<Lightbulb className="w-4 h-4" />
Generate Ideas
</Button>
<IdeationSettingsPopover projectPath={projectPath} />
</div>
</div>
</div>
);
@@ -282,6 +288,7 @@ export function IdeationView() {
discardAllReady={discardAllReady}
discardAllCount={discardAllCount}
onDiscardAll={handleDiscardAll}
projectPath={currentProject.path}
/>
{/* Dashboard - main view */}

View File

@@ -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');

View File

@@ -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

View File

@@ -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 }),

View File

@@ -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<string, Partial<IdeationContextSources>>;
}
// ============================================================================
@@ -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<IdeationState & IdeationActions>()(
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<IdeationState & IdeationActions>()(
}),
{
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<string, unknown>;
@@ -331,6 +364,13 @@ export const useIdeationStore = create<IdeationState & IdeationActions>()(
generationJobs: jobs.filter((job) => job.projectPath !== undefined),
};
}
if (version < 5) {
// Initialize contextSourcesByProject if not present
return {
...state,
contextSourcesByProject: state.contextSourcesByProject ?? {},
};
}
return state;
},
}

View File

@@ -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,
};

View File

@@ -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';

View File

@@ -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)