diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index dc4c1f8d9..cda06daad 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -40,6 +40,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; +import { insightCommand } from '../ui/commands/insightCommand.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -90,6 +91,7 @@ export class BuiltinCommandLoader implements ICommandLoader { vimCommand, setupGithubCommand, terminalSetupCommand, + insightCommand, ]; return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/services/insightServer.ts b/packages/cli/src/services/insightServer.ts new file mode 100644 index 000000000..7c0c204cb --- /dev/null +++ b/packages/cli/src/services/insightServer.ts @@ -0,0 +1,404 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import express from 'express'; +import fs from 'fs/promises'; +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import type { ChatRecord } from '@qwen-code/qwen-code-core'; +import { read } from '@qwen-code/qwen-code-core/src/utils/jsonl-utils.js'; + +interface StreakData { + currentStreak: number; + longestStreak: number; + dates: string[]; +} + +// For heat map data +interface HeatMapData { + [date: string]: number; +} + +// For token usage data +interface TokenUsageData { + [date: string]: { + input: number; + output: number; + total: number; + }; +} + +// For achievement data +interface AchievementData { + id: string; + name: string; + description: string; +} + +// For the final insight data +interface InsightData { + heatmap: HeatMapData; + tokenUsage: TokenUsageData; + currentStreak: number; + longestStreak: number; + longestWorkDate: string | null; + longestWorkDuration: number; // in minutes + activeHours: { [hour: number]: number }; + latestActiveTime: string | null; + achievements: AchievementData[]; +} + +function debugLog(message: string) { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] ${message}\n`; + console.log(logMessage); +} + +debugLog('Insight server starting...'); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const app = express(); +const PORT = process.env['PORT']; +const BASE_DIR = process.env['BASE_DIR']; + +if (!BASE_DIR) { + debugLog('BASE_DIR environment variable is required'); + process.exit(1); +} + +app.get('/', (_req, res) => { + res.sendFile(path.join(__dirname, 'views', 'index.html')); +}); + +// API endpoint to get insight data +app.get('/api/insights', async (_req, res) => { + try { + debugLog('Received request for insights data'); + const insights = await generateInsights(BASE_DIR); + debugLog( + `Returning insights data, heatmap size: ${Object.keys(insights.heatmap).length}`, + ); + res.json(insights); + } catch (error) { + debugLog(`Error generating insights: ${error}`); + res.status(500).json({ error: 'Failed to generate insights' }); + } +}); + +// Process chat files from all projects in the base directory and generate insights +async function generateInsights(baseDir: string): Promise { + // Initialize data structures + const heatmap: HeatMapData = {}; + const tokenUsage: TokenUsageData = {}; + const activeHours: { [hour: number]: number } = {}; + const sessionStartTimes: { [sessionId: string]: Date } = {}; + const sessionEndTimes: { [sessionId: string]: Date } = {}; + + try { + // Get all project directories in the base directory + const projectDirs = await fs.readdir(baseDir); + + // Process each project directory + for (const projectDir of projectDirs) { + const projectPath = path.join(baseDir, projectDir); + const stats = await fs.stat(projectPath); + + // Only process if it's a directory + if (stats.isDirectory()) { + const chatsDir = path.join(projectPath, 'chats'); + + let chatFiles: string[] = []; + try { + // Get all chat files in the chats directory + const files = await fs.readdir(chatsDir); + chatFiles = files.filter((file) => file.endsWith('.jsonl')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + debugLog( + `Error reading chats directory for project ${projectDir}: ${error}`, + ); + } + // Continue to next project if chats directory doesn't exist + continue; + } + + // Process each chat file in this project + for (const file of chatFiles) { + const filePath = path.join(chatsDir, file); + const records = await read(filePath); + debugLog( + `Processing file: ${filePath}, records count: ${records.length}`, + ); + + // Process each record + for (const record of records) { + const timestamp = new Date(record.timestamp); + const dateKey = formatDate(timestamp); + const hour = timestamp.getHours(); + + // Update heatmap (count of interactions per day) + heatmap[dateKey] = (heatmap[dateKey] || 0) + 1; + + // Update active hours + activeHours[hour] = (activeHours[hour] || 0) + 1; + + // Update token usage + if (record.usageMetadata) { + const usage = tokenUsage[dateKey] || { + input: 0, + output: 0, + total: 0, + }; + + usage.input += record.usageMetadata.promptTokenCount || 0; + usage.output += record.usageMetadata.candidatesTokenCount || 0; + usage.total += record.usageMetadata.totalTokenCount || 0; + + tokenUsage[dateKey] = usage; + } + + // Track session times + if (!sessionStartTimes[record.sessionId]) { + sessionStartTimes[record.sessionId] = timestamp; + } + sessionEndTimes[record.sessionId] = timestamp; + } + } + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // Base directory doesn't exist, return empty insights + debugLog(`Base directory does not exist: ${baseDir}`); + } else { + debugLog(`Error reading base directory: ${error}`); + } + } + + // Calculate streak data + const streakData = calculateStreaks(Object.keys(heatmap)); + + // Calculate longest work session + let longestWorkDuration = 0; + let longestWorkDate: string | null = null; + for (const sessionId in sessionStartTimes) { + const start = sessionStartTimes[sessionId]; + const end = sessionEndTimes[sessionId]; + const durationMinutes = Math.round( + (end.getTime() - start.getTime()) / (1000 * 60), + ); + + if (durationMinutes > longestWorkDuration) { + longestWorkDuration = durationMinutes; + longestWorkDate = formatDate(start); + } + } + + // Calculate latest active time + let latestActiveTime: string | null = null; + let latestTimestamp = new Date(0); + for (const dateStr in heatmap) { + const date = new Date(dateStr); + if (date > latestTimestamp) { + latestTimestamp = date; + latestActiveTime = date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + } + } + + // Calculate achievements + const achievements = calculateAchievements(activeHours, heatmap, tokenUsage); + + return { + heatmap, + tokenUsage, + currentStreak: streakData.currentStreak, + longestStreak: streakData.longestStreak, + longestWorkDate, + longestWorkDuration, + activeHours, + latestActiveTime, + achievements, + }; +} + +// Helper function to format date as YYYY-MM-DD +function formatDate(date: Date): string { + return date.toISOString().split('T')[0]; +} + +// Calculate streaks from activity dates +function calculateStreaks(dates: string[]): StreakData { + if (dates.length === 0) { + return { currentStreak: 0, longestStreak: 0, dates: [] }; + } + + // Convert string dates to Date objects and sort them + const dateObjects = dates.map((dateStr) => new Date(dateStr)); + dateObjects.sort((a, b) => a.getTime() - b.getTime()); + + let currentStreak = 1; + let maxStreak = 1; + let currentDate = new Date(dateObjects[0]); + currentDate.setHours(0, 0, 0, 0); // Normalize to start of day + + for (let i = 1; i < dateObjects.length; i++) { + const nextDate = new Date(dateObjects[i]); + nextDate.setHours(0, 0, 0, 0); // Normalize to start of day + + // Calculate difference in days + const diffDays = Math.floor( + (nextDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24), + ); + + if (diffDays === 1) { + // Consecutive day + currentStreak++; + maxStreak = Math.max(maxStreak, currentStreak); + } else if (diffDays > 1) { + // Gap in streak + currentStreak = 1; + } + // If diffDays === 0, same day, so streak continues + + currentDate = nextDate; + } + + // Check if the streak is still ongoing (if last activity was yesterday or today) + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if ( + currentDate.getTime() === today.getTime() || + currentDate.getTime() === yesterday.getTime() + ) { + // The streak might still be active, so we don't reset it + } + + return { + currentStreak, + longestStreak: maxStreak, + dates, + }; +} + +// Calculate achievements based on user behavior +function calculateAchievements( + activeHours: { [hour: number]: number }, + heatmap: HeatMapData, + _tokenUsage: TokenUsageData, +): AchievementData[] { + const achievements: AchievementData[] = []; + + // Total activities + const totalActivities = Object.values(heatmap).reduce( + (sum, count) => sum + count, + 0, + ); + + // Total tokens used - commented out since it's not currently used + // const totalTokens = Object.values(tokenUsage).reduce((sum, usage) => sum + usage.total, 0); + + // Total sessions + const totalSessions = Object.keys(heatmap).length; + + // Calculate percentage of activity per hour + const totalHourlyActivity = Object.values(activeHours).reduce( + (sum, count) => sum + count, + 0, + ); + if (totalHourlyActivity > 0) { + // Midnight debugger: 20% of sessions happen between 12AM-5AM + const midnightActivity = + (activeHours[0] || 0) + + (activeHours[1] || 0) + + (activeHours[2] || 0) + + (activeHours[3] || 0) + + (activeHours[4] || 0) + + (activeHours[5] || 0); + + if (midnightActivity / totalHourlyActivity >= 0.2) { + achievements.push({ + id: 'midnight-debugger', + name: 'Midnight Debugger', + description: '20% of your sessions happen between 12AM-5AM', + }); + } + + // Morning coder: 20% of sessions happen between 6AM-9AM + const morningActivity = + (activeHours[6] || 0) + + (activeHours[7] || 0) + + (activeHours[8] || 0) + + (activeHours[9] || 0); + + if (morningActivity / totalHourlyActivity >= 0.2) { + achievements.push({ + id: 'morning-coder', + name: 'Morning Coder', + description: '20% of your sessions happen between 6AM-9AM', + }); + } + } + + // Patient king: average conversation length >= 10 exchanges + if (totalSessions > 0) { + const avgExchanges = totalActivities / totalSessions; + if (avgExchanges >= 10) { + achievements.push({ + id: 'patient-king', + name: 'Patient King', + description: 'Your average conversation length is 10+ exchanges', + }); + } + } + + // Quick finisher: 70% of sessions have <= 2 exchanges + let quickSessions = 0; + // Since we don't have per-session exchange counts easily available, + // we'll estimate based on the distribution of activities + if (totalSessions > 0) { + // This is a simplified calculation - in a real implementation, + // we'd need to count exchanges per session + const avgPerSession = totalActivities / totalSessions; + if (avgPerSession <= 2) { + // Estimate based on low average + quickSessions = Math.floor(totalSessions * 0.7); + } + + if (quickSessions / totalSessions >= 0.7) { + achievements.push({ + id: 'quick-finisher', + name: 'Quick Finisher', + description: '70% of your sessions end in 2 exchanges or fewer', + }); + } + } + + // Explorer: for users with insufficient data or default + if (achievements.length === 0) { + achievements.push({ + id: 'explorer', + name: 'Explorer', + description: 'Getting started with Qwen Code', + }); + } + + return achievements; +} + +// Start the server +app.listen(PORT, () => { + debugLog(`Server running at http://localhost:${PORT}/`); + debugLog(`Analyzing projects in: ${BASE_DIR}`); + debugLog('Server is running. Press Ctrl+C to stop.'); +}); diff --git a/packages/cli/src/services/views/index.html b/packages/cli/src/services/views/index.html new file mode 100644 index 000000000..9b6cb5f9a --- /dev/null +++ b/packages/cli/src/services/views/index.html @@ -0,0 +1,404 @@ + + + + + + Qwen Code Insights + + + + +
+
+

Qwen Code Insights

+

Your personalized coding journey and patterns

+
+ +
+
+

Current Streak

+
+
+
0
+
Days
+
+
+
0
+
Longest
+
+
+
+ +
+

Active Hours

+ +
+ +
+

Work Session

+
Longest: 0 min
+
Date: -
+
Last Active: -
+
+
+ +
+

Activity Heatmap

+
+ +
+
+ +
+

Token Usage

+ +
+ +
+

Achievements

+
+ +
+
+ + +
+ + + + + diff --git a/packages/cli/src/ui/commands/insightCommand.ts b/packages/cli/src/ui/commands/insightCommand.ts new file mode 100644 index 000000000..2721e1282 --- /dev/null +++ b/packages/cli/src/ui/commands/insightCommand.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandContext, SlashCommand } from './types.js'; +import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import { t } from '../../i18n/index.js'; +import { spawn } from 'child_process'; +import { join } from 'path'; +import os from 'os'; +import { registerCleanup } from '../../utils/cleanup.js'; +import net from 'net'; + +// Track the insight server subprocess so we can terminate it on quit +let insightServerProcess: import('child_process').ChildProcess | null = null; + +// Find an available port starting from a default port +async function findAvailablePort(startingPort: number = 3000): Promise { + return new Promise((resolve, reject) => { + let port = startingPort; + + const checkPort = () => { + const server = net.createServer(); + + server.listen(port, () => { + server.once('close', () => { + resolve(port); + }); + server.close(); + }); + + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + port++; // Try next port + checkPort(); + } else { + reject(err); + } + }); + }; + + checkPort(); + }); +} + +export const insightCommand: SlashCommand = { + name: 'insight', + get description() { + return t( + 'generate personalized programming insights from your chat history', + ); + }, + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext) => { + try { + context.ui.setDebugMessage(t('Starting insight server...')); + + // If there's an existing insight server process, terminate it first + if (insightServerProcess && !insightServerProcess.killed) { + insightServerProcess.kill(); + insightServerProcess = null; + } + + // Find an available port + const availablePort = await findAvailablePort(3000); + + const projectsDir = join(os.homedir(), '.qwen', 'projects'); + + // Path to the insight server script + const insightScriptPath = join( + process.cwd(), + 'packages', + 'cli', + 'src', + 'services', + 'insightServer.ts', + ); + + // Spawn the insight server process + const serverProcess = spawn('npx', ['tsx', insightScriptPath], { + stdio: 'pipe', + env: { + ...process.env, + NODE_ENV: 'production', + BASE_DIR: projectsDir, + PORT: String(availablePort), + }, + }); + + // Store the server process for cleanup + insightServerProcess = serverProcess; + + // Register cleanup function to terminate the server process on quit + registerCleanup(() => { + if (insightServerProcess && !insightServerProcess.killed) { + insightServerProcess.kill(); + insightServerProcess = null; + } + }); + + serverProcess.stderr.on('data', (data) => { + // Forward error output to parent process stderr + process.stderr.write(`Insight server error: ${data}`); + + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Insight server error: ${data.toString()}`, + }, + Date.now(), + ); + }); + + serverProcess.on('close', (code) => { + console.log(`Insight server process exited with code ${code}`); + context.ui.setDebugMessage(t('Insight server stopped.')); + // Reset the reference when the process closes + if (insightServerProcess === serverProcess) { + insightServerProcess = null; + } + }); + + const url = `http://localhost:${availablePort}`; + + // Open browser automatically + const openBrowser = async () => { + try { + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + + switch (process.platform) { + case 'darwin': // macOS + await execAsync(`open ${url}`); + break; + case 'win32': // Windows + await execAsync(`start ${url}`); + break; + default: // Linux and others + await execAsync(`xdg-open ${url}`); + } + + context.ui.addItem( + { + type: MessageType.INFO, + text: `Insight server started. Visit: ${url}`, + }, + Date.now(), + ); + } catch (err) { + console.error('Failed to open browser automatically:', err); + context.ui.addItem( + { + type: MessageType.INFO, + text: `Insight server started. Please visit: ${url}`, + }, + Date.now(), + ); + } + }; + + // Wait for the server to start (give it some time to bind to the port) + setTimeout(openBrowser, 1000); + + // Inform the user that the server is running + context.ui.addItem( + { + type: MessageType.INFO, + text: t( + 'Insight server started. Check your browser for the visualization.', + ), + }, + Date.now(), + ); + } catch (error) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: t('Failed to start insight server: {{error}}', { + error: (error as Error).message, + }), + }, + Date.now(), + ); + } + }, +};