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: 10 additions & 2 deletions src/application/services/MemoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export class MemoryService implements IMemoryService {
this.logger?.warn('Skipping trailer write for non-HEAD commit', { sha: targetSha });
} else {
try {
const trailers = this.buildTrailers(memory);
const trailers = this.buildTrailers(memory, options);
this.trailerService.addTrailers(trailers, options?.cwd);
this.logger?.info('Trailers written', { count: trailers.length, sha: memory.sha });
} catch (err) {
Expand All @@ -62,7 +62,7 @@ export class MemoryService implements IMemoryService {
return value.replace(/\r?\n+/g, ' ').trim();
}

private buildTrailers(memory: IMemoryEntity): ITrailer[] {
private buildTrailers(memory: IMemoryEntity, options?: ICreateMemoryOptions): ITrailer[] {
const trailers: ITrailer[] = [
{ key: MEMORY_TYPE_TO_TRAILER_KEY[memory.type], value: this.normalizeTrailerValue(memory.content) },
{ key: AI_TRAILER_KEYS.CONFIDENCE, value: memory.confidence },
Expand All @@ -73,6 +73,14 @@ export class MemoryService implements IMemoryService {
trailers.push({ key: AI_TRAILER_KEYS.TAGS, value: this.normalizeTrailerValue(memory.tags.join(', ')) });
}

if (options?.agent) {
trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: this.normalizeTrailerValue(options.agent) });
}

if (options?.model) {
trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: this.normalizeTrailerValue(options.model) });
}

return trailers;
}

Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ program
.option('--confidence <level>', 'Confidence: verified, high, medium, low', 'high')
.option('--lifecycle <tier>', 'Lifecycle: permanent, project, session', 'project')
.option('--tags <tags>', 'Comma-separated tags')
.option('--agent <name>', 'AI agent name (default: auto-detect from $GIT_MEM_AGENT / $CLAUDE_CODE)')
.option('--model <name>', 'AI model identifier (default: $GIT_MEM_MODEL)')
.option('--no-trailers', 'Skip writing AI-* trailers to the commit message')
.action((text, options) => rememberCommand(text, options, logger));

Expand Down
12 changes: 12 additions & 0 deletions src/commands/remember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,32 @@ interface IRememberOptions {
lifecycle?: string;
tags?: string;
noTrailers?: boolean;
agent?: string;
model?: string;
}

export async function rememberCommand(text: string, options: IRememberOptions, logger?: ILogger): Promise<void> {
const container = createContainer({ logger, scope: 'remember' });
const { memoryService, logger: log } = container.cradle;
log.info('Command invoked', { type: options.type || 'fact' });

// Resolve agent: explicit flag > $GIT_MEM_AGENT > $CLAUDE_CODE heuristic
const agent = options.agent
|| process.env.GIT_MEM_AGENT
|| (process.env.CLAUDE_CODE ? 'Claude-Code' : undefined);

// Resolve model: explicit flag > $GIT_MEM_MODEL
const model = options.model || process.env.GIT_MEM_MODEL || undefined;

const memory = memoryService.remember(text, {
sha: options.commit,
type: (options.type || 'fact') as MemoryType,
confidence: (options.confidence || 'high') as ConfidenceLevel,
lifecycle: (options.lifecycle || 'project') as MemoryLifecycle,
tags: options.tags,
trailers: !options.noTrailers,
agent,
model,
});

console.log(`Remembered: ${memory.content}`);
Expand Down
4 changes: 4 additions & 0 deletions src/domain/entities/IMemoryEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export interface ICreateMemoryOptions {
readonly cwd?: string;
/** Write AI-* trailers to the commit message (default: true). */
readonly trailers?: boolean;
/** AI agent name (e.g. 'Claude-Code'). */
readonly agent?: string;
/** AI model identifier (e.g. 'claude-opus-4-6'). */
readonly model?: string;
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/domain/entities/ITrailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const AI_TRAILER_KEYS = {
LIFECYCLE: 'AI-Lifecycle',
MEMORY_ID: 'AI-Memory-Id',
AGENT: 'AI-Agent',
MODEL: 'AI-Model',
} as const;

/**
Expand Down
51 changes: 42 additions & 9 deletions src/hooks/prepare-commit-msg.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/**
* prepare-commit-msg git hook
*
* Installs/uninstalls a git hook that injects AI-Agent trailers
* into commit messages when an AI-assisted session is detected.
* Installs/uninstalls a git hook that injects AI-Agent and AI-Model
* trailers into commit messages when an AI-assisted session is detected.
*
* Detection heuristics (checked in order):
* - $GIT_MEM_AGENT env var (explicit, user-defined agent string)
* - $CLAUDE_CODE env var (Claude Code session)
* - $GIT_MEM_MODEL env var (explicit, user-defined model string)
*
* The hook uses `git interpret-trailers` for proper formatting.
*/
Expand All @@ -15,16 +16,19 @@ import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, chmodS
import { join, resolve } from 'path';
import { execFileSync } from 'child_process';

/** Fingerprint comment used to detect our hook. */
const HOOK_FINGERPRINT = '# git-mem:prepare-commit-msg v1';
/** Prefix used to detect any version of our hook. */
const HOOK_FINGERPRINT_PREFIX = '# git-mem:prepare-commit-msg';

/** Full fingerprint with version — used for upgrade detection. */
const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v2`;

/**
* The shell hook script.
* Uses `git interpret-trailers` for correct trailer formatting.
*/
const HOOK_SCRIPT = `#!/bin/sh
${HOOK_FINGERPRINT}
# Injects AI-Agent trailer when an AI-assisted session is detected.
# Injects AI-Agent and AI-Model trailers when an AI-assisted session is detected.

COMMIT_MSG_FILE="$1"
COMMIT_SOURCE="$2"
Expand All @@ -42,14 +46,26 @@ elif [ -n "$CLAUDE_CODE" ]; then
AGENT="Claude-Code"
fi

# Detect AI model
MODEL=""
if [ -n "$GIT_MEM_MODEL" ]; then
MODEL="$GIT_MEM_MODEL"
fi

# No agent detected — exit silently
[ -z "$AGENT" ] && exit 0

# Skip if AI-Agent trailer already present
grep -q "^AI-Agent:" "$COMMIT_MSG_FILE" && exit 0

# Append trailer using git's built-in formatter
# Append agent trailer using git's built-in formatter
git interpret-trailers --in-place --trailer "AI-Agent: $AGENT" "$COMMIT_MSG_FILE"

# Append model trailer if detected
if [ -n "$MODEL" ]; then
grep -q "^AI-Model:" "$COMMIT_MSG_FILE" ||
git interpret-trailers --in-place --trailer "AI-Model: $MODEL" "$COMMIT_MSG_FILE"
fi
`;

/**
Expand All @@ -70,9 +86,18 @@ function findGitDir(cwd: string): string {
}

/**
* Check if an existing hook file was installed by git-mem.
* Check if an existing hook file was installed by git-mem (any version).
*/
function isGitMemHook(hookPath: string): boolean {
if (!existsSync(hookPath)) return false;
const content = readFileSync(hookPath, 'utf8');
return content.includes(HOOK_FINGERPRINT_PREFIX);
}

/**
* Check if an installed hook is the current version.
*/
function isCurrentVersion(hookPath: string): boolean {
if (!existsSync(hookPath)) return false;
const content = readFileSync(hookPath, 'utf8');
return content.includes(HOOK_FINGERPRINT);
Expand All @@ -91,18 +116,26 @@ export interface IHookInstallResult {
* Install the prepare-commit-msg hook.
* Idempotent: re-running is safe.
* Wraps existing non-git-mem hooks by renaming them to .user-backup.
* Upgrades outdated git-mem hooks in-place.
*/
export function installHook(cwd?: string): IHookInstallResult {
const gitDir = findGitDir(cwd || process.cwd());
const hooksDir = join(gitDir, 'hooks');
const hookPath = join(hooksDir, 'prepare-commit-msg');
const backupPath = join(hooksDir, 'prepare-commit-msg.user-backup');

// Already installed — idempotent
if (isGitMemHook(hookPath)) {
// Already installed and up-to-date — idempotent
if (isGitMemHook(hookPath) && isCurrentVersion(hookPath)) {
return { installed: false, wrapped: false, hookPath };
}

// Outdated git-mem hook — upgrade in-place
if (isGitMemHook(hookPath) && !isCurrentVersion(hookPath)) {
writeFileSync(hookPath, HOOK_SCRIPT);
chmodSync(hookPath, 0o755);
return { installed: true, wrapped: false, hookPath };
}

let wrapped = false;

// Existing non-git-mem hook — wrap it
Expand Down
12 changes: 12 additions & 0 deletions src/mcp/tools/remember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export function registerRememberTool(server: McpServer): void {
confidence: z.enum(['verified', 'high', 'medium', 'low']).optional().describe('Confidence level (default: high)'),
tags: z.string().optional().describe('Comma-separated tags'),
lifecycle: z.enum(['permanent', 'project', 'session']).optional().describe('Lifecycle tier (default: project)'),
agent: z.string().optional().describe('AI agent name (default: auto-detect from $GIT_MEM_AGENT / $CLAUDE_CODE)'),
model: z.string().optional().describe('AI model identifier (default: $GIT_MEM_MODEL)'),
trailers: z.boolean().optional().describe('Write AI-* trailers to commit message (default: true)'),
},
async (args) => {
Expand All @@ -30,13 +32,23 @@ export function registerRememberTool(server: McpServer): void {
try {
logger.info('Tool invoked', { type: args.type || 'fact' });

// Resolve agent: explicit param > $GIT_MEM_AGENT > $CLAUDE_CODE heuristic
const agent = args.agent
|| process.env.GIT_MEM_AGENT
|| (process.env.CLAUDE_CODE ? 'Claude-Code' : undefined);

// Resolve model: explicit param > $GIT_MEM_MODEL
const model = args.model || process.env.GIT_MEM_MODEL || undefined;

const memory = memoryService.remember(args.text, {
sha: args.commit,
type: (args.type || 'fact') as MemoryType,
confidence: (args.confidence || 'high') as ConfidenceLevel,
lifecycle: (args.lifecycle || 'project') as MemoryLifecycle,
tags: args.tags,
trailers: args.trailers,
agent,
model,
});

return {
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/application/services/MemoryService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,38 @@ describe('MemoryService', () => {
const trailers = trailerService.readTrailers('HEAD', repoDir);
assert.ok(!trailers.find(t => t.key === 'AI-Tags'));
});

it('should write AI-Agent and AI-Model trailers when provided', () => {
writeFileSync(join(repoDir, 'agent-model.txt'), 'agent-model');
git(['add', '.'], repoDir);
git(['commit', '-m', 'feat: agent model test'], repoDir);

serviceWithTrailers.remember('Memory with agent and model', {
cwd: repoDir,
type: 'fact',
agent: 'Claude-Code',
model: 'claude-opus-4-6',
});

const trailers = trailerService.readTrailers('HEAD', repoDir);
assert.ok(trailers.find(t => t.key === 'AI-Agent' && t.value === 'Claude-Code'));
assert.ok(trailers.find(t => t.key === 'AI-Model' && t.value === 'claude-opus-4-6'));
});

it('should not write AI-Agent or AI-Model trailers when not provided', () => {
writeFileSync(join(repoDir, 'no-agent-model.txt'), 'no-agent');
git(['add', '.'], repoDir);
git(['commit', '-m', 'feat: no agent model test'], repoDir);

serviceWithTrailers.remember('Memory without agent', {
cwd: repoDir,
type: 'fact',
});

const trailers = trailerService.readTrailers('HEAD', repoDir);
assert.ok(!trailers.find(t => t.key === 'AI-Agent'));
assert.ok(!trailers.find(t => t.key === 'AI-Model'));
});
});

describe('recall', () => {
Expand Down
82 changes: 80 additions & 2 deletions tests/unit/hooks/prepare-commit-msg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('installHook', () => {

const content = readFileSync(result.hookPath, 'utf8');
assert.ok(content.includes('#!/bin/sh'));
assert.ok(content.includes('git-mem:prepare-commit-msg v1'));
assert.ok(content.includes('git-mem:prepare-commit-msg v2'));
assert.ok(content.includes('git interpret-trailers'));
assert.ok(content.includes('AI-Agent'));
});
Expand Down Expand Up @@ -75,12 +75,44 @@ describe('installHook', () => {

// Installed hook should contain both fingerprint and wrapper reference
const content = readFileSync(hookPath, 'utf8');
assert.ok(content.includes('git-mem:prepare-commit-msg v1'));
assert.ok(content.includes('git-mem:prepare-commit-msg v2'));
assert.ok(content.includes('user-backup'));
} finally {
rmSync(freshRepo, { recursive: true, force: true });
}
});

it('should upgrade v1 hook to v2 on reinstall', () => {
const freshRepo = mkdtempSync(join(tmpdir(), 'git-mem-hook-upgrade-'));
git(['init'], freshRepo);

const hooksDir = join(freshRepo, '.git', 'hooks');
const hookPath = join(hooksDir, 'prepare-commit-msg');

// Write a v1 hook (old fingerprint, no AI-Model support)
const v1Hook = '#!/bin/sh\n# git-mem:prepare-commit-msg v1\n# Old hook without AI-Model\nexit 0\n';
mkdirSync(hooksDir, { recursive: true });
writeFileSync(hookPath, v1Hook);
chmodSync(hookPath, 0o755);

try {
const result = installHook(freshRepo);

// Should have upgraded in-place
assert.equal(result.installed, true);
assert.equal(result.wrapped, false);

const content = readFileSync(hookPath, 'utf8');
assert.ok(content.includes('git-mem:prepare-commit-msg v2'), 'Should be upgraded to v2');
assert.ok(content.includes('AI-Model'), 'Should include AI-Model support');

// Second install should be idempotent
const result2 = installHook(freshRepo);
assert.equal(result2.installed, false);
} finally {
rmSync(freshRepo, { recursive: true, force: true });
}
});
});

describe('uninstallHook', () => {
Expand Down Expand Up @@ -245,6 +277,52 @@ describe('hook integration — commit message modification', () => {
assert.ok(message.includes('ExistingAgent'));
});

it('should add AI-Model trailer when GIT_MEM_MODEL is set', () => {
writeFileSync(join(repoDir, 'model-test.txt'), 'model');
git(['add', '.'], repoDir);

execFileSync('git', ['commit', '-m', 'feat: model trailer test'], {
encoding: 'utf8',
cwd: repoDir,
env: { ...process.env, GIT_MEM_AGENT: 'TestAgent', GIT_MEM_MODEL: 'claude-opus-4-6' },
});

const message = git(['log', '-1', '--format=%B'], repoDir);
assert.ok(message.includes('AI-Model: claude-opus-4-6'), `Expected AI-Model trailer in: ${message}`);
});

it('should not add AI-Model trailer when GIT_MEM_MODEL is not set', () => {
writeFileSync(join(repoDir, 'no-model.txt'), 'no-model');
git(['add', '.'], repoDir);

const cleanEnv = { ...process.env, GIT_MEM_AGENT: 'TestAgent' };
delete cleanEnv.GIT_MEM_MODEL;

execFileSync('git', ['commit', '-m', 'feat: no model test'], {
encoding: 'utf8',
cwd: repoDir,
env: cleanEnv,
});

const message = git(['log', '-1', '--format=%B'], repoDir);
assert.ok(!message.includes('AI-Model:'), `Should not have AI-Model trailer in: ${message}`);
});

it('should add both AI-Agent and AI-Model when both env vars set', () => {
writeFileSync(join(repoDir, 'both-trailers.txt'), 'both');
git(['add', '.'], repoDir);

execFileSync('git', ['commit', '-m', 'feat: both trailers test'], {
encoding: 'utf8',
cwd: repoDir,
env: { ...process.env, GIT_MEM_AGENT: 'Claude-Code', GIT_MEM_MODEL: 'claude-opus-4-6' },
});

const message = git(['log', '-1', '--format=%B'], repoDir);
assert.ok(message.includes('AI-Agent: Claude-Code'), `Expected AI-Agent trailer in: ${message}`);
assert.ok(message.includes('AI-Model: claude-opus-4-6'), `Expected AI-Model trailer in: ${message}`);
});

it('should skip merge commits', () => {
// Determine the default branch name before creating feature branch
const defaultBranch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repoDir);
Expand Down
Loading