diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 0ae9ef3598..f18330f672 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -31,6 +31,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; +import { insightsCommand } from '../ui/commands/insightsCommand.js'; import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js'; import { rewindCommand } from '../ui/commands/rewindCommand.js'; import { hooksCommand } from '../ui/commands/hooksCommand.js'; @@ -117,6 +118,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ] : [extensionsCommand(this.config?.getEnableExtensionReloading())]), helpCommand, + insightsCommand, shortcutsCommand, ...(this.config?.getEnableHooksUI() ? [hooksCommand] : []), rewindCommand, diff --git a/packages/cli/src/ui/commands/insightsCommand.ts b/packages/cli/src/ui/commands/insightsCommand.ts new file mode 100644 index 0000000000..734344e204 --- /dev/null +++ b/packages/cli/src/ui/commands/insightsCommand.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InsightsService } from '@google/gemini-cli-core'; +import { MessageType } from '../types.js'; +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; + +/** + * Slash command to generate usage insights based on past sessions. + */ +export const insightsCommand: SlashCommand = { + name: 'insights', + description: 'Analyze past sessions and get usage improvements and summary.', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext) => { + const config = context.services.config; + if (!config) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Config is not available.', + }); + return; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: 'Analyzing your past sessions to generate insights...', + }); + + try { + const insightsService = new InsightsService(config); + const baseLlmClient = config.getBaseLlmClient(); + + const reportMarkdown = + await insightsService.generateInsightsReport(baseLlmClient); + + context.ui.addItem({ + type: MessageType.GEMINI, + text: reportMarkdown, + }); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to generate insights: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 856a896b3a..261c76dba8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -109,6 +109,7 @@ export * from './services/gitService.js'; export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; export * from './services/sessionSummaryUtils.js'; +export * from './services/insightsService.js'; export * from './services/contextManager.js'; export * from './skills/skillManager.js'; export * from './skills/skillLoader.js'; diff --git a/packages/core/src/services/insightsService.ts b/packages/core/src/services/insightsService.ts new file mode 100644 index 0000000000..861cfe7b1e --- /dev/null +++ b/packages/core/src/services/insightsService.ts @@ -0,0 +1,199 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { Storage } from '../config/storage.js'; +import { + type ConversationRecord, + SESSION_FILE_PREFIX, +} from './chatRecordingService.js'; +import type { BaseLlmClient } from '../core/baseLlmClient.js'; +import { getResponseText } from '../utils/partUtils.js'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { debugLogger } from '../utils/debugLogger.js'; + +export interface SessionMetadata { + projectPath: string; + projectName: string; + startTime: string; + summary?: string; + messageCount: number; + toolCalls: Array<{ + name: string; + status: string; + }>; + errors: string[]; +} + +export interface InsightsReport { + summary: string; + working: string[]; + notWorking: string[]; + recommendations: string[]; + fullMarkdown: string; +} + +const INSIGHTS_PROMPT = `You are an expert AI productivity consultant for Gemini CLI (gcli) users. +Analyze the following session metadata from the user's past and present GCLI sessions. +Provide a comprehensive "Insights Report" to help the user understand their usage patterns and improve their effectiveness with the tool. + +Metadata: +{metadata} + +The report MUST include: +1. **Summary**: A brief (2-3 sentences) high-level overview of the user's CLI usage and general effectiveness. +2. **What is Working**: A bulleted list of successful patterns, frequently used tools that consistently work well, and types of tasks the user is successfully completing. +3. **What Isn't Working**: A bulleted list of recurring errors, failed tool calls, friction points, or inefficient patterns. +4. **Recommendations**: Specific, actionable advice on how the user can improve their usage (e.g., better prompting, trying specific slash commands, managing context better). + +Output the report in Markdown format. Use a professional, encouraging, and highly technical tone.`; + +export class InsightsService { + constructor(private readonly config: Config) {} + + /** + * Generates an insights report based on past and present sessions. + */ + async generateInsightsReport( + baseLlmClient: BaseLlmClient, + maxSessions: number = 10, + ): Promise { + const metadata = await this.collectMetadata(maxSessions); + + if (metadata.length === 0) { + return 'No session data found to analyze. Start using Gemini CLI to get insights!'; + } + + const metadataStr = JSON.stringify(metadata, null, 2); + const prompt = INSIGHTS_PROMPT.replace('{metadata}', metadataStr); + + try { + const response = await baseLlmClient.generateContent({ + modelConfigKey: { model: 'summarizer-default' }, + contents: [ + { + role: 'user', + parts: [{ text: prompt }], + }, + ], + promptId: 'insights-generation', + abortSignal: new AbortController().signal, + }); + + return ( + getResponseText(response) ?? + 'Failed to generate insights report content.' + ); + } catch (error) { + debugLogger.error('Error generating insights report:', error); + throw new Error('Failed to generate insights report.'); + } + } + + private async collectMetadata( + maxSessions: number, + ): Promise { + const allMetadata: SessionMetadata[] = []; + const globalGeminiDir = Storage.getGlobalGeminiDir(); + const projectsFile = path.join(globalGeminiDir, 'projects.json'); + + let projects: Record = {}; + try { + const content = await fs.readFile(projectsFile, 'utf-8'); + projects = JSON.parse(content).projects || {}; + } catch (_e) { + debugLogger.debug('Failed to read projects.json:', _e); + // Fallback to current project if registry is missing + projects[this.config.getProjectRoot()] = 'current'; + } + + const sessionFiles: Array<{ + filePath: string; + projectPath: string; + mtime: number; + }> = []; + + for (const [projectPath, shortId] of Object.entries(projects)) { + const chatsDir = path.join(Storage.getGlobalTempDir(), shortId, 'chats'); + try { + const files = await fs.readdir(chatsDir); + for (const file of files) { + if (file.startsWith(SESSION_FILE_PREFIX) && file.endsWith('.json')) { + const filePath = path.join(chatsDir, file); + const stats = await fs.stat(filePath); + sessionFiles.push({ + filePath, + projectPath, + mtime: stats.mtime.getTime(), + }); + } + } + } catch (_e) { + // Skip projects with no chats or inaccessible chats + } + } + + // Sort by most recent first + sessionFiles.sort((a, b) => b.mtime - a.mtime); + + // Take top N sessions + const selectedSessions = sessionFiles.slice(0, maxSessions); + + for (const session of selectedSessions) { + try { + const content = await fs.readFile(session.filePath, 'utf-8'); + const conversation: ConversationRecord = JSON.parse(content); + allMetadata.push( + this.extractSessionMetadata(conversation, session.projectPath), + ); + } catch (_e) { + debugLogger.debug( + `Failed to parse session file ${session.filePath}:`, + _e, + ); + } + } + + return allMetadata; + } + + private extractSessionMetadata( + conversation: ConversationRecord, + projectPath: string, + ): SessionMetadata { + const toolCalls: Array<{ name: string; status: string }> = []; + const errors: string[] = []; + + for (const msg of conversation.messages) { + if (msg.type === 'gemini' && msg.toolCalls) { + for (const tc of msg.toolCalls) { + toolCalls.push({ + name: tc.name, + status: tc.status, + }); + } + } + if (msg.type === 'error') { + const errorText = + typeof msg.content === 'string' + ? msg.content + : JSON.stringify(msg.content); + errors.push(errorText.slice(0, 200)); // Limit error length + } + } + + return { + projectPath, + projectName: path.basename(projectPath), + startTime: conversation.startTime, + summary: conversation.summary, + messageCount: conversation.messages.length, + toolCalls, + errors: errors.slice(0, 5), // Limit number of errors + }; + } +}