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:
Shirone
2026-01-25 11:45:37 +01:00
18 changed files with 687 additions and 124 deletions

View File

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

View File

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

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