mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Merge remote-tracking branch 'origin/v0.14.0rc' into feature/bug-complete-fix-for-the-plan-mode-system-inside-sbyt
This commit is contained in:
@@ -4,15 +4,21 @@
|
||||
|
||||
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';
|
||||
|
||||
const logger = createLogger('ideation:suggestions-generate');
|
||||
|
||||
/**
|
||||
* Creates an Express route handler for generating AI-powered ideation suggestions.
|
||||
* Accepts a prompt, category, and optional context sources configuration,
|
||||
* then returns structured suggestions that can be added to the board.
|
||||
*/
|
||||
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 +44,8 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic
|
||||
projectPath,
|
||||
promptId,
|
||||
category,
|
||||
suggestionCount
|
||||
suggestionCount,
|
||||
contextSources as IdeationContextSources | undefined
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -39,8 +39,15 @@ interface GitHubRemoteCacheEntry {
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
interface GitHubPRCacheEntry {
|
||||
prs: Map<string, WorktreePRInfo>;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
|
||||
const githubPRCache = new Map<string, GitHubPRCacheEntry>();
|
||||
const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const GITHUB_PR_CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes - avoid hitting GitHub on every poll
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -180,9 +187,21 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
|
||||
* This also allows detecting PRs that were created outside the app.
|
||||
*
|
||||
* Uses cached GitHub remote status to avoid repeated warnings when the
|
||||
* project doesn't have a GitHub remote configured.
|
||||
* project doesn't have a GitHub remote configured. Results are cached
|
||||
* briefly to avoid hammering GitHub on frequent worktree polls.
|
||||
*/
|
||||
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
|
||||
async function fetchGitHubPRs(
|
||||
projectPath: string,
|
||||
forceRefresh = false
|
||||
): Promise<Map<string, WorktreePRInfo>> {
|
||||
const now = Date.now();
|
||||
const cached = githubPRCache.get(projectPath);
|
||||
|
||||
// Return cached result if valid and not forcing refresh
|
||||
if (!forceRefresh && cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) {
|
||||
return cached.prs;
|
||||
}
|
||||
|
||||
const prMap = new Map<string, WorktreePRInfo>();
|
||||
|
||||
try {
|
||||
@@ -225,8 +244,22 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
|
||||
createdAt: pr.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Only update cache on successful fetch
|
||||
githubPRCache.set(projectPath, {
|
||||
prs: prMap,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - PR detection is optional
|
||||
// On fetch failure, return stale cached data if available to avoid
|
||||
// repeated API calls during GitHub API flakiness or temporary outages
|
||||
if (cached) {
|
||||
logger.warn(`Failed to fetch GitHub PRs, returning stale cache: ${getErrorMessage(error)}`);
|
||||
// Extend cache TTL to avoid repeated retries during outages
|
||||
githubPRCache.set(projectPath, { prs: cached.prs, fetchedAt: Date.now() });
|
||||
return cached.prs;
|
||||
}
|
||||
// No cache available, log warning and return empty map
|
||||
logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
@@ -364,7 +397,7 @@ export function createListHandler() {
|
||||
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
|
||||
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
|
||||
const githubPRs = includeDetails
|
||||
? await fetchGitHubPRs(projectPath)
|
||||
? await fetchGitHubPRs(projectPath, forceRefreshGitHub)
|
||||
: new Map<string, WorktreePRInfo>();
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
|
||||
@@ -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,47 @@ ${contextSection}${existingWorkSection}`;
|
||||
*/
|
||||
private parseSuggestionsFromResponse(
|
||||
response: string,
|
||||
category: IdeaCategory
|
||||
category: IdeaCategory,
|
||||
count: number
|
||||
): AnalysisSuggestion[] {
|
||||
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, count);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
|
||||
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, count);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse JSON response:', error);
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: parse text response into suggestions
|
||||
*/
|
||||
private parseTextResponse(response: string, category: IdeaCategory): AnalysisSuggestion[] {
|
||||
private parseTextResponse(
|
||||
response: string,
|
||||
category: IdeaCategory,
|
||||
count: number
|
||||
): AnalysisSuggestion[] {
|
||||
const suggestions: AnalysisSuggestion[] = [];
|
||||
|
||||
// Try to find numbered items or headers
|
||||
@@ -907,7 +939,7 @@ ${contextSection}${existingWorkSection}`;
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions.slice(0, 5); // Max 5 suggestions
|
||||
return suggestions.slice(0, count);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1345,6 +1377,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 +1534,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 +1590,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');
|
||||
|
||||
Reference in New Issue
Block a user