feat: Enhance JSON streaming parsing and UI updates

- Added a function to parse JSON streaming content in core-memory.js, extracting readable text from messages.
- Updated memory detail view to utilize the new parsing function for content and summary.
- Introduced an enableReview option in rules-manager.js, allowing users to toggle review functionality in rule creation.
- Simplified skill creation modal in skills-manager.js by removing generation type selection UI.
- Improved CLI executor to handle tool calls for file writing, ensuring proper output parsing.
- Adjusted CLI command tests to set timeout to 0 for immediate execution.
- Updated file watcher to implement a true debounce mechanism and added a pending queue status for UI updates.
- Enhanced watcher manager to handle queue changes and provide JSON output for better integration with TypeScript backend.
- Established TypeScript naming conventions documentation to standardize code style across the project.
This commit is contained in:
catlog22
2026-01-07 21:51:26 +08:00
parent e9fb7be85f
commit 05514631f2
19 changed files with 1346 additions and 173 deletions

View File

@@ -0,0 +1,88 @@
# TypeScript Naming Conventions
This rule enforces consistent naming conventions for TypeScript code to improve readability and maintain codebase consistency.
## Guidelines
1. **Variables and Functions** - Use camelCase for all variable names, function names, and function parameters
2. **Classes and Interfaces** - Use PascalCase for class names, interface names, type aliases, and enum names
3. **Constants** - Use UPPER_SNAKE_CASE for module-level constants and readonly static class members
4. **Private Members** - Use camelCase with no special prefix for private class members (rely on TypeScript's `private` keyword)
5. **File Names** - Use kebab-case for file names (e.g., `user-service.ts`, `auth-controller.ts`)
## Examples
### ✅ Correct
```typescript
// Variables and functions
const userName = 'John';
let itemCount = 0;
function calculateTotal(orderItems: Item[]): number {
return orderItems.reduce((sum, item) => sum + item.price, 0);
}
// Classes and interfaces
class UserService {
private userRepository: UserRepository;
constructor(userRepository: UserRepository) {
this.userRepository = userRepository;
}
}
interface ApiResponse {
statusCode: number;
data: unknown;
}
type UserId = string;
enum OrderStatus {
Pending,
Confirmed,
Shipped
}
// Constants
const MAX_RETRY_ATTEMPTS = 3;
const API_BASE_URL = 'https://api.example.com';
class Configuration {
static readonly DEFAULT_TIMEOUT = 5000;
}
```
### ❌ Incorrect
```typescript
// Wrong: PascalCase for variables/functions
const UserName = 'John';
function CalculateTotal(order_items: Item[]): number { }
// Wrong: camelCase for classes/interfaces
class userService { }
interface apiResponse { }
type userId = string;
// Wrong: camelCase for constants
const maxRetryAttempts = 3;
const apiBaseUrl = 'https://api.example.com';
// Wrong: snake_case usage
const user_name = 'John';
function calculate_total(items: Item[]): number { }
class user_service { }
// Wrong: Hungarian notation or underscore prefix for private
class Service {
private _userData: User; // Don't use underscore prefix
private m_count: number; // Don't use Hungarian notation
}
```
## Exceptions
- Third-party library types and names should maintain their original casing for compatibility
- Database column names or API field names may use different conventions when mapped from external systems (use transformation layers to convert)
- Test files may use descriptive names like `UserService.test.ts` to match the tested class

View File

@@ -453,7 +453,8 @@ ${memory.content}
category: 'internal' category: 'internal'
}); });
const summary = result.stdout?.trim() || 'Failed to generate summary'; // Use parsedOutput (extracted text from stream JSON) instead of raw stdout
const summary = result.parsedOutput?.trim() || result.stdout?.trim() || 'Failed to generate summary';
// Update memory with summary // Update memory with summary
const stmt = this.db.prepare(` const stmt = this.db.prepare(`

View File

@@ -613,7 +613,7 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
id: syncId id: syncId
}); });
if (!result.success || !result.execution?.output) { if (!result.success) {
return { return {
error: 'CLI execution failed', error: 'CLI execution failed',
details: result.execution?.error || 'No output received', details: result.execution?.error || 'No output received',
@@ -621,10 +621,13 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
}; };
} }
// Extract CLI output // Extract CLI output - prefer parsedOutput (extracted text from stream JSON)
const cliOutput = typeof result.execution.output === 'string' let cliOutput = result.parsedOutput || '';
if (!cliOutput && result.execution?.output) {
cliOutput = typeof result.execution.output === 'string'
? result.execution.output ? result.execution.output
: result.execution.output.stdout || ''; : result.execution.output.stdout || '';
}
if (!cliOutput || cliOutput.trim().length === 0) { if (!cliOutput || cliOutput.trim().length === 0) {
return { error: 'CLI returned empty output', status: 500 }; return { error: 'CLI returned empty output', status: 500 };

View File

@@ -39,11 +39,32 @@ interface WatcherConfig {
debounce_ms: number; debounce_ms: number;
} }
interface PendingQueueStatus {
file_count: number;
files: string[];
countdown_seconds: number;
last_event_time: number | null;
}
interface IndexResultDetail {
files_indexed: number;
files_removed: number;
symbols_added: number;
symbols_removed: number;
files_success: string[];
files_failed: string[];
errors: string[];
timestamp: number;
}
interface WatcherStats { interface WatcherStats {
running: boolean; running: boolean;
root_path: string; root_path: string;
events_processed: number; events_processed: number;
start_time: Date | null; start_time: Date | null;
pending_queue: PendingQueueStatus | null;
last_index_result: IndexResultDetail | null;
index_history: IndexResultDetail[];
} }
interface ActiveWatcher { interface ActiveWatcher {
@@ -58,13 +79,12 @@ const WATCHER_CONFIG_FILE = path.join(WATCHER_CONFIG_DIR, 'watchers.json');
// Active watchers Map: normalized_path -> { process, stats } // Active watchers Map: normalized_path -> { process, stats }
const activeWatchers = new Map<string, ActiveWatcher>(); const activeWatchers = new Map<string, ActiveWatcher>();
/**
* Normalize path for consistent key usage
* - Convert to absolute path
// Flag to ensure watchers are initialized only once // Flag to ensure watchers are initialized only once
let watchersInitialized = false; let watchersInitialized = false;
/**
* Normalize path for consistent key usage
* - Convert to absolute path
* - Convert to lowercase on Windows * - Convert to lowercase on Windows
* - Use forward slashes * - Use forward slashes
*/ */
@@ -183,7 +203,10 @@ async function startWatcherProcess(
running: true, running: true,
root_path: targetPath, root_path: targetPath,
events_processed: 0, events_processed: 0,
start_time: new Date() start_time: new Date(),
pending_queue: null,
last_index_result: null,
index_history: []
}; };
// Register in activeWatchers Map // Register in activeWatchers Map
@@ -201,17 +224,67 @@ async function startWatcherProcess(
}); });
} }
// Handle process output for event counting // Handle process output for JSON parsing and event counting
if (childProcess.stdout) { if (childProcess.stdout) {
childProcess.stdout.on('data', (data: Buffer) => { childProcess.stdout.on('data', (data: Buffer) => {
const output = data.toString(); const output = data.toString();
const matches = output.match(/Processed \d+ events?/g);
if (matches) {
const watcher = activeWatchers.get(normalizedPath); const watcher = activeWatchers.get(normalizedPath);
if (watcher) { if (!watcher) return;
watcher.stats.events_processed += matches.length;
// Process output line by line for reliable JSON parsing
// (handles nested arrays/objects that simple regex can't match)
const lines = output.split('\n');
let hasIndexResult = false;
for (const line of lines) {
// Parse [QUEUE_STATUS] JSON
if (line.includes('[QUEUE_STATUS]')) {
const jsonStart = line.indexOf('{');
if (jsonStart !== -1) {
try {
const queueStatus: PendingQueueStatus = JSON.parse(line.slice(jsonStart));
watcher.stats.pending_queue = queueStatus;
broadcastToClients({
type: 'CODEXLENS_WATCHER_QUEUE_UPDATE',
payload: { path: targetPath, queue: queueStatus }
});
} catch (e) {
console.warn('[CodexLens] Failed to parse queue status:', e, line);
} }
} }
}
// Parse [INDEX_RESULT] JSON
if (line.includes('[INDEX_RESULT]')) {
const jsonStart = line.indexOf('{');
if (jsonStart !== -1) {
try {
const indexResult: IndexResultDetail = JSON.parse(line.slice(jsonStart));
watcher.stats.last_index_result = indexResult;
watcher.stats.index_history.push(indexResult);
if (watcher.stats.index_history.length > 10) {
watcher.stats.index_history.shift();
}
watcher.stats.events_processed += indexResult.files_indexed + indexResult.files_removed;
watcher.stats.pending_queue = null;
hasIndexResult = true;
broadcastToClients({
type: 'CODEXLENS_WATCHER_INDEX_COMPLETE',
payload: { path: targetPath, result: indexResult }
});
} catch (e) {
console.warn('[CodexLens] Failed to parse index result:', e, line);
}
}
}
}
// Legacy event counting (fallback)
const matches = output.match(/Processed \d+ events?/g);
if (matches && !hasIndexResult) {
watcher.stats.events_processed += matches.length;
}
}); });
} }
@@ -2111,6 +2184,68 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
return true; return true;
} }
// API: Get Pending Queue Status
if (pathname === '/api/codexlens/watch/queue' && req.method === 'GET') {
const queryPath = url.searchParams.get('path');
const targetPath = queryPath || initialPath;
const normalizedPath = normalizePath(targetPath);
const watcher = activeWatchers.get(normalizedPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
queue: watcher?.stats.pending_queue || { file_count: 0, files: [], countdown_seconds: 0, last_event_time: null }
}));
return true;
}
// API: Flush Pending Queue (Immediate Index)
if (pathname === '/api/codexlens/watch/flush' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: watchPath } = body;
const targetPath = watchPath || initialPath;
const normalizedPath = normalizePath(targetPath);
const watcher = activeWatchers.get(normalizedPath);
if (!watcher) {
return { success: false, error: 'Watcher not running for this path', status: 400 };
}
try {
// Create flush.signal file to trigger immediate indexing
const signalDir = path.join(targetPath, '.codexlens');
const signalFile = path.join(signalDir, 'flush.signal');
if (!fs.existsSync(signalDir)) {
fs.mkdirSync(signalDir, { recursive: true });
}
fs.writeFileSync(signalFile, Date.now().toString());
return { success: true, message: 'Flush signal sent' };
} catch (err: any) {
return { success: false, error: err.message, status: 500 };
}
});
return true;
}
// API: Get Index History
if (pathname === '/api/codexlens/watch/history' && req.method === 'GET') {
const queryPath = url.searchParams.get('path');
const limitParam = url.searchParams.get('limit');
const limit = limitParam ? parseInt(limitParam, 10) : 10;
const targetPath = queryPath || initialPath;
const normalizedPath = normalizePath(targetPath);
const watcher = activeWatchers.get(normalizedPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
history: watcher?.stats.index_history?.slice(-limit) || []
}));
return true;
}
// ============================================================ // ============================================================

View File

@@ -392,10 +392,11 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
category: 'insight' category: 'insight'
}); });
// Try to parse JSON from response // Try to parse JSON from response - use parsedOutput (extracted text) instead of raw stdout
let insights: { patterns: any[]; suggestions: any[] } = { patterns: [], suggestions: [] }; let insights: { patterns: any[]; suggestions: any[] } = { patterns: [], suggestions: [] };
if (result.stdout) { const cliOutput = result.parsedOutput || result.stdout || '';
let outputText = result.stdout; if (cliOutput) {
let outputText = cliOutput;
// Strip markdown code blocks if present // Strip markdown code blocks if present
const codeBlockMatch = outputText.match(/```(?:json)?\s*([\s\S]*?)```/); const codeBlockMatch = outputText.match(/```(?:json)?\s*([\s\S]*?)```/);
@@ -415,14 +416,14 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
console.error('[insights/analyze] JSON parse error:', e); console.error('[insights/analyze] JSON parse error:', e);
// Return raw output if JSON parse fails // Return raw output if JSON parse fails
insights = { insights = {
patterns: [{ type: 'raw_analysis', description: result.stdout.substring(0, 500), occurrences: 1, severity: 'low', suggestion: '' }], patterns: [{ type: 'raw_analysis', description: cliOutput.substring(0, 500), occurrences: 1, severity: 'low', suggestion: '' }],
suggestions: [] suggestions: []
}; };
} }
} else { } else {
// No JSON found, wrap raw output // No JSON found, wrap raw output
insights = { insights = {
patterns: [{ type: 'raw_analysis', description: result.stdout.substring(0, 500), occurrences: 1, severity: 'low', suggestion: '' }], patterns: [{ type: 'raw_analysis', description: cliOutput.substring(0, 500), occurrences: 1, severity: 'low', suggestion: '' }],
suggestions: [] suggestions: []
}; };
} }
@@ -439,7 +440,7 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
promptCount: prompts.length, promptCount: prompts.length,
patterns: insights.patterns, patterns: insights.patterns,
suggestions: insights.suggestions, suggestions: insights.suggestions,
rawOutput: result.stdout || '', rawOutput: cliOutput,
executionId: result.execution?.id, executionId: result.execution?.id,
lang lang
}); });
@@ -981,8 +982,12 @@ RULES: Be concise. Focus on practical understanding. Include function signatures
id: syncId id: syncId
}); });
if (result.success && result.execution?.output) { if (result.success) {
// Extract stdout from output object with proper serialization // Prefer parsedOutput (extracted text from stream JSON) over raw execution output
if (result.parsedOutput) {
cliOutput = result.parsedOutput;
} else if (result.execution?.output) {
// Fallback to execution.output
const output = result.execution.output; const output = result.execution.output;
if (typeof output === 'string') { if (typeof output === 'string') {
cliOutput = output; cliOutput = output;
@@ -996,8 +1001,7 @@ RULES: Be concise. Focus on practical understanding. Include function signatures
// Last resort: serialize the entire object as JSON // Last resort: serialize the entire object as JSON
cliOutput = JSON.stringify(output, null, 2); cliOutput = JSON.stringify(output, null, 2);
} }
} else { }
cliOutput = '';
} }
} }

View File

@@ -221,6 +221,310 @@ function deleteRule(ruleName, location, projectPath) {
} }
} }
/**
* Infer rule context from file name and subdirectory for better prompt generation
* @param {string} fileName - Rule file name
* @param {string} subdirectory - Optional subdirectory path
* @param {string} location - 'project' or 'user'
* @returns {Object} Inferred context
*/
function inferRuleContext(fileName: string, subdirectory: string, location: string) {
const normalizedName = fileName.replace(/\.md$/i, '').toLowerCase();
const normalizedSubdir = (subdirectory || '').toLowerCase();
// Rule category inference from file name and subdirectory
const categories = {
coding: ['coding', 'code', 'style', 'format', 'lint', 'convention'],
testing: ['test', 'spec', 'jest', 'vitest', 'mocha', 'coverage'],
security: ['security', 'auth', 'permission', 'access', 'secret', 'credential'],
architecture: ['arch', 'design', 'pattern', 'structure', 'module', 'layer'],
documentation: ['doc', 'comment', 'readme', 'jsdoc', 'api-doc'],
performance: ['perf', 'performance', 'optimize', 'cache', 'memory'],
workflow: ['workflow', 'ci', 'cd', 'deploy', 'build', 'release'],
tooling: ['tool', 'cli', 'script', 'npm', 'yarn', 'pnpm'],
error: ['error', 'exception', 'handling', 'logging', 'debug']
};
let inferredCategory = 'general';
let inferredKeywords: string[] = [];
for (const [category, keywords] of Object.entries(categories)) {
for (const keyword of keywords) {
if (normalizedName.includes(keyword) || normalizedSubdir.includes(keyword)) {
inferredCategory = category;
inferredKeywords = keywords;
break;
}
}
if (inferredCategory !== 'general') break;
}
// Scope inference from location
const scopeHint = location === 'project'
? 'This rule applies to the current project only'
: 'This rule applies globally to all projects';
// Technology hints from file name
const techPatterns = {
typescript: ['ts', 'typescript', 'tsc'],
javascript: ['js', 'javascript', 'node'],
react: ['react', 'jsx', 'tsx', 'component'],
vue: ['vue', 'vuex', 'pinia'],
python: ['python', 'py', 'pip', 'poetry'],
rust: ['rust', 'cargo', 'rs'],
go: ['go', 'golang', 'mod'],
java: ['java', 'maven', 'gradle', 'spring']
};
let inferredTech: string | null = null;
for (const [tech, patterns] of Object.entries(techPatterns)) {
if (patterns.some(p => normalizedName.includes(p) || normalizedSubdir.includes(p))) {
inferredTech = tech;
break;
}
}
return {
category: inferredCategory,
keywords: inferredKeywords,
scopeHint,
technology: inferredTech,
isConditional: normalizedSubdir.length > 0
};
}
/**
* Build structured prompt for rule generation
* @param {Object} params
* @returns {string} Structured prompt
*/
function buildStructuredRulePrompt(params: {
description: string;
fileName: string;
subdirectory: string;
location: string;
context: ReturnType<typeof inferRuleContext>;
enableReview?: boolean;
}) {
const { description, fileName, subdirectory, location, context, enableReview } = params;
// Build category-specific guidance
const categoryGuidance = {
coding: 'Focus on code style, naming conventions, and formatting rules. Include specific examples of correct and incorrect patterns.',
testing: 'Emphasize test structure, coverage expectations, mocking strategies, and assertion patterns.',
security: 'Highlight security best practices, input validation, authentication requirements, and sensitive data handling.',
architecture: 'Define module boundaries, dependency rules, layer responsibilities, and design pattern usage.',
documentation: 'Specify documentation requirements, comment styles, and API documentation standards.',
performance: 'Address caching strategies, optimization guidelines, resource management, and performance metrics.',
workflow: 'Define CI/CD requirements, deployment procedures, and release management rules.',
tooling: 'Specify tool configurations, script conventions, and dependency management rules.',
error: 'Define error handling patterns, logging requirements, and exception management.',
general: 'Provide clear, actionable guidelines that Claude can follow consistently.'
};
const guidance = categoryGuidance[context.category] || categoryGuidance.general;
// Build technology-specific hint
const techHint = context.technology
? `\nTECHNOLOGY CONTEXT: This rule is for ${context.technology} development. Use ${context.technology}-specific best practices and terminology.`
: '';
// Build subdirectory context
const subdirHint = subdirectory
? `\nORGANIZATION: This rule will be placed in the "${subdirectory}" subdirectory, indicating its category/scope.`
: '';
// Build review instruction if enabled
const reviewInstruction = enableReview
? `\n\nAFTER GENERATION:
- Verify the rule is specific and actionable
- Check for ambiguous language that could be misinterpreted
- Ensure examples are clear and relevant
- Validate markdown formatting is correct`
: '';
return `PURPOSE: Generate a high-quality Claude Code memory rule that will guide Claude's behavior when working in this codebase
SUCCESS CRITERIA: The rule must be (1) specific and actionable, (2) include concrete examples, (3) avoid ambiguous language, (4) follow Claude Code rule format
TASK:
• Parse the user's description to identify core requirements
• Infer additional context from file name "${fileName}" and category "${context.category}"
• Generate structured markdown content with clear instructions
• Include DO and DON'T examples where appropriate
${context.isConditional ? 'Consider if frontmatter paths are needed for conditional activation' : 'Create as a global rule'}
MODE: write
RULE CATEGORY: ${context.category}
CATEGORY GUIDANCE: ${guidance}
${techHint}
${subdirHint}
SCOPE: ${context.scopeHint}
EXPECTED OUTPUT FORMAT:
\`\`\`markdown
${context.isConditional ? `---
paths: [specific/path/patterns/**/*]
---
` : ''}# Rule Title
Brief description of what this rule enforces.
## Guidelines
1. **First guideline** - Explanation
2. **Second guideline** - Explanation
## Examples
### ✅ Correct
\`\`\`language
// Good example
\`\`\`
### ❌ Incorrect
\`\`\`language
// Bad example
\`\`\`
## Exceptions
- When this rule may not apply
\`\`\`
USER DESCRIPTION:
${description}
FILE NAME: ${fileName}
${subdirectory ? `SUBDIRECTORY: ${subdirectory}` : ''}
${reviewInstruction}
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Generate ONLY the rule content in markdown | No additional commentary | Do NOT use any tools | Output raw markdown text directly | write=CREATE`;
}
/**
* Build structured prompt for code extraction
* @param {Object} params
* @returns {string} Structured prompt
*/
function buildExtractPrompt(params: {
extractScope: string;
extractFocus: string;
fileName: string;
subdirectory: string;
context: ReturnType<typeof inferRuleContext>;
}) {
const { extractScope, extractFocus, fileName, subdirectory, context } = params;
const scope = extractScope || '**/*';
const focus = extractFocus || 'naming conventions, error handling, code structure, patterns';
return `PURPOSE: Extract and document coding conventions from the existing codebase to create a Claude Code memory rule
SUCCESS CRITERIA: The rule must reflect ACTUAL patterns found in the code, not theoretical best practices
TASK:
• Scan files matching "${scope}" for recurring patterns
• Identify ${focus.split(',').length} or more distinct conventions
• Document each pattern with real code examples from the codebase
• Create actionable rules based on observed practices
• Note any inconsistencies found (optional section)
MODE: analysis
ANALYSIS SCOPE: @${scope}
FOCUS AREAS: ${focus}
EXTRACTION STRATEGY:
1. **Pattern Recognition**: Look for repeated code structures, naming patterns, file organization
2. **Consistency Check**: Identify which patterns are consistently followed vs. occasionally violated
3. **Frequency Analysis**: Prioritize patterns that appear most frequently
4. **Context Awareness**: Consider why certain patterns are used (performance, readability, etc.)
EXPECTED OUTPUT FORMAT:
\`\`\`markdown
# ${fileName.replace(/\.md$/i, '')} Conventions
Conventions extracted from codebase analysis of \`${scope}\`.
## Naming Conventions
- **Pattern name**: Description with example
\`\`\`language
// Actual code from codebase
\`\`\`
## Code Structure
- **Pattern name**: Description with example
## Error Handling
- **Pattern name**: Description with example
## Notes
- Any inconsistencies or variations observed
\`\`\`
FILE NAME: ${fileName}
${subdirectory ? `SUBDIRECTORY: ${subdirectory}` : ''}
INFERRED CATEGORY: ${context.category}
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/analysis/02-analyze-code-patterns.txt) | Extract REAL patterns from code | Include actual code snippets as examples | Do NOT use any tools | Output raw markdown text directly | analysis=READ-ONLY`;
}
/**
* Build review prompt for validating and improving generated rules
* @param {string} content - Generated rule content to review
* @param {string} fileName - Target file name
* @param {Object} context - Inferred context
* @returns {string} Review prompt
*/
function buildReviewPrompt(
content: string,
fileName: string,
context: ReturnType<typeof inferRuleContext>
) {
return `PURPOSE: Review and improve a Claude Code memory rule for quality, clarity, and actionability
SUCCESS CRITERIA: Output an improved version that is (1) more specific, (2) includes better examples, (3) has no ambiguous language
TASK:
• Analyze the rule for clarity and specificity
• Check if guidelines are actionable (Claude can follow them)
• Verify examples are concrete and helpful
• Remove any ambiguous or vague language
• Ensure markdown formatting is correct
• Improve structure if needed
• Keep the core intent and requirements intact
MODE: write
REVIEW CRITERIA:
1. **Specificity**: Each guideline should be specific enough to follow without interpretation
2. **Actionability**: Guidelines should tell Claude exactly what to do or not do
3. **Examples**: Good and bad examples should be clearly different and illustrative
4. **Consistency**: Formatting and style should be consistent throughout
5. **Completeness**: All necessary aspects of the rule should be covered
6. **Conciseness**: No unnecessary verbosity or repetition
RULE CATEGORY: ${context.category}
FILE NAME: ${fileName}
ORIGINAL RULE CONTENT:
\`\`\`markdown
${content}
\`\`\`
EXPECTED OUTPUT:
- Output ONLY the improved rule content in markdown format
- Do NOT include any commentary, explanation, or meta-text
- If the original is already high quality, return it unchanged
- Preserve any frontmatter (---paths---) if present
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Output ONLY improved markdown content | No additional text | Do NOT use any tools | Output raw markdown text directly | write=CREATE`;
}
/** /**
* Generate rule content via CLI tool * Generate rule content via CLI tool
* @param {Object} params * @param {Object} params
@@ -233,6 +537,7 @@ function deleteRule(ruleName, location, projectPath) {
* @param {string} params.location - 'project' or 'user' * @param {string} params.location - 'project' or 'user'
* @param {string} params.subdirectory - Optional subdirectory * @param {string} params.subdirectory - Optional subdirectory
* @param {string} params.projectPath - Project root path * @param {string} params.projectPath - Project root path
* @param {boolean} params.enableReview - Optional: enable secondary review
* @returns {Object} * @returns {Object}
*/ */
async function generateRuleViaCLI(params) { async function generateRuleViaCLI(params) {
@@ -246,47 +551,47 @@ async function generateRuleViaCLI(params) {
fileName, fileName,
location, location,
subdirectory, subdirectory,
projectPath projectPath,
enableReview
} = params; } = params;
let prompt = ''; let prompt = '';
let mode = 'analysis'; let mode = 'analysis';
let workingDir = projectPath; let workingDir = projectPath;
// Infer context from file name and subdirectory
const context = inferRuleContext(fileName, subdirectory || '', location);
// Build prompt based on generation type // Build prompt based on generation type
if (generationType === 'description') { if (generationType === 'description') {
mode = 'write'; mode = 'write';
prompt = `PURPOSE: Generate Claude Code memory rule from description to guide Claude's behavior prompt = buildStructuredRulePrompt({
TASK: • Analyze rule requirements • Generate markdown content with clear instructions description,
MODE: write fileName,
EXPECTED: Complete rule content in markdown format subdirectory: subdirectory || '',
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Follow Claude Code rule format | Use frontmatter for conditional rules if paths specified | write=CREATE location,
context,
RULE DESCRIPTION: enableReview
${description} });
FILE NAME: ${fileName}`;
} else if (generationType === 'template') { } else if (generationType === 'template') {
mode = 'write'; mode = 'write';
prompt = `PURPOSE: Generate Claude Code rule from template type prompt = `PURPOSE: Generate Claude Code rule from template type
TASK: • Create rule based on ${templateType} template • Generate structured markdown content TASK: • Create rule based on ${templateType} template • Generate structured markdown content
MODE: write MODE: write
EXPECTED: Complete rule content in markdown format following template structure EXPECTED: Complete rule content in markdown format following template structure
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Follow Claude Code rule format | Use ${templateType} template patterns | write=CREATE RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Follow Claude Code rule format | Use ${templateType} template patterns | Do NOT use any tools | Output raw markdown text directly | write=CREATE
TEMPLATE TYPE: ${templateType} TEMPLATE TYPE: ${templateType}
FILE NAME: ${fileName}`; FILE NAME: ${fileName}`;
} else if (generationType === 'extract') { } else if (generationType === 'extract') {
mode = 'analysis'; mode = 'analysis';
prompt = `PURPOSE: Extract coding rules from existing codebase to document patterns and conventions prompt = buildExtractPrompt({
TASK: • Analyze code patterns in specified scope • Extract common conventions • Identify best practices extractScope: extractScope || '',
MODE: analysis extractFocus: extractFocus || '',
CONTEXT: @${extractScope || '**/*'} fileName,
EXPECTED: Rule content based on codebase analysis with examples subdirectory: subdirectory || '',
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/analysis/02-analyze-code-patterns.txt) | Focus on actual patterns found | Include code examples | analysis=READ-ONLY context
});
ANALYSIS SCOPE: ${extractScope || '**/*'}
FOCUS AREAS: ${extractFocus || 'naming conventions, error handling, code structure'}`;
} else { } else {
return { error: `Unknown generation type: ${generationType}` }; return { error: `Unknown generation type: ${generationType}` };
} }
@@ -308,8 +613,15 @@ FOCUS AREAS: ${extractFocus || 'naming conventions, error handling, code structu
}; };
} }
// Extract generated content from stdout // Extract generated content - prefer parsedOutput (extracted text from stream JSON)
const generatedContent = result.stdout.trim(); let generatedContent = (result.parsedOutput || result.stdout || '').trim();
// Remove markdown code block wrapper if present (e.g., ```markdown...```)
if (generatedContent.startsWith('```markdown')) {
generatedContent = generatedContent.replace(/^```markdown\s*\n?/, '').replace(/\n?```\s*$/, '');
} else if (generatedContent.startsWith('```')) {
generatedContent = generatedContent.replace(/^```\w*\s*\n?/, '').replace(/\n?```\s*$/, '');
}
if (!generatedContent) { if (!generatedContent) {
return { return {
@@ -319,6 +631,40 @@ FOCUS AREAS: ${extractFocus || 'naming conventions, error handling, code structu
}; };
} }
// Optional review step - verify and improve the generated rule
let reviewResult = null;
if (enableReview) {
const reviewPrompt = buildReviewPrompt(generatedContent, fileName, context);
const reviewExecution = await executeCliTool({
tool: 'claude',
prompt: reviewPrompt,
mode: 'write',
cd: workingDir,
timeout: 300000, // 5 minutes for review
category: 'internal'
});
if (reviewExecution.success) {
let reviewedContent = (reviewExecution.parsedOutput || reviewExecution.stdout || '').trim();
// Remove markdown code block wrapper if present
if (reviewedContent.startsWith('```markdown')) {
reviewedContent = reviewedContent.replace(/^```markdown\s*\n?/, '').replace(/\n?```\s*$/, '');
} else if (reviewedContent.startsWith('```')) {
reviewedContent = reviewedContent.replace(/^```\w*\s*\n?/, '').replace(/\n?```\s*$/, '');
}
// Only use reviewed content if it's valid and different
if (reviewedContent.length > 50 && reviewedContent !== generatedContent) {
generatedContent = reviewedContent;
reviewResult = {
reviewed: true,
originalLength: (result.parsedOutput || result.stdout || '').trim().length,
reviewedLength: reviewedContent.length
};
}
}
}
// Create the rule using the generated content // Create the rule using the generated content
const createResult = await createRule({ const createResult = await createRule({
fileName, fileName,
@@ -333,7 +679,8 @@ FOCUS AREAS: ${extractFocus || 'naming conventions, error handling, code structu
success: createResult.success || false, success: createResult.success || false,
...createResult, ...createResult,
generatedContent, generatedContent,
executionId: result.conversation?.id executionId: result.conversation?.id,
review: reviewResult
}; };
} catch (error) { } catch (error) {
return { error: (error as Error).message }; return { error: (error as Error).message };

View File

@@ -483,7 +483,7 @@ Create a new Claude Code skill with the following specifications:
if (!result.success) { if (!result.success) {
return { return {
error: `CLI generation failed: ${result.stderr || 'Unknown error'}`, error: `CLI generation failed: ${result.stderr || 'Unknown error'}`,
stdout: result.stdout, stdout: result.parsedOutput || result.stdout,
stderr: result.stderr stderr: result.stderr
}; };
} }
@@ -493,7 +493,7 @@ Create a new Claude Code skill with the following specifications:
if (!validation.valid) { if (!validation.valid) {
return { return {
error: `Generated skill is invalid: ${validation.errors.join(', ')}`, error: `Generated skill is invalid: ${validation.errors.join(', ')}`,
stdout: result.stdout, stdout: result.parsedOutput || result.stdout,
stderr: result.stderr stderr: result.stderr
}; };
} }
@@ -503,7 +503,7 @@ Create a new Claude Code skill with the following specifications:
skillName: validation.skillInfo.name, skillName: validation.skillInfo.name,
location, location,
path: targetPath, path: targetPath,
stdout: result.stdout, stdout: result.parsedOutput || result.stdout,
stderr: result.stderr stderr: result.stderr
}; };
} catch (error) { } catch (error) {

View File

@@ -472,6 +472,65 @@ function handleNotification(data) {
} }
break; break;
case 'CODEXLENS_WATCHER_UPDATE':
// Handle CodexLens watcher real-time updates (file changes detected)
if (typeof handleWatcherStatusUpdate === 'function') {
handleWatcherStatusUpdate(payload);
}
console.log('[CodexLens] Watcher update:', payload.events_processed, 'events');
break;
case 'CODEXLENS_WATCHER_QUEUE_UPDATE':
// Handle pending queue status updates
if (typeof updatePendingQueueUI === 'function') {
updatePendingQueueUI(payload.queue);
}
// Add activity log entries only for NEW files (not already logged)
if (payload.queue && payload.queue.files && payload.queue.files.length > 0) {
if (typeof addWatcherLogEntry === 'function') {
// Track logged files to avoid duplicates
window._watcherLoggedFiles = window._watcherLoggedFiles || new Set();
var newFiles = payload.queue.files.filter(function(f) {
return !window._watcherLoggedFiles.has(f);
});
// Only show first few new files to avoid spam
newFiles.slice(0, 5).forEach(function(fileName) {
window._watcherLoggedFiles.add(fileName);
addWatcherLogEntry('modified', fileName);
});
// Clear tracking when queue is empty (after flush)
if (payload.queue.file_count === 0) {
window._watcherLoggedFiles.clear();
}
}
}
console.log('[CodexLens] Queue update:', payload.queue?.file_count, 'files pending');
break;
case 'CODEXLENS_WATCHER_INDEX_COMPLETE':
// Handle index completion event
if (typeof updateLastIndexResult === 'function') {
updateLastIndexResult(payload.result);
}
// Clear logged files tracking after index completes
if (window._watcherLoggedFiles) {
window._watcherLoggedFiles.clear();
}
// Add activity log entry for index completion
if (typeof addWatcherLogEntry === 'function' && payload.result) {
var summary = 'Indexed ' + (payload.result.files_indexed || 0) + ' files';
addWatcherLogEntry('indexed', summary);
}
// Show toast notification
if (typeof showRefreshToast === 'function' && payload.result) {
var indexMsg = 'Indexed ' + (payload.result.files_indexed || 0) + ' files, ' +
(payload.result.symbols_added || 0) + ' symbols';
var toastType = (payload.result.errors && payload.result.errors.length > 0) ? 'warning' : 'success';
showRefreshToast(indexMsg, toastType);
}
console.log('[CodexLens] Index complete:', payload.result?.files_indexed, 'files indexed');
break;
default: default:
console.log('[WS] Unknown notification type:', type); console.log('[WS] Unknown notification type:', type);
} }

View File

@@ -1363,6 +1363,8 @@ const i18n = {
'rules.extractScopeRequired': 'Analysis scope is required', 'rules.extractScopeRequired': 'Analysis scope is required',
'rules.extractFocus': 'Focus Areas', 'rules.extractFocus': 'Focus Areas',
'rules.extractFocusHint': 'Comma-separated aspects to focus on (e.g., naming, error-handling)', 'rules.extractFocusHint': 'Comma-separated aspects to focus on (e.g., naming, error-handling)',
'rules.enableReview': 'Enable Quality Review',
'rules.enableReviewHint': 'AI will verify the generated rule for clarity, actionability, and proper formatting',
'rules.cliGenerating': 'Generating rule via CLI (this may take a few minutes)...', 'rules.cliGenerating': 'Generating rule via CLI (this may take a few minutes)...',
// CLAUDE.md Manager // CLAUDE.md Manager
@@ -3375,6 +3377,8 @@ const i18n = {
'rules.extractScopeRequired': '分析范围是必需的', 'rules.extractScopeRequired': '分析范围是必需的',
'rules.extractFocus': '关注领域', 'rules.extractFocus': '关注领域',
'rules.extractFocusHint': '以逗号分隔的关注方面(例如:命名规范, 错误处理)', 'rules.extractFocusHint': '以逗号分隔的关注方面(例如:命名规范, 错误处理)',
'rules.enableReview': '启用质量审查',
'rules.enableReviewHint': 'AI 将验证生成的规则是否清晰、可操作且格式正确',
'rules.cliGenerating': '正在通过 CLI 生成规则(可能需要几分钟)...', 'rules.cliGenerating': '正在通过 CLI 生成规则(可能需要几分钟)...',
// CLAUDE.md Manager // CLAUDE.md Manager

View File

@@ -636,12 +636,6 @@ function renderFileMetadata() {
'<option value="gemini">Gemini</option>' + '<option value="gemini">Gemini</option>' +
'<option value="qwen">Qwen</option>' + '<option value="qwen">Qwen</option>' +
'</select>' + '</select>' +
'<label>' + (t('claude.mode') || 'Mode') + '</label>' +
'<select id="cliModeSelect" class="sync-select">' +
'<option value="update">' + (t('claude.modeUpdate') || 'Update (Smart Merge)') + '</option>' +
'<option value="generate">' + (t('claude.modeGenerate') || 'Generate (Full Replace)') + '</option>' +
'<option value="append">' + (t('claude.modeAppend') || 'Append') + '</option>' +
'</select>' +
'</div>' + '</div>' +
'<button class="btn btn-sm btn-primary full-width sync-button" onclick="syncFileWithCLI()" id="cliSyncButton">' + '<button class="btn btn-sm btn-primary full-width sync-button" onclick="syncFileWithCLI()" id="cliSyncButton">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i> ' + '<i data-lucide="refresh-cw" class="w-4 h-4"></i> ' +
@@ -664,7 +658,7 @@ async function syncFileWithCLI() {
if (!selectedFile) return; if (!selectedFile) return;
var tool = document.getElementById('cliToolSelect').value; var tool = document.getElementById('cliToolSelect').value;
var mode = document.getElementById('cliModeSelect').value; var mode = 'generate'; // Default to full replace mode
// Show progress // Show progress
showSyncProgress(true, tool); showSyncProgress(true, tool);

View File

@@ -4536,10 +4536,25 @@ window.toggleWatcher = async function toggleWatcher() {
// Check current status first // Check current status first
try { try {
console.log('[CodexLens] Checking watcher status...'); console.log('[CodexLens] Checking watcher status...');
var statusResponse = await fetch('/api/codexlens/watch/status'); // Pass path parameter to get specific watcher status
var statusResponse = await fetch('/api/codexlens/watch/status?path=' + encodeURIComponent(projectPath));
var statusResult = await statusResponse.json(); var statusResult = await statusResponse.json();
console.log('[CodexLens] Status result:', statusResult); console.log('[CodexLens] Status result:', statusResult);
var isRunning = statusResult.success && statusResult.running;
// Handle both single watcher response and array response
var isRunning = false;
if (statusResult.success) {
if (typeof statusResult.running === 'boolean') {
isRunning = statusResult.running;
} else if (statusResult.watchers && Array.isArray(statusResult.watchers)) {
var normalizedPath = projectPath.toLowerCase().replace(/\\/g, '/');
var matchingWatcher = statusResult.watchers.find(function(w) {
var watcherPath = (w.root_path || '').toLowerCase().replace(/\\/g, '/');
return watcherPath === normalizedPath || watcherPath.includes(normalizedPath) || normalizedPath.includes(watcherPath);
});
isRunning = matchingWatcher ? matchingWatcher.running : false;
}
}
// Toggle: if running, stop; if stopped, start // Toggle: if running, stop; if stopped, start
var action = isRunning ? 'stop' : 'start'; var action = isRunning ? 'stop' : 'start';
@@ -4592,7 +4607,8 @@ function updateWatcherUI(running, stats) {
var uptimeDisplay = document.getElementById('watcherUptimeDisplay'); var uptimeDisplay = document.getElementById('watcherUptimeDisplay');
if (filesCount) filesCount.textContent = stats.files_watched || '-'; if (filesCount) filesCount.textContent = stats.files_watched || '-';
if (changesCount) changesCount.textContent = stats.changes_detected || '0'; // Support both changes_detected and events_processed
if (changesCount) changesCount.textContent = stats.events_processed || stats.changes_detected || '0';
if (uptimeDisplay) uptimeDisplay.textContent = formatUptime(stats.uptime_seconds); if (uptimeDisplay) uptimeDisplay.textContent = formatUptime(stats.uptime_seconds);
} }
@@ -4628,17 +4644,25 @@ function startWatcherPolling() {
if (watcherPollInterval) return; // Already polling if (watcherPollInterval) return; // Already polling
watcherStartTime = Date.now(); watcherStartTime = Date.now();
var projectPath = window.CCW_PROJECT_ROOT || '.';
watcherPollInterval = setInterval(async function() { watcherPollInterval = setInterval(async function() {
try { try {
var response = await fetch('/api/codexlens/watch/status'); // Must include path parameter to get specific watcher status
var response = await fetch('/api/codexlens/watch/status?path=' + encodeURIComponent(projectPath));
var result = await response.json(); var result = await response.json();
if (result.success && result.running) { if (result.success && result.running) {
// Update uptime // Update uptime from server response
var uptimeDisplay = document.getElementById('watcherUptimeDisplay'); var uptimeDisplay = document.getElementById('watcherUptimeDisplay');
if (uptimeDisplay) { if (uptimeDisplay && result.uptime_seconds !== undefined) {
var uptime = (Date.now() - watcherStartTime) / 1000; uptimeDisplay.textContent = formatUptime(result.uptime_seconds);
uptimeDisplay.textContent = formatUptime(uptime); }
// Update changes count from events_processed
if (result.events_processed !== undefined) {
var changesCount = document.getElementById('watcherChangesCount');
if (changesCount) changesCount.textContent = result.events_processed;
} }
// Update files count if available // Update files count if available
@@ -4653,8 +4677,8 @@ function startWatcherPolling() {
addWatcherLogEntry(event.type, event.path); addWatcherLogEntry(event.type, event.path);
}); });
} }
} else if (!result.running) { } else if (result.success && result.running === false) {
// Watcher stopped externally // Watcher stopped externally (only if running is explicitly false)
updateWatcherUI(false); updateWatcherUI(false);
stopWatcherPolling(); stopWatcherPolling();
} }
@@ -4699,13 +4723,15 @@ function addWatcherLogEntry(type, path) {
'created': 'text-success', 'created': 'text-success',
'modified': 'text-warning', 'modified': 'text-warning',
'deleted': 'text-destructive', 'deleted': 'text-destructive',
'renamed': 'text-primary' 'renamed': 'text-primary',
'indexed': 'text-success'
}; };
var typeIcons = { var typeIcons = {
'created': '+', 'created': '+',
'modified': '~', 'modified': '~',
'deleted': '-', 'deleted': '-',
'renamed': '→' 'renamed': '→',
'indexed': '✓'
}; };
var colorClass = typeColors[type] || 'text-muted-foreground'; var colorClass = typeColors[type] || 'text-muted-foreground';
@@ -4748,13 +4774,35 @@ function clearWatcherLog() {
*/ */
async function initWatcherStatus() { async function initWatcherStatus() {
try { try {
var response = await fetch('/api/codexlens/watch/status'); var projectPath = window.CCW_PROJECT_ROOT || '.';
// Pass path parameter to get specific watcher status
var response = await fetch('/api/codexlens/watch/status?path=' + encodeURIComponent(projectPath));
var result = await response.json(); var result = await response.json();
if (result.success) { if (result.success) {
updateWatcherUI(result.running, { // Handle both single watcher response (with path param) and array response (without path param)
files_watched: result.files_watched, var running = result.running;
var uptime = result.uptime_seconds || 0;
var filesWatched = result.files_watched;
// If response has watchers array (no path param), find matching watcher
if (result.watchers && Array.isArray(result.watchers)) {
var normalizedPath = projectPath.toLowerCase().replace(/\\/g, '/');
var matchingWatcher = result.watchers.find(function(w) {
var watcherPath = (w.root_path || '').toLowerCase().replace(/\\/g, '/');
return watcherPath === normalizedPath || watcherPath.includes(normalizedPath) || normalizedPath.includes(watcherPath);
});
if (matchingWatcher) {
running = matchingWatcher.running;
uptime = matchingWatcher.uptime_seconds || 0;
} else {
running = false;
}
}
updateWatcherUI(running, {
files_watched: filesWatched,
changes_detected: 0, changes_detected: 0,
uptime_seconds: result.uptime_seconds uptime_seconds: uptime
}); });
} }
} catch (err) { } catch (err) {
@@ -5846,6 +5894,45 @@ function buildWatcherControlContent(status, defaultPath) {
'</div>' + '</div>' +
'</div>' + '</div>' +
// Pending Queue Section (shown when running)
'<div id="watcherPendingQueue" class="tool-config-section" style="display:' + (running ? 'block' : 'none') + '">' +
'<div class="flex items-center justify-between mb-2">' +
'<h4 class="flex items-center gap-2 m-0">' +
'<i data-lucide="clock" class="w-4 h-4"></i>' +
(t('codexlens.pendingChanges') || 'Pending Changes') +
'</h4>' +
'<button onclick="flushWatcherNow()" class="btn btn-sm btn-primary" id="flushNowBtn" disabled>' +
'<i data-lucide="zap" class="w-3 h-3 mr-1"></i>' +
(t('codexlens.indexNow') || 'Index Now') +
'</button>' +
'</div>' +
'<div class="flex items-center justify-between p-3 bg-muted/20 rounded-lg mb-2">' +
'<div>' +
'<span class="text-2xl font-bold text-warning" id="pendingFileCount">0</span>' +
'<span class="text-sm text-muted-foreground ml-1">' + (t('codexlens.filesWaiting') || 'files waiting') + '</span>' +
'</div>' +
'<div class="text-right">' +
'<div class="text-lg font-mono" id="countdownTimer">--:--</div>' +
'<div class="text-xs text-muted-foreground">' + (t('codexlens.untilNextIndex') || 'until next index') + '</div>' +
'</div>' +
'</div>' +
'<div id="pendingFilesList" class="max-h-24 overflow-y-auto space-y-1 text-sm"></div>' +
'</div>' +
// Last Index Result (shown when running)
'<div id="watcherLastIndex" class="tool-config-section" style="display:none">' +
'<div class="flex items-center justify-between mb-2">' +
'<h4 class="flex items-center gap-2 m-0">' +
'<i data-lucide="check-circle" class="w-4 h-4"></i>' +
(t('codexlens.lastIndexResult') || 'Last Index Result') +
'</h4>' +
'<button onclick="showIndexHistory()" class="text-xs text-muted-foreground hover:text-foreground">' +
(t('codexlens.viewHistory') || 'View History') +
'</button>' +
'</div>' +
'<div class="grid grid-cols-4 gap-2 text-center" id="lastIndexStats"></div>' +
'</div>' +
// Start Configuration (shown when not running) // Start Configuration (shown when not running)
'<div id="watcherStartConfig" class="tool-config-section" style="display:' + (running ? 'none' : 'block') + '">' + '<div id="watcherStartConfig" class="tool-config-section" style="display:' + (running ? 'none' : 'block') + '">' +
'<h4>' + (t('codexlens.watcherConfig') || 'Configuration') + '</h4>' + '<h4>' + (t('codexlens.watcherConfig') || 'Configuration') + '</h4>' +
@@ -5857,7 +5944,7 @@ function buildWatcherControlContent(status, defaultPath) {
'</div>' + '</div>' +
'<div>' + '<div>' +
'<label class="block text-sm font-medium mb-1.5">' + (t('codexlens.debounceMs') || 'Debounce (ms)') + '</label>' + '<label class="block text-sm font-medium mb-1.5">' + (t('codexlens.debounceMs') || 'Debounce (ms)') + '</label>' +
'<input type="number" id="watcherDebounce" value="1000" min="100" max="10000" step="100" ' + '<input type="number" id="watcherDebounce" value="60000" min="1000" max="120000" step="1000" ' +
'class="w-full px-3 py-2 border border-border rounded-lg bg-background text-sm" />' + 'class="w-full px-3 py-2 border border-border rounded-lg bg-background text-sm" />' +
'<p class="text-xs text-muted-foreground mt-1">' + (t('codexlens.debounceHint') || 'Time to wait before processing file changes') + '</p>' + '<p class="text-xs text-muted-foreground mt-1">' + (t('codexlens.debounceHint') || 'Time to wait before processing file changes') + '</p>' +
'</div>' + '</div>' +
@@ -5986,6 +6073,204 @@ function stopWatcherStatusPolling() {
clearInterval(watcherPollingInterval); clearInterval(watcherPollingInterval);
watcherPollingInterval = null; watcherPollingInterval = null;
} }
stopCountdownTimer();
}
// Countdown timer for pending queue
var countdownInterval = null;
var currentCountdownSeconds = 0;
function startCountdownTimer(seconds) {
currentCountdownSeconds = seconds;
if (countdownInterval) return;
countdownInterval = setInterval(function() {
var timerEl = document.getElementById('countdownTimer');
if (!timerEl) {
stopCountdownTimer();
return;
}
if (currentCountdownSeconds <= 0) {
timerEl.textContent = '--:--';
} else {
currentCountdownSeconds--;
timerEl.textContent = formatCountdown(currentCountdownSeconds);
}
}, 1000);
}
function stopCountdownTimer() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
}
function formatCountdown(seconds) {
if (seconds <= 0) return '--:--';
var mins = Math.floor(seconds / 60);
var secs = seconds % 60;
return (mins < 10 ? '0' : '') + mins + ':' + (secs < 10 ? '0' : '') + secs;
}
/**
* Immediately flush pending queue and trigger indexing
*/
async function flushWatcherNow() {
var btn = document.getElementById('flushNowBtn');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-3 h-3 mr-1 animate-spin"></i> Indexing...';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
try {
var watchPath = document.getElementById('watcherPath');
var path = watchPath ? watchPath.value.trim() : '';
var response = await fetch('/api/codexlens/watch/flush', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path || undefined })
});
var result = await response.json();
if (result.success) {
showRefreshToast(t('codexlens.indexTriggered') || 'Indexing triggered', 'success');
} else {
showRefreshToast(t('common.error') + ': ' + result.error, 'error');
}
} catch (err) {
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i data-lucide="zap" class="w-3 h-3 mr-1"></i>' + (t('codexlens.indexNow') || 'Index Now');
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
}
window.flushWatcherNow = flushWatcherNow;
/**
* Show index history in a modal
*/
async function showIndexHistory() {
try {
var watchPath = document.getElementById('watcherPath');
var path = watchPath ? watchPath.value.trim() : '';
var response = await fetch('/api/codexlens/watch/history?limit=10&path=' + encodeURIComponent(path));
var result = await response.json();
if (!result.success || !result.history || result.history.length === 0) {
showRefreshToast(t('codexlens.noHistory') || 'No index history available', 'info');
return;
}
var historyHtml = result.history.slice().reverse().map(function(h, i) {
var timestamp = h.timestamp ? new Date(h.timestamp * 1000).toLocaleString() : 'Unknown';
return '<div class="p-3 border-b border-border last:border-0">' +
'<div class="flex justify-between items-center mb-2">' +
'<span class="text-sm font-medium">#' + (result.history.length - i) + '</span>' +
'<span class="text-xs text-muted-foreground">' + timestamp + '</span>' +
'</div>' +
'<div class="grid grid-cols-4 gap-2 text-center text-sm">' +
'<div><span class="text-success">' + (h.files_indexed || 0) + '</span> indexed</div>' +
'<div><span class="text-warning">' + (h.files_removed || 0) + '</span> removed</div>' +
'<div><span class="text-primary">+' + (h.symbols_added || 0) + '</span> symbols</div>' +
'<div><span class="text-destructive">' + ((h.errors && h.errors.length) || 0) + '</span> errors</div>' +
'</div>' +
(h.errors && h.errors.length > 0 ? '<div class="mt-2 text-xs text-destructive">' +
h.errors.slice(0, 2).map(function(e) { return '<div>• ' + e + '</div>'; }).join('') +
(h.errors.length > 2 ? '<div>... and ' + (h.errors.length - 2) + ' more</div>' : '') +
'</div>' : '') +
'</div>';
}).join('');
var modal = document.createElement('div');
modal.id = 'indexHistoryModal';
modal.className = 'modal-backdrop';
modal.innerHTML = '<div class="modal-container max-w-md">' +
'<div class="modal-header">' +
'<h2 class="text-lg font-bold">' + (t('codexlens.indexHistory') || 'Index History') + '</h2>' +
'<button onclick="document.getElementById(\'indexHistoryModal\').remove()" class="text-muted-foreground hover:text-foreground">' +
'<i data-lucide="x" class="w-5 h-5"></i>' +
'</button>' +
'</div>' +
'<div class="modal-body max-h-96 overflow-y-auto">' + historyHtml + '</div>' +
'</div>';
document.body.appendChild(modal);
if (typeof lucide !== 'undefined') lucide.createIcons();
} catch (err) {
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
}
}
window.showIndexHistory = showIndexHistory;
/**
* Update pending queue UI elements
*/
function updatePendingQueueUI(queue) {
var countEl = document.getElementById('pendingFileCount');
var timerEl = document.getElementById('countdownTimer');
var listEl = document.getElementById('pendingFilesList');
var flushBtn = document.getElementById('flushNowBtn');
if (countEl) countEl.textContent = queue.file_count || 0;
if (queue.countdown_seconds > 0) {
currentCountdownSeconds = queue.countdown_seconds;
if (timerEl) timerEl.textContent = formatCountdown(queue.countdown_seconds);
startCountdownTimer(queue.countdown_seconds);
} else {
if (timerEl) timerEl.textContent = '--:--';
}
if (flushBtn) flushBtn.disabled = (queue.file_count || 0) === 0;
if (listEl && queue.files) {
listEl.innerHTML = queue.files.map(function(f) {
return '<div class="flex items-center gap-2 text-muted-foreground">' +
'<i data-lucide="file" class="w-3 h-3"></i>' +
'<span class="truncate">' + f + '</span>' +
'</div>';
}).join('');
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
/**
* Update last index result UI
*/
function updateLastIndexResult(result) {
var statsEl = document.getElementById('lastIndexStats');
var sectionEl = document.getElementById('watcherLastIndex');
if (sectionEl) sectionEl.style.display = 'block';
if (statsEl) {
statsEl.innerHTML = '<div class="p-2 bg-success/10 rounded">' +
'<div class="text-lg font-bold text-success">' + (result.files_indexed || 0) + '</div>' +
'<div class="text-xs text-muted-foreground">Indexed</div>' +
'</div>' +
'<div class="p-2 bg-warning/10 rounded">' +
'<div class="text-lg font-bold text-warning">' + (result.files_removed || 0) + '</div>' +
'<div class="text-xs text-muted-foreground">Removed</div>' +
'</div>' +
'<div class="p-2 bg-primary/10 rounded">' +
'<div class="text-lg font-bold text-primary">' + (result.symbols_added || 0) + '</div>' +
'<div class="text-xs text-muted-foreground">+Symbols</div>' +
'</div>' +
'<div class="p-2 bg-destructive/10 rounded">' +
'<div class="text-lg font-bold text-destructive">' + ((result.errors && result.errors.length) || 0) + '</div>' +
'<div class="text-xs text-muted-foreground">Errors</div>' +
'</div>';
}
// Clear pending queue after indexing
updatePendingQueueUI({ file_count: 0, files: [], countdown_seconds: 0 });
} }
/** /**
@@ -5999,12 +6284,37 @@ function closeWatcherModal() {
/** /**
* Handle watcher status update from WebSocket * Handle watcher status update from WebSocket
* @param {Object} payload - { running: boolean, path?: string, error?: string } * @param {Object} payload - { running: boolean, path?: string, error?: string, events_processed?: number, uptime_seconds?: number }
*/ */
function handleWatcherStatusUpdate(payload) { function handleWatcherStatusUpdate(payload) {
var toggle = document.getElementById('watcherToggle'); var toggle = document.getElementById('watcherToggle');
var statsDiv = document.getElementById('watcherStats'); var statsDiv = document.getElementById('watcherStats');
var configDiv = document.getElementById('watcherStartConfig'); var configDiv = document.getElementById('watcherStartConfig');
var eventsCountEl = document.getElementById('watcherEventsCount');
var uptimeEl = document.getElementById('watcherUptime');
// Update events count if provided (real-time updates)
if (payload.events_processed !== undefined && eventsCountEl) {
eventsCountEl.textContent = payload.events_processed;
}
// Update uptime if provided
if (payload.uptime_seconds !== undefined && uptimeEl) {
var seconds = payload.uptime_seconds;
var formatted = seconds < 60 ? seconds + 's' :
seconds < 3600 ? Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's' :
Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
uptimeEl.textContent = formatted;
}
// Also update main page watcher status badge if it exists
var statusBadge = document.getElementById('watcherStatusBadge');
if (statusBadge && payload.running !== undefined) {
updateWatcherUI(payload.running, {
events_processed: payload.events_processed,
uptime_seconds: payload.uptime_seconds
});
}
if (payload.error) { if (payload.error) {
// Watcher failed - update UI to show stopped state // Watcher failed - update UI to show stopped state
@@ -6018,8 +6328,8 @@ function handleWatcherStatusUpdate(payload) {
if (statsDiv) statsDiv.style.display = 'block'; if (statsDiv) statsDiv.style.display = 'block';
if (configDiv) configDiv.style.display = 'none'; if (configDiv) configDiv.style.display = 'none';
startWatcherStatusPolling(); startWatcherStatusPolling();
} else { } else if (payload.running === false) {
// Watcher stopped normally // Watcher stopped normally (only if running is explicitly false)
if (toggle) toggle.checked = false; if (toggle) toggle.checked = false;
if (statsDiv) statsDiv.style.display = 'none'; if (statsDiv) statsDiv.style.display = 'none';
if (configDiv) configDiv.style.display = 'block'; if (configDiv) configDiv.style.display = 'block';

View File

@@ -1,6 +1,56 @@
// Core Memory View // Core Memory View
// Manages strategic context entries with knowledge graph and evolution tracking // Manages strategic context entries with knowledge graph and evolution tracking
/**
* Parse JSON streaming content and extract readable text
* Handles Gemini/Qwen format: {"type":"message","content":"...","delta":true}
*/
function parseJsonStreamContent(content) {
if (!content || typeof content !== 'string') return content;
// Check if content looks like JSON streaming (multiple JSON objects)
if (!content.includes('{"type":')) return content;
const lines = content.split('\n');
const extractedParts = [];
let hasJsonLines = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Try to parse as JSON
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
const obj = JSON.parse(trimmed);
// Extract content from message type
if (obj.type === 'message' && obj.content) {
extractedParts.push(obj.content);
hasJsonLines = true;
}
// Skip init/result/error types (metadata)
else if (obj.type === 'init' || obj.type === 'result' || obj.type === 'error') {
hasJsonLines = true;
continue;
}
} catch (e) {
// Not valid JSON, keep as plain text
extractedParts.push(trimmed);
}
} else {
// Plain text line
extractedParts.push(trimmed);
}
}
// If we found JSON lines, return extracted content
if (hasJsonLines && extractedParts.length > 0) {
return extractedParts.join('');
}
return content;
}
// Notification function // Notification function
function showNotification(message, type = 'info') { function showNotification(message, type = 'info') {
// Create notification container if it doesn't exist // Create notification container if it doesn't exist
@@ -527,20 +577,24 @@ async function viewMemoryDetail(memoryId) {
const modal = document.getElementById('memoryDetailModal'); const modal = document.getElementById('memoryDetailModal');
document.getElementById('memoryDetailTitle').textContent = memory.id; document.getElementById('memoryDetailTitle').textContent = memory.id;
// Parse content and summary in case they contain JSON streaming format
const parsedContent = parseJsonStreamContent(memory.content);
const parsedSummary = parseJsonStreamContent(memory.summary);
const body = document.getElementById('memoryDetailBody'); const body = document.getElementById('memoryDetailBody');
body.innerHTML = ` body.innerHTML = `
<div class="memory-detail-content"> <div class="memory-detail-content">
${memory.summary ${parsedSummary
? `<div class="detail-section"> ? `<div class="detail-section">
<h3>${t('coreMemory.summary')}</h3> <h3>${t('coreMemory.summary')}</h3>
<div class="detail-text">${escapeHtml(memory.summary)}</div> <div class="detail-text">${escapeHtml(parsedSummary)}</div>
</div>` </div>`
: '' : ''
} }
<div class="detail-section"> <div class="detail-section">
<h3>${t('coreMemory.content')}</h3> <h3>${t('coreMemory.content')}</h3>
<pre class="detail-code">${escapeHtml(memory.content)}</pre> <pre class="detail-code">${escapeHtml(parsedContent)}</pre>
</div> </div>
${(() => { ${(() => {
@@ -564,7 +618,7 @@ async function viewMemoryDetail(memoryId) {
${memory.raw_output ${memory.raw_output
? `<div class="detail-section"> ? `<div class="detail-section">
<h3>${t('coreMemory.rawOutput')}</h3> <h3>${t('coreMemory.rawOutput')}</h3>
<pre class="detail-code">${escapeHtml(memory.raw_output)}</pre> <pre class="detail-code">${escapeHtml(parseJsonStreamContent(memory.raw_output))}</pre>
</div>` </div>`
: '' : ''
} }

View File

@@ -347,7 +347,8 @@ var ruleCreateState = {
generationType: 'description', generationType: 'description',
description: '', description: '',
extractScope: '', extractScope: '',
extractFocus: '' extractFocus: '',
enableReview: false
}; };
function openRuleCreateModal() { function openRuleCreateModal() {
@@ -363,7 +364,8 @@ function openRuleCreateModal() {
generationType: 'description', generationType: 'description',
description: '', description: '',
extractScope: '', extractScope: '',
extractFocus: '' extractFocus: '',
enableReview: false
}; };
// Create modal HTML // Create modal HTML
@@ -506,6 +508,18 @@ function openRuleCreateModal() {
</div> </div>
</div> </div>
<!-- Review Option (CLI mode only) -->
<div id="ruleReviewSection" style="display: ${ruleCreateState.mode === 'cli-generate' ? 'block' : 'none'}">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="ruleEnableReview"
class="w-4 h-4 text-primary bg-background border-border rounded focus:ring-2 focus:ring-primary"
${ruleCreateState.enableReview ? 'checked' : ''}
onchange="toggleRuleReview()">
<span class="text-sm font-medium text-foreground">${t('rules.enableReview')}</span>
</label>
<p class="text-xs text-muted-foreground mt-1 ml-6">${t('rules.enableReviewHint')}</p>
</div>
<!-- Conditional Rule Toggle (Manual mode only) --> <!-- Conditional Rule Toggle (Manual mode only) -->
<div id="ruleConditionalSection" style="display: ${ruleCreateState.mode === 'input' ? 'block' : 'none'}"> <div id="ruleConditionalSection" style="display: ${ruleCreateState.mode === 'input' ? 'block' : 'none'}">
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
@@ -667,11 +681,13 @@ function switchRuleCreateMode(mode) {
const generationTypeSection = document.getElementById('ruleGenerationTypeSection'); const generationTypeSection = document.getElementById('ruleGenerationTypeSection');
const descriptionSection = document.getElementById('ruleDescriptionSection'); const descriptionSection = document.getElementById('ruleDescriptionSection');
const extractSection = document.getElementById('ruleExtractSection'); const extractSection = document.getElementById('ruleExtractSection');
const reviewSection = document.getElementById('ruleReviewSection');
const conditionalSection = document.getElementById('ruleConditionalSection'); const conditionalSection = document.getElementById('ruleConditionalSection');
const contentSection = document.getElementById('ruleContentSection'); const contentSection = document.getElementById('ruleContentSection');
if (mode === 'cli-generate') { if (mode === 'cli-generate') {
if (generationTypeSection) generationTypeSection.style.display = 'block'; if (generationTypeSection) generationTypeSection.style.display = 'block';
if (reviewSection) reviewSection.style.display = 'block';
if (conditionalSection) conditionalSection.style.display = 'none'; if (conditionalSection) conditionalSection.style.display = 'none';
if (contentSection) contentSection.style.display = 'none'; if (contentSection) contentSection.style.display = 'none';
@@ -687,6 +703,7 @@ function switchRuleCreateMode(mode) {
if (generationTypeSection) generationTypeSection.style.display = 'none'; if (generationTypeSection) generationTypeSection.style.display = 'none';
if (descriptionSection) descriptionSection.style.display = 'none'; if (descriptionSection) descriptionSection.style.display = 'none';
if (extractSection) extractSection.style.display = 'none'; if (extractSection) extractSection.style.display = 'none';
if (reviewSection) reviewSection.style.display = 'none';
if (conditionalSection) conditionalSection.style.display = 'block'; if (conditionalSection) conditionalSection.style.display = 'block';
if (contentSection) contentSection.style.display = 'block'; if (contentSection) contentSection.style.display = 'block';
} }
@@ -724,6 +741,11 @@ function switchRuleGenerationType(type) {
} }
} }
function toggleRuleReview() {
const checkbox = document.getElementById('ruleEnableReview');
ruleCreateState.enableReview = checkbox ? checkbox.checked : false;
}
async function createRule() { async function createRule() {
const fileNameInput = document.getElementById('ruleFileName'); const fileNameInput = document.getElementById('ruleFileName');
const subdirectoryInput = document.getElementById('ruleSubdirectory'); const subdirectoryInput = document.getElementById('ruleSubdirectory');
@@ -784,7 +806,8 @@ async function createRule() {
generationType: ruleCreateState.generationType, generationType: ruleCreateState.generationType,
description: ruleCreateState.generationType === 'description' ? description : undefined, description: ruleCreateState.generationType === 'description' ? description : undefined,
extractScope: ruleCreateState.generationType === 'extract' ? extractScope : undefined, extractScope: ruleCreateState.generationType === 'extract' ? extractScope : undefined,
extractFocus: ruleCreateState.generationType === 'extract' ? extractFocus : undefined extractFocus: ruleCreateState.generationType === 'extract' ? extractFocus : undefined,
enableReview: ruleCreateState.enableReview || undefined
}; };
// Show progress message // Show progress message

View File

@@ -510,35 +510,8 @@ function openSkillCreateModal() {
<p class="text-xs text-muted-foreground mt-1">${t('skills.skillNameHint')}</p> <p class="text-xs text-muted-foreground mt-1">${t('skills.skillNameHint')}</p>
</div> </div>
<!-- Generation Type Selection --> <!-- Description Text Area -->
<div> <div id="skillDescriptionArea">
<label class="block text-sm font-medium text-foreground mb-2">${t('skills.generationType')}</label>
<div class="flex gap-3">
<button class="flex-1 px-4 py-3 text-left border-2 rounded-lg transition-all ${skillCreateState.generationType === 'description' ? 'border-primary bg-primary/10' : 'border-border hover:border-primary/50'}"
onclick="switchSkillGenerationType('description')">
<div class="flex items-center gap-2">
<i data-lucide="file-text" class="w-5 h-5"></i>
<div>
<div class="font-medium text-sm">${t('skills.fromDescription')}</div>
<div class="text-xs text-muted-foreground">${t('skills.fromDescriptionHint')}</div>
</div>
</div>
</button>
<button class="flex-1 px-4 py-3 text-left border-2 rounded-lg transition-all opacity-50 cursor-not-allowed"
disabled>
<div class="flex items-center gap-2">
<i data-lucide="layout-template" class="w-5 h-5"></i>
<div>
<div class="font-medium text-sm">${t('skills.fromTemplate')}</div>
<div class="text-xs text-muted-foreground">${t('skills.comingSoon')}</div>
</div>
</div>
</button>
</div>
</div>
<!-- Description Text Area (for 'description' type) -->
<div id="skillDescriptionArea" style="display: ${skillCreateState.generationType === 'description' ? 'block' : 'none'}">
<label class="block text-sm font-medium text-foreground mb-2">${t('skills.descriptionLabel')} <span class="text-destructive">*</span></label> <label class="block text-sm font-medium text-foreground mb-2">${t('skills.descriptionLabel')} <span class="text-destructive">*</span></label>
<textarea id="skillDescription" <textarea id="skillDescription"
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
@@ -683,19 +656,6 @@ function switchSkillCreateMode(mode) {
} }
} }
function switchSkillGenerationType(type) {
skillCreateState.generationType = type;
// Toggle visibility of description area
const descriptionArea = document.getElementById('skillDescriptionArea');
if (descriptionArea) {
descriptionArea.style.display = type === 'description' ? 'block' : 'none';
}
// Update generation type button styles (only the description button is active, template is disabled)
// No need to update button styles since template button is disabled
}
function browseSkillFolder() { function browseSkillFolder() {
// Use browser prompt for now (Phase 3 will implement file browser) // Use browser prompt for now (Phase 3 will implement file browser)
const path = prompt(t('skills.enterFolderPath'), skillCreateState.sourcePath); const path = prompt(t('skills.enterFolderPath'), skillCreateState.sourcePath);

View File

@@ -205,6 +205,18 @@ class UnifiedStreamParser {
this.extractedText += item.text; this.extractedText += item.text;
output += `[响应] ${item.text}\n`; // Add newline for proper line separation output += `[响应] ${item.text}\n`; // Add newline for proper line separation
} }
// Extract content from write_file tool calls (for rules generation)
// Use type assertion to access tool_use properties
const anyItem = item as { type: string; name?: string; input?: { content?: string } };
if (anyItem.type === 'tool_use' && anyItem.input?.content && typeof anyItem.input.content === 'string') {
const toolName = anyItem.name || '';
// Check if this is a file write operation
if (toolName.includes('write_file') || toolName.includes('Write')) {
// Use the file content as extracted text (overwrite previous text response)
this.extractedText = anyItem.input.content;
output += `[工具] ${toolName}: 写入文件内容 (${anyItem.input.content.length} 字符)\n`;
}
}
} }
} }
@@ -1286,9 +1298,10 @@ async function executeCliTool(
stdout += text; stdout += text;
// Parse stream-json for all supported tools // Parse stream-json for all supported tools
if (streamParser && onOutput) { // Always process chunks to populate extractedText, even without onOutput callback
if (streamParser) {
const parsedText = streamParser.processChunk(text); const parsedText = streamParser.processChunk(text);
if (parsedText) { if (parsedText && onOutput) {
onOutput({ type: 'stdout', data: parsedText }); onOutput({ type: 'stdout', data: parsedText });
} }
} else if (onOutput) { } else if (onOutput) {
@@ -1311,9 +1324,10 @@ async function executeCliTool(
currentChildProcess = null; currentChildProcess = null;
// Flush unified parser buffer if present // Flush unified parser buffer if present
if (streamParser && onOutput) { // Always flush to capture remaining content, even without onOutput callback
if (streamParser) {
const remaining = streamParser.flush(); const remaining = streamParser.flush();
if (remaining) { if (remaining && onOutput) {
onOutput({ type: 'stdout', data: remaining }); onOutput({ type: 'stdout', data: remaining });
} }

View File

@@ -107,7 +107,7 @@ describe('cli command module', async () => {
assert.equal(call.prompt, 'Hello'); assert.equal(call.prompt, 'Hello');
assert.equal(call.mode, 'analysis'); assert.equal(call.mode, 'analysis');
assert.equal(call.stream, false); assert.equal(call.stream, false);
assert.equal(call.timeout, 300000); assert.equal(call.timeout, 0);
} }
assert.deepEqual(exitCodes, [0, 0, 0]); assert.deepEqual(exitCodes, [0, 0, 0]);
}); });

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@@ -28,7 +29,7 @@ class FileEvent:
@dataclass @dataclass
class WatcherConfig: class WatcherConfig:
"""Configuration for file watcher.""" """Configuration for file watcher."""
debounce_ms: int = 1000 debounce_ms: int = 60000 # Default 60 seconds for debounce
ignored_patterns: Set[str] = field(default_factory=lambda: { ignored_patterns: Set[str] = field(default_factory=lambda: {
# Version control # Version control
".git", ".svn", ".hg", ".git", ".svn", ".hg",
@@ -50,13 +51,26 @@ class WatcherConfig:
languages: Optional[List[str]] = None # None = all supported languages: Optional[List[str]] = None # None = all supported
@dataclass
class PendingQueueStatus:
"""Status of pending file changes queue."""
file_count: int = 0
files: List[str] = field(default_factory=list) # Limited to 20 files
countdown_seconds: int = 0
last_event_time: Optional[float] = None
@dataclass @dataclass
class IndexResult: class IndexResult:
"""Result of processing file changes.""" """Result of processing file changes."""
files_indexed: int = 0 files_indexed: int = 0
files_removed: int = 0 files_removed: int = 0
symbols_added: int = 0 symbols_added: int = 0
symbols_removed: int = 0
files_success: List[str] = field(default_factory=list)
files_failed: List[str] = field(default_factory=list)
errors: List[str] = field(default_factory=list) errors: List[str] = field(default_factory=list)
timestamp: float = field(default_factory=time.time)
@dataclass @dataclass

View File

@@ -11,11 +11,15 @@ from typing import Callable, Dict, List, Optional
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
from .events import ChangeType, FileEvent, WatcherConfig from .events import ChangeType, FileEvent, WatcherConfig, PendingQueueStatus
from ..config import Config from ..config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Maximum queue size to prevent unbounded memory growth
# When exceeded, forces immediate flush to avoid memory exhaustion
MAX_QUEUE_SIZE = 50000
class _CodexLensHandler(FileSystemEventHandler): class _CodexLensHandler(FileSystemEventHandler):
"""Internal handler for watchdog events.""" """Internal handler for watchdog events."""
@@ -112,8 +116,12 @@ class FileWatcher:
self._event_queue: List[FileEvent] = [] self._event_queue: List[FileEvent] = []
self._queue_lock = threading.Lock() self._queue_lock = threading.Lock()
# Debounce thread # Debounce timer (true debounce - waits after last event)
self._debounce_thread: Optional[threading.Thread] = None self._flush_timer: Optional[threading.Timer] = None
self._last_event_time: float = 0
# Queue change callbacks for real-time UI updates
self._queue_change_callbacks: List[Callable[[PendingQueueStatus], None]] = []
# Config instance for language checking # Config instance for language checking
self._codexlens_config = Config() self._codexlens_config = Config()
@@ -138,16 +146,57 @@ class FileWatcher:
return language is not None return language is not None
def _on_raw_event(self, event: FileEvent) -> None: def _on_raw_event(self, event: FileEvent) -> None:
"""Handle raw event from watchdog handler.""" """Handle raw event from watchdog handler with true debounce."""
force_flush = False
with self._queue_lock: with self._queue_lock:
# Check queue size limit to prevent memory exhaustion
if len(self._event_queue) >= MAX_QUEUE_SIZE:
logger.warning(
"Event queue limit (%d) reached, forcing immediate flush",
MAX_QUEUE_SIZE
)
if self._flush_timer:
self._flush_timer.cancel()
self._flush_timer = None
force_flush = True
self._event_queue.append(event) self._event_queue.append(event)
# Debouncing is handled by background thread self._last_event_time = time.time()
# Cancel previous timer and schedule new one (true debounce)
# Skip if we're about to force flush
if not force_flush:
if self._flush_timer:
self._flush_timer.cancel()
self._flush_timer = threading.Timer(
self.config.debounce_ms / 1000.0,
self._flush_events
)
self._flush_timer.daemon = True
self._flush_timer.start()
# Force flush outside lock to avoid deadlock
if force_flush:
self._flush_events()
# Notify queue change (outside lock to avoid deadlock)
self._notify_queue_change()
def _debounce_loop(self) -> None: def _debounce_loop(self) -> None:
"""Background thread for debounced event batching.""" """Background thread for checking flush signal file."""
signal_file = self.root_path / '.codexlens' / 'flush.signal'
while self._running: while self._running:
time.sleep(self.config.debounce_ms / 1000.0) time.sleep(1.0) # Check every second
self._flush_events() # Check for flush signal file
if signal_file.exists():
try:
signal_file.unlink()
logger.info("Flush signal detected, triggering immediate index")
self.flush_now()
except Exception as e:
logger.warning("Failed to handle flush signal: %s", e)
def _flush_events(self) -> None: def _flush_events(self) -> None:
"""Flush queued events with deduplication.""" """Flush queued events with deduplication."""
@@ -162,6 +211,10 @@ class FileWatcher:
events = list(deduped.values()) events = list(deduped.values())
self._event_queue.clear() self._event_queue.clear()
self._last_event_time = 0 # Reset after flush
# Notify queue cleared
self._notify_queue_change()
if events: if events:
try: try:
@@ -169,6 +222,50 @@ class FileWatcher:
except Exception as exc: except Exception as exc:
logger.error("Error in on_changes callback: %s", exc) logger.error("Error in on_changes callback: %s", exc)
def flush_now(self) -> None:
"""Immediately flush pending queue (manual trigger)."""
with self._queue_lock:
if self._flush_timer:
self._flush_timer.cancel()
self._flush_timer = None
self._flush_events()
def get_pending_queue_status(self) -> PendingQueueStatus:
"""Get current pending queue status for UI display."""
with self._queue_lock:
file_count = len(self._event_queue)
files = [str(e.path.name) for e in self._event_queue[:20]]
# Calculate countdown
if self._last_event_time > 0 and file_count > 0:
elapsed = time.time() - self._last_event_time
remaining = max(0, self.config.debounce_ms / 1000.0 - elapsed)
countdown = int(remaining)
else:
countdown = 0
return PendingQueueStatus(
file_count=file_count,
files=files,
countdown_seconds=countdown,
last_event_time=self._last_event_time if file_count > 0 else None
)
def register_queue_change_callback(
self, callback: Callable[[PendingQueueStatus], None]
) -> None:
"""Register callback for queue change notifications."""
self._queue_change_callbacks.append(callback)
def _notify_queue_change(self) -> None:
"""Notify all registered callbacks of queue change."""
status = self.get_pending_queue_status()
for callback in self._queue_change_callbacks:
try:
callback(status)
except Exception as e:
logger.error("Queue change callback error: %s", e)
def start(self) -> None: def start(self) -> None:
"""Start watching the directory. """Start watching the directory.
@@ -190,13 +287,13 @@ class FileWatcher:
self._stop_event.clear() self._stop_event.clear()
self._observer.start() self._observer.start()
# Start debounce thread # Start signal check thread (for flush.signal file)
self._debounce_thread = threading.Thread( self._signal_check_thread = threading.Thread(
target=self._debounce_loop, target=self._debounce_loop,
daemon=True, daemon=True,
name="FileWatcher-Debounce", name="FileWatcher-SignalCheck",
) )
self._debounce_thread.start() self._signal_check_thread.start()
logger.info("Started watching: %s", self.root_path) logger.info("Started watching: %s", self.root_path)
@@ -212,15 +309,20 @@ class FileWatcher:
self._running = False self._running = False
self._stop_event.set() self._stop_event.set()
# Cancel pending flush timer
if self._flush_timer:
self._flush_timer.cancel()
self._flush_timer = None
if self._observer: if self._observer:
self._observer.stop() self._observer.stop()
self._observer.join(timeout=5.0) self._observer.join(timeout=5.0)
self._observer = None self._observer = None
# Wait for debounce thread to finish # Wait for signal check thread to finish
if self._debounce_thread and self._debounce_thread.is_alive(): if hasattr(self, '_signal_check_thread') and self._signal_check_thread and self._signal_check_thread.is_alive():
self._debounce_thread.join(timeout=2.0) self._signal_check_thread.join(timeout=2.0)
self._debounce_thread = None self._signal_check_thread = None
# Flush any remaining events # Flush any remaining events
self._flush_events() self._flush_events()

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import signal import signal
import threading import threading
@@ -13,7 +14,7 @@ from codexlens.config import Config
from codexlens.storage.path_mapper import PathMapper from codexlens.storage.path_mapper import PathMapper
from codexlens.storage.registry import RegistryStore from codexlens.storage.registry import RegistryStore
from .events import FileEvent, IndexResult, WatcherConfig, WatcherStats from .events import FileEvent, IndexResult, PendingQueueStatus, WatcherConfig, WatcherStats
from .file_watcher import FileWatcher from .file_watcher import FileWatcher
from .incremental_indexer import IncrementalIndexer from .incremental_indexer import IncrementalIndexer
@@ -36,11 +37,13 @@ class WatcherManager:
config: Optional[Config] = None, config: Optional[Config] = None,
watcher_config: Optional[WatcherConfig] = None, watcher_config: Optional[WatcherConfig] = None,
on_indexed: Optional[Callable[[IndexResult], None]] = None, on_indexed: Optional[Callable[[IndexResult], None]] = None,
on_queue_change: Optional[Callable[[PendingQueueStatus], None]] = None,
) -> None: ) -> None:
self.root_path = Path(root_path).resolve() self.root_path = Path(root_path).resolve()
self.config = config or Config() self.config = config or Config()
self.watcher_config = watcher_config or WatcherConfig() self.watcher_config = watcher_config or WatcherConfig()
self.on_indexed = on_indexed self.on_indexed = on_indexed
self.on_queue_change = on_queue_change
self._registry: Optional[RegistryStore] = None self._registry: Optional[RegistryStore] = None
self._mapper: Optional[PathMapper] = None self._mapper: Optional[PathMapper] = None
@@ -56,6 +59,10 @@ class WatcherManager:
self._original_sigint = None self._original_sigint = None
self._original_sigterm = None self._original_sigterm = None
# Index history for tracking recent results
self._index_history: List[IndexResult] = []
self._max_history_size = 10
def _handle_changes(self, events: List[FileEvent]) -> None: def _handle_changes(self, events: List[FileEvent]) -> None:
"""Handle file change events from watcher.""" """Handle file change events from watcher."""
if not self._indexer or not events: if not self._indexer or not events:
@@ -68,12 +75,30 @@ class WatcherManager:
self._stats.events_processed += len(events) self._stats.events_processed += len(events)
self._stats.last_event_time = time.time() self._stats.last_event_time = time.time()
# Save to history
self._index_history.append(result)
if len(self._index_history) > self._max_history_size:
self._index_history.pop(0)
if result.files_indexed > 0 or result.files_removed > 0: if result.files_indexed > 0 or result.files_removed > 0:
logger.info( logger.info(
"Indexed %d files, removed %d files, %d errors", "Indexed %d files, removed %d files, %d errors",
result.files_indexed, result.files_removed, len(result.errors) result.files_indexed, result.files_removed, len(result.errors)
) )
# Output JSON for TypeScript backend parsing
result_data = {
"files_indexed": result.files_indexed,
"files_removed": result.files_removed,
"symbols_added": result.symbols_added,
"symbols_removed": result.symbols_removed,
"files_success": result.files_success[:20], # Limit output
"files_failed": result.files_failed[:20],
"errors": result.errors[:10],
"timestamp": result.timestamp
}
print(f"[INDEX_RESULT] {json.dumps(result_data)}", flush=True)
if self.on_indexed: if self.on_indexed:
try: try:
self.on_indexed(result) self.on_indexed(result)
@@ -129,6 +154,10 @@ class WatcherManager:
self.root_path, self.watcher_config, self._handle_changes self.root_path, self.watcher_config, self._handle_changes
) )
# Register queue change callback for real-time UI updates
if self.on_queue_change:
self._watcher.register_queue_change_callback(self._on_queue_change_wrapper)
# Install signal handlers # Install signal handlers
self._install_signal_handlers() self._install_signal_handlers()
@@ -192,3 +221,35 @@ class WatcherManager:
last_event_time=self._stats.last_event_time, last_event_time=self._stats.last_event_time,
is_running=self._running, is_running=self._running,
) )
def _on_queue_change_wrapper(self, status: PendingQueueStatus) -> None:
"""Wrapper for queue change callback with JSON output."""
# Output JSON for TypeScript backend parsing
status_data = {
"file_count": status.file_count,
"files": status.files,
"countdown_seconds": status.countdown_seconds,
"last_event_time": status.last_event_time
}
print(f"[QUEUE_STATUS] {json.dumps(status_data)}", flush=True)
if self.on_queue_change:
try:
self.on_queue_change(status)
except Exception as exc:
logger.error("Error in on_queue_change callback: %s", exc)
def flush_now(self) -> None:
"""Immediately flush pending queue (manual trigger)."""
if self._watcher:
self._watcher.flush_now()
def get_pending_queue_status(self) -> Optional[PendingQueueStatus]:
"""Get current pending queue status."""
if self._watcher:
return self._watcher.get_pending_queue_status()
return None
def get_index_history(self, limit: int = 5) -> List[IndexResult]:
"""Get recent index history."""
return self._index_history[-limit:]