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
180 changes: 175 additions & 5 deletions src/application/services/MemoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,204 @@
* 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, ICommitTrailers } from '../../domain/interfaces/ITrailerService';
import type { ITrailer } from '../../domain/entities/ITrailer';
import { AI_TRAILER_KEYS, AI_TRAILER_PREFIX } from '../../domain/entities/ITrailer';
import type { ConfidenceLevel } from '../../domain/types/IMemoryQuality';
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,
};

const TRAILER_KEY_TO_MEMORY_TYPE: Record<string, MemoryType> = {
[AI_TRAILER_KEYS.DECISION]: 'decision',
[AI_TRAILER_KEYS.GOTCHA]: 'gotcha',
[AI_TRAILER_KEYS.CONVENTION]: 'convention',
[AI_TRAILER_KEYS.FACT]: '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,
});
}
}

return memory;
}

private buildTrailers(memory: IMemoryEntity): ITrailer[] {
const trailers: ITrailer[] = [
{ key: MEMORY_TYPE_TO_TRAILER_KEY[memory.type], value: memory.content },
{ 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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

recall(query?: string, options?: IMemoryQueryOptions): IMemoryQueryResult {
const effectiveQuery = query ?? options?.query;
const result = this.memoryRepository.query({

// 1. Search notes (existing path)
const notesResult = this.memoryRepository.query({
...options,
query: effectiveQuery,
});
this.logger?.info('Memory recall', { query: effectiveQuery, count: result.memories.length, total: result.total });
return result;

// 2. Search trailers if service available
if (!this.trailerService) {
this.logger?.info('Memory recall', { query: effectiveQuery, count: notesResult.memories.length, total: notesResult.total });
return notesResult;
}

const trailerMemories = this.recallFromTrailers(effectiveQuery, options, notesResult.memories);

// 3. Merge results (notes first, then trailer-only)
const allMemories = [...notesResult.memories, ...trailerMemories];
const limit = options?.limit ?? allMemories.length;
const merged = allMemories.slice(0, limit);
Comment on lines +78 to +95
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 limit is applied after merging notes and trailer results, but the notes repository already applied the limit internally. This means if the notes result returns 10 items (the default limit) and trailer recall finds 5 more unique items, the merged result will still only return 10 items total, potentially excluding all trailer-only memories. The notesResult is already limited, so combining it with trailerMemories and re-applying the limit doesn't achieve true unified limiting. Consider passing a larger or no limit to the notes query, or re-sorting and limiting the combined results properly.

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.

Acknowledged. The total count is now correctly computed as notesResult.total + trailerMemories.length to preserve pagination semantics. For the limit itself, the current approach (notes first, then trailer-only, sliced to limit) is acceptable for v1 — a full merged-source pagination would add significant complexity. See 97190a7.


this.logger?.info('Memory recall', {
query: effectiveQuery,
notesCount: notesResult.memories.length,
trailerCount: trailerMemories.length,
total: allMemories.length,
});

return {
memories: merged,
total: allMemories.length,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private recallFromTrailers(
query: string | undefined,
options: IMemoryQueryOptions | undefined,
notesMemories: readonly IMemoryEntity[],
): IMemoryEntity[] {
if (!this.trailerService) return [];

try {
const trailerCommits = this.trailerService.queryTrailers(AI_TRAILER_PREFIX, {
cwd: options?.cwd,
since: options?.since,
});

// IDs already present in notes results (for deduplication)
const noteIds = new Set(notesMemories.map(m => m.id));

const results: IMemoryEntity[] = [];

for (const commit of trailerCommits) {
const entities = this.trailerCommitToEntities(commit);

for (const entity of entities) {
// Deduplicate: skip if AI-Memory-Id matches a notes entry
if (noteIds.has(entity.id)) continue;

// Apply query filter
if (query && !this.matchesQuery(entity, query)) continue;
Comment on lines +132 to +136
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.

Trailer recall applies the query filter AFTER deduplication by ID, but the notes query already applies its own query filter. This creates an asymmetry: if a memory exists in both notes and trailers but only the trailer version would match the query (hypothetically, if content differed), the trailer version gets filtered out due to deduplication, and nothing is returned. While in practice dual-written memories should have identical content, this logic assumes they always do. Consider clarifying this assumption with a comment, or ensure the deduplication doesn't affect query matching.

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.

Good observation. In practice, dual-written memories have identical content, so the notes version (preferred in dedup) will always match the query if the trailer version would. I've left a comment in the code documenting this assumption. The notes version wins by design since it has richer metadata.


// Apply type filter
if (options?.type && entity.type !== options.type) continue;

// Apply tag filter
if (options?.tag && !entity.tags.some(t => t.toLowerCase() === options.tag!.toLowerCase())) continue;
Comment on lines +141 to +142
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.

Tag filtering logic is inconsistent between notes and trailer recall. The MemoryRepository uses case-sensitive exact match with tags.includes(options.tag), while trailer recall uses case-insensitive comparison with t.toLowerCase() === options.tag.toLowerCase(). This means a tag filter for "Auth" will match "auth" in trailer-sourced memories but not in notes-sourced memories, leading to inconsistent behavior. Both should use the same comparison logic for consistency.

Suggested change
// Apply tag filter
if (options?.tag && !entity.tags.some(t => t.toLowerCase() === options.tag!.toLowerCase())) continue;
// Apply tag filter (case-sensitive, consistent with MemoryRepository)
if (options?.tag && !entity.tags.includes(options.tag)) continue;

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 — trailer tag filter is now case-sensitive using tags.includes(options.tag), consistent with MemoryRepository behavior. See 97190a7.


results.push(entity);
}
}

return results;
} catch (err) {
this.logger?.warn('Trailer recall failed', {
error: err instanceof Error ? err.message : String(err),
});
return [];
}
}

private trailerCommitToEntities(commit: ICommitTrailers): IMemoryEntity[] {
const entities: IMemoryEntity[] = [];

// Find all memory-type trailers on this commit
const typeTrailers = commit.trailers.filter(t => t.key in TRAILER_KEY_TO_MEMORY_TYPE);
if (typeTrailers.length === 0) return entities;

// Collect all AI-Memory-Ids (one per remember() call)
const memoryIds = commit.trailers
.filter(t => t.key === AI_TRAILER_KEYS.MEMORY_ID)
.map(t => t.value);

// Shared metadata from the commit's trailers
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.

Shared metadata (confidence, tags) from commit trailers is applied to all memory entities derived from that commit. This assumes all type trailers on a single commit share the same confidence and tags, which is correct for dual-write scenarios but may be incorrect for manually-added trailers. For example, if someone manually adds "AI-Decision: Use Redis\nAI-Gotcha: Watch for memory leaks\nAI-Confidence: high\nAI-Tags: cache", both memories will get confidence 'high' and tags ['cache'], even though the gotcha might have different intended metadata. Consider documenting this limitation or parsing per-memory metadata if needed.

Suggested change
// Shared metadata from the commit's trailers
// Shared metadata from the commit's trailers.
//
// NOTE: We intentionally treat AI-Confidence and AI-Tags as *commit-level* metadata.
// All memory entities derived from this commit will share the same confidence and tags.
//
// This matches the dual-write behavior where a single remember() call emits both
// rich JSON notes and lightweight commit trailers, and all trailers for a commit
// are expected to describe the same logical change.
//
// Limitation: If someone manually adds multiple memory-type trailers (e.g.
// AI-Decision and AI-Gotcha) with different intended confidences/tags in the same
// commit, we do *not* attempt to parse or align per-memory metadata here; instead
// we use a single shared AI-Confidence/AI-Tags value for every memory entity.
// If per-memory trailer metadata is ever required, this is the place to extend
// the parsing logic to support it.

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.

Added documentation comment explaining that AI-Confidence and AI-Tags are treated as commit-level metadata, matching the dual-write behavior. The limitation for manually-added multi-type trailers is now explicitly documented. See 97190a7.

const confidence = (commit.trailers.find(t => t.key === AI_TRAILER_KEYS.CONFIDENCE)?.value || 'high') as ConfidenceLevel;
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 confidence level from trailer may not be a valid ConfidenceLevel value. If a trailer has AI-Confidence set to an invalid value (e.g., 'super-high'), the type assertion will silently succeed but the value will violate the type contract. Consider validating that the confidence value is one of the valid ConfidenceLevel values before the type assertion, or add a fallback that defaults to 'high' if the value is not recognized.

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 — now uses isValidConfidence() from domain types to validate the trailer confidence value. Falls back to 'high' for invalid values. See 97190a7.

const tagsStr = commit.trailers.find(t => t.key === AI_TRAILER_KEYS.TAGS)?.value;
const tags: readonly string[] = tagsStr ? tagsStr.split(',').map(t => t.trim()) : [];

for (let i = 0; i < typeTrailers.length; i++) {
const typeTrailer = typeTrailers[i];
const type = TRAILER_KEY_TO_MEMORY_TYPE[typeTrailer.key];
if (!type) continue;

// Pair with AI-Memory-Id by position, or generate synthetic ID
const id = memoryIds[i] || `trailer:${commit.sha}:${type}`;
Comment on lines +179 to +180
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.

Synthetic ID generation may produce duplicate IDs if a commit has multiple manually-added trailers of the same type. For example, two AI-Decision trailers without AI-Memory-Ids would both get the same synthetic ID (trailer:SHA:decision), violating the uniqueness constraint on memory IDs. Consider appending the index or a hash of the content to ensure uniqueness, e.g., trailer:${commit.sha}:${type}:${i} or using the content itself as part of the ID.

Suggested change
// Pair with AI-Memory-Id by position, or generate synthetic ID
const id = memoryIds[i] || `trailer:${commit.sha}:${type}`;
// Pair with AI-Memory-Id by position, or generate synthetic ID (unique per commit/type/index)
const id = memoryIds[i] || `trailer:${commit.sha}:${type}:${i}`;

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 — synthetic IDs now include the index: trailer:${sha}:${type}:${i}. Also added a test verifying uniqueness for multiple same-type trailers. See 97190a7.


entities.push({
id,
content: typeTrailer.value,
type,
sha: commit.sha,
confidence,
source: 'commit-trailer',
lifecycle: 'project',
tags,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
Comment on lines +191 to +192
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.

Trailer-sourced memories use the current timestamp instead of the commit's author or committer date. This creates incorrect temporal data: a commit from 6 months ago will appear to have been created today when recalled from trailers. The createdAt and updatedAt fields should reflect the actual commit timestamp to maintain accurate chronology. Consider fetching the commit date from git (via git log --format=%aI or %cI) and using it here instead of new Date().toISOString().

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.

Acknowledged — added a documentation comment noting this as a known limitation. Using the commit's author date would require an extra git log call per commit in the trailer recall path, which we're deferring for now. The commit SHA is available for date lookup if needed.

});
}

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

private matchesQuery(entity: IMemoryEntity, query: string): boolean {
const lower = query.toLowerCase();
return entity.content.toLowerCase().includes(lower) ||
entity.tags.some(t => t.toLowerCase().includes(lower));
}

get(id: string, cwd?: string): IMemoryEntity | null {
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'] }
);
}

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
Loading
Loading