Skip to content

Commit 86e0d71

Browse files
authored
Merge pull request #185 from maystudios/worktree-agent-add7d920
fix(hooks): improve pattern extraction and structured MEMORY.md entries
2 parents 99f622e + 92ddfca commit 86e0d71

File tree

2 files changed

+111
-23
lines changed

2 files changed

+111
-23
lines changed

packages/cli/src/hooks/maxsim-capture-learnings.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface StopInput {
2828
}
2929

3030
export const MEMORY_MAX_LINES = 180;
31+
const PATTERN_MAX_LENGTH = 200;
3132

3233
/** Formats today's date as YYYY-MM-DD. */
3334
export function today(): string {
@@ -79,6 +80,43 @@ export function pruneMemory(memoryPath: string): void {
7980
}
8081
}
8182

83+
const PATTERN_PREFIXES = [
84+
'pattern:', 'learning:', 'key finding:', 'insight:',
85+
'what worked:', 'what failed:', 'takeaway:', 'note:',
86+
'discovered:', 'found that', 'issue was', 'fixed by',
87+
];
88+
89+
/** Extract meaningful patterns from the assistant's last message. */
90+
export function extractPattern(message: string): string | undefined {
91+
const trimmed = message.trim();
92+
if (!trimmed) return undefined;
93+
94+
const lines = message.split('\n');
95+
96+
for (const line of lines) {
97+
const stripped = line.trim();
98+
const lower = stripped.toLowerCase();
99+
for (const prefix of PATTERN_PREFIXES) {
100+
if (lower.startsWith(prefix) || lower.startsWith(`- ${prefix}`)) {
101+
return stripped.slice(0, PATTERN_MAX_LENGTH);
102+
}
103+
}
104+
}
105+
106+
const bullets = lines.filter(l => /^\s*[-*]\s+/.test(l)).map(l => l.trim());
107+
if (bullets.length > 0 && bullets.length <= 5) {
108+
return bullets.join('; ').slice(0, PATTERN_MAX_LENGTH);
109+
}
110+
111+
// Last sentence is more likely a summary than the first
112+
const sentences = message.match(/[^.!?]+[.!?]+/g);
113+
if (sentences && sentences.length > 0) {
114+
return sentences[sentences.length - 1].trim().slice(0, PATTERN_MAX_LENGTH);
115+
}
116+
117+
return trimmed.slice(-PATTERN_MAX_LENGTH);
118+
}
119+
82120
/** Appends a learning entry to the MEMORY.md file, creating dirs as needed. */
83121
export function appendLearning(
84122
memoryPath: string,
@@ -90,18 +128,17 @@ export function appendLearning(
90128
const dir = path.dirname(memoryPath);
91129
fs.mkdirSync(dir, { recursive: true });
92130

93-
const sessionLabel = sessionId ? ` (${sessionId.slice(0, 8)})` : '';
94-
const reasonLabel = stopReason ? ` [${stopReason}]` : '';
131+
const sessionLabel = sessionId ? sessionId.slice(0, 8) : 'unknown';
132+
const reasonLabel = stopReason ?? 'unknown';
95133

96-
const commitLines =
134+
const commitLine =
97135
commits.length > 0
98-
? commits.map((c) => `- commit: ${c}`).join('\n')
136+
? `- commits: ${commits.join(', ')}`
99137
: '- no commits recorded this session';
100138

101139
const parts = [
102-
`## Session ${today()}${sessionLabel}${reasonLabel}`,
103-
`- ${commits.length} commit(s) made this session`,
104-
commitLines,
140+
`### ${today()} | ${sessionLabel} | ${reasonLabel} | ${commits.length} commits`,
141+
commitLine,
105142
];
106143

107144
if (patternSummary) {
@@ -140,7 +177,7 @@ readStdinJson<StopInput>((input) => {
140177
: recentCommits(projectDir, 5);
141178

142179
const trimmedMessage = input.last_assistant_message?.trim();
143-
const patternSummary = trimmedMessage ? trimmedMessage.slice(0, 200) : undefined;
180+
const patternSummary = trimmedMessage ? extractPattern(trimmedMessage) : undefined;
144181

145182
appendLearning(memoryPath, input.session_id, commits, input.stop_reason, patternSummary);
146183
pruneMemory(memoryPath);

packages/cli/tests/unit/capture-learnings.test.ts

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
appendLearning,
2929
pruneMemory,
3030
sessionCommits,
31+
extractPattern,
3132
MEMORY_MAX_LINES,
3233
today,
3334
} from '../../src/hooks/maxsim-capture-learnings.js';
@@ -85,25 +86,25 @@ describe('appendLearning', () => {
8586
expect(fs.existsSync(path.dirname(memPath))).toBe(true);
8687
});
8788

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

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

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

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

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

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

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

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

@@ -264,3 +264,54 @@ describe('sessionCommits', () => {
264264
expect(result).toEqual(['fallback-commit']);
265265
});
266266
});
267+
268+
// ---------------------------------------------------------------------------
269+
// extractPattern
270+
// ---------------------------------------------------------------------------
271+
272+
describe('extractPattern', () => {
273+
it('returns undefined for empty/whitespace input', () => {
274+
expect(extractPattern('')).toBeUndefined();
275+
expect(extractPattern(' ')).toBeUndefined();
276+
expect(extractPattern('\n\n')).toBeUndefined();
277+
});
278+
279+
it('finds a line starting with "Pattern:" prefix', () => {
280+
const msg = 'Some preamble.\nPattern: always run tests before committing.\nMore text.';
281+
expect(extractPattern(msg)).toBe('Pattern: always run tests before committing.');
282+
});
283+
284+
it('finds a line starting with "Learning:" prefix', () => {
285+
const msg = 'Debugging session complete.\nLearning: the config file must be UTF-8.';
286+
expect(extractPattern(msg)).toBe('Learning: the config file must be UTF-8.');
287+
});
288+
289+
it('finds a bullet-prefixed learning line', () => {
290+
const msg = 'Summary:\n- Found that the API rate limits at 100 req/s.';
291+
expect(extractPattern(msg)).toBe('- Found that the API rate limits at 100 req/s.');
292+
});
293+
294+
it('extracts bullet points when there are 1-5 of them', () => {
295+
const msg = 'Results:\n- Added auth module\n- Fixed login bug\n- Updated tests';
296+
expect(extractPattern(msg)).toBe('- Added auth module; - Fixed login bug; - Updated tests');
297+
});
298+
299+
it('falls back to the last sentence when no prefix or bullets match', () => {
300+
const msg = 'I refactored several files. The build is now green. All tests pass.';
301+
expect(extractPattern(msg)).toBe('All tests pass.');
302+
});
303+
304+
it('caps result at 200 characters', () => {
305+
const longLine = `Pattern: ${'x'.repeat(300)}`;
306+
const result = extractPattern(longLine);
307+
expect(result).toBeDefined();
308+
expect(result?.length).toBe(200);
309+
});
310+
311+
it('uses last 200 chars as final fallback when no sentences found', () => {
312+
const msg = `no punctuation here just a long stream of words ${'word '.repeat(50)}`;
313+
const result = extractPattern(msg);
314+
expect(result).toBeDefined();
315+
expect(result?.length).toBeLessThanOrEqual(200);
316+
});
317+
});

0 commit comments

Comments
 (0)