From 0116693e1bb90fa22fb1d3adceb408488d6120c1 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sun, 15 Feb 2026 00:56:01 +0000 Subject: [PATCH] feat: include commit message bodies in memory context (GIT-104) Add commit subject and body alongside memories in prompt-submit output. When memories are loaded, their associated commit messages are fetched and displayed inline, giving the AI richer context about past decisions. - Add getCommitMessages() to IGitClient for batch commit fetching - Extend IMemoryContextLoader result with commitMessages map - Update ContextFormatter to render commit subjects/bodies inline - Add includeCommitMessages config option (default: true) Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Decision: include commit message bodies in memory context (GIT-104). Add commit subject and body alongside memories in prompt-submit output. AI-Confidence: medium AI-Tags: application, handlers, services, domain, hooks, utils, infrastructure AI-Lifecycle: project AI-Memory-Id: c78152dc AI-Source: heuristic --- .../handlers/PromptSubmitHandler.ts | 12 +++- src/application/services/ContextFormatter.ts | 17 +++++ .../services/MemoryContextLoader.ts | 48 +++++++++++-- src/domain/interfaces/IContextFormatter.ts | 3 + src/domain/interfaces/IGitClient.ts | 8 +++ src/domain/interfaces/IHookConfig.ts | 4 +- src/domain/interfaces/IMemoryContextLoader.ts | 10 +++ src/hooks/utils/config.ts | 1 + src/infrastructure/git/GitClient.ts | 68 +++++++++++++++++++ 9 files changed, 165 insertions(+), 6 deletions(-) diff --git a/src/application/handlers/PromptSubmitHandler.ts b/src/application/handlers/PromptSubmitHandler.ts index 52bb05be..c645da67 100644 --- a/src/application/handlers/PromptSubmitHandler.ts +++ b/src/application/handlers/PromptSubmitHandler.ts @@ -8,6 +8,7 @@ * from the prompt and queries memories with those keywords. * Falls back to loading recent memories if extraction is skipped * or no keywords are found. + * Optionally includes commit message bodies for additional context. */ import type { IPromptSubmitHandler } from '../interfaces/IPromptSubmitHandler'; @@ -44,6 +45,7 @@ export class PromptSubmitHandler implements IPromptSubmitHandler { memoryLimit: 20, minWords: 5, intentTimeout: 3000, + includeCommitMessages: true, }; // Early exit if context surfacing is disabled @@ -56,6 +58,9 @@ export class PromptSubmitHandler implements IPromptSubmitHandler { }; } + // Determine includeCommitMessages setting + const includeCommitMessages = promptConfig.includeCommitMessages ?? true; + // Try intent extraction if enabled let result: IMemoryContextResult; @@ -79,6 +84,7 @@ export class PromptSubmitHandler implements IPromptSubmitHandler { result = this.memoryContextLoader.load({ cwd: event.cwd, limit: promptConfig.memoryLimit, + includeCommitMessages, }); } } else { @@ -86,6 +92,7 @@ export class PromptSubmitHandler implements IPromptSubmitHandler { result = this.memoryContextLoader.load({ cwd: event.cwd, limit: promptConfig.memoryLimit, + includeCommitMessages, }); } @@ -98,11 +105,14 @@ export class PromptSubmitHandler implements IPromptSubmitHandler { }; } - const output = this.contextFormatter.format(result.memories); + const output = this.contextFormatter.format(result.memories, { + commitMessages: result.commitMessages, + }); this.logger?.info('Memories loaded for prompt context', { total: result.total, filtered: result.filtered, + hasCommitMessages: !!result.commitMessages, }); return { diff --git a/src/application/services/ContextFormatter.ts b/src/application/services/ContextFormatter.ts index 725fe2bf..566f5bfa 100644 --- a/src/application/services/ContextFormatter.ts +++ b/src/application/services/ContextFormatter.ts @@ -56,6 +56,23 @@ export class ContextFormatter implements IContextFormatter { for (const item of items) { const date = item.createdAt.slice(0, 10); // YYYY-MM-DD sections.push(`- ${item.content} (${date})`); + + // Include commit message if available + if (options?.commitMessages && item.sha) { + const commit = options.commitMessages.get(item.sha); + if (commit) { + sections.push(` > Commit: ${commit.subject}`); + if (commit.body) { + // Indent body lines and limit length + const bodyLines = commit.body.split('\n').slice(0, 3); // Max 3 lines + for (const line of bodyLines) { + if (line.trim()) { + sections.push(` > ${line.trim()}`); + } + } + } + } + } } sections.push(''); } diff --git a/src/application/services/MemoryContextLoader.ts b/src/application/services/MemoryContextLoader.ts index 47e1f9ae..47852e7c 100644 --- a/src/application/services/MemoryContextLoader.ts +++ b/src/application/services/MemoryContextLoader.ts @@ -4,18 +4,22 @@ * Loads and filters memories for hook context injection. * Delegates to IMemoryRepository for general loads and * IMemoryService for query-based searches (notes + trailers). + * Optionally fetches commit messages for loaded memories. */ -import type { IMemoryContextLoader, IMemoryContextOptions, IMemoryContextResult } from '../../domain/interfaces/IMemoryContextLoader'; +import type { IMemoryContextLoader, IMemoryContextOptions, IMemoryContextResult, ICommitMessage } from '../../domain/interfaces/IMemoryContextLoader'; import type { IMemoryRepository } from '../../domain/interfaces/IMemoryRepository'; import type { IMemoryService } from '../interfaces/IMemoryService'; +import type { IGitClient } from '../../domain/interfaces/IGitClient'; import type { ILogger } from '../../domain/interfaces/ILogger'; +import type { IMemoryEntity } from '../../domain/entities/IMemoryEntity'; export class MemoryContextLoader implements IMemoryContextLoader { constructor( private readonly memoryRepository: IMemoryRepository, private readonly logger?: ILogger, private readonly memoryService?: IMemoryService, + private readonly gitClient?: IGitClient, ) {} load(options?: IMemoryContextOptions): IMemoryContextResult { @@ -37,16 +41,26 @@ export class MemoryContextLoader implements IMemoryContextLoader { cwd: options?.cwd, }); + const memories = result.memories as readonly IMemoryEntity[]; + + // Fetch commit messages if requested + let commitMessages: ReadonlyMap | undefined; + if (options?.includeCommitMessages && this.gitClient && memories.length > 0) { + commitMessages = this.fetchCommitMessages(memories, options.cwd); + } + this.logger?.debug('Memories loaded for context', { total, - filtered: result.memories.length, + filtered: memories.length, limit: options?.limit, + hasCommitMessages: !!commitMessages, }); return { - memories: result.memories as readonly import('../../domain/entities/IMemoryEntity').IMemoryEntity[], + memories, total, - filtered: result.memories.length, + filtered: memories.length, + commitMessages, }; } @@ -77,4 +91,30 @@ export class MemoryContextLoader implements IMemoryContextLoader { filtered: result.memories.length, }; } + + /** + * Fetch commit messages for all memories in a single batch call. + */ + private fetchCommitMessages( + memories: readonly IMemoryEntity[], + cwd?: string, + ): ReadonlyMap { + // Collect unique SHAs from memories + const shas = [...new Set(memories.map(m => m.sha).filter(Boolean))]; + + if (shas.length === 0 || !this.gitClient) { + return new Map(); + } + + try { + const messages = this.gitClient.getCommitMessages(shas, cwd); + this.logger?.debug('Fetched commit messages', { count: messages.size }); + return messages; + } catch (error) { + this.logger?.warn('Failed to fetch commit messages', { + error: error instanceof Error ? error.message : String(error), + }); + return new Map(); + } + } } diff --git a/src/domain/interfaces/IContextFormatter.ts b/src/domain/interfaces/IContextFormatter.ts index 3a0590dc..8fe04c74 100644 --- a/src/domain/interfaces/IContextFormatter.ts +++ b/src/domain/interfaces/IContextFormatter.ts @@ -6,6 +6,7 @@ */ import type { IMemoryEntity } from '../entities/IMemoryEntity'; +import type { ICommitMessage } from './IMemoryContextLoader'; export interface IFormatOptions { /** How the session was triggered (e.g., 'startup', 'resume'). */ @@ -14,6 +15,8 @@ export interface IFormatOptions { readonly includeStats?: boolean; /** Maximum output length in characters. */ readonly maxLength?: number; + /** Commit messages keyed by SHA, to include with memories. */ + readonly commitMessages?: ReadonlyMap; } export interface IContextFormatter { diff --git a/src/domain/interfaces/IGitClient.ts b/src/domain/interfaces/IGitClient.ts index 1610b7fa..6d9e8a49 100644 --- a/src/domain/interfaces/IGitClient.ts +++ b/src/domain/interfaces/IGitClient.ts @@ -192,4 +192,12 @@ export interface IGitClient { * @returns Array of file paths. */ diffStagedNames(cwd?: string): string[]; + + /** + * Get commit messages for multiple SHAs in a single batch call. + * @param shas - Array of commit SHAs. + * @param cwd - Working directory. + * @returns Map of SHA to commit message (subject + body). + */ + getCommitMessages(shas: readonly string[], cwd?: string): Map; } diff --git a/src/domain/interfaces/IHookConfig.ts b/src/domain/interfaces/IHookConfig.ts index 751862f5..7b767db1 100644 --- a/src/domain/interfaces/IHookConfig.ts +++ b/src/domain/interfaces/IHookConfig.ts @@ -31,12 +31,14 @@ export interface IPromptSubmitConfig { readonly surfaceContext: boolean; /** Enable LLM-based intent extraction for smarter memory retrieval. */ readonly extractIntent: boolean; - /** Timeout in ms for intent extraction LLM call. Default: 3000. */ + /** Timeout in ms for intent extraction LLM call. Default: 3000. Must be under hook timeout (10s). */ readonly intentTimeout: number; /** Minimum word count to trigger intent extraction. Default: 5. */ readonly minWords: number; /** Maximum memories to return. Default: 20. */ readonly memoryLimit: number; + /** Include commit message bodies with memories. Default: true. */ + readonly includeCommitMessages: boolean; } export interface IPostCommitConfig { diff --git a/src/domain/interfaces/IMemoryContextLoader.ts b/src/domain/interfaces/IMemoryContextLoader.ts index eb7d4baa..318c7532 100644 --- a/src/domain/interfaces/IMemoryContextLoader.ts +++ b/src/domain/interfaces/IMemoryContextLoader.ts @@ -16,6 +16,14 @@ export interface IMemoryContextOptions { readonly tags?: string[]; /** Working directory for git operations. */ readonly cwd?: string; + /** Include commit message bodies with memories. */ + readonly includeCommitMessages?: boolean; +} + +/** Commit message data. */ +export interface ICommitMessage { + readonly subject: string; + readonly body: string; } export interface IMemoryContextResult { @@ -25,6 +33,8 @@ export interface IMemoryContextResult { readonly total: number; /** Number returned after filtering. */ readonly filtered: number; + /** Commit messages keyed by SHA. Present when includeCommitMessages is true. */ + readonly commitMessages?: ReadonlyMap; } export interface IMemoryContextLoader { diff --git a/src/hooks/utils/config.ts b/src/hooks/utils/config.ts index 85d13bad..125a9f6c 100644 --- a/src/hooks/utils/config.ts +++ b/src/hooks/utils/config.ts @@ -50,6 +50,7 @@ const DEFAULTS: IHookConfig = { intentTimeout: 3000, minWords: 5, memoryLimit: 20, + includeCommitMessages: true, }, postCommit: { enabled: true }, commitMsg: { diff --git a/src/infrastructure/git/GitClient.ts b/src/infrastructure/git/GitClient.ts index 53047309..92b67eaf 100644 --- a/src/infrastructure/git/GitClient.ts +++ b/src/infrastructure/git/GitClient.ts @@ -272,4 +272,72 @@ export class GitClient implements IGitClient { return []; } } + + getCommitMessages(shas: readonly string[], cwd?: string): Map { + const result = new Map(); + + if (shas.length === 0) return result; + + // Deduplicate SHAs + const uniqueShas = [...new Set(shas)]; + + // Use git log with specific SHAs to fetch all at once + // Format: SHAsubjectbody + const format = ['%H', '%s', '%b'].join(GitClient.FIELD_SEP); + + try { + // We use --no-walk to avoid following parents, just show the specified commits + const output = execFileSync( + 'git', + [ + 'log', + '--no-walk', + `--format=${GitClient.RECORD_SEP}${format}`, + ...uniqueShas, + ], + { + encoding: 'utf8', + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + } + ).trim(); + + if (!output) return result; + + const records = output.split(GitClient.RECORD_SEP).filter(r => r.trim()); + for (const record of records) { + const fields = record.split(GitClient.FIELD_SEP); + const sha = fields[0] || ''; + const subject = fields[1] || ''; + const body = (fields[2] || '').trim(); + + if (sha) { + result.set(sha, { subject, body }); + } + } + } catch { + // If batch fails, try individual lookups as fallback + for (const sha of uniqueShas) { + try { + const output = execFileSync( + 'git', + ['log', '-1', `--format=%s${GitClient.FIELD_SEP}%b`, sha], + { + encoding: 'utf8', + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + } + ).trim(); + + const [subject, body] = output.split(GitClient.FIELD_SEP); + result.set(sha, { subject: subject || '', body: (body || '').trim() }); + } catch { + // Skip commits that can't be found + } + } + } + + return result; + } }