mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f1970df81 | |||
| c4fe8f4ff1 | |||
| b36e4eb1eb | |||
| 5f034e58af | |||
| f72359efef |
@@ -796,6 +796,8 @@ export async function loadCliConfig(
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
sessionLearnings: settings.general?.sessionLearnings?.enabled,
|
||||
sessionLearningsOutputPath: settings.general?.sessionLearnings?.outputPath,
|
||||
ideMode,
|
||||
disableLoopDetection: settings.model?.disableLoopDetection,
|
||||
compressionThreshold: settings.model?.compressionThreshold,
|
||||
|
||||
@@ -322,6 +322,37 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
description: 'Settings for automatic session cleanup.',
|
||||
},
|
||||
sessionLearnings: {
|
||||
type: 'object',
|
||||
label: 'Session Learnings',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Settings for session learning summaries.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Session Learnings',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Automatically generate a session-learnings.md file when the session ends.',
|
||||
showInDialog: true,
|
||||
},
|
||||
outputPath: {
|
||||
type: 'string',
|
||||
label: 'Output Path',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'Directory where session-learnings files should be saved. Defaults to project root.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
|
||||
@@ -477,6 +477,8 @@ export interface ConfigParameters {
|
||||
experimentalJitContext?: boolean;
|
||||
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
|
||||
disableLLMCorrection?: boolean;
|
||||
sessionLearnings?: boolean;
|
||||
sessionLearningsOutputPath?: string;
|
||||
plan?: boolean;
|
||||
onModelChange?: (model: string) => void;
|
||||
mcpEnabled?: boolean;
|
||||
@@ -662,6 +664,8 @@ export class Config {
|
||||
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
private readonly sessionLearnings: boolean;
|
||||
private readonly sessionLearningsOutputPath: string | undefined;
|
||||
private readonly planEnabled: boolean;
|
||||
private contextManager?: ContextManager;
|
||||
private terminalBackground: string | undefined = undefined;
|
||||
@@ -750,6 +754,8 @@ export class Config {
|
||||
this.enableAgents = params.enableAgents ?? false;
|
||||
this.agents = params.agents ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
this.sessionLearnings = params.sessionLearnings ?? false;
|
||||
this.sessionLearningsOutputPath = params.sessionLearningsOutputPath;
|
||||
this.planEnabled = params.plan ?? false;
|
||||
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
|
||||
this.skillsSupport = params.skillsSupport ?? true;
|
||||
@@ -1953,6 +1959,14 @@ export class Config {
|
||||
return this.disableLLMCorrection;
|
||||
}
|
||||
|
||||
isSessionLearningsEnabled(): boolean {
|
||||
return this.sessionLearnings;
|
||||
}
|
||||
|
||||
getSessionLearningsOutputPath(): string | undefined {
|
||||
return this.sessionLearningsOutputPath;
|
||||
}
|
||||
|
||||
isPlanEnabled(): boolean {
|
||||
return this.planEnabled;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ describe('HookRegistry', () => {
|
||||
getDisabledHooks: vi.fn().mockReturnValue([]),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/project'),
|
||||
isSessionLearningsEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
hookRegistry = new HookRegistry(mockConfig);
|
||||
@@ -279,6 +280,21 @@ describe('HookRegistry', () => {
|
||||
hookRegistry.getHooksForEvent(HookEventName.BeforeTool),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should register builtin session-learnings hook when enabled', async () => {
|
||||
vi.mocked(mockConfig.isSessionLearningsEnabled).mockReturnValue(true);
|
||||
|
||||
await hookRegistry.initialize();
|
||||
|
||||
const hooks = hookRegistry.getHooksForEvent(HookEventName.SessionEnd);
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0].config.type).toBe(HookType.Builtin);
|
||||
|
||||
expect((hooks[0].config as BuiltinHookConfig).builtin_id).toBe(
|
||||
'session-learnings',
|
||||
);
|
||||
expect(hooks[0].source).toBe(ConfigSource.System);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHooksForEvent', () => {
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { HookDefinition, HookConfig } from './types.js';
|
||||
import { HookEventName, ConfigSource, HOOKS_CONFIG_FIELDS } from './types.js';
|
||||
import {
|
||||
HookEventName,
|
||||
ConfigSource,
|
||||
HOOKS_CONFIG_FIELDS,
|
||||
HookType,
|
||||
} from './types.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { TrustedHooksManager } from './trustedHooks.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
@@ -137,6 +142,8 @@ please review the project settings (.gemini/settings.json) and remove them.`;
|
||||
this.checkProjectHooksTrust();
|
||||
}
|
||||
|
||||
this.registerBuiltinHooks();
|
||||
|
||||
// Get hooks from the main config (this comes from the merged settings)
|
||||
const configHooks = this.config.getHooks();
|
||||
if (configHooks) {
|
||||
@@ -161,6 +168,27 @@ please review the project settings (.gemini/settings.json) and remove them.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register system-level builtin hooks
|
||||
*/
|
||||
private registerBuiltinHooks(): void {
|
||||
if (this.config.isSessionLearningsEnabled()) {
|
||||
debugLogger.debug('Registering builtin session-learnings hook');
|
||||
this.entries.push({
|
||||
config: {
|
||||
type: HookType.Builtin,
|
||||
builtin_id: 'session-learnings',
|
||||
name: 'session-learnings',
|
||||
description: 'Automatically generate session learning summaries',
|
||||
source: ConfigSource.System,
|
||||
},
|
||||
source: ConfigSource.System,
|
||||
eventName: HookEventName.SessionEnd,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process hooks configuration and add entries
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HookRunner } from './hookRunner.js';
|
||||
import {
|
||||
HookEventName,
|
||||
HookType,
|
||||
SessionEndReason,
|
||||
type BuiltinHookConfig,
|
||||
type SessionEndInput,
|
||||
} from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
describe('HookRunner (Builtin Hooks)', () => {
|
||||
let hookRunner: HookRunner;
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockConfig = {
|
||||
sanitizationConfig: {},
|
||||
isSessionLearningsEnabled: vi.fn().mockReturnValue(true),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
getConversation: vi.fn().mockReturnValue({ messages: [] }),
|
||||
getConversationFilePath: vi
|
||||
.fn()
|
||||
.mockReturnValue('/tmp/transcript.json'),
|
||||
}),
|
||||
}),
|
||||
getContentGenerator: vi.fn(),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/work'),
|
||||
} as unknown as Config;
|
||||
|
||||
hookRunner = new HookRunner(mockConfig);
|
||||
});
|
||||
|
||||
it('should execute session-learnings builtin hook on SessionEnd', async () => {
|
||||
const hookConfig: BuiltinHookConfig = {
|
||||
type: HookType.Builtin,
|
||||
builtin_id: 'session-learnings',
|
||||
name: 'test-learnings',
|
||||
};
|
||||
|
||||
const input: SessionEndInput = {
|
||||
session_id: 'test-session',
|
||||
transcript_path: '/tmp/transcript.json',
|
||||
cwd: '/work',
|
||||
hook_event_name: HookEventName.SessionEnd,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: SessionEndReason.Exit,
|
||||
};
|
||||
|
||||
// Spy on the service
|
||||
const serviceSpy = vi
|
||||
.spyOn(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(hookRunner as any).sessionLearningsService,
|
||||
'generateAndSaveLearnings',
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const result = await hookRunner.executeHook(
|
||||
hookConfig,
|
||||
HookEventName.SessionEnd,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(serviceSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not execute session-learnings if reason is not exit/logout', async () => {
|
||||
const hookConfig: BuiltinHookConfig = {
|
||||
type: HookType.Builtin,
|
||||
builtin_id: 'session-learnings',
|
||||
};
|
||||
|
||||
const input: SessionEndInput = {
|
||||
session_id: 'test-session',
|
||||
transcript_path: '/tmp/transcript.json',
|
||||
cwd: '/work',
|
||||
hook_event_name: HookEventName.SessionEnd,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: SessionEndReason.Clear,
|
||||
};
|
||||
|
||||
const serviceSpy = vi.spyOn(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(hookRunner as any).sessionLearningsService,
|
||||
'generateAndSaveLearnings',
|
||||
);
|
||||
|
||||
const result = await hookRunner.executeHook(
|
||||
hookConfig,
|
||||
HookEventName.SessionEnd,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(serviceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { HookConfig } from './types.js';
|
||||
import { HookEventName, ConfigSource } from './types.js';
|
||||
import { HookEventName, ConfigSource, HookType } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type {
|
||||
HookInput,
|
||||
@@ -16,7 +16,9 @@ import type {
|
||||
BeforeModelInput,
|
||||
BeforeModelOutput,
|
||||
BeforeToolInput,
|
||||
SessionEndInput,
|
||||
} from './types.js';
|
||||
import { SessionEndReason } from './types.js';
|
||||
import type { LLMRequest } from './hookTranslator.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { sanitizeEnvironment } from '../services/environmentSanitization.js';
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
getShellConfiguration,
|
||||
type ShellType,
|
||||
} from '../utils/shell-utils.js';
|
||||
import { SessionLearningsService } from '../services/sessionLearningsService.js';
|
||||
|
||||
/**
|
||||
* Default timeout for hook execution (60 seconds)
|
||||
@@ -43,9 +46,11 @@ const EXIT_CODE_NON_BLOCKING_ERROR = 1;
|
||||
*/
|
||||
export class HookRunner {
|
||||
private readonly config: Config;
|
||||
private readonly sessionLearningsService: SessionLearningsService;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
this.sessionLearningsService = new SessionLearningsService(config);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,12 +81,25 @@ export class HookRunner {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.executeCommandHook(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
startTime,
|
||||
);
|
||||
if (hookConfig.type === HookType.Command) {
|
||||
return await this.executeCommandHook(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
startTime,
|
||||
);
|
||||
} else if (hookConfig.type === HookType.Builtin) {
|
||||
return await this.executeBuiltinHook(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
startTime,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported hook type: ${(hookConfig as HookConfig).type}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const hookId = hookConfig.name || hookConfig.command || 'unknown';
|
||||
@@ -231,6 +249,52 @@ export class HookRunner {
|
||||
return modifiedInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a builtin hook
|
||||
*/
|
||||
private async executeBuiltinHook(
|
||||
hookConfig: HookConfig,
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
startTime: number,
|
||||
): Promise<HookExecutionResult> {
|
||||
if (hookConfig.type !== HookType.Builtin) {
|
||||
throw new Error('Expected builtin hook configuration');
|
||||
}
|
||||
|
||||
try {
|
||||
if (hookConfig.builtin_id === 'session-learnings') {
|
||||
if (eventName === HookEventName.SessionEnd) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const sessionEndInput = input as SessionEndInput;
|
||||
if (
|
||||
sessionEndInput.reason === SessionEndReason.Exit ||
|
||||
sessionEndInput.reason === SessionEndReason.Logout
|
||||
) {
|
||||
await this.sessionLearningsService.generateAndSaveLearnings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: true,
|
||||
duration: Date.now() - startTime,
|
||||
exitCode: EXIT_CODE_SUCCESS,
|
||||
output: {},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command hook
|
||||
*/
|
||||
|
||||
@@ -62,7 +62,16 @@ export interface CommandHookConfig {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type HookConfig = CommandHookConfig;
|
||||
export interface BuiltinHookConfig {
|
||||
type: HookType.Builtin;
|
||||
builtin_id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
source?: ConfigSource;
|
||||
}
|
||||
|
||||
export type HookConfig = CommandHookConfig | BuiltinHookConfig;
|
||||
|
||||
/**
|
||||
* Hook definition with matcher
|
||||
@@ -78,6 +87,7 @@ export interface HookDefinition {
|
||||
*/
|
||||
export enum HookType {
|
||||
Command = 'command',
|
||||
Builtin = 'builtin',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,8 +95,9 @@ export enum HookType {
|
||||
*/
|
||||
export function getHookKey(hook: HookConfig): string {
|
||||
const name = hook.name || '';
|
||||
const command = hook.command || '';
|
||||
return `${name}:${command}`;
|
||||
const identifier =
|
||||
hook.type === HookType.Command ? hook.command : hook.builtin_id;
|
||||
return `${name}:${identifier}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SessionLearningsService } from './sessionLearningsService.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { GenerateContentResponse } from '@google/genai';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('SessionLearningsService', () => {
|
||||
let service: SessionLearningsService;
|
||||
let mockConfig: unknown;
|
||||
let mockRecordingService: any;
|
||||
let mockGeminiClient: any;
|
||||
let mockContentGenerator: any;
|
||||
let mockGenerateContent: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGenerateContent = vi.fn().mockImplementation((_params, promptId) => {
|
||||
if (promptId === 'session-learnings-generation') {
|
||||
return Promise.resolve({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: '# Session Learnings\nSummary text here.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
} else if (promptId === 'session-summary-generation') {
|
||||
return Promise.resolve({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Mock Session Title' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected promptId: ${promptId}`));
|
||||
});
|
||||
|
||||
mockContentGenerator = {
|
||||
generateContent: mockGenerateContent,
|
||||
};
|
||||
|
||||
mockRecordingService = {
|
||||
getConversation: vi.fn().mockReturnValue({
|
||||
messages: [
|
||||
{ type: 'user', content: [{ text: 'Question' }] },
|
||||
{ type: 'gemini', content: [{ text: 'Answer' }] },
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mockGeminiClient = {
|
||||
getChatRecordingService: () => mockRecordingService,
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
isSessionLearningsEnabled: vi.fn().mockReturnValue(true),
|
||||
getSessionLearningsOutputPath: vi.fn().mockReturnValue(undefined),
|
||||
getGeminiClient: () => mockGeminiClient,
|
||||
getContentGenerator: () => mockContentGenerator,
|
||||
getWorkingDir: () => '/mock/cwd',
|
||||
getActiveModel: () => 'gemini-1.5-flash',
|
||||
getModel: () => 'gemini-1.5-flash',
|
||||
isInteractive: () => true,
|
||||
setActiveModel: vi.fn(),
|
||||
getUserTier: () => 'free',
|
||||
getContentGeneratorConfig: () => ({ authType: 'apiKey' }),
|
||||
getModelAvailabilityService: () => ({
|
||||
selectFirstAvailable: (models: string[]) => ({
|
||||
selectedModel: models[0],
|
||||
}),
|
||||
consumeStickyAttempt: vi.fn(),
|
||||
markHealthy: vi.fn(),
|
||||
}),
|
||||
modelConfigService: {
|
||||
getResolvedConfig: vi
|
||||
.fn()
|
||||
.mockReturnValue({ model: 'gemini-1.5-flash', config: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
service = new SessionLearningsService(mockConfig as Config);
|
||||
|
||||
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
|
||||
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should generate and save learnings with descriptive filename', async () => {
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.join('/mock/cwd', `learnings-Mock-Session-Title-${dateStr}.md`),
|
||||
'# Session Learnings\nSummary text here.',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom output path if configured', async () => {
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
(mockConfig as any).getSessionLearningsOutputPath.mockReturnValue(
|
||||
'custom/path',
|
||||
);
|
||||
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||
path.join('/mock/cwd', 'custom/path'),
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.join(
|
||||
'/mock/cwd',
|
||||
'custom/path',
|
||||
`learnings-Mock-Session-Title-${dateStr}.md`,
|
||||
),
|
||||
'# Session Learnings\nSummary text here.',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use absolute output path if configured', async () => {
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
(mockConfig as any).getSessionLearningsOutputPath.mockReturnValue(
|
||||
'/absolute/path',
|
||||
);
|
||||
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith('/absolute/path', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.join('/absolute/path', `learnings-Mock-Session-Title-${dateStr}.md`),
|
||||
'# Session Learnings\nSummary text here.',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not generate learnings if disabled', async () => {
|
||||
(mockConfig as any).isSessionLearningsEnabled.mockReturnValue(false);
|
||||
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(mockGenerateContent).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not generate learnings if not enough messages', async () => {
|
||||
mockRecordingService.getConversation.mockReturnValue({
|
||||
messages: [{ type: 'user', content: [{ text: 'Single message' }] }],
|
||||
});
|
||||
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(mockGenerateContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockGenerateContent.mockRejectedValue(new Error('LLM Error'));
|
||||
|
||||
// Should not throw
|
||||
await expect(service.generateAndSaveLearnings()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { partListUnionToString } from '../core/geminiRequest.js';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
import { SessionSummaryService } from './sessionSummaryService.js';
|
||||
import { sanitizeFilenamePart } from '../utils/fileUtils.js';
|
||||
import type { Content } from '@google/genai';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const MIN_MESSAGES = 2;
|
||||
const TIMEOUT_MS = 60000; // Increased timeout for potentially larger context
|
||||
|
||||
const LEARNINGS_PROMPT = `It's time to pause on this development. Looking back at what you have done so far:
|
||||
Prepare a summary of the problem you were trying to solve, the analysis synthesized, and information you would need to implement this request if you were to start again
|
||||
Don't focus on unnecessary details - keep the abstraction at a level that allows a senior engineer for example, to take it from you.
|
||||
Do focus on gotchas, explored paths that didn't go anywhere with a why, and what you'd do differently.
|
||||
Also note down other issues you might have found as future project ideas.
|
||||
|
||||
Conversation transcript follows:
|
||||
---
|
||||
{transcript}
|
||||
---
|
||||
|
||||
Provide your response in Markdown format.`;
|
||||
|
||||
/**
|
||||
* Service to generate and save session learnings summaries.
|
||||
*/
|
||||
export class SessionLearningsService {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
/**
|
||||
* Generates a summary of the session learnings and saves it to a file.
|
||||
*/
|
||||
async generateAndSaveLearnings(): Promise<void> {
|
||||
try {
|
||||
// Check if enabled in settings
|
||||
if (!this.config.isSessionLearningsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geminiClient = this.config.getGeminiClient();
|
||||
const recordingService = geminiClient.getChatRecordingService();
|
||||
|
||||
if (!recordingService) {
|
||||
debugLogger.debug('[SessionLearnings] Recording service not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = recordingService.getConversation();
|
||||
|
||||
if (!conversation || conversation.messages.length < MIN_MESSAGES) {
|
||||
debugLogger.debug(
|
||||
`[SessionLearnings] Skipping summary, not enough messages (${conversation?.messages.length || 0})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare transcript (no max messages, no max length)
|
||||
const transcript = conversation.messages
|
||||
.map((msg) => {
|
||||
const role = msg.type === 'user' ? 'User' : 'Assistant';
|
||||
const content = partListUnionToString(msg.content);
|
||||
return `[${role}]: ${content}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
const prompt = LEARNINGS_PROMPT.replace('{transcript}', transcript);
|
||||
|
||||
const contentGenerator = this.config.getContentGenerator();
|
||||
if (!contentGenerator) {
|
||||
debugLogger.debug('[SessionLearnings] Content generator not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const baseLlmClient = new BaseLlmClient(contentGenerator, this.config);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
];
|
||||
|
||||
debugLogger.debug('[SessionLearnings] Generating summary...');
|
||||
const response = await baseLlmClient.generateContent({
|
||||
modelConfigKey: { model: 'summarizer-default' },
|
||||
contents,
|
||||
abortSignal: abortController.signal,
|
||||
promptId: 'session-learnings-generation',
|
||||
});
|
||||
|
||||
const summaryText = getResponseText(response);
|
||||
if (!summaryText) {
|
||||
debugLogger.warn(
|
||||
'[SessionLearnings] Failed to generate summary (empty response)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate descriptive filename
|
||||
const summaryService = new SessionSummaryService(baseLlmClient);
|
||||
const sessionTitle = await summaryService.generateSummary({
|
||||
messages: conversation.messages,
|
||||
});
|
||||
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const sanitizedTitle = sessionTitle
|
||||
? sanitizeFilenamePart(sessionTitle.trim().replace(/\s+/g, '-'))
|
||||
: 'untitled';
|
||||
|
||||
const fileName = `learnings-${sanitizedTitle}-${dateStr}.md`;
|
||||
|
||||
// Determine output directory
|
||||
const configOutputPath = this.config.getSessionLearningsOutputPath();
|
||||
let outputDir = this.config.getWorkingDir();
|
||||
|
||||
if (configOutputPath) {
|
||||
if (path.isAbsolute(configOutputPath)) {
|
||||
outputDir = configOutputPath;
|
||||
} else {
|
||||
outputDir = path.join(
|
||||
this.config.getWorkingDir(),
|
||||
configOutputPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(outputDir, fileName);
|
||||
await fs.writeFile(filePath, summaryText, 'utf-8');
|
||||
debugLogger.log(
|
||||
`[SessionLearnings] Saved session learnings to ${filePath}`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`[SessionLearnings] Error generating learnings: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { ToolDefinition } from './types.js';
|
||||
import { Type } from '@google/genai';
|
||||
import * as os from 'node:os';
|
||||
|
||||
// Centralized tool names to avoid circular dependencies
|
||||
@@ -24,21 +25,21 @@ export const READ_FILE_DEFINITION: ToolDefinition = {
|
||||
name: READ_FILE_TOOL_NAME,
|
||||
description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
file_path: {
|
||||
description: 'The path to the file to read.',
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
offset: {
|
||||
description:
|
||||
"Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.",
|
||||
type: 'number',
|
||||
type: Type.NUMBER,
|
||||
},
|
||||
limit: {
|
||||
description:
|
||||
"Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).",
|
||||
type: 'number',
|
||||
type: Type.NUMBER,
|
||||
},
|
||||
},
|
||||
required: ['file_path'],
|
||||
@@ -57,15 +58,15 @@ export const WRITE_FILE_DEFINITION: ToolDefinition = {
|
||||
|
||||
The user has the ability to modify \`content\`. If modified, this will be stated in the response.`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
file_path: {
|
||||
description: 'The path to the file to write to.',
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
content: {
|
||||
description: 'The content to write to the file.',
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
},
|
||||
required: ['file_path', 'content'],
|
||||
@@ -83,31 +84,31 @@ export const GREP_DEFINITION: ToolDefinition = {
|
||||
description:
|
||||
'Searches for a regular expression pattern within file contents. Max 100 matches.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
pattern: {
|
||||
description: `The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').`,
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
dir_path: {
|
||||
description:
|
||||
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
include: {
|
||||
description: `Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).`,
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
max_matches_per_file: {
|
||||
description:
|
||||
'Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.',
|
||||
type: 'integer',
|
||||
type: Type.INTEGER,
|
||||
minimum: 1,
|
||||
},
|
||||
total_max_matches: {
|
||||
description:
|
||||
'Optional: Maximum number of total matches to return. Use this to limit the overall size of the response. Defaults to 100 if omitted.',
|
||||
type: 'integer',
|
||||
type: Type.INTEGER,
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
@@ -126,32 +127,32 @@ export const GLOB_DEFINITION: ToolDefinition = {
|
||||
description:
|
||||
'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
pattern: {
|
||||
description:
|
||||
"The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').",
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
dir_path: {
|
||||
description:
|
||||
'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
case_sensitive: {
|
||||
description:
|
||||
'Optional: Whether the search should be case-sensitive. Defaults to false.',
|
||||
type: 'boolean',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
respect_git_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.',
|
||||
type: 'boolean',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
respect_gemini_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .geminiignore patterns when finding files. Defaults to true.',
|
||||
type: 'boolean',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
@@ -169,33 +170,33 @@ export const LS_DEFINITION: ToolDefinition = {
|
||||
description:
|
||||
'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
dir_path: {
|
||||
description: 'The path to the directory to list',
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
ignore: {
|
||||
description: 'List of glob patterns to ignore',
|
||||
items: {
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
type: 'array',
|
||||
type: Type.ARRAY,
|
||||
},
|
||||
file_filtering_options: {
|
||||
description:
|
||||
'Optional: Whether to respect ignore patterns from .gitignore or .geminiignore',
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
respect_git_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
|
||||
type: 'boolean',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
respect_gemini_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
|
||||
type: 'boolean',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -273,24 +274,24 @@ export function getShellDefinition(
|
||||
enableEfficiency,
|
||||
),
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
command: {
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
description: getCommandDescription(),
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
description:
|
||||
'Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.',
|
||||
},
|
||||
dir_path: {
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
description:
|
||||
'(OPTIONAL) The path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.',
|
||||
},
|
||||
is_background: {
|
||||
type: 'boolean',
|
||||
type: Type.BOOLEAN,
|
||||
description:
|
||||
'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user