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
33 changes: 32 additions & 1 deletion src/application/services/MemoryContextLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@
* MemoryContextLoader
*
* Loads and filters memories for hook context injection.
* Delegates to IMemoryRepository for actual data access.
* Delegates to IMemoryRepository for general loads and
* IMemoryService for query-based searches (notes + trailers).
*/

import type { IMemoryContextLoader, IMemoryContextOptions, IMemoryContextResult } from '../../domain/interfaces/IMemoryContextLoader';
import type { IMemoryRepository } from '../../domain/interfaces/IMemoryRepository';
import type { IMemoryService } from '../interfaces/IMemoryService';
import type { ILogger } from '../../domain/interfaces/ILogger';

export class MemoryContextLoader implements IMemoryContextLoader {
constructor(
private readonly memoryRepository: IMemoryRepository,
private readonly logger?: ILogger,
private readonly memoryService?: IMemoryService,
) {}

load(options?: IMemoryContextOptions): IMemoryContextResult {
Expand Down Expand Up @@ -46,4 +49,32 @@ export class MemoryContextLoader implements IMemoryContextLoader {
filtered: result.memories.length,
};
}

loadWithQuery(query: string, limit?: number, cwd?: string): IMemoryContextResult {
// Use MemoryService.recall() to search both notes and trailers
if (!this.memoryService) {
this.logger?.warn('MemoryService not available for query search, falling back to repository');
return this.load({ limit, cwd });
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.

In the MemoryService-not-available fallback, loadWithQuery ignores the provided query and returns the same result as load(). This breaks the contract of a query-filtered load; consider falling back to memoryRepository.query({ query, limit, cwd }) (notes-only) so at least notes are filtered correctly, and keep the total/filtered semantics consistent.

Suggested change
return this.load({ limit, cwd });
// Get total count (no filters) for stats
const allResult = this.memoryRepository.query({ cwd });
const total = allResult.total;
// Fallback: query repository notes only
const result = this.memoryRepository.query({
query,
limit,
cwd,
});
this.logger?.debug('Memories loaded with query (repository fallback)', {
query,
total,
matched: result.memories.length,
limit,
});
return {
memories: result.memories,
total,
filtered: result.memories.length,
};

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +57
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.

loadWithQuery adds a new code path (MemoryService present vs fallback) and new stats semantics, but there are no unit tests covering it. Since this service already has unit tests for load(), add focused tests for loadWithQuery to lock in notes+trailers behavior and the fallback behavior.

Copilot uses AI. Check for mistakes.
}

// Get total count for stats
const allResult = this.memoryRepository.query({ cwd });
const total = allResult.total;
Comment on lines +60 to +62
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.

total is computed via memoryRepository.query({ cwd }), which only counts notes-backed memories. Since loadWithQuery returns results from notes + commit trailers, total will undercount when trailer-only memories exist. Consider deriving the unfiltered total from memoryService (e.g., a recall with no query) or otherwise counting both sources so IMemoryContextResult.total remains “total memories in store”.

Suggested change
// Get total count for stats
const allResult = this.memoryRepository.query({ cwd });
const total = allResult.total;
// Get total count for stats across notes and trailers
const totalResult = this.memoryService.recall('', { cwd });
const total = totalResult.total;

Copilot uses AI. Check for mistakes.

// Search with query
const result = this.memoryService.recall(query, { limit, cwd });

this.logger?.debug('Memories loaded with query', {
query,
total,
matched: result.memories.length,
limit,
});

return {
memories: result.memories,
total,
filtered: result.memories.length,
};
}
}
11 changes: 11 additions & 0 deletions src/domain/interfaces/IMemoryContextLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,15 @@ export interface IMemoryContextResult {
export interface IMemoryContextLoader {
/** Load memories with optional filters. */
load(options?: IMemoryContextOptions): IMemoryContextResult;

/**
* Load memories filtered by a search query.
* Searches both git notes and commit trailers for matching memories.
*
* @param query - Search keywords (e.g., "authentication, GIT-95, LoginHandler")
* @param limit - Maximum memories to return
* @param cwd - Working directory for git operations
* @returns Matching memories from both notes and trailers
*/
loadWithQuery(query: string, limit?: number, cwd?: string): IMemoryContextResult;
Comment on lines +33 to +43
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

Consider using an options object for API consistency.

The existing load() method uses an options object pattern, but loadWithQuery() uses positional parameters. For consistency and future extensibility (e.g., adding since, tags filters to query-based searches), consider using an options object:

interface ILoadWithQueryOptions {
  readonly query: string;
  readonly limit?: number;
  readonly cwd?: string;
}
loadWithQuery(options: ILoadWithQueryOptions): IMemoryContextResult;

This is a minor API design consideration—the current approach works and is acceptable if you prefer the simpler signature.

🤖 Prompt for AI Agents
In `@src/domain/interfaces/IMemoryContextLoader.ts` around lines 33 - 43, Change
the loadWithQuery method to accept an options object for consistency with
load(): add a new ILoadWithQueryOptions type (readonly query: string; readonly
limit?: number; readonly cwd?: string) and update the
IMemoryContextLoader.loadWithQuery signature to loadWithQuery(options:
ILoadWithQueryOptions): IMemoryContextResult; then update any callers of
IMemoryContextLoader.loadWithQuery to pass an options object instead of
positional args and export the new options interface if needed.

}
103 changes: 103 additions & 0 deletions tests/unit/application/services/MemoryContextLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,107 @@ describe('MemoryContextLoader', () => {

assert.equal(result.filtered, 1);
});

describe('loadWithQuery', () => {
function createMockMemoryService(memories: IMemoryEntity[]) {
return {
remember: () => memories[0]!,
recall: (query?: string, options?: { limit?: number; cwd?: string }) => {
let result = [...memories];
if (query) {
result = result.filter(m =>
m.content.toLowerCase().includes(query.toLowerCase())
);
}
if (options?.limit) {
result = result.slice(0, options.limit);
}
return { memories: result, total: memories.length };
},
get: () => null,
delete: () => false,
};
}

it('should search memories using MemoryService.recall', () => {
const memories = [
createMemory({ id: '1', content: 'JWT authentication decision' }),
createMemory({ id: '2', content: 'Database migration gotcha' }),
];
const repo = createMockRepository(memories);
const service = createMockMemoryService(memories);
const loader = new MemoryContextLoader(repo, undefined, service);

const result = loader.loadWithQuery('JWT');

assert.equal(result.filtered, 1);
assert.ok(result.memories[0]!.content.includes('JWT'));
});

it('should fall back to load() when MemoryService not available', () => {
const memories = [
createMemory({ id: '1', content: 'Test memory' }),
createMemory({ id: '2', content: 'Another memory' }),
];
const repo = createMockRepository(memories);
const logger = createMockLogger();
const loader = new MemoryContextLoader(repo, logger);

const result = loader.loadWithQuery('anything');

assert.equal(result.total, 2);
assert.equal(result.filtered, 2);
assert.ok(logger.warnCalled, 'expected warning about fallback');
});

it('should apply limit when using MemoryService', () => {
const memories = [
createMemory({ id: '1', content: 'Auth memory' }),
createMemory({ id: '2', content: 'Auth decision' }),
createMemory({ id: '3', content: 'Auth gotcha' }),
];
const repo = createMockRepository(memories);
const service = createMockMemoryService(memories);
const loader = new MemoryContextLoader(repo, undefined, service);

const result = loader.loadWithQuery('Auth', 2);

assert.equal(result.filtered, 2);
});

it('should pass cwd to MemoryService', () => {
let capturedCwd: string | undefined;
const repo = createMockRepository([]);
const service = {
remember: () => createMemory(),
recall: (_query?: string, options?: { cwd?: string }) => {
capturedCwd = options?.cwd;
return { memories: [], total: 0 };
},
get: () => null,
delete: () => false,
};
const loader = new MemoryContextLoader(repo, undefined, service);

loader.loadWithQuery('test', 10, '/custom/path');

assert.equal(capturedCwd, '/custom/path');
});

it('should return total from repository for accurate stats', () => {
const repoMemories = [
createMemory({ id: '1' }),
createMemory({ id: '2' }),
createMemory({ id: '3' }),
];
const repo = createMockRepository(repoMemories);
const service = createMockMemoryService([createMemory({ id: '1' })]);
const loader = new MemoryContextLoader(repo, undefined, service);

const result = loader.loadWithQuery('test');

assert.equal(result.total, 3, 'total should come from repository');
assert.equal(result.filtered, 1, 'filtered should come from query result');
});
});
});