From 44c84f343aa6e7f1ea117e5f764bcd022fc89594 Mon Sep 17 00:00:00 2001 From: Sven Date: Wed, 25 Mar 2026 18:27:14 +0100 Subject: [PATCH 1/2] fix(hooks): improve pattern extraction and structured MEMORY.md entries Add extractPattern function with prefix matching, bullet extraction, and sentence-based fallback. Change MEMORY.md entry format to pipe-delimited headers (### date | session | reason | commits) with single-line commit lists. Add comprehensive tests for the new extractPattern function. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/hooks/maxsim-capture-learnings.ts | 53 ++++++++++-- .../cli/tests/unit/capture-learnings.test.ts | 81 +++++++++++++++---- 2 files changed, 111 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/hooks/maxsim-capture-learnings.ts b/packages/cli/src/hooks/maxsim-capture-learnings.ts index e8064b85..4bc3a178 100644 --- a/packages/cli/src/hooks/maxsim-capture-learnings.ts +++ b/packages/cli/src/hooks/maxsim-capture-learnings.ts @@ -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 { @@ -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, @@ -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, ]; if (patternSummary) { @@ -140,7 +177,7 @@ readStdinJson((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); diff --git a/packages/cli/tests/unit/capture-learnings.test.ts b/packages/cli/tests/unit/capture-learnings.test.ts index 76b0e9a2..22e1ce4e 100644 --- a/packages/cli/tests/unit/capture-learnings.test.ts +++ b/packages/cli/tests/unit/capture-learnings.test.ts @@ -28,6 +28,7 @@ import { appendLearning, pruneMemory, sessionCommits, + extractPattern, MEMORY_MAX_LINES, today, } from '../../src/hooks/maxsim-capture-learnings.js'; @@ -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', () => { @@ -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', () => { @@ -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 |'); }); }); @@ -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); + }); +}); From 92ddfca83009059ed0a861925f2a309a16c06d98 Mon Sep 17 00:00:00 2001 From: Sven Date: Wed, 25 Mar 2026 18:28:09 +0100 Subject: [PATCH 2/2] style(tests): fix lint warnings in capture-learnings tests Use template literals instead of string concatenation and optional chaining instead of non-null assertions per biome lint rules. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/tests/unit/capture-learnings.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/tests/unit/capture-learnings.test.ts b/packages/cli/tests/unit/capture-learnings.test.ts index 22e1ce4e..773d4098 100644 --- a/packages/cli/tests/unit/capture-learnings.test.ts +++ b/packages/cli/tests/unit/capture-learnings.test.ts @@ -302,16 +302,16 @@ describe('extractPattern', () => { }); it('caps result at 200 characters', () => { - const longLine = 'Pattern: ' + 'x'.repeat(300); + const longLine = `Pattern: ${'x'.repeat(300)}`; const result = extractPattern(longLine); expect(result).toBeDefined(); - expect(result!.length).toBe(200); + 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 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); + expect(result?.length).toBeLessThanOrEqual(200); }); });