Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/application/handlers/PromptSubmitHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,6 +45,7 @@ export class PromptSubmitHandler implements IPromptSubmitHandler {
memoryLimit: 20,
minWords: 5,
intentTimeout: 3000,
includeCommitMessages: true,
};

// Early exit if context surfacing is disabled
Expand All @@ -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;

Expand All @@ -79,13 +84,15 @@ export class PromptSubmitHandler implements IPromptSubmitHandler {
result = this.memoryContextLoader.load({
cwd: event.cwd,
limit: promptConfig.memoryLimit,
includeCommitMessages,
});
}
} else {
// No intent extraction, load recent memories
result = this.memoryContextLoader.load({
cwd: event.cwd,
limit: promptConfig.memoryLimit,
includeCommitMessages,
});
}

Expand All @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions src/application/services/ContextFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
}
Expand Down
48 changes: 44 additions & 4 deletions src/application/services/MemoryContextLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, ICommitMessage> | 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,
};
}

Expand Down Expand Up @@ -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<string, ICommitMessage> {
// Collect unique SHAs from memories
const shas = [...new Set(memories.map(m => m.sha).filter(Boolean))];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Use a type guard for proper type narrowing.

filter(Boolean) removes falsy values at runtime but TypeScript doesn't narrow the resulting type. If sha can be undefined, the array type remains (string | undefined)[] rather than string[].

🛡️ Proposed fix for type safety
-    const shas = [...new Set(memories.map(m => m.sha).filter(Boolean))];
+    const shas = [...new Set(memories.map(m => m.sha).filter((sha): sha is string => Boolean(sha)))];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const shas = [...new Set(memories.map(m => m.sha).filter(Boolean))];
const shas = [...new Set(memories.map(m => m.sha).filter((sha): sha is string => Boolean(sha)))];
🤖 Prompt for AI Agents
In `@src/application/services/MemoryContextLoader.ts` at line 71, The current
extraction in MemoryContextLoader that builds shas from memories uses
filter(Boolean) which doesn't narrow types; replace that filter with a proper
type guard so the resulting shas is string[] (for example use a predicate like
(s): s is string => s !== undefined && s !== null or (s): s is string =>
Boolean(s)). Update the expression that references shas (the const shas =
[...new Set(memories.map(m => m.sha).filter(Boolean))];) to use the type-guarded
filter so TypeScript knows shas contains only strings.


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();
}
}
}
3 changes: 3 additions & 0 deletions src/domain/interfaces/IContextFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'). */
Expand All @@ -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<string, ICommitMessage>;
}

export interface IContextFormatter {
Expand Down
8 changes: 8 additions & 0 deletions src/domain/interfaces/IGitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { subject: string; body: string }>;
}
4 changes: 3 additions & 1 deletion src/domain/interfaces/IHookConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions src/domain/interfaces/IMemoryContextLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, ICommitMessage>;
}

export interface IMemoryContextLoader {
Expand Down
1 change: 1 addition & 0 deletions src/hooks/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const DEFAULTS: IHookConfig = {
intentTimeout: 3000,
minWords: 5,
memoryLimit: 20,
includeCommitMessages: true,
},
postCommit: { enabled: true },
commitMsg: {
Expand Down
68 changes: 68 additions & 0 deletions src/infrastructure/git/GitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,4 +272,72 @@ export class GitClient implements IGitClient {
return [];
}
}

getCommitMessages(shas: readonly string[], cwd?: string): Map<string, { subject: string; body: string }> {
const result = new Map<string, { subject: string; body: string }>();

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: SHA<FS>subject<FS>body<RS>
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() });
Comment on lines +333 to +334
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The destructuring assignment only captures the first two elements when splitting on FIELD_SEP. If the commit body contains the FIELD_SEP character (ASCII 0x1F), the body will be truncated at that point. This should use the same index-based approach as the batch parsing to handle the body correctly: const fields = output.split(GitClient.FIELD_SEP); result.set(sha, { subject: fields[0] || '', body: fields.slice(1).join(GitClient.FIELD_SEP).trim() });

Suggested change
const [subject, body] = output.split(GitClient.FIELD_SEP);
result.set(sha, { subject: subject || '', body: (body || '').trim() });
const fields = output.split(GitClient.FIELD_SEP);
const subject = fields[0] || '';
const body = fields.slice(1).join(GitClient.FIELD_SEP).trim();
result.set(sha, { subject, body });

Copilot uses AI. Check for mistakes.
} catch {
// Skip commits that can't be found
}
}
}

return result;
}
Comment on lines +276 to +342
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Solid implementation with batch optimization and fallback.

The approach is sound: batch query with --no-walk for efficiency, deduplication via Set, and per-SHA fallback for resilience.

Minor edge case: If a commit body contains the field separator \x1f, the body would be truncated at line 333. This is extremely rare in practice (ASCII Unit Separator in commit messages), but for robustness you could use a limit parameter on split:

🔧 Optional fix for body containing field separator
-          const [subject, body] = output.split(GitClient.FIELD_SEP);
+          const parts = output.split(GitClient.FIELD_SEP);
+          const subject = parts[0] || '';
+          const body = parts.slice(1).join(GitClient.FIELD_SEP).trim();

The same pattern could be applied to the batch parsing at lines 310-313 if desired.

🤖 Prompt for AI Agents
In `@src/infrastructure/git/GitClient.ts` around lines 276 - 342, The parsing in
getCommitMessages can truncate commit bodies if they contain the FIELD_SEP
(GitClient.FIELD_SEP); change the splits to limit to three fields so only the
first two separators are honored (e.g., use split with a limit of 3) when
parsing both the batch records (records.map/for loop that assigns sha, subject,
body) and the per-commit fallback (where output is split into subject and body)
so the body retains any embedded separators; update references around
GitClient.RECORD_SEP, GitClient.FIELD_SEP, and the getCommitMessages parsing
logic accordingly.

}