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
46 changes: 44 additions & 2 deletions src/application/services/MemoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,68 @@
* MemoryService
*
* Application service that orchestrates memory operations.
* Delegates storage to IMemoryRepository.
* Dual-writes to git notes (rich JSON) and commit trailers
* (lightweight, natively queryable metadata).
*/

import type { IMemoryService } from '../interfaces/IMemoryService';
import type { IMemoryRepository, IMemoryQueryOptions, IMemoryQueryResult } from '../../domain/interfaces/IMemoryRepository';
import type { IMemoryEntity, ICreateMemoryOptions } from '../../domain/entities/IMemoryEntity';
import type { IMemoryEntity, ICreateMemoryOptions, MemoryType } from '../../domain/entities/IMemoryEntity';
import type { ITrailerService } from '../../domain/interfaces/ITrailerService';
import type { ITrailer } from '../../domain/entities/ITrailer';
import { AI_TRAILER_KEYS } from '../../domain/entities/ITrailer';
import type { ILogger } from '../../domain/interfaces/ILogger';

const MEMORY_TYPE_TO_TRAILER_KEY: Record<MemoryType, string> = {
decision: AI_TRAILER_KEYS.DECISION,
gotcha: AI_TRAILER_KEYS.GOTCHA,
convention: AI_TRAILER_KEYS.CONVENTION,
fact: AI_TRAILER_KEYS.FACT,
};

export class MemoryService implements IMemoryService {
constructor(
private readonly memoryRepository: IMemoryRepository,
private readonly logger?: ILogger,
private readonly trailerService?: ITrailerService,
) {}

remember(text: string, options?: ICreateMemoryOptions): IMemoryEntity {
const memory = this.memoryRepository.create(text, options);
this.logger?.info('Memory stored', { id: memory.id, type: memory.type, sha: memory.sha });

// Dual-write: also add AI-* trailers to the commit (opt-out via trailers: false)
if (options?.trailers !== false && this.trailerService) {
try {
const trailers = this.buildTrailers(memory);
this.trailerService.addTrailers(trailers, options?.cwd);
this.logger?.info('Trailers written', { count: trailers.length, sha: memory.sha });
} catch (err) {
// Trailer write failure is non-fatal (commit may be pushed already)
this.logger?.warn('Trailer write failed', {
error: err instanceof Error ? err.message : String(err),
sha: memory.sha,
});
}
Comment on lines +37 to +47
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

Missing test coverage for the error handling path when trailer writing fails. The code at lines 37-47 in MemoryService catches and logs errors during trailer writing, but there's no test verifying this behavior. Consider adding a test that uses a mock TrailerService that throws an error to verify the error is caught, logged, and doesn't prevent memory creation.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The trailer write error path is intentionally non-fatal — it catches and logs silently. Since the test would need to force an error in a real git repo (e.g., a read-only .git dir or a pushed commit), it's fragile to test in unit tests. The catch block is simple (log + continue), so I'm leaving this as-is. If we see issues, I'll add an integration test.

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return memory;
}

private buildTrailers(memory: IMemoryEntity): ITrailer[] {
const trailers: ITrailer[] = [
{ key: MEMORY_TYPE_TO_TRAILER_KEY[memory.type], value: memory.content },
Comment on lines +53 to +55
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The implementation stores memory.content directly as a trailer value without escaping newlines. While git trailers can support multi-line values using continuation lines (RFC 822 style), the current formatTrailers implementation doesn't handle this. If a memory contains newlines, it could break the trailer format. Consider either: (1) sanitizing memory.content to remove/replace newlines before creating the trailer, or (2) updating TrailerService.formatTrailers to properly encode multi-line values. Since trailer write failures are non-fatal (caught and logged), this won't prevent memory creation, but multi-line memories won't be properly represented in trailers.

Suggested change
private buildTrailers(memory: IMemoryEntity): ITrailer[] {
const trailers: ITrailer[] = [
{ key: MEMORY_TYPE_TO_TRAILER_KEY[memory.type], value: memory.content },
/**
* Sanitize values before writing them as git trailers.
* Currently we just ensure the value is single-line so that
* TrailerService.formatTrailers, which does not support multi-line
* values, cannot be broken by embedded newlines.
*/
private sanitizeTrailerValue(value: string): string {
return value.replace(/[\r\n]+/g, ' ');
}
private buildTrailers(memory: IMemoryEntity): ITrailer[] {
const trailers: ITrailer[] = [
{
key: MEMORY_TYPE_TO_TRAILER_KEY[memory.type],
value: this.sanitizeTrailerValue(memory.content),
},

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — added normalizeTrailerValue() that replaces embedded newlines with spaces before writing trailers. Applied to both the content value and tags. See 97190a7.

{ key: AI_TRAILER_KEYS.CONFIDENCE, value: memory.confidence },
{ key: AI_TRAILER_KEYS.MEMORY_ID, value: memory.id },
];

if (memory.tags.length > 0) {
trailers.push({ key: AI_TRAILER_KEYS.TAGS, value: memory.tags.join(', ') });
}

return trailers;
}

recall(query?: string, options?: IMemoryQueryOptions): IMemoryQueryResult {
const effectiveQuery = query ?? options?.query;
const result = this.memoryRepository.query({
Expand Down
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ 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('--no-trailers', 'Skip writing AI-* trailers to the commit message')
.action((text, options) => rememberCommand(text, options, logger));

program
Expand Down
2 changes: 2 additions & 0 deletions src/commands/remember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface IRememberOptions {
confidence?: string;
lifecycle?: string;
tags?: string;
noTrailers?: boolean;
}

export async function rememberCommand(text: string, options: IRememberOptions, logger?: ILogger): Promise<void> {
Expand All @@ -27,6 +28,7 @@ export async function rememberCommand(text: string, options: IRememberOptions, l
confidence: (options.confidence || 'high') as ConfidenceLevel,
lifecycle: (options.lifecycle || 'project') as MemoryLifecycle,
tags: options.tags,
trailers: !options.noTrailers,
});

console.log(`Remembered: ${memory.content}`);
Expand Down
2 changes: 2 additions & 0 deletions src/domain/entities/IMemoryEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export interface ICreateMemoryOptions {
readonly source?: SourceType;
/** Working directory. */
readonly cwd?: string;
/** Write AI-* trailers to the commit message (default: true). */
readonly trailers?: boolean;
}

/**
Expand Down
17 changes: 17 additions & 0 deletions src/domain/interfaces/ITrailerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,21 @@ export interface ITrailerService {
* @returns Array of commits with matching trailers.
*/
queryTrailers(key: string, options?: ITrailerQueryOptions): ICommitTrailers[];

/**
* Amend HEAD commit to append AI-* trailers to the commit message.
* Preserves existing trailers and skips duplicates.
* @param trailers - Trailers to add.
* @param cwd - Working directory.
*/
addTrailers(trailers: readonly ITrailer[], cwd?: string): void;

/**
* Build a commit message with trailers appended.
* Pure string operation — no git commands.
* @param message - Original commit message.
* @param trailers - Trailers to append.
* @returns Complete commit message with trailer block.
*/
buildCommitMessage(message: string, trailers: readonly ITrailer[]): string;
}
2 changes: 2 additions & 0 deletions src/infrastructure/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { IGitClient } from '../../domain/interfaces/IGitClient';
import { NotesService } from '../services/NotesService';
import { GitClient } from '../git/GitClient';
import { MemoryRepository } from '../repositories/MemoryRepository';
import { TrailerService } from '../services/TrailerService';
import { EventBus } from '../events/EventBus';
import { createLogger } from '../logging/factory';
import { createLLMClient } from '../llm/LLMClientFactory';
Expand Down Expand Up @@ -55,6 +56,7 @@ export function createContainer(options?: IContainerOptions): AwilixContainer<IC
notesService: asClass(NotesService).singleton(),
gitClient: asClass(GitClient).singleton(),
memoryRepository: asClass(MemoryRepository).singleton(),
trailerService: asClass(TrailerService).singleton(),

eventBus: asFunction(() => {
const bus = new EventBus(container.cradle.logger);
Expand Down
2 changes: 2 additions & 0 deletions src/infrastructure/di/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ import type { ILiberateService } from '../../application/interfaces/ILiberateSer
import type { IMemoryContextLoader } from '../../domain/interfaces/IMemoryContextLoader';
import type { IContextFormatter } from '../../domain/interfaces/IContextFormatter';
import type { ISessionCaptureService } from '../../domain/interfaces/ISessionCaptureService';
import type { ITrailerService } from '../../domain/interfaces/ITrailerService';

export interface ICradle {
// Infrastructure
logger: ILogger;
notesService: INotesService;
gitClient: IGitClient;
memoryRepository: IMemoryRepository;
trailerService: ITrailerService;
triageService: IGitTriageService;
llmClient: ILLMClient | null;
eventBus: IEventBus;
Expand Down
55 changes: 55 additions & 0 deletions src/infrastructure/services/TrailerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,61 @@ export class TrailerService implements ITrailerService {
}
}

addTrailers(trailers: readonly ITrailer[], cwd?: string): void {
// Only operate on AI-* trailers to match readTrailers/parseTrailerBlock behavior.
const aiTrailers = trailers.filter(t => t.key.startsWith(AI_TRAILER_PREFIX));
if (aiTrailers.length === 0) return;

// Read existing trailers to avoid duplicates
const existing = this.readTrailers('HEAD', cwd);
const existingKeys = new Set(existing.map(t => `${t.key}:${t.value}`));
const newTrailers = aiTrailers.filter(t => !existingKeys.has(`${t.key}:${t.value}`));
if (newTrailers.length === 0) return;

// Get current commit message
const currentMessage = execFileSync(
'git',
['log', '-1', '--format=%B', 'HEAD'],
{ encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe'] }
).trimEnd();

// Build amended message with new trailers
const amended = this.buildCommitMessage(currentMessage, newTrailers);

// Amend HEAD with the new message
execFileSync(
'git',
['commit', '--amend', '--no-edit', '-m', amended],
{ encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
Comment on lines +95 to +121
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Feb 13, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether any call sites already ensure a clean index before addTrailers.
echo "=== Calls to addTrailers() ==="
rg -n -A5 "addTrailers\(" src tests

echo ""
echo "=== Search for staged changes/index checks ==="
rg -n "diff --cached|--cached --quiet|staged changes|staged" src tests

Repository: TonyCasey/git-mem

Length of output: 8963


🏁 Script executed:

#!/bin/bash
# Examine the MemoryService context around the addTrailers call
sed -n '25,55p' src/application/services/MemoryService.ts

Repository: TonyCasey/git-mem

Length of output: 1310


Add a guard against staged changes before amending the commit.

When addTrailers() calls git commit --amend, the index is used. If staged files exist, they will be silently included in the amended commit alongside trailer updates. This can unexpectedly commit changes a user did not intend to include.

Add a check for staged changes before amending:

Suggested guard before amend
     const newTrailers = trailers.filter(t => !existingKeys.has(`${t.key}:${t.value}`));
     if (newTrailers.length === 0) return;

+    const staged = execFileSync(
+      'git',
+      ['diff', '--cached', '--name-only'],
+      { encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe'] }
+    ).trim();
+    if (staged) {
+      throw new Error('Refusing to amend commit while staged changes are present');
+    }
+
     // Get current commit message
     const currentMessage = execFileSync(
       'git',
🤖 Prompt for AI Agents
In `@src/infrastructure/services/TrailerService.ts` around lines 95 - 119, The
addTrailers method can inadvertently include staged changes when running git
commit --amend; before building/amending the message (after reading existing
trailers and before currentMessage/amended/execFileSync commit), run a check for
staged files using git diff --cached --name-only (or git diff --staged --quiet)
via execFileSync and if any staged paths are returned, abort (throw or return)
and surface a clear error/exception to the caller; update the code around
addTrailers to perform this staged-change guard and ensure any early exit is
consistent with how readTrailers, buildCommitMessage and the commit execFileSync
are used.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — addTrailers() now checks for staged changes via git diff --cached --name-only before amending and throws an error if any are present. See 97190a7.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the staged changes guard is present in addTrailers method
echo "=== Current addTrailers implementation with staged check ==="
sed -n '/addTrailers(trailers: readonly ITrailer/,/^  }/p' src/infrastructure/services/TrailerService.ts

Length of output: 1190


🏁 Script executed:

#!/bin/bash
# Check recent commits to see if 97190a7 exists and what it contains
echo "=== Recent commits on this branch ==="
git log --oneline -10

echo ""
echo "=== Check if commit 97190a7 exists ==="
git log --oneline | grep "97190a7" || echo "Commit 97190a7 not found in current branch"

echo ""
echo "=== Search for any staged changes checks in the codebase ==="
rg -n "diff.*--cached|--cached.*--quiet|staged changes" src/

echo ""
echo "=== Full context around addTrailers with more lines ==="
sed -n '94,130p' src/infrastructure/services/TrailerService.ts

Length of output: 3394


@TonyCasey I don't see commit 97190a7 in the current branch—the latest commit is 4e83310. Additionally, the addTrailers() method in the current code doesn't include a staged changes guard (it goes directly from filtering newTrailers to fetching currentMessage).

Could you verify:

  • Has the fix been pushed to the remote branch?
  • Is the commit SHA correct, or might it be on a different branch?

I'd be happy to confirm once the staged changes check is visible in the code! 🐰


🧬 Code Graph Analysis Results

[filename: src/domain/entities/ITrailer.ts] (lines 16-21)

export interface ITrailer {
  /** Trailer key (e.g. 'AI-Decision', 'AI-Gotcha'). */
  readonly key: string;
  /** Trailer value. */
  readonly value: string;
}

[filename: src/domain/entities/ITrailer.ts] (lines 40-40)

export const AI_TRAILER_PREFIX = 'AI-';

[filename: src/domain/interfaces/ITrailerService.ts] (lines 38-78)

export interface ITrailerService {
  /**
   * Read all AI-* trailers from a specific commit.
   * `@param` sha - Commit SHA (default: HEAD).
   * `@param` cwd - Working directory.
   * `@returns` Array of parsed trailers.
   */
  readTrailers(sha?: string, cwd?: string): ITrailer[];

  /**
   * Format trailers for appending to a commit message.
   * `@param` trailers - Trailers to format.
   * `@returns` Formatted trailer block string.
   */
  formatTrailers(trailers: readonly ITrailer[]): string;

  /**
   * Search for a specific trailer key across commit history.
   * `@param` key - Trailer key to search for (e.g. 'AI-Decision').
   * `@param` options - Query options.
   * `@returns` Array of commits with matching trailers.
   */
  queryTrailers(key: string, options?: ITrailerQueryOptions): ICommitTrailers[];

  /**
   * Amend HEAD commit to append AI-* trailers to the commit message.
   * Preserves existing trailers and skips duplicates.
   * `@param` trailers - Trailers to add.
   * `@param` cwd - Working directory.
   */
  addTrailers(trailers: readonly ITrailer[], cwd?: string): void;

  /**
   * Build a commit message with trailers appended.
   * Pure string operation — no git commands.
   * `@param` message - Original commit message.
   * `@param` trailers - Trailers to append.
   * `@returns` Complete commit message with trailer block.
   */
  buildCommitMessage(message: string, trailers: readonly ITrailer[]): string;
}

[filename: src/domain/interfaces/ITrailer.ts] (lines 14-21)

export interface ITrailerQueryOptions {
  /** Only show trailers after this date (YYYY-MM-DD). */
  readonly since?: string;
  /** Maximum number of commits to search. */
  readonly maxCount?: number;
  /** Working directory. */
  readonly cwd?: string;
}

[filename: src/domain/interfaces/ITrailerService.ts] (lines 26-31)

export interface ICommitTrailers {
  /** Commit SHA. */
  readonly sha: string;
  /** Parsed AI-* trailers from this commit. */
  readonly trailers: readonly ITrailer[];
}

🧠 Learnings used
Learnt from: CR
Repo: TonyCasey/git-mem PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-13T09:51:03.954Z
Learning: Applies to **/*.{ts,tsx} : Run type-checking with `npm run type-check` (tsc --noEmit) before committing

Learnt from: CR
Repo: TonyCasey/git-mem PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-13T09:51:03.954Z
Learning: Applies to src/application/services/ExtractService.ts : Commit triage should use weighted scoring based on conventional prefixes, decision keywords, diff size, and PR merges

Learnt from: TonyCasey
Repo: TonyCasey/lisa PR: 211
File: src/lib/skills/shared/services/MemoryService.ts:393-418
Timestamp: 2026-02-06T17:09:35.417Z
Learning: In src/lib/skills/shared/services/MemoryService.ts consolidate method with git-mem backend, returning a placeholder retainedUuid ('new-merged-fact') for the merge action is acceptable because git-mem's remember() API returns Promise<boolean> (success/failure) rather than the created fact's ID. The delete + remember pattern is the standard way to update memories in git-mem.

Learnt from: CR
Repo: TonyCasey/git-mem PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-13T09:51:03.954Z
Learning: Applies to src/infrastructure/**/*.ts : Infrastructure layer should implement domain interfaces: GitClient (wraps git CLI), NotesService (reads/writes refs/notes/mem), TrailerService (queries commit trailers), MemoryRepository (persists IMemoryEntity[] as JSON in git notes), HeuristicPatterns (provides regex-based extraction rules)

}

buildCommitMessage(message: string, trailers: readonly ITrailer[]): string {
if (trailers.length === 0) return message;

const trailerBlock = this.formatTrailers(trailers);
const trimmed = message.trimEnd();

// Detect existing trailer block: lines after the last blank line
// must all match "Key: Value" format (git trailer convention).
if (this.hasTrailerBlock(trimmed)) {
return `${trimmed}\n${trailerBlock}\n`;
}

return `${trimmed}\n\n${trailerBlock}\n`;
}

private hasTrailerBlock(message: string): boolean {
const lastBlankIdx = message.lastIndexOf('\n\n');
if (lastBlankIdx === -1) return false;

const afterBlank = message.slice(lastBlankIdx + 2).trim();
if (!afterBlank) return false;

const lines = afterBlank.split('\n').filter(l => l.trim().length > 0);
return lines.length > 0 && lines.every(l => /^[\w-]+:\s/.test(l));
}

private parseTrailerBlock(block: string): ITrailer[] {
const trailers: ITrailer[] = [];
const lines = block.split('\n');
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/tools/remember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ 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)'),
trailers: z.boolean().optional().describe('Write AI-* trailers to commit message (default: true)'),
},
async (args) => {
const container = createContainer({ scope: 'mcp:remember' });
Expand All @@ -35,6 +36,7 @@ export function registerRememberTool(server: McpServer): void {
confidence: (args.confidence || 'high') as ConfidenceLevel,
lifecycle: (args.lifecycle || 'project') as MemoryLifecycle,
tags: args.tags,
trailers: args.trailers,
});

return {
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/application/services/MemoryService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,25 @@ import { tmpdir } from 'os';
import { MemoryService } from '../../../../src/application/services/MemoryService';
import { MemoryRepository } from '../../../../src/infrastructure/repositories/MemoryRepository';
import { NotesService } from '../../../../src/infrastructure/services/NotesService';
import { TrailerService } from '../../../../src/infrastructure/services/TrailerService';

function git(args: string[], cwd: string): string {
return execFileSync('git', args, { encoding: 'utf8', cwd }).trim();
}

describe('MemoryService', () => {
let service: MemoryService;
let serviceWithTrailers: MemoryService;
let trailerService: TrailerService;
let repoDir: string;
let commitSha: string;

before(() => {
const notesService = new NotesService();
const memoryRepo = new MemoryRepository(notesService);
trailerService = new TrailerService();
service = new MemoryService(memoryRepo);
serviceWithTrailers = new MemoryService(memoryRepo, undefined, trailerService);

repoDir = mkdtempSync(join(tmpdir(), 'git-mem-memsvc-test-'));
git(['init'], repoDir);
Expand Down Expand Up @@ -53,6 +58,100 @@ describe('MemoryService', () => {
});
});

describe('remember with trailers', () => {
it('should write AI-* trailers to the commit', () => {
// Create a fresh commit for this test
writeFileSync(join(repoDir, 'trailer-test.txt'), 'trailer');
git(['add', '.'], repoDir);
git(['commit', '-m', 'feat: trailer test'], repoDir);

serviceWithTrailers.remember('Use Redis for caching', {
cwd: repoDir,
type: 'decision',
confidence: 'high',
tags: 'cache, infra',
});

const trailers = trailerService.readTrailers('HEAD', repoDir);
assert.ok(trailers.find(t => t.key === 'AI-Decision' && t.value === 'Use Redis for caching'));
assert.ok(trailers.find(t => t.key === 'AI-Confidence' && t.value === 'high'));
assert.ok(trailers.find(t => t.key === 'AI-Tags' && t.value === 'cache, infra'));
assert.ok(trailers.find(t => t.key === 'AI-Memory-Id'));
});

it('should map memory type to correct trailer key', () => {
writeFileSync(join(repoDir, 'gotcha-test.txt'), 'gotcha');
git(['add', '.'], repoDir);
git(['commit', '-m', 'fix: gotcha test'], repoDir);

serviceWithTrailers.remember('Watch out for null tokens', {
cwd: repoDir,
type: 'gotcha',
});

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

it('should skip trailers when trailers: false', () => {
writeFileSync(join(repoDir, 'no-trailer.txt'), 'skip');
git(['add', '.'], repoDir);
git(['commit', '-m', 'feat: no trailer test'], repoDir);
const shaBefore = git(['rev-parse', 'HEAD'], repoDir);

serviceWithTrailers.remember('No trailer for this', {
cwd: repoDir,
type: 'fact',
trailers: false,
});

// SHA unchanged means commit was not amended
const shaAfter = git(['rev-parse', 'HEAD'], repoDir);
assert.equal(shaBefore, shaAfter);
});
Comment on lines +97 to +112
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The test verifies the commit SHA is unchanged when trailers are disabled, but it doesn't verify that the memory was still successfully created in git notes. Consider adding an assertion to verify the memory exists (e.g., by checking that the returned memory has a valid id, or by attempting to retrieve it via recall/get).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — the skip trailers when trailers: false test now asserts that the memory is created with a valid id and correct content even when trailers are disabled. See 97190a7.


it('should not fail when trailerService is not injected', () => {
writeFileSync(join(repoDir, 'no-svc.txt'), 'plain');
git(['add', '.'], repoDir);
git(['commit', '-m', 'feat: no service'], repoDir);

// service (without trailerService) should not throw
const memory = service.remember('Plain memory', { cwd: repoDir });
assert.ok(memory.id);
});

it('should include AI-Memory-Id linking trailer to notes entry', () => {
writeFileSync(join(repoDir, 'link-test.txt'), 'link');
git(['add', '.'], repoDir);
git(['commit', '-m', 'feat: link test'], repoDir);

const memory = serviceWithTrailers.remember('Linked memory', {
cwd: repoDir,
type: 'convention',
});

const trailers = trailerService.readTrailers('HEAD', repoDir);
const memoryId = trailers.find(t => t.key === 'AI-Memory-Id');
assert.ok(memoryId);
assert.equal(memoryId.value, memory.id);
});

it('should not write AI-Tags trailer when tags are empty', () => {
writeFileSync(join(repoDir, 'no-tags.txt'), 'notags');
git(['add', '.'], repoDir);
git(['commit', '-m', 'feat: no tags test'], repoDir);

serviceWithTrailers.remember('No tags here', {
cwd: repoDir,
type: 'fact',
});

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

describe('recall', () => {
it('should find memories by query', () => {
const result = service.recall('JWT', { cwd: repoDir });
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/infrastructure/di/container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('createContainer', () => {
assert.ok(cradle.notesService);
assert.ok(cradle.gitClient);
assert.ok(cradle.memoryRepository);
assert.ok(cradle.trailerService);
assert.ok(cradle.eventBus);
assert.ok(cradle.triageService);
assert.ok(cradle.memoryService);
Expand Down Expand Up @@ -162,6 +163,24 @@ describe('createContainer', () => {
});
});

describe('trailerService', () => {
it('should resolve with expected interface', () => {
const container = createContainer();
const { trailerService } = container.cradle;

assert.equal(typeof trailerService.readTrailers, 'function');
assert.equal(typeof trailerService.formatTrailers, 'function');
assert.equal(typeof trailerService.queryTrailers, 'function');
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The trailerService interface validation test should include the newly added methods addTrailers and buildCommitMessage to ensure they are properly available through the DI container.

Suggested change
assert.equal(typeof trailerService.queryTrailers, 'function');
assert.equal(typeof trailerService.queryTrailers, 'function');
assert.equal(typeof trailerService.addTrailers, 'function');
assert.equal(typeof trailerService.buildCommitMessage, 'function');

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Already addressed in commit 8fcd93f (PR #35). Container test now asserts addTrailers and buildCommitMessage.

assert.equal(typeof trailerService.addTrailers, 'function');
assert.equal(typeof trailerService.buildCommitMessage, 'function');
});

it('should return singleton within container scope', () => {
const container = createContainer();
assert.equal(container.cradle.trailerService, container.cradle.trailerService);
});
});

describe('hook services', () => {
it('should resolve hook services with expected interfaces', () => {
const container = createContainer();
Expand Down
Loading