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
53 changes: 45 additions & 8 deletions packages/cli/src/hooks/maxsim-capture-learnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface StopInput {
}

export const MEMORY_MAX_LINES = 180;
const PATTERN_MAX_LENGTH = 200;

/** Formats today's date as YYYY-MM-DD. */
export function today(): string {
Expand Down Expand Up @@ -79,6 +80,43 @@ export function pruneMemory(memoryPath: string): void {
}
}

const PATTERN_PREFIXES = [
'pattern:', 'learning:', 'key finding:', 'insight:',
'what worked:', 'what failed:', 'takeaway:', 'note:',
'discovered:', 'found that', 'issue was', 'fixed by',
];

/** Extract meaningful patterns from the assistant's last message. */
export function extractPattern(message: string): string | undefined {
const trimmed = message.trim();
if (!trimmed) return undefined;

const lines = message.split('\n');

for (const line of lines) {
const stripped = line.trim();
const lower = stripped.toLowerCase();
for (const prefix of PATTERN_PREFIXES) {
if (lower.startsWith(prefix) || lower.startsWith(`- ${prefix}`)) {
return stripped.slice(0, PATTERN_MAX_LENGTH);
}
}
}

const bullets = lines.filter(l => /^\s*[-*]\s+/.test(l)).map(l => l.trim());
if (bullets.length > 0 && bullets.length <= 5) {
return bullets.join('; ').slice(0, PATTERN_MAX_LENGTH);
}

// Last sentence is more likely a summary than the first
const sentences = message.match(/[^.!?]+[.!?]+/g);
if (sentences && sentences.length > 0) {
return sentences[sentences.length - 1].trim().slice(0, PATTERN_MAX_LENGTH);
}

return trimmed.slice(-PATTERN_MAX_LENGTH);
}

/** Appends a learning entry to the MEMORY.md file, creating dirs as needed. */
export function appendLearning(
memoryPath: string,
Expand All @@ -90,18 +128,17 @@ export function appendLearning(
const dir = path.dirname(memoryPath);
fs.mkdirSync(dir, { recursive: true });

const sessionLabel = sessionId ? ` (${sessionId.slice(0, 8)})` : '';
const reasonLabel = stopReason ? ` [${stopReason}]` : '';
const sessionLabel = sessionId ? sessionId.slice(0, 8) : 'unknown';
const reasonLabel = stopReason ?? 'unknown';

const commitLines =
const commitLine =
commits.length > 0
? commits.map((c) => `- commit: ${c}`).join('\n')
? `- commits: ${commits.join(', ')}`
: '- no commits recorded this session';

const parts = [
`## Session ${today()}${sessionLabel}${reasonLabel}`,
`- ${commits.length} commit(s) made this session`,
commitLines,
`### ${today()} | ${sessionLabel} | ${reasonLabel} | ${commits.length} commits`,
commitLine,
Comment on lines 139 to +141
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The new header always uses "${commits.length} commits", which yields grammatically incorrect output for a single commit ("1 commits"). Consider pluralizing ("1 commit" vs "N commits") and aligning the commit line label similarly ("- commit:" vs "- commits:") so MEMORY.md entries read correctly.

Copilot uses AI. Check for mistakes.
];

if (patternSummary) {
Expand Down Expand Up @@ -140,7 +177,7 @@ readStdinJson<StopInput>((input) => {
: recentCommits(projectDir, 5);

const trimmedMessage = input.last_assistant_message?.trim();
const patternSummary = trimmedMessage ? trimmedMessage.slice(0, 200) : undefined;
const patternSummary = trimmedMessage ? extractPattern(trimmedMessage) : undefined;

appendLearning(memoryPath, input.session_id, commits, input.stop_reason, patternSummary);
pruneMemory(memoryPath);
Expand Down
81 changes: 66 additions & 15 deletions packages/cli/tests/unit/capture-learnings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
appendLearning,
pruneMemory,
sessionCommits,
extractPattern,
MEMORY_MAX_LINES,
today,
} from '../../src/hooks/maxsim-capture-learnings.js';
Expand Down Expand Up @@ -85,25 +86,25 @@ describe('appendLearning', () => {
expect(fs.existsSync(path.dirname(memPath))).toBe(true);
});

it('appends a session header with the correct date', () => {
it('appends a session header with the correct date in pipe-delimited format', () => {
const memPath = path.join(tmpDir, 'MEMORY.md');
appendLearning(memPath, undefined, [], undefined, undefined);
appendLearning(memPath, 'sess1234abcd', [], 'user_exit', undefined);
const content = fs.readFileSync(memPath, 'utf8');
expect(content).toContain(`## Session ${today()}`);
expect(content).toContain(`### ${today()} | sess1234 | user_exit | 0 commits`);
});

it('includes session ID (first 8 chars) in the header', () => {
const memPath = path.join(tmpDir, 'MEMORY.md');
appendLearning(memPath, 'abc12345xyz', [], undefined, undefined);
const content = fs.readFileSync(memPath, 'utf8');
expect(content).toContain('(abc12345)');
expect(content).toContain('| abc12345 |');
});

it('includes stop_reason in square brackets in the header', () => {
it('includes stop_reason in the pipe-delimited header', () => {
const memPath = path.join(tmpDir, 'MEMORY.md');
appendLearning(memPath, undefined, [], 'user_exit', undefined);
const content = fs.readFileSync(memPath, 'utf8');
expect(content).toContain('[user_exit]');
expect(content).toContain('| user_exit |');
});

it('writes "no commits recorded this session" when commits array is empty', () => {
Expand All @@ -113,19 +114,18 @@ describe('appendLearning', () => {
expect(content).toContain('- no commits recorded this session');
});

it('writes each commit on its own line with "- commit:" prefix', () => {
it('writes all commits on a single "- commits:" line', () => {
const memPath = path.join(tmpDir, 'MEMORY.md');
appendLearning(memPath, undefined, ['abc1234 fix bug', 'def5678 add feature'], undefined, undefined);
const content = fs.readFileSync(memPath, 'utf8');
expect(content).toContain('- commit: abc1234 fix bug');
expect(content).toContain('- commit: def5678 add feature');
expect(content).toContain('- commits: abc1234 fix bug, def5678 add feature');
});

it('records the commit count on a summary line', () => {
it('records the commit count in the header', () => {
const memPath = path.join(tmpDir, 'MEMORY.md');
appendLearning(memPath, undefined, ['a', 'b', 'c'], undefined, undefined);
const content = fs.readFileSync(memPath, 'utf8');
expect(content).toContain('- 3 commit(s) made this session');
expect(content).toContain('| 3 commits');
});

it('includes pattern summary when provided', () => {
Expand All @@ -149,15 +149,15 @@ describe('appendLearning', () => {
appendLearning(memPath, 'session2', ['commit-b'], undefined, undefined);
const content = fs.readFileSync(memPath, 'utf8');
expect(content).toContain('# Existing content');
expect(content).toContain('(session1)');
expect(content).toContain('(session2)');
expect(content).toContain('| session1 |');
expect(content).toContain('| session2 |');
});

it('omits stop_reason bracket when stop_reason is undefined', () => {
it('uses "unknown" for stop_reason when undefined', () => {
const memPath = path.join(tmpDir, 'MEMORY.md');
appendLearning(memPath, undefined, [], undefined, undefined);
const content = fs.readFileSync(memPath, 'utf8');
expect(content).not.toMatch(/\[.*\]/);
expect(content).toContain('| unknown |');
});
});

Expand Down Expand Up @@ -264,3 +264,54 @@ describe('sessionCommits', () => {
expect(result).toEqual(['fallback-commit']);
});
});

// ---------------------------------------------------------------------------
// extractPattern
// ---------------------------------------------------------------------------

describe('extractPattern', () => {
it('returns undefined for empty/whitespace input', () => {
expect(extractPattern('')).toBeUndefined();
expect(extractPattern(' ')).toBeUndefined();
expect(extractPattern('\n\n')).toBeUndefined();
});

it('finds a line starting with "Pattern:" prefix', () => {
const msg = 'Some preamble.\nPattern: always run tests before committing.\nMore text.';
expect(extractPattern(msg)).toBe('Pattern: always run tests before committing.');
});

it('finds a line starting with "Learning:" prefix', () => {
const msg = 'Debugging session complete.\nLearning: the config file must be UTF-8.';
expect(extractPattern(msg)).toBe('Learning: the config file must be UTF-8.');
});

it('finds a bullet-prefixed learning line', () => {
const msg = 'Summary:\n- Found that the API rate limits at 100 req/s.';
expect(extractPattern(msg)).toBe('- Found that the API rate limits at 100 req/s.');
});

it('extracts bullet points when there are 1-5 of them', () => {
const msg = 'Results:\n- Added auth module\n- Fixed login bug\n- Updated tests';
expect(extractPattern(msg)).toBe('- Added auth module; - Fixed login bug; - Updated tests');
});

it('falls back to the last sentence when no prefix or bullets match', () => {
const msg = 'I refactored several files. The build is now green. All tests pass.';
expect(extractPattern(msg)).toBe('All tests pass.');
});

it('caps result at 200 characters', () => {
const longLine = `Pattern: ${'x'.repeat(300)}`;
const result = extractPattern(longLine);
expect(result).toBeDefined();
expect(result?.length).toBe(200);
});

it('uses last 200 chars as final fallback when no sentences found', () => {
const msg = `no punctuation here just a long stream of words ${'word '.repeat(50)}`;
const result = extractPattern(msg);
expect(result).toBeDefined();
expect(result?.length).toBeLessThanOrEqual(200);
});
});
Loading