Merge branch 'main' into feat/feature-priority

This commit is contained in:
Ben
2025-12-16 01:13:27 -06:00
committed by GitHub
42 changed files with 2201 additions and 610 deletions

View File

@@ -3,9 +3,304 @@
*/
import { createLogger } from "../lib/logger.js";
import fs from "fs/promises";
import path from "path";
import { exec } from "child_process";
import { promisify } from "util";
type Logger = ReturnType<typeof createLogger>;
const execAsync = promisify(exec);
const logger = createLogger("Common");
// Max file size for generating synthetic diffs (1MB)
const MAX_SYNTHETIC_DIFF_SIZE = 1024 * 1024;
// Binary file extensions to skip
const BINARY_EXTENSIONS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".zip", ".tar", ".gz", ".rar", ".7z",
".exe", ".dll", ".so", ".dylib",
".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv",
".ttf", ".otf", ".woff", ".woff2", ".eot",
".db", ".sqlite", ".sqlite3",
".pyc", ".pyo", ".class", ".o", ".obj",
]);
// Status map for git status codes
const GIT_STATUS_MAP: Record<string, string> = {
M: "Modified",
A: "Added",
D: "Deleted",
R: "Renamed",
C: "Copied",
U: "Updated",
"?": "Untracked",
};
/**
* File status interface for git status results
*/
export interface FileStatus {
status: string;
path: string;
statusText: string;
}
/**
* Check if a file is likely binary based on extension
*/
function isBinaryFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
return BINARY_EXTENSIONS.has(ext);
}
/**
* Check if a path is a git repository
*/
export async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath });
return true;
} catch {
return false;
}
}
/**
* Parse the output of `git status --porcelain` into FileStatus array
*/
export function parseGitStatus(statusOutput: string): FileStatus[] {
return statusOutput
.split("\n")
.filter(Boolean)
.map((line) => {
const statusChar = line[0];
const filePath = line.slice(3);
return {
status: statusChar,
path: filePath,
statusText: GIT_STATUS_MAP[statusChar] || "Unknown",
};
});
}
/**
* Generate a synthetic unified diff for an untracked (new) file
* This is needed because `git diff HEAD` doesn't include untracked files
*/
export async function generateSyntheticDiffForNewFile(
basePath: string,
relativePath: string
): Promise<string> {
const fullPath = path.join(basePath, relativePath);
try {
// Check if it's a binary file
if (isBinaryFile(relativePath)) {
return `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
index 0000000..0000000
Binary file ${relativePath} added
`;
}
// Get file stats to check size
const stats = await fs.stat(fullPath);
if (stats.size > MAX_SYNTHETIC_DIFF_SIZE) {
const sizeKB = Math.round(stats.size / 1024);
return `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
index 0000000..0000000
--- /dev/null
+++ b/${relativePath}
@@ -0,0 +1 @@
+[File too large to display: ${sizeKB}KB]
`;
}
// Read file content
const content = await fs.readFile(fullPath, "utf-8");
const hasTrailingNewline = content.endsWith("\n");
const lines = content.split("\n");
// Remove trailing empty line if the file ends with newline
if (lines.length > 0 && lines.at(-1) === "") {
lines.pop();
}
// Generate diff format
const lineCount = lines.length;
const addedLines = lines.map(line => `+${line}`).join("\n");
let diff = `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
index 0000000..0000000
--- /dev/null
+++ b/${relativePath}
@@ -0,0 +1,${lineCount} @@
${addedLines}`;
// Add "No newline at end of file" indicator if needed
if (!hasTrailingNewline && content.length > 0) {
diff += "\n\\ No newline at end of file";
}
return diff + "\n";
} catch (error) {
// Log the error for debugging
logger.error(`Failed to generate synthetic diff for ${fullPath}:`, error);
// Return a placeholder diff
return `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
index 0000000..0000000
--- /dev/null
+++ b/${relativePath}
@@ -0,0 +1 @@
+[Unable to read file content]
`;
}
}
/**
* Generate synthetic diffs for all untracked files and combine with existing diff
*/
export async function appendUntrackedFileDiffs(
basePath: string,
existingDiff: string,
files: Array<{ status: string; path: string }>
): Promise<string> {
// Find untracked files (status "?")
const untrackedFiles = files.filter(f => f.status === "?");
if (untrackedFiles.length === 0) {
return existingDiff;
}
// Generate synthetic diffs for each untracked file
const syntheticDiffs = await Promise.all(
untrackedFiles.map(f => generateSyntheticDiffForNewFile(basePath, f.path))
);
// Combine existing diff with synthetic diffs
const combinedDiff = existingDiff + syntheticDiffs.join("");
return combinedDiff;
}
/**
* List all files in a directory recursively (for non-git repositories)
* Excludes hidden files/folders and common build artifacts
*/
export async function listAllFilesInDirectory(
basePath: string,
relativePath: string = ""
): Promise<string[]> {
const files: string[] = [];
const fullPath = path.join(basePath, relativePath);
// Directories to skip
const skipDirs = new Set([
"node_modules", ".git", ".automaker", "dist", "build",
".next", ".nuxt", "__pycache__", ".cache", "coverage"
]);
try {
const entries = await fs.readdir(fullPath, { withFileTypes: true });
for (const entry of entries) {
// Skip hidden files/folders (except we want to allow some)
if (entry.name.startsWith(".") && entry.name !== ".env") {
continue;
}
const entryRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (!skipDirs.has(entry.name)) {
const subFiles = await listAllFilesInDirectory(basePath, entryRelPath);
files.push(...subFiles);
}
} else if (entry.isFile()) {
files.push(entryRelPath);
}
}
} catch (error) {
// Log the error to help diagnose file system issues
logger.error(`Error reading directory ${fullPath}:`, error);
}
return files;
}
/**
* Generate diffs for all files in a non-git directory
* Treats all files as "new" files
*/
export async function generateDiffsForNonGitDirectory(
basePath: string
): Promise<{ diff: string; files: FileStatus[] }> {
const allFiles = await listAllFilesInDirectory(basePath);
const files: FileStatus[] = allFiles.map(filePath => ({
status: "?",
path: filePath,
statusText: "New",
}));
// Generate synthetic diffs for all files
const syntheticDiffs = await Promise.all(
files.map(f => generateSyntheticDiffForNewFile(basePath, f.path))
);
return {
diff: syntheticDiffs.join(""),
files,
};
}
/**
* Get git repository diffs for a given path
* Handles both git repos and non-git directories
*/
export async function getGitRepositoryDiffs(
repoPath: string
): Promise<{ diff: string; files: FileStatus[]; hasChanges: boolean }> {
// Check if it's a git repository
const isRepo = await isGitRepo(repoPath);
if (!isRepo) {
// Not a git repo - list all files and treat them as new
const result = await generateDiffsForNonGitDirectory(repoPath);
return {
diff: result.diff,
files: result.files,
hasChanges: result.files.length > 0,
};
}
// Get git diff and status
const { stdout: diff } = await execAsync("git diff HEAD", {
cwd: repoPath,
maxBuffer: 10 * 1024 * 1024,
});
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: repoPath,
});
const files = parseGitStatus(status);
// Generate synthetic diffs for untracked (new) files
const combinedDiff = await appendUntrackedFileDiffs(repoPath, diff, files);
return {
diff: combinedDiff,
files,
hasChanges: files.length > 0,
};
}
/**
* Get error message from error object
*/

View File

@@ -0,0 +1,22 @@
/**
* Enhance prompt routes - HTTP API for AI-powered text enhancement
*
* Provides endpoints for enhancing user input text using Claude AI
* with different enhancement modes (improve, expand, simplify, etc.)
*/
import { Router } from "express";
import { createEnhanceHandler } from "./routes/enhance.js";
/**
* Create the enhance-prompt router
*
* @returns Express router with enhance-prompt endpoints
*/
export function createEnhancePromptRoutes(): Router {
const router = Router();
router.post("/", createEnhanceHandler());
return router;
}

View File

@@ -0,0 +1,195 @@
/**
* 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
*/
import type { Request, Response } from "express";
import { query } from "@anthropic-ai/claude-agent-sdk";
import { createLogger } from "../../../lib/logger.js";
import {
getSystemPrompt,
buildUserPrompt,
isValidEnhancementMode,
type EnhancementMode,
} from "../../../lib/enhancement-prompts.js";
import { resolveModelString, CLAUDE_MODEL_MAP } from "../../../lib/model-resolver.js";
const logger = createLogger("EnhancePrompt");
/**
* Request body for the enhance endpoint
*/
interface EnhanceRequestBody {
/** The original text to enhance */
originalText: string;
/** The enhancement mode to apply */
enhancementMode: string;
/** Optional model override */
model?: string;
}
/**
* Success response from the enhance endpoint
*/
interface EnhanceSuccessResponse {
success: true;
enhancedText: string;
}
/**
* Error response from the enhance endpoint
*/
interface EnhanceErrorResponse {
success: false;
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
*
* @returns Express request handler for text enhancement
*/
export function createEnhanceHandler(): (
req: Request,
res: Response
) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { originalText, enhancementMode, model } =
req.body as EnhanceRequestBody;
// Validate required fields
if (!originalText || typeof originalText !== "string") {
const response: EnhanceErrorResponse = {
success: false,
error: "originalText is required and must be a string",
};
res.status(400).json(response);
return;
}
if (!enhancementMode || typeof enhancementMode !== "string") {
const response: EnhanceErrorResponse = {
success: false,
error: "enhancementMode is required and must be a string",
};
res.status(400).json(response);
return;
}
// Validate text is not empty
const trimmedText = originalText.trim();
if (trimmedText.length === 0) {
const response: EnhanceErrorResponse = {
success: false,
error: "originalText cannot be empty",
};
res.status(400).json(response);
return;
}
// Validate and normalize enhancement mode
const normalizedMode = enhancementMode.toLowerCase();
const validMode: EnhancementMode = isValidEnhancementMode(normalizedMode)
? normalizedMode
: "improve";
logger.info(
`Enhancing text with mode: ${validMode}, length: ${trimmedText.length} chars`
);
// Get the system prompt for this mode
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({
prompt: userPrompt,
options: {
model: resolvedModel,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: "acceptEdits",
},
});
// 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");
const response: EnhanceErrorResponse = {
success: false,
error: "Failed to generate enhanced text - empty response",
};
res.status(500).json(response);
return;
}
logger.info(
`Enhancement complete, output length: ${enhancedText.length} chars`
);
const response: EnhanceSuccessResponse = {
success: true,
enhancedText: enhancedText.trim(),
};
res.json(response);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
logger.error("Enhancement failed:", errorMessage);
const response: EnhanceErrorResponse = {
success: false,
error: errorMessage,
};
res.status(500).json(response);
}
};
}

View File

@@ -3,11 +3,8 @@
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
import { getGitRepositoryDiffs } from "../../common.js";
export function createDiffsHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -20,43 +17,15 @@ export function createDiffsHandler() {
}
try {
const { stdout: diff } = await execAsync("git diff HEAD", {
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
});
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: projectPath,
});
const files = status
.split("\n")
.filter(Boolean)
.map((line) => {
const statusChar = line[0];
const filePath = line.slice(3);
const statusMap: Record<string, string> = {
M: "Modified",
A: "Added",
D: "Deleted",
R: "Renamed",
C: "Copied",
U: "Updated",
"?": "Untracked",
};
return {
status: statusChar,
path: filePath,
statusText: statusMap[statusChar] || "Unknown",
};
});
const result = await getGitRepositoryDiffs(projectPath);
res.json({
success: true,
diff,
files,
hasChanges: files.length > 0,
diff: result.diff,
files: result.files,
hasChanges: result.hasChanges,
});
} catch {
} catch (innerError) {
logError(innerError, "Git diff failed");
res.json({ success: true, diff: "", files: [], hasChanges: false });
}
} catch (error) {

View File

@@ -6,6 +6,7 @@ import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { getErrorMessage, logError } from "../common.js";
import { generateSyntheticDiffForNewFile } from "../../common.js";
const execAsync = promisify(exec);
@@ -25,16 +26,33 @@ export function createFileDiffHandler() {
}
try {
const { stdout: diff } = await execAsync(
`git diff HEAD -- "${filePath}"`,
{
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
}
// First check if the file is untracked
const { stdout: status } = await execAsync(
`git status --porcelain -- "${filePath}"`,
{ cwd: projectPath }
);
const isUntracked = status.trim().startsWith("??");
let diff: string;
if (isUntracked) {
// Generate synthetic diff for untracked file
diff = await generateSyntheticDiffForNewFile(projectPath, filePath);
} else {
// Use regular git diff for tracked files
const result = await execAsync(
`git diff HEAD -- "${filePath}"`,
{
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
}
);
diff = result.stdout;
}
res.json({ success: true, diff, filePath });
} catch {
} catch (innerError) {
logError(innerError, "Git file diff failed");
res.json({ success: true, diff: "", filePath });
}
} catch (error) {

View File

@@ -3,13 +3,10 @@
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
import { getGitRepositoryDiffs } from "../../common.js";
export function createDiffsHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -37,45 +34,33 @@ export function createDiffsHandler() {
);
try {
// Check if worktree exists
await fs.access(worktreePath);
const { stdout: diff } = await execAsync("git diff HEAD", {
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024,
});
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: worktreePath,
});
const files = status
.split("\n")
.filter(Boolean)
.map((line) => {
const statusChar = line[0];
const filePath = line.slice(3);
const statusMap: Record<string, string> = {
M: "Modified",
A: "Added",
D: "Deleted",
R: "Renamed",
C: "Copied",
U: "Updated",
"?": "Untracked",
};
return {
status: statusChar,
path: filePath,
statusText: statusMap[statusChar] || "Unknown",
};
});
// Get diffs from worktree
const result = await getGitRepositoryDiffs(worktreePath);
res.json({
success: true,
diff,
files,
hasChanges: files.length > 0,
diff: result.diff,
files: result.files,
hasChanges: result.hasChanges,
});
} catch {
res.json({ success: true, diff: "", files: [], hasChanges: false });
} catch (innerError) {
// Worktree doesn't exist - fallback to main project path
logError(innerError, "Worktree access failed, falling back to main project");
try {
const result = await getGitRepositoryDiffs(projectPath);
res.json({
success: true,
diff: result.diff,
files: result.files,
hasChanges: result.hasChanges,
});
} catch (fallbackError) {
logError(fallbackError, "Fallback to main project also failed");
res.json({ success: true, diff: "", files: [], hasChanges: false });
}
}
} catch (error) {
logError(error, "Get worktree diffs failed");

View File

@@ -8,6 +8,7 @@ import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js";
import { generateSyntheticDiffForNewFile } from "../../common.js";
const execAsync = promisify(exec);
@@ -37,16 +38,34 @@ export function createFileDiffHandler() {
try {
await fs.access(worktreePath);
const { stdout: diff } = await execAsync(
`git diff HEAD -- "${filePath}"`,
{
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024,
}
// First check if the file is untracked
const { stdout: status } = await execAsync(
`git status --porcelain -- "${filePath}"`,
{ cwd: worktreePath }
);
const isUntracked = status.trim().startsWith("??");
let diff: string;
if (isUntracked) {
// Generate synthetic diff for untracked file
diff = await generateSyntheticDiffForNewFile(worktreePath, filePath);
} else {
// Use regular git diff for tracked files
const result = await execAsync(
`git diff HEAD -- "${filePath}"`,
{
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024,
}
);
diff = result.stdout;
}
res.json({ success: true, diff, filePath });
} catch {
} catch (innerError) {
logError(innerError, "Worktree file diff failed");
res.json({ success: true, diff: "", filePath });
}
} catch (error) {