refactor: consolidate Claude SDK interactions into provider architecture

Add executeSimpleQuery() and executeStreamingQuery() methods to ClaudeProvider
to consolidate duplicated SDK interaction patterns across route handlers.

Changes:
- Add executeSimpleQuery() for one-shot queries (title gen, descriptions, etc.)
- Add executeStreamingQuery() for queries with tools and structured output
- Add extractTextFromStream() private method (was duplicated 5+ times)
- Add createPromptGenerator() for multi-part prompts (images, text)
- Add new types: SimpleQueryOptions, SimpleQueryResult, StreamingQueryOptions,
  StreamingQueryResult, PromptContentBlock
- Update BaseProvider with abstract method signatures

Refactored routes to use provider:
- generate-title.ts: uses executeSimpleQuery()
- describe-file.ts: uses executeSimpleQuery()
- describe-image.ts: uses executeSimpleQuery()
- enhance.ts: uses executeSimpleQuery()
- generate-spec.ts: uses executeStreamingQuery()
- generate-features-from-spec.ts: uses executeStreamingQuery()
- generate-suggestions.ts: uses executeStreamingQuery()

Benefits:
- Eliminates 5+ duplicated extractTextFromStream() functions
- All SDK interactions now go through provider architecture
- Consistent error handling and logging
- Support for streaming callbacks (onText, onToolUse)
- Support for structured output (outputFormat)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-22 23:58:02 +01:00
parent 79ef8c8510
commit 460afa82b8
10 changed files with 500 additions and 463 deletions

View File

@@ -9,6 +9,10 @@ import type {
InstallationStatus,
ValidationResult,
ModelDefinition,
SimpleQueryOptions,
SimpleQueryResult,
StreamingQueryOptions,
StreamingQueryResult,
} from './types.js';
/**
@@ -35,6 +39,22 @@ export abstract class BaseProvider {
*/
abstract executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage>;
/**
* Execute a simple one-shot query and return text directly
* Use for quick completions without tools (title gen, descriptions, etc.)
* @param options Simple query options
* @returns Query result with text
*/
abstract executeSimpleQuery(options: SimpleQueryOptions): Promise<SimpleQueryResult>;
/**
* Execute a streaming query with tools and/or structured output
* Use for queries that need tools, progress callbacks, or structured JSON output
* @param options Streaming query options
* @returns Query result with text and optional structured output
*/
abstract executeStreamingQuery(options: StreamingQueryOptions): Promise<StreamingQueryResult>;
/**
* Detect if the provider is installed and configured
* @returns Installation status

View File

@@ -3,15 +3,25 @@
*
* Wraps the @anthropic-ai/claude-agent-sdk for seamless integration
* with the provider architecture.
*
* Provides two query methods:
* - executeQuery(): Streaming async generator for complex multi-turn sessions
* - executeSimpleQuery(): One-shot queries that return text directly (title gen, descriptions, etc.)
*/
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
import { BaseProvider } from './base-provider.js';
import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import type {
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
SimpleQueryOptions,
SimpleQueryResult,
StreamingQueryOptions,
StreamingQueryResult,
} from './types.js';
export class ClaudeProvider extends BaseProvider {
@@ -175,4 +185,225 @@ export class ClaudeProvider extends BaseProvider {
const supportedFeatures = ['tools', 'text', 'vision', 'thinking'];
return supportedFeatures.includes(feature);
}
/**
* Execute a simple one-shot query and return text directly
*
* Use this for:
* - Title generation from description
* - Text enhancement
* - File/image description
* - Any quick, single-turn completion without tools
*
* @example
* ```typescript
* const provider = ProviderFactory.getProviderForModel('haiku');
* const result = await provider.executeSimpleQuery({
* prompt: 'Generate a title for: User authentication feature',
* systemPrompt: 'You are a title generator...',
* });
* if (result.success) console.log(result.text);
* ```
*/
async executeSimpleQuery(options: SimpleQueryOptions): Promise<SimpleQueryResult> {
const { prompt, model, systemPrompt, abortController } = options;
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.haiku);
try {
const sdkOptions: Options = {
model: resolvedModel,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
abortController,
};
// Handle both string prompts and multi-part content blocks
const stream = Array.isArray(prompt)
? query({ prompt: this.createPromptGenerator(prompt), options: sdkOptions })
: query({ prompt, options: sdkOptions });
const { text } = await this.extractTextFromStream(stream);
if (!text || text.trim().length === 0) {
return {
text: '',
success: false,
error: 'Empty response from Claude',
};
}
return {
text: text.trim(),
success: true,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[ClaudeProvider] executeSimpleQuery() error:', errorMessage);
return {
text: '',
success: false,
error: errorMessage,
};
}
}
/**
* Execute a streaming query with tools and/or structured output
*
* Use this for:
* - Spec generation (with JSON schema output)
* - Feature generation from specs
* - Suggestions generation
* - Any query that needs tools or progress callbacks
*
* @example
* ```typescript
* const provider = ProviderFactory.getProviderForModel('opus');
* const result = await provider.executeStreamingQuery({
* prompt: 'Analyze this project...',
* cwd: '/path/to/project',
* allowedTools: ['Read', 'Glob', 'Grep'],
* outputFormat: { type: 'json_schema', schema: mySchema },
* onText: (chunk) => console.log('Progress:', chunk),
* });
* console.log(result.structuredOutput);
* ```
*/
async executeStreamingQuery(options: StreamingQueryOptions): Promise<StreamingQueryResult> {
const {
prompt,
model,
systemPrompt,
cwd,
maxTurns = 100,
allowedTools = ['Read', 'Glob', 'Grep'],
abortController,
outputFormat,
onText,
onToolUse,
} = options;
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.haiku);
try {
const sdkOptions: Options = {
model: resolvedModel,
systemPrompt,
maxTurns,
cwd,
allowedTools: [...allowedTools],
permissionMode: 'acceptEdits',
abortController,
...(outputFormat && { outputFormat }),
};
// Handle both string prompts and multi-part content blocks
const stream = Array.isArray(prompt)
? query({ prompt: this.createPromptGenerator(prompt), options: sdkOptions })
: query({ prompt, options: sdkOptions });
const { text, structuredOutput } = await this.extractTextFromStream(stream, {
onText,
onToolUse,
});
if (!text && !structuredOutput) {
return {
text: '',
success: false,
error: 'Empty response from Claude',
};
}
return {
text: text.trim(),
success: true,
structuredOutput,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[ClaudeProvider] executeStreamingQuery() error:', errorMessage);
return {
text: '',
success: false,
error: errorMessage,
};
}
}
/**
* Create a multi-part prompt generator for content blocks
*/
private createPromptGenerator(content: Array<{ type: string; text?: string; source?: object }>) {
// Return an async generator that yields SDK user messages
// The SDK expects this format for multi-part prompts
return (async function* () {
yield {
type: 'user' as const,
session_id: '',
message: { role: 'user' as const, content },
parent_tool_use_id: null,
};
})();
}
/**
* Extract text and structured output from SDK stream
*
* This consolidates the duplicated extractTextFromStream() function
* that was copied across 5+ route files.
*/
private async extractTextFromStream(
stream: AsyncIterable<unknown>,
handlers?: {
onText?: (text: string) => void;
onToolUse?: (name: string, input: unknown) => void;
}
): Promise<{ text: string; structuredOutput?: unknown }> {
let responseText = '';
let structuredOutput: unknown = undefined;
for await (const msg of stream) {
const message = msg as {
type: string;
subtype?: string;
result?: string;
structured_output?: unknown;
message?: {
content?: Array<{ type: string; text?: string; name?: string; input?: unknown }>;
};
};
if (message.type === 'assistant' && message.message?.content) {
for (const block of message.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
handlers?.onText?.(block.text);
} else if (block.type === 'tool_use' && block.name) {
handlers?.onToolUse?.(block.name, block.input);
}
}
} else if (message.type === 'result' && message.subtype === 'success') {
if (message.result) {
responseText = message.result;
}
if (message.structured_output) {
structuredOutput = message.structured_output;
}
} else if (message.type === 'result' && message.subtype === 'error_max_turns') {
console.warn('[ClaudeProvider] Hit max turns limit');
} else if (
message.type === 'result' &&
message.subtype === 'error_max_structured_output_retries'
) {
throw new Error('Failed to produce valid structured output after retries');
} else if (message.type === 'error') {
const errorMsg = (message as { error?: string }).error || 'Unknown error';
throw new Error(errorMsg);
}
}
return { text: responseText, structuredOutput };
}
}

View File

@@ -102,3 +102,92 @@ export interface ModelDefinition {
tier?: 'basic' | 'standard' | 'premium';
default?: boolean;
}
/**
* Content block for multi-part prompts (images, structured text)
*/
export interface TextContentBlock {
type: 'text';
text: string;
}
export interface ImageContentBlock {
type: 'image';
source: {
type: 'base64';
media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
data: string;
};
}
export type PromptContentBlock = TextContentBlock | ImageContentBlock;
/**
* Options for simple one-shot queries (title generation, descriptions, text enhancement)
*
* These queries:
* - Don't need tools
* - Return text directly (no streaming)
* - Are single-turn (maxTurns=1)
*/
export interface SimpleQueryOptions {
/** The prompt - either a string or array of content blocks */
prompt: string | PromptContentBlock[];
/** Model to use (defaults to haiku) */
model?: string;
/** Optional system prompt */
systemPrompt?: string;
/** Abort controller for cancellation */
abortController?: AbortController;
}
/**
* Result from a simple query
*/
export interface SimpleQueryResult {
/** Extracted text from the response */
text: string;
/** Whether the query completed successfully */
success: boolean;
/** Error message if failed */
error?: string;
}
/**
* Options for streaming queries with tools and/or structured output
*/
export interface StreamingQueryOptions extends SimpleQueryOptions {
/** Working directory for tool execution */
cwd: string;
/** Max turns (defaults to sdk-options presets) */
maxTurns?: number;
/** Tools to allow */
allowedTools?: readonly string[];
/** JSON schema for structured output */
outputFormat?: {
type: 'json_schema';
schema: Record<string, unknown>;
};
/** Callback for text chunks */
onText?: (text: string) => void;
/** Callback for tool usage */
onToolUse?: (name: string, input: unknown) => void;
}
/**
* Result from a streaming query with structured output
*/
export interface StreamingQueryResult extends SimpleQueryResult {
/** Parsed structured output if outputFormat was specified */
structuredOutput?: unknown;
}

View File

@@ -1,13 +1,13 @@
/**
* Generate features from existing app_spec.txt
*
* Uses ClaudeProvider.executeStreamingQuery() for SDK interaction.
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform';
@@ -91,72 +91,37 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
projectPath: projectPath,
});
const options = createFeatureGenerationOptions({
logger.info('Calling provider.executeStreamingQuery() for features...');
const provider = ProviderFactory.getProviderForModel('haiku');
const result = await provider.executeStreamingQuery({
prompt,
model: 'haiku',
cwd: projectPath,
maxTurns: 50,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: text,
projectPath: projectPath,
});
},
});
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
logger.info('Calling Claude Agent SDK query() for features...');
logAuthStatus('Right before SDK query() for features');
let stream;
try {
stream = query({ prompt, options });
logger.debug('query() returned stream successfully');
} catch (queryError) {
logger.error('❌ query() threw an exception:');
logger.error('Error:', queryError);
throw queryError;
if (!result.success) {
logger.error('❌ Feature generation failed:', result.error);
throw new Error(result.error || 'Feature generation failed');
}
let responseText = '';
let messageCount = 0;
logger.debug('Starting to iterate over feature stream...');
try {
for await (const msg of stream) {
messageCount++;
logger.debug(
`Feature stream message #${messageCount}:`,
JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2)
);
if (msg.type === 'assistant' && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
logger.debug(`Feature text block received (${block.text.length} chars)`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: block.text,
projectPath: projectPath,
});
}
}
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
logger.debug('Received success result for features');
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === 'error') {
logger.error('❌ Received error message from feature stream:');
logger.error('Error message:', JSON.stringify(msg, null, 2));
}
}
} catch (streamError) {
logger.error('❌ Error while iterating feature stream:');
logger.error('Stream error:', streamError);
throw streamError;
}
logger.info(`Feature stream complete. Total messages: ${messageCount}`);
logger.info(`Feature response length: ${responseText.length} chars`);
logger.info(`Feature response length: ${result.text.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(responseText);
logger.info(result.text);
logger.info('========== END RESPONSE TEXT ==========');
await parseAndCreateFeatures(projectPath, responseText, events);
await parseAndCreateFeatures(projectPath, result.text, events);
logger.debug('========== generateFeaturesFromSpec() completed ==========');
}

View File

@@ -1,9 +1,9 @@
/**
* Generate app_spec.txt from project overview
*
* Uses ClaudeProvider.executeStreamingQuery() for SDK interaction.
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import path from 'path';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import {
@@ -13,8 +13,7 @@ import {
type SpecOutput,
} from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
@@ -83,105 +82,53 @@ ${getStructuredSpecPromptInstruction()}`;
content: 'Starting spec generation...\n',
});
const options = createSpecGenerationOptions({
logger.info('Calling provider.executeStreamingQuery()...');
const provider = ProviderFactory.getProviderForModel('haiku');
const result = await provider.executeStreamingQuery({
prompt,
model: 'haiku',
cwd: projectPath,
maxTurns: 1000,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
outputFormat: {
type: 'json_schema',
schema: specOutputSchema,
},
onText: (text) => {
logger.info(`Text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: text,
projectPath: projectPath,
});
},
onToolUse: (name, input) => {
logger.info('Tool use:', name);
events.emit('spec-regeneration:event', {
type: 'spec_tool',
tool: name,
input,
});
},
});
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
logger.info('Calling Claude Agent SDK query()...');
// Log auth status right before the SDK call
logAuthStatus('Right before SDK query()');
let stream;
try {
stream = query({ prompt, options });
logger.debug('query() returned stream successfully');
} catch (queryError) {
logger.error('❌ query() threw an exception:');
logger.error('Error:', queryError);
throw queryError;
if (!result.success) {
logger.error('❌ Spec generation failed:', result.error);
throw new Error(result.error || 'Spec generation failed');
}
let responseText = '';
let messageCount = 0;
let structuredOutput: SpecOutput | null = null;
const responseText = result.text;
const structuredOutput = result.structuredOutput as SpecOutput | undefined;
logger.info('Starting to iterate over stream...');
try {
for await (const msg of stream) {
messageCount++;
logger.info(
`Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}`
);
if (msg.type === 'assistant') {
const msgAny = msg as any;
if (msgAny.message?.content) {
for (const block of msgAny.message.content) {
if (block.type === 'text') {
responseText += block.text;
logger.info(
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: block.text,
projectPath: projectPath,
});
} else if (block.type === 'tool_use') {
logger.info('Tool use:', block.name);
events.emit('spec-regeneration:event', {
type: 'spec_tool',
tool: block.name,
input: block.input,
});
}
}
}
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
logger.info('Received success result');
// Check for structured output - this is the reliable way to get spec data
const resultMsg = msg as any;
if (resultMsg.structured_output) {
structuredOutput = resultMsg.structured_output as SpecOutput;
logger.info('✅ Received structured output');
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
} else {
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
}
} else if (msg.type === 'result') {
// Handle error result types
const subtype = (msg as any).subtype;
logger.info(`Result message: subtype=${subtype}`);
if (subtype === 'error_max_turns') {
logger.error('❌ Hit max turns limit!');
} else if (subtype === 'error_max_structured_output_retries') {
logger.error('❌ Failed to produce valid structured output after retries');
throw new Error('Could not produce valid spec output');
}
} else if ((msg as { type: string }).type === 'error') {
logger.error('❌ Received error message from stream:');
logger.error('Error message:', JSON.stringify(msg, null, 2));
} else if (msg.type === 'user') {
// Log user messages (tool results)
logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`);
}
}
} catch (streamError) {
logger.error('❌ Error while iterating stream:');
logger.error('Stream error:', streamError);
throw streamError;
}
logger.info(`Stream iteration complete. Total messages: ${messageCount}`);
logger.info(`Response text length: ${responseText.length} chars`);
if (structuredOutput) {
logger.info('✅ Received structured output');
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
} else {
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
}
// Determine XML content to save
let xmlContent: string;

View File

@@ -1,8 +1,8 @@
/**
* POST /context/describe-file endpoint - Generate description for a text file
*
* Uses Claude Haiku to analyze a text file and generate a concise description
* suitable for context file metadata.
* Uses Claude Haiku via ClaudeProvider to analyze a text file and generate
* a concise description suitable for context file metadata.
*
* SECURITY: This endpoint validates file paths against ALLOWED_ROOT_DIRECTORY
* and reads file content directly (not via Claude's Read tool) to prevent
@@ -10,11 +10,9 @@
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { PathNotAllowedError } from '@automaker/platform';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
@@ -44,31 +42,6 @@ interface DescribeFileErrorResponse {
error: string;
}
/**
* Extract text content from Claude SDK response messages
*/
async function extractTextFromStream(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stream: AsyncIterable<any>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
for (const block of blocks) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
/**
* Create the describe-file request handler
*
@@ -150,60 +123,39 @@ export function createDescribeFileHandler(): (req: Request, res: Response) => Pr
const fileName = path.basename(resolvedPath);
// Build prompt with file content passed as structured data
// The file content is included directly, not via tool invocation
const instructionText = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
const promptContent = [
{
type: 'text' as const,
text: `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
File: ${fileName}${truncated ? ' (truncated)' : ''}`;
const promptContent = [
{ type: 'text' as const, text: instructionText },
File: ${fileName}${truncated ? ' (truncated)' : ''}`,
},
{ type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` },
];
// Use the file's directory as the working directory
const cwd = path.dirname(resolvedPath);
// Use centralized SDK options with proper cwd validation
// No tools needed since we're passing file content directly
const sdkOptions = createCustomOptions({
cwd,
model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1,
allowedTools: [],
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
const provider = ProviderFactory.getProviderForModel('haiku');
const result = await provider.executeSimpleQuery({
prompt: promptContent,
model: 'haiku',
});
const promptGenerator = (async function* () {
yield {
type: 'user' as const,
session_id: '',
message: { role: 'user' as const, content: promptContent },
parent_tool_use_id: null,
};
})();
const stream = query({ prompt: promptGenerator, options: sdkOptions });
// Extract the description from the response
const description = await extractTextFromStream(stream);
if (!description || description.trim().length === 0) {
logger.warn('Received empty response from Claude');
if (!result.success) {
logger.warn('Failed to generate description:', result.error);
const response: DescribeFileErrorResponse = {
success: false,
error: 'Failed to generate description - empty response',
error: result.error || 'Failed to generate description',
};
res.status(500).json(response);
return;
}
logger.info(`Description generated, length: ${description.length} chars`);
logger.info(`Description generated, length: ${result.text.length} chars`);
const response: DescribeFileSuccessResponse = {
success: true,
description: description.trim(),
description: result.text,
};
res.json(response);
} catch (error) {

View File

@@ -1,8 +1,8 @@
/**
* POST /context/describe-image endpoint - Generate description for an image
*
* Uses Claude Haiku to analyze an image and generate a concise description
* suitable for context file metadata.
* Uses Claude Haiku via ClaudeProvider to analyze an image and generate
* a concise description suitable for context file metadata.
*
* IMPORTANT:
* The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks),
@@ -11,10 +11,9 @@
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger, readImageAsBase64 } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { PromptContentBlock } from '../../../providers/types.js';
import * as fs from 'fs';
import * as path from 'path';
@@ -173,53 +172,6 @@ function mapDescribeImageError(rawMessage: string | undefined): {
return baseResponse;
}
/**
* Extract text content from Claude SDK response messages and log high-signal stream events.
*/
async function extractTextFromStream(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stream: AsyncIterable<any>,
requestId: string
): Promise<string> {
let responseText = '';
let messageCount = 0;
logger.info(`[${requestId}] [Stream] Begin reading SDK stream...`);
for await (const msg of stream) {
messageCount++;
const msgType = msg?.type;
const msgSubtype = msg?.subtype;
// Keep this concise but informative. Full error object is logged in catch blocks.
logger.info(
`[${requestId}] [Stream] #${messageCount} type=${String(msgType)} subtype=${String(msgSubtype ?? '')}`
);
if (msgType === 'assistant' && msg.message?.content) {
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
logger.info(`[${requestId}] [Stream] assistant blocks=${blocks.length}`);
for (const block of blocks) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
}
if (msgType === 'result' && msgSubtype === 'success') {
if (typeof msg.result === 'string' && msg.result.length > 0) {
responseText = msg.result;
}
}
}
logger.info(
`[${requestId}] [Stream] End of stream. messages=${messageCount} textLength=${responseText.length}`
);
return responseText;
}
/**
* Create the describe-image request handler
*
@@ -308,13 +260,17 @@ export function createDescribeImageHandler(): (req: Request, res: Response) => P
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
const promptContent = [
{ type: 'text' as const, text: instructionText },
const promptContent: PromptContentBlock[] = [
{ type: 'text', text: instructionText },
{
type: 'image' as const,
type: 'image',
source: {
type: 'base64' as const,
media_type: imageData.mimeType,
type: 'base64',
media_type: imageData.mimeType as
| 'image/jpeg'
| 'image/png'
| 'image/gif'
| 'image/webp',
data: imageData.base64,
},
},
@@ -322,48 +278,26 @@ export function createDescribeImageHandler(): (req: Request, res: Response) => P
logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`);
const cwd = path.dirname(actualPath);
logger.info(`[${requestId}] Using cwd=${cwd}`);
logger.info(`[${requestId}] Calling provider.executeSimpleQuery()...`);
const queryStart = Date.now();
// Use the same centralized option builder used across the server (validates cwd)
const sdkOptions = createCustomOptions({
cwd,
model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1,
allowedTools: [],
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
const provider = ProviderFactory.getProviderForModel('haiku');
const result = await provider.executeSimpleQuery({
prompt: promptContent,
model: 'haiku',
});
logger.info(
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
sdkOptions.allowedTools
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
);
logger.info(`[${requestId}] Query completed in ${Date.now() - queryStart}ms`);
const promptGenerator = (async function* () {
yield {
type: 'user' as const,
session_id: '',
message: { role: 'user' as const, content: promptContent },
parent_tool_use_id: null,
};
})();
const description = result.success ? result.text : '';
logger.info(`[${requestId}] Calling query()...`);
const queryStart = Date.now();
const stream = query({ prompt: promptGenerator, options: sdkOptions });
logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`);
// Extract the description from the response
const extractStart = Date.now();
const description = await extractTextFromStream(stream, requestId);
logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`);
if (!description || description.trim().length === 0) {
logger.warn(`[${requestId}] Received empty response from Claude`);
if (!result.success || !description || description.trim().length === 0) {
logger.warn(
`[${requestId}] Failed to generate description: ${result.error || 'empty response'}`
);
const response: DescribeImageErrorResponse = {
success: false,
error: 'Failed to generate description - empty response',
error: result.error || 'Failed to generate description - empty response',
requestId,
};
res.status(500).json(response);

View File

@@ -1,15 +1,13 @@
/**
* POST /enhance-prompt endpoint - Enhance user input text
*
* Uses Claude AI to enhance text based on the specified enhancement mode.
* Supports modes: improve, technical, simplify, acceptance
* Uses Claude AI via ClaudeProvider to enhance text based on the specified
* enhancement mode. Supports modes: improve, technical, simplify, acceptance
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import {
getSystemPrompt,
buildUserPrompt,
@@ -47,39 +45,6 @@ interface EnhanceErrorResponse {
error: string;
}
/**
* Extract text content from Claude SDK response messages
*
* @param stream - The async iterable from the query function
* @returns The extracted text content
*/
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
/**
* Create the enhance request handler
*
@@ -132,45 +97,30 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
const systemPrompt = getSystemPrompt(validMode);
// Build the user prompt with few-shot examples
// This helps the model understand this is text transformation, not a coding task
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
// Resolve the model - use the passed model, default to sonnet for quality
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
logger.debug(`Using model: ${resolvedModel}`);
// Call Claude SDK with minimal configuration for text transformation
// Key: no tools, just text completion
const stream = query({
const provider = ProviderFactory.getProviderForModel(model || 'sonnet');
const result = await provider.executeSimpleQuery({
prompt: userPrompt,
options: {
model: resolvedModel,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
},
model: model || 'sonnet',
systemPrompt,
});
// Extract the enhanced text from the response
const enhancedText = await extractTextFromStream(stream);
if (!enhancedText || enhancedText.trim().length === 0) {
logger.warn('Received empty response from Claude');
if (!result.success) {
logger.warn('Failed to enhance text:', result.error);
const response: EnhanceErrorResponse = {
success: false,
error: 'Failed to generate enhanced text - empty response',
error: result.error || 'Failed to generate enhanced text',
};
res.status(500).json(response);
return;
}
logger.info(`Enhancement complete, output length: ${enhancedText.length} chars`);
logger.info(`Enhancement complete, output length: ${result.text.length} chars`);
const response: EnhanceSuccessResponse = {
success: true,
enhancedText: enhancedText.trim(),
enhancedText: result.text,
};
res.json(response);
} catch (error) {

View File

@@ -1,13 +1,12 @@
/**
* POST /features/generate-title endpoint - Generate a concise title from description
*
* Uses Claude Haiku to generate a short, descriptive title from feature description.
* Uses Claude Haiku via ClaudeProvider to generate a short, descriptive title.
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
import { ProviderFactory } from '../../../providers/provider-factory.js';
const logger = createLogger('GenerateTitle');
@@ -34,33 +33,6 @@ Rules:
- No quotes, periods, or extra formatting
- Capture the essence of the feature in a scannable way`;
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -89,34 +61,28 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
const stream = query({
const provider = ProviderFactory.getProviderForModel('haiku');
const result = await provider.executeSimpleQuery({
prompt: userPrompt,
options: {
model: CLAUDE_MODEL_MAP.haiku,
systemPrompt: SYSTEM_PROMPT,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
},
model: 'haiku',
systemPrompt: SYSTEM_PROMPT,
});
const title = await extractTextFromStream(stream);
if (!title || title.trim().length === 0) {
logger.warn('Received empty response from Claude');
if (!result.success) {
logger.warn('Failed to generate title:', result.error);
const response: GenerateTitleErrorResponse = {
success: false,
error: 'Failed to generate title - empty response',
error: result.error || 'Failed to generate title',
};
res.status(500).json(response);
return;
}
logger.info(`Generated title: ${title.trim()}`);
logger.info(`Generated title: ${result.text}`);
const response: GenerateTitleSuccessResponse = {
success: true,
title: title.trim(),
title: result.text,
};
res.json(response);
} catch (error) {

View File

@@ -1,11 +1,12 @@
/**
* Business logic for generating suggestions
*
* Uses ClaudeProvider.executeStreamingQuery() for SDK interaction.
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
const logger = createLogger('Suggestions');
@@ -68,62 +69,44 @@ The response will be automatically formatted as structured JSON.`;
content: `Starting ${suggestionType} analysis...\n`,
});
const options = createSuggestionsOptions({
const provider = ProviderFactory.getProviderForModel('haiku');
const result = await provider.executeStreamingQuery({
prompt,
model: 'haiku',
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
outputFormat: {
type: 'json_schema',
schema: suggestionsSchema,
},
onText: (text) => {
events.emit('suggestions:event', {
type: 'suggestions_progress',
content: text,
});
},
onToolUse: (name, input) => {
events.emit('suggestions:event', {
type: 'suggestions_tool',
tool: name,
input,
});
},
});
const stream = query({ prompt, options });
let responseText = '';
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
events.emit('suggestions:event', {
type: 'suggestions_progress',
content: block.text,
});
} else if (block.type === 'tool_use') {
events.emit('suggestions:event', {
type: 'suggestions_tool',
tool: block.name,
input: block.input,
});
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
// Check for structured output
const resultMsg = msg as any;
if (resultMsg.structured_output) {
structuredOutput = resultMsg.structured_output as {
suggestions: Array<Record<string, unknown>>;
};
logger.debug('Received structured output:', structuredOutput);
}
} else if (msg.type === 'result') {
const resultMsg = msg as any;
if (resultMsg.subtype === 'error_max_structured_output_retries') {
logger.error('Failed to produce valid structured output after retries');
throw new Error('Could not produce valid suggestions output');
} else if (resultMsg.subtype === 'error_max_turns') {
logger.error('Hit max turns limit before completing suggestions generation');
logger.warn(`Response text length: ${responseText.length} chars`);
// Still try to parse what we have
}
}
}
// Use structured output if available, otherwise fall back to parsing text
try {
const structuredOutput = result.structuredOutput as
| {
suggestions: Array<Record<string, unknown>>;
}
| undefined;
if (structuredOutput && structuredOutput.suggestions) {
// Use structured output directly
logger.debug('Received structured output:', structuredOutput);
events.emit('suggestions:event', {
type: 'suggestions_complete',
suggestions: structuredOutput.suggestions.map((s: Record<string, unknown>, i: number) => ({
@@ -134,7 +117,7 @@ The response will be automatically formatted as structured JSON.`;
} else {
// Fallback: try to parse from text (for backwards compatibility)
logger.warn('No structured output received, attempting to parse from text');
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
const jsonMatch = result.text.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
events.emit('suggestions:event', {